todo_tree/
lib.rs

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