todo_tree/
cli.rs

1use clap::{Args, Parser, Subcommand, ValueHint};
2use std::path::PathBuf;
3
4/// A CLI tool to find and display TODO-style comments in your codebase
5///
6/// Similar to the VS Code "Todo Tree" extension, this tool recursively scans
7/// directories for comments containing TODO-style tags and displays them
8/// in a tree view grouped by file.
9#[derive(Parser, Debug)]
10#[command(
11    name = "todo-tree",
12    author,
13    version,
14    about,
15    long_about = None,
16)]
17pub struct Cli {
18    /// The command to execute
19    #[command(subcommand)]
20    pub command: Option<Commands>,
21
22    /// Global options that apply to all commands
23    #[command(flatten)]
24    pub global: GlobalOptions,
25}
26
27/// Global options available for all commands
28#[derive(Args, Debug, Clone)]
29pub struct GlobalOptions {
30    /// Disable colored output
31    #[arg(long, global = true, env = "NO_COLOR")]
32    pub no_color: bool,
33
34    /// Enable verbose output
35    #[arg(short, long, global = true)]
36    pub verbose: bool,
37
38    /// Path to a custom config file
39    #[arg(long, global = true, value_hint = ValueHint::FilePath)]
40    pub config: Option<PathBuf>,
41}
42
43/// Available commands for the todo-tree CLI
44#[derive(Subcommand, Debug, Clone)]
45pub enum Commands {
46    /// Scan directories for TODO-style comments (default command)
47    #[command(visible_alias = "s")]
48    Scan(ScanArgs),
49
50    /// List all TODO-style comments in a flat format
51    #[command(visible_alias = "l", visible_alias = "ls")]
52    List(ListArgs),
53
54    /// Show or manage configured tags
55    #[command(visible_alias = "t")]
56    Tags(TagsArgs),
57
58    /// Initialize a new .todorc config file
59    Init(InitArgs),
60
61    /// Show statistics about TODOs in the codebase
62    Stats(StatsArgs),
63}
64
65/// Arguments for the scan command
66#[derive(Args, Debug, Clone)]
67pub struct ScanArgs {
68    /// Directory or file to scan (defaults to current directory)
69    #[arg(value_hint = ValueHint::AnyPath)]
70    pub path: Option<PathBuf>,
71
72    /// Tags to search for (comma-separated)
73    #[arg(short, long, value_delimiter = ',')]
74    pub tags: Option<Vec<String>>,
75
76    /// File patterns to include (glob patterns, comma-separated)
77    #[arg(short, long, value_delimiter = ',')]
78    pub include: Option<Vec<String>>,
79
80    /// File patterns to exclude (glob patterns, comma-separated)
81    #[arg(short, long, value_delimiter = ',')]
82    pub exclude: Option<Vec<String>>,
83
84    /// Output results in JSON format
85    #[arg(long)]
86    pub json: bool,
87
88    /// Output results in flat format (no tree structure)
89    #[arg(long)]
90    pub flat: bool,
91
92    /// Maximum depth to scan (0 = unlimited)
93    #[arg(short, long, default_value = "0")]
94    pub depth: usize,
95
96    /// Follow symbolic links
97    #[arg(long)]
98    pub follow_links: bool,
99
100    /// Include hidden files and directories
101    #[arg(long)]
102    pub hidden: bool,
103
104    /// Case-sensitive tag matching
105    #[arg(long)]
106    pub case_sensitive: bool,
107
108    /// Sort results by: file, tag, line
109    #[arg(long, default_value = "file")]
110    pub sort: SortOrder,
111
112    /// Group results by tag instead of by file
113    #[arg(long)]
114    pub group_by_tag: bool,
115}
116
117impl Default for ScanArgs {
118    fn default() -> Self {
119        Self {
120            path: None,
121            tags: None,
122            include: None,
123            exclude: None,
124            json: false,
125            flat: false,
126            depth: 0,
127            follow_links: false,
128            hidden: false,
129            case_sensitive: false,
130            sort: SortOrder::File,
131            group_by_tag: false,
132        }
133    }
134}
135
136/// Arguments for the list command
137#[derive(Args, Debug, Clone, Default)]
138pub struct ListArgs {
139    /// Directory or file to scan (defaults to current directory)
140    #[arg(value_hint = ValueHint::AnyPath)]
141    pub path: Option<PathBuf>,
142
143    /// Tags to search for (comma-separated)
144    #[arg(short, long, value_delimiter = ',')]
145    pub tags: Option<Vec<String>>,
146
147    /// File patterns to include (glob patterns, comma-separated)
148    #[arg(short, long, value_delimiter = ',')]
149    pub include: Option<Vec<String>>,
150
151    /// File patterns to exclude (glob patterns, comma-separated)
152    #[arg(short, long, value_delimiter = ',')]
153    pub exclude: Option<Vec<String>>,
154
155    /// Output results in JSON format
156    #[arg(long)]
157    pub json: bool,
158
159    /// Filter by specific tag
160    #[arg(long)]
161    pub filter: Option<String>,
162
163    /// Case-sensitive tag matching
164    #[arg(long)]
165    pub case_sensitive: bool,
166}
167
168/// Arguments for the tags command
169#[derive(Args, Debug, Clone)]
170pub struct TagsArgs {
171    /// Show tags in JSON format
172    #[arg(long)]
173    pub json: bool,
174
175    /// Add a new tag to the configuration
176    #[arg(long)]
177    pub add: Option<String>,
178
179    /// Remove a tag from the configuration
180    #[arg(long)]
181    pub remove: Option<String>,
182
183    /// Reset tags to defaults
184    #[arg(long)]
185    pub reset: bool,
186}
187
188/// Arguments for the init command
189#[derive(Args, Debug, Clone)]
190pub struct InitArgs {
191    /// Configuration format: json or yaml
192    #[arg(long, default_value = "json")]
193    pub format: ConfigFormat,
194
195    /// Force overwrite if config file exists
196    #[arg(short, long)]
197    pub force: bool,
198}
199
200/// Arguments for the stats command
201#[derive(Args, Debug, Clone)]
202pub struct StatsArgs {
203    /// Directory or file to scan (defaults to current directory)
204    #[arg(value_hint = ValueHint::AnyPath)]
205    pub path: Option<PathBuf>,
206
207    /// Tags to search for (comma-separated)
208    #[arg(short, long, value_delimiter = ',')]
209    pub tags: Option<Vec<String>>,
210
211    /// Output results in JSON format
212    #[arg(long)]
213    pub json: bool,
214}
215
216/// Sort order for results
217#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, clap::ValueEnum)]
218pub enum SortOrder {
219    /// Sort by file path
220    #[default]
221    File,
222    /// Sort by line number
223    Line,
224    /// Sort by priority (based on tag type)
225    Priority,
226}
227
228/// Configuration format for init command
229#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, clap::ValueEnum)]
230pub enum ConfigFormat {
231    #[default]
232    Json,
233    Yaml,
234}
235
236impl Cli {
237    /// Parse CLI arguments
238    pub fn parse_args() -> Self {
239        Self::parse()
240    }
241
242    /// Get the effective command, defaulting to Scan if none specified
243    pub fn get_command(&self) -> Commands {
244        self.command
245            .clone()
246            .unwrap_or_else(|| Commands::Scan(ScanArgs::default()))
247    }
248}
249
250/// Convert ScanArgs to ListArgs for the list command
251impl From<ScanArgs> for ListArgs {
252    fn from(scan: ScanArgs) -> Self {
253        Self {
254            path: scan.path,
255            tags: scan.tags,
256            include: scan.include,
257            exclude: scan.exclude,
258            json: scan.json,
259            filter: None,
260            case_sensitive: scan.case_sensitive,
261        }
262    }
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    #[test]
270    fn test_parse_scan_command() {
271        let cli = Cli::parse_from(["todo-tree", "scan", "--tags", "TODO,FIXME"]);
272
273        match cli.command {
274            Some(Commands::Scan(args)) => {
275                assert_eq!(
276                    args.tags,
277                    Some(vec!["TODO".to_string(), "FIXME".to_string()])
278                );
279            }
280            _ => panic!("Expected Scan command"),
281        }
282    }
283
284    #[test]
285    fn test_parse_scan_with_path() {
286        let cli = Cli::parse_from(["todo-tree", "scan", "./src"]);
287
288        match cli.command {
289            Some(Commands::Scan(args)) => {
290                assert_eq!(args.path, Some(PathBuf::from("./src")));
291            }
292            _ => panic!("Expected Scan command"),
293        }
294    }
295
296    #[test]
297    fn test_parse_list_command() {
298        let cli = Cli::parse_from(["todo-tree", "list", "--json"]);
299
300        match cli.command {
301            Some(Commands::List(args)) => {
302                assert!(args.json);
303            }
304            _ => panic!("Expected List command"),
305        }
306    }
307
308    #[test]
309    fn test_parse_tags_command() {
310        let cli = Cli::parse_from(["todo-tree", "tags"]);
311
312        assert!(matches!(cli.command, Some(Commands::Tags(_))));
313    }
314
315    #[test]
316    fn test_parse_no_color() {
317        let cli = Cli::parse_from(["todo-tree", "--no-color", "scan"]);
318
319        assert!(cli.global.no_color);
320    }
321
322    #[test]
323    fn test_parse_include_exclude() {
324        let cli = Cli::parse_from([
325            "todo-tree",
326            "scan",
327            "--include",
328            "*.rs,*.py",
329            "--exclude",
330            "target/**,node_modules/**",
331        ]);
332
333        match cli.command {
334            Some(Commands::Scan(args)) => {
335                assert_eq!(
336                    args.include,
337                    Some(vec!["*.rs".to_string(), "*.py".to_string()])
338                );
339                assert_eq!(
340                    args.exclude,
341                    Some(vec!["target/**".to_string(), "node_modules/**".to_string()])
342                );
343            }
344            _ => panic!("Expected Scan command"),
345        }
346    }
347
348    #[test]
349    fn test_default_command_is_scan() {
350        let cli = Cli::parse_from(["todo-tree"]);
351
352        match cli.get_command() {
353            Commands::Scan(_) => {}
354            _ => panic!("Expected default to be Scan command"),
355        }
356    }
357
358    #[test]
359    fn test_parse_init_command() {
360        let cli = Cli::parse_from(["todo-tree", "init", "--format", "yaml", "--force"]);
361
362        match cli.command {
363            Some(Commands::Init(args)) => {
364                assert_eq!(args.format, ConfigFormat::Yaml);
365                assert!(args.force);
366            }
367            _ => panic!("Expected Init command"),
368        }
369    }
370
371    #[test]
372    fn test_sort_order() {
373        let cli = Cli::parse_from(["todo-tree", "scan", "--sort", "priority"]);
374
375        match cli.command {
376            Some(Commands::Scan(args)) => {
377                assert_eq!(args.sort, SortOrder::Priority);
378            }
379            _ => panic!("Expected Scan command"),
380        }
381    }
382
383    #[test]
384    fn test_scan_args_from_list_args() {
385        let scan = ScanArgs {
386            path: Some(PathBuf::from("./src")),
387            tags: Some(vec!["TODO".to_string()]),
388            json: true,
389            ..Default::default()
390        };
391
392        let list: ListArgs = scan.into();
393        assert_eq!(list.path, Some(PathBuf::from("./src")));
394        assert_eq!(list.tags, Some(vec!["TODO".to_string()]));
395        assert!(list.json);
396    }
397
398    #[test]
399    fn test_parse_verbose_flag() {
400        let cli = Cli::parse_from(["todo-tree", "-v", "scan"]);
401        assert!(cli.global.verbose);
402    }
403
404    #[test]
405    fn test_parse_config_path() {
406        let cli = Cli::parse_from(["todo-tree", "--config", "/path/to/config.json", "scan"]);
407        assert_eq!(
408            cli.global.config,
409            Some(PathBuf::from("/path/to/config.json"))
410        );
411    }
412
413    #[test]
414    fn test_parse_list_with_filter() {
415        let cli = Cli::parse_from(["todo-tree", "list", "--filter", "TODO"]);
416
417        match cli.command {
418            Some(Commands::List(args)) => {
419                assert_eq!(args.filter, Some("TODO".to_string()));
420            }
421            _ => panic!("Expected List command"),
422        }
423    }
424
425    #[test]
426    fn test_parse_stats_command() {
427        let cli = Cli::parse_from(["todo-tree", "stats", "--json"]);
428
429        match cli.command {
430            Some(Commands::Stats(args)) => {
431                assert!(args.json);
432            }
433            _ => panic!("Expected Stats command"),
434        }
435    }
436
437    #[test]
438    fn test_parse_stats_with_path() {
439        let cli = Cli::parse_from(["todo-tree", "stats", "./src"]);
440
441        match cli.command {
442            Some(Commands::Stats(args)) => {
443                assert_eq!(args.path, Some(PathBuf::from("./src")));
444            }
445            _ => panic!("Expected Stats command"),
446        }
447    }
448
449    #[test]
450    fn test_parse_tags_add() {
451        let cli = Cli::parse_from(["todo-tree", "tags", "--add", "CUSTOM"]);
452
453        match cli.command {
454            Some(Commands::Tags(args)) => {
455                assert_eq!(args.add, Some("CUSTOM".to_string()));
456            }
457            _ => panic!("Expected Tags command"),
458        }
459    }
460
461    #[test]
462    fn test_parse_tags_remove() {
463        let cli = Cli::parse_from(["todo-tree", "tags", "--remove", "NOTE"]);
464
465        match cli.command {
466            Some(Commands::Tags(args)) => {
467                assert_eq!(args.remove, Some("NOTE".to_string()));
468            }
469            _ => panic!("Expected Tags command"),
470        }
471    }
472
473    #[test]
474    fn test_parse_tags_reset() {
475        let cli = Cli::parse_from(["todo-tree", "tags", "--reset"]);
476
477        match cli.command {
478            Some(Commands::Tags(args)) => {
479                assert!(args.reset);
480            }
481            _ => panic!("Expected Tags command"),
482        }
483    }
484
485    #[test]
486    fn test_parse_scan_depth() {
487        let cli = Cli::parse_from(["todo-tree", "scan", "--depth", "3"]);
488
489        match cli.command {
490            Some(Commands::Scan(args)) => {
491                assert_eq!(args.depth, 3);
492            }
493            _ => panic!("Expected Scan command"),
494        }
495    }
496
497    #[test]
498    fn test_parse_scan_follow_links() {
499        let cli = Cli::parse_from(["todo-tree", "scan", "--follow-links"]);
500
501        match cli.command {
502            Some(Commands::Scan(args)) => {
503                assert!(args.follow_links);
504            }
505            _ => panic!("Expected Scan command"),
506        }
507    }
508
509    #[test]
510    fn test_parse_scan_hidden() {
511        let cli = Cli::parse_from(["todo-tree", "scan", "--hidden"]);
512
513        match cli.command {
514            Some(Commands::Scan(args)) => {
515                assert!(args.hidden);
516            }
517            _ => panic!("Expected Scan command"),
518        }
519    }
520
521    #[test]
522    fn test_parse_scan_case_sensitive() {
523        let cli = Cli::parse_from(["todo-tree", "scan", "--case-sensitive"]);
524
525        match cli.command {
526            Some(Commands::Scan(args)) => {
527                assert!(args.case_sensitive);
528            }
529            _ => panic!("Expected Scan command"),
530        }
531    }
532
533    #[test]
534    fn test_parse_scan_flat() {
535        let cli = Cli::parse_from(["todo-tree", "scan", "--flat"]);
536
537        match cli.command {
538            Some(Commands::Scan(args)) => {
539                assert!(args.flat);
540            }
541            _ => panic!("Expected Scan command"),
542        }
543    }
544
545    #[test]
546    fn test_sort_order_line() {
547        let cli = Cli::parse_from(["todo-tree", "scan", "--sort", "line"]);
548
549        match cli.command {
550            Some(Commands::Scan(args)) => {
551                assert_eq!(args.sort, SortOrder::Line);
552            }
553            _ => panic!("Expected Scan command"),
554        }
555    }
556
557    #[test]
558    fn test_config_format_default() {
559        assert_eq!(ConfigFormat::default(), ConfigFormat::Json);
560    }
561
562    #[test]
563    fn test_sort_order_default() {
564        assert_eq!(SortOrder::default(), SortOrder::File);
565    }
566
567    #[test]
568    fn test_scan_args_default() {
569        let args = ScanArgs::default();
570        assert!(args.path.is_none());
571        assert!(args.tags.is_none());
572        assert!(args.include.is_none());
573        assert!(args.exclude.is_none());
574        assert!(!args.json);
575        assert!(!args.flat);
576        assert_eq!(args.depth, 0);
577        assert!(!args.follow_links);
578        assert!(!args.hidden);
579        assert!(!args.case_sensitive);
580        assert_eq!(args.sort, SortOrder::File);
581    }
582
583    #[test]
584    fn test_list_args_default() {
585        let args = ListArgs::default();
586        assert!(args.path.is_none());
587        assert!(args.tags.is_none());
588        assert!(args.include.is_none());
589        assert!(args.exclude.is_none());
590        assert!(!args.json);
591        assert!(args.filter.is_none());
592        assert!(!args.case_sensitive);
593    }
594
595    #[test]
596    fn test_scan_args_to_list_args_preserves_case_sensitive() {
597        let scan = ScanArgs {
598            case_sensitive: true,
599            ..Default::default()
600        };
601
602        let list: ListArgs = scan.into();
603        assert!(list.case_sensitive);
604    }
605
606    #[test]
607    fn test_scan_args_to_list_args_preserves_include_exclude() {
608        let scan = ScanArgs {
609            include: Some(vec!["*.rs".to_string()]),
610            exclude: Some(vec!["target/**".to_string()]),
611            ..Default::default()
612        };
613
614        let list: ListArgs = scan.into();
615        assert_eq!(list.include, Some(vec!["*.rs".to_string()]));
616        assert_eq!(list.exclude, Some(vec!["target/**".to_string()]));
617    }
618}