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;
18
19use std::fs::File;
20
21use cache::CacheManager;
22use cli::Args;
23use config::{Config, load_config_from_path};
24use diff::render_per_file_diffs;
25use file_utils::{collect_files, confirm_overwrite, confirm_processing};
26use markdown::generate_markdown;
27use state::{ProjectState, StateComparison};
28use token_count::{count_file_tokens, count_tree_tokens, estimate_tokens};
29use tree::{build_file_tree, print_tree};
30
31#[derive(Debug, Clone)]
33pub struct DiffConfig {
34 pub context_lines: usize,
35 pub enabled: bool,
36 pub diff_only: bool,
37}
38
39impl Default for DiffConfig {
40 fn default() -> Self {
41 Self {
42 context_lines: 3,
43 enabled: false,
44 diff_only: false,
45 }
46 }
47}
48
49pub trait Prompter {
50 fn confirm_processing(&self, file_count: usize) -> io::Result<bool>;
51 fn confirm_overwrite(&self, file_path: &str) -> io::Result<bool>;
52}
53
54pub struct DefaultPrompter;
55
56impl Prompter for DefaultPrompter {
57 fn confirm_processing(&self, file_count: usize) -> io::Result<bool> {
58 confirm_processing(file_count)
59 }
60 fn confirm_overwrite(&self, file_path: &str) -> io::Result<bool> {
61 confirm_overwrite(file_path)
62 }
63}
64
65pub fn run_with_args(args: Args, config: Config, prompter: &impl Prompter) -> io::Result<()> {
66 let start_time = Instant::now();
67
68 let silent = std::env::var("CB_SILENT")
69 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
70 .unwrap_or(false);
71
72 let final_args = args;
74 let mut resolved_base = PathBuf::from(&final_args.input);
77 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
78 if resolved_base == Path::new(".")
79 && !cwd.join("context-builder.toml").exists()
80 && let Some(output_parent) = Path::new(&final_args.output).parent()
81 && output_parent
82 .file_name()
83 .map(|n| n == "output")
84 .unwrap_or(false)
85 && let Some(project_root) = output_parent.parent()
86 && project_root.join("context-builder.toml").exists()
87 {
88 resolved_base = project_root.to_path_buf();
89 }
90 let base_path = resolved_base.as_path();
91
92 if !base_path.exists() || !base_path.is_dir() {
93 if !silent {
94 eprintln!(
95 "Error: The specified input directory '{}' does not exist or is not a directory.",
96 final_args.input
97 );
98 }
99 return Err(io::Error::new(
100 io::ErrorKind::NotFound,
101 format!(
102 "Input directory '{}' does not exist or is not a directory",
103 final_args.input
104 ),
105 ));
106 }
107
108 let diff_config = if config.auto_diff.unwrap_or(false) {
110 Some(DiffConfig {
111 context_lines: config.diff_context_lines.unwrap_or(3),
112 enabled: true,
113 diff_only: final_args.diff_only,
114 })
115 } else {
116 None
117 };
118
119 if !final_args.preview
120 && !final_args.token_count
121 && Path::new(&final_args.output).exists()
122 && !final_args.yes
123 && !prompter.confirm_overwrite(&final_args.output)?
124 {
125 if !silent {
126 println!("Operation cancelled.");
127 }
128 return Err(io::Error::new(
129 io::ErrorKind::Interrupted,
130 "Operation cancelled by user",
131 ));
132 }
133
134 let mut auto_ignores: Vec<String> = vec![".context-builder".to_string()];
136
137 let output_path = Path::new(&final_args.output);
139 if let Ok(rel_output) = output_path.strip_prefix(base_path) {
140 if config.timestamped_output == Some(true) {
142 if let (Some(parent), Some(stem), Some(ext)) = (
144 rel_output.parent(),
145 output_path.file_stem().and_then(|s| s.to_str()),
146 output_path.extension().and_then(|s| s.to_str()),
147 ) {
148 let base_stem = if let Some(ref cfg_output) = config.output {
152 Path::new(cfg_output)
153 .file_stem()
154 .and_then(|s| s.to_str())
155 .unwrap_or(stem)
156 .to_string()
157 } else {
158 stem.to_string()
159 };
160 let glob = if parent == Path::new("") {
161 format!("{}_*.{}", base_stem, ext)
162 } else {
163 format!("{}/{}_*.{}", parent.display(), base_stem, ext)
164 };
165 auto_ignores.push(glob);
166 }
167 } else {
168 auto_ignores.push(rel_output.to_string_lossy().to_string());
170 }
171 } else {
172 let output_str = final_args.output.clone();
174 if config.timestamped_output == Some(true) {
175 if let (Some(stem), Some(ext)) = (
176 output_path.file_stem().and_then(|s| s.to_str()),
177 output_path.extension().and_then(|s| s.to_str()),
178 ) {
179 let base_stem = if let Some(ref cfg_output) = config.output {
180 Path::new(cfg_output)
181 .file_stem()
182 .and_then(|s| s.to_str())
183 .unwrap_or(stem)
184 .to_string()
185 } else {
186 stem.to_string()
187 };
188 if let Some(parent) = output_path.parent() {
189 let parent_str = parent.to_string_lossy();
190 if parent_str.is_empty() || parent_str == "." {
191 auto_ignores.push(format!("{}_*.{}", base_stem, ext));
192 } else {
193 auto_ignores.push(format!("{}/{}_*.{}", parent_str, base_stem, ext));
194 }
195 }
196 }
197 } else {
198 auto_ignores.push(output_str);
199 }
200 }
201
202 if let Some(ref output_folder) = config.output_folder {
204 auto_ignores.push(output_folder.clone());
205 }
206
207 let files = collect_files(
208 base_path,
209 &final_args.filter,
210 &final_args.ignore,
211 &auto_ignores,
212 )?;
213 let debug_config = std::env::var("CB_DEBUG_CONFIG").is_ok();
214 if debug_config {
215 eprintln!("[DEBUG][CONFIG] Args: {:?}", final_args);
216 eprintln!("[DEBUG][CONFIG] Raw Config: {:?}", config);
217 eprintln!("[DEBUG][CONFIG] Auto-ignores: {:?}", auto_ignores);
218 eprintln!("[DEBUG][CONFIG] Collected {} files", files.len());
219 for f in &files {
220 eprintln!("[DEBUG][CONFIG] - {}", f.path().display());
221 }
222 }
223
224 if !silent {
226 const LARGE_FILE_THRESHOLD: u64 = 100 * 1024; let mut large_files: Vec<(String, u64)> = Vec::new();
228 let mut total_size: u64 = 0;
229
230 for entry in &files {
231 if let Ok(metadata) = entry.path().metadata() {
232 let size = metadata.len();
233 total_size += size;
234 if size > LARGE_FILE_THRESHOLD {
235 let rel_path = entry
236 .path()
237 .strip_prefix(base_path)
238 .unwrap_or(entry.path())
239 .to_string_lossy()
240 .to_string();
241 large_files.push((rel_path, size));
242 }
243 }
244 }
245
246 if !large_files.is_empty() {
247 large_files.sort_by(|a, b| b.1.cmp(&a.1)); eprintln!(
249 "\nā {} large file(s) detected (>{} KB):",
250 large_files.len(),
251 LARGE_FILE_THRESHOLD / 1024
252 );
253 for (path, size) in large_files.iter().take(5) {
254 eprintln!(" {:>8} KB {}", size / 1024, path);
255 }
256 if large_files.len() > 5 {
257 eprintln!(" ... and {} more", large_files.len() - 5);
258 }
259 eprintln!(
260 " Total context size: {} KB across {} files\n",
261 total_size / 1024,
262 files.len()
263 );
264 }
265 }
266 let file_tree = build_file_tree(&files, base_path);
267
268 if final_args.preview {
269 if !silent {
270 println!("\n# File Tree Structure (Preview)\n");
271 print_tree(&file_tree, 0);
272 }
273 if !final_args.token_count {
274 return Ok(());
275 }
276 }
277
278 if final_args.token_count {
279 if !silent {
280 println!("\n# Token Count Estimation\n");
281 let mut total_tokens = 0;
282 total_tokens += estimate_tokens("# Directory Structure Report\n\n");
283 if !final_args.filter.is_empty() {
284 total_tokens += estimate_tokens(&format!(
285 "This document contains files from the `{}` directory with extensions: {} \n",
286 final_args.input,
287 final_args.filter.join(", ")
288 ));
289 } else {
290 total_tokens += estimate_tokens(&format!(
291 "This document contains all files from the `{}` directory, optimized for LLM consumption.\n",
292 final_args.input
293 ));
294 }
295 if !final_args.ignore.is_empty() {
296 total_tokens += estimate_tokens(&format!(
297 "Custom ignored patterns: {} \n",
298 final_args.ignore.join(", ")
299 ));
300 }
301 total_tokens += estimate_tokens("Content hash: 0000000000000000\n\n");
302 total_tokens += estimate_tokens("## File Tree Structure\n\n");
303 let tree_tokens = count_tree_tokens(&file_tree, 0);
304 total_tokens += tree_tokens;
305 let file_tokens: usize = files
306 .iter()
307 .map(|entry| count_file_tokens(base_path, entry, final_args.line_numbers))
308 .sum();
309 total_tokens += file_tokens;
310 println!("Estimated total tokens: {}", total_tokens);
311 println!("File tree tokens: {}", tree_tokens);
312 println!("File content tokens: {}", file_tokens);
313 }
314 return Ok(());
315 }
316
317 if !final_args.yes && !prompter.confirm_processing(files.len())? {
318 if !silent {
319 println!("Operation cancelled.");
320 }
321 return Err(io::Error::new(
322 io::ErrorKind::Interrupted,
323 "Operation cancelled by user",
324 ));
325 }
326
327 if config.auto_diff.unwrap_or(false) {
332 let mut effective_config = config.clone();
337 if !final_args.filter.is_empty() {
339 effective_config.filter = Some(final_args.filter.clone());
340 }
341 if !final_args.ignore.is_empty() {
342 effective_config.ignore = Some(final_args.ignore.clone());
343 }
344 effective_config.line_numbers = Some(final_args.line_numbers);
345
346 let current_state = ProjectState::from_files(
348 &files,
349 base_path,
350 &effective_config,
351 final_args.line_numbers,
352 )?;
353
354 let cache_manager = CacheManager::new(base_path, &effective_config);
356 let previous_state = match cache_manager.read_cache() {
357 Ok(state) => state,
358 Err(e) => {
359 if !silent {
360 eprintln!(
361 "Warning: Failed to read cache (proceeding without diff): {}",
362 e
363 );
364 }
365 None
366 }
367 };
368
369 let diff_cfg = diff_config.as_ref().unwrap();
370
371 let effective_previous = if let Some(prev) = previous_state.as_ref() {
373 if prev.config_hash != current_state.config_hash {
374 None
376 } else {
377 Some(prev)
378 }
379 } else {
380 None
381 };
382
383 let comparison = effective_previous.map(|prev| current_state.compare_with(prev));
385
386 let debug_autodiff = std::env::var("CB_DEBUG_AUTODIFF").is_ok();
387 if debug_autodiff {
388 eprintln!(
389 "[DEBUG][AUTODIFF] cache file: {}",
390 cache_manager.debug_cache_file_path().display()
391 );
392 eprintln!(
393 "[DEBUG][AUTODIFF] config_hash current={} prev={:?} invalidated={}",
394 current_state.config_hash,
395 previous_state.as_ref().map(|s| s.config_hash.clone()),
396 effective_previous.is_none() && previous_state.is_some()
397 );
398 eprintln!("[DEBUG][AUTODIFF] effective_config: {:?}", effective_config);
399 if let Some(prev) = previous_state.as_ref() {
400 eprintln!("[DEBUG][AUTODIFF] raw previous files: {}", prev.files.len());
401 }
402 if let Some(prev) = effective_previous {
403 eprintln!(
404 "[DEBUG][AUTODIFF] effective previous files: {}",
405 prev.files.len()
406 );
407 for k in prev.files.keys() {
408 eprintln!(" PREV: {}", k.display());
409 }
410 }
411 eprintln!(
412 "[DEBUG][AUTODIFF] current files: {}",
413 current_state.files.len()
414 );
415 for k in current_state.files.keys() {
416 eprintln!(" CURR: {}", k.display());
417 }
418 }
419
420 let cwd = std::env::current_dir().unwrap_or_else(|_| base_path.to_path_buf());
425 let sorted_paths: Vec<PathBuf> = files
426 .iter()
427 .map(|entry| {
428 entry
429 .path()
430 .strip_prefix(base_path)
431 .or_else(|_| entry.path().strip_prefix(&cwd))
432 .map(|p| p.to_path_buf())
433 .unwrap_or_else(|_| {
434 entry
435 .path()
436 .file_name()
437 .map(PathBuf::from)
438 .unwrap_or_else(|| entry.path().to_path_buf())
439 })
440 })
441 .collect();
442
443 let mut final_doc = generate_markdown_with_diff(
445 ¤t_state,
446 comparison.as_ref(),
447 &final_args,
448 &file_tree,
449 diff_cfg,
450 &sorted_paths,
451 )?;
452
453 if let Some(max_tokens) = final_args.max_tokens {
455 let max_bytes = max_tokens * 4;
456 if final_doc.len() > max_bytes {
457 let mut truncate_at = max_bytes;
459 while truncate_at > 0 && !final_doc.is_char_boundary(truncate_at) {
460 truncate_at -= 1;
461 }
462 final_doc.truncate(truncate_at);
463 final_doc.push_str("\n\n---\n\n");
464 final_doc.push_str(&format!(
465 "_Output truncated: exceeded {} token budget (estimated)._\n",
466 max_tokens
467 ));
468 }
469 }
470
471 let output_path = Path::new(&final_args.output);
473 if let Some(parent) = output_path.parent()
474 && !parent.exists()
475 && let Err(e) = fs::create_dir_all(parent)
476 {
477 return Err(io::Error::other(format!(
478 "Failed to create output directory {}: {}",
479 parent.display(),
480 e
481 )));
482 }
483 let mut final_output = fs::File::create(output_path)?;
484 final_output.write_all(final_doc.as_bytes())?;
485
486 if let Err(e) = cache_manager.write_cache(¤t_state)
488 && !silent
489 {
490 eprintln!("Warning: failed to update state cache: {}", e);
491 }
492
493 let duration = start_time.elapsed();
494 if !silent {
495 if let Some(comp) = &comparison {
496 if comp.summary.has_changes() {
497 println!(
498 "Documentation created successfully with {} changes: {}",
499 comp.summary.total_changes, final_args.output
500 );
501 } else {
502 println!(
503 "Documentation created successfully (no changes detected): {}",
504 final_args.output
505 );
506 }
507 } else {
508 println!(
509 "Documentation created successfully (initial state): {}",
510 final_args.output
511 );
512 }
513 println!("Processing time: {:.2?}", duration);
514
515 let output_bytes = final_doc.len();
517 print_context_window_warning(output_bytes, final_args.max_tokens);
518 }
519 return Ok(());
520 }
521
522 generate_markdown(
524 &final_args.output,
525 &final_args.input,
526 &final_args.filter,
527 &final_args.ignore,
528 &file_tree,
529 &files,
530 base_path,
531 final_args.line_numbers,
532 config.encoding_strategy.as_deref(),
533 final_args.max_tokens,
534 )?;
535
536 let duration = start_time.elapsed();
537 if !silent {
538 println!("Documentation created successfully: {}", final_args.output);
539 println!("Processing time: {:.2?}", duration);
540
541 let output_bytes = fs::metadata(&final_args.output)
543 .map(|m| m.len() as usize)
544 .unwrap_or(0);
545 print_context_window_warning(output_bytes, final_args.max_tokens);
546 }
547
548 Ok(())
549}
550
551fn print_context_window_warning(output_bytes: usize, max_tokens: Option<usize>) {
556 let estimated_tokens = output_bytes / 4;
557
558 println!("Estimated tokens: ~{}K", estimated_tokens / 1000);
559
560 if max_tokens.is_some() {
562 return;
563 }
564
565 const RECOMMENDED_LIMIT: usize = 128_000;
566
567 if estimated_tokens <= RECOMMENDED_LIMIT {
568 return;
569 }
570
571 eprintln!();
572 eprintln!(
573 "ā ļø Output is ~{}K tokens ā recommended limit is 128K for effective LLM context.",
574 estimated_tokens / 1000
575 );
576 eprintln!(" Large contexts degrade response quality. Consider narrowing the scope:");
577 eprintln!();
578 eprintln!(" ⢠--max-tokens 100000 Cap output to a token budget");
579 eprintln!(" ⢠--filter rs,toml Include only specific file types");
580 eprintln!(" ⢠--ignore docs,assets Exclude directories by name");
581 eprintln!(" ⢠--token-count Preview size without generating");
582 eprintln!();
583}
584
585fn generate_markdown_with_diff(
587 current_state: &ProjectState,
588 comparison: Option<&StateComparison>,
589 args: &Args,
590 file_tree: &tree::FileTree,
591 diff_config: &DiffConfig,
592 sorted_paths: &[PathBuf],
593) -> io::Result<String> {
594 let mut output = String::new();
595
596 output.push_str("# Directory Structure Report\n\n");
598
599 output.push_str(&format!(
601 "**Project:** {}\n",
602 current_state.metadata.project_name
603 ));
604 output.push_str(&format!("**Generated:** {}\n", current_state.timestamp));
605
606 if !args.filter.is_empty() {
607 output.push_str(&format!("**Filters:** {}\n", args.filter.join(", ")));
608 }
609
610 if !args.ignore.is_empty() {
611 output.push_str(&format!("**Ignored:** {}\n", args.ignore.join(", ")));
612 }
613
614 output.push('\n');
615
616 if let Some(comp) = comparison {
618 if comp.summary.has_changes() {
619 output.push_str(&comp.summary.to_markdown());
620
621 let added_files: Vec<_> = comp
623 .file_diffs
624 .iter()
625 .filter(|d| matches!(d.status, diff::PerFileStatus::Added))
626 .collect();
627
628 if diff_config.diff_only && !added_files.is_empty() {
629 output.push_str("## Added Files\n\n");
630 for added in added_files {
631 output.push_str(&format!("### File: `{}`\n\n", added.path));
632 output.push_str("_Status: Added_\n\n");
633 let mut lines: Vec<String> = Vec::new();
635 for line in added.diff.lines() {
636 if let Some(rest) = line.strip_prefix("+ ") {
639 lines.push(rest.to_string());
640 } else if let Some(rest) = line.strip_prefix('+') {
641 lines.push(rest.to_string());
643 }
644 }
645 output.push_str("```text\n");
646 if args.line_numbers {
647 for (idx, l) in lines.iter().enumerate() {
648 output.push_str(&format!("{:>4} | {}\n", idx + 1, l));
649 }
650 } else {
651 for l in lines {
652 output.push_str(&l);
653 output.push('\n');
654 }
655 }
656 output.push_str("```\n\n");
657 }
658 }
659
660 let changed_diffs: Vec<diff::PerFileDiff> = comp
662 .file_diffs
663 .iter()
664 .filter(|d| d.is_changed())
665 .cloned()
666 .collect();
667 if !changed_diffs.is_empty() {
668 output.push_str("## File Differences\n\n");
669 let diff_markdown = render_per_file_diffs(&changed_diffs);
670 output.push_str(&diff_markdown);
671 }
672 } else {
673 output.push_str("## No Changes Detected\n\n");
674 }
675 }
676
677 output.push_str("## File Tree Structure\n\n");
679 let mut tree_output = Vec::new();
680 tree::write_tree_to_file(&mut tree_output, file_tree, 0)?;
681 output.push_str(&String::from_utf8_lossy(&tree_output));
682 output.push('\n');
683
684 if !diff_config.diff_only {
686 output.push_str("## File Contents\n\n");
687
688 for path in sorted_paths {
691 if let Some(file_state) = current_state.files.get(path) {
692 output.push_str(&format!("### File: `{}`\n\n", path.display()));
693 output.push_str(&format!("- Size: {} bytes\n", file_state.size));
694 output.push_str(&format!("- Modified: {:?}\n\n", file_state.modified));
695
696 let extension = path.extension().and_then(|s| s.to_str()).unwrap_or("text");
698 let language = match extension {
699 "rs" => "rust",
700 "js" => "javascript",
701 "ts" => "typescript",
702 "py" => "python",
703 "json" => "json",
704 "toml" => "toml",
705 "md" => "markdown",
706 "yaml" | "yml" => "yaml",
707 "html" => "html",
708 "css" => "css",
709 _ => extension,
710 };
711
712 output.push_str(&format!("```{}\n", language));
713
714 if args.line_numbers {
715 for (i, line) in file_state.content.lines().enumerate() {
716 output.push_str(&format!("{:>4} | {}\n", i + 1, line));
717 }
718 } else {
719 output.push_str(&file_state.content);
720 if !file_state.content.ends_with('\n') {
721 output.push('\n');
722 }
723 }
724
725 output.push_str("```\n\n");
726 }
727 }
728 }
729
730 Ok(output)
731}
732
733pub fn run() -> io::Result<()> {
734 env_logger::init();
735 let args = Args::parse();
736
737 if args.init {
739 return init_config();
740 }
741
742 let project_root = Path::new(&args.input);
744 let config = load_config_from_path(project_root);
745
746 if args.clear_cache {
748 let cache_path = project_root.join(".context-builder").join("cache");
749 if cache_path.exists() {
750 match fs::remove_dir_all(&cache_path) {
751 Ok(()) => println!("Cache cleared: {}", cache_path.display()),
752 Err(e) => eprintln!("Failed to clear cache ({}): {}", cache_path.display(), e),
753 }
754 } else {
755 println!("No cache directory found at {}", cache_path.display());
756 }
757 return Ok(());
758 }
759
760 if std::env::args().len() == 1 && config.is_none() {
761 Args::command().print_help()?;
762 return Ok(());
763 }
764
765 let resolution = crate::config_resolver::resolve_final_config(args, config.clone());
767
768 let silent = std::env::var("CB_SILENT")
770 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
771 .unwrap_or(false);
772
773 if !silent {
774 for warning in &resolution.warnings {
775 eprintln!("Warning: {}", warning);
776 }
777 }
778
779 let final_args = Args {
781 input: resolution.config.input,
782 output: resolution.config.output,
783 filter: resolution.config.filter,
784 ignore: resolution.config.ignore,
785 line_numbers: resolution.config.line_numbers,
786 preview: resolution.config.preview,
787 token_count: resolution.config.token_count,
788 yes: resolution.config.yes,
789 diff_only: resolution.config.diff_only,
790 clear_cache: resolution.config.clear_cache,
791 max_tokens: resolution.config.max_tokens,
792 init: false,
793 };
794
795 let final_config = Config {
797 auto_diff: Some(resolution.config.auto_diff),
798 diff_context_lines: Some(resolution.config.diff_context_lines),
799 ..config.unwrap_or_default()
800 };
801
802 run_with_args(final_args, final_config, &DefaultPrompter)
803}
804
805fn detect_major_file_types() -> io::Result<Vec<String>> {
807 use std::collections::HashMap;
808 let mut extension_counts = HashMap::new();
809
810 let default_ignores = vec![
812 "docs".to_string(),
813 "target".to_string(),
814 ".git".to_string(),
815 "node_modules".to_string(),
816 ];
817
818 let files = crate::file_utils::collect_files(Path::new("."), &[], &default_ignores, &[])?;
820
821 for entry in files {
823 let path = entry.path();
824 if let Some(extension) = path.extension().and_then(|ext| ext.to_str()) {
825 *extension_counts.entry(extension.to_string()).or_insert(0) += 1;
827 }
828 }
829
830 let mut extensions: Vec<(String, usize)> = extension_counts.into_iter().collect();
832 extensions.sort_by(|a, b| b.1.cmp(&a.1));
833
834 let top_extensions: Vec<String> = extensions.into_iter().take(5).map(|(ext, _)| ext).collect();
836
837 Ok(top_extensions)
838}
839
840fn init_config() -> io::Result<()> {
842 let config_path = Path::new("context-builder.toml");
843
844 if config_path.exists() {
845 println!("Config file already exists at {}", config_path.display());
846 println!("If you want to replace it, please remove it manually first.");
847 return Ok(());
848 }
849
850 let filter_suggestions = match detect_major_file_types() {
852 Ok(extensions) => extensions,
853 _ => vec!["rs".to_string(), "toml".to_string()], };
855
856 let filter_string = if filter_suggestions.is_empty() {
857 r#"["rs", "toml"]"#.to_string()
858 } else {
859 format!(r#"["{}"]"#, filter_suggestions.join(r#"", ""#))
860 };
861
862 let default_config_content = format!(
863 r#"# Context Builder Configuration File
864# This file was generated with sensible defaults based on the file types detected in your project
865
866# Output file name (or base name when timestamped_output is true)
867output = "context.md"
868
869# Optional folder to place the generated output file(s) in
870output_folder = "docs"
871
872# Append a UTC timestamp to the output file name (before extension)
873timestamped_output = true
874
875# Enable automatic diff generation (requires timestamped_output = true)
876auto_diff = true
877
878# Emit only change summary + modified file diffs (no full file bodies)
879diff_only = false
880
881# File extensions to include (no leading dot, e.g. "rs", "toml")
882filter = {}
883
884# File / directory names to ignore (exact name matches)
885ignore = ["docs", "target", ".git", "node_modules"]
886
887# Add line numbers to code blocks
888line_numbers = false
889"#,
890 filter_string
891 );
892
893 let mut file = File::create(config_path)?;
894 file.write_all(default_config_content.as_bytes())?;
895
896 println!("Config file created at {}", config_path.display());
897 println!("Detected file types: {}", filter_suggestions.join(", "));
898 println!("You can now customize it according to your project needs.");
899
900 Ok(())
901}
902
903#[cfg(test)]
904mod tests {
905 use super::*;
906 use std::io::Result;
907 use tempfile::tempdir;
908
909 struct MockPrompter {
911 confirm_processing_response: bool,
912 confirm_overwrite_response: bool,
913 }
914
915 impl MockPrompter {
916 fn new(processing: bool, overwrite: bool) -> Self {
917 Self {
918 confirm_processing_response: processing,
919 confirm_overwrite_response: overwrite,
920 }
921 }
922 }
923
924 impl Prompter for MockPrompter {
925 fn confirm_processing(&self, _file_count: usize) -> Result<bool> {
926 Ok(self.confirm_processing_response)
927 }
928
929 fn confirm_overwrite(&self, _file_path: &str) -> Result<bool> {
930 Ok(self.confirm_overwrite_response)
931 }
932 }
933
934 #[test]
935 fn test_diff_config_default() {
936 let config = DiffConfig::default();
937 assert_eq!(config.context_lines, 3);
938 assert!(!config.enabled);
939 assert!(!config.diff_only);
940 }
941
942 #[test]
943 fn test_diff_config_custom() {
944 let config = DiffConfig {
945 context_lines: 5,
946 enabled: true,
947 diff_only: true,
948 };
949 assert_eq!(config.context_lines, 5);
950 assert!(config.enabled);
951 assert!(config.diff_only);
952 }
953
954 #[test]
955 fn test_default_prompter() {
956 let prompter = DefaultPrompter;
957
958 let result = prompter.confirm_processing(50);
960 assert!(result.is_ok());
961 assert!(result.unwrap());
962 }
963
964 #[test]
965 fn test_run_with_args_nonexistent_directory() {
966 let args = Args {
967 input: "/nonexistent/directory".to_string(),
968 output: "output.md".to_string(),
969 filter: vec![],
970 ignore: vec![],
971 line_numbers: false,
972 preview: false,
973 token_count: false,
974 yes: false,
975 diff_only: false,
976 clear_cache: false,
977 init: false,
978 max_tokens: None,
979 };
980 let config = Config::default();
981 let prompter = MockPrompter::new(true, true);
982
983 let result = run_with_args(args, config, &prompter);
984 assert!(result.is_err());
985 assert!(result.unwrap_err().to_string().contains("does not exist"));
986 }
987
988 #[test]
989 fn test_run_with_args_preview_mode() {
990 let temp_dir = tempdir().unwrap();
991 let base_path = temp_dir.path();
992
993 fs::write(base_path.join("test.rs"), "fn main() {}").unwrap();
995 fs::create_dir(base_path.join("src")).unwrap();
996 fs::write(base_path.join("src/lib.rs"), "pub fn hello() {}").unwrap();
997
998 let args = Args {
999 input: ".".to_string(),
1000 output: "test.md".to_string(),
1001 filter: vec![],
1002 ignore: vec![],
1003 line_numbers: false,
1004 preview: false,
1005 token_count: false,
1006 yes: false,
1007 diff_only: false,
1008 clear_cache: false,
1009 init: false,
1010 max_tokens: None,
1011 };
1012 let config = Config::default();
1013 let prompter = MockPrompter::new(true, true);
1014
1015 unsafe {
1017 std::env::set_var("CB_SILENT", "1");
1018 }
1019 let result = run_with_args(args, config, &prompter);
1020 unsafe {
1021 std::env::remove_var("CB_SILENT");
1022 }
1023
1024 assert!(result.is_ok());
1025 }
1026
1027 #[test]
1028 fn test_run_with_args_token_count_mode() {
1029 let temp_dir = tempdir().unwrap();
1030 let base_path = temp_dir.path();
1031
1032 fs::write(base_path.join("small.txt"), "Hello world").unwrap();
1034
1035 let args = Args {
1036 input: base_path.to_string_lossy().to_string(),
1037 output: "test.md".to_string(),
1038 filter: vec![],
1039 ignore: vec![],
1040 line_numbers: false,
1041 preview: false,
1042 token_count: true,
1043 yes: false,
1044 diff_only: false,
1045 clear_cache: false,
1046 init: false,
1047 max_tokens: None,
1048 };
1049 let config = Config::default();
1050 let prompter = MockPrompter::new(true, true);
1051
1052 unsafe {
1053 std::env::set_var("CB_SILENT", "1");
1054 }
1055 let result = run_with_args(args, config, &prompter);
1056 unsafe {
1057 std::env::remove_var("CB_SILENT");
1058 }
1059
1060 assert!(result.is_ok());
1061 }
1062
1063 #[test]
1064 fn test_run_with_args_preview_and_token_count() {
1065 let temp_dir = tempdir().unwrap();
1066 let base_path = temp_dir.path();
1067
1068 fs::write(base_path.join("test.txt"), "content").unwrap();
1069
1070 let args = Args {
1071 input: base_path.to_string_lossy().to_string(),
1072 output: "test.md".to_string(),
1073 filter: vec![],
1074 ignore: vec![],
1075 line_numbers: false,
1076 preview: true,
1077 token_count: false,
1078 yes: false,
1079 diff_only: false,
1080 clear_cache: false,
1081 init: false,
1082 max_tokens: None,
1083 };
1084 let config = Config::default();
1085 let prompter = MockPrompter::new(true, true);
1086
1087 unsafe {
1088 std::env::set_var("CB_SILENT", "1");
1089 }
1090 let result = run_with_args(args, config, &prompter);
1091 unsafe {
1092 std::env::remove_var("CB_SILENT");
1093 }
1094
1095 assert!(result.is_ok());
1096 }
1097
1098 #[test]
1099 fn test_run_with_args_user_cancels_overwrite() {
1100 let temp_dir = tempdir().unwrap();
1101 let base_path = temp_dir.path();
1102 let output_path = temp_dir.path().join("existing.md");
1103
1104 fs::write(base_path.join("test.txt"), "content").unwrap();
1106 fs::write(&output_path, "existing content").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!["target".to_string()],
1113 line_numbers: false,
1114 preview: false,
1115 token_count: false,
1116 yes: false,
1117 diff_only: false,
1118 clear_cache: false,
1119 init: false,
1120 max_tokens: None,
1121 };
1122 let config = Config::default();
1123 let prompter = MockPrompter::new(true, false); unsafe {
1126 std::env::set_var("CB_SILENT", "1");
1127 }
1128 let result = run_with_args(args, config, &prompter);
1129 unsafe {
1130 std::env::remove_var("CB_SILENT");
1131 }
1132
1133 assert!(result.is_err());
1134 assert!(result.unwrap_err().to_string().contains("cancelled"));
1135 }
1136
1137 #[test]
1138 fn test_run_with_args_user_cancels_processing() {
1139 let temp_dir = tempdir().unwrap();
1140 let base_path = temp_dir.path();
1141
1142 for i in 0..105 {
1144 fs::write(base_path.join(format!("file{}.txt", i)), "content").unwrap();
1145 }
1146
1147 let args = Args {
1148 input: base_path.to_string_lossy().to_string(),
1149 output: "test.md".to_string(),
1150 filter: vec!["rs".to_string()],
1151 ignore: vec![],
1152 line_numbers: false,
1153 preview: false,
1154 token_count: false,
1155 yes: false,
1156 diff_only: false,
1157 clear_cache: false,
1158 init: false,
1159 max_tokens: None,
1160 };
1161 let config = Config::default();
1162 let prompter = MockPrompter::new(false, true); unsafe {
1165 std::env::set_var("CB_SILENT", "1");
1166 }
1167 let result = run_with_args(args, config, &prompter);
1168 unsafe {
1169 std::env::remove_var("CB_SILENT");
1170 }
1171
1172 assert!(result.is_err());
1173 assert!(result.unwrap_err().to_string().contains("cancelled"));
1174 }
1175
1176 #[test]
1177 fn test_run_with_args_with_yes_flag() {
1178 let temp_dir = tempdir().unwrap();
1179 let base_path = temp_dir.path();
1180 let output_file_name = "test.md";
1181 let output_path = temp_dir.path().join(output_file_name);
1182
1183 fs::write(base_path.join("test.txt"), "Hello world").unwrap();
1184
1185 let args = Args {
1186 input: base_path.to_string_lossy().to_string(),
1187 output: output_path.to_string_lossy().to_string(),
1188 filter: vec![],
1189 ignore: vec!["ignored_dir".to_string()],
1190 line_numbers: false,
1191 preview: false,
1192 token_count: false,
1193 yes: true,
1194 diff_only: false,
1195 clear_cache: false,
1196 init: false,
1197 max_tokens: None,
1198 };
1199 let config = Config::default();
1200 let prompter = MockPrompter::new(true, true);
1201
1202 unsafe {
1203 std::env::set_var("CB_SILENT", "1");
1204 }
1205 let result = run_with_args(args, config, &prompter);
1206 unsafe {
1207 std::env::remove_var("CB_SILENT");
1208 }
1209
1210 assert!(result.is_ok());
1211 assert!(output_path.exists());
1212
1213 let content = fs::read_to_string(&output_path).unwrap();
1214 assert!(content.contains("Directory Structure Report"));
1215 assert!(content.contains("test.txt"));
1216 }
1217
1218 #[test]
1219 fn test_run_with_args_with_filters() {
1220 let temp_dir = tempdir().unwrap();
1221 let base_path = temp_dir.path();
1222 let output_file_name = "test.md";
1223 let output_path = temp_dir.path().join(output_file_name);
1224
1225 fs::write(base_path.join("code.rs"), "fn main() {}").unwrap();
1226 fs::write(base_path.join("readme.md"), "# README").unwrap();
1227 fs::write(base_path.join("data.json"), r#"{"key": "value"}"#).unwrap();
1228
1229 let args = Args {
1230 input: base_path.to_string_lossy().to_string(),
1231 output: output_path.to_string_lossy().to_string(),
1232 filter: vec!["rs".to_string(), "md".to_string()],
1233 ignore: vec![],
1234 line_numbers: true,
1235 preview: false,
1236 token_count: false,
1237 yes: true,
1238 diff_only: false,
1239 clear_cache: false,
1240 init: false,
1241 max_tokens: None,
1242 };
1243 let config = Config::default();
1244 let prompter = MockPrompter::new(true, true);
1245
1246 unsafe {
1247 std::env::set_var("CB_SILENT", "1");
1248 }
1249 let result = run_with_args(args, config, &prompter);
1250 unsafe {
1251 std::env::remove_var("CB_SILENT");
1252 }
1253
1254 assert!(result.is_ok());
1255
1256 let content = fs::read_to_string(&output_path).unwrap();
1257 assert!(content.contains("code.rs"));
1258 assert!(content.contains("readme.md"));
1259 assert!(!content.contains("data.json")); assert!(content.contains(" 1 |")); }
1262
1263 #[test]
1264 fn test_run_with_args_with_ignores() {
1265 let temp_dir = tempdir().unwrap();
1266 let base_path = temp_dir.path();
1267 let output_path = temp_dir.path().join("ignored.md");
1268
1269 fs::write(base_path.join("important.txt"), "important content").unwrap();
1270 fs::write(base_path.join("secret.txt"), "secret content").unwrap();
1271
1272 let args = Args {
1273 input: base_path.to_string_lossy().to_string(),
1274 output: output_path.to_string_lossy().to_string(),
1275 filter: vec![],
1276 ignore: vec!["secret.txt".to_string()],
1277 line_numbers: false,
1278 preview: false,
1279 token_count: false,
1280 yes: true,
1281 diff_only: false,
1282 clear_cache: false,
1283 init: false,
1284 max_tokens: None,
1285 };
1286 let config = Config::default();
1287 let prompter = MockPrompter::new(true, true);
1288
1289 unsafe {
1290 std::env::set_var("CB_SILENT", "1");
1291 }
1292 let result = run_with_args(args, config, &prompter);
1293 unsafe {
1294 std::env::remove_var("CB_SILENT");
1295 }
1296
1297 assert!(result.is_ok());
1298
1299 let content = fs::read_to_string(&output_path).unwrap();
1300 assert!(content.contains("important.txt"));
1301 }
1304
1305 #[test]
1306 fn test_auto_diff_without_previous_state() {
1307 let temp_dir = tempdir().unwrap();
1308 let base_path = temp_dir.path();
1309 let output_file_name = "test.md";
1310 let output_path = temp_dir.path().join(output_file_name);
1311
1312 fs::write(base_path.join("new.txt"), "new content").unwrap();
1313
1314 let args = Args {
1315 input: base_path.to_string_lossy().to_string(),
1316 output: output_path.to_string_lossy().to_string(),
1317 filter: vec![],
1318 ignore: vec![],
1319 line_numbers: false,
1320 preview: false,
1321 token_count: false,
1322 yes: true,
1323 diff_only: false,
1324 clear_cache: false,
1325 init: false,
1326 max_tokens: None,
1327 };
1328 let config = Config {
1329 auto_diff: Some(true),
1330 diff_context_lines: Some(5),
1331 ..Default::default()
1332 };
1333 let prompter = MockPrompter::new(true, true);
1334
1335 unsafe {
1336 std::env::set_var("CB_SILENT", "1");
1337 }
1338 let result = run_with_args(args, config, &prompter);
1339 unsafe {
1340 std::env::remove_var("CB_SILENT");
1341 }
1342
1343 assert!(result.is_ok());
1344 assert!(output_path.exists());
1345
1346 let content = fs::read_to_string(&output_path).unwrap();
1347 assert!(content.contains("new.txt"));
1348 }
1349
1350 #[test]
1351 fn test_run_creates_output_directory() {
1352 let temp_dir = tempdir().unwrap();
1353 let base_path = temp_dir.path();
1354 let output_dir = temp_dir.path().join("nested").join("output");
1355 let output_path = output_dir.join("result.md");
1356
1357 fs::write(base_path.join("test.txt"), "content").unwrap();
1358
1359 let args = Args {
1360 input: base_path.to_string_lossy().to_string(),
1361 output: output_path.to_string_lossy().to_string(),
1362 filter: vec![],
1363 ignore: vec![],
1364 line_numbers: false,
1365 preview: false,
1366 token_count: false,
1367 yes: true,
1368 diff_only: false,
1369 clear_cache: false,
1370 init: false,
1371 max_tokens: None,
1372 };
1373 let config = Config::default();
1374 let prompter = MockPrompter::new(true, true);
1375
1376 unsafe {
1377 std::env::set_var("CB_SILENT", "1");
1378 }
1379 let result = run_with_args(args, config, &prompter);
1380 unsafe {
1381 std::env::remove_var("CB_SILENT");
1382 }
1383
1384 assert!(result.is_ok());
1385 assert!(output_path.exists());
1386 assert!(output_dir.exists());
1387 }
1388
1389 #[test]
1390 fn test_generate_markdown_with_diff_no_comparison() {
1391 let temp_dir = tempdir().unwrap();
1392 let base_path = temp_dir.path();
1393
1394 fs::write(base_path.join("test.rs"), "fn main() {}").unwrap();
1395
1396 let files = collect_files(base_path, &[], &[], &[]).unwrap();
1397 let file_tree = build_file_tree(&files, base_path);
1398 let config = Config::default();
1399 let state = ProjectState::from_files(&files, base_path, &config, false).unwrap();
1400
1401 let args = Args {
1402 input: base_path.to_string_lossy().to_string(),
1403 output: "test.md".to_string(),
1404 filter: vec![],
1405 ignore: vec![],
1406 line_numbers: false,
1407 preview: false,
1408 token_count: false,
1409 yes: false,
1410 diff_only: false,
1411 clear_cache: false,
1412 init: false,
1413 max_tokens: None,
1414 };
1415
1416 let diff_config = DiffConfig::default();
1417
1418 let sorted_paths: Vec<PathBuf> = files
1419 .iter()
1420 .map(|e| {
1421 e.path()
1422 .strip_prefix(base_path)
1423 .unwrap_or(e.path())
1424 .to_path_buf()
1425 })
1426 .collect();
1427
1428 let result = generate_markdown_with_diff(
1429 &state,
1430 None,
1431 &args,
1432 &file_tree,
1433 &diff_config,
1434 &sorted_paths,
1435 );
1436 assert!(result.is_ok());
1437
1438 let content = result.unwrap();
1439 assert!(content.contains("Directory Structure Report"));
1440 assert!(content.contains("test.rs"));
1441 }
1442}