1use clap::{Args, Parser, Subcommand, ValueHint};
2use std::path::PathBuf;
3
4#[derive(Parser, Debug)]
10#[command(
11 name = "todo-tree",
12 author,
13 version,
14 about,
15 long_about = None,
16)]
17pub struct Cli {
18 #[command(subcommand)]
20 pub command: Option<Commands>,
21
22 #[command(flatten)]
24 pub global: GlobalOptions,
25}
26
27#[derive(Args, Debug, Clone)]
29pub struct GlobalOptions {
30 #[arg(long, global = true, env = "NO_COLOR")]
32 pub no_color: bool,
33
34 #[arg(short, long, global = true)]
36 pub verbose: bool,
37
38 #[arg(long, global = true, value_hint = ValueHint::FilePath)]
40 pub config: Option<PathBuf>,
41}
42
43#[derive(Subcommand, Debug, Clone)]
45pub enum Commands {
46 #[command(visible_alias = "s")]
48 Scan(ScanArgs),
49
50 #[command(visible_alias = "l", visible_alias = "ls")]
52 List(ListArgs),
53
54 #[command(visible_alias = "t")]
56 Tags(TagsArgs),
57
58 Init(InitArgs),
60
61 Stats(StatsArgs),
63}
64
65#[derive(Args, Debug, Clone)]
67pub struct ScanArgs {
68 #[arg(value_hint = ValueHint::AnyPath)]
70 pub path: Option<PathBuf>,
71
72 #[arg(short, long, value_delimiter = ',')]
74 pub tags: Option<Vec<String>>,
75
76 #[arg(short, long, value_delimiter = ',')]
78 pub include: Option<Vec<String>>,
79
80 #[arg(short, long, value_delimiter = ',')]
82 pub exclude: Option<Vec<String>>,
83
84 #[arg(long)]
86 pub json: bool,
87
88 #[arg(long)]
90 pub flat: bool,
91
92 #[arg(short, long, default_value = "0")]
94 pub depth: usize,
95
96 #[arg(long)]
98 pub follow_links: bool,
99
100 #[arg(long)]
102 pub hidden: bool,
103
104 #[arg(long)]
106 pub case_sensitive: bool,
107
108 #[arg(long, default_value = "file")]
110 pub sort: SortOrder,
111
112 #[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#[derive(Args, Debug, Clone, Default)]
138pub struct ListArgs {
139 #[arg(value_hint = ValueHint::AnyPath)]
141 pub path: Option<PathBuf>,
142
143 #[arg(short, long, value_delimiter = ',')]
145 pub tags: Option<Vec<String>>,
146
147 #[arg(short, long, value_delimiter = ',')]
149 pub include: Option<Vec<String>>,
150
151 #[arg(short, long, value_delimiter = ',')]
153 pub exclude: Option<Vec<String>>,
154
155 #[arg(long)]
157 pub json: bool,
158
159 #[arg(long)]
161 pub filter: Option<String>,
162
163 #[arg(long)]
165 pub case_sensitive: bool,
166}
167
168#[derive(Args, Debug, Clone)]
170pub struct TagsArgs {
171 #[arg(long)]
173 pub json: bool,
174
175 #[arg(long)]
177 pub add: Option<String>,
178
179 #[arg(long)]
181 pub remove: Option<String>,
182
183 #[arg(long)]
185 pub reset: bool,
186}
187
188#[derive(Args, Debug, Clone)]
190pub struct InitArgs {
191 #[arg(long, default_value = "json")]
193 pub format: ConfigFormat,
194
195 #[arg(short, long)]
197 pub force: bool,
198}
199
200#[derive(Args, Debug, Clone)]
202pub struct StatsArgs {
203 #[arg(value_hint = ValueHint::AnyPath)]
205 pub path: Option<PathBuf>,
206
207 #[arg(short, long, value_delimiter = ',')]
209 pub tags: Option<Vec<String>>,
210
211 #[arg(long)]
213 pub json: bool,
214}
215
216#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, clap::ValueEnum)]
218pub enum SortOrder {
219 #[default]
221 File,
222 Line,
224 Priority,
226}
227
228#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, clap::ValueEnum)]
230pub enum ConfigFormat {
231 #[default]
232 Json,
233 Yaml,
234}
235
236impl Cli {
237 pub fn parse_args() -> Self {
239 Self::parse()
240 }
241
242 pub fn get_command(&self) -> Commands {
244 self.command
245 .clone()
246 .unwrap_or_else(|| Commands::Scan(ScanArgs::default()))
247 }
248}
249
250impl 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}