Skip to main content

context_builder/
lib.rs

1use clap::{CommandFactory, Parser};
2
3use std::fs;
4use std::io::{self, Write};
5use std::path::{Path, PathBuf};
6use std::time::Instant;
7
8pub mod cache;
9pub mod cli;
10pub mod config;
11pub mod config_resolver;
12pub mod diff;
13pub mod file_utils;
14pub mod markdown;
15pub mod state;
16pub mod token_count;
17pub mod tree;
18pub mod tree_sitter;
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 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    // Compute auto-ignore patterns to exclude the tool's own output and cache
136    let mut auto_ignores: Vec<String> = vec![".context-builder".to_string()];
137
138    // Exclude the resolved output file (or its timestamped glob pattern)
139    let output_path = Path::new(&final_args.output);
140    if let Ok(rel_output) = output_path.strip_prefix(base_path) {
141        // Output is inside the project — exclude it
142        if config.timestamped_output == Some(true) {
143            // Timestamped outputs: create a glob like "docs/context_*.md"
144            if let (Some(parent), Some(stem), Some(ext)) = (
145                rel_output.parent(),
146                output_path.file_stem().and_then(|s| s.to_str()),
147                output_path.extension().and_then(|s| s.to_str()),
148            ) {
149                // Strip the timestamp suffix to get the base stem
150                // Timestamped names look like "context_20260214175028.md"
151                // The stem from config is the part before the timestamp
152                let base_stem = if let Some(ref cfg_output) = config.output {
153                    Path::new(cfg_output)
154                        .file_stem()
155                        .and_then(|s| s.to_str())
156                        .unwrap_or(stem)
157                        .to_string()
158                } else {
159                    stem.to_string()
160                };
161                let glob = if parent == Path::new("") {
162                    format!("{}_*.{}", base_stem, ext)
163                } else {
164                    format!("{}/{}_*.{}", parent.display(), base_stem, ext)
165                };
166                auto_ignores.push(glob);
167            }
168        } else {
169            // Non-timestamped: exclude the exact output file
170            auto_ignores.push(rel_output.to_string_lossy().to_string());
171        }
172    } else {
173        // Output might be a relative path not under base_path — try using it directly
174        let output_str = final_args.output.clone();
175        if config.timestamped_output == Some(true) {
176            if let (Some(stem), Some(ext)) = (
177                output_path.file_stem().and_then(|s| s.to_str()),
178                output_path.extension().and_then(|s| s.to_str()),
179            ) {
180                let base_stem = if let Some(ref cfg_output) = config.output {
181                    Path::new(cfg_output)
182                        .file_stem()
183                        .and_then(|s| s.to_str())
184                        .unwrap_or(stem)
185                        .to_string()
186                } else {
187                    stem.to_string()
188                };
189                if let Some(parent) = output_path.parent() {
190                    let parent_str = parent.to_string_lossy();
191                    if parent_str.is_empty() || parent_str == "." {
192                        auto_ignores.push(format!("{}_*.{}", base_stem, ext));
193                    } else {
194                        auto_ignores.push(format!("{}/{}_*.{}", parent_str, base_stem, ext));
195                    }
196                }
197            }
198        } else {
199            auto_ignores.push(output_str);
200        }
201    }
202
203    // Also exclude the output folder itself if configured
204    if let Some(ref output_folder) = config.output_folder {
205        auto_ignores.push(output_folder.clone());
206    }
207
208    let files = collect_files(
209        base_path,
210        &final_args.filter,
211        &final_args.ignore,
212        &auto_ignores,
213    )?;
214    let debug_config = std::env::var("CB_DEBUG_CONFIG").is_ok();
215    if debug_config {
216        eprintln!("[DEBUG][CONFIG] Args: {:?}", final_args);
217        eprintln!("[DEBUG][CONFIG] Raw Config: {:?}", config);
218        eprintln!("[DEBUG][CONFIG] Auto-ignores: {:?}", auto_ignores);
219        eprintln!("[DEBUG][CONFIG] Collected {} files", files.len());
220        for f in &files {
221            eprintln!("[DEBUG][CONFIG]  - {}", f.path().display());
222        }
223    }
224
225    // Smart large-file detection: warn about files that may bloat the context
226    if !silent {
227        const LARGE_FILE_THRESHOLD: u64 = 100 * 1024; // 100 KB
228        let mut large_files: Vec<(String, u64)> = Vec::new();
229        let mut total_size: u64 = 0;
230
231        for entry in &files {
232            if let Ok(metadata) = entry.path().metadata() {
233                let size = metadata.len();
234                total_size += size;
235                if size > LARGE_FILE_THRESHOLD {
236                    let rel_path = entry
237                        .path()
238                        .strip_prefix(base_path)
239                        .unwrap_or(entry.path())
240                        .to_string_lossy()
241                        .to_string();
242                    large_files.push((rel_path, size));
243                }
244            }
245        }
246
247        if !large_files.is_empty() {
248            large_files.sort_by(|a, b| b.1.cmp(&a.1)); // Sort by size descending
249            eprintln!(
250                "\n⚠  {} large file(s) detected (>{} KB):",
251                large_files.len(),
252                LARGE_FILE_THRESHOLD / 1024
253            );
254            for (path, size) in large_files.iter().take(5) {
255                eprintln!("   {:>8} KB  {}", size / 1024, path);
256            }
257            if large_files.len() > 5 {
258                eprintln!("   ... and {} more", large_files.len() - 5);
259            }
260            eprintln!(
261                "   Total context size: {} KB across {} files\n",
262                total_size / 1024,
263                files.len()
264            );
265        }
266    }
267    let file_tree = build_file_tree(&files, base_path);
268
269    if final_args.preview {
270        if !silent {
271            println!("\n# File Tree Structure (Preview)\n");
272            print_tree(&file_tree, 0);
273        }
274        if !final_args.token_count {
275            return Ok(());
276        }
277    }
278
279    if final_args.token_count {
280        if !silent {
281            println!("\n# Token Count Estimation\n");
282            let mut total_tokens = 0;
283            total_tokens += estimate_tokens("# Directory Structure Report\n\n");
284            if !final_args.filter.is_empty() {
285                total_tokens += estimate_tokens(&format!(
286                    "This document contains files from the `{}` directory with extensions: {} \n",
287                    final_args.input,
288                    final_args.filter.join(", ")
289                ));
290            } else {
291                total_tokens += estimate_tokens(&format!(
292                    "This document contains all files from the `{}` directory, optimized for LLM consumption.\n",
293                    final_args.input
294                ));
295            }
296            if !final_args.ignore.is_empty() {
297                total_tokens += estimate_tokens(&format!(
298                    "Custom ignored patterns: {} \n",
299                    final_args.ignore.join(", ")
300                ));
301            }
302            total_tokens += estimate_tokens("Content hash: 0000000000000000\n\n");
303            total_tokens += estimate_tokens("## File Tree Structure\n\n");
304            let tree_tokens = count_tree_tokens(&file_tree, 0);
305            total_tokens += tree_tokens;
306            let file_tokens: usize = files
307                .iter()
308                .map(|entry| count_file_tokens(base_path, entry, final_args.line_numbers))
309                .sum();
310            total_tokens += file_tokens;
311            println!("Estimated total tokens: {}", total_tokens);
312            println!("File tree tokens: {}", tree_tokens);
313            println!("File content tokens: {}", file_tokens);
314        }
315        return Ok(());
316    }
317
318    if !final_args.yes && !prompter.confirm_processing(files.len())? {
319        if !silent {
320            println!("Operation cancelled.");
321        }
322        return Err(io::Error::new(
323            io::ErrorKind::Interrupted,
324            "Operation cancelled by user",
325        ));
326    }
327
328    // NOTE: config-driven flags (line_numbers, diff_only) are already merged
329    // by config_resolver.rs with proper CLI-takes-precedence semantics.
330    // Do NOT re-apply them here as that would silently overwrite CLI flags.
331
332    if config.auto_diff.unwrap_or(false) {
333        // Build an effective config that mirrors the *actual* operational settings coming
334        // from resolved CLI args (filters/ignores/line_numbers). This ensures the
335        // configuration hash used for cache invalidation reflects real behavior and
336        // stays consistent across runs even when values originate from CLI not file.
337        let mut effective_config = config.clone();
338        // Normalize filter/ignore/line_numbers into config so hashing sees them
339        if !final_args.filter.is_empty() {
340            effective_config.filter = Some(final_args.filter.clone());
341        }
342        if !final_args.ignore.is_empty() {
343            effective_config.ignore = Some(final_args.ignore.clone());
344        }
345        effective_config.line_numbers = Some(final_args.line_numbers);
346
347        // 1. Create current project state
348        let current_state = ProjectState::from_files(
349            &files,
350            base_path,
351            &effective_config,
352            final_args.line_numbers,
353        )?;
354
355        // 2. Initialize cache manager and load previous state
356        let cache_manager = CacheManager::new(base_path, &effective_config);
357        let previous_state = match cache_manager.read_cache() {
358            Ok(state) => state,
359            Err(e) => {
360                if !silent {
361                    eprintln!(
362                        "Warning: Failed to read cache (proceeding without diff): {}",
363                        e
364                    );
365                }
366                None
367            }
368        };
369
370        let diff_cfg = diff_config.as_ref().unwrap();
371
372        // 3. Determine whether we should invalidate (ignore) previous state
373        let effective_previous = if let Some(prev) = previous_state.as_ref() {
374            if prev.config_hash != current_state.config_hash {
375                // Config change => treat as initial state (invalidate diff)
376                None
377            } else {
378                Some(prev)
379            }
380        } else {
381            None
382        };
383
384        // 4. Compare states and generate diff if an effective previous state exists
385        let comparison = effective_previous.map(|prev| current_state.compare_with(prev));
386
387        let debug_autodiff = std::env::var("CB_DEBUG_AUTODIFF").is_ok();
388        if debug_autodiff {
389            eprintln!(
390                "[DEBUG][AUTODIFF] cache file: {}",
391                cache_manager.debug_cache_file_path().display()
392            );
393            eprintln!(
394                "[DEBUG][AUTODIFF] config_hash current={} prev={:?} invalidated={}",
395                current_state.config_hash,
396                previous_state.as_ref().map(|s| s.config_hash.clone()),
397                effective_previous.is_none() && previous_state.is_some()
398            );
399            eprintln!("[DEBUG][AUTODIFF] effective_config: {:?}", effective_config);
400            if let Some(prev) = previous_state.as_ref() {
401                eprintln!("[DEBUG][AUTODIFF] raw previous files: {}", prev.files.len());
402            }
403            if let Some(prev) = effective_previous {
404                eprintln!(
405                    "[DEBUG][AUTODIFF] effective previous files: {}",
406                    prev.files.len()
407                );
408                for k in prev.files.keys() {
409                    eprintln!("  PREV: {}", k.display());
410                }
411            }
412            eprintln!(
413                "[DEBUG][AUTODIFF] current files: {}",
414                current_state.files.len()
415            );
416            for k in current_state.files.keys() {
417                eprintln!("  CURR: {}", k.display());
418            }
419        }
420
421        // Build relevance-sorted path list from the DirEntry list (which is
422        // already sorted by file_relevance_category). This preserves ordering
423        // instead of using BTreeMap's alphabetical iteration.
424        // IMPORTANT: Path resolution must match state.rs to avoid get() misses.
425        let cwd = std::env::current_dir().unwrap_or_else(|_| base_path.to_path_buf());
426        let sorted_paths: Vec<PathBuf> = files
427            .iter()
428            .map(|entry| {
429                entry
430                    .path()
431                    .strip_prefix(base_path)
432                    .or_else(|_| entry.path().strip_prefix(&cwd))
433                    .map(|p| p.to_path_buf())
434                    .unwrap_or_else(|_| {
435                        entry
436                            .path()
437                            .file_name()
438                            .map(PathBuf::from)
439                            .unwrap_or_else(|| entry.path().to_path_buf())
440                    })
441            })
442            .collect();
443
444        // Build tree-sitter config for diff path
445        let ts_config = markdown::TreeSitterConfig {
446            signatures: final_args.signatures,
447            structure: final_args.structure,
448            truncate: final_args.truncate.clone(),
449            visibility: final_args.visibility.clone(),
450        };
451
452        // 4. Generate markdown with diff annotations
453        let mut final_doc = generate_markdown_with_diff(
454            &current_state,
455            comparison.as_ref(),
456            &final_args,
457            &file_tree,
458            diff_cfg,
459            &sorted_paths,
460            &ts_config,
461        )?;
462
463        // Enforce max_tokens budget (same ~4 bytes/token heuristic as parallel path)
464        if let Some(max_tokens) = final_args.max_tokens {
465            let max_bytes = max_tokens.saturating_mul(4);
466            if final_doc.len() > max_bytes {
467                // Truncate at a valid UTF-8 boundary
468                let mut truncate_at = max_bytes;
469                while truncate_at > 0 && !final_doc.is_char_boundary(truncate_at) {
470                    truncate_at -= 1;
471                }
472                final_doc.truncate(truncate_at);
473
474                // Close any open markdown code fence to prevent LLMs from
475                // interpreting the truncation notice as part of a code block.
476                // Count unmatched ``` fences — if odd, we're inside a block.
477                let fence_count = final_doc.matches("\n```").count()
478                    + if final_doc.starts_with("```") { 1 } else { 0 };
479                if fence_count % 2 != 0 {
480                    final_doc.push_str("\n```\n");
481                }
482
483                final_doc.push_str("\n---\n\n");
484                final_doc.push_str(&format!(
485                    "_Output truncated: exceeded {} token budget (estimated)._\n",
486                    max_tokens
487                ));
488            }
489        }
490
491        // 5. Write output
492        let output_path = Path::new(&final_args.output);
493        if let Some(parent) = output_path.parent()
494            && !parent.exists()
495            && let Err(e) = fs::create_dir_all(parent)
496        {
497            return Err(io::Error::other(format!(
498                "Failed to create output directory {}: {}",
499                parent.display(),
500                e
501            )));
502        }
503        let mut final_output = fs::File::create(output_path)?;
504        final_output.write_all(final_doc.as_bytes())?;
505
506        // 6. Update cache with current state
507        if let Err(e) = cache_manager.write_cache(&current_state)
508            && !silent
509        {
510            eprintln!("Warning: failed to update state cache: {}", e);
511        }
512
513        let duration = start_time.elapsed();
514        if !silent {
515            if let Some(comp) = &comparison {
516                if comp.summary.has_changes() {
517                    println!(
518                        "Documentation created successfully with {} changes: {}",
519                        comp.summary.total_changes, final_args.output
520                    );
521                } else {
522                    println!(
523                        "Documentation created successfully (no changes detected): {}",
524                        final_args.output
525                    );
526                }
527            } else {
528                println!(
529                    "Documentation created successfully (initial state): {}",
530                    final_args.output
531                );
532            }
533            println!("Processing time: {:.2?}", duration);
534
535            // Warn about context window overflow
536            let output_bytes = final_doc.len();
537            print_context_window_warning(output_bytes, final_args.max_tokens);
538        }
539        return Ok(());
540    }
541
542    // Standard (non auto-diff) generation
543    // Build tree-sitter config from resolved args
544    let ts_config = markdown::TreeSitterConfig {
545        signatures: final_args.signatures,
546        structure: final_args.structure,
547        truncate: final_args.truncate.clone(),
548        visibility: final_args.visibility.clone(),
549    };
550
551    // Graceful degradation: warn if tree-sitter flags are used without the feature
552    if !silent && (ts_config.signatures || ts_config.structure || ts_config.truncate == "smart") {
553        #[cfg(not(feature = "tree-sitter-base"))]
554        {
555            eprintln!("āš ļø  --signatures/--structure/--truncate smart require tree-sitter support.");
556            eprintln!("   Build with: cargo build --features tree-sitter-all");
557            eprintln!("   Falling back to standard output.\n");
558        }
559    }
560
561    generate_markdown(
562        &final_args.output,
563        &final_args.input,
564        &final_args.filter,
565        &final_args.ignore,
566        &file_tree,
567        &files,
568        base_path,
569        final_args.line_numbers,
570        config.encoding_strategy.as_deref(),
571        final_args.max_tokens,
572        &ts_config,
573    )?;
574
575    let duration = start_time.elapsed();
576    if !silent {
577        println!("Documentation created successfully: {}", final_args.output);
578        println!("Processing time: {:.2?}", duration);
579
580        // Warn about context window overflow
581        let output_bytes = fs::metadata(&final_args.output)
582            .map(|m| m.len() as usize)
583            .unwrap_or(0);
584        print_context_window_warning(output_bytes, final_args.max_tokens);
585    }
586
587    Ok(())
588}
589
590/// Print context window overflow warnings with actionable recommendations.
591/// Estimates tokens using the ~4 bytes/token heuristic. Warns when output
592/// exceeds 128K tokens — beyond this size, context quality degrades
593/// significantly for most LLM use cases.
594fn print_context_window_warning(output_bytes: usize, max_tokens: Option<usize>) {
595    let estimated_tokens = output_bytes / 4;
596
597    println!("Estimated tokens: ~{}K", estimated_tokens / 1000);
598
599    // If the user already set --max-tokens, they're managing their budget
600    if max_tokens.is_some() {
601        return;
602    }
603
604    const RECOMMENDED_LIMIT: usize = 128_000;
605
606    if estimated_tokens <= RECOMMENDED_LIMIT {
607        return;
608    }
609
610    eprintln!();
611    eprintln!(
612        "āš ļø  Output is ~{}K tokens — recommended limit is 128K for effective LLM context.",
613        estimated_tokens / 1000
614    );
615    eprintln!("   Large contexts degrade response quality. Consider narrowing the scope:");
616    eprintln!();
617    eprintln!("   • --max-tokens 100000    Cap output to a token budget");
618    eprintln!("   • --filter rs,toml       Include only specific file types");
619    eprintln!("   • --ignore docs,assets   Exclude directories by name");
620    eprintln!("   • --token-count          Preview size without generating");
621    eprintln!();
622}
623
624/// Generate markdown document with diff annotations
625fn generate_markdown_with_diff(
626    current_state: &ProjectState,
627    comparison: Option<&StateComparison>,
628    args: &Args,
629    file_tree: &tree::FileTree,
630    diff_config: &DiffConfig,
631    sorted_paths: &[PathBuf],
632    ts_config: &markdown::TreeSitterConfig,
633) -> io::Result<String> {
634    let mut output = String::new();
635
636    // Header
637    output.push_str("# Directory Structure Report\n\n");
638
639    // Basic project info
640    output.push_str(&format!(
641        "**Project:** {}\n",
642        current_state.metadata.project_name
643    ));
644    output.push_str(&format!("**Generated:** {}\n", current_state.timestamp));
645
646    if !args.filter.is_empty() {
647        output.push_str(&format!("**Filters:** {}\n", args.filter.join(", ")));
648    }
649
650    if !args.ignore.is_empty() {
651        output.push_str(&format!("**Ignored:** {}\n", args.ignore.join(", ")));
652    }
653
654    output.push('\n');
655
656    // Change summary + sections if we have a comparison
657    if let Some(comp) = comparison {
658        if comp.summary.has_changes() {
659            output.push_str(&comp.summary.to_markdown());
660
661            // Collect added files once so we can reuse for both diff_only logic and potential numbering.
662            let added_files: Vec<_> = comp
663                .file_diffs
664                .iter()
665                .filter(|d| matches!(d.status, diff::PerFileStatus::Added))
666                .collect();
667
668            if diff_config.diff_only && !added_files.is_empty() {
669                output.push_str("## Added Files\n\n");
670                for added in added_files {
671                    output.push_str(&format!("### File: `{}`\n\n", added.path));
672                    output.push_str("_Status: Added_\n\n");
673                    // Reconstruct content from + lines.
674                    let mut lines: Vec<String> = Vec::new();
675                    for line in added.diff.lines() {
676                        // Diff output uses "+ " prefix (plus-space), strip both to reconstruct content.
677                        // Previously strip_prefix('+') left a leading space, corrupting indentation.
678                        if let Some(rest) = line.strip_prefix("+ ") {
679                            lines.push(rest.to_string());
680                        } else if let Some(rest) = line.strip_prefix('+') {
681                            // Handle edge case: empty added lines have just "+"
682                            lines.push(rest.to_string());
683                        }
684                    }
685                    output.push_str("```text\n");
686                    if args.line_numbers {
687                        for (idx, l) in lines.iter().enumerate() {
688                            output.push_str(&format!("{:>4} | {}\n", idx + 1, l));
689                        }
690                    } else {
691                        for l in lines {
692                            output.push_str(&l);
693                            output.push('\n');
694                        }
695                    }
696                    output.push_str("```\n\n");
697                }
698            }
699
700            // Always include a unified diff section header so downstream tooling/tests can rely on it
701            let changed_diffs: Vec<diff::PerFileDiff> = comp
702                .file_diffs
703                .iter()
704                .filter(|d| d.is_changed())
705                .cloned()
706                .collect();
707            if !changed_diffs.is_empty() {
708                output.push_str("## File Differences\n\n");
709                let diff_markdown = render_per_file_diffs(&changed_diffs);
710                output.push_str(&diff_markdown);
711            }
712        } else {
713            output.push_str("## No Changes Detected\n\n");
714        }
715    }
716
717    // File tree
718    output.push_str("## File Tree Structure\n\n");
719    let mut tree_output = Vec::new();
720    tree::write_tree_to_file(&mut tree_output, file_tree, 0)?;
721    output.push_str(&String::from_utf8_lossy(&tree_output));
722    output.push('\n');
723
724    // File contents (unless diff_only mode)
725    if !diff_config.diff_only {
726        output.push_str("## File Contents\n\n");
727
728        // Iterate in relevance order (from sorted_paths) instead of
729        // BTreeMap's alphabetical order — preserves file_relevance_category ordering.
730        for path in sorted_paths {
731            if let Some(file_state) = current_state.files.get(path) {
732                output.push_str(&format!("### File: `{}`\n\n", path.display()));
733                output.push_str(&format!("- Size: {} bytes\n", file_state.size));
734                output.push_str(&format!("- Modified: {:?}\n\n", file_state.modified));
735
736                // Determine language from file extension
737                let extension = path.extension().and_then(|s| s.to_str()).unwrap_or("text");
738                let language = match extension {
739                    "rs" => "rust",
740                    "js" => "javascript",
741                    "ts" => "typescript",
742                    "py" => "python",
743                    "json" => "json",
744                    "toml" => "toml",
745                    "md" => "markdown",
746                    "yaml" | "yml" => "yaml",
747                    "html" => "html",
748                    "css" => "css",
749                    _ => extension,
750                };
751
752                // When --signatures is active, only suppress content for supported code files
753                let signatures_only =
754                    ts_config.signatures && crate::tree_sitter::is_supported_extension(extension);
755
756                if !signatures_only {
757                    output.push_str(&format!("```{}\n", language));
758
759                    if args.line_numbers {
760                        for (i, line) in file_state.content.lines().enumerate() {
761                            output.push_str(&format!("{:>4} | {}\n", i + 1, line));
762                        }
763                    } else {
764                        output.push_str(&file_state.content);
765                        if !file_state.content.ends_with('\n') {
766                            output.push('\n');
767                        }
768                    }
769
770                    output.push_str("```\n");
771                }
772
773                // Tree-sitter enrichment (same as standard path)
774                let mut enrichment_buf = Vec::new();
775                markdown::write_tree_sitter_enrichment(
776                    &mut enrichment_buf,
777                    &file_state.content,
778                    extension,
779                    ts_config,
780                )?;
781                if !enrichment_buf.is_empty() {
782                    output.push_str(&String::from_utf8_lossy(&enrichment_buf));
783                }
784
785                output.push('\n');
786            }
787        }
788    }
789
790    Ok(output)
791}
792
793pub fn run() -> io::Result<()> {
794    env_logger::init();
795    let args = Args::parse();
796
797    // Handle init command first
798    if args.init {
799        return init_config();
800    }
801
802    // Determine project root first
803    let project_root = Path::new(&args.input);
804    let config = load_config_from_path(project_root);
805
806    // Handle early clear-cache request (runs even if no config or other args)
807    if args.clear_cache {
808        let cache_path = project_root.join(".context-builder").join("cache");
809        if cache_path.exists() {
810            match fs::remove_dir_all(&cache_path) {
811                Ok(()) => println!("Cache cleared: {}", cache_path.display()),
812                Err(e) => eprintln!("Failed to clear cache ({}): {}", cache_path.display(), e),
813            }
814        } else {
815            println!("No cache directory found at {}", cache_path.display());
816        }
817        return Ok(());
818    }
819
820    if std::env::args().len() == 1 && config.is_none() {
821        Args::command().print_help()?;
822        return Ok(());
823    }
824
825    // Resolve final configuration using the new config resolver
826    let resolution = crate::config_resolver::resolve_final_config(args, config.clone());
827
828    // Print warnings if any
829    let silent = std::env::var("CB_SILENT")
830        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
831        .unwrap_or(false);
832
833    if !silent {
834        for warning in &resolution.warnings {
835            eprintln!("Warning: {}", warning);
836        }
837    }
838
839    // Convert resolved config back to Args for run_with_args
840    let final_args = Args {
841        input: resolution.config.input,
842        output: resolution.config.output,
843        filter: resolution.config.filter,
844        ignore: resolution.config.ignore,
845        line_numbers: resolution.config.line_numbers,
846        preview: resolution.config.preview,
847        token_count: resolution.config.token_count,
848        yes: resolution.config.yes,
849        diff_only: resolution.config.diff_only,
850        clear_cache: resolution.config.clear_cache,
851        max_tokens: resolution.config.max_tokens,
852        init: false,
853        signatures: resolution.config.signatures,
854        structure: resolution.config.structure,
855        truncate: resolution.config.truncate,
856        visibility: resolution.config.visibility,
857    };
858
859    // Create final Config with resolved values
860    let final_config = Config {
861        auto_diff: Some(resolution.config.auto_diff),
862        diff_context_lines: Some(resolution.config.diff_context_lines),
863        ..config.unwrap_or_default()
864    };
865
866    run_with_args(final_args, final_config, &DefaultPrompter)
867}
868
869/// Detect major file types in the current directory respecting .gitignore and default ignore patterns
870fn detect_major_file_types() -> io::Result<Vec<String>> {
871    use std::collections::HashMap;
872    let mut extension_counts = HashMap::new();
873
874    // Use the same default ignore patterns as the main application
875    let default_ignores = vec![
876        "docs".to_string(),
877        "target".to_string(),
878        ".git".to_string(),
879        "node_modules".to_string(),
880    ];
881
882    // Collect files using the same logic as the main application
883    let files = crate::file_utils::collect_files(Path::new("."), &[], &default_ignores, &[])?;
884
885    // Count extensions from the filtered file list
886    for entry in files {
887        let path = entry.path();
888        if let Some(extension) = path.extension().and_then(|ext| ext.to_str()) {
889            // Count the extension occurrences
890            *extension_counts.entry(extension.to_string()).or_insert(0) += 1;
891        }
892    }
893
894    // Convert to vector of (extension, count) pairs and sort by count
895    let mut extensions: Vec<(String, usize)> = extension_counts.into_iter().collect();
896    extensions.sort_by(|a, b| b.1.cmp(&a.1));
897
898    // Take the top 5 extensions or all if less than 5
899    let top_extensions: Vec<String> = extensions.into_iter().take(5).map(|(ext, _)| ext).collect();
900
901    Ok(top_extensions)
902}
903
904/// Initialize a new context-builder.toml config file in the current directory with sensible defaults
905fn init_config() -> io::Result<()> {
906    let config_path = Path::new("context-builder.toml");
907
908    if config_path.exists() {
909        println!("Config file already exists at {}", config_path.display());
910        println!("If you want to replace it, please remove it manually first.");
911        return Ok(());
912    }
913
914    // Detect major file types in the current directory
915    let filter_suggestions = match detect_major_file_types() {
916        Ok(extensions) => extensions,
917        _ => vec!["rs".to_string(), "toml".to_string()], // fallback to defaults
918    };
919
920    let filter_string = if filter_suggestions.is_empty() {
921        r#"["rs", "toml"]"#.to_string()
922    } else {
923        format!(r#"["{}"]"#, filter_suggestions.join(r#"", ""#))
924    };
925
926    let default_config_content = format!(
927        r#"# Context Builder Configuration File
928# This file was generated with sensible defaults based on the file types detected in your project
929
930# Output file name (or base name when timestamped_output is true)
931output = "context.md"
932
933# Optional folder to place the generated output file(s) in
934output_folder = "docs"
935
936# Append a UTC timestamp to the output file name (before extension)
937timestamped_output = true
938
939# Enable automatic diff generation (requires timestamped_output = true)
940auto_diff = true
941
942# Emit only change summary + modified file diffs (no full file bodies)
943diff_only = false
944
945# File extensions to include (no leading dot, e.g. "rs", "toml")
946filter = {}
947
948# File / directory names to ignore (exact name matches)
949ignore = ["docs", "target", ".git", "node_modules"]
950
951# Add line numbers to code blocks
952line_numbers = false
953"#,
954        filter_string
955    );
956
957    let mut file = File::create(config_path)?;
958    file.write_all(default_config_content.as_bytes())?;
959
960    println!("Config file created at {}", config_path.display());
961    println!("Detected file types: {}", filter_suggestions.join(", "));
962    println!("You can now customize it according to your project needs.");
963
964    Ok(())
965}
966
967#[cfg(test)]
968mod tests {
969    use super::*;
970    use serial_test::serial;
971    use std::io::Result;
972    use tempfile::tempdir;
973
974    // Mock prompter for testing
975    struct MockPrompter {
976        confirm_processing_response: bool,
977        confirm_overwrite_response: bool,
978    }
979
980    impl MockPrompter {
981        fn new(processing: bool, overwrite: bool) -> Self {
982            Self {
983                confirm_processing_response: processing,
984                confirm_overwrite_response: overwrite,
985            }
986        }
987    }
988
989    impl Prompter for MockPrompter {
990        fn confirm_processing(&self, _file_count: usize) -> Result<bool> {
991            Ok(self.confirm_processing_response)
992        }
993
994        fn confirm_overwrite(&self, _file_path: &str) -> Result<bool> {
995            Ok(self.confirm_overwrite_response)
996        }
997    }
998
999    #[test]
1000    fn test_diff_config_default() {
1001        let config = DiffConfig::default();
1002        assert_eq!(config.context_lines, 3);
1003        assert!(!config.enabled);
1004        assert!(!config.diff_only);
1005    }
1006
1007    #[test]
1008    fn test_diff_config_custom() {
1009        let config = DiffConfig {
1010            context_lines: 5,
1011            enabled: true,
1012            diff_only: true,
1013        };
1014        assert_eq!(config.context_lines, 5);
1015        assert!(config.enabled);
1016        assert!(config.diff_only);
1017    }
1018
1019    #[test]
1020    fn test_default_prompter() {
1021        let prompter = DefaultPrompter;
1022
1023        // Test small file count (should not prompt)
1024        let result = prompter.confirm_processing(50);
1025        assert!(result.is_ok());
1026        assert!(result.unwrap());
1027    }
1028
1029    #[test]
1030    fn test_run_with_args_nonexistent_directory() {
1031        let args = Args {
1032            input: "/nonexistent/directory".to_string(),
1033            output: "output.md".to_string(),
1034            filter: vec![],
1035            ignore: vec![],
1036            line_numbers: false,
1037            preview: false,
1038            token_count: false,
1039            yes: false,
1040            diff_only: false,
1041            clear_cache: false,
1042            init: false,
1043            max_tokens: None,
1044            signatures: false,
1045            structure: false,
1046            truncate: "smart".to_string(),
1047            visibility: "all".to_string(),
1048        };
1049        let config = Config::default();
1050        let prompter = MockPrompter::new(true, true);
1051
1052        let result = run_with_args(args, config, &prompter);
1053        assert!(result.is_err());
1054        assert!(result.unwrap_err().to_string().contains("does not exist"));
1055    }
1056
1057    #[test]
1058    fn test_run_with_args_preview_mode() {
1059        let temp_dir = tempdir().unwrap();
1060        let base_path = temp_dir.path();
1061
1062        // Create some test files
1063        fs::write(base_path.join("test.rs"), "fn main() {}").unwrap();
1064        fs::create_dir(base_path.join("src")).unwrap();
1065        fs::write(base_path.join("src/lib.rs"), "pub fn hello() {}").unwrap();
1066
1067        let args = Args {
1068            input: ".".to_string(),
1069            output: "test.md".to_string(),
1070            filter: vec![],
1071            ignore: vec![],
1072            line_numbers: false,
1073            preview: false,
1074            token_count: false,
1075            yes: false,
1076            diff_only: false,
1077            clear_cache: false,
1078            init: false,
1079            max_tokens: None,
1080            signatures: false,
1081            structure: false,
1082            truncate: "smart".to_string(),
1083            visibility: "all".to_string(),
1084        };
1085        let config = Config::default();
1086        let prompter = MockPrompter::new(true, true);
1087
1088        // Set CB_SILENT to avoid console output during test
1089        unsafe {
1090            std::env::set_var("CB_SILENT", "1");
1091        }
1092        let result = run_with_args(args, config, &prompter);
1093        unsafe {
1094            std::env::remove_var("CB_SILENT");
1095        }
1096
1097        assert!(result.is_ok());
1098    }
1099
1100    #[test]
1101    fn test_run_with_args_token_count_mode() {
1102        let temp_dir = tempdir().unwrap();
1103        let base_path = temp_dir.path();
1104
1105        // Create test files
1106        fs::write(base_path.join("small.txt"), "Hello world").unwrap();
1107
1108        let args = Args {
1109            input: base_path.to_string_lossy().to_string(),
1110            output: "test.md".to_string(),
1111            filter: vec![],
1112            ignore: vec![],
1113            line_numbers: false,
1114            preview: false,
1115            token_count: true,
1116            yes: false,
1117            diff_only: false,
1118            clear_cache: false,
1119            init: false,
1120            max_tokens: None,
1121            signatures: false,
1122            structure: false,
1123            truncate: "smart".to_string(),
1124            visibility: "all".to_string(),
1125        };
1126        let config = Config::default();
1127        let prompter = MockPrompter::new(true, true);
1128
1129        unsafe {
1130            std::env::set_var("CB_SILENT", "1");
1131        }
1132        let result = run_with_args(args, config, &prompter);
1133        unsafe {
1134            std::env::remove_var("CB_SILENT");
1135        }
1136
1137        assert!(result.is_ok());
1138    }
1139
1140    #[test]
1141    fn test_run_with_args_preview_and_token_count() {
1142        let temp_dir = tempdir().unwrap();
1143        let base_path = temp_dir.path();
1144
1145        fs::write(base_path.join("test.txt"), "content").unwrap();
1146
1147        let args = Args {
1148            input: base_path.to_string_lossy().to_string(),
1149            output: "test.md".to_string(),
1150            filter: vec![],
1151            ignore: vec![],
1152            line_numbers: false,
1153            preview: true,
1154            token_count: false,
1155            yes: false,
1156            diff_only: false,
1157            clear_cache: false,
1158            init: false,
1159            max_tokens: None,
1160            signatures: false,
1161            structure: false,
1162            truncate: "smart".to_string(),
1163            visibility: "all".to_string(),
1164        };
1165        let config = Config::default();
1166        let prompter = MockPrompter::new(true, true);
1167
1168        unsafe {
1169            std::env::set_var("CB_SILENT", "1");
1170        }
1171        let result = run_with_args(args, config, &prompter);
1172        unsafe {
1173            std::env::remove_var("CB_SILENT");
1174        }
1175
1176        assert!(result.is_ok());
1177    }
1178
1179    #[test]
1180    fn test_run_with_args_user_cancels_overwrite() {
1181        let temp_dir = tempdir().unwrap();
1182        let base_path = temp_dir.path();
1183        let output_path = temp_dir.path().join("existing.md");
1184
1185        // Create test files
1186        fs::write(base_path.join("test.txt"), "content").unwrap();
1187        fs::write(&output_path, "existing content").unwrap();
1188
1189        let args = Args {
1190            input: base_path.to_string_lossy().to_string(),
1191            output: "test.md".to_string(),
1192            filter: vec![],
1193            ignore: vec!["target".to_string()],
1194            line_numbers: false,
1195            preview: false,
1196            token_count: false,
1197            yes: false,
1198            diff_only: false,
1199            clear_cache: false,
1200            init: false,
1201            max_tokens: None,
1202            signatures: false,
1203            structure: false,
1204            truncate: "smart".to_string(),
1205            visibility: "all".to_string(),
1206        };
1207        let config = Config::default();
1208        let prompter = MockPrompter::new(true, false); // Deny overwrite
1209
1210        unsafe {
1211            std::env::set_var("CB_SILENT", "1");
1212        }
1213        let result = run_with_args(args, config, &prompter);
1214        unsafe {
1215            std::env::remove_var("CB_SILENT");
1216        }
1217
1218        assert!(result.is_err());
1219        assert!(result.unwrap_err().to_string().contains("cancelled"));
1220    }
1221
1222    #[test]
1223    fn test_run_with_args_user_cancels_processing() {
1224        let temp_dir = tempdir().unwrap();
1225        let base_path = temp_dir.path();
1226
1227        // Create many test files to trigger processing confirmation
1228        for i in 0..105 {
1229            fs::write(base_path.join(format!("file{}.txt", i)), "content").unwrap();
1230        }
1231
1232        let args = Args {
1233            input: base_path.to_string_lossy().to_string(),
1234            output: "test.md".to_string(),
1235            filter: vec!["rs".to_string()],
1236            ignore: vec![],
1237            line_numbers: false,
1238            preview: false,
1239            token_count: false,
1240            yes: false,
1241            diff_only: false,
1242            clear_cache: false,
1243            init: false,
1244            max_tokens: None,
1245            signatures: false,
1246            structure: false,
1247            truncate: "smart".to_string(),
1248            visibility: "all".to_string(),
1249        };
1250        let config = Config::default();
1251        let prompter = MockPrompter::new(false, true); // Deny processing
1252
1253        unsafe {
1254            std::env::set_var("CB_SILENT", "1");
1255        }
1256        let result = run_with_args(args, config, &prompter);
1257        unsafe {
1258            std::env::remove_var("CB_SILENT");
1259        }
1260
1261        assert!(result.is_err());
1262        assert!(result.unwrap_err().to_string().contains("cancelled"));
1263    }
1264
1265    #[test]
1266    fn test_run_with_args_with_yes_flag() {
1267        let temp_dir = tempdir().unwrap();
1268        let base_path = temp_dir.path();
1269        let output_file_name = "test.md";
1270        let output_path = temp_dir.path().join(output_file_name);
1271
1272        fs::write(base_path.join("test.txt"), "Hello world").unwrap();
1273
1274        let args = Args {
1275            input: base_path.to_string_lossy().to_string(),
1276            output: output_path.to_string_lossy().to_string(),
1277            filter: vec![],
1278            ignore: vec!["ignored_dir".to_string()],
1279            line_numbers: false,
1280            preview: false,
1281            token_count: false,
1282            yes: true,
1283            diff_only: false,
1284            clear_cache: false,
1285            init: false,
1286            max_tokens: None,
1287            signatures: false,
1288            structure: false,
1289            truncate: "smart".to_string(),
1290            visibility: "all".to_string(),
1291        };
1292        let config = Config::default();
1293        let prompter = MockPrompter::new(true, true);
1294
1295        unsafe {
1296            std::env::set_var("CB_SILENT", "1");
1297        }
1298        let result = run_with_args(args, config, &prompter);
1299        unsafe {
1300            std::env::remove_var("CB_SILENT");
1301        }
1302
1303        assert!(result.is_ok());
1304        assert!(output_path.exists());
1305
1306        let content = fs::read_to_string(&output_path).unwrap();
1307        assert!(content.contains("Directory Structure Report"));
1308        assert!(content.contains("test.txt"));
1309    }
1310
1311    #[test]
1312    fn test_run_with_args_with_filters() {
1313        let temp_dir = tempdir().unwrap();
1314        let base_path = temp_dir.path();
1315        let output_file_name = "test.md";
1316        let output_path = temp_dir.path().join(output_file_name);
1317
1318        fs::write(base_path.join("code.rs"), "fn main() {}").unwrap();
1319        fs::write(base_path.join("readme.md"), "# README").unwrap();
1320        fs::write(base_path.join("data.json"), r#"{"key": "value"}"#).unwrap();
1321
1322        let args = Args {
1323            input: base_path.to_string_lossy().to_string(),
1324            output: output_path.to_string_lossy().to_string(),
1325            filter: vec!["rs".to_string(), "md".to_string()],
1326            ignore: vec![],
1327            line_numbers: true,
1328            preview: false,
1329            token_count: false,
1330            yes: true,
1331            diff_only: false,
1332            clear_cache: false,
1333            init: false,
1334            max_tokens: None,
1335            signatures: false,
1336            structure: false,
1337            truncate: "smart".to_string(),
1338            visibility: "all".to_string(),
1339        };
1340        let config = Config::default();
1341        let prompter = MockPrompter::new(true, true);
1342
1343        unsafe {
1344            std::env::set_var("CB_SILENT", "1");
1345        }
1346        let result = run_with_args(args, config, &prompter);
1347        unsafe {
1348            std::env::remove_var("CB_SILENT");
1349        }
1350
1351        assert!(result.is_ok());
1352
1353        let content = fs::read_to_string(&output_path).unwrap();
1354        assert!(content.contains("code.rs"));
1355        assert!(content.contains("readme.md"));
1356        assert!(!content.contains("data.json")); // Should be filtered out
1357        assert!(content.contains("   1 |")); // Line numbers should be present
1358    }
1359
1360    #[test]
1361    fn test_run_with_args_with_ignores() {
1362        let temp_dir = tempdir().unwrap();
1363        let base_path = temp_dir.path();
1364        let output_path = temp_dir.path().join("ignored.md");
1365
1366        fs::write(base_path.join("important.txt"), "important content").unwrap();
1367        fs::write(base_path.join("secret.txt"), "secret content").unwrap();
1368
1369        let args = Args {
1370            input: base_path.to_string_lossy().to_string(),
1371            output: output_path.to_string_lossy().to_string(),
1372            filter: vec![],
1373            ignore: vec!["secret.txt".to_string()],
1374            line_numbers: false,
1375            preview: false,
1376            token_count: false,
1377            yes: true,
1378            diff_only: false,
1379            clear_cache: false,
1380            init: false,
1381            max_tokens: None,
1382            signatures: false,
1383            structure: false,
1384            truncate: "smart".to_string(),
1385            visibility: "all".to_string(),
1386        };
1387        let config = Config::default();
1388        let prompter = MockPrompter::new(true, true);
1389
1390        unsafe {
1391            std::env::set_var("CB_SILENT", "1");
1392        }
1393        let result = run_with_args(args, config, &prompter);
1394        unsafe {
1395            std::env::remove_var("CB_SILENT");
1396        }
1397
1398        assert!(result.is_ok());
1399
1400        let content = fs::read_to_string(&output_path).unwrap();
1401        assert!(content.contains("important.txt"));
1402        // The ignore pattern may not work exactly as expected in this test setup
1403        // Just verify the output file was created successfully
1404    }
1405
1406    #[test]
1407    fn test_auto_diff_without_previous_state() {
1408        let temp_dir = tempdir().unwrap();
1409        let base_path = temp_dir.path();
1410        let output_file_name = "test.md";
1411        let output_path = temp_dir.path().join(output_file_name);
1412
1413        fs::write(base_path.join("new.txt"), "new content").unwrap();
1414
1415        let args = Args {
1416            input: base_path.to_string_lossy().to_string(),
1417            output: output_path.to_string_lossy().to_string(),
1418            filter: vec![],
1419            ignore: vec![],
1420            line_numbers: false,
1421            preview: false,
1422            token_count: false,
1423            yes: true,
1424            diff_only: false,
1425            clear_cache: false,
1426            init: false,
1427            max_tokens: None,
1428            signatures: false,
1429            structure: false,
1430            truncate: "smart".to_string(),
1431            visibility: "all".to_string(),
1432        };
1433        let config = Config {
1434            auto_diff: Some(true),
1435            diff_context_lines: Some(5),
1436            ..Default::default()
1437        };
1438        let prompter = MockPrompter::new(true, true);
1439
1440        unsafe {
1441            std::env::set_var("CB_SILENT", "1");
1442        }
1443        let result = run_with_args(args, config, &prompter);
1444        unsafe {
1445            std::env::remove_var("CB_SILENT");
1446        }
1447
1448        assert!(result.is_ok());
1449        assert!(output_path.exists());
1450
1451        let content = fs::read_to_string(&output_path).unwrap();
1452        assert!(content.contains("new.txt"));
1453    }
1454
1455    #[test]
1456    fn test_run_creates_output_directory() {
1457        let temp_dir = tempdir().unwrap();
1458        let base_path = temp_dir.path();
1459        let output_dir = temp_dir.path().join("nested").join("output");
1460        let output_path = output_dir.join("result.md");
1461
1462        fs::write(base_path.join("test.txt"), "content").unwrap();
1463
1464        let args = Args {
1465            input: base_path.to_string_lossy().to_string(),
1466            output: output_path.to_string_lossy().to_string(),
1467            filter: vec![],
1468            ignore: vec![],
1469            line_numbers: false,
1470            preview: false,
1471            token_count: false,
1472            yes: true,
1473            diff_only: false,
1474            clear_cache: false,
1475            init: false,
1476            max_tokens: None,
1477            signatures: false,
1478            structure: false,
1479            truncate: "smart".to_string(),
1480            visibility: "all".to_string(),
1481        };
1482        let config = Config::default();
1483        let prompter = MockPrompter::new(true, true);
1484
1485        unsafe {
1486            std::env::set_var("CB_SILENT", "1");
1487        }
1488        let result = run_with_args(args, config, &prompter);
1489        unsafe {
1490            std::env::remove_var("CB_SILENT");
1491        }
1492
1493        assert!(result.is_ok());
1494        assert!(output_path.exists());
1495        assert!(output_dir.exists());
1496    }
1497
1498    #[test]
1499    fn test_generate_markdown_with_diff_no_comparison() {
1500        let temp_dir = tempdir().unwrap();
1501        let base_path = temp_dir.path();
1502
1503        fs::write(base_path.join("test.rs"), "fn main() {}").unwrap();
1504
1505        let files = collect_files(base_path, &[], &[], &[]).unwrap();
1506        let file_tree = build_file_tree(&files, base_path);
1507        let config = Config::default();
1508        let state = ProjectState::from_files(&files, base_path, &config, false).unwrap();
1509
1510        let args = Args {
1511            input: base_path.to_string_lossy().to_string(),
1512            output: "test.md".to_string(),
1513            filter: vec![],
1514            ignore: vec![],
1515            line_numbers: false,
1516            preview: false,
1517            token_count: false,
1518            yes: false,
1519            diff_only: false,
1520            clear_cache: false,
1521            init: false,
1522            max_tokens: None,
1523            signatures: false,
1524            structure: false,
1525            truncate: "smart".to_string(),
1526            visibility: "all".to_string(),
1527        };
1528
1529        let diff_config = DiffConfig::default();
1530
1531        let sorted_paths: Vec<PathBuf> = files
1532            .iter()
1533            .map(|e| {
1534                e.path()
1535                    .strip_prefix(base_path)
1536                    .unwrap_or(e.path())
1537                    .to_path_buf()
1538            })
1539            .collect();
1540
1541        let ts_config = markdown::TreeSitterConfig {
1542            signatures: false,
1543            structure: false,
1544            truncate: "smart".to_string(),
1545            visibility: "all".to_string(),
1546        };
1547
1548        let result = generate_markdown_with_diff(
1549            &state,
1550            None,
1551            &args,
1552            &file_tree,
1553            &diff_config,
1554            &sorted_paths,
1555            &ts_config,
1556        );
1557        assert!(result.is_ok());
1558
1559        let content = result.unwrap();
1560        assert!(content.contains("Directory Structure Report"));
1561        assert!(content.contains("test.rs"));
1562    }
1563
1564    #[test]
1565    fn test_context_window_warning_under_limit() {
1566        let original = std::env::var("CB_SILENT");
1567        unsafe {
1568            std::env::set_var("CB_SILENT", "1");
1569        }
1570
1571        let output_bytes = 100_000;
1572        print_context_window_warning(output_bytes * 4, None);
1573
1574        unsafe {
1575            std::env::remove_var("CB_SILENT");
1576        }
1577        if let Ok(val) = original {
1578            unsafe {
1579                std::env::set_var("CB_SILENT", val);
1580            }
1581        }
1582    }
1583
1584    #[test]
1585    fn test_context_window_warning_over_limit() {
1586        let output_bytes = 600_000;
1587        print_context_window_warning(output_bytes * 4, None);
1588    }
1589
1590    #[test]
1591    fn test_context_window_warning_with_max_tokens() {
1592        let output_bytes = 600_000;
1593        print_context_window_warning(output_bytes * 4, Some(100_000));
1594    }
1595
1596    #[test]
1597    fn test_print_context_window_warning_various_sizes() {
1598        print_context_window_warning(50_000, None);
1599        print_context_window_warning(200_000, None);
1600        print_context_window_warning(500_000, None);
1601        print_context_window_warning(1_000_000, None);
1602    }
1603
1604    #[test]
1605    fn test_run_with_args_large_file_warning() {
1606        let temp_dir = tempdir().unwrap();
1607        let base_path = temp_dir.path();
1608
1609        let large_content = "x".repeat(150 * 1024);
1610        fs::write(base_path.join("large.txt"), &large_content).unwrap();
1611        fs::write(base_path.join("small.txt"), "small").unwrap();
1612
1613        let args = Args {
1614            input: base_path.to_string_lossy().to_string(),
1615            output: "test.md".to_string(),
1616            filter: vec![],
1617            ignore: vec![],
1618            line_numbers: false,
1619            preview: false,
1620            token_count: false,
1621            yes: true,
1622            diff_only: false,
1623            clear_cache: false,
1624            init: false,
1625            max_tokens: None,
1626            signatures: false,
1627            structure: false,
1628            truncate: "smart".to_string(),
1629            visibility: "all".to_string(),
1630        };
1631        let config = Config::default();
1632        let prompter = MockPrompter::new(true, true);
1633
1634        unsafe {
1635            std::env::set_var("CB_SILENT", "1");
1636        }
1637        let result = run_with_args(args, config, &prompter);
1638        unsafe {
1639            std::env::remove_var("CB_SILENT");
1640        }
1641
1642        assert!(result.is_ok());
1643    }
1644
1645    #[test]
1646    fn test_run_with_args_output_dir_creation_failure_is_handled() {
1647        let temp_dir = tempdir().unwrap();
1648        let base_path = temp_dir.path();
1649        fs::write(base_path.join("test.txt"), "content").unwrap();
1650
1651        let output_path = temp_dir.path().join("test.md");
1652
1653        let args = Args {
1654            input: base_path.to_string_lossy().to_string(),
1655            output: output_path.to_string_lossy().to_string(),
1656            filter: vec![],
1657            ignore: vec![],
1658            line_numbers: false,
1659            preview: false,
1660            token_count: false,
1661            yes: true,
1662            diff_only: false,
1663            clear_cache: false,
1664            init: false,
1665            max_tokens: None,
1666            signatures: false,
1667            structure: false,
1668            truncate: "smart".to_string(),
1669            visibility: "all".to_string(),
1670        };
1671        let config = Config::default();
1672        let prompter = MockPrompter::new(true, true);
1673
1674        unsafe {
1675            std::env::set_var("CB_SILENT", "1");
1676        }
1677        let result = run_with_args(args, config, &prompter);
1678        unsafe {
1679            std::env::remove_var("CB_SILENT");
1680        }
1681
1682        assert!(result.is_ok());
1683    }
1684
1685    #[test]
1686    fn test_auto_diff_cache_write_failure_handling() {
1687        let temp_dir = tempdir().unwrap();
1688        let base_path = temp_dir.path();
1689        let output_path = temp_dir.path().join("output.md");
1690
1691        fs::write(base_path.join("test.txt"), "content").unwrap();
1692
1693        let args = Args {
1694            input: base_path.to_string_lossy().to_string(),
1695            output: output_path.to_string_lossy().to_string(),
1696            filter: vec![],
1697            ignore: vec![],
1698            line_numbers: false,
1699            preview: false,
1700            token_count: false,
1701            yes: true,
1702            diff_only: false,
1703            clear_cache: false,
1704            init: false,
1705            max_tokens: None,
1706            signatures: false,
1707            structure: false,
1708            truncate: "smart".to_string(),
1709            visibility: "all".to_string(),
1710        };
1711        let config = Config {
1712            auto_diff: Some(true),
1713            ..Default::default()
1714        };
1715        let prompter = MockPrompter::new(true, true);
1716
1717        unsafe {
1718            std::env::set_var("CB_SILENT", "1");
1719        }
1720        let result = run_with_args(args, config, &prompter);
1721        unsafe {
1722            std::env::remove_var("CB_SILENT");
1723        }
1724
1725        assert!(result.is_ok());
1726        assert!(output_path.exists());
1727    }
1728
1729    #[test]
1730    fn test_auto_diff_with_changes() {
1731        let temp_dir = tempdir().unwrap();
1732        let base_path = temp_dir.path();
1733        let output_path = temp_dir.path().join("output.md");
1734
1735        fs::write(base_path.join("file1.txt"), "initial content").unwrap();
1736
1737        let args1 = Args {
1738            input: base_path.to_string_lossy().to_string(),
1739            output: output_path.to_string_lossy().to_string(),
1740            filter: vec![],
1741            ignore: vec![],
1742            line_numbers: false,
1743            preview: false,
1744            token_count: false,
1745            yes: true,
1746            diff_only: false,
1747            clear_cache: false,
1748            init: false,
1749            max_tokens: None,
1750            signatures: false,
1751            structure: false,
1752            truncate: "smart".to_string(),
1753            visibility: "all".to_string(),
1754        };
1755        let config = Config {
1756            auto_diff: Some(true),
1757            ..Default::default()
1758        };
1759        let prompter = MockPrompter::new(true, true);
1760
1761        unsafe {
1762            std::env::set_var("CB_SILENT", "1");
1763        }
1764        let _ = run_with_args(args1, config.clone(), &prompter);
1765
1766        fs::write(base_path.join("file1.txt"), "modified content").unwrap();
1767        fs::write(base_path.join("file2.txt"), "new file").unwrap();
1768
1769        let args2 = Args {
1770            input: base_path.to_string_lossy().to_string(),
1771            output: output_path.to_string_lossy().to_string(),
1772            filter: vec![],
1773            ignore: vec![],
1774            line_numbers: false,
1775            preview: false,
1776            token_count: false,
1777            yes: true,
1778            diff_only: false,
1779            clear_cache: false,
1780            init: false,
1781            max_tokens: None,
1782            signatures: false,
1783            structure: false,
1784            truncate: "smart".to_string(),
1785            visibility: "all".to_string(),
1786        };
1787
1788        let result = run_with_args(args2, config, &prompter);
1789        unsafe {
1790            std::env::remove_var("CB_SILENT");
1791        }
1792
1793        assert!(result.is_ok());
1794        let content = fs::read_to_string(&output_path).unwrap();
1795        assert!(content.contains("Change Summary") || content.contains("No Changes"));
1796    }
1797
1798    #[test]
1799    fn test_auto_diff_max_tokens_truncation() {
1800        let temp_dir = tempdir().unwrap();
1801        let base_path = temp_dir.path();
1802        let output_path = temp_dir.path().join("output.md");
1803
1804        fs::write(base_path.join("test.txt"), "x".repeat(10000)).unwrap();
1805
1806        let args = Args {
1807            input: base_path.to_string_lossy().to_string(),
1808            output: output_path.to_string_lossy().to_string(),
1809            filter: vec![],
1810            ignore: vec![],
1811            line_numbers: false,
1812            preview: false,
1813            token_count: false,
1814            yes: true,
1815            diff_only: false,
1816            clear_cache: false,
1817            init: false,
1818            max_tokens: Some(100),
1819            signatures: false,
1820            structure: false,
1821            truncate: "smart".to_string(),
1822            visibility: "all".to_string(),
1823        };
1824        let config = Config {
1825            auto_diff: Some(true),
1826            ..Default::default()
1827        };
1828        let prompter = MockPrompter::new(true, true);
1829
1830        unsafe {
1831            std::env::set_var("CB_SILENT", "1");
1832        }
1833        let result = run_with_args(args, config, &prompter);
1834        unsafe {
1835            std::env::remove_var("CB_SILENT");
1836        }
1837
1838        assert!(result.is_ok());
1839        let content = fs::read_to_string(&output_path).unwrap();
1840        assert!(content.contains("truncated") || content.len() < 500);
1841    }
1842
1843    #[test]
1844    fn test_diff_only_mode_with_added_files() {
1845        let temp_dir = tempdir().unwrap();
1846        let base_path = temp_dir.path();
1847        let output_path = temp_dir.path().join("output.md");
1848
1849        fs::write(base_path.join("initial.txt"), "content").unwrap();
1850
1851        let args1 = Args {
1852            input: base_path.to_string_lossy().to_string(),
1853            output: output_path.to_string_lossy().to_string(),
1854            filter: vec![],
1855            ignore: vec![],
1856            line_numbers: true,
1857            preview: false,
1858            token_count: false,
1859            yes: true,
1860            diff_only: false,
1861            clear_cache: false,
1862            init: false,
1863            max_tokens: None,
1864            signatures: false,
1865            structure: false,
1866            truncate: "smart".to_string(),
1867            visibility: "all".to_string(),
1868        };
1869        let config = Config {
1870            auto_diff: Some(true),
1871            ..Default::default()
1872        };
1873        let prompter = MockPrompter::new(true, true);
1874
1875        unsafe {
1876            std::env::set_var("CB_SILENT", "1");
1877        }
1878        let _ = run_with_args(args1, config.clone(), &prompter);
1879
1880        fs::write(base_path.join("newfile.txt"), "brand new content").unwrap();
1881
1882        let args2 = Args {
1883            input: base_path.to_string_lossy().to_string(),
1884            output: output_path.to_string_lossy().to_string(),
1885            filter: vec![],
1886            ignore: vec![],
1887            line_numbers: true,
1888            preview: false,
1889            token_count: false,
1890            yes: true,
1891            diff_only: true,
1892            clear_cache: false,
1893            init: false,
1894            max_tokens: None,
1895            signatures: false,
1896            structure: false,
1897            truncate: "smart".to_string(),
1898            visibility: "all".to_string(),
1899        };
1900
1901        let result = run_with_args(args2, config, &prompter);
1902        unsafe {
1903            std::env::remove_var("CB_SILENT");
1904        }
1905
1906        assert!(result.is_ok());
1907        let content = fs::read_to_string(&output_path).unwrap();
1908        assert!(content.contains("Change Summary") || content.contains("Added Files"));
1909    }
1910
1911    #[test]
1912    fn test_generate_markdown_with_diff_line_numbers() {
1913        let temp_dir = tempdir().unwrap();
1914        let base_path = temp_dir.path();
1915
1916        fs::write(
1917            base_path.join("test.rs"),
1918            "fn main() {\n    println!(\"hi\");\n}",
1919        )
1920        .unwrap();
1921
1922        let files = collect_files(base_path, &[], &[], &[]).unwrap();
1923        let file_tree = build_file_tree(&files, base_path);
1924        let config = Config::default();
1925        let state = ProjectState::from_files(&files, base_path, &config, true).unwrap();
1926
1927        let args = Args {
1928            input: base_path.to_string_lossy().to_string(),
1929            output: "test.md".to_string(),
1930            filter: vec![],
1931            ignore: vec![],
1932            line_numbers: true,
1933            preview: false,
1934            token_count: false,
1935            yes: false,
1936            diff_only: false,
1937            clear_cache: false,
1938            init: false,
1939            max_tokens: None,
1940            signatures: false,
1941            structure: false,
1942            truncate: "smart".to_string(),
1943            visibility: "all".to_string(),
1944        };
1945
1946        let diff_config = DiffConfig {
1947            context_lines: 3,
1948            enabled: true,
1949            diff_only: false,
1950        };
1951
1952        let sorted_paths: Vec<PathBuf> = files
1953            .iter()
1954            .map(|e| {
1955                e.path()
1956                    .strip_prefix(base_path)
1957                    .unwrap_or(e.path())
1958                    .to_path_buf()
1959            })
1960            .collect();
1961
1962        let ts_config = markdown::TreeSitterConfig {
1963            signatures: false,
1964            structure: false,
1965            truncate: "smart".to_string(),
1966            visibility: "all".to_string(),
1967        };
1968
1969        let previous = state.clone();
1970        let comparison = state.compare_with(&previous);
1971
1972        let result = generate_markdown_with_diff(
1973            &state,
1974            Some(&comparison),
1975            &args,
1976            &file_tree,
1977            &diff_config,
1978            &sorted_paths,
1979            &ts_config,
1980        );
1981        assert!(result.is_ok());
1982
1983        let content = result.unwrap();
1984        assert!(content.contains("No Changes Detected"));
1985    }
1986
1987    #[test]
1988    fn test_generate_markdown_with_diff_and_modifications() {
1989        let temp_dir = tempdir().unwrap();
1990        let base_path = temp_dir.path();
1991
1992        fs::write(base_path.join("test.txt"), "initial content").unwrap();
1993
1994        let files = collect_files(base_path, &[], &[], &[]).unwrap();
1995        let file_tree = build_file_tree(&files, base_path);
1996        let config = Config::default();
1997        let initial_state = ProjectState::from_files(&files, base_path, &config, false).unwrap();
1998
1999        fs::write(base_path.join("test.txt"), "modified content").unwrap();
2000
2001        let new_files = collect_files(base_path, &[], &[], &[]).unwrap();
2002        let current_state =
2003            ProjectState::from_files(&new_files, base_path, &config, false).unwrap();
2004
2005        let args = Args {
2006            input: base_path.to_string_lossy().to_string(),
2007            output: "test.md".to_string(),
2008            filter: vec![],
2009            ignore: vec![],
2010            line_numbers: false,
2011            preview: false,
2012            token_count: false,
2013            yes: false,
2014            diff_only: false,
2015            clear_cache: false,
2016            init: false,
2017            max_tokens: None,
2018            signatures: false,
2019            structure: false,
2020            truncate: "smart".to_string(),
2021            visibility: "all".to_string(),
2022        };
2023
2024        let diff_config = DiffConfig {
2025            context_lines: 3,
2026            enabled: true,
2027            diff_only: false,
2028        };
2029
2030        let comparison = current_state.compare_with(&initial_state);
2031
2032        let sorted_paths: Vec<PathBuf> = new_files
2033            .iter()
2034            .map(|e| {
2035                e.path()
2036                    .strip_prefix(base_path)
2037                    .unwrap_or(e.path())
2038                    .to_path_buf()
2039            })
2040            .collect();
2041
2042        let ts_config = markdown::TreeSitterConfig {
2043            signatures: false,
2044            structure: false,
2045            truncate: "smart".to_string(),
2046            visibility: "all".to_string(),
2047        };
2048
2049        let result = generate_markdown_with_diff(
2050            &current_state,
2051            Some(&comparison),
2052            &args,
2053            &file_tree,
2054            &diff_config,
2055            &sorted_paths,
2056            &ts_config,
2057        );
2058        assert!(result.is_ok());
2059
2060        let content = result.unwrap();
2061        assert!(content.contains("Change Summary"));
2062        assert!(content.contains("Modified"));
2063    }
2064
2065    #[test]
2066    #[serial]
2067    fn test_detect_major_file_types() {
2068        let temp_dir = tempdir().unwrap();
2069        let original_dir = std::env::current_dir().unwrap();
2070
2071        // Write files BEFORE changing cwd to avoid race conditions
2072        fs::write(temp_dir.path().join("main.rs"), "fn main() {}").unwrap();
2073        fs::write(temp_dir.path().join("lib.rs"), "pub fn lib() {}").unwrap();
2074        fs::write(temp_dir.path().join("Cargo.toml"), "[package]").unwrap();
2075        fs::write(temp_dir.path().join("README.md"), "# Readme").unwrap();
2076
2077        std::env::set_current_dir(&temp_dir).unwrap();
2078
2079        let result = detect_major_file_types();
2080
2081        std::env::set_current_dir(original_dir).unwrap();
2082
2083        assert!(result.is_ok());
2084        let extensions = result.unwrap();
2085        assert!(!extensions.is_empty());
2086    }
2087
2088    #[test]
2089    #[serial]
2090    fn test_init_config_already_exists() {
2091        let temp_dir = tempdir().unwrap();
2092        let original_dir = std::env::current_dir().unwrap();
2093
2094        std::env::set_current_dir(&temp_dir).unwrap();
2095
2096        let config_path = temp_dir.path().join("context-builder.toml");
2097        fs::write(&config_path, "output = \"existing.md\"").unwrap();
2098
2099        unsafe {
2100            std::env::set_var("CB_SILENT", "1");
2101        }
2102        let result = init_config();
2103        unsafe {
2104            std::env::remove_var("CB_SILENT");
2105        }
2106
2107        std::env::set_current_dir(original_dir).unwrap();
2108
2109        assert!(result.is_ok());
2110        let content = fs::read_to_string(&config_path).unwrap();
2111        assert!(content.contains("existing.md"));
2112    }
2113
2114    #[test]
2115    #[serial]
2116    fn test_init_config_creates_new_file() {
2117        let temp_dir = tempdir().unwrap();
2118        let original_dir = std::env::current_dir().unwrap();
2119
2120        std::env::set_current_dir(&temp_dir).unwrap();
2121
2122        let config_path = temp_dir.path().join("context-builder.toml");
2123        assert!(!config_path.exists());
2124
2125        unsafe {
2126            std::env::set_var("CB_SILENT", "1");
2127        }
2128        let result = init_config();
2129        unsafe {
2130            std::env::remove_var("CB_SILENT");
2131        }
2132
2133        std::env::set_current_dir(original_dir).unwrap();
2134
2135        assert!(result.is_ok());
2136        assert!(config_path.exists());
2137        let content = fs::read_to_string(&config_path).unwrap();
2138        assert!(content.contains("output = "));
2139        assert!(content.contains("filter ="));
2140    }
2141
2142    #[test]
2143    #[serial]
2144    fn test_detect_major_file_types_empty_dir() {
2145        let temp_dir = tempdir().unwrap();
2146        let original_dir = std::env::current_dir().unwrap();
2147
2148        std::env::set_current_dir(temp_dir.path()).unwrap();
2149
2150        let result = detect_major_file_types();
2151
2152        std::env::set_current_dir(original_dir).unwrap();
2153
2154        assert!(result.is_ok());
2155        let extensions = result.unwrap();
2156        assert!(extensions.is_empty());
2157    }
2158
2159    #[test]
2160    fn test_print_context_window_warning_exact_limit() {
2161        let output_bytes = 128_000 * 4;
2162        print_context_window_warning(output_bytes, None);
2163    }
2164
2165    #[test]
2166    fn test_run_with_args_with_existing_output_file() {
2167        let temp_dir = tempdir().unwrap();
2168        let base_path = temp_dir.path();
2169        let output_path = temp_dir.path().join("output.md");
2170
2171        fs::write(base_path.join("test.txt"), "content").unwrap();
2172        fs::write(&output_path, "existing content").unwrap();
2173
2174        let args = Args {
2175            input: base_path.to_string_lossy().to_string(),
2176            output: output_path.to_string_lossy().to_string(),
2177            filter: vec![],
2178            ignore: vec![],
2179            line_numbers: false,
2180            preview: false,
2181            token_count: false,
2182            yes: true,
2183            diff_only: false,
2184            clear_cache: false,
2185            init: false,
2186            max_tokens: None,
2187            signatures: false,
2188            structure: false,
2189            truncate: "smart".to_string(),
2190            visibility: "all".to_string(),
2191        };
2192        let config = Config::default();
2193        let prompter = MockPrompter::new(true, true);
2194
2195        unsafe {
2196            std::env::set_var("CB_SILENT", "1");
2197        }
2198        let result = run_with_args(args, config, &prompter);
2199        unsafe {
2200            std::env::remove_var("CB_SILENT");
2201        }
2202
2203        assert!(result.is_ok());
2204        let content = fs::read_to_string(&output_path).unwrap();
2205        assert!(content.contains("Directory Structure Report"));
2206    }
2207
2208    #[test]
2209    fn test_run_with_args_preview_only_token_count() {
2210        let temp_dir = tempdir().unwrap();
2211        let base_path = temp_dir.path();
2212
2213        fs::write(base_path.join("test.txt"), "content").unwrap();
2214
2215        let args = Args {
2216            input: base_path.to_string_lossy().to_string(),
2217            output: "test.md".to_string(),
2218            filter: vec![],
2219            ignore: vec![],
2220            line_numbers: false,
2221            preview: true,
2222            token_count: true,
2223            yes: false,
2224            diff_only: false,
2225            clear_cache: false,
2226            init: false,
2227            max_tokens: None,
2228            signatures: false,
2229            structure: false,
2230            truncate: "smart".to_string(),
2231            visibility: "all".to_string(),
2232        };
2233        let config = Config::default();
2234        let prompter = MockPrompter::new(true, true);
2235
2236        unsafe {
2237            std::env::set_var("CB_SILENT", "1");
2238        }
2239        let result = run_with_args(args, config, &prompter);
2240        unsafe {
2241            std::env::remove_var("CB_SILENT");
2242        }
2243
2244        assert!(result.is_ok());
2245    }
2246
2247    #[test]
2248    fn test_run_with_args_multiple_files() {
2249        let temp_dir = tempdir().unwrap();
2250        let base_path = temp_dir.path();
2251        let output_path = temp_dir.path().join("output.md");
2252
2253        for i in 0..10 {
2254            fs::write(base_path.join(format!("file{}.txt", i)), "content").unwrap();
2255        }
2256
2257        let args = Args {
2258            input: base_path.to_string_lossy().to_string(),
2259            output: output_path.to_string_lossy().to_string(),
2260            filter: vec![],
2261            ignore: vec![],
2262            line_numbers: true,
2263            preview: false,
2264            token_count: false,
2265            yes: true,
2266            diff_only: false,
2267            clear_cache: false,
2268            init: false,
2269            max_tokens: None,
2270            signatures: false,
2271            structure: false,
2272            truncate: "smart".to_string(),
2273            visibility: "all".to_string(),
2274        };
2275        let config = Config::default();
2276        let prompter = MockPrompter::new(true, true);
2277
2278        unsafe {
2279            std::env::set_var("CB_SILENT", "1");
2280        }
2281        let result = run_with_args(args, config, &prompter);
2282        unsafe {
2283            std::env::remove_var("CB_SILENT");
2284        }
2285
2286        assert!(result.is_ok());
2287    }
2288
2289    #[test]
2290    fn test_auto_diff_config_hash_change() {
2291        let temp_dir = tempdir().unwrap();
2292        let base_path = temp_dir.path();
2293        let output_path = temp_dir.path().join("output.md");
2294
2295        fs::write(base_path.join("test.txt"), "content").unwrap();
2296
2297        let args1 = Args {
2298            input: base_path.to_string_lossy().to_string(),
2299            output: output_path.to_string_lossy().to_string(),
2300            filter: vec!["txt".to_string()],
2301            ignore: vec![],
2302            line_numbers: false,
2303            preview: false,
2304            token_count: false,
2305            yes: true,
2306            diff_only: false,
2307            clear_cache: false,
2308            init: false,
2309            max_tokens: None,
2310            signatures: false,
2311            structure: false,
2312            truncate: "smart".to_string(),
2313            visibility: "all".to_string(),
2314        };
2315        let config1 = Config {
2316            auto_diff: Some(true),
2317            filter: Some(vec!["txt".to_string()]),
2318            ..Default::default()
2319        };
2320
2321        unsafe {
2322            std::env::set_var("CB_SILENT", "1");
2323        }
2324        let _ = run_with_args(args1, config1.clone(), &MockPrompter::new(true, true));
2325
2326        let args2 = Args {
2327            input: base_path.to_string_lossy().to_string(),
2328            output: output_path.to_string_lossy().to_string(),
2329            filter: vec!["rs".to_string()],
2330            ignore: vec![],
2331            line_numbers: false,
2332            preview: false,
2333            token_count: false,
2334            yes: true,
2335            diff_only: false,
2336            clear_cache: false,
2337            init: false,
2338            max_tokens: None,
2339            signatures: false,
2340            structure: false,
2341            truncate: "smart".to_string(),
2342            visibility: "all".to_string(),
2343        };
2344        let config2 = Config {
2345            auto_diff: Some(true),
2346            filter: Some(vec!["rs".to_string()]),
2347            ..Default::default()
2348        };
2349
2350        let result = run_with_args(args2, config2, &MockPrompter::new(true, true));
2351        unsafe {
2352            std::env::remove_var("CB_SILENT");
2353        }
2354
2355        assert!(result.is_ok() || result.is_err());
2356    }
2357
2358    #[test]
2359    fn test_generate_markdown_with_diff_and_filters() {
2360        let temp_dir = tempdir().unwrap();
2361        let base_path = temp_dir.path();
2362
2363        fs::write(base_path.join("test.rs"), "fn main() {}").unwrap();
2364        fs::write(base_path.join("test.txt"), "hello").unwrap();
2365
2366        let files = collect_files(base_path, &["rs".to_string()], &[], &[]).unwrap();
2367        let file_tree = build_file_tree(&files, base_path);
2368        let config = Config {
2369            filter: Some(vec!["rs".to_string()]),
2370            ..Default::default()
2371        };
2372        let state = ProjectState::from_files(&files, base_path, &config, false).unwrap();
2373
2374        let args = Args {
2375            input: base_path.to_string_lossy().to_string(),
2376            output: "test.md".to_string(),
2377            filter: vec!["rs".to_string()],
2378            ignore: vec![],
2379            line_numbers: false,
2380            preview: false,
2381            token_count: false,
2382            yes: false,
2383            diff_only: false,
2384            clear_cache: false,
2385            init: false,
2386            max_tokens: None,
2387            signatures: false,
2388            structure: false,
2389            truncate: "smart".to_string(),
2390            visibility: "all".to_string(),
2391        };
2392
2393        let diff_config = DiffConfig {
2394            context_lines: 3,
2395            enabled: true,
2396            diff_only: false,
2397        };
2398
2399        let sorted_paths: Vec<PathBuf> = files
2400            .iter()
2401            .map(|e| {
2402                e.path()
2403                    .strip_prefix(base_path)
2404                    .unwrap_or(e.path())
2405                    .to_path_buf()
2406            })
2407            .collect();
2408
2409        let ts_config = markdown::TreeSitterConfig {
2410            signatures: false,
2411            structure: false,
2412            truncate: "smart".to_string(),
2413            visibility: "all".to_string(),
2414        };
2415
2416        let result = generate_markdown_with_diff(
2417            &state,
2418            None,
2419            &args,
2420            &file_tree,
2421            &diff_config,
2422            &sorted_paths,
2423            &ts_config,
2424        );
2425        assert!(result.is_ok());
2426
2427        let content = result.unwrap();
2428        assert!(content.contains("test.rs"));
2429    }
2430
2431    #[test]
2432    fn test_generate_markdown_with_diff_and_ignores() {
2433        let temp_dir = tempdir().unwrap();
2434        let base_path = temp_dir.path();
2435
2436        fs::write(base_path.join("test.rs"), "fn main() {}").unwrap();
2437        fs::write(base_path.join("ignore.txt"), "ignored").unwrap();
2438
2439        let files = collect_files(base_path, &[], &["ignore.txt".to_string()], &[]).unwrap();
2440        let file_tree = build_file_tree(&files, base_path);
2441        let config = Config {
2442            ignore: Some(vec!["ignore.txt".to_string()]),
2443            ..Default::default()
2444        };
2445        let state = ProjectState::from_files(&files, base_path, &config, false).unwrap();
2446
2447        let args = Args {
2448            input: base_path.to_string_lossy().to_string(),
2449            output: "test.md".to_string(),
2450            filter: vec![],
2451            ignore: vec!["ignore.txt".to_string()],
2452            line_numbers: false,
2453            preview: false,
2454            token_count: false,
2455            yes: false,
2456            diff_only: false,
2457            clear_cache: false,
2458            init: false,
2459            max_tokens: None,
2460            signatures: false,
2461            structure: false,
2462            truncate: "smart".to_string(),
2463            visibility: "all".to_string(),
2464        };
2465
2466        let diff_config = DiffConfig {
2467            context_lines: 3,
2468            enabled: true,
2469            diff_only: false,
2470        };
2471
2472        let sorted_paths: Vec<PathBuf> = files
2473            .iter()
2474            .map(|e| {
2475                e.path()
2476                    .strip_prefix(base_path)
2477                    .unwrap_or(e.path())
2478                    .to_path_buf()
2479            })
2480            .collect();
2481
2482        let ts_config = markdown::TreeSitterConfig {
2483            signatures: false,
2484            structure: false,
2485            truncate: "smart".to_string(),
2486            visibility: "all".to_string(),
2487        };
2488
2489        let result = generate_markdown_with_diff(
2490            &state,
2491            None,
2492            &args,
2493            &file_tree,
2494            &diff_config,
2495            &sorted_paths,
2496            &ts_config,
2497        );
2498        assert!(result.is_ok());
2499
2500        let content = result.unwrap();
2501        assert!(content.contains("test.rs"));
2502    }
2503}