Skip to main content

ralph/cli/
watch.rs

1//! `ralph watch` command: Clap types and handler.
2//!
3//! Responsibilities:
4//! - Define clap arguments for watch commands.
5//! - Dispatch watch execution with file watching and task detection.
6//!
7//! Not handled here:
8//! - File system watching implementation details.
9//! - TODO/FIXME comment detection logic.
10//! - Queue mutation operations.
11//!
12//! Invariants/assumptions:
13//! - Configuration is resolved from the current working directory.
14//! - Watch mode respects gitignore patterns for file exclusion.
15
16use crate::commands::watch::{CommentType, WatchOptions};
17use crate::{commands::watch as watch_cmd, config};
18use anyhow::Result;
19use clap::{Args, ValueEnum};
20use std::path::PathBuf;
21
22/// Comment types to detect in watched files.
23#[derive(Clone, Copy, Debug, ValueEnum, PartialEq, Eq)]
24pub enum WatchCommentType {
25    /// Detect TODO comments.
26    Todo,
27    /// Detect FIXME comments.
28    Fixme,
29    /// Detect HACK comments.
30    Hack,
31    /// Detect XXX comments.
32    Xxx,
33    /// Detect all comment types (default).
34    All,
35}
36
37impl From<WatchCommentType> for CommentType {
38    fn from(value: WatchCommentType) -> Self {
39        match value {
40            WatchCommentType::Todo => CommentType::Todo,
41            WatchCommentType::Fixme => CommentType::Fixme,
42            WatchCommentType::Hack => CommentType::Hack,
43            WatchCommentType::Xxx => CommentType::Xxx,
44            WatchCommentType::All => CommentType::All,
45        }
46    }
47}
48
49pub fn handle_watch(args: WatchArgs, force: bool) -> Result<()> {
50    let resolved = config::resolve_from_cwd()?;
51
52    let comment_types: Vec<CommentType> = if args.comments.is_empty() {
53        vec![CommentType::All]
54    } else {
55        args.comments.iter().map(|&c| c.into()).collect()
56    };
57
58    let patterns: Vec<String> = if args.patterns.is_empty() {
59        vec![
60            "*.rs".to_string(),
61            "*.ts".to_string(),
62            "*.js".to_string(),
63            "*.py".to_string(),
64            "*.go".to_string(),
65            "*.java".to_string(),
66            "*.md".to_string(),
67            "*.toml".to_string(),
68            "*.json".to_string(),
69        ]
70    } else {
71        args.patterns.clone()
72    };
73
74    let paths: Vec<PathBuf> = if args.paths.is_empty() {
75        vec![std::env::current_dir()?]
76    } else {
77        args.paths.clone()
78    };
79
80    watch_cmd::run_watch(
81        &resolved,
82        WatchOptions {
83            patterns,
84            debounce_ms: args.debounce_ms,
85            auto_queue: args.auto_queue,
86            notify: args.notify,
87            ignore_patterns: args.ignore_patterns,
88            comment_types,
89            paths,
90            force,
91            close_removed: args.close_removed,
92        },
93    )
94}
95
96#[derive(Args)]
97#[command(
98    about = "Watch files for changes and auto-detect tasks from TODO/FIXME/HACK/XXX comments",
99    after_long_help = "Examples:
100  ralph watch
101  ralph watch src/
102  ralph watch --patterns \"*.rs,*.toml\"
103  ralph watch --auto-queue
104  ralph watch --notify
105  ralph watch --comments todo,fixme
106  ralph watch --debounce-ms 1000
107  ralph watch --ignore-patterns \"vendor/,target/,node_modules/\"
108  ralph watch --auto-queue --close-removed"
109)]
110pub struct WatchArgs {
111    /// Directories or files to watch (defaults to current directory).
112    #[arg(value_name = "PATH")]
113    pub paths: Vec<PathBuf>,
114
115    /// File patterns to watch (comma-separated, default: *.rs,*.ts,*.js,*.py,*.go,*.java,*.md,*.toml,*.json).
116    #[arg(long, value_delimiter = ',')]
117    pub patterns: Vec<String>,
118
119    /// Debounce duration in milliseconds (default: 500).
120    #[arg(long, default_value_t = 500)]
121    pub debounce_ms: u64,
122
123    /// Automatically create tasks without prompting.
124    #[arg(long)]
125    pub auto_queue: bool,
126
127    /// Enable desktop notifications for new tasks.
128    #[arg(long)]
129    pub notify: bool,
130
131    /// Additional gitignore-style exclusions (comma-separated).
132    #[arg(long, value_delimiter = ',')]
133    pub ignore_patterns: Vec<String>,
134
135    /// Comment types to detect: todo,fixme,hack,xxx,all (default: all).
136    #[arg(long, value_enum, value_delimiter = ',')]
137    pub comments: Vec<WatchCommentType>,
138
139    /// Automatically close (mark done) watch tasks when their originating comments are removed.
140    #[arg(long)]
141    pub close_removed: bool,
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147    use crate::cli::Cli;
148    use clap::{CommandFactory, Parser};
149
150    #[test]
151    fn watch_help_examples_exist() {
152        let mut cmd = Cli::command();
153        let watch = cmd.find_subcommand_mut("watch").expect("watch subcommand");
154        let help = watch.render_long_help().to_string();
155
156        assert!(help.contains("ralph watch"), "missing basic watch example");
157        assert!(
158            help.contains("--auto-queue"),
159            "missing --auto-queue example"
160        );
161        assert!(help.contains("--notify"), "missing --notify example");
162        assert!(help.contains("--comments"), "missing --comments example");
163    }
164
165    #[test]
166    fn watch_parses_default_args() {
167        let cli = Cli::try_parse_from(["ralph", "watch"]).expect("parse");
168
169        match cli.command {
170            crate::cli::Command::Watch(args) => {
171                assert!(args.paths.is_empty());
172                assert!(args.patterns.is_empty());
173                assert_eq!(args.debounce_ms, 500);
174                assert!(!args.auto_queue);
175                assert!(!args.notify);
176                assert!(args.ignore_patterns.is_empty());
177                assert!(args.comments.is_empty());
178            }
179            _ => panic!("expected watch command"),
180        }
181    }
182
183    #[test]
184    fn watch_parses_paths() {
185        let cli = Cli::try_parse_from(["ralph", "watch", "src/", "tests/"]).expect("parse");
186
187        match cli.command {
188            crate::cli::Command::Watch(args) => {
189                assert_eq!(args.paths.len(), 2);
190                assert_eq!(args.paths[0], PathBuf::from("src/"));
191                assert_eq!(args.paths[1], PathBuf::from("tests/"));
192            }
193            _ => panic!("expected watch command"),
194        }
195    }
196
197    #[test]
198    fn watch_parses_patterns() {
199        let cli =
200            Cli::try_parse_from(["ralph", "watch", "--patterns", "*.rs,*.toml"]).expect("parse");
201
202        match cli.command {
203            crate::cli::Command::Watch(args) => {
204                assert_eq!(args.patterns, vec!["*.rs", "*.toml"]);
205            }
206            _ => panic!("expected watch command"),
207        }
208    }
209
210    #[test]
211    fn watch_parses_debounce() {
212        let cli = Cli::try_parse_from(["ralph", "watch", "--debounce-ms", "1000"]).expect("parse");
213
214        match cli.command {
215            crate::cli::Command::Watch(args) => {
216                assert_eq!(args.debounce_ms, 1000);
217            }
218            _ => panic!("expected watch command"),
219        }
220    }
221
222    #[test]
223    fn watch_parses_auto_queue() {
224        let cli = Cli::try_parse_from(["ralph", "watch", "--auto-queue"]).expect("parse");
225
226        match cli.command {
227            crate::cli::Command::Watch(args) => {
228                assert!(args.auto_queue);
229            }
230            _ => panic!("expected watch command"),
231        }
232    }
233
234    #[test]
235    fn watch_parses_notify() {
236        let cli = Cli::try_parse_from(["ralph", "watch", "--notify"]).expect("parse");
237
238        match cli.command {
239            crate::cli::Command::Watch(args) => {
240                assert!(args.notify);
241            }
242            _ => panic!("expected watch command"),
243        }
244    }
245
246    #[test]
247    fn watch_parses_comments() {
248        let cli =
249            Cli::try_parse_from(["ralph", "watch", "--comments", "todo,fixme"]).expect("parse");
250
251        match cli.command {
252            crate::cli::Command::Watch(args) => {
253                assert_eq!(args.comments.len(), 2);
254                assert_eq!(args.comments[0], WatchCommentType::Todo);
255                assert_eq!(args.comments[1], WatchCommentType::Fixme);
256            }
257            _ => panic!("expected watch command"),
258        }
259    }
260
261    #[test]
262    fn watch_parses_ignore_patterns() {
263        let cli = Cli::try_parse_from(["ralph", "watch", "--ignore-patterns", "vendor/,target/"])
264            .expect("parse");
265
266        match cli.command {
267            crate::cli::Command::Watch(args) => {
268                assert_eq!(args.ignore_patterns, vec!["vendor/", "target/"]);
269            }
270            _ => panic!("expected watch command"),
271        }
272    }
273
274    #[test]
275    fn watch_parses_close_removed() {
276        let cli = Cli::try_parse_from(["ralph", "watch", "--close-removed"]).expect("parse");
277
278        match cli.command {
279            crate::cli::Command::Watch(args) => {
280                assert!(args.close_removed);
281            }
282            _ => panic!("expected watch command"),
283        }
284    }
285}