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 mut 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 files = collect_files(base_path, &final_args.filter, &final_args.ignore)?;
136 let debug_config = std::env::var("CB_DEBUG_CONFIG").is_ok();
137 if debug_config {
138 eprintln!("[DEBUG][CONFIG] Args: {:?}", final_args);
139 eprintln!("[DEBUG][CONFIG] Raw Config: {:?}", config);
140 eprintln!("[DEBUG][CONFIG] Collected {} files", files.len());
141 for f in &files {
142 eprintln!("[DEBUG][CONFIG] - {}", f.path().display());
143 }
144 }
145 let file_tree = build_file_tree(&files, base_path);
146
147 if final_args.preview {
148 if !silent {
149 println!("\n# File Tree Structure (Preview)\n");
150 print_tree(&file_tree, 0);
151 }
152 if !final_args.token_count {
153 return Ok(());
154 }
155 }
156
157 if final_args.token_count {
158 if !silent {
159 println!("\n# Token Count Estimation\n");
160 let mut total_tokens = 0;
161 total_tokens += estimate_tokens("# Directory Structure Report\n\n");
162 if !final_args.filter.is_empty() {
163 total_tokens += estimate_tokens(&format!(
164 "This document contains files from the `{}` directory with extensions: {} \n",
165 final_args.input,
166 final_args.filter.join(", ")
167 ));
168 } else {
169 total_tokens += estimate_tokens(&format!(
170 "This document contains all files from the `{}` directory, optimized for LLM consumption.\n",
171 final_args.input
172 ));
173 }
174 if !final_args.ignore.is_empty() {
175 total_tokens += estimate_tokens(&format!(
176 "Custom ignored patterns: {} \n",
177 final_args.ignore.join(", ")
178 ));
179 }
180 total_tokens += estimate_tokens(&format!(
181 "Processed at: {}\n\n",
182 Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
183 ));
184 total_tokens += estimate_tokens("## File Tree Structure\n\n");
185 let tree_tokens = count_tree_tokens(&file_tree, 0);
186 total_tokens += tree_tokens;
187 let file_tokens: usize = files
188 .iter()
189 .map(|entry| count_file_tokens(base_path, entry, final_args.line_numbers))
190 .sum();
191 total_tokens += file_tokens;
192 println!("Estimated total tokens: {}", total_tokens);
193 println!("File tree tokens: {}", tree_tokens);
194 println!("File content tokens: {}", file_tokens);
195 }
196 return Ok(());
197 }
198
199 if !final_args.yes && !prompter.confirm_processing(files.len())? {
200 if !silent {
201 println!("Operation cancelled.");
202 }
203 return Err(io::Error::new(
204 io::ErrorKind::Interrupted,
205 "Operation cancelled by user",
206 ));
207 }
208
209 if let Some(cfg_ln) = config.line_numbers {
214 final_args.line_numbers = cfg_ln;
215 }
216 if let Some(cfg_diff_only) = config.diff_only {
217 final_args.diff_only = cfg_diff_only;
218 }
219
220 if config.auto_diff.unwrap_or(false) {
221 let mut effective_config = config.clone();
226 if !final_args.filter.is_empty() {
228 effective_config.filter = Some(final_args.filter.clone());
229 }
230 if !final_args.ignore.is_empty() {
231 effective_config.ignore = Some(final_args.ignore.clone());
232 }
233 effective_config.line_numbers = Some(final_args.line_numbers);
234
235 let current_state = ProjectState::from_files(
237 &files,
238 base_path,
239 &effective_config,
240 final_args.line_numbers,
241 )?;
242
243 let cache_manager = CacheManager::new(base_path, &effective_config);
245 let previous_state = match cache_manager.read_cache() {
246 Ok(state) => state,
247 Err(e) => {
248 if !silent {
249 eprintln!(
250 "Warning: Failed to read cache (proceeding without diff): {}",
251 e
252 );
253 }
254 None
255 }
256 };
257
258 let diff_cfg = diff_config.as_ref().unwrap();
259
260 let effective_previous = if let Some(prev) = previous_state.as_ref() {
262 if prev.config_hash != current_state.config_hash {
263 None
265 } else {
266 Some(prev)
267 }
268 } else {
269 None
270 };
271
272 let comparison = effective_previous.map(|prev| current_state.compare_with(prev));
274
275 let debug_autodiff = std::env::var("CB_DEBUG_AUTODIFF").is_ok();
276 if debug_autodiff {
277 eprintln!(
278 "[DEBUG][AUTODIFF] cache file: {}",
279 cache_manager.debug_cache_file_path().display()
280 );
281 eprintln!(
282 "[DEBUG][AUTODIFF] config_hash current={} prev={:?} invalidated={}",
283 current_state.config_hash,
284 previous_state.as_ref().map(|s| s.config_hash.clone()),
285 effective_previous.is_none() && previous_state.is_some()
286 );
287 eprintln!("[DEBUG][AUTODIFF] effective_config: {:?}", effective_config);
288 if let Some(prev) = previous_state.as_ref() {
289 eprintln!("[DEBUG][AUTODIFF] raw previous files: {}", prev.files.len());
290 }
291 if let Some(prev) = effective_previous {
292 eprintln!(
293 "[DEBUG][AUTODIFF] effective previous files: {}",
294 prev.files.len()
295 );
296 for k in prev.files.keys() {
297 eprintln!(" PREV: {}", k.display());
298 }
299 }
300 eprintln!(
301 "[DEBUG][AUTODIFF] current files: {}",
302 current_state.files.len()
303 );
304 for k in current_state.files.keys() {
305 eprintln!(" CURR: {}", k.display());
306 }
307 }
308
309 let final_doc = generate_markdown_with_diff(
311 ¤t_state,
312 comparison.as_ref(),
313 &final_args,
314 &file_tree,
315 diff_cfg,
316 )?;
317
318 let output_path = Path::new(&final_args.output);
320 if let Some(parent) = output_path.parent()
321 && !parent.exists()
322 && let Err(e) = fs::create_dir_all(parent)
323 {
324 return Err(io::Error::other(format!(
325 "Failed to create output directory {}: {}",
326 parent.display(),
327 e
328 )));
329 }
330 let mut final_output = fs::File::create(output_path)?;
331 final_output.write_all(final_doc.as_bytes())?;
332
333 if let Err(e) = cache_manager.write_cache(¤t_state)
335 && !silent
336 {
337 eprintln!("Warning: failed to update state cache: {}", e);
338 }
339
340 let duration = start_time.elapsed();
341 if !silent {
342 if let Some(comp) = &comparison {
343 if comp.summary.has_changes() {
344 println!(
345 "Documentation created successfully with {} changes: {}",
346 comp.summary.total_changes, final_args.output
347 );
348 } else {
349 println!(
350 "Documentation created successfully (no changes detected): {}",
351 final_args.output
352 );
353 }
354 } else {
355 println!(
356 "Documentation created successfully (initial state): {}",
357 final_args.output
358 );
359 }
360 println!("Processing time: {:.2?}", duration);
361 }
362 return Ok(());
363 }
364
365 generate_markdown(
367 &final_args.output,
368 &final_args.input,
369 &final_args.filter,
370 &final_args.ignore,
371 &file_tree,
372 &files,
373 base_path,
374 final_args.line_numbers,
375 config.encoding_strategy.as_deref(),
376 )?;
377
378 let duration = start_time.elapsed();
379 if !silent {
380 println!("Documentation created successfully: {}", final_args.output);
381 println!("Processing time: {:.2?}", duration);
382 }
383
384 Ok(())
385}
386
387fn generate_markdown_with_diff(
389 current_state: &ProjectState,
390 comparison: Option<&StateComparison>,
391 args: &Args,
392 file_tree: &tree::FileTree,
393 diff_config: &DiffConfig,
394) -> io::Result<String> {
395 let mut output = String::new();
396
397 output.push_str("# Directory Structure Report\n\n");
399
400 output.push_str(&format!(
402 "**Project:** {}\n",
403 current_state.metadata.project_name
404 ));
405 output.push_str(&format!("**Generated:** {}\n", current_state.timestamp));
406
407 if !args.filter.is_empty() {
408 output.push_str(&format!("**Filters:** {}\n", args.filter.join(", ")));
409 }
410
411 if !args.ignore.is_empty() {
412 output.push_str(&format!("**Ignored:** {}\n", args.ignore.join(", ")));
413 }
414
415 output.push('\n');
416
417 if let Some(comp) = comparison {
419 if comp.summary.has_changes() {
420 output.push_str(&comp.summary.to_markdown());
421
422 let added_files: Vec<_> = comp
424 .file_diffs
425 .iter()
426 .filter(|d| matches!(d.status, diff::PerFileStatus::Added))
427 .collect();
428
429 if diff_config.diff_only && !added_files.is_empty() {
430 output.push_str("## Added Files\n\n");
431 for added in added_files {
432 output.push_str(&format!("### File: `{}`\n\n", added.path));
433 output.push_str("_Status: Added_\n\n");
434 let mut lines: Vec<String> = Vec::new();
436 for line in added.diff.lines() {
437 if let Some(rest) = line.strip_prefix('+') {
438 lines.push(rest.trim_start().to_string());
439 }
440 }
441 output.push_str("```text\n");
442 if args.line_numbers {
443 for (idx, l) in lines.iter().enumerate() {
444 output.push_str(&format!("{:>4} | {}\n", idx + 1, l));
445 }
446 } else {
447 for l in lines {
448 output.push_str(&l);
449 output.push('\n');
450 }
451 }
452 output.push_str("```\n\n");
453 }
454 }
455
456 let changed_diffs: Vec<diff::PerFileDiff> = comp
458 .file_diffs
459 .iter()
460 .filter(|d| d.is_changed())
461 .cloned()
462 .collect();
463 if !changed_diffs.is_empty() {
464 output.push_str("## File Differences\n\n");
465 let diff_markdown = render_per_file_diffs(&changed_diffs);
466 output.push_str(&diff_markdown);
467 }
468 } else {
469 output.push_str("## No Changes Detected\n\n");
470 }
471 }
472
473 output.push_str("## File Tree Structure\n\n");
475 let mut tree_output = Vec::new();
476 tree::write_tree_to_file(&mut tree_output, file_tree, 0)?;
477 output.push_str(&String::from_utf8_lossy(&tree_output));
478 output.push('\n');
479
480 if !diff_config.diff_only {
482 output.push_str("## File Contents\n\n");
483
484 for (path, file_state) in ¤t_state.files {
485 output.push_str(&format!("### File: `{}`\n\n", path.display()));
486 output.push_str(&format!("- Size: {} bytes\n", file_state.size));
487 output.push_str(&format!("- Modified: {:?}\n\n", file_state.modified));
488
489 let extension = path.extension().and_then(|s| s.to_str()).unwrap_or("text");
491 let language = match extension {
492 "rs" => "rust",
493 "js" => "javascript",
494 "ts" => "typescript",
495 "py" => "python",
496 "json" => "json",
497 "toml" => "toml",
498 "md" => "markdown",
499 "yaml" | "yml" => "yaml",
500 "html" => "html",
501 "css" => "css",
502 _ => extension,
503 };
504
505 output.push_str(&format!("```{}\n", language));
506
507 if args.line_numbers {
508 for (i, line) in file_state.content.lines().enumerate() {
509 output.push_str(&format!("{:>4} | {}\n", i + 1, line));
510 }
511 } else {
512 output.push_str(&file_state.content);
513 if !file_state.content.ends_with('\n') {
514 output.push('\n');
515 }
516 }
517
518 output.push_str("```\n\n");
519 }
520 }
521
522 Ok(output)
523}
524
525pub fn run() -> io::Result<()> {
526 env_logger::init();
527 let args = Args::parse();
528
529 if args.init {
531 return init_config();
532 }
533
534 let project_root = Path::new(&args.input);
536 let config = load_config_from_path(project_root);
537
538 if args.clear_cache {
540 let cache_path = project_root.join(".context-builder").join("cache");
541 if cache_path.exists() {
542 match fs::remove_dir_all(&cache_path) {
543 Ok(()) => println!("Cache cleared: {}", cache_path.display()),
544 Err(e) => eprintln!("Failed to clear cache ({}): {}", cache_path.display(), e),
545 }
546 } else {
547 println!("No cache directory found at {}", cache_path.display());
548 }
549 return Ok(());
550 }
551
552 if std::env::args().len() == 1 && config.is_none() {
553 Args::command().print_help()?;
554 return Ok(());
555 }
556
557 let resolution = crate::config_resolver::resolve_final_config(args, config.clone());
559
560 let silent = std::env::var("CB_SILENT")
562 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
563 .unwrap_or(false);
564
565 if !silent {
566 for warning in &resolution.warnings {
567 eprintln!("Warning: {}", warning);
568 }
569 }
570
571 let final_args = Args {
573 input: resolution.config.input,
574 output: resolution.config.output,
575 filter: resolution.config.filter,
576 ignore: resolution.config.ignore,
577 line_numbers: resolution.config.line_numbers,
578 preview: resolution.config.preview,
579 token_count: resolution.config.token_count,
580 yes: resolution.config.yes,
581 diff_only: resolution.config.diff_only,
582 clear_cache: resolution.config.clear_cache,
583 init: false,
584 };
585
586 let final_config = Config {
588 auto_diff: Some(resolution.config.auto_diff),
589 diff_context_lines: Some(resolution.config.diff_context_lines),
590 ..config.unwrap_or_default()
591 };
592
593 run_with_args(final_args, final_config, &DefaultPrompter)
594}
595
596fn detect_major_file_types() -> io::Result<Vec<String>> {
598 use std::collections::HashMap;
599 let mut extension_counts = HashMap::new();
600
601 let default_ignores = vec![
603 "docs".to_string(),
604 "target".to_string(),
605 ".git".to_string(),
606 "node_modules".to_string(),
607 ];
608
609 let files = crate::file_utils::collect_files(Path::new("."), &[], &default_ignores)?;
611
612 for entry in files {
614 let path = entry.path();
615 if let Some(extension) = path.extension().and_then(|ext| ext.to_str()) {
616 *extension_counts.entry(extension.to_string()).or_insert(0) += 1;
618 }
619 }
620
621 let mut extensions: Vec<(String, usize)> = extension_counts.into_iter().collect();
623 extensions.sort_by(|a, b| b.1.cmp(&a.1));
624
625 let top_extensions: Vec<String> = extensions.into_iter().take(5).map(|(ext, _)| ext).collect();
627
628 Ok(top_extensions)
629}
630
631fn init_config() -> io::Result<()> {
633 let config_path = Path::new("context-builder.toml");
634
635 if config_path.exists() {
636 println!("Config file already exists at {}", config_path.display());
637 println!("If you want to replace it, please remove it manually first.");
638 return Ok(());
639 }
640
641 let filter_suggestions = match detect_major_file_types() {
643 Ok(extensions) => extensions,
644 _ => vec!["rs".to_string(), "toml".to_string()], };
646
647 let filter_string = if filter_suggestions.is_empty() {
648 r#"["rs", "toml"]"#.to_string()
649 } else {
650 format!(r#"["{}"]"#, filter_suggestions.join(r#"", ""#))
651 };
652
653 let default_config_content = format!(
654 r#"# Context Builder Configuration File
655# This file was generated with sensible defaults based on the file types detected in your project
656
657# Output file name (or base name when timestamped_output is true)
658output = "context.md"
659
660# Optional folder to place the generated output file(s) in
661output_folder = "docs"
662
663# Append a UTC timestamp to the output file name (before extension)
664timestamped_output = true
665
666# Enable automatic diff generation (requires timestamped_output = true)
667auto_diff = true
668
669# Emit only change summary + modified file diffs (no full file bodies)
670diff_only = false
671
672# File extensions to include (no leading dot, e.g. "rs", "toml")
673filter = {}
674
675# File / directory names to ignore (exact name matches)
676ignore = ["docs", "target", ".git", "node_modules"]
677
678# Add line numbers to code blocks
679line_numbers = false
680"#,
681 filter_string
682 );
683
684 let mut file = File::create(config_path)?;
685 file.write_all(default_config_content.as_bytes())?;
686
687 println!("Config file created at {}", config_path.display());
688 println!("Detected file types: {}", filter_suggestions.join(", "));
689 println!("You can now customize it according to your project needs.");
690
691 Ok(())
692}
693
694#[cfg(test)]
695mod tests {
696 use super::*;
697 use std::io::Result;
698 use tempfile::tempdir;
699
700 struct MockPrompter {
702 confirm_processing_response: bool,
703 confirm_overwrite_response: bool,
704 }
705
706 impl MockPrompter {
707 fn new(processing: bool, overwrite: bool) -> Self {
708 Self {
709 confirm_processing_response: processing,
710 confirm_overwrite_response: overwrite,
711 }
712 }
713 }
714
715 impl Prompter for MockPrompter {
716 fn confirm_processing(&self, _file_count: usize) -> Result<bool> {
717 Ok(self.confirm_processing_response)
718 }
719
720 fn confirm_overwrite(&self, _file_path: &str) -> Result<bool> {
721 Ok(self.confirm_overwrite_response)
722 }
723 }
724
725 #[test]
726 fn test_diff_config_default() {
727 let config = DiffConfig::default();
728 assert_eq!(config.context_lines, 3);
729 assert!(!config.enabled);
730 assert!(!config.diff_only);
731 }
732
733 #[test]
734 fn test_diff_config_custom() {
735 let config = DiffConfig {
736 context_lines: 5,
737 enabled: true,
738 diff_only: true,
739 };
740 assert_eq!(config.context_lines, 5);
741 assert!(config.enabled);
742 assert!(config.diff_only);
743 }
744
745 #[test]
746 fn test_default_prompter() {
747 let prompter = DefaultPrompter;
748
749 let result = prompter.confirm_processing(50);
751 assert!(result.is_ok());
752 assert!(result.unwrap());
753 }
754
755 #[test]
756 fn test_run_with_args_nonexistent_directory() {
757 let args = Args {
758 input: "/nonexistent/directory".to_string(),
759 output: "output.md".to_string(),
760 filter: vec![],
761 ignore: vec![],
762 line_numbers: false,
763 preview: false,
764 token_count: false,
765 yes: false,
766 diff_only: false,
767 clear_cache: false,
768 init: false,
769 };
770 let config = Config::default();
771 let prompter = MockPrompter::new(true, true);
772
773 let result = run_with_args(args, config, &prompter);
774 assert!(result.is_err());
775 assert!(result.unwrap_err().to_string().contains("does not exist"));
776 }
777
778 #[test]
779 fn test_run_with_args_preview_mode() {
780 let temp_dir = tempdir().unwrap();
781 let base_path = temp_dir.path();
782
783 fs::write(base_path.join("test.rs"), "fn main() {}").unwrap();
785 fs::create_dir(base_path.join("src")).unwrap();
786 fs::write(base_path.join("src/lib.rs"), "pub fn hello() {}").unwrap();
787
788 let args = Args {
789 input: ".".to_string(),
790 output: "test.md".to_string(),
791 filter: vec![],
792 ignore: vec![],
793 line_numbers: false,
794 preview: false,
795 token_count: false,
796 yes: false,
797 diff_only: false,
798 clear_cache: false,
799 init: false,
800 };
801 let config = Config::default();
802 let prompter = MockPrompter::new(true, true);
803
804 unsafe {
806 std::env::set_var("CB_SILENT", "1");
807 }
808 let result = run_with_args(args, config, &prompter);
809 unsafe {
810 std::env::remove_var("CB_SILENT");
811 }
812
813 assert!(result.is_ok());
814 }
815
816 #[test]
817 fn test_run_with_args_token_count_mode() {
818 let temp_dir = tempdir().unwrap();
819 let base_path = temp_dir.path();
820
821 fs::write(base_path.join("small.txt"), "Hello world").unwrap();
823
824 let args = Args {
825 input: base_path.to_string_lossy().to_string(),
826 output: "test.md".to_string(),
827 filter: vec![],
828 ignore: vec![],
829 line_numbers: false,
830 preview: false,
831 token_count: true,
832 yes: false,
833 diff_only: false,
834 clear_cache: false,
835 init: false,
836 };
837 let config = Config::default();
838 let prompter = MockPrompter::new(true, true);
839
840 unsafe {
841 std::env::set_var("CB_SILENT", "1");
842 }
843 let result = run_with_args(args, config, &prompter);
844 unsafe {
845 std::env::remove_var("CB_SILENT");
846 }
847
848 assert!(result.is_ok());
849 }
850
851 #[test]
852 fn test_run_with_args_preview_and_token_count() {
853 let temp_dir = tempdir().unwrap();
854 let base_path = temp_dir.path();
855
856 fs::write(base_path.join("test.txt"), "content").unwrap();
857
858 let args = Args {
859 input: base_path.to_string_lossy().to_string(),
860 output: "test.md".to_string(),
861 filter: vec![],
862 ignore: vec![],
863 line_numbers: false,
864 preview: true,
865 token_count: false,
866 yes: false,
867 diff_only: false,
868 clear_cache: false,
869 init: false,
870 };
871 let config = Config::default();
872 let prompter = MockPrompter::new(true, true);
873
874 unsafe {
875 std::env::set_var("CB_SILENT", "1");
876 }
877 let result = run_with_args(args, config, &prompter);
878 unsafe {
879 std::env::remove_var("CB_SILENT");
880 }
881
882 assert!(result.is_ok());
883 }
884
885 #[test]
886 fn test_run_with_args_user_cancels_overwrite() {
887 let temp_dir = tempdir().unwrap();
888 let base_path = temp_dir.path();
889 let output_path = temp_dir.path().join("existing.md");
890
891 fs::write(base_path.join("test.txt"), "content").unwrap();
893 fs::write(&output_path, "existing content").unwrap();
894
895 let args = Args {
896 input: base_path.to_string_lossy().to_string(),
897 output: "test.md".to_string(),
898 filter: vec![],
899 ignore: vec!["target".to_string()],
900 line_numbers: false,
901 preview: false,
902 token_count: false,
903 yes: false,
904 diff_only: false,
905 clear_cache: false,
906 init: false,
907 };
908 let config = Config::default();
909 let prompter = MockPrompter::new(true, false); unsafe {
912 std::env::set_var("CB_SILENT", "1");
913 }
914 let result = run_with_args(args, config, &prompter);
915 unsafe {
916 std::env::remove_var("CB_SILENT");
917 }
918
919 assert!(result.is_err());
920 assert!(result.unwrap_err().to_string().contains("cancelled"));
921 }
922
923 #[test]
924 fn test_run_with_args_user_cancels_processing() {
925 let temp_dir = tempdir().unwrap();
926 let base_path = temp_dir.path();
927
928 for i in 0..105 {
930 fs::write(base_path.join(format!("file{}.txt", i)), "content").unwrap();
931 }
932
933 let args = Args {
934 input: base_path.to_string_lossy().to_string(),
935 output: "test.md".to_string(),
936 filter: vec!["rs".to_string()],
937 ignore: vec![],
938 line_numbers: false,
939 preview: false,
940 token_count: false,
941 yes: false,
942 diff_only: false,
943 clear_cache: false,
944 init: false,
945 };
946 let config = Config::default();
947 let prompter = MockPrompter::new(false, true); unsafe {
950 std::env::set_var("CB_SILENT", "1");
951 }
952 let result = run_with_args(args, config, &prompter);
953 unsafe {
954 std::env::remove_var("CB_SILENT");
955 }
956
957 assert!(result.is_err());
958 assert!(result.unwrap_err().to_string().contains("cancelled"));
959 }
960
961 #[test]
962 fn test_run_with_args_with_yes_flag() {
963 let temp_dir = tempdir().unwrap();
964 let base_path = temp_dir.path();
965 let output_file_name = "test.md";
966 let output_path = temp_dir.path().join(output_file_name);
967
968 fs::write(base_path.join("test.txt"), "Hello world").unwrap();
969
970 let args = Args {
971 input: base_path.to_string_lossy().to_string(),
972 output: output_path.to_string_lossy().to_string(),
973 filter: vec![],
974 ignore: vec!["ignored_dir".to_string()],
975 line_numbers: false,
976 preview: false,
977 token_count: false,
978 yes: true,
979 diff_only: false,
980 clear_cache: false,
981 init: false,
982 };
983 let config = Config::default();
984 let prompter = MockPrompter::new(true, true);
985
986 unsafe {
987 std::env::set_var("CB_SILENT", "1");
988 }
989 let result = run_with_args(args, config, &prompter);
990 unsafe {
991 std::env::remove_var("CB_SILENT");
992 }
993
994 assert!(result.is_ok());
995 assert!(output_path.exists());
996
997 let content = fs::read_to_string(&output_path).unwrap();
998 assert!(content.contains("Directory Structure Report"));
999 assert!(content.contains("test.txt"));
1000 }
1001
1002 #[test]
1003 fn test_run_with_args_with_filters() {
1004 let temp_dir = tempdir().unwrap();
1005 let base_path = temp_dir.path();
1006 let output_file_name = "test.md";
1007 let output_path = temp_dir.path().join(output_file_name);
1008
1009 fs::write(base_path.join("code.rs"), "fn main() {}").unwrap();
1010 fs::write(base_path.join("readme.md"), "# README").unwrap();
1011 fs::write(base_path.join("data.json"), r#"{"key": "value"}"#).unwrap();
1012
1013 let args = Args {
1014 input: base_path.to_string_lossy().to_string(),
1015 output: output_path.to_string_lossy().to_string(),
1016 filter: vec!["rs".to_string(), "md".to_string()],
1017 ignore: vec![],
1018 line_numbers: true,
1019 preview: false,
1020 token_count: false,
1021 yes: true,
1022 diff_only: false,
1023 clear_cache: false,
1024 init: false,
1025 };
1026 let config = Config::default();
1027 let prompter = MockPrompter::new(true, true);
1028
1029 unsafe {
1030 std::env::set_var("CB_SILENT", "1");
1031 }
1032 let result = run_with_args(args, config, &prompter);
1033 unsafe {
1034 std::env::remove_var("CB_SILENT");
1035 }
1036
1037 assert!(result.is_ok());
1038
1039 let content = fs::read_to_string(&output_path).unwrap();
1040 assert!(content.contains("code.rs"));
1041 assert!(content.contains("readme.md"));
1042 assert!(!content.contains("data.json")); assert!(content.contains(" 1 |")); }
1045
1046 #[test]
1047 fn test_run_with_args_with_ignores() {
1048 let temp_dir = tempdir().unwrap();
1049 let base_path = temp_dir.path();
1050 let output_path = temp_dir.path().join("ignored.md");
1051
1052 fs::write(base_path.join("important.txt"), "important content").unwrap();
1053 fs::write(base_path.join("secret.txt"), "secret content").unwrap();
1054
1055 let args = Args {
1056 input: base_path.to_string_lossy().to_string(),
1057 output: output_path.to_string_lossy().to_string(),
1058 filter: vec![],
1059 ignore: vec!["secret.txt".to_string()],
1060 line_numbers: false,
1061 preview: false,
1062 token_count: false,
1063 yes: true,
1064 diff_only: false,
1065 clear_cache: false,
1066 init: false,
1067 };
1068 let config = Config::default();
1069 let prompter = MockPrompter::new(true, true);
1070
1071 unsafe {
1072 std::env::set_var("CB_SILENT", "1");
1073 }
1074 let result = run_with_args(args, config, &prompter);
1075 unsafe {
1076 std::env::remove_var("CB_SILENT");
1077 }
1078
1079 assert!(result.is_ok());
1080
1081 let content = fs::read_to_string(&output_path).unwrap();
1082 assert!(content.contains("important.txt"));
1083 }
1086
1087 #[test]
1088 fn test_auto_diff_without_previous_state() {
1089 let temp_dir = tempdir().unwrap();
1090 let base_path = temp_dir.path();
1091 let output_file_name = "test.md";
1092 let output_path = temp_dir.path().join(output_file_name);
1093
1094 fs::write(base_path.join("new.txt"), "new content").unwrap();
1095
1096 let args = Args {
1097 input: base_path.to_string_lossy().to_string(),
1098 output: output_path.to_string_lossy().to_string(),
1099 filter: vec![],
1100 ignore: vec![],
1101 line_numbers: false,
1102 preview: false,
1103 token_count: false,
1104 yes: true,
1105 diff_only: false,
1106 clear_cache: false,
1107 init: false,
1108 };
1109 let config = Config {
1110 auto_diff: Some(true),
1111 diff_context_lines: Some(5),
1112 ..Default::default()
1113 };
1114 let prompter = MockPrompter::new(true, true);
1115
1116 unsafe {
1117 std::env::set_var("CB_SILENT", "1");
1118 }
1119 let result = run_with_args(args, config, &prompter);
1120 unsafe {
1121 std::env::remove_var("CB_SILENT");
1122 }
1123
1124 assert!(result.is_ok());
1125 assert!(output_path.exists());
1126
1127 let content = fs::read_to_string(&output_path).unwrap();
1128 assert!(content.contains("new.txt"));
1129 }
1130
1131 #[test]
1132 fn test_run_creates_output_directory() {
1133 let temp_dir = tempdir().unwrap();
1134 let base_path = temp_dir.path();
1135 let output_dir = temp_dir.path().join("nested").join("output");
1136 let output_path = output_dir.join("result.md");
1137
1138 fs::write(base_path.join("test.txt"), "content").unwrap();
1139
1140 let args = Args {
1141 input: base_path.to_string_lossy().to_string(),
1142 output: output_path.to_string_lossy().to_string(),
1143 filter: vec![],
1144 ignore: vec![],
1145 line_numbers: false,
1146 preview: false,
1147 token_count: false,
1148 yes: true,
1149 diff_only: false,
1150 clear_cache: false,
1151 init: false,
1152 };
1153 let config = Config::default();
1154 let prompter = MockPrompter::new(true, true);
1155
1156 unsafe {
1157 std::env::set_var("CB_SILENT", "1");
1158 }
1159 let result = run_with_args(args, config, &prompter);
1160 unsafe {
1161 std::env::remove_var("CB_SILENT");
1162 }
1163
1164 assert!(result.is_ok());
1165 assert!(output_path.exists());
1166 assert!(output_dir.exists());
1167 }
1168
1169 #[test]
1170 fn test_generate_markdown_with_diff_no_comparison() {
1171 let temp_dir = tempdir().unwrap();
1172 let base_path = temp_dir.path();
1173
1174 fs::write(base_path.join("test.rs"), "fn main() {}").unwrap();
1175
1176 let files = collect_files(base_path, &[], &[]).unwrap();
1177 let file_tree = build_file_tree(&files, base_path);
1178 let config = Config::default();
1179 let state = ProjectState::from_files(&files, base_path, &config, false).unwrap();
1180
1181 let args = Args {
1182 input: base_path.to_string_lossy().to_string(),
1183 output: "test.md".to_string(),
1184 filter: vec![],
1185 ignore: vec![],
1186 line_numbers: false,
1187 preview: false,
1188 token_count: false,
1189 yes: false,
1190 diff_only: false,
1191 clear_cache: false,
1192 init: false,
1193 };
1194
1195 let diff_config = DiffConfig::default();
1196
1197 let result = generate_markdown_with_diff(&state, None, &args, &file_tree, &diff_config);
1198 assert!(result.is_ok());
1199
1200 let content = result.unwrap();
1201 assert!(content.contains("Directory Structure Report"));
1202 assert!(content.contains("test.rs"));
1203 }
1204}