1use chrono::Utc;
2use clap::{CommandFactory, Parser};
3
4use std::fs;
5use std::io::{self, Write};
6use std::path::{Path, PathBuf};
7use std::time::Instant;
8
9pub mod cache;
10pub mod cli;
11pub mod config;
12pub mod config_resolver;
13pub mod diff;
14pub mod file_utils;
15pub mod markdown;
16pub mod state;
17pub mod token_count;
18pub mod tree;
19
20use std::fs::File;
21
22use cache::CacheManager;
23use cli::Args;
24use config::{Config, load_config_from_path};
25use diff::render_per_file_diffs;
26use file_utils::{collect_files, confirm_overwrite, confirm_processing};
27use markdown::generate_markdown;
28use state::{ProjectState, StateComparison};
29use token_count::{count_file_tokens, count_tree_tokens, estimate_tokens};
30use tree::{build_file_tree, print_tree};
31
32#[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(base_path, &final_args.filter, &final_args.ignore, &auto_ignores)?;
209 let debug_config = std::env::var("CB_DEBUG_CONFIG").is_ok();
210 if debug_config {
211 eprintln!("[DEBUG][CONFIG] Args: {:?}", final_args);
212 eprintln!("[DEBUG][CONFIG] Raw Config: {:?}", config);
213 eprintln!("[DEBUG][CONFIG] Auto-ignores: {:?}", auto_ignores);
214 eprintln!("[DEBUG][CONFIG] Collected {} files", files.len());
215 for f in &files {
216 eprintln!("[DEBUG][CONFIG] - {}", f.path().display());
217 }
218 }
219
220 if !silent {
222 const LARGE_FILE_THRESHOLD: u64 = 100 * 1024; let mut large_files: Vec<(String, u64)> = Vec::new();
224 let mut total_size: u64 = 0;
225
226 for entry in &files {
227 if let Ok(metadata) = entry.path().metadata() {
228 let size = metadata.len();
229 total_size += size;
230 if size > LARGE_FILE_THRESHOLD {
231 let rel_path = entry
232 .path()
233 .strip_prefix(base_path)
234 .unwrap_or(entry.path())
235 .to_string_lossy()
236 .to_string();
237 large_files.push((rel_path, size));
238 }
239 }
240 }
241
242 if !large_files.is_empty() {
243 large_files.sort_by(|a, b| b.1.cmp(&a.1)); eprintln!(
245 "\nā {} large file(s) detected (>{} KB):",
246 large_files.len(),
247 LARGE_FILE_THRESHOLD / 1024
248 );
249 for (path, size) in large_files.iter().take(5) {
250 eprintln!(" {:>8} KB {}", size / 1024, path);
251 }
252 if large_files.len() > 5 {
253 eprintln!(" ... and {} more", large_files.len() - 5);
254 }
255 eprintln!(
256 " Total context size: {} KB across {} files\n",
257 total_size / 1024,
258 files.len()
259 );
260 }
261 }
262 let file_tree = build_file_tree(&files, base_path);
263
264 if final_args.preview {
265 if !silent {
266 println!("\n# File Tree Structure (Preview)\n");
267 print_tree(&file_tree, 0);
268 }
269 if !final_args.token_count {
270 return Ok(());
271 }
272 }
273
274 if final_args.token_count {
275 if !silent {
276 println!("\n# Token Count Estimation\n");
277 let mut total_tokens = 0;
278 total_tokens += estimate_tokens("# Directory Structure Report\n\n");
279 if !final_args.filter.is_empty() {
280 total_tokens += estimate_tokens(&format!(
281 "This document contains files from the `{}` directory with extensions: {} \n",
282 final_args.input,
283 final_args.filter.join(", ")
284 ));
285 } else {
286 total_tokens += estimate_tokens(&format!(
287 "This document contains all files from the `{}` directory, optimized for LLM consumption.\n",
288 final_args.input
289 ));
290 }
291 if !final_args.ignore.is_empty() {
292 total_tokens += estimate_tokens(&format!(
293 "Custom ignored patterns: {} \n",
294 final_args.ignore.join(", ")
295 ));
296 }
297 total_tokens += estimate_tokens(&format!(
298 "Processed at: {}\n\n",
299 Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
300 ));
301 total_tokens += estimate_tokens("## File Tree Structure\n\n");
302 let tree_tokens = count_tree_tokens(&file_tree, 0);
303 total_tokens += tree_tokens;
304 let file_tokens: usize = files
305 .iter()
306 .map(|entry| count_file_tokens(base_path, entry, final_args.line_numbers))
307 .sum();
308 total_tokens += file_tokens;
309 println!("Estimated total tokens: {}", total_tokens);
310 println!("File tree tokens: {}", tree_tokens);
311 println!("File content tokens: {}", file_tokens);
312 }
313 return Ok(());
314 }
315
316 if !final_args.yes && !prompter.confirm_processing(files.len())? {
317 if !silent {
318 println!("Operation cancelled.");
319 }
320 return Err(io::Error::new(
321 io::ErrorKind::Interrupted,
322 "Operation cancelled by user",
323 ));
324 }
325
326 if config.auto_diff.unwrap_or(false) {
331 let mut effective_config = config.clone();
336 if !final_args.filter.is_empty() {
338 effective_config.filter = Some(final_args.filter.clone());
339 }
340 if !final_args.ignore.is_empty() {
341 effective_config.ignore = Some(final_args.ignore.clone());
342 }
343 effective_config.line_numbers = Some(final_args.line_numbers);
344
345 let current_state = ProjectState::from_files(
347 &files,
348 base_path,
349 &effective_config,
350 final_args.line_numbers,
351 )?;
352
353 let cache_manager = CacheManager::new(base_path, &effective_config);
355 let previous_state = match cache_manager.read_cache() {
356 Ok(state) => state,
357 Err(e) => {
358 if !silent {
359 eprintln!(
360 "Warning: Failed to read cache (proceeding without diff): {}",
361 e
362 );
363 }
364 None
365 }
366 };
367
368 let diff_cfg = diff_config.as_ref().unwrap();
369
370 let effective_previous = if let Some(prev) = previous_state.as_ref() {
372 if prev.config_hash != current_state.config_hash {
373 None
375 } else {
376 Some(prev)
377 }
378 } else {
379 None
380 };
381
382 let comparison = effective_previous.map(|prev| current_state.compare_with(prev));
384
385 let debug_autodiff = std::env::var("CB_DEBUG_AUTODIFF").is_ok();
386 if debug_autodiff {
387 eprintln!(
388 "[DEBUG][AUTODIFF] cache file: {}",
389 cache_manager.debug_cache_file_path().display()
390 );
391 eprintln!(
392 "[DEBUG][AUTODIFF] config_hash current={} prev={:?} invalidated={}",
393 current_state.config_hash,
394 previous_state.as_ref().map(|s| s.config_hash.clone()),
395 effective_previous.is_none() && previous_state.is_some()
396 );
397 eprintln!("[DEBUG][AUTODIFF] effective_config: {:?}", effective_config);
398 if let Some(prev) = previous_state.as_ref() {
399 eprintln!("[DEBUG][AUTODIFF] raw previous files: {}", prev.files.len());
400 }
401 if let Some(prev) = effective_previous {
402 eprintln!(
403 "[DEBUG][AUTODIFF] effective previous files: {}",
404 prev.files.len()
405 );
406 for k in prev.files.keys() {
407 eprintln!(" PREV: {}", k.display());
408 }
409 }
410 eprintln!(
411 "[DEBUG][AUTODIFF] current files: {}",
412 current_state.files.len()
413 );
414 for k in current_state.files.keys() {
415 eprintln!(" CURR: {}", k.display());
416 }
417 }
418
419 let final_doc = generate_markdown_with_diff(
421 ¤t_state,
422 comparison.as_ref(),
423 &final_args,
424 &file_tree,
425 diff_cfg,
426 )?;
427
428 let output_path = Path::new(&final_args.output);
430 if let Some(parent) = output_path.parent()
431 && !parent.exists()
432 && let Err(e) = fs::create_dir_all(parent)
433 {
434 return Err(io::Error::other(format!(
435 "Failed to create output directory {}: {}",
436 parent.display(),
437 e
438 )));
439 }
440 let mut final_output = fs::File::create(output_path)?;
441 final_output.write_all(final_doc.as_bytes())?;
442
443 if let Err(e) = cache_manager.write_cache(¤t_state)
445 && !silent
446 {
447 eprintln!("Warning: failed to update state cache: {}", e);
448 }
449
450 let duration = start_time.elapsed();
451 if !silent {
452 if let Some(comp) = &comparison {
453 if comp.summary.has_changes() {
454 println!(
455 "Documentation created successfully with {} changes: {}",
456 comp.summary.total_changes, final_args.output
457 );
458 } else {
459 println!(
460 "Documentation created successfully (no changes detected): {}",
461 final_args.output
462 );
463 }
464 } else {
465 println!(
466 "Documentation created successfully (initial state): {}",
467 final_args.output
468 );
469 }
470 println!("Processing time: {:.2?}", duration);
471 }
472 return Ok(());
473 }
474
475 generate_markdown(
477 &final_args.output,
478 &final_args.input,
479 &final_args.filter,
480 &final_args.ignore,
481 &file_tree,
482 &files,
483 base_path,
484 final_args.line_numbers,
485 config.encoding_strategy.as_deref(),
486 )?;
487
488 let duration = start_time.elapsed();
489 if !silent {
490 println!("Documentation created successfully: {}", final_args.output);
491 println!("Processing time: {:.2?}", duration);
492 }
493
494 Ok(())
495}
496
497fn generate_markdown_with_diff(
499 current_state: &ProjectState,
500 comparison: Option<&StateComparison>,
501 args: &Args,
502 file_tree: &tree::FileTree,
503 diff_config: &DiffConfig,
504) -> io::Result<String> {
505 let mut output = String::new();
506
507 output.push_str("# Directory Structure Report\n\n");
509
510 output.push_str(&format!(
512 "**Project:** {}\n",
513 current_state.metadata.project_name
514 ));
515 output.push_str(&format!("**Generated:** {}\n", current_state.timestamp));
516
517 if !args.filter.is_empty() {
518 output.push_str(&format!("**Filters:** {}\n", args.filter.join(", ")));
519 }
520
521 if !args.ignore.is_empty() {
522 output.push_str(&format!("**Ignored:** {}\n", args.ignore.join(", ")));
523 }
524
525 output.push('\n');
526
527 if let Some(comp) = comparison {
529 if comp.summary.has_changes() {
530 output.push_str(&comp.summary.to_markdown());
531
532 let added_files: Vec<_> = comp
534 .file_diffs
535 .iter()
536 .filter(|d| matches!(d.status, diff::PerFileStatus::Added))
537 .collect();
538
539 if diff_config.diff_only && !added_files.is_empty() {
540 output.push_str("## Added Files\n\n");
541 for added in added_files {
542 output.push_str(&format!("### File: `{}`\n\n", added.path));
543 output.push_str("_Status: Added_\n\n");
544 let mut lines: Vec<String> = Vec::new();
546 for line in added.diff.lines() {
547 if let Some(rest) = line.strip_prefix('+') {
548 lines.push(rest.to_string());
549 }
550 }
551 output.push_str("```text\n");
552 if args.line_numbers {
553 for (idx, l) in lines.iter().enumerate() {
554 output.push_str(&format!("{:>4} | {}\n", idx + 1, l));
555 }
556 } else {
557 for l in lines {
558 output.push_str(&l);
559 output.push('\n');
560 }
561 }
562 output.push_str("```\n\n");
563 }
564 }
565
566 let changed_diffs: Vec<diff::PerFileDiff> = comp
568 .file_diffs
569 .iter()
570 .filter(|d| d.is_changed())
571 .cloned()
572 .collect();
573 if !changed_diffs.is_empty() {
574 output.push_str("## File Differences\n\n");
575 let diff_markdown = render_per_file_diffs(&changed_diffs);
576 output.push_str(&diff_markdown);
577 }
578 } else {
579 output.push_str("## No Changes Detected\n\n");
580 }
581 }
582
583 output.push_str("## File Tree Structure\n\n");
585 let mut tree_output = Vec::new();
586 tree::write_tree_to_file(&mut tree_output, file_tree, 0)?;
587 output.push_str(&String::from_utf8_lossy(&tree_output));
588 output.push('\n');
589
590 if !diff_config.diff_only {
592 output.push_str("## File Contents\n\n");
593
594 for (path, file_state) in ¤t_state.files {
595 output.push_str(&format!("### File: `{}`\n\n", path.display()));
596 output.push_str(&format!("- Size: {} bytes\n", file_state.size));
597 output.push_str(&format!("- Modified: {:?}\n\n", file_state.modified));
598
599 let extension = path.extension().and_then(|s| s.to_str()).unwrap_or("text");
601 let language = match extension {
602 "rs" => "rust",
603 "js" => "javascript",
604 "ts" => "typescript",
605 "py" => "python",
606 "json" => "json",
607 "toml" => "toml",
608 "md" => "markdown",
609 "yaml" | "yml" => "yaml",
610 "html" => "html",
611 "css" => "css",
612 _ => extension,
613 };
614
615 output.push_str(&format!("```{}\n", language));
616
617 if args.line_numbers {
618 for (i, line) in file_state.content.lines().enumerate() {
619 output.push_str(&format!("{:>4} | {}\n", i + 1, line));
620 }
621 } else {
622 output.push_str(&file_state.content);
623 if !file_state.content.ends_with('\n') {
624 output.push('\n');
625 }
626 }
627
628 output.push_str("```\n\n");
629 }
630 }
631
632 Ok(output)
633}
634
635pub fn run() -> io::Result<()> {
636 env_logger::init();
637 let args = Args::parse();
638
639 if args.init {
641 return init_config();
642 }
643
644 let project_root = Path::new(&args.input);
646 let config = load_config_from_path(project_root);
647
648 if args.clear_cache {
650 let cache_path = project_root.join(".context-builder").join("cache");
651 if cache_path.exists() {
652 match fs::remove_dir_all(&cache_path) {
653 Ok(()) => println!("Cache cleared: {}", cache_path.display()),
654 Err(e) => eprintln!("Failed to clear cache ({}): {}", cache_path.display(), e),
655 }
656 } else {
657 println!("No cache directory found at {}", cache_path.display());
658 }
659 return Ok(());
660 }
661
662 if std::env::args().len() == 1 && config.is_none() {
663 Args::command().print_help()?;
664 return Ok(());
665 }
666
667 let resolution = crate::config_resolver::resolve_final_config(args, config.clone());
669
670 let silent = std::env::var("CB_SILENT")
672 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
673 .unwrap_or(false);
674
675 if !silent {
676 for warning in &resolution.warnings {
677 eprintln!("Warning: {}", warning);
678 }
679 }
680
681 let final_args = Args {
683 input: resolution.config.input,
684 output: resolution.config.output,
685 filter: resolution.config.filter,
686 ignore: resolution.config.ignore,
687 line_numbers: resolution.config.line_numbers,
688 preview: resolution.config.preview,
689 token_count: resolution.config.token_count,
690 yes: resolution.config.yes,
691 diff_only: resolution.config.diff_only,
692 clear_cache: resolution.config.clear_cache,
693 init: false,
694 };
695
696 let final_config = Config {
698 auto_diff: Some(resolution.config.auto_diff),
699 diff_context_lines: Some(resolution.config.diff_context_lines),
700 ..config.unwrap_or_default()
701 };
702
703 run_with_args(final_args, final_config, &DefaultPrompter)
704}
705
706fn detect_major_file_types() -> io::Result<Vec<String>> {
708 use std::collections::HashMap;
709 let mut extension_counts = HashMap::new();
710
711 let default_ignores = vec![
713 "docs".to_string(),
714 "target".to_string(),
715 ".git".to_string(),
716 "node_modules".to_string(),
717 ];
718
719 let files = crate::file_utils::collect_files(Path::new("."), &[], &default_ignores, &[])?;
721
722 for entry in files {
724 let path = entry.path();
725 if let Some(extension) = path.extension().and_then(|ext| ext.to_str()) {
726 *extension_counts.entry(extension.to_string()).or_insert(0) += 1;
728 }
729 }
730
731 let mut extensions: Vec<(String, usize)> = extension_counts.into_iter().collect();
733 extensions.sort_by(|a, b| b.1.cmp(&a.1));
734
735 let top_extensions: Vec<String> = extensions.into_iter().take(5).map(|(ext, _)| ext).collect();
737
738 Ok(top_extensions)
739}
740
741fn init_config() -> io::Result<()> {
743 let config_path = Path::new("context-builder.toml");
744
745 if config_path.exists() {
746 println!("Config file already exists at {}", config_path.display());
747 println!("If you want to replace it, please remove it manually first.");
748 return Ok(());
749 }
750
751 let filter_suggestions = match detect_major_file_types() {
753 Ok(extensions) => extensions,
754 _ => vec!["rs".to_string(), "toml".to_string()], };
756
757 let filter_string = if filter_suggestions.is_empty() {
758 r#"["rs", "toml"]"#.to_string()
759 } else {
760 format!(r#"["{}"]"#, filter_suggestions.join(r#"", ""#))
761 };
762
763 let default_config_content = format!(
764 r#"# Context Builder Configuration File
765# This file was generated with sensible defaults based on the file types detected in your project
766
767# Output file name (or base name when timestamped_output is true)
768output = "context.md"
769
770# Optional folder to place the generated output file(s) in
771output_folder = "docs"
772
773# Append a UTC timestamp to the output file name (before extension)
774timestamped_output = true
775
776# Enable automatic diff generation (requires timestamped_output = true)
777auto_diff = true
778
779# Emit only change summary + modified file diffs (no full file bodies)
780diff_only = false
781
782# File extensions to include (no leading dot, e.g. "rs", "toml")
783filter = {}
784
785# File / directory names to ignore (exact name matches)
786ignore = ["docs", "target", ".git", "node_modules"]
787
788# Add line numbers to code blocks
789line_numbers = false
790"#,
791 filter_string
792 );
793
794 let mut file = File::create(config_path)?;
795 file.write_all(default_config_content.as_bytes())?;
796
797 println!("Config file created at {}", config_path.display());
798 println!("Detected file types: {}", filter_suggestions.join(", "));
799 println!("You can now customize it according to your project needs.");
800
801 Ok(())
802}
803
804#[cfg(test)]
805mod tests {
806 use super::*;
807 use std::io::Result;
808 use tempfile::tempdir;
809
810 struct MockPrompter {
812 confirm_processing_response: bool,
813 confirm_overwrite_response: bool,
814 }
815
816 impl MockPrompter {
817 fn new(processing: bool, overwrite: bool) -> Self {
818 Self {
819 confirm_processing_response: processing,
820 confirm_overwrite_response: overwrite,
821 }
822 }
823 }
824
825 impl Prompter for MockPrompter {
826 fn confirm_processing(&self, _file_count: usize) -> Result<bool> {
827 Ok(self.confirm_processing_response)
828 }
829
830 fn confirm_overwrite(&self, _file_path: &str) -> Result<bool> {
831 Ok(self.confirm_overwrite_response)
832 }
833 }
834
835 #[test]
836 fn test_diff_config_default() {
837 let config = DiffConfig::default();
838 assert_eq!(config.context_lines, 3);
839 assert!(!config.enabled);
840 assert!(!config.diff_only);
841 }
842
843 #[test]
844 fn test_diff_config_custom() {
845 let config = DiffConfig {
846 context_lines: 5,
847 enabled: true,
848 diff_only: true,
849 };
850 assert_eq!(config.context_lines, 5);
851 assert!(config.enabled);
852 assert!(config.diff_only);
853 }
854
855 #[test]
856 fn test_default_prompter() {
857 let prompter = DefaultPrompter;
858
859 let result = prompter.confirm_processing(50);
861 assert!(result.is_ok());
862 assert!(result.unwrap());
863 }
864
865 #[test]
866 fn test_run_with_args_nonexistent_directory() {
867 let args = Args {
868 input: "/nonexistent/directory".to_string(),
869 output: "output.md".to_string(),
870 filter: vec![],
871 ignore: vec![],
872 line_numbers: false,
873 preview: false,
874 token_count: false,
875 yes: false,
876 diff_only: false,
877 clear_cache: false,
878 init: false,
879 };
880 let config = Config::default();
881 let prompter = MockPrompter::new(true, true);
882
883 let result = run_with_args(args, config, &prompter);
884 assert!(result.is_err());
885 assert!(result.unwrap_err().to_string().contains("does not exist"));
886 }
887
888 #[test]
889 fn test_run_with_args_preview_mode() {
890 let temp_dir = tempdir().unwrap();
891 let base_path = temp_dir.path();
892
893 fs::write(base_path.join("test.rs"), "fn main() {}").unwrap();
895 fs::create_dir(base_path.join("src")).unwrap();
896 fs::write(base_path.join("src/lib.rs"), "pub fn hello() {}").unwrap();
897
898 let args = Args {
899 input: ".".to_string(),
900 output: "test.md".to_string(),
901 filter: vec![],
902 ignore: vec![],
903 line_numbers: false,
904 preview: false,
905 token_count: false,
906 yes: false,
907 diff_only: false,
908 clear_cache: false,
909 init: false,
910 };
911 let config = Config::default();
912 let prompter = MockPrompter::new(true, true);
913
914 unsafe {
916 std::env::set_var("CB_SILENT", "1");
917 }
918 let result = run_with_args(args, config, &prompter);
919 unsafe {
920 std::env::remove_var("CB_SILENT");
921 }
922
923 assert!(result.is_ok());
924 }
925
926 #[test]
927 fn test_run_with_args_token_count_mode() {
928 let temp_dir = tempdir().unwrap();
929 let base_path = temp_dir.path();
930
931 fs::write(base_path.join("small.txt"), "Hello world").unwrap();
933
934 let args = Args {
935 input: base_path.to_string_lossy().to_string(),
936 output: "test.md".to_string(),
937 filter: vec![],
938 ignore: vec![],
939 line_numbers: false,
940 preview: false,
941 token_count: true,
942 yes: false,
943 diff_only: false,
944 clear_cache: false,
945 init: false,
946 };
947 let config = Config::default();
948 let prompter = MockPrompter::new(true, true);
949
950 unsafe {
951 std::env::set_var("CB_SILENT", "1");
952 }
953 let result = run_with_args(args, config, &prompter);
954 unsafe {
955 std::env::remove_var("CB_SILENT");
956 }
957
958 assert!(result.is_ok());
959 }
960
961 #[test]
962 fn test_run_with_args_preview_and_token_count() {
963 let temp_dir = tempdir().unwrap();
964 let base_path = temp_dir.path();
965
966 fs::write(base_path.join("test.txt"), "content").unwrap();
967
968 let args = Args {
969 input: base_path.to_string_lossy().to_string(),
970 output: "test.md".to_string(),
971 filter: vec![],
972 ignore: vec![],
973 line_numbers: false,
974 preview: true,
975 token_count: false,
976 yes: false,
977 diff_only: false,
978 clear_cache: false,
979 init: false,
980 };
981 let config = Config::default();
982 let prompter = MockPrompter::new(true, true);
983
984 unsafe {
985 std::env::set_var("CB_SILENT", "1");
986 }
987 let result = run_with_args(args, config, &prompter);
988 unsafe {
989 std::env::remove_var("CB_SILENT");
990 }
991
992 assert!(result.is_ok());
993 }
994
995 #[test]
996 fn test_run_with_args_user_cancels_overwrite() {
997 let temp_dir = tempdir().unwrap();
998 let base_path = temp_dir.path();
999 let output_path = temp_dir.path().join("existing.md");
1000
1001 fs::write(base_path.join("test.txt"), "content").unwrap();
1003 fs::write(&output_path, "existing content").unwrap();
1004
1005 let args = Args {
1006 input: base_path.to_string_lossy().to_string(),
1007 output: "test.md".to_string(),
1008 filter: vec![],
1009 ignore: vec!["target".to_string()],
1010 line_numbers: false,
1011 preview: false,
1012 token_count: false,
1013 yes: false,
1014 diff_only: false,
1015 clear_cache: false,
1016 init: false,
1017 };
1018 let config = Config::default();
1019 let prompter = MockPrompter::new(true, false); unsafe {
1022 std::env::set_var("CB_SILENT", "1");
1023 }
1024 let result = run_with_args(args, config, &prompter);
1025 unsafe {
1026 std::env::remove_var("CB_SILENT");
1027 }
1028
1029 assert!(result.is_err());
1030 assert!(result.unwrap_err().to_string().contains("cancelled"));
1031 }
1032
1033 #[test]
1034 fn test_run_with_args_user_cancels_processing() {
1035 let temp_dir = tempdir().unwrap();
1036 let base_path = temp_dir.path();
1037
1038 for i in 0..105 {
1040 fs::write(base_path.join(format!("file{}.txt", i)), "content").unwrap();
1041 }
1042
1043 let args = Args {
1044 input: base_path.to_string_lossy().to_string(),
1045 output: "test.md".to_string(),
1046 filter: vec!["rs".to_string()],
1047 ignore: vec![],
1048 line_numbers: false,
1049 preview: false,
1050 token_count: false,
1051 yes: false,
1052 diff_only: false,
1053 clear_cache: false,
1054 init: false,
1055 };
1056 let config = Config::default();
1057 let prompter = MockPrompter::new(false, true); unsafe {
1060 std::env::set_var("CB_SILENT", "1");
1061 }
1062 let result = run_with_args(args, config, &prompter);
1063 unsafe {
1064 std::env::remove_var("CB_SILENT");
1065 }
1066
1067 assert!(result.is_err());
1068 assert!(result.unwrap_err().to_string().contains("cancelled"));
1069 }
1070
1071 #[test]
1072 fn test_run_with_args_with_yes_flag() {
1073 let temp_dir = tempdir().unwrap();
1074 let base_path = temp_dir.path();
1075 let output_file_name = "test.md";
1076 let output_path = temp_dir.path().join(output_file_name);
1077
1078 fs::write(base_path.join("test.txt"), "Hello world").unwrap();
1079
1080 let args = Args {
1081 input: base_path.to_string_lossy().to_string(),
1082 output: output_path.to_string_lossy().to_string(),
1083 filter: vec![],
1084 ignore: vec!["ignored_dir".to_string()],
1085 line_numbers: false,
1086 preview: false,
1087 token_count: false,
1088 yes: true,
1089 diff_only: false,
1090 clear_cache: false,
1091 init: false,
1092 };
1093 let config = Config::default();
1094 let prompter = MockPrompter::new(true, true);
1095
1096 unsafe {
1097 std::env::set_var("CB_SILENT", "1");
1098 }
1099 let result = run_with_args(args, config, &prompter);
1100 unsafe {
1101 std::env::remove_var("CB_SILENT");
1102 }
1103
1104 assert!(result.is_ok());
1105 assert!(output_path.exists());
1106
1107 let content = fs::read_to_string(&output_path).unwrap();
1108 assert!(content.contains("Directory Structure Report"));
1109 assert!(content.contains("test.txt"));
1110 }
1111
1112 #[test]
1113 fn test_run_with_args_with_filters() {
1114 let temp_dir = tempdir().unwrap();
1115 let base_path = temp_dir.path();
1116 let output_file_name = "test.md";
1117 let output_path = temp_dir.path().join(output_file_name);
1118
1119 fs::write(base_path.join("code.rs"), "fn main() {}").unwrap();
1120 fs::write(base_path.join("readme.md"), "# README").unwrap();
1121 fs::write(base_path.join("data.json"), r#"{"key": "value"}"#).unwrap();
1122
1123 let args = Args {
1124 input: base_path.to_string_lossy().to_string(),
1125 output: output_path.to_string_lossy().to_string(),
1126 filter: vec!["rs".to_string(), "md".to_string()],
1127 ignore: vec![],
1128 line_numbers: true,
1129 preview: false,
1130 token_count: false,
1131 yes: true,
1132 diff_only: false,
1133 clear_cache: false,
1134 init: false,
1135 };
1136 let config = Config::default();
1137 let prompter = MockPrompter::new(true, true);
1138
1139 unsafe {
1140 std::env::set_var("CB_SILENT", "1");
1141 }
1142 let result = run_with_args(args, config, &prompter);
1143 unsafe {
1144 std::env::remove_var("CB_SILENT");
1145 }
1146
1147 assert!(result.is_ok());
1148
1149 let content = fs::read_to_string(&output_path).unwrap();
1150 assert!(content.contains("code.rs"));
1151 assert!(content.contains("readme.md"));
1152 assert!(!content.contains("data.json")); assert!(content.contains(" 1 |")); }
1155
1156 #[test]
1157 fn test_run_with_args_with_ignores() {
1158 let temp_dir = tempdir().unwrap();
1159 let base_path = temp_dir.path();
1160 let output_path = temp_dir.path().join("ignored.md");
1161
1162 fs::write(base_path.join("important.txt"), "important content").unwrap();
1163 fs::write(base_path.join("secret.txt"), "secret content").unwrap();
1164
1165 let args = Args {
1166 input: base_path.to_string_lossy().to_string(),
1167 output: output_path.to_string_lossy().to_string(),
1168 filter: vec![],
1169 ignore: vec!["secret.txt".to_string()],
1170 line_numbers: false,
1171 preview: false,
1172 token_count: false,
1173 yes: true,
1174 diff_only: false,
1175 clear_cache: false,
1176 init: false,
1177 };
1178 let config = Config::default();
1179 let prompter = MockPrompter::new(true, true);
1180
1181 unsafe {
1182 std::env::set_var("CB_SILENT", "1");
1183 }
1184 let result = run_with_args(args, config, &prompter);
1185 unsafe {
1186 std::env::remove_var("CB_SILENT");
1187 }
1188
1189 assert!(result.is_ok());
1190
1191 let content = fs::read_to_string(&output_path).unwrap();
1192 assert!(content.contains("important.txt"));
1193 }
1196
1197 #[test]
1198 fn test_auto_diff_without_previous_state() {
1199 let temp_dir = tempdir().unwrap();
1200 let base_path = temp_dir.path();
1201 let output_file_name = "test.md";
1202 let output_path = temp_dir.path().join(output_file_name);
1203
1204 fs::write(base_path.join("new.txt"), "new content").unwrap();
1205
1206 let args = Args {
1207 input: base_path.to_string_lossy().to_string(),
1208 output: output_path.to_string_lossy().to_string(),
1209 filter: vec![],
1210 ignore: vec![],
1211 line_numbers: false,
1212 preview: false,
1213 token_count: false,
1214 yes: true,
1215 diff_only: false,
1216 clear_cache: false,
1217 init: false,
1218 };
1219 let config = Config {
1220 auto_diff: Some(true),
1221 diff_context_lines: Some(5),
1222 ..Default::default()
1223 };
1224 let prompter = MockPrompter::new(true, true);
1225
1226 unsafe {
1227 std::env::set_var("CB_SILENT", "1");
1228 }
1229 let result = run_with_args(args, config, &prompter);
1230 unsafe {
1231 std::env::remove_var("CB_SILENT");
1232 }
1233
1234 assert!(result.is_ok());
1235 assert!(output_path.exists());
1236
1237 let content = fs::read_to_string(&output_path).unwrap();
1238 assert!(content.contains("new.txt"));
1239 }
1240
1241 #[test]
1242 fn test_run_creates_output_directory() {
1243 let temp_dir = tempdir().unwrap();
1244 let base_path = temp_dir.path();
1245 let output_dir = temp_dir.path().join("nested").join("output");
1246 let output_path = output_dir.join("result.md");
1247
1248 fs::write(base_path.join("test.txt"), "content").unwrap();
1249
1250 let args = Args {
1251 input: base_path.to_string_lossy().to_string(),
1252 output: output_path.to_string_lossy().to_string(),
1253 filter: vec![],
1254 ignore: vec![],
1255 line_numbers: false,
1256 preview: false,
1257 token_count: false,
1258 yes: true,
1259 diff_only: false,
1260 clear_cache: false,
1261 init: false,
1262 };
1263 let config = Config::default();
1264 let prompter = MockPrompter::new(true, true);
1265
1266 unsafe {
1267 std::env::set_var("CB_SILENT", "1");
1268 }
1269 let result = run_with_args(args, config, &prompter);
1270 unsafe {
1271 std::env::remove_var("CB_SILENT");
1272 }
1273
1274 assert!(result.is_ok());
1275 assert!(output_path.exists());
1276 assert!(output_dir.exists());
1277 }
1278
1279 #[test]
1280 fn test_generate_markdown_with_diff_no_comparison() {
1281 let temp_dir = tempdir().unwrap();
1282 let base_path = temp_dir.path();
1283
1284 fs::write(base_path.join("test.rs"), "fn main() {}").unwrap();
1285
1286 let files = collect_files(base_path, &[], &[], &[]).unwrap();
1287 let file_tree = build_file_tree(&files, base_path);
1288 let config = Config::default();
1289 let state = ProjectState::from_files(&files, base_path, &config, false).unwrap();
1290
1291 let args = Args {
1292 input: base_path.to_string_lossy().to_string(),
1293 output: "test.md".to_string(),
1294 filter: vec![],
1295 ignore: vec![],
1296 line_numbers: false,
1297 preview: false,
1298 token_count: false,
1299 yes: false,
1300 diff_only: false,
1301 clear_cache: false,
1302 init: false,
1303 };
1304
1305 let diff_config = DiffConfig::default();
1306
1307 let result = generate_markdown_with_diff(&state, None, &args, &file_tree, &diff_config);
1308 assert!(result.is_ok());
1309
1310 let content = result.unwrap();
1311 assert!(content.contains("Directory Structure Report"));
1312 assert!(content.contains("test.rs"));
1313 }
1314}