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/// Detect major file types in the current directory respecting .gitignore and default ignore patterns
597fn detect_major_file_types() -> io::Result<Vec<String>> {
598    use std::collections::HashMap;
599    let mut extension_counts = HashMap::new();
600
601    // Use the same default ignore patterns as the main application
602    let default_ignores = vec![
603        "docs".to_string(),
604        "target".to_string(),
605        ".git".to_string(),
606        "node_modules".to_string(),
607    ];
608
609    // Collect files using the same logic as the main application
610    let files = crate::file_utils::collect_files(Path::new("."), &[], &default_ignores)?;
611
612    // Count extensions from the filtered file list
613    for entry in files {
614        let path = entry.path();
615        if let Some(extension) = path.extension().and_then(|ext| ext.to_str()) {
616            // Count the extension occurrences
617            *extension_counts.entry(extension.to_string()).or_insert(0) += 1;
618        }
619    }
620
621    // Convert to vector of (extension, count) pairs and sort by count
622    let mut extensions: Vec<(String, usize)> = extension_counts.into_iter().collect();
623    extensions.sort_by(|a, b| b.1.cmp(&a.1));
624
625    // Take the top 5 extensions or all if less than 5
626    let top_extensions: Vec<String> = extensions.into_iter().take(5).map(|(ext, _)| ext).collect();
627
628    Ok(top_extensions)
629}
630
631/// Initialize a new context-builder.toml config file in the current directory with sensible defaults
632fn init_config() -> io::Result<()> {
633    let config_path = Path::new("context-builder.toml");
634
635    if config_path.exists() {
636        println!("Config file already exists at {}", config_path.display());
637        println!("If you want to replace it, please remove it manually first.");
638        return Ok(());
639    }
640
641    // Detect major file types in the current directory
642    let filter_suggestions = match detect_major_file_types() {
643        Ok(extensions) => extensions,
644        _ => vec!["rs".to_string(), "toml".to_string()], // fallback to defaults
645    };
646
647    let filter_string = if filter_suggestions.is_empty() {
648        r#"["rs", "toml"]"#.to_string()
649    } else {
650        format!(r#"["{}"]"#, filter_suggestions.join(r#"", ""#))
651    };
652
653    let default_config_content = format!(
654        r#"# Context Builder Configuration File
655# This file was generated with sensible defaults based on the file types detected in your project
656
657# Output file name (or base name when timestamped_output is true)
658output = "context.md"
659
660# Optional folder to place the generated output file(s) in
661output_folder = "docs"
662
663# Append a UTC timestamp to the output file name (before extension)
664timestamped_output = true
665
666# Enable automatic diff generation (requires timestamped_output = true)
667auto_diff = true
668
669# Emit only change summary + modified file diffs (no full file bodies)
670diff_only = false
671
672# File extensions to include (no leading dot, e.g. "rs", "toml")
673filter = {}
674
675# File / directory names to ignore (exact name matches)
676ignore = ["docs", "target", ".git", "node_modules"]
677
678# Add line numbers to code blocks
679line_numbers = false
680"#,
681        filter_string
682    );
683
684    let mut file = File::create(config_path)?;
685    file.write_all(default_config_content.as_bytes())?;
686
687    println!("Config file created at {}", config_path.display());
688    println!("Detected file types: {}", filter_suggestions.join(", "));
689    println!("You can now customize it according to your project needs.");
690
691    Ok(())
692}
693
694#[cfg(test)]
695mod tests {
696    use super::*;
697    use std::io::Result;
698    use tempfile::tempdir;
699
700    // Mock prompter for testing
701    struct MockPrompter {
702        confirm_processing_response: bool,
703        confirm_overwrite_response: bool,
704    }
705
706    impl MockPrompter {
707        fn new(processing: bool, overwrite: bool) -> Self {
708            Self {
709                confirm_processing_response: processing,
710                confirm_overwrite_response: overwrite,
711            }
712        }
713    }
714
715    impl Prompter for MockPrompter {
716        fn confirm_processing(&self, _file_count: usize) -> Result<bool> {
717            Ok(self.confirm_processing_response)
718        }
719
720        fn confirm_overwrite(&self, _file_path: &str) -> Result<bool> {
721            Ok(self.confirm_overwrite_response)
722        }
723    }
724
725    #[test]
726    fn test_diff_config_default() {
727        let config = DiffConfig::default();
728        assert_eq!(config.context_lines, 3);
729        assert!(!config.enabled);
730        assert!(!config.diff_only);
731    }
732
733    #[test]
734    fn test_diff_config_custom() {
735        let config = DiffConfig {
736            context_lines: 5,
737            enabled: true,
738            diff_only: true,
739        };
740        assert_eq!(config.context_lines, 5);
741        assert!(config.enabled);
742        assert!(config.diff_only);
743    }
744
745    #[test]
746    fn test_default_prompter() {
747        let prompter = DefaultPrompter;
748
749        // Test small file count (should not prompt)
750        let result = prompter.confirm_processing(50);
751        assert!(result.is_ok());
752        assert!(result.unwrap());
753    }
754
755    #[test]
756    fn test_run_with_args_nonexistent_directory() {
757        let args = Args {
758            input: "/nonexistent/directory".to_string(),
759            output: "output.md".to_string(),
760            filter: vec![],
761            ignore: vec![],
762            line_numbers: false,
763            preview: false,
764            token_count: false,
765            yes: false,
766            diff_only: false,
767            clear_cache: false,
768            init: false,
769        };
770        let config = Config::default();
771        let prompter = MockPrompter::new(true, true);
772
773        let result = run_with_args(args, config, &prompter);
774        assert!(result.is_err());
775        assert!(result.unwrap_err().to_string().contains("does not exist"));
776    }
777
778    #[test]
779    fn test_run_with_args_preview_mode() {
780        let temp_dir = tempdir().unwrap();
781        let base_path = temp_dir.path();
782
783        // Create some test files
784        fs::write(base_path.join("test.rs"), "fn main() {}").unwrap();
785        fs::create_dir(base_path.join("src")).unwrap();
786        fs::write(base_path.join("src/lib.rs"), "pub fn hello() {}").unwrap();
787
788        let args = Args {
789            input: ".".to_string(),
790            output: "test.md".to_string(),
791            filter: vec![],
792            ignore: vec![],
793            line_numbers: false,
794            preview: false,
795            token_count: false,
796            yes: false,
797            diff_only: false,
798            clear_cache: false,
799            init: false,
800        };
801        let config = Config::default();
802        let prompter = MockPrompter::new(true, true);
803
804        // Set CB_SILENT to avoid console output during test
805        unsafe {
806            std::env::set_var("CB_SILENT", "1");
807        }
808        let result = run_with_args(args, config, &prompter);
809        unsafe {
810            std::env::remove_var("CB_SILENT");
811        }
812
813        assert!(result.is_ok());
814    }
815
816    #[test]
817    fn test_run_with_args_token_count_mode() {
818        let temp_dir = tempdir().unwrap();
819        let base_path = temp_dir.path();
820
821        // Create test files
822        fs::write(base_path.join("small.txt"), "Hello world").unwrap();
823
824        let args = Args {
825            input: base_path.to_string_lossy().to_string(),
826            output: "test.md".to_string(),
827            filter: vec![],
828            ignore: vec![],
829            line_numbers: false,
830            preview: false,
831            token_count: true,
832            yes: false,
833            diff_only: false,
834            clear_cache: false,
835            init: false,
836        };
837        let config = Config::default();
838        let prompter = MockPrompter::new(true, true);
839
840        unsafe {
841            std::env::set_var("CB_SILENT", "1");
842        }
843        let result = run_with_args(args, config, &prompter);
844        unsafe {
845            std::env::remove_var("CB_SILENT");
846        }
847
848        assert!(result.is_ok());
849    }
850
851    #[test]
852    fn test_run_with_args_preview_and_token_count() {
853        let temp_dir = tempdir().unwrap();
854        let base_path = temp_dir.path();
855
856        fs::write(base_path.join("test.txt"), "content").unwrap();
857
858        let args = Args {
859            input: base_path.to_string_lossy().to_string(),
860            output: "test.md".to_string(),
861            filter: vec![],
862            ignore: vec![],
863            line_numbers: false,
864            preview: true,
865            token_count: false,
866            yes: false,
867            diff_only: false,
868            clear_cache: false,
869            init: false,
870        };
871        let config = Config::default();
872        let prompter = MockPrompter::new(true, true);
873
874        unsafe {
875            std::env::set_var("CB_SILENT", "1");
876        }
877        let result = run_with_args(args, config, &prompter);
878        unsafe {
879            std::env::remove_var("CB_SILENT");
880        }
881
882        assert!(result.is_ok());
883    }
884
885    #[test]
886    fn test_run_with_args_user_cancels_overwrite() {
887        let temp_dir = tempdir().unwrap();
888        let base_path = temp_dir.path();
889        let output_path = temp_dir.path().join("existing.md");
890
891        // Create test files
892        fs::write(base_path.join("test.txt"), "content").unwrap();
893        fs::write(&output_path, "existing content").unwrap();
894
895        let args = Args {
896            input: base_path.to_string_lossy().to_string(),
897            output: "test.md".to_string(),
898            filter: vec![],
899            ignore: vec!["target".to_string()],
900            line_numbers: false,
901            preview: false,
902            token_count: false,
903            yes: false,
904            diff_only: false,
905            clear_cache: false,
906            init: false,
907        };
908        let config = Config::default();
909        let prompter = MockPrompter::new(true, false); // Deny overwrite
910
911        unsafe {
912            std::env::set_var("CB_SILENT", "1");
913        }
914        let result = run_with_args(args, config, &prompter);
915        unsafe {
916            std::env::remove_var("CB_SILENT");
917        }
918
919        assert!(result.is_err());
920        assert!(result.unwrap_err().to_string().contains("cancelled"));
921    }
922
923    #[test]
924    fn test_run_with_args_user_cancels_processing() {
925        let temp_dir = tempdir().unwrap();
926        let base_path = temp_dir.path();
927
928        // Create many test files to trigger processing confirmation
929        for i in 0..105 {
930            fs::write(base_path.join(format!("file{}.txt", i)), "content").unwrap();
931        }
932
933        let args = Args {
934            input: base_path.to_string_lossy().to_string(),
935            output: "test.md".to_string(),
936            filter: vec!["rs".to_string()],
937            ignore: vec![],
938            line_numbers: false,
939            preview: false,
940            token_count: false,
941            yes: false,
942            diff_only: false,
943            clear_cache: false,
944            init: false,
945        };
946        let config = Config::default();
947        let prompter = MockPrompter::new(false, true); // Deny processing
948
949        unsafe {
950            std::env::set_var("CB_SILENT", "1");
951        }
952        let result = run_with_args(args, config, &prompter);
953        unsafe {
954            std::env::remove_var("CB_SILENT");
955        }
956
957        assert!(result.is_err());
958        assert!(result.unwrap_err().to_string().contains("cancelled"));
959    }
960
961    #[test]
962    fn test_run_with_args_with_yes_flag() {
963        let temp_dir = tempdir().unwrap();
964        let base_path = temp_dir.path();
965        let output_file_name = "test.md";
966        let output_path = temp_dir.path().join(output_file_name);
967
968        fs::write(base_path.join("test.txt"), "Hello world").unwrap();
969
970        let args = Args {
971            input: base_path.to_string_lossy().to_string(),
972            output: output_path.to_string_lossy().to_string(),
973            filter: vec![],
974            ignore: vec!["ignored_dir".to_string()],
975            line_numbers: false,
976            preview: false,
977            token_count: false,
978            yes: true,
979            diff_only: false,
980            clear_cache: false,
981            init: false,
982        };
983        let config = Config::default();
984        let prompter = MockPrompter::new(true, true);
985
986        unsafe {
987            std::env::set_var("CB_SILENT", "1");
988        }
989        let result = run_with_args(args, config, &prompter);
990        unsafe {
991            std::env::remove_var("CB_SILENT");
992        }
993
994        assert!(result.is_ok());
995        assert!(output_path.exists());
996
997        let content = fs::read_to_string(&output_path).unwrap();
998        assert!(content.contains("Directory Structure Report"));
999        assert!(content.contains("test.txt"));
1000    }
1001
1002    #[test]
1003    fn test_run_with_args_with_filters() {
1004        let temp_dir = tempdir().unwrap();
1005        let base_path = temp_dir.path();
1006        let output_file_name = "test.md";
1007        let output_path = temp_dir.path().join(output_file_name);
1008
1009        fs::write(base_path.join("code.rs"), "fn main() {}").unwrap();
1010        fs::write(base_path.join("readme.md"), "# README").unwrap();
1011        fs::write(base_path.join("data.json"), r#"{"key": "value"}"#).unwrap();
1012
1013        let args = Args {
1014            input: base_path.to_string_lossy().to_string(),
1015            output: output_path.to_string_lossy().to_string(),
1016            filter: vec!["rs".to_string(), "md".to_string()],
1017            ignore: vec![],
1018            line_numbers: true,
1019            preview: false,
1020            token_count: false,
1021            yes: true,
1022            diff_only: false,
1023            clear_cache: false,
1024            init: false,
1025        };
1026        let config = Config::default();
1027        let prompter = MockPrompter::new(true, true);
1028
1029        unsafe {
1030            std::env::set_var("CB_SILENT", "1");
1031        }
1032        let result = run_with_args(args, config, &prompter);
1033        unsafe {
1034            std::env::remove_var("CB_SILENT");
1035        }
1036
1037        assert!(result.is_ok());
1038
1039        let content = fs::read_to_string(&output_path).unwrap();
1040        assert!(content.contains("code.rs"));
1041        assert!(content.contains("readme.md"));
1042        assert!(!content.contains("data.json")); // Should be filtered out
1043        assert!(content.contains("   1 |")); // Line numbers should be present
1044    }
1045
1046    #[test]
1047    fn test_run_with_args_with_ignores() {
1048        let temp_dir = tempdir().unwrap();
1049        let base_path = temp_dir.path();
1050        let output_path = temp_dir.path().join("ignored.md");
1051
1052        fs::write(base_path.join("important.txt"), "important content").unwrap();
1053        fs::write(base_path.join("secret.txt"), "secret content").unwrap();
1054
1055        let args = Args {
1056            input: base_path.to_string_lossy().to_string(),
1057            output: output_path.to_string_lossy().to_string(),
1058            filter: vec![],
1059            ignore: vec!["secret.txt".to_string()],
1060            line_numbers: false,
1061            preview: false,
1062            token_count: false,
1063            yes: true,
1064            diff_only: false,
1065            clear_cache: false,
1066            init: false,
1067        };
1068        let config = Config::default();
1069        let prompter = MockPrompter::new(true, true);
1070
1071        unsafe {
1072            std::env::set_var("CB_SILENT", "1");
1073        }
1074        let result = run_with_args(args, config, &prompter);
1075        unsafe {
1076            std::env::remove_var("CB_SILENT");
1077        }
1078
1079        assert!(result.is_ok());
1080
1081        let content = fs::read_to_string(&output_path).unwrap();
1082        assert!(content.contains("important.txt"));
1083        // The ignore pattern may not work exactly as expected in this test setup
1084        // Just verify the output file was created successfully
1085    }
1086
1087    #[test]
1088    fn test_auto_diff_without_previous_state() {
1089        let temp_dir = tempdir().unwrap();
1090        let base_path = temp_dir.path();
1091        let output_file_name = "test.md";
1092        let output_path = temp_dir.path().join(output_file_name);
1093
1094        fs::write(base_path.join("new.txt"), "new content").unwrap();
1095
1096        let args = Args {
1097            input: base_path.to_string_lossy().to_string(),
1098            output: output_path.to_string_lossy().to_string(),
1099            filter: vec![],
1100            ignore: vec![],
1101            line_numbers: false,
1102            preview: false,
1103            token_count: false,
1104            yes: true,
1105            diff_only: false,
1106            clear_cache: false,
1107            init: false,
1108        };
1109        let config = Config {
1110            auto_diff: Some(true),
1111            diff_context_lines: Some(5),
1112            ..Default::default()
1113        };
1114        let prompter = MockPrompter::new(true, true);
1115
1116        unsafe {
1117            std::env::set_var("CB_SILENT", "1");
1118        }
1119        let result = run_with_args(args, config, &prompter);
1120        unsafe {
1121            std::env::remove_var("CB_SILENT");
1122        }
1123
1124        assert!(result.is_ok());
1125        assert!(output_path.exists());
1126
1127        let content = fs::read_to_string(&output_path).unwrap();
1128        assert!(content.contains("new.txt"));
1129    }
1130
1131    #[test]
1132    fn test_run_creates_output_directory() {
1133        let temp_dir = tempdir().unwrap();
1134        let base_path = temp_dir.path();
1135        let output_dir = temp_dir.path().join("nested").join("output");
1136        let output_path = output_dir.join("result.md");
1137
1138        fs::write(base_path.join("test.txt"), "content").unwrap();
1139
1140        let args = Args {
1141            input: base_path.to_string_lossy().to_string(),
1142            output: output_path.to_string_lossy().to_string(),
1143            filter: vec![],
1144            ignore: vec![],
1145            line_numbers: false,
1146            preview: false,
1147            token_count: false,
1148            yes: true,
1149            diff_only: false,
1150            clear_cache: false,
1151            init: false,
1152        };
1153        let config = Config::default();
1154        let prompter = MockPrompter::new(true, true);
1155
1156        unsafe {
1157            std::env::set_var("CB_SILENT", "1");
1158        }
1159        let result = run_with_args(args, config, &prompter);
1160        unsafe {
1161            std::env::remove_var("CB_SILENT");
1162        }
1163
1164        assert!(result.is_ok());
1165        assert!(output_path.exists());
1166        assert!(output_dir.exists());
1167    }
1168
1169    #[test]
1170    fn test_generate_markdown_with_diff_no_comparison() {
1171        let temp_dir = tempdir().unwrap();
1172        let base_path = temp_dir.path();
1173
1174        fs::write(base_path.join("test.rs"), "fn main() {}").unwrap();
1175
1176        let files = collect_files(base_path, &[], &[]).unwrap();
1177        let file_tree = build_file_tree(&files, base_path);
1178        let config = Config::default();
1179        let state = ProjectState::from_files(&files, base_path, &config, false).unwrap();
1180
1181        let args = Args {
1182            input: base_path.to_string_lossy().to_string(),
1183            output: "test.md".to_string(),
1184            filter: vec![],
1185            ignore: vec![],
1186            line_numbers: false,
1187            preview: false,
1188            token_count: false,
1189            yes: false,
1190            diff_only: false,
1191            clear_cache: false,
1192            init: false,
1193        };
1194
1195        let diff_config = DiffConfig::default();
1196
1197        let result = generate_markdown_with_diff(&state, None, &args, &file_tree, &diff_config);
1198        assert!(result.is_ok());
1199
1200        let content = result.unwrap();
1201        assert!(content.contains("Directory Structure Report"));
1202        assert!(content.contains("test.rs"));
1203    }
1204}