context_builder/
lib.rs

1use chrono::Utc;
2use clap::{CommandFactory, Parser};
3
4use std::fs;
5use std::io::{self, Write};
6use std::path::{Path, PathBuf};
7use std::time::Instant;
8
9pub mod cache;
10pub mod cli;
11pub mod config;
12pub mod config_resolver;
13pub mod diff;
14pub mod file_utils;
15pub mod markdown;
16pub mod state;
17pub mod token_count;
18pub mod tree;
19
20use std::fs::File;
21
22use cache::CacheManager;
23use cli::Args;
24use config::{Config, load_config_from_path};
25use diff::render_per_file_diffs;
26use file_utils::{collect_files, confirm_overwrite, confirm_processing};
27use markdown::generate_markdown;
28use state::{ProjectState, StateComparison};
29use token_count::{count_file_tokens, count_tree_tokens, estimate_tokens};
30use tree::{build_file_tree, print_tree};
31
32/// Configuration for diff operations
33#[derive(Debug, Clone)]
34pub struct DiffConfig {
35    pub context_lines: usize,
36    pub enabled: bool,
37    pub diff_only: bool,
38}
39
40impl Default for DiffConfig {
41    fn default() -> Self {
42        Self {
43            context_lines: 3,
44            enabled: false,
45            diff_only: false,
46        }
47    }
48}
49
50pub trait Prompter {
51    fn confirm_processing(&self, file_count: usize) -> io::Result<bool>;
52    fn confirm_overwrite(&self, file_path: &str) -> io::Result<bool>;
53}
54
55pub struct DefaultPrompter;
56
57impl Prompter for DefaultPrompter {
58    fn confirm_processing(&self, file_count: usize) -> io::Result<bool> {
59        confirm_processing(file_count)
60    }
61    fn confirm_overwrite(&self, file_path: &str) -> io::Result<bool> {
62        confirm_overwrite(file_path)
63    }
64}
65
66pub fn run_with_args(args: Args, config: Config, prompter: &impl Prompter) -> io::Result<()> {
67    let start_time = Instant::now();
68
69    let silent = std::env::var("CB_SILENT")
70        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
71        .unwrap_or(false);
72
73    // Use the finalized args passed in from run()
74    let mut final_args = args;
75    // Resolve base path. If input is '.' but current working directory lost the project context
76    // (no context-builder.toml), attempt to infer project root from output path (parent of 'output' dir).
77    let mut resolved_base = PathBuf::from(&final_args.input);
78    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
79    if resolved_base == Path::new(".")
80        && !cwd.join("context-builder.toml").exists()
81        && let Some(output_parent) = Path::new(&final_args.output).parent()
82        && output_parent
83            .file_name()
84            .map(|n| n == "output")
85            .unwrap_or(false)
86        && let Some(project_root) = output_parent.parent()
87        && project_root.join("context-builder.toml").exists()
88    {
89        resolved_base = project_root.to_path_buf();
90    }
91    let base_path = resolved_base.as_path();
92
93    if !base_path.exists() || !base_path.is_dir() {
94        if !silent {
95            eprintln!(
96                "Error: The specified input directory '{}' does not exist or is not a directory.",
97                final_args.input
98            );
99        }
100        return Err(io::Error::new(
101            io::ErrorKind::NotFound,
102            format!(
103                "Input directory '{}' does not exist or is not a directory",
104                final_args.input
105            ),
106        ));
107    }
108
109    // Create diff configuration from config
110    let diff_config = if config.auto_diff.unwrap_or(false) {
111        Some(DiffConfig {
112            context_lines: config.diff_context_lines.unwrap_or(3),
113            enabled: true,
114            diff_only: final_args.diff_only,
115        })
116    } else {
117        None
118    };
119
120    if !final_args.preview
121        && !final_args.token_count
122        && Path::new(&final_args.output).exists()
123        && !final_args.yes
124        && !prompter.confirm_overwrite(&final_args.output)?
125    {
126        if !silent {
127            println!("Operation cancelled.");
128        }
129        return Err(io::Error::new(
130            io::ErrorKind::Interrupted,
131            "Operation cancelled by user",
132        ));
133    }
134
135    let files = collect_files(base_path, &final_args.filter, &final_args.ignore)?;
136    let debug_config = std::env::var("CB_DEBUG_CONFIG").is_ok();
137    if debug_config {
138        eprintln!("[DEBUG][CONFIG] Args: {:?}", final_args);
139        eprintln!("[DEBUG][CONFIG] Raw Config: {:?}", config);
140        eprintln!("[DEBUG][CONFIG] Collected {} files", files.len());
141        for f in &files {
142            eprintln!("[DEBUG][CONFIG]  - {}", f.path().display());
143        }
144    }
145    let file_tree = build_file_tree(&files, base_path);
146
147    if final_args.preview {
148        if !silent {
149            println!("\n# File Tree Structure (Preview)\n");
150            print_tree(&file_tree, 0);
151        }
152        if !final_args.token_count {
153            return Ok(());
154        }
155    }
156
157    if final_args.token_count {
158        if !silent {
159            println!("\n# Token Count Estimation\n");
160            let mut total_tokens = 0;
161            total_tokens += estimate_tokens("# Directory Structure Report\n\n");
162            if !final_args.filter.is_empty() {
163                total_tokens += estimate_tokens(&format!(
164                    "This document contains files from the `{}` directory with extensions: {} \n",
165                    final_args.input,
166                    final_args.filter.join(", ")
167                ));
168            } else {
169                total_tokens += estimate_tokens(&format!(
170                    "This document contains all files from the `{}` directory, optimized for LLM consumption.\n",
171                    final_args.input
172                ));
173            }
174            if !final_args.ignore.is_empty() {
175                total_tokens += estimate_tokens(&format!(
176                    "Custom ignored patterns: {} \n",
177                    final_args.ignore.join(", ")
178                ));
179            }
180            total_tokens += estimate_tokens(&format!(
181                "Processed at: {}\n\n",
182                Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
183            ));
184            total_tokens += estimate_tokens("## File Tree Structure\n\n");
185            let tree_tokens = count_tree_tokens(&file_tree, 0);
186            total_tokens += tree_tokens;
187            let file_tokens: usize = files
188                .iter()
189                .map(|entry| count_file_tokens(base_path, entry, final_args.line_numbers))
190                .sum();
191            total_tokens += file_tokens;
192            println!("Estimated total tokens: {}", total_tokens);
193            println!("File tree tokens: {}", tree_tokens);
194            println!("File content tokens: {}", file_tokens);
195        }
196        return Ok(());
197    }
198
199    if !final_args.yes && !prompter.confirm_processing(files.len())? {
200        if !silent {
201            println!("Operation cancelled.");
202        }
203        return Err(io::Error::new(
204            io::ErrorKind::Interrupted,
205            "Operation cancelled by user",
206        ));
207    }
208
209    // Merge config-driven flags into final_args when the user did not explicitly enable them
210    // (we cannot distinguish CLI-provided false vs default false, mirroring test logic which
211    // only overwrites when the current flag is false). This ensures subsequent formatting
212    // (e.g., line numbers) reflects a config change that invalidates the cache.
213    if let Some(cfg_ln) = config.line_numbers {
214        final_args.line_numbers = cfg_ln;
215    }
216    if let Some(cfg_diff_only) = config.diff_only {
217        final_args.diff_only = cfg_diff_only;
218    }
219
220    if config.auto_diff.unwrap_or(false) {
221        // Build an effective config that mirrors the *actual* operational settings coming
222        // from resolved CLI args (filters/ignores/line_numbers). This ensures the
223        // configuration hash used for cache invalidation reflects real behavior and
224        // stays consistent across runs even when values originate from CLI not file.
225        let mut effective_config = config.clone();
226        // Normalize filter/ignore/line_numbers into config so hashing sees them
227        if !final_args.filter.is_empty() {
228            effective_config.filter = Some(final_args.filter.clone());
229        }
230        if !final_args.ignore.is_empty() {
231            effective_config.ignore = Some(final_args.ignore.clone());
232        }
233        effective_config.line_numbers = Some(final_args.line_numbers);
234
235        // 1. Create current project state
236        let current_state = ProjectState::from_files(
237            &files,
238            base_path,
239            &effective_config,
240            final_args.line_numbers,
241        )?;
242
243        // 2. Initialize cache manager and load previous state
244        let cache_manager = CacheManager::new(base_path, &effective_config);
245        let previous_state = match cache_manager.read_cache() {
246            Ok(state) => state,
247            Err(e) => {
248                if !silent {
249                    eprintln!(
250                        "Warning: Failed to read cache (proceeding without diff): {}",
251                        e
252                    );
253                }
254                None
255            }
256        };
257
258        let diff_cfg = diff_config.as_ref().unwrap();
259
260        // 3. Determine whether we should invalidate (ignore) previous state
261        let effective_previous = if let Some(prev) = previous_state.as_ref() {
262            if prev.config_hash != current_state.config_hash {
263                // Config change => treat as initial state (invalidate diff)
264                None
265            } else {
266                Some(prev)
267            }
268        } else {
269            None
270        };
271
272        // 4. Compare states and generate diff if an effective previous state exists
273        let comparison = effective_previous.map(|prev| current_state.compare_with(prev));
274
275        let debug_autodiff = std::env::var("CB_DEBUG_AUTODIFF").is_ok();
276        if debug_autodiff {
277            eprintln!(
278                "[DEBUG][AUTODIFF] cache file: {}",
279                cache_manager.debug_cache_file_path().display()
280            );
281            eprintln!(
282                "[DEBUG][AUTODIFF] config_hash current={} prev={:?} invalidated={}",
283                current_state.config_hash,
284                previous_state.as_ref().map(|s| s.config_hash.clone()),
285                effective_previous.is_none() && previous_state.is_some()
286            );
287            eprintln!("[DEBUG][AUTODIFF] effective_config: {:?}", effective_config);
288            if let Some(prev) = previous_state.as_ref() {
289                eprintln!("[DEBUG][AUTODIFF] raw previous files: {}", prev.files.len());
290            }
291            if let Some(prev) = effective_previous {
292                eprintln!(
293                    "[DEBUG][AUTODIFF] effective previous files: {}",
294                    prev.files.len()
295                );
296                for k in prev.files.keys() {
297                    eprintln!("  PREV: {}", k.display());
298                }
299            }
300            eprintln!(
301                "[DEBUG][AUTODIFF] current files: {}",
302                current_state.files.len()
303            );
304            for k in current_state.files.keys() {
305                eprintln!("  CURR: {}", k.display());
306            }
307        }
308
309        // 4. Generate markdown with diff annotations
310        let final_doc = generate_markdown_with_diff(
311            &current_state,
312            comparison.as_ref(),
313            &final_args,
314            &file_tree,
315            diff_cfg,
316        )?;
317
318        // 5. Write output
319        let output_path = Path::new(&final_args.output);
320        if let Some(parent) = output_path.parent()
321            && !parent.exists()
322            && let Err(e) = fs::create_dir_all(parent)
323        {
324            return Err(io::Error::other(format!(
325                "Failed to create output directory {}: {}",
326                parent.display(),
327                e
328            )));
329        }
330        let mut final_output = fs::File::create(output_path)?;
331        final_output.write_all(final_doc.as_bytes())?;
332
333        // 6. Update cache with current state
334        if let Err(e) = cache_manager.write_cache(&current_state)
335            && !silent
336        {
337            eprintln!("Warning: failed to update state cache: {}", e);
338        }
339
340        let duration = start_time.elapsed();
341        if !silent {
342            if let Some(comp) = &comparison {
343                if comp.summary.has_changes() {
344                    println!(
345                        "Documentation created successfully with {} changes: {}",
346                        comp.summary.total_changes, final_args.output
347                    );
348                } else {
349                    println!(
350                        "Documentation created successfully (no changes detected): {}",
351                        final_args.output
352                    );
353                }
354            } else {
355                println!(
356                    "Documentation created successfully (initial state): {}",
357                    final_args.output
358                );
359            }
360            println!("Processing time: {:.2?}", duration);
361        }
362        return Ok(());
363    }
364
365    // Standard (non auto-diff) generation
366    generate_markdown(
367        &final_args.output,
368        &final_args.input,
369        &final_args.filter,
370        &final_args.ignore,
371        &file_tree,
372        &files,
373        base_path,
374        final_args.line_numbers,
375        config.encoding_strategy.as_deref(),
376    )?;
377
378    let duration = start_time.elapsed();
379    if !silent {
380        println!("Documentation created successfully: {}", final_args.output);
381        println!("Processing time: {:.2?}", duration);
382    }
383
384    Ok(())
385}
386
387/// Generate markdown document with diff annotations
388fn generate_markdown_with_diff(
389    current_state: &ProjectState,
390    comparison: Option<&StateComparison>,
391    args: &Args,
392    file_tree: &tree::FileTree,
393    diff_config: &DiffConfig,
394) -> io::Result<String> {
395    let mut output = String::new();
396
397    // Header
398    output.push_str("# Directory Structure Report\n\n");
399
400    // Basic project info
401    output.push_str(&format!(
402        "**Project:** {}\n",
403        current_state.metadata.project_name
404    ));
405    output.push_str(&format!("**Generated:** {}\n", current_state.timestamp));
406
407    if !args.filter.is_empty() {
408        output.push_str(&format!("**Filters:** {}\n", args.filter.join(", ")));
409    }
410
411    if !args.ignore.is_empty() {
412        output.push_str(&format!("**Ignored:** {}\n", args.ignore.join(", ")));
413    }
414
415    output.push('\n');
416
417    // Change summary + sections if we have a comparison
418    if let Some(comp) = comparison {
419        if comp.summary.has_changes() {
420            output.push_str(&comp.summary.to_markdown());
421
422            // Collect added files once so we can reuse for both diff_only logic and potential numbering.
423            let added_files: Vec<_> = comp
424                .file_diffs
425                .iter()
426                .filter(|d| matches!(d.status, diff::PerFileStatus::Added))
427                .collect();
428
429            if diff_config.diff_only && !added_files.is_empty() {
430                output.push_str("## Added Files\n\n");
431                for added in added_files {
432                    output.push_str(&format!("### File: `{}`\n\n", added.path));
433                    output.push_str("_Status: Added_\n\n");
434                    // Reconstruct content from + lines.
435                    let mut lines: Vec<String> = Vec::new();
436                    for line in added.diff.lines() {
437                        if let Some(rest) = line.strip_prefix('+') {
438                            lines.push(rest.trim_start().to_string());
439                        }
440                    }
441                    output.push_str("```text\n");
442                    if args.line_numbers {
443                        for (idx, l) in lines.iter().enumerate() {
444                            output.push_str(&format!("{:>4} | {}\n", idx + 1, l));
445                        }
446                    } else {
447                        for l in lines {
448                            output.push_str(&l);
449                            output.push('\n');
450                        }
451                    }
452                    output.push_str("```\n\n");
453                }
454            }
455
456            // Always include a unified diff section header so downstream tooling/tests can rely on it
457            let changed_diffs: Vec<diff::PerFileDiff> = comp
458                .file_diffs
459                .iter()
460                .filter(|d| d.is_changed())
461                .cloned()
462                .collect();
463            if !changed_diffs.is_empty() {
464                output.push_str("## File Differences\n\n");
465                let diff_markdown = render_per_file_diffs(&changed_diffs);
466                output.push_str(&diff_markdown);
467            }
468        } else {
469            output.push_str("## No Changes Detected\n\n");
470        }
471    }
472
473    // File tree
474    output.push_str("## File Tree Structure\n\n");
475    let mut tree_output = Vec::new();
476    tree::write_tree_to_file(&mut tree_output, file_tree, 0)?;
477    output.push_str(&String::from_utf8_lossy(&tree_output));
478    output.push('\n');
479
480    // File contents (unless diff_only mode)
481    if !diff_config.diff_only {
482        output.push_str("## File Contents\n\n");
483
484        for (path, file_state) in &current_state.files {
485            output.push_str(&format!("### File: `{}`\n\n", path.display()));
486            output.push_str(&format!("- Size: {} bytes\n", file_state.size));
487            output.push_str(&format!("- Modified: {:?}\n\n", file_state.modified));
488
489            // Determine language from file extension
490            let extension = path.extension().and_then(|s| s.to_str()).unwrap_or("text");
491            let language = match extension {
492                "rs" => "rust",
493                "js" => "javascript",
494                "ts" => "typescript",
495                "py" => "python",
496                "json" => "json",
497                "toml" => "toml",
498                "md" => "markdown",
499                "yaml" | "yml" => "yaml",
500                "html" => "html",
501                "css" => "css",
502                _ => extension,
503            };
504
505            output.push_str(&format!("```{}\n", language));
506
507            if args.line_numbers {
508                for (i, line) in file_state.content.lines().enumerate() {
509                    output.push_str(&format!("{:>4} | {}\n", i + 1, line));
510                }
511            } else {
512                output.push_str(&file_state.content);
513                if !file_state.content.ends_with('\n') {
514                    output.push('\n');
515                }
516            }
517
518            output.push_str("```\n\n");
519        }
520    }
521
522    Ok(output)
523}
524
525pub fn run() -> io::Result<()> {
526    env_logger::init();
527    let args = Args::parse();
528
529    // Handle init command first
530    if args.init {
531        return init_config();
532    }
533
534    // Determine project root first
535    let project_root = Path::new(&args.input);
536    let config = load_config_from_path(project_root);
537
538    // Handle early clear-cache request (runs even if no config or other args)
539    if args.clear_cache {
540        let cache_path = project_root.join(".context-builder").join("cache");
541        if cache_path.exists() {
542            match fs::remove_dir_all(&cache_path) {
543                Ok(()) => println!("Cache cleared: {}", cache_path.display()),
544                Err(e) => eprintln!("Failed to clear cache ({}): {}", cache_path.display(), e),
545            }
546        } else {
547            println!("No cache directory found at {}", cache_path.display());
548        }
549        return Ok(());
550    }
551
552    if std::env::args().len() == 1 && config.is_none() {
553        Args::command().print_help()?;
554        return Ok(());
555    }
556
557    // Resolve final configuration using the new config resolver
558    let resolution = crate::config_resolver::resolve_final_config(args, config.clone());
559
560    // Print warnings if any
561    let silent = std::env::var("CB_SILENT")
562        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
563        .unwrap_or(false);
564
565    if !silent {
566        for warning in &resolution.warnings {
567            eprintln!("Warning: {}", warning);
568        }
569    }
570
571    // Convert resolved config back to Args for run_with_args
572    let final_args = Args {
573        input: resolution.config.input,
574        output: resolution.config.output,
575        filter: resolution.config.filter,
576        ignore: resolution.config.ignore,
577        line_numbers: resolution.config.line_numbers,
578        preview: resolution.config.preview,
579        token_count: resolution.config.token_count,
580        yes: resolution.config.yes,
581        diff_only: resolution.config.diff_only,
582        clear_cache: resolution.config.clear_cache,
583        init: false,
584    };
585
586    // Create final Config with resolved values
587    let final_config = Config {
588        auto_diff: Some(resolution.config.auto_diff),
589        diff_context_lines: Some(resolution.config.diff_context_lines),
590        ..config.unwrap_or_default()
591    };
592
593    run_with_args(final_args, final_config, &DefaultPrompter)
594}
595
596/// Initialize a new context-builder.toml config file in the current directory with sensible defaults
597fn init_config() -> io::Result<()> {
598    let config_path = Path::new("context-builder.toml");
599
600    if config_path.exists() {
601        println!("Config file already exists at {}", config_path.display());
602        println!("If you want to replace it, please remove it manually first.");
603        return Ok(());
604    }
605
606    let default_config_content = r#"# Context Builder Configuration File
607# This file was generated with sensible defaults
608
609# Output file name (or base name when timestamped_output is true)
610output = "context.md"
611
612# Optional folder to place the generated output file(s) in
613output_folder = "docs"
614
615# Append a UTC timestamp to the output file name (before extension)
616timestamped_output = true
617
618# Enable automatic diff generation (requires timestamped_output = true)
619auto_diff = true
620
621# Emit only change summary + modified file diffs (no full file bodies)
622diff_only = false
623
624# File extensions to include (no leading dot, e.g. "rs", "toml")
625filter = ["rs", "toml"]
626
627# File / directory names to ignore (exact name matches)
628ignore = ["docs", "target", ".git", "node_modules"]
629
630# Add line numbers to code blocks
631line_numbers = false
632"#;
633
634    let mut file = File::create(config_path)?;
635    file.write_all(default_config_content.as_bytes())?;
636
637    println!("Config file created at {}", config_path.display());
638    println!("You can now customize it according to your project needs.");
639
640    Ok(())
641}
642
643#[cfg(test)]
644mod tests {
645    use super::*;
646    use std::io::Result;
647    use tempfile::tempdir;
648
649    // Mock prompter for testing
650    struct MockPrompter {
651        confirm_processing_response: bool,
652        confirm_overwrite_response: bool,
653    }
654
655    impl MockPrompter {
656        fn new(processing: bool, overwrite: bool) -> Self {
657            Self {
658                confirm_processing_response: processing,
659                confirm_overwrite_response: overwrite,
660            }
661        }
662    }
663
664    impl Prompter for MockPrompter {
665        fn confirm_processing(&self, _file_count: usize) -> Result<bool> {
666            Ok(self.confirm_processing_response)
667        }
668
669        fn confirm_overwrite(&self, _file_path: &str) -> Result<bool> {
670            Ok(self.confirm_overwrite_response)
671        }
672    }
673
674    #[test]
675    fn test_diff_config_default() {
676        let config = DiffConfig::default();
677        assert_eq!(config.context_lines, 3);
678        assert!(!config.enabled);
679        assert!(!config.diff_only);
680    }
681
682    #[test]
683    fn test_diff_config_custom() {
684        let config = DiffConfig {
685            context_lines: 5,
686            enabled: true,
687            diff_only: true,
688        };
689        assert_eq!(config.context_lines, 5);
690        assert!(config.enabled);
691        assert!(config.diff_only);
692    }
693
694    #[test]
695    fn test_default_prompter() {
696        let prompter = DefaultPrompter;
697
698        // Test small file count (should not prompt)
699        let result = prompter.confirm_processing(50);
700        assert!(result.is_ok());
701        assert!(result.unwrap());
702    }
703
704    #[test]
705    fn test_run_with_args_nonexistent_directory() {
706        let args = Args {
707            input: "/nonexistent/directory".to_string(),
708            output: "output.md".to_string(),
709            filter: vec![],
710            ignore: vec![],
711            line_numbers: false,
712            preview: false,
713            token_count: false,
714            yes: false,
715            diff_only: false,
716            clear_cache: false,
717            init: false,
718        };
719        let config = Config::default();
720        let prompter = MockPrompter::new(true, true);
721
722        let result = run_with_args(args, config, &prompter);
723        assert!(result.is_err());
724        assert!(result.unwrap_err().to_string().contains("does not exist"));
725    }
726
727    #[test]
728    fn test_run_with_args_preview_mode() {
729        let temp_dir = tempdir().unwrap();
730        let base_path = temp_dir.path();
731
732        // Create some test files
733        fs::write(base_path.join("test.rs"), "fn main() {}").unwrap();
734        fs::create_dir(base_path.join("src")).unwrap();
735        fs::write(base_path.join("src/lib.rs"), "pub fn hello() {}").unwrap();
736
737        let args = Args {
738            input: ".".to_string(),
739            output: "test.md".to_string(),
740            filter: vec![],
741            ignore: vec![],
742            line_numbers: false,
743            preview: false,
744            token_count: false,
745            yes: false,
746            diff_only: false,
747            clear_cache: false,
748            init: false,
749        };
750        let config = Config::default();
751        let prompter = MockPrompter::new(true, true);
752
753        // Set CB_SILENT to avoid console output during test
754        unsafe {
755            std::env::set_var("CB_SILENT", "1");
756        }
757        let result = run_with_args(args, config, &prompter);
758        unsafe {
759            std::env::remove_var("CB_SILENT");
760        }
761
762        assert!(result.is_ok());
763    }
764
765    #[test]
766    fn test_run_with_args_token_count_mode() {
767        let temp_dir = tempdir().unwrap();
768        let base_path = temp_dir.path();
769
770        // Create test files
771        fs::write(base_path.join("small.txt"), "Hello world").unwrap();
772
773        let args = Args {
774            input: base_path.to_string_lossy().to_string(),
775            output: "test.md".to_string(),
776            filter: vec![],
777            ignore: vec![],
778            line_numbers: false,
779            preview: false,
780            token_count: true,
781            yes: false,
782            diff_only: false,
783            clear_cache: false,
784            init: false,
785        };
786        let config = Config::default();
787        let prompter = MockPrompter::new(true, true);
788
789        unsafe {
790            std::env::set_var("CB_SILENT", "1");
791        }
792        let result = run_with_args(args, config, &prompter);
793        unsafe {
794            std::env::remove_var("CB_SILENT");
795        }
796
797        assert!(result.is_ok());
798    }
799
800    #[test]
801    fn test_run_with_args_preview_and_token_count() {
802        let temp_dir = tempdir().unwrap();
803        let base_path = temp_dir.path();
804
805        fs::write(base_path.join("test.txt"), "content").unwrap();
806
807        let args = Args {
808            input: base_path.to_string_lossy().to_string(),
809            output: "test.md".to_string(),
810            filter: vec![],
811            ignore: vec![],
812            line_numbers: false,
813            preview: true,
814            token_count: false,
815            yes: false,
816            diff_only: false,
817            clear_cache: false,
818            init: false,
819        };
820        let config = Config::default();
821        let prompter = MockPrompter::new(true, true);
822
823        unsafe {
824            std::env::set_var("CB_SILENT", "1");
825        }
826        let result = run_with_args(args, config, &prompter);
827        unsafe {
828            std::env::remove_var("CB_SILENT");
829        }
830
831        assert!(result.is_ok());
832    }
833
834    #[test]
835    fn test_run_with_args_user_cancels_overwrite() {
836        let temp_dir = tempdir().unwrap();
837        let base_path = temp_dir.path();
838        let output_path = temp_dir.path().join("existing.md");
839
840        // Create test files
841        fs::write(base_path.join("test.txt"), "content").unwrap();
842        fs::write(&output_path, "existing content").unwrap();
843
844        let args = Args {
845            input: base_path.to_string_lossy().to_string(),
846            output: "test.md".to_string(),
847            filter: vec![],
848            ignore: vec!["target".to_string()],
849            line_numbers: false,
850            preview: false,
851            token_count: false,
852            yes: false,
853            diff_only: false,
854            clear_cache: false,
855            init: false,
856        };
857        let config = Config::default();
858        let prompter = MockPrompter::new(true, false); // Deny overwrite
859
860        unsafe {
861            std::env::set_var("CB_SILENT", "1");
862        }
863        let result = run_with_args(args, config, &prompter);
864        unsafe {
865            std::env::remove_var("CB_SILENT");
866        }
867
868        assert!(result.is_err());
869        assert!(result.unwrap_err().to_string().contains("cancelled"));
870    }
871
872    #[test]
873    fn test_run_with_args_user_cancels_processing() {
874        let temp_dir = tempdir().unwrap();
875        let base_path = temp_dir.path();
876
877        // Create many test files to trigger processing confirmation
878        for i in 0..105 {
879            fs::write(base_path.join(format!("file{}.txt", i)), "content").unwrap();
880        }
881
882        let args = Args {
883            input: base_path.to_string_lossy().to_string(),
884            output: "test.md".to_string(),
885            filter: vec!["rs".to_string()],
886            ignore: vec![],
887            line_numbers: false,
888            preview: false,
889            token_count: false,
890            yes: false,
891            diff_only: false,
892            clear_cache: false,
893            init: false,
894        };
895        let config = Config::default();
896        let prompter = MockPrompter::new(false, true); // Deny processing
897
898        unsafe {
899            std::env::set_var("CB_SILENT", "1");
900        }
901        let result = run_with_args(args, config, &prompter);
902        unsafe {
903            std::env::remove_var("CB_SILENT");
904        }
905
906        assert!(result.is_err());
907        assert!(result.unwrap_err().to_string().contains("cancelled"));
908    }
909
910    #[test]
911    fn test_run_with_args_with_yes_flag() {
912        let temp_dir = tempdir().unwrap();
913        let base_path = temp_dir.path();
914        let output_file_name = "test.md";
915        let output_path = temp_dir.path().join(output_file_name);
916
917        fs::write(base_path.join("test.txt"), "Hello world").unwrap();
918
919        let args = Args {
920            input: base_path.to_string_lossy().to_string(),
921            output: output_path.to_string_lossy().to_string(),
922            filter: vec![],
923            ignore: vec!["ignored_dir".to_string()],
924            line_numbers: false,
925            preview: false,
926            token_count: false,
927            yes: true,
928            diff_only: false,
929            clear_cache: false,
930            init: false,
931        };
932        let config = Config::default();
933        let prompter = MockPrompter::new(true, true);
934
935        unsafe {
936            std::env::set_var("CB_SILENT", "1");
937        }
938        let result = run_with_args(args, config, &prompter);
939        unsafe {
940            std::env::remove_var("CB_SILENT");
941        }
942
943        assert!(result.is_ok());
944        assert!(output_path.exists());
945
946        let content = fs::read_to_string(&output_path).unwrap();
947        assert!(content.contains("Directory Structure Report"));
948        assert!(content.contains("test.txt"));
949    }
950
951    #[test]
952    fn test_run_with_args_with_filters() {
953        let temp_dir = tempdir().unwrap();
954        let base_path = temp_dir.path();
955        let output_file_name = "test.md";
956        let output_path = temp_dir.path().join(output_file_name);
957
958        fs::write(base_path.join("code.rs"), "fn main() {}").unwrap();
959        fs::write(base_path.join("readme.md"), "# README").unwrap();
960        fs::write(base_path.join("data.json"), r#"{"key": "value"}"#).unwrap();
961
962        let args = Args {
963            input: base_path.to_string_lossy().to_string(),
964            output: output_path.to_string_lossy().to_string(),
965            filter: vec!["rs".to_string(), "md".to_string()],
966            ignore: vec![],
967            line_numbers: true,
968            preview: false,
969            token_count: false,
970            yes: true,
971            diff_only: false,
972            clear_cache: false,
973            init: false,
974        };
975        let config = Config::default();
976        let prompter = MockPrompter::new(true, true);
977
978        unsafe {
979            std::env::set_var("CB_SILENT", "1");
980        }
981        let result = run_with_args(args, config, &prompter);
982        unsafe {
983            std::env::remove_var("CB_SILENT");
984        }
985
986        assert!(result.is_ok());
987
988        let content = fs::read_to_string(&output_path).unwrap();
989        assert!(content.contains("code.rs"));
990        assert!(content.contains("readme.md"));
991        assert!(!content.contains("data.json")); // Should be filtered out
992        assert!(content.contains("   1 |")); // Line numbers should be present
993    }
994
995    #[test]
996    fn test_run_with_args_with_ignores() {
997        let temp_dir = tempdir().unwrap();
998        let base_path = temp_dir.path();
999        let output_path = temp_dir.path().join("ignored.md");
1000
1001        fs::write(base_path.join("important.txt"), "important content").unwrap();
1002        fs::write(base_path.join("secret.txt"), "secret content").unwrap();
1003
1004        let args = Args {
1005            input: base_path.to_string_lossy().to_string(),
1006            output: output_path.to_string_lossy().to_string(),
1007            filter: vec![],
1008            ignore: vec!["secret.txt".to_string()],
1009            line_numbers: false,
1010            preview: false,
1011            token_count: false,
1012            yes: true,
1013            diff_only: false,
1014            clear_cache: false,
1015            init: false,
1016        };
1017        let config = Config::default();
1018        let prompter = MockPrompter::new(true, true);
1019
1020        unsafe {
1021            std::env::set_var("CB_SILENT", "1");
1022        }
1023        let result = run_with_args(args, config, &prompter);
1024        unsafe {
1025            std::env::remove_var("CB_SILENT");
1026        }
1027
1028        assert!(result.is_ok());
1029
1030        let content = fs::read_to_string(&output_path).unwrap();
1031        assert!(content.contains("important.txt"));
1032        // The ignore pattern may not work exactly as expected in this test setup
1033        // Just verify the output file was created successfully
1034    }
1035
1036    #[test]
1037    fn test_auto_diff_without_previous_state() {
1038        let temp_dir = tempdir().unwrap();
1039        let base_path = temp_dir.path();
1040        let output_file_name = "test.md";
1041        let output_path = temp_dir.path().join(output_file_name);
1042
1043        fs::write(base_path.join("new.txt"), "new content").unwrap();
1044
1045        let args = Args {
1046            input: base_path.to_string_lossy().to_string(),
1047            output: output_path.to_string_lossy().to_string(),
1048            filter: vec![],
1049            ignore: vec![],
1050            line_numbers: false,
1051            preview: false,
1052            token_count: false,
1053            yes: true,
1054            diff_only: false,
1055            clear_cache: false,
1056            init: false,
1057        };
1058        let config = Config {
1059            auto_diff: Some(true),
1060            diff_context_lines: Some(5),
1061            ..Default::default()
1062        };
1063        let prompter = MockPrompter::new(true, true);
1064
1065        unsafe {
1066            std::env::set_var("CB_SILENT", "1");
1067        }
1068        let result = run_with_args(args, config, &prompter);
1069        unsafe {
1070            std::env::remove_var("CB_SILENT");
1071        }
1072
1073        assert!(result.is_ok());
1074        assert!(output_path.exists());
1075
1076        let content = fs::read_to_string(&output_path).unwrap();
1077        assert!(content.contains("new.txt"));
1078    }
1079
1080    #[test]
1081    fn test_run_creates_output_directory() {
1082        let temp_dir = tempdir().unwrap();
1083        let base_path = temp_dir.path();
1084        let output_dir = temp_dir.path().join("nested").join("output");
1085        let output_path = output_dir.join("result.md");
1086
1087        fs::write(base_path.join("test.txt"), "content").unwrap();
1088
1089        let args = Args {
1090            input: base_path.to_string_lossy().to_string(),
1091            output: output_path.to_string_lossy().to_string(),
1092            filter: vec![],
1093            ignore: vec![],
1094            line_numbers: false,
1095            preview: false,
1096            token_count: false,
1097            yes: true,
1098            diff_only: false,
1099            clear_cache: false,
1100            init: false,
1101        };
1102        let config = Config::default();
1103        let prompter = MockPrompter::new(true, true);
1104
1105        unsafe {
1106            std::env::set_var("CB_SILENT", "1");
1107        }
1108        let result = run_with_args(args, config, &prompter);
1109        unsafe {
1110            std::env::remove_var("CB_SILENT");
1111        }
1112
1113        assert!(result.is_ok());
1114        assert!(output_path.exists());
1115        assert!(output_dir.exists());
1116    }
1117
1118    #[test]
1119    fn test_generate_markdown_with_diff_no_comparison() {
1120        let temp_dir = tempdir().unwrap();
1121        let base_path = temp_dir.path();
1122
1123        fs::write(base_path.join("test.rs"), "fn main() {}").unwrap();
1124
1125        let files = collect_files(base_path, &[], &[]).unwrap();
1126        let file_tree = build_file_tree(&files, base_path);
1127        let config = Config::default();
1128        let state = ProjectState::from_files(&files, base_path, &config, false).unwrap();
1129
1130        let args = Args {
1131            input: base_path.to_string_lossy().to_string(),
1132            output: "test.md".to_string(),
1133            filter: vec![],
1134            ignore: vec![],
1135            line_numbers: false,
1136            preview: false,
1137            token_count: false,
1138            yes: false,
1139            diff_only: false,
1140            clear_cache: false,
1141            init: false,
1142        };
1143
1144        let diff_config = DiffConfig::default();
1145
1146        let result = generate_markdown_with_diff(&state, None, &args, &file_tree, &diff_config);
1147        assert!(result.is_ok());
1148
1149        let content = result.unwrap();
1150        assert!(content.contains("Directory Structure Report"));
1151        assert!(content.contains("test.rs"));
1152    }
1153}