todo_tree/
lib.rs

1pub mod cli;
2pub mod config;
3pub mod parser;
4pub mod printer;
5pub mod scanner;
6
7use anyhow::Result;
8use cli::{Cli, Commands, ConfigFormat, ScanArgs, SortOrder};
9use config::Config;
10use parser::TodoParser;
11use printer::{OutputFormat, PrintOptions, Printer};
12use scanner::{ScanOptions, ScanResult, Scanner};
13use std::path::PathBuf;
14
15/// Main entry point for the CLI application
16pub fn run() -> Result<()> {
17    let cli = Cli::parse_args();
18
19    // Handle no-color globally
20    if cli.global.no_color || std::env::var("NO_COLOR").is_ok() {
21        colored::control::set_override(false);
22    }
23
24    // Execute the command
25    match cli.get_command() {
26        Commands::Scan(args) => cmd_scan(args, &cli.global),
27        Commands::List(args) => cmd_list(args, &cli.global),
28        Commands::Tags(args) => cmd_tags(args, &cli.global),
29        Commands::Init(args) => cmd_init(args),
30        Commands::Stats(args) => cmd_stats(args, &cli.global),
31    }
32}
33
34/// Execute the scan command
35fn cmd_scan(args: ScanArgs, global: &cli::GlobalOptions) -> Result<()> {
36    let path = args.path.clone().unwrap_or_else(|| PathBuf::from("."));
37    let path = path
38        .canonicalize()
39        .with_context(|| format!("Failed to resolve path: {}", path.display()))?;
40
41    // Load configuration
42    let mut config = load_config(&path, global.config.as_deref())?;
43
44    // Merge CLI options
45    config.merge_with_cli(
46        args.tags.clone(),
47        args.include.clone(),
48        args.exclude.clone(),
49        args.json,
50        args.flat,
51        global.no_color,
52    );
53
54    // Create parser
55    let parser = TodoParser::new(&config.tags, args.case_sensitive);
56
57    // Create scan options
58    let scan_options = ScanOptions {
59        include: config.include.clone(),
60        exclude: config.exclude.clone(),
61        max_depth: args.depth,
62        follow_links: args.follow_links,
63        hidden: args.hidden,
64        threads: 0, // Auto
65        respect_gitignore: true,
66    };
67
68    // Create scanner and scan
69    let scanner = Scanner::new(parser, scan_options);
70    let mut result = scanner.scan(&path)?;
71
72    // Sort results if needed
73    sort_results(&mut result, args.sort);
74
75    // Print results
76    let print_options = PrintOptions {
77        format: if args.json {
78            OutputFormat::Json
79        } else if args.flat {
80            OutputFormat::Flat
81        } else {
82            OutputFormat::Tree
83        },
84        colored: !global.no_color,
85        show_line_numbers: true,
86        full_paths: false,
87        clickable_links: !global.no_color,
88        base_path: Some(path),
89        show_summary: !args.json,
90        group_by_tag: args.group_by_tag,
91    };
92
93    let printer = Printer::new(print_options);
94    printer.print(&result)?;
95
96    Ok(())
97}
98
99/// Execute the list command
100fn cmd_list(args: cli::ListArgs, global: &cli::GlobalOptions) -> Result<()> {
101    let path = args.path.clone().unwrap_or_else(|| PathBuf::from("."));
102    let path = path
103        .canonicalize()
104        .with_context(|| format!("Failed to resolve path: {}", path.display()))?;
105
106    // Load configuration
107    let mut config = load_config(&path, global.config.as_deref())?;
108
109    // Merge CLI options
110    config.merge_with_cli(
111        args.tags.clone(),
112        args.include.clone(),
113        args.exclude.clone(),
114        args.json,
115        true, // flat format for list
116        global.no_color,
117    );
118
119    // Create parser
120    let parser = TodoParser::new(&config.tags, args.case_sensitive);
121
122    // Create scan options
123    let scan_options = ScanOptions {
124        include: config.include.clone(),
125        exclude: config.exclude.clone(),
126        ..Default::default()
127    };
128
129    // Create scanner and scan
130    let scanner = Scanner::new(parser, scan_options);
131    let result = scanner.scan(&path)?;
132
133    // Filter by tag if specified
134    let result = if let Some(filter_tag) = &args.filter {
135        result.filter_by_tag(filter_tag)
136    } else {
137        result
138    };
139
140    // Print results
141    let print_options = PrintOptions {
142        format: if args.json {
143            OutputFormat::Json
144        } else {
145            OutputFormat::Flat
146        },
147        colored: !global.no_color,
148        show_line_numbers: true,
149        full_paths: false,
150        clickable_links: !global.no_color,
151        base_path: Some(path),
152        show_summary: !args.json,
153        group_by_tag: false,
154    };
155
156    let printer = Printer::new(print_options);
157    printer.print(&result)?;
158
159    Ok(())
160}
161
162/// Execute the tags command
163fn cmd_tags(args: cli::TagsArgs, global: &cli::GlobalOptions) -> Result<()> {
164    let current_dir = std::env::current_dir()?;
165    let mut config = load_config(&current_dir, global.config.as_deref())?;
166
167    // Handle tag modifications
168    if let Some(new_tag) = &args.add {
169        if !config.tags.iter().any(|t| t.eq_ignore_ascii_case(new_tag)) {
170            config.tags.push(new_tag.to_uppercase());
171            save_config(&config)?;
172            println!("Added tag: {}", new_tag.to_uppercase());
173        } else {
174            println!("Tag already exists: {}", new_tag);
175        }
176        return Ok(());
177    }
178
179    if let Some(remove_tag) = &args.remove {
180        let original_len = config.tags.len();
181        config.tags.retain(|t| !t.eq_ignore_ascii_case(remove_tag));
182        if config.tags.len() < original_len {
183            save_config(&config)?;
184            println!("Removed tag: {}", remove_tag);
185        } else {
186            println!("Tag not found: {}", remove_tag);
187        }
188        return Ok(());
189    }
190
191    if args.reset {
192        config.tags = config::DEFAULT_TAGS.iter().map(|s| s.to_string()).collect();
193        save_config(&config)?;
194        println!("Tags reset to defaults");
195        return Ok(());
196    }
197
198    // Display current tags
199    if args.json {
200        let json = serde_json::json!({
201            "tags": config.tags,
202            "default_tags": config::DEFAULT_TAGS,
203        });
204        println!("{}", serde_json::to_string_pretty(&json)?);
205    } else {
206        use colored::Colorize;
207        println!("{}", "Configured tags:".bold());
208        for tag in &config.tags {
209            if global.no_color {
210                println!("  - {}", tag);
211            } else {
212                let color = parser::Priority::from_tag(tag).to_color();
213                println!("  - {}", tag.color(color));
214            }
215        }
216    }
217
218    Ok(())
219}
220
221/// Execute the init command
222fn cmd_init(args: cli::InitArgs) -> Result<()> {
223    let filename = match args.format {
224        ConfigFormat::Json => ".todorc.json",
225        ConfigFormat::Yaml => ".todorc.yaml",
226    };
227
228    let path = PathBuf::from(filename);
229
230    if path.exists() && !args.force {
231        anyhow::bail!(
232            "Config file {} already exists. Use --force to overwrite.",
233            filename
234        );
235    }
236
237    let config = Config::new();
238    config.save(&path)?;
239
240    println!("Created configuration file: {}", filename);
241    println!("\nYou can customize the following settings:");
242    println!("  - tags: List of tags to search for");
243    println!("  - include: File patterns to include");
244    println!("  - exclude: File patterns to exclude");
245    println!("  - json: Default to JSON output");
246    println!("  - flat: Default to flat output");
247
248    Ok(())
249}
250
251/// Execute the stats command
252fn cmd_stats(args: cli::StatsArgs, global: &cli::GlobalOptions) -> Result<()> {
253    let path = args.path.clone().unwrap_or_else(|| PathBuf::from("."));
254    let path = path
255        .canonicalize()
256        .with_context(|| format!("Failed to resolve path: {}", path.display()))?;
257
258    // Load configuration
259    let config = load_config(&path, global.config.as_deref())?;
260
261    // Get tags from CLI or config
262    let tags = args.tags.clone().unwrap_or(config.tags.clone());
263
264    // Create parser and scanner
265    let parser = TodoParser::new(&tags, false);
266    let scanner = Scanner::new(parser, ScanOptions::default());
267    let result = scanner.scan(&path)?;
268
269    if args.json {
270        let stats = serde_json::json!({
271            "total_items": result.total_count,
272            "files_with_todos": result.files_with_todos,
273            "files_scanned": result.files_scanned,
274            "tag_counts": result.tag_counts,
275            "items_per_file": if result.files_with_todos > 0 {
276                result.total_count as f64 / result.files_with_todos as f64
277            } else {
278                0.0
279            },
280        });
281        println!("{}", serde_json::to_string_pretty(&stats)?);
282    } else {
283        use colored::Colorize;
284
285        println!("{}", "TODO Statistics".bold().underline());
286        println!();
287        println!("  Total items:        {}", result.total_count);
288        println!("  Files with TODOs:   {}", result.files_with_todos);
289        println!("  Files scanned:      {}", result.files_scanned);
290
291        if result.files_with_todos > 0 {
292            let avg = result.total_count as f64 / result.files_with_todos as f64;
293            println!("  Avg items per file: {:.2}", avg);
294        }
295
296        println!();
297        println!("{}", "By Tag:".bold());
298
299        let mut tags: Vec<_> = result.tag_counts.iter().collect();
300        tags.sort_by(|a, b| b.1.cmp(a.1));
301
302        for (tag, count) in tags {
303            let percentage = if result.total_count > 0 {
304                (*count as f64 / result.total_count as f64) * 100.0
305            } else {
306                0.0
307            };
308
309            let bar_width = 20;
310            let filled = ((percentage / 100.0) * bar_width as f64) as usize;
311            let bar: String = "█".repeat(filled) + &"░".repeat(bar_width - filled);
312
313            if global.no_color {
314                println!("  {:<8} {:>4} ({:>5.1}%) {}", tag, count, percentage, bar);
315            } else {
316                let color = parser::Priority::from_tag(tag).to_color();
317                println!(
318                    "  {:<8} {:>4} ({:>5.1}%) {}",
319                    tag.color(color),
320                    count,
321                    percentage,
322                    bar.dimmed()
323                );
324            }
325        }
326    }
327
328    Ok(())
329}
330
331/// Load configuration from file or use defaults
332fn load_config(path: &std::path::Path, config_path: Option<&std::path::Path>) -> Result<Config> {
333    if let Some(config_path) = config_path {
334        return Config::load_from_file(config_path);
335    }
336
337    match Config::load(path)? {
338        Some(config) => Ok(config),
339        None => Ok(Config::new()),
340    }
341}
342
343/// Save configuration to the default config file
344fn save_config(config: &Config) -> Result<()> {
345    let current_dir = std::env::current_dir()?;
346
347    // Try to find existing config file
348    let config_files = [
349        current_dir.join(".todorc"),
350        current_dir.join(".todorc.json"),
351        current_dir.join(".todorc.yaml"),
352        current_dir.join(".todorc.yml"),
353    ];
354
355    for path in &config_files {
356        if path.exists() {
357            return config.save(path);
358        }
359    }
360
361    // Create new config file
362    let path = current_dir.join(".todorc.json");
363    config.save(&path)
364}
365
366/// Sort scan results based on the specified order
367fn sort_results(result: &mut ScanResult, sort: SortOrder) {
368    match sort {
369        SortOrder::File => {
370            // Already sorted by file path
371        }
372
373        SortOrder::Line => {
374            // Sort items within each file by line number
375            for items in result.files.values_mut() {
376                items.sort_by_key(|item| item.line);
377            }
378        }
379        SortOrder::Priority => {
380            // Sort items within each file by priority
381            for items in result.files.values_mut() {
382                items.sort_by_key(|item| std::cmp::Reverse(item.priority));
383            }
384        }
385    }
386}
387
388use anyhow::Context;
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393    use serial_test::serial;
394    use std::fs;
395    use tempfile::TempDir;
396
397    fn create_test_project() -> TempDir {
398        let temp_dir = TempDir::new().unwrap();
399
400        // Create some test files with TODOs
401        fs::write(
402            temp_dir.path().join("main.rs"),
403            r#"
404fn main() {
405    // TODO: Implement main logic
406    println!("Hello, world!");
407    // FIXME: This is broken
408}
409"#,
410        )
411        .unwrap();
412
413        fs::write(
414            temp_dir.path().join("lib.rs"),
415            r#"
416// NOTE: This is a library module
417pub fn hello() {
418    // TODO(alice): Add documentation
419    // BUG: Memory leak here
420}
421"#,
422        )
423        .unwrap();
424
425        fs::create_dir(temp_dir.path().join("src")).unwrap();
426        fs::write(
427            temp_dir.path().join("src/utils.rs"),
428            r#"
429// HACK: Temporary workaround
430fn temp_fix() {}
431"#,
432        )
433        .unwrap();
434
435        temp_dir
436    }
437
438    #[test]
439    fn test_scan_finds_todos() {
440        let temp_dir = create_test_project();
441
442        let tags: Vec<String> = config::DEFAULT_TAGS.iter().map(|s| s.to_string()).collect();
443        let parser = TodoParser::new(&tags, false);
444        let scanner = Scanner::new(parser, ScanOptions::default());
445
446        let result = scanner.scan(temp_dir.path()).unwrap();
447
448        assert!(result.total_count >= 5);
449        assert!(result.files_with_todos >= 2);
450    }
451
452    #[test]
453    fn test_config_loading() {
454        let temp_dir = TempDir::new().unwrap();
455
456        // Create a config file
457        let config_content = r#"{
458            "tags": ["CUSTOM", "TEST"],
459            "include": ["*.rs"],
460            "exclude": ["target/**"]
461        }"#;
462
463        fs::write(temp_dir.path().join(".todorc.json"), config_content).unwrap();
464
465        let config = load_config(temp_dir.path(), None).unwrap();
466
467        assert_eq!(config.tags, vec!["CUSTOM", "TEST"]);
468        assert_eq!(config.include, vec!["*.rs"]);
469    }
470
471    #[test]
472    fn test_sort_by_priority() {
473        let temp_dir = TempDir::new().unwrap();
474
475        fs::write(
476            temp_dir.path().join("test.rs"),
477            r#"
478// NOTE: Low priority
479// TODO: Medium priority
480// BUG: Critical priority
481// HACK: High priority
482"#,
483        )
484        .unwrap();
485
486        let tags: Vec<String> = config::DEFAULT_TAGS.iter().map(|s| s.to_string()).collect();
487        let parser = TodoParser::new(&tags, false);
488        let scanner = Scanner::new(parser, ScanOptions::default());
489
490        let mut result = scanner.scan(temp_dir.path()).unwrap();
491        sort_results(&mut result, SortOrder::Priority);
492
493        // Check that items are sorted by priority within files
494        for items in result.files.values() {
495            for window in items.windows(2) {
496                assert!(window[0].priority >= window[1].priority);
497            }
498        }
499    }
500
501    #[test]
502    fn test_sort_by_file() {
503        let temp_dir = TempDir::new().unwrap();
504
505        fs::write(temp_dir.path().join("test.rs"), "// TODO: Test").unwrap();
506
507        let tags: Vec<String> = config::DEFAULT_TAGS.iter().map(|s| s.to_string()).collect();
508        let parser = TodoParser::new(&tags, false);
509        let scanner = Scanner::new(parser, ScanOptions::default());
510
511        let mut result = scanner.scan(temp_dir.path()).unwrap();
512        // Sort by file should not panic
513        sort_results(&mut result, SortOrder::File);
514
515        assert!(result.total_count >= 1);
516    }
517
518    #[test]
519    fn test_sort_by_line() {
520        let temp_dir = TempDir::new().unwrap();
521
522        fs::write(
523            temp_dir.path().join("test.rs"),
524            r#"
525// TODO: Line 2
526fn main() {}
527// TODO: Line 4
528// TODO: Line 5
529"#,
530        )
531        .unwrap();
532
533        let tags: Vec<String> = config::DEFAULT_TAGS.iter().map(|s| s.to_string()).collect();
534        let parser = TodoParser::new(&tags, false);
535        let scanner = Scanner::new(parser, ScanOptions::default());
536
537        let mut result = scanner.scan(temp_dir.path()).unwrap();
538        sort_results(&mut result, SortOrder::Line);
539
540        // Check that items are sorted by line number within files
541        for items in result.files.values() {
542            for window in items.windows(2) {
543                assert!(window[0].line <= window[1].line);
544            }
545        }
546    }
547
548    #[test]
549    fn test_load_config_with_explicit_path() {
550        let temp_dir = TempDir::new().unwrap();
551
552        let config_content = r#"{"tags": ["EXPLICIT"]}"#;
553        let config_path = temp_dir.path().join("custom.json");
554        fs::write(&config_path, config_content).unwrap();
555
556        let config = load_config(temp_dir.path(), Some(&config_path)).unwrap();
557        assert_eq!(config.tags, vec!["EXPLICIT"]);
558    }
559
560    #[test]
561    fn test_load_config_no_file() {
562        let temp_dir = TempDir::new().unwrap();
563
564        let config = load_config(temp_dir.path(), None).unwrap();
565        // Should return default config
566        assert!(!config.tags.is_empty());
567        assert!(config.tags.contains(&"TODO".to_string()));
568    }
569
570    #[test]
571    #[serial]
572    fn test_save_config_creates_new_file() {
573        let temp_dir = TempDir::new().unwrap();
574        let original_dir = std::env::current_dir().unwrap();
575
576        // Change to temp directory
577        std::env::set_current_dir(temp_dir.path()).unwrap();
578
579        let config = Config::new();
580        let result = save_config(&config);
581
582        // Restore original directory
583        std::env::set_current_dir(original_dir).unwrap();
584
585        assert!(result.is_ok());
586        assert!(temp_dir.path().join(".todorc.json").exists());
587    }
588
589    #[test]
590    #[serial]
591    fn test_save_config_updates_existing() {
592        let temp_dir = TempDir::new().unwrap();
593        let original_dir = std::env::current_dir().unwrap();
594
595        // Get absolute path before changing directories
596        let temp_path = temp_dir.path().to_path_buf();
597
598        // Create existing config file
599        let existing_path = temp_path.join(".todorc.json");
600        fs::write(&existing_path, r#"{"tags": ["OLD"]}"#).unwrap();
601
602        // Change to temp directory
603        std::env::set_current_dir(&temp_path).unwrap();
604
605        let mut config = Config::new();
606        config.tags = vec!["NEW".to_string()];
607        let result = save_config(&config);
608
609        // Restore original directory
610        std::env::set_current_dir(&original_dir).unwrap();
611
612        assert!(result.is_ok());
613
614        // Verify the file was updated
615        let loaded = Config::load_from_file(&existing_path).unwrap();
616        assert_eq!(loaded.tags, vec!["NEW"]);
617    }
618
619    #[test]
620    fn test_save_config_to_yaml_file() {
621        // Test saving config directly to a YAML file (not via save_config)
622        // This avoids directory change issues in parallel tests
623        let temp_dir = TempDir::new().unwrap();
624        let yaml_path = temp_dir.path().join(".todorc.yaml");
625
626        let mut config = Config::new();
627        config.tags = vec!["YAML_TEST".to_string()];
628        config.save(&yaml_path).unwrap();
629
630        // Verify the YAML file was created and can be loaded
631        let loaded = Config::load_from_file(&yaml_path).unwrap();
632        assert_eq!(loaded.tags, vec!["YAML_TEST"]);
633    }
634
635    #[test]
636    fn test_create_test_project_structure() {
637        let temp_dir = create_test_project();
638
639        assert!(temp_dir.path().join("main.rs").exists());
640        assert!(temp_dir.path().join("lib.rs").exists());
641        assert!(temp_dir.path().join("src/utils.rs").exists());
642    }
643
644    #[test]
645    fn test_cmd_scan_basic() {
646        let temp_dir = create_test_project();
647
648        let args = cli::ScanArgs {
649            path: Some(temp_dir.path().to_path_buf()),
650            tags: None,
651            include: None,
652            exclude: None,
653            json: false,
654            flat: false,
655            depth: 0,
656            follow_links: false,
657            hidden: false,
658            case_sensitive: false,
659            sort: cli::SortOrder::File,
660            group_by_tag: false,
661        };
662
663        let global = cli::GlobalOptions {
664            no_color: true,
665            verbose: false,
666            config: None,
667        };
668
669        let result = cmd_scan(args, &global);
670        assert!(result.is_ok());
671    }
672
673    #[test]
674    fn test_cmd_scan_with_json_output() {
675        let temp_dir = create_test_project();
676
677        let args = cli::ScanArgs {
678            path: Some(temp_dir.path().to_path_buf()),
679            tags: Some(vec!["TODO".to_string()]),
680            include: Some(vec!["*.rs".to_string()]),
681            exclude: None,
682            json: true,
683            flat: false,
684            depth: 0,
685            follow_links: false,
686            hidden: false,
687            case_sensitive: true,
688            sort: cli::SortOrder::Priority,
689            group_by_tag: false,
690        };
691
692        let global = cli::GlobalOptions {
693            no_color: true,
694            verbose: false,
695            config: None,
696        };
697
698        let result = cmd_scan(args, &global);
699        assert!(result.is_ok());
700    }
701
702    #[test]
703    fn test_cmd_scan_with_flat_output() {
704        let temp_dir = create_test_project();
705
706        let args = cli::ScanArgs {
707            path: Some(temp_dir.path().to_path_buf()),
708            tags: None,
709            include: None,
710            exclude: Some(vec!["src/**".to_string()]),
711            json: false,
712            flat: true,
713            depth: 1,
714            follow_links: true,
715            hidden: true,
716            case_sensitive: false,
717            sort: cli::SortOrder::Line,
718            group_by_tag: false,
719        };
720
721        let global = cli::GlobalOptions {
722            no_color: false,
723            verbose: false,
724            config: None,
725        };
726
727        let result = cmd_scan(args, &global);
728        assert!(result.is_ok());
729    }
730
731    #[test]
732    fn test_cmd_scan_group_by_tag() {
733        let temp_dir = create_test_project();
734
735        let args = cli::ScanArgs {
736            path: Some(temp_dir.path().to_path_buf()),
737            tags: None,
738            include: None,
739            exclude: None,
740            json: false,
741            flat: false,
742            depth: 0,
743            follow_links: false,
744            hidden: false,
745            case_sensitive: false,
746            sort: cli::SortOrder::File,
747            group_by_tag: true,
748        };
749
750        let global = cli::GlobalOptions {
751            no_color: true,
752            verbose: false,
753            config: None,
754        };
755
756        let result = cmd_scan(args, &global);
757        assert!(result.is_ok());
758    }
759
760    #[test]
761    fn test_cmd_scan_group_by_tag_with_color() {
762        let temp_dir = create_test_project();
763
764        let args = cli::ScanArgs {
765            path: Some(temp_dir.path().to_path_buf()),
766            tags: None,
767            include: None,
768            exclude: None,
769            json: false,
770            flat: false,
771            depth: 0,
772            follow_links: false,
773            hidden: false,
774            case_sensitive: false,
775            sort: cli::SortOrder::File,
776            group_by_tag: true,
777        };
778
779        let global = cli::GlobalOptions {
780            no_color: false,
781            verbose: false,
782            config: None,
783        };
784
785        let result = cmd_scan(args, &global);
786        assert!(result.is_ok());
787    }
788
789    #[test]
790    fn test_cmd_list_basic() {
791        let temp_dir = create_test_project();
792
793        let args = cli::ListArgs {
794            path: Some(temp_dir.path().to_path_buf()),
795            tags: None,
796            include: None,
797            exclude: None,
798            json: false,
799            filter: None,
800            case_sensitive: false,
801        };
802
803        let global = cli::GlobalOptions {
804            no_color: true,
805            verbose: false,
806            config: None,
807        };
808
809        let result = cmd_list(args, &global);
810        assert!(result.is_ok());
811    }
812
813    #[test]
814    fn test_cmd_list_with_filter() {
815        let temp_dir = create_test_project();
816
817        let args = cli::ListArgs {
818            path: Some(temp_dir.path().to_path_buf()),
819            tags: Some(vec!["TODO".to_string(), "FIXME".to_string()]),
820            include: Some(vec!["*.rs".to_string()]),
821            exclude: Some(vec!["src/**".to_string()]),
822            json: false,
823            filter: Some("TODO".to_string()),
824            case_sensitive: true,
825        };
826
827        let global = cli::GlobalOptions {
828            no_color: true,
829            verbose: false,
830            config: None,
831        };
832
833        let result = cmd_list(args, &global);
834        assert!(result.is_ok());
835    }
836
837    #[test]
838    fn test_cmd_list_with_json_output() {
839        let temp_dir = create_test_project();
840
841        let args = cli::ListArgs {
842            path: Some(temp_dir.path().to_path_buf()),
843            tags: None,
844            include: None,
845            exclude: None,
846            json: true,
847            filter: None,
848            case_sensitive: false,
849        };
850
851        let global = cli::GlobalOptions {
852            no_color: false,
853            verbose: false,
854            config: None,
855        };
856
857        let result = cmd_list(args, &global);
858        assert!(result.is_ok());
859    }
860
861    #[test]
862    #[serial]
863    fn test_cmd_tags_display() {
864        let temp_dir = TempDir::new().unwrap();
865        let original_dir = std::env::current_dir().unwrap();
866
867        // Create a config file
868        fs::write(
869            temp_dir.path().join(".todorc.json"),
870            r#"{"tags": ["TODO", "FIXME"]}"#,
871        )
872        .unwrap();
873
874        std::env::set_current_dir(temp_dir.path()).unwrap();
875
876        let args = cli::TagsArgs {
877            json: false,
878            add: None,
879            remove: None,
880            reset: false,
881        };
882
883        let global = cli::GlobalOptions {
884            no_color: true,
885            verbose: false,
886            config: None,
887        };
888
889        let result = cmd_tags(args, &global);
890
891        std::env::set_current_dir(original_dir).unwrap();
892
893        assert!(result.is_ok());
894    }
895
896    #[test]
897    #[serial]
898    fn test_cmd_tags_display_json() {
899        let temp_dir = TempDir::new().unwrap();
900        let original_dir = std::env::current_dir().unwrap();
901
902        fs::write(
903            temp_dir.path().join(".todorc.json"),
904            r#"{"tags": ["TODO", "FIXME"]}"#,
905        )
906        .unwrap();
907
908        std::env::set_current_dir(temp_dir.path()).unwrap();
909
910        let args = cli::TagsArgs {
911            json: true,
912            add: None,
913            remove: None,
914            reset: false,
915        };
916
917        let global = cli::GlobalOptions {
918            no_color: true,
919            verbose: false,
920            config: None,
921        };
922
923        let result = cmd_tags(args, &global);
924
925        std::env::set_current_dir(original_dir).unwrap();
926
927        assert!(result.is_ok());
928    }
929
930    #[test]
931    #[serial]
932    fn test_cmd_tags_display_with_color() {
933        let temp_dir = TempDir::new().unwrap();
934        let original_dir = std::env::current_dir().unwrap();
935
936        fs::write(
937            temp_dir.path().join(".todorc.json"),
938            r#"{"tags": ["TODO", "FIXME", "BUG"]}"#,
939        )
940        .unwrap();
941
942        std::env::set_current_dir(temp_dir.path()).unwrap();
943
944        let args = cli::TagsArgs {
945            json: false,
946            add: None,
947            remove: None,
948            reset: false,
949        };
950
951        let global = cli::GlobalOptions {
952            no_color: false,
953            verbose: false,
954            config: None,
955        };
956
957        let result = cmd_tags(args, &global);
958
959        std::env::set_current_dir(original_dir).unwrap();
960
961        assert!(result.is_ok());
962    }
963
964    #[test]
965    #[serial]
966    fn test_cmd_tags_add_new() {
967        let temp_dir = TempDir::new().unwrap();
968        let original_dir = std::env::current_dir().unwrap();
969
970        fs::write(
971            temp_dir.path().join(".todorc.json"),
972            r#"{"tags": ["TODO"]}"#,
973        )
974        .unwrap();
975
976        std::env::set_current_dir(temp_dir.path()).unwrap();
977
978        let args = cli::TagsArgs {
979            json: false,
980            add: Some("NEWTAG".to_string()),
981            remove: None,
982            reset: false,
983        };
984
985        let global = cli::GlobalOptions {
986            no_color: true,
987            verbose: false,
988            config: None,
989        };
990
991        let result = cmd_tags(args, &global);
992
993        std::env::set_current_dir(original_dir).unwrap();
994
995        assert!(result.is_ok());
996    }
997
998    #[test]
999    #[serial]
1000    fn test_cmd_tags_add_existing() {
1001        let temp_dir = TempDir::new().unwrap();
1002        let original_dir = std::env::current_dir().unwrap();
1003
1004        fs::write(
1005            temp_dir.path().join(".todorc.json"),
1006            r#"{"tags": ["TODO"]}"#,
1007        )
1008        .unwrap();
1009
1010        std::env::set_current_dir(temp_dir.path()).unwrap();
1011
1012        let args = cli::TagsArgs {
1013            json: false,
1014            add: Some("todo".to_string()), // case-insensitive match
1015            remove: None,
1016            reset: false,
1017        };
1018
1019        let global = cli::GlobalOptions {
1020            no_color: true,
1021            verbose: false,
1022            config: None,
1023        };
1024
1025        let result = cmd_tags(args, &global);
1026
1027        std::env::set_current_dir(original_dir).unwrap();
1028
1029        assert!(result.is_ok());
1030    }
1031
1032    #[test]
1033    #[serial]
1034    fn test_cmd_tags_remove_existing() {
1035        let temp_dir = TempDir::new().unwrap();
1036        let original_dir = std::env::current_dir().unwrap();
1037
1038        fs::write(
1039            temp_dir.path().join(".todorc.json"),
1040            r#"{"tags": ["TODO", "FIXME"]}"#,
1041        )
1042        .unwrap();
1043
1044        std::env::set_current_dir(temp_dir.path()).unwrap();
1045
1046        let args = cli::TagsArgs {
1047            json: false,
1048            add: None,
1049            remove: Some("TODO".to_string()),
1050            reset: false,
1051        };
1052
1053        let global = cli::GlobalOptions {
1054            no_color: true,
1055            verbose: false,
1056            config: None,
1057        };
1058
1059        let result = cmd_tags(args, &global);
1060
1061        std::env::set_current_dir(original_dir).unwrap();
1062
1063        assert!(result.is_ok());
1064    }
1065
1066    #[test]
1067    #[serial]
1068    fn test_cmd_tags_remove_nonexistent() {
1069        let temp_dir = TempDir::new().unwrap();
1070        let original_dir = std::env::current_dir().unwrap();
1071
1072        fs::write(
1073            temp_dir.path().join(".todorc.json"),
1074            r#"{"tags": ["TODO"]}"#,
1075        )
1076        .unwrap();
1077
1078        std::env::set_current_dir(temp_dir.path()).unwrap();
1079
1080        let args = cli::TagsArgs {
1081            json: false,
1082            add: None,
1083            remove: Some("NONEXISTENT".to_string()),
1084            reset: false,
1085        };
1086
1087        let global = cli::GlobalOptions {
1088            no_color: true,
1089            verbose: false,
1090            config: None,
1091        };
1092
1093        let result = cmd_tags(args, &global);
1094
1095        std::env::set_current_dir(original_dir).unwrap();
1096
1097        assert!(result.is_ok());
1098    }
1099
1100    #[test]
1101    #[serial]
1102    fn test_cmd_tags_reset() {
1103        let temp_dir = TempDir::new().unwrap();
1104        let original_dir = std::env::current_dir().unwrap();
1105
1106        fs::write(
1107            temp_dir.path().join(".todorc.json"),
1108            r#"{"tags": ["CUSTOM"]}"#,
1109        )
1110        .unwrap();
1111
1112        std::env::set_current_dir(temp_dir.path()).unwrap();
1113
1114        let args = cli::TagsArgs {
1115            json: false,
1116            add: None,
1117            remove: None,
1118            reset: true,
1119        };
1120
1121        let global = cli::GlobalOptions {
1122            no_color: true,
1123            verbose: false,
1124            config: None,
1125        };
1126
1127        let result = cmd_tags(args, &global);
1128
1129        std::env::set_current_dir(original_dir).unwrap();
1130
1131        assert!(result.is_ok());
1132    }
1133
1134    #[test]
1135    #[serial]
1136    fn test_cmd_init_json() {
1137        let temp_dir = TempDir::new().unwrap();
1138        let original_dir = std::env::current_dir().unwrap();
1139
1140        std::env::set_current_dir(temp_dir.path()).unwrap();
1141
1142        let args = cli::InitArgs {
1143            format: cli::ConfigFormat::Json,
1144            force: false,
1145        };
1146
1147        let result = cmd_init(args);
1148
1149        std::env::set_current_dir(original_dir).unwrap();
1150
1151        assert!(result.is_ok());
1152        assert!(temp_dir.path().join(".todorc.json").exists());
1153    }
1154
1155    #[test]
1156    #[serial]
1157    fn test_cmd_init_yaml() {
1158        let temp_dir = TempDir::new().unwrap();
1159        let original_dir = std::env::current_dir().unwrap();
1160
1161        std::env::set_current_dir(temp_dir.path()).unwrap();
1162
1163        let args = cli::InitArgs {
1164            format: cli::ConfigFormat::Yaml,
1165            force: false,
1166        };
1167
1168        let result = cmd_init(args);
1169
1170        std::env::set_current_dir(original_dir).unwrap();
1171
1172        assert!(result.is_ok());
1173        assert!(temp_dir.path().join(".todorc.yaml").exists());
1174    }
1175
1176    #[test]
1177    #[serial]
1178    fn test_cmd_init_already_exists() {
1179        let temp_dir = TempDir::new().unwrap();
1180        let original_dir = std::env::current_dir().unwrap();
1181
1182        // Create existing config
1183        fs::write(temp_dir.path().join(".todorc.json"), "{}").unwrap();
1184
1185        std::env::set_current_dir(temp_dir.path()).unwrap();
1186
1187        let args = cli::InitArgs {
1188            format: cli::ConfigFormat::Json,
1189            force: false,
1190        };
1191
1192        let result = cmd_init(args);
1193
1194        std::env::set_current_dir(original_dir).unwrap();
1195
1196        assert!(result.is_err());
1197    }
1198
1199    #[test]
1200    #[serial]
1201    fn test_cmd_init_force_overwrite() {
1202        let temp_dir = TempDir::new().unwrap();
1203        let original_dir = std::env::current_dir().unwrap();
1204
1205        // Create existing config
1206        fs::write(temp_dir.path().join(".todorc.json"), r#"{"tags": ["OLD"]}"#).unwrap();
1207
1208        std::env::set_current_dir(temp_dir.path()).unwrap();
1209
1210        let args = cli::InitArgs {
1211            format: cli::ConfigFormat::Json,
1212            force: true,
1213        };
1214
1215        let result = cmd_init(args);
1216
1217        std::env::set_current_dir(original_dir).unwrap();
1218
1219        assert!(result.is_ok());
1220    }
1221
1222    #[test]
1223    fn test_cmd_stats_basic() {
1224        let temp_dir = create_test_project();
1225
1226        let args = cli::StatsArgs {
1227            path: Some(temp_dir.path().to_path_buf()),
1228            tags: None,
1229            json: false,
1230        };
1231
1232        let global = cli::GlobalOptions {
1233            no_color: true,
1234            verbose: false,
1235            config: None,
1236        };
1237
1238        let result = cmd_stats(args, &global);
1239        assert!(result.is_ok());
1240    }
1241
1242    #[test]
1243    fn test_cmd_stats_with_json() {
1244        let temp_dir = create_test_project();
1245
1246        let args = cli::StatsArgs {
1247            path: Some(temp_dir.path().to_path_buf()),
1248            tags: Some(vec!["TODO".to_string(), "FIXME".to_string()]),
1249            json: true,
1250        };
1251
1252        let global = cli::GlobalOptions {
1253            no_color: true,
1254            verbose: false,
1255            config: None,
1256        };
1257
1258        let result = cmd_stats(args, &global);
1259        assert!(result.is_ok());
1260    }
1261
1262    #[test]
1263    fn test_cmd_stats_with_color() {
1264        let temp_dir = create_test_project();
1265
1266        let args = cli::StatsArgs {
1267            path: Some(temp_dir.path().to_path_buf()),
1268            tags: None,
1269            json: false,
1270        };
1271
1272        let global = cli::GlobalOptions {
1273            no_color: false,
1274            verbose: false,
1275            config: None,
1276        };
1277
1278        let result = cmd_stats(args, &global);
1279        assert!(result.is_ok());
1280    }
1281
1282    #[test]
1283    fn test_cmd_stats_empty_project() {
1284        let temp_dir = TempDir::new().unwrap();
1285
1286        // Create a file with no TODOs
1287        fs::write(temp_dir.path().join("empty.rs"), "fn main() {}").unwrap();
1288
1289        let args = cli::StatsArgs {
1290            path: Some(temp_dir.path().to_path_buf()),
1291            tags: None,
1292            json: false,
1293        };
1294
1295        let global = cli::GlobalOptions {
1296            no_color: true,
1297            verbose: false,
1298            config: None,
1299        };
1300
1301        let result = cmd_stats(args, &global);
1302        assert!(result.is_ok());
1303    }
1304
1305    #[test]
1306    fn test_cmd_stats_empty_project_json() {
1307        let temp_dir = TempDir::new().unwrap();
1308
1309        // Create a file with no TODOs - tests the 0.0 edge case for items_per_file
1310        fs::write(temp_dir.path().join("empty.rs"), "fn main() {}").unwrap();
1311
1312        let args = cli::StatsArgs {
1313            path: Some(temp_dir.path().to_path_buf()),
1314            tags: None,
1315            json: true,
1316        };
1317
1318        let global = cli::GlobalOptions {
1319            no_color: true,
1320            verbose: false,
1321            config: None,
1322        };
1323
1324        let result = cmd_stats(args, &global);
1325        assert!(result.is_ok());
1326    }
1327
1328    #[test]
1329    fn test_cmd_stats_zero_percentage() {
1330        let temp_dir = TempDir::new().unwrap();
1331
1332        // Create a completely empty directory (no files at all)
1333        // This tests the 0.0 percentage edge case
1334
1335        let args = cli::StatsArgs {
1336            path: Some(temp_dir.path().to_path_buf()),
1337            tags: Some(vec!["NONEXISTENT".to_string()]),
1338            json: false,
1339        };
1340
1341        let global = cli::GlobalOptions {
1342            no_color: true,
1343            verbose: false,
1344            config: None,
1345        };
1346
1347        let result = cmd_stats(args, &global);
1348        assert!(result.is_ok());
1349    }
1350
1351    #[test]
1352    fn test_cmd_stats_zero_percentage_with_color() {
1353        let temp_dir = TempDir::new().unwrap();
1354
1355        // Create a completely empty directory (no files at all)
1356        // This tests the 0.0 percentage edge case with color output
1357
1358        let args = cli::StatsArgs {
1359            path: Some(temp_dir.path().to_path_buf()),
1360            tags: Some(vec!["NONEXISTENT".to_string()]),
1361            json: false,
1362        };
1363
1364        let global = cli::GlobalOptions {
1365            no_color: false,
1366            verbose: false,
1367            config: None,
1368        };
1369
1370        let result = cmd_stats(args, &global);
1371        assert!(result.is_ok());
1372    }
1373
1374    #[test]
1375    fn test_cmd_stats_with_config() {
1376        let temp_dir = create_test_project();
1377
1378        // Create config file
1379        fs::write(
1380            temp_dir.path().join(".todorc.json"),
1381            r#"{"tags": ["TODO", "FIXME", "NOTE"]}"#,
1382        )
1383        .unwrap();
1384
1385        let args = cli::StatsArgs {
1386            path: Some(temp_dir.path().to_path_buf()),
1387            tags: None,
1388            json: false,
1389        };
1390
1391        let global = cli::GlobalOptions {
1392            no_color: true,
1393            verbose: false,
1394            config: None,
1395        };
1396
1397        let result = cmd_stats(args, &global);
1398        assert!(result.is_ok());
1399    }
1400
1401    #[test]
1402    fn test_cmd_scan_with_config_file() {
1403        let temp_dir = create_test_project();
1404
1405        // Create config file
1406        let config_path = temp_dir.path().join("custom-config.json");
1407        fs::write(&config_path, r#"{"tags": ["TODO", "CUSTOM"]}"#).unwrap();
1408
1409        let args = cli::ScanArgs {
1410            path: Some(temp_dir.path().to_path_buf()),
1411            tags: None,
1412            include: None,
1413            exclude: None,
1414            json: false,
1415            flat: false,
1416            depth: 0,
1417            follow_links: false,
1418            hidden: false,
1419            case_sensitive: false,
1420            sort: cli::SortOrder::File,
1421            group_by_tag: false,
1422        };
1423
1424        let global = cli::GlobalOptions {
1425            no_color: true,
1426            verbose: false,
1427            config: Some(config_path),
1428        };
1429
1430        let result = cmd_scan(args, &global);
1431        assert!(result.is_ok());
1432    }
1433
1434    #[test]
1435    fn test_cmd_list_with_config_file() {
1436        let temp_dir = create_test_project();
1437
1438        // Create config file
1439        let config_path = temp_dir.path().join("custom-config.json");
1440        fs::write(&config_path, r#"{"tags": ["TODO"]}"#).unwrap();
1441
1442        let args = cli::ListArgs {
1443            path: Some(temp_dir.path().to_path_buf()),
1444            tags: None,
1445            include: None,
1446            exclude: None,
1447            json: false,
1448            filter: None,
1449            case_sensitive: false,
1450        };
1451
1452        let global = cli::GlobalOptions {
1453            no_color: true,
1454            verbose: false,
1455            config: Some(config_path),
1456        };
1457
1458        let result = cmd_list(args, &global);
1459        assert!(result.is_ok());
1460    }
1461
1462    #[test]
1463    #[serial]
1464    fn test_save_config_yaml_existing() {
1465        let temp_dir = TempDir::new().unwrap();
1466        let original_dir = std::env::current_dir().unwrap();
1467
1468        // Create existing YAML config file
1469        fs::write(temp_dir.path().join(".todorc.yaml"), "tags:\n  - OLD").unwrap();
1470
1471        std::env::set_current_dir(temp_dir.path()).unwrap();
1472
1473        let mut config = Config::new();
1474        config.tags = vec!["UPDATED".to_string()];
1475        let result = save_config(&config);
1476
1477        std::env::set_current_dir(original_dir).unwrap();
1478
1479        assert!(result.is_ok());
1480    }
1481
1482    #[test]
1483    #[serial]
1484    fn test_save_config_yml_existing() {
1485        let temp_dir = TempDir::new().unwrap();
1486        let original_dir = std::env::current_dir().unwrap();
1487
1488        // Create existing YML config file
1489        fs::write(temp_dir.path().join(".todorc.yml"), "tags:\n  - OLD").unwrap();
1490
1491        std::env::set_current_dir(temp_dir.path()).unwrap();
1492
1493        let mut config = Config::new();
1494        config.tags = vec!["UPDATED".to_string()];
1495        let result = save_config(&config);
1496
1497        std::env::set_current_dir(original_dir).unwrap();
1498
1499        assert!(result.is_ok());
1500    }
1501
1502    #[test]
1503    #[serial]
1504    fn test_save_config_todorc_existing() {
1505        let temp_dir = TempDir::new().unwrap();
1506        let original_dir = std::env::current_dir().unwrap();
1507
1508        // Create existing .todorc file (without extension)
1509        fs::write(temp_dir.path().join(".todorc"), r#"{"tags": ["OLD"]}"#).unwrap();
1510
1511        std::env::set_current_dir(temp_dir.path()).unwrap();
1512
1513        let mut config = Config::new();
1514        config.tags = vec!["UPDATED".to_string()];
1515        let result = save_config(&config);
1516
1517        std::env::set_current_dir(original_dir).unwrap();
1518
1519        assert!(result.is_ok());
1520    }
1521}