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#[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 let final_args = args;
75 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 let diff_config = if config.auto_diff.unwrap_or(false) {
111 Some(DiffConfig {
112 context_lines: config.diff_context_lines.unwrap_or(3),
113 enabled: true,
114 diff_only: final_args.diff_only,
115 })
116 } else {
117 None
118 };
119
120 if !final_args.preview
121 && !final_args.token_count
122 && Path::new(&final_args.output).exists()
123 && !final_args.yes
124 && !prompter.confirm_overwrite(&final_args.output)?
125 {
126 if !silent {
127 println!("Operation cancelled.");
128 }
129 return Err(io::Error::new(
130 io::ErrorKind::Interrupted,
131 "Operation cancelled by user",
132 ));
133 }
134
135 let mut auto_ignores: Vec<String> = vec![".context-builder".to_string()];
137
138 let output_path = Path::new(&final_args.output);
140 if let Ok(rel_output) = output_path.strip_prefix(base_path) {
141 if config.timestamped_output == Some(true) {
143 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 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 auto_ignores.push(rel_output.to_string_lossy().to_string());
171 }
172 } else {
173 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 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 if !silent {
227 const LARGE_FILE_THRESHOLD: u64 = 100 * 1024; 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)); 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 if config.auto_diff.unwrap_or(false) {
333 let mut effective_config = config.clone();
338 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 let current_state = ProjectState::from_files(
349 &files,
350 base_path,
351 &effective_config,
352 final_args.line_numbers,
353 )?;
354
355 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 let effective_previous = if let Some(prev) = previous_state.as_ref() {
374 if prev.config_hash != current_state.config_hash {
375 None
377 } else {
378 Some(prev)
379 }
380 } else {
381 None
382 };
383
384 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 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 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 let mut final_doc = generate_markdown_with_diff(
454 ¤t_state,
455 comparison.as_ref(),
456 &final_args,
457 &file_tree,
458 diff_cfg,
459 &sorted_paths,
460 &ts_config,
461 )?;
462
463 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 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 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 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 if let Err(e) = cache_manager.write_cache(¤t_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 let output_bytes = final_doc.len();
537 print_context_window_warning(output_bytes, final_args.max_tokens);
538 }
539 return Ok(());
540 }
541
542 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 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 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
590fn 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 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
624fn 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 output.push_str("# Directory Structure Report\n\n");
638
639 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 if let Some(comp) = comparison {
658 if comp.summary.has_changes() {
659 output.push_str(&comp.summary.to_markdown());
660
661 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 let mut lines: Vec<String> = Vec::new();
675 for line in added.diff.lines() {
676 if let Some(rest) = line.strip_prefix("+ ") {
679 lines.push(rest.to_string());
680 } else if let Some(rest) = line.strip_prefix('+') {
681 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 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 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 if !diff_config.diff_only {
726 output.push_str("## File Contents\n\n");
727
728 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 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 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 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 if args.init {
799 return init_config();
800 }
801
802 let project_root = Path::new(&args.input);
804 let config = load_config_from_path(project_root);
805
806 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 let resolution = crate::config_resolver::resolve_final_config(args, config.clone());
827
828 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 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 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
869fn detect_major_file_types() -> io::Result<Vec<String>> {
871 use std::collections::HashMap;
872 let mut extension_counts = HashMap::new();
873
874 let default_ignores = vec![
876 "docs".to_string(),
877 "target".to_string(),
878 ".git".to_string(),
879 "node_modules".to_string(),
880 ];
881
882 let files = crate::file_utils::collect_files(Path::new("."), &[], &default_ignores, &[])?;
884
885 for entry in files {
887 let path = entry.path();
888 if let Some(extension) = path.extension().and_then(|ext| ext.to_str()) {
889 *extension_counts.entry(extension.to_string()).or_insert(0) += 1;
891 }
892 }
893
894 let mut extensions: Vec<(String, usize)> = extension_counts.into_iter().collect();
896 extensions.sort_by(|a, b| b.1.cmp(&a.1));
897
898 let top_extensions: Vec<String> = extensions.into_iter().take(5).map(|(ext, _)| ext).collect();
900
901 Ok(top_extensions)
902}
903
904fn 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 let filter_suggestions = match detect_major_file_types() {
916 Ok(extensions) => extensions,
917 _ => vec!["rs".to_string(), "toml".to_string()], };
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 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 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 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 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 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 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); 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 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); 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")); assert!(content.contains(" 1 |")); }
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 }
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 ¤t_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 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}