aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CLAUDE.md161
-rw-r--r--IMPROVEMENT_PLAN.md166
-rw-r--r--src/main.rs35
3 files changed, 345 insertions, 17 deletions
diff --git a/CLAUDE.md b/CLAUDE.md
index 6b97b70..c940c92 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -8,3 +8,164 @@ do not add dependencies manually, instead, use the following tools:
8+ for logging, prefer the `tracing` crate with `tracing-subscriber` and fully qualify the log macros (ex: `tracing::info!`) 8+ for logging, prefer the `tracing` crate with `tracing-subscriber` and fully qualify the log macros (ex: `tracing::info!`)
9+ for cli use the `clap` crate. when implementing subcommands use an `enum` and separate structs for each subcommand's arguments 9+ for cli use the `clap` crate. when implementing subcommands use an `enum` and separate structs for each subcommand's arguments
10+ use the `anyhow` crate for error handling 10+ use the `anyhow` crate for error handling
11
12## testing guidelines
13for testing cli applications, use the `assert_cmd` crate for integration testing
14
15## assert_cmd crate reference
16
17### Overview
18`assert_cmd` is a Rust testing library designed to simplify integration testing of command-line applications. It provides easy command initialization, simplified configuration, and robust assertion mechanisms.
19
20### Key Features
21- Easy command initialization and execution
22- Cargo binary testing support
23- Flexible output validation with predicates
24- Environment variable and stdin management
25- Comprehensive assertion mechanisms
26
27### Basic Usage Patterns
28
29#### 1. Basic Command Testing
30```rust
31use assert_cmd::Command;
32
33// Run a Cargo binary
34let mut cmd = Command::cargo_bin("demon").unwrap();
35cmd.assert().success(); // Basic success assertion
36```
37
38#### 2. Command with Arguments
39```rust
40let mut cmd = Command::cargo_bin("demon").unwrap();
41cmd.args(&["run", "--id", "test", "sleep", "5"])
42 .assert()
43 .success();
44```
45
46#### 3. Output Validation
47```rust
48use predicates::prelude::*;
49
50let mut cmd = Command::cargo_bin("demon").unwrap();
51cmd.args(&["list"])
52 .assert()
53 .success()
54 .stdout(predicate::str::contains("ID"))
55 .stderr(predicate::str::is_empty());
56```
57
58#### 4. Testing Failures
59```rust
60let mut cmd = Command::cargo_bin("demon").unwrap();
61cmd.args(&["run", "--id", "test"]) // Missing command
62 .assert()
63 .failure()
64 .stderr(predicate::str::contains("Command cannot be empty"));
65```
66
67### Key Methods
68
69#### Command Configuration
70- `Command::cargo_bin("binary_name")`: Find and initialize a Cargo project binary
71- `arg(arg)` / `args(&[args])`: Add command arguments
72- `env(key, value)` / `envs(vars)`: Set environment variables
73- `current_dir(path)`: Set working directory
74- `write_stdin(input)`: Provide stdin input
75
76#### Assertions
77- `assert()`: Start assertion chain
78- `success()`: Check for successful execution (exit code 0)
79- `failure()`: Check for command failure (exit code != 0)
80- `code(expected)`: Validate specific exit code
81- `stdout(predicate)`: Validate stdout content
82- `stderr(predicate)`: Validate stderr content
83
84### Predicates for Output Validation
85```rust
86use predicates::prelude::*;
87
88// Exact match
89.stdout("exact output")
90
91// Contains text
92.stdout(predicate::str::contains("partial"))
93
94// Regex match
95.stdout(predicate::str::is_match(r"PID: \d+").unwrap())
96
97// Empty output
98.stderr(predicate::str::is_empty())
99
100// Multiple conditions
101.stdout(predicate::str::contains("SUCCESS").and(predicate::str::contains("ID")))
102```
103
104### Testing File I/O
105For testing CLI tools that create/modify files, combine with `tempfile` and `assert_fs`:
106
107```rust
108use tempfile::TempDir;
109use std::fs;
110
111#[test]
112fn test_file_creation() {
113 let temp_dir = TempDir::new().unwrap();
114
115 let mut cmd = Command::cargo_bin("demon").unwrap();
116 cmd.current_dir(temp_dir.path())
117 .args(&["run", "--id", "test", "echo", "hello"])
118 .assert()
119 .success();
120
121 // Verify files were created
122 assert!(temp_dir.path().join("test.pid").exists());
123 assert!(temp_dir.path().join("test.stdout").exists());
124}
125```
126
127### Best Practices
128
1291. **Use `cargo_bin()`**: Automatically locate project binaries
1302. **Chain configuration**: Configure all arguments/env before calling `assert()`
1313. **Test various scenarios**: Success, failure, edge cases
1324. **Use predicates**: More flexible than exact string matching
1335. **Isolate tests**: Use temporary directories for file-based tests
1346. **Test error conditions**: Verify proper error handling and messages
135
136### Common Patterns for CLI Testing
137
138#### Testing Help Output
139```rust
140let mut cmd = Command::cargo_bin("demon").unwrap();
141cmd.args(&["--help"])
142 .assert()
143 .success()
144 .stdout(predicate::str::contains("daemon process management"));
145```
146
147#### Testing Subcommands
148```rust
149let mut cmd = Command::cargo_bin("demon").unwrap();
150cmd.args(&["list"])
151 .assert()
152 .success()
153 .stdout(predicate::str::contains("ID"));
154```
155
156#### Testing with Timeouts
157```rust
158use std::time::Duration;
159
160let mut cmd = Command::cargo_bin("demon").unwrap();
161cmd.timeout(Duration::from_secs(30)) // Prevent hanging tests
162 .args(&["run", "--id", "long", "sleep", "10"])
163 .assert()
164 .success();
165```
166
167### Integration with Other Test Crates
168- **`assert_fs`**: Filesystem testing utilities
169- **`predicates`**: Advanced output validation
170- **`tempfile`**: Temporary file/directory management
171- **`serial_test`**: Serialize tests that can't run concurrently
diff --git a/IMPROVEMENT_PLAN.md b/IMPROVEMENT_PLAN.md
new file mode 100644
index 0000000..a553884
--- /dev/null
+++ b/IMPROVEMENT_PLAN.md
@@ -0,0 +1,166 @@
1# Demon CLI Improvement Plan
2
3## Overview
4This document outlines the planned improvements to the demon CLI tool based on feedback and best practices.
5
6## Improvement Tasks
7
8### 1. Switch to `anyhow` for Error Handling
9**Priority**: High
10**Status**: Pending
11
12**Goal**: Replace `Box<dyn std::error::Error>` with `anyhow::Result` throughout the codebase for better error handling.
13
14**Tasks**:
15- Replace all `Result<(), Box<dyn std::error::Error>>` with `anyhow::Result<()>`
16- Use `anyhow::Context` for better error context
17- Simplify error handling code
18- Update imports and error propagation
19
20**Benefits**:
21- Better error messages with context
22- Simpler error handling
23- More idiomatic Rust error handling
24
25### 2. Implement CLI Testing with `assert_cmd`
26**Priority**: High
27**Status**: Pending
28
29**Goal**: Create comprehensive integration tests for all CLI commands using the `assert_cmd` crate.
30
31**Prerequisites**:
32- Research and document `assert_cmd` usage in CLAUDE.md
33- Add `assert_cmd` dependency
34- Create test infrastructure
35
36**Test Coverage Required**:
37- `demon run`: Process spawning, file creation, duplicate detection
38- `demon stop`: Process termination, timeout handling, cleanup
39- `demon tail`: File watching behavior (basic scenarios)
40- `demon cat`: File content display, flag handling
41- `demon list`: Process listing, status detection
42- `demon status`: Individual process status checks
43- `demon clean`: Orphaned file cleanup
44- Error scenarios: missing files, invalid PIDs, etc.
45
46**Test Structure**:
47```
48tests/
49├── cli.rs # Main CLI integration tests
50├── fixtures/ # Test data and helper files
51└── common/ # Shared test utilities
52```
53
54### 3. Add Quiet Flag to List Command
55**Priority**: Medium
56**Status**: Pending
57
58**Goal**: Add `-q/--quiet` flag to the `demon list` command for machine-readable output.
59
60**Requirements**:
61- Add `quiet` field to `ListArgs` struct (if needed, since `List` currently has no args)
62- Convert `List` command to use `ListArgs` struct
63- When quiet flag is used:
64 - No headers
65 - One line per process: `id:pid:status`
66 - No "No daemon processes found" message when empty
67
68**Example Output**:
69```bash
70# Normal mode
71$ demon list
72ID PID STATUS COMMAND
73--------------------------------------------------
74my-app 12345 RUNNING N/A
75
76# Quiet mode
77$ demon list -q
78my-app:12345:RUNNING
79```
80
81### 4. Add LLM Command
82**Priority**: Medium
83**Status**: Pending
84
85**Goal**: Add a `demon llm` command that outputs a comprehensive usage guide for other LLMs.
86
87**Requirements**:
88- Add `Llm` variant to `Commands` enum
89- No arguments needed
90- Output to stdout (not stderr like other messages)
91- Include all commands with examples
92- Assume the reader is an LLM that needs to understand how to use the tool
93
94**Content Structure**:
95- Tool overview and purpose
96- All available commands with syntax
97- Practical examples for each command
98- Common workflows
99- File structure explanation
100- Error handling tips
101
102### 5. Remove `glob` Dependency
103**Priority**: Low
104**Status**: Pending
105
106**Goal**: Replace the `glob` crate with standard library `std::fs` functionality.
107
108**Implementation**:
109- Remove `glob` from Cargo.toml
110- Replace `glob("*.pid")` with `std::fs::read_dir()` + filtering
111- Update imports
112- Ensure same functionality is maintained
113
114**Functions to Update**:
115- `list_daemons()`: Find all .pid files
116- `clean_orphaned_files()`: Find all .pid files
117
118**Implementation Pattern**:
119```rust
120// Replace glob("*.pid") with:
121std::fs::read_dir(".")?
122 .filter_map(|entry| entry.ok())
123 .filter(|entry| {
124 entry.path().extension()
125 .and_then(|ext| ext.to_str())
126 .map(|ext| ext == "pid")
127 .unwrap_or(false)
128 })
129```
130
131## Implementation Order
132
1331. **Document assert_cmd** - Add understanding to CLAUDE.md
1342. **Switch to anyhow** - Foundation for better error handling
1353. **Implement tests** - Ensure current functionality works correctly
1364. **Add quiet flag** - Small feature addition
1375. **Add LLM command** - Documentation feature
1386. **Remove glob** - Cleanup and reduce dependencies
139
140## Success Criteria
141
142- [ ] All existing functionality remains intact
143- [ ] Comprehensive test coverage (>80% of CLI scenarios)
144- [ ] Better error messages with context
145- [ ] Machine-readable list output option
146- [ ] LLM-friendly documentation command
147- [ ] Reduced dependency footprint
148- [ ] All changes committed with proper messages
149
150## Risk Assessment
151
152**Low Risk**:
153- anyhow migration (straightforward replacement)
154- quiet flag addition (additive change)
155- LLM command (new, isolated feature)
156
157**Medium Risk**:
158- glob removal (need to ensure exact same behavior)
159- CLI testing (need to handle file system interactions carefully)
160
161## Notes
162
163- Each improvement should be implemented, tested, and committed separately
164- Maintain backward compatibility for all existing commands
165- Update IMPLEMENTATION_PLAN.md as work progresses
166- Consider adding integration tests that verify the actual daemon functionality \ No newline at end of file
diff --git a/src/main.rs b/src/main.rs
index e0545e6..12a08b6 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -8,6 +8,7 @@ use std::path::Path;
8use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; 8use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
9use std::sync::mpsc::channel; 9use std::sync::mpsc::channel;
10use glob::glob; 10use glob::glob;
11use anyhow::{Result, Context};
11 12
12#[derive(Parser)] 13#[derive(Parser)]
13#[command(name = "demon")] 14#[command(name = "demon")]
@@ -114,11 +115,11 @@ fn main() {
114 } 115 }
115} 116}
116 117
117fn run_command(command: Commands) -> Result<(), Box<dyn std::error::Error>> { 118fn run_command(command: Commands) -> Result<()> {
118 match command { 119 match command {
119 Commands::Run(args) => { 120 Commands::Run(args) => {
120 if args.command.is_empty() { 121 if args.command.is_empty() {
121 return Err("Command cannot be empty".into()); 122 return Err(anyhow::anyhow!("Command cannot be empty"));
122 } 123 }
123 run_daemon(&args.id, &args.command) 124 run_daemon(&args.id, &args.command)
124 } 125 }
@@ -147,14 +148,14 @@ fn run_command(command: Commands) -> Result<(), Box<dyn std::error::Error>> {
147 } 148 }
148} 149}
149 150
150fn run_daemon(id: &str, command: &[String]) -> Result<(), Box<dyn std::error::Error>> { 151fn run_daemon(id: &str, command: &[String]) -> Result<()> {
151 let pid_file = format!("{}.pid", id); 152 let pid_file = format!("{}.pid", id);
152 let stdout_file = format!("{}.stdout", id); 153 let stdout_file = format!("{}.stdout", id);
153 let stderr_file = format!("{}.stderr", id); 154 let stderr_file = format!("{}.stderr", id);
154 155
155 // Check if process is already running 156 // Check if process is already running
156 if is_process_running(&pid_file)? { 157 if is_process_running(&pid_file)? {
157 return Err(format!("Process '{}' is already running", id).into()); 158 return Err(anyhow::anyhow!("Process '{}' is already running", id));
158 } 159 }
159 160
160 tracing::info!("Starting daemon '{}' with command: {:?}", id, command); 161 tracing::info!("Starting daemon '{}' with command: {:?}", id, command);
@@ -177,7 +178,7 @@ fn run_daemon(id: &str, command: &[String]) -> Result<(), Box<dyn std::error::Er
177 .stderr(Stdio::from(stderr_redirect)) 178 .stderr(Stdio::from(stderr_redirect))
178 .stdin(Stdio::null()) 179 .stdin(Stdio::null())
179 .spawn() 180 .spawn()
180 .map_err(|e| format!("Failed to start process '{}': {}", program, e))?; 181 .with_context(|| format!("Failed to start process '{}' with args {:?}", program, args))?;
181 182
182 // Write PID to file 183 // Write PID to file
183 let mut pid_file_handle = File::create(&pid_file)?; 184 let mut pid_file_handle = File::create(&pid_file)?;
@@ -191,7 +192,7 @@ fn run_daemon(id: &str, command: &[String]) -> Result<(), Box<dyn std::error::Er
191 Ok(()) 192 Ok(())
192} 193}
193 194
194fn is_process_running(pid_file: &str) -> Result<bool, Box<dyn std::error::Error>> { 195fn is_process_running(pid_file: &str) -> Result<bool> {
195 // Try to read the PID file 196 // Try to read the PID file
196 let mut file = match File::open(pid_file) { 197 let mut file = match File::open(pid_file) {
197 Ok(f) => f, 198 Ok(f) => f,
@@ -214,7 +215,7 @@ fn is_process_running(pid_file: &str) -> Result<bool, Box<dyn std::error::Error>
214 Ok(output.status.success()) 215 Ok(output.status.success())
215} 216}
216 217
217fn stop_daemon(id: &str, timeout: u64) -> Result<(), Box<dyn std::error::Error>> { 218fn stop_daemon(id: &str, timeout: u64) -> Result<()> {
218 let pid_file = format!("{}.pid", id); 219 let pid_file = format!("{}.pid", id);
219 220
220 // Check if PID file exists 221 // Check if PID file exists
@@ -255,7 +256,7 @@ fn stop_daemon(id: &str, timeout: u64) -> Result<(), Box<dyn std::error::Error>>
255 .output()?; 256 .output()?;
256 257
257 if !output.status.success() { 258 if !output.status.success() {
258 return Err(format!("Failed to send SIGTERM to PID {}", pid).into()); 259 return Err(anyhow::anyhow!("Failed to send SIGTERM to PID {}", pid));
259 } 260 }
260 261
261 // Wait for the process to terminate 262 // Wait for the process to terminate
@@ -280,14 +281,14 @@ fn stop_daemon(id: &str, timeout: u64) -> Result<(), Box<dyn std::error::Error>>
280 .output()?; 281 .output()?;
281 282
282 if !output.status.success() { 283 if !output.status.success() {
283 return Err(format!("Failed to send SIGKILL to PID {}", pid).into()); 284 return Err(anyhow::anyhow!("Failed to send SIGKILL to PID {}", pid));
284 } 285 }
285 286
286 // Wait a bit more for SIGKILL to take effect 287 // Wait a bit more for SIGKILL to take effect
287 thread::sleep(Duration::from_secs(1)); 288 thread::sleep(Duration::from_secs(1));
288 289
289 if is_process_running_by_pid(pid) { 290 if is_process_running_by_pid(pid) {
290 return Err(format!("Process {} is still running after SIGKILL", pid).into()); 291 return Err(anyhow::anyhow!("Process {} is still running after SIGKILL", pid));
291 } 292 }
292 293
293 println!("Process '{}' (PID: {}) terminated forcefully", id, pid); 294 println!("Process '{}' (PID: {}) terminated forcefully", id, pid);
@@ -307,7 +308,7 @@ fn is_process_running_by_pid(pid: u32) -> bool {
307 } 308 }
308} 309}
309 310
310fn cat_logs(id: &str, show_stdout: bool, show_stderr: bool) -> Result<(), Box<dyn std::error::Error>> { 311fn cat_logs(id: &str, show_stdout: bool, show_stderr: bool) -> Result<()> {
311 let stdout_file = format!("{}.stdout", id); 312 let stdout_file = format!("{}.stdout", id);
312 let stderr_file = format!("{}.stderr", id); 313 let stderr_file = format!("{}.stderr", id);
313 314
@@ -348,7 +349,7 @@ fn cat_logs(id: &str, show_stdout: bool, show_stderr: bool) -> Result<(), Box<dy
348 Ok(()) 349 Ok(())
349} 350}
350 351
351fn tail_logs(id: &str, show_stdout: bool, show_stderr: bool) -> Result<(), Box<dyn std::error::Error>> { 352fn tail_logs(id: &str, show_stdout: bool, show_stderr: bool) -> Result<()> {
352 let stdout_file = format!("{}.stdout", id); 353 let stdout_file = format!("{}.stdout", id);
353 let stderr_file = format!("{}.stderr", id); 354 let stderr_file = format!("{}.stderr", id);
354 355
@@ -464,7 +465,7 @@ fn tail_logs(id: &str, show_stdout: bool, show_stderr: bool) -> Result<(), Box<d
464 Ok(()) 465 Ok(())
465} 466}
466 467
467fn read_file_content(file: &mut File) -> Result<String, Box<dyn std::error::Error>> { 468fn read_file_content(file: &mut File) -> Result<String> {
468 let mut content = String::new(); 469 let mut content = String::new();
469 file.read_to_string(&mut content)?; 470 file.read_to_string(&mut content)?;
470 Ok(content) 471 Ok(content)
@@ -474,7 +475,7 @@ fn handle_file_change(
474 file_path: &str, 475 file_path: &str,
475 positions: &mut std::collections::HashMap<String, u64>, 476 positions: &mut std::collections::HashMap<String, u64>,
476 show_headers: bool 477 show_headers: bool
477) -> Result<(), Box<dyn std::error::Error>> { 478) -> Result<()> {
478 let mut file = File::open(file_path)?; 479 let mut file = File::open(file_path)?;
479 let current_pos = positions.get(file_path).copied().unwrap_or(0); 480 let current_pos = positions.get(file_path).copied().unwrap_or(0);
480 481
@@ -500,7 +501,7 @@ fn handle_file_change(
500 Ok(()) 501 Ok(())
501} 502}
502 503
503fn list_daemons() -> Result<(), Box<dyn std::error::Error>> { 504fn list_daemons() -> Result<()> {
504 println!("{:<20} {:<8} {:<10} {}", "ID", "PID", "STATUS", "COMMAND"); 505 println!("{:<20} {:<8} {:<10} {}", "ID", "PID", "STATUS", "COMMAND");
505 println!("{}", "-".repeat(50)); 506 println!("{}", "-".repeat(50));
506 507
@@ -557,7 +558,7 @@ fn list_daemons() -> Result<(), Box<dyn std::error::Error>> {
557 Ok(()) 558 Ok(())
558} 559}
559 560
560fn status_daemon(id: &str) -> Result<(), Box<dyn std::error::Error>> { 561fn status_daemon(id: &str) -> Result<()> {
561 let pid_file = format!("{}.pid", id); 562 let pid_file = format!("{}.pid", id);
562 let stdout_file = format!("{}.stdout", id); 563 let stdout_file = format!("{}.stdout", id);
563 let stderr_file = format!("{}.stderr", id); 564 let stderr_file = format!("{}.stderr", id);
@@ -614,7 +615,7 @@ fn status_daemon(id: &str) -> Result<(), Box<dyn std::error::Error>> {
614 Ok(()) 615 Ok(())
615} 616}
616 617
617fn clean_orphaned_files() -> Result<(), Box<dyn std::error::Error>> { 618fn clean_orphaned_files() -> Result<()> {
618 tracing::info!("Scanning for orphaned daemon files..."); 619 tracing::info!("Scanning for orphaned daemon files...");
619 620
620 let mut cleaned_count = 0; 621 let mut cleaned_count = 0;