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 cache::CacheManager;
21use cli::Args;
22use config::{Config, load_config_from_path};
23use diff::render_per_file_diffs;
24use file_utils::{collect_files, confirm_overwrite, confirm_processing};
25use markdown::generate_markdown;
26use state::{ProjectState, StateComparison};
27use token_count::{count_file_tokens, count_tree_tokens, estimate_tokens};
28use tree::{build_file_tree, print_tree};
29
30#[derive(Debug, Clone)]
32pub struct DiffConfig {
33 pub context_lines: usize,
34 pub enabled: bool,
35 pub diff_only: bool,
36}
37
38impl Default for DiffConfig {
39 fn default() -> Self {
40 Self {
41 context_lines: 3,
42 enabled: false,
43 diff_only: false,
44 }
45 }
46}
47
48pub trait Prompter {
49 fn confirm_processing(&self, file_count: usize) -> io::Result<bool>;
50 fn confirm_overwrite(&self, file_path: &str) -> io::Result<bool>;
51}
52
53pub struct DefaultPrompter;
54
55impl Prompter for DefaultPrompter {
56 fn confirm_processing(&self, file_count: usize) -> io::Result<bool> {
57 confirm_processing(file_count)
58 }
59 fn confirm_overwrite(&self, file_path: &str) -> io::Result<bool> {
60 confirm_overwrite(file_path)
61 }
62}
63
64pub fn run_with_args(args: Args, config: Config, prompter: &impl Prompter) -> io::Result<()> {
65 let start_time = Instant::now();
66
67 let silent = std::env::var("CB_SILENT")
68 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
69 .unwrap_or(false);
70
71 let mut final_args = args;
73 let mut resolved_base = PathBuf::from(&final_args.input);
76 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
77 if resolved_base == Path::new(".")
78 && !cwd.join("context-builder.toml").exists()
79 && let Some(output_parent) = Path::new(&final_args.output).parent()
80 && output_parent
81 .file_name()
82 .map(|n| n == "output")
83 .unwrap_or(false)
84 && let Some(project_root) = output_parent.parent()
85 && project_root.join("context-builder.toml").exists()
86 {
87 resolved_base = project_root.to_path_buf();
88 }
89 let base_path = resolved_base.as_path();
90
91 if !base_path.exists() || !base_path.is_dir() {
92 if !silent {
93 eprintln!(
94 "Error: The specified input directory '{}' does not exist or is not a directory.",
95 final_args.input
96 );
97 }
98 return Err(io::Error::new(
99 io::ErrorKind::NotFound,
100 format!(
101 "Input directory '{}' does not exist or is not a directory",
102 final_args.input
103 ),
104 ));
105 }
106
107 let diff_config = if config.auto_diff.unwrap_or(false) {
109 Some(DiffConfig {
110 context_lines: config.diff_context_lines.unwrap_or(3),
111 enabled: true,
112 diff_only: final_args.diff_only,
113 })
114 } else {
115 None
116 };
117
118 if !final_args.preview
119 && !final_args.token_count
120 && Path::new(&final_args.output).exists()
121 && !final_args.yes
122 && !prompter.confirm_overwrite(&final_args.output)?
123 {
124 if !silent {
125 println!("Operation cancelled.");
126 }
127 return Err(io::Error::new(
128 io::ErrorKind::Interrupted,
129 "Operation cancelled by user",
130 ));
131 }
132
133 let files = collect_files(base_path, &final_args.filter, &final_args.ignore)?;
134 let debug_config = std::env::var("CB_DEBUG_CONFIG").is_ok();
135 if debug_config {
136 eprintln!("[DEBUG][CONFIG] Args: {:?}", final_args);
137 eprintln!("[DEBUG][CONFIG] Raw Config: {:?}", config);
138 eprintln!("[DEBUG][CONFIG] Collected {} files", files.len());
139 for f in &files {
140 eprintln!("[DEBUG][CONFIG] - {}", f.path().display());
141 }
142 }
143 let file_tree = build_file_tree(&files, base_path);
144
145 if final_args.preview {
146 if !silent {
147 println!("\n# File Tree Structure (Preview)\n");
148 print_tree(&file_tree, 0);
149 }
150 if !final_args.token_count {
151 return Ok(());
152 }
153 }
154
155 if final_args.token_count {
156 if !silent {
157 println!("\n# Token Count Estimation\n");
158 let mut total_tokens = 0;
159 total_tokens += estimate_tokens("# Directory Structure Report\n\n");
160 if !final_args.filter.is_empty() {
161 total_tokens += estimate_tokens(&format!(
162 "This document contains files from the `{}` directory with extensions: {} \n",
163 final_args.input,
164 final_args.filter.join(", ")
165 ));
166 } else {
167 total_tokens += estimate_tokens(&format!(
168 "This document contains all files from the `{}` directory, optimized for LLM consumption.\n",
169 final_args.input
170 ));
171 }
172 if !final_args.ignore.is_empty() {
173 total_tokens += estimate_tokens(&format!(
174 "Custom ignored patterns: {} \n",
175 final_args.ignore.join(", ")
176 ));
177 }
178 total_tokens += estimate_tokens(&format!(
179 "Processed at: {}\n\n",
180 Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
181 ));
182 total_tokens += estimate_tokens("## File Tree Structure\n\n");
183 let tree_tokens = count_tree_tokens(&file_tree, 0);
184 total_tokens += tree_tokens;
185 let file_tokens: usize = files
186 .iter()
187 .map(|entry| count_file_tokens(base_path, entry, final_args.line_numbers))
188 .sum();
189 total_tokens += file_tokens;
190 println!("Estimated total tokens: {}", total_tokens);
191 println!("File tree tokens: {}", tree_tokens);
192 println!("File content tokens: {}", file_tokens);
193 }
194 return Ok(());
195 }
196
197 if !final_args.yes && !prompter.confirm_processing(files.len())? {
198 if !silent {
199 println!("Operation cancelled.");
200 }
201 return Err(io::Error::new(
202 io::ErrorKind::Interrupted,
203 "Operation cancelled by user",
204 ));
205 }
206
207 if let Some(cfg_ln) = config.line_numbers {
212 final_args.line_numbers = cfg_ln;
213 }
214 if let Some(cfg_diff_only) = config.diff_only {
215 final_args.diff_only = cfg_diff_only;
216 }
217
218 if config.auto_diff.unwrap_or(false) {
219 let mut effective_config = config.clone();
224 if !final_args.filter.is_empty() {
226 effective_config.filter = Some(final_args.filter.clone());
227 }
228 if !final_args.ignore.is_empty() {
229 effective_config.ignore = Some(final_args.ignore.clone());
230 }
231 effective_config.line_numbers = Some(final_args.line_numbers);
232
233 let current_state = ProjectState::from_files(
235 &files,
236 base_path,
237 &effective_config,
238 final_args.line_numbers,
239 )?;
240
241 let cache_manager = CacheManager::new(base_path, &effective_config);
243 let previous_state = match cache_manager.read_cache() {
244 Ok(state) => state,
245 Err(e) => {
246 if !silent {
247 eprintln!(
248 "Warning: Failed to read cache (proceeding without diff): {}",
249 e
250 );
251 }
252 None
253 }
254 };
255
256 let diff_cfg = diff_config.as_ref().unwrap();
257
258 let effective_previous = if let Some(prev) = previous_state.as_ref() {
260 if prev.config_hash != current_state.config_hash {
261 None
263 } else {
264 Some(prev)
265 }
266 } else {
267 None
268 };
269
270 let comparison = effective_previous.map(|prev| current_state.compare_with(prev));
272
273 let debug_autodiff = std::env::var("CB_DEBUG_AUTODIFF").is_ok();
274 if debug_autodiff {
275 eprintln!(
276 "[DEBUG][AUTODIFF] cache file: {}",
277 cache_manager.debug_cache_file_path().display()
278 );
279 eprintln!(
280 "[DEBUG][AUTODIFF] config_hash current={} prev={:?} invalidated={}",
281 current_state.config_hash,
282 previous_state.as_ref().map(|s| s.config_hash.clone()),
283 effective_previous.is_none() && previous_state.is_some()
284 );
285 eprintln!("[DEBUG][AUTODIFF] effective_config: {:?}", effective_config);
286 if let Some(prev) = previous_state.as_ref() {
287 eprintln!("[DEBUG][AUTODIFF] raw previous files: {}", prev.files.len());
288 }
289 if let Some(prev) = effective_previous {
290 eprintln!(
291 "[DEBUG][AUTODIFF] effective previous files: {}",
292 prev.files.len()
293 );
294 for k in prev.files.keys() {
295 eprintln!(" PREV: {}", k.display());
296 }
297 }
298 eprintln!(
299 "[DEBUG][AUTODIFF] current files: {}",
300 current_state.files.len()
301 );
302 for k in current_state.files.keys() {
303 eprintln!(" CURR: {}", k.display());
304 }
305 }
306
307 let final_doc = generate_markdown_with_diff(
309 ¤t_state,
310 comparison.as_ref(),
311 &final_args,
312 &file_tree,
313 diff_cfg,
314 )?;
315
316 let output_path = Path::new(&final_args.output);
318 if let Some(parent) = output_path.parent()
319 && !parent.exists()
320 && let Err(e) = fs::create_dir_all(parent)
321 {
322 return Err(io::Error::other(format!(
323 "Failed to create output directory {}: {}",
324 parent.display(),
325 e
326 )));
327 }
328 let mut final_output = fs::File::create(output_path)?;
329 final_output.write_all(final_doc.as_bytes())?;
330
331 if let Err(e) = cache_manager.write_cache(¤t_state)
333 && !silent
334 {
335 eprintln!("Warning: failed to update state cache: {}", e);
336 }
337
338 let duration = start_time.elapsed();
339 if !silent {
340 if let Some(comp) = &comparison {
341 if comp.summary.has_changes() {
342 println!(
343 "Documentation created successfully with {} changes: {}",
344 comp.summary.total_changes, final_args.output
345 );
346 } else {
347 println!(
348 "Documentation created successfully (no changes detected): {}",
349 final_args.output
350 );
351 }
352 } else {
353 println!(
354 "Documentation created successfully (initial state): {}",
355 final_args.output
356 );
357 }
358 println!("Processing time: {:.2?}", duration);
359 }
360 return Ok(());
361 }
362
363 generate_markdown(
365 &final_args.output,
366 &final_args.input,
367 &final_args.filter,
368 &final_args.ignore,
369 &file_tree,
370 &files,
371 base_path,
372 final_args.line_numbers,
373 config.encoding_strategy.as_deref(),
374 )?;
375
376 let duration = start_time.elapsed();
377 if !silent {
378 println!("Documentation created successfully: {}", final_args.output);
379 println!("Processing time: {:.2?}", duration);
380 }
381
382 Ok(())
383}
384
385fn generate_markdown_with_diff(
387 current_state: &ProjectState,
388 comparison: Option<&StateComparison>,
389 args: &Args,
390 file_tree: &tree::FileTree,
391 diff_config: &DiffConfig,
392) -> io::Result<String> {
393 let mut output = String::new();
394
395 output.push_str("# Directory Structure Report\n\n");
397
398 output.push_str(&format!(
400 "**Project:** {}\n",
401 current_state.metadata.project_name
402 ));
403 output.push_str(&format!("**Generated:** {}\n", current_state.timestamp));
404
405 if !args.filter.is_empty() {
406 output.push_str(&format!("**Filters:** {}\n", args.filter.join(", ")));
407 }
408
409 if !args.ignore.is_empty() {
410 output.push_str(&format!("**Ignored:** {}\n", args.ignore.join(", ")));
411 }
412
413 output.push('\n');
414
415 if let Some(comp) = comparison {
417 if comp.summary.has_changes() {
418 output.push_str(&comp.summary.to_markdown());
419
420 let added_files: Vec<_> = comp
422 .file_diffs
423 .iter()
424 .filter(|d| matches!(d.status, diff::PerFileStatus::Added))
425 .collect();
426
427 if diff_config.diff_only && !added_files.is_empty() {
428 output.push_str("## Added Files\n\n");
429 for added in added_files {
430 output.push_str(&format!("### File: `{}`\n\n", added.path));
431 output.push_str("_Status: Added_\n\n");
432 let mut lines: Vec<String> = Vec::new();
434 for line in added.diff.lines() {
435 if let Some(rest) = line.strip_prefix('+') {
436 lines.push(rest.trim_start().to_string());
437 }
438 }
439 output.push_str("```text\n");
440 if args.line_numbers {
441 for (idx, l) in lines.iter().enumerate() {
442 output.push_str(&format!("{:>4} | {}\n", idx + 1, l));
443 }
444 } else {
445 for l in lines {
446 output.push_str(&l);
447 output.push('\n');
448 }
449 }
450 output.push_str("```\n\n");
451 }
452 }
453
454 let changed_diffs: Vec<diff::PerFileDiff> = comp
456 .file_diffs
457 .iter()
458 .filter(|d| d.is_changed())
459 .cloned()
460 .collect();
461 if !changed_diffs.is_empty() {
462 output.push_str("## File Differences\n\n");
463 let diff_markdown = render_per_file_diffs(&changed_diffs);
464 output.push_str(&diff_markdown);
465 }
466 } else {
467 output.push_str("## No Changes Detected\n\n");
468 }
469 }
470
471 output.push_str("## File Tree Structure\n\n");
473 let mut tree_output = Vec::new();
474 tree::write_tree_to_file(&mut tree_output, file_tree, 0)?;
475 output.push_str(&String::from_utf8_lossy(&tree_output));
476 output.push('\n');
477
478 if !diff_config.diff_only {
480 output.push_str("## File Contents\n\n");
481
482 for (path, file_state) in ¤t_state.files {
483 output.push_str(&format!("### File: `{}`\n\n", path.display()));
484 output.push_str(&format!("- Size: {} bytes\n", file_state.size));
485 output.push_str(&format!("- Modified: {:?}\n\n", file_state.modified));
486
487 let extension = path.extension().and_then(|s| s.to_str()).unwrap_or("text");
489 let language = match extension {
490 "rs" => "rust",
491 "js" => "javascript",
492 "ts" => "typescript",
493 "py" => "python",
494 "json" => "json",
495 "toml" => "toml",
496 "md" => "markdown",
497 "yaml" | "yml" => "yaml",
498 "html" => "html",
499 "css" => "css",
500 _ => extension,
501 };
502
503 output.push_str(&format!("```{}\n", language));
504
505 if args.line_numbers {
506 for (i, line) in file_state.content.lines().enumerate() {
507 output.push_str(&format!("{:>4} | {}\n", i + 1, line));
508 }
509 } else {
510 output.push_str(&file_state.content);
511 if !file_state.content.ends_with('\n') {
512 output.push('\n');
513 }
514 }
515
516 output.push_str("```\n\n");
517 }
518 }
519
520 Ok(output)
521}
522
523pub fn run() -> io::Result<()> {
524 env_logger::init();
525 let args = Args::parse();
526
527 let project_root = Path::new(&args.input);
529 let config = load_config_from_path(project_root);
530
531 if args.clear_cache {
533 let cache_path = project_root.join(".context-builder").join("cache");
534 if cache_path.exists() {
535 match fs::remove_dir_all(&cache_path) {
536 Ok(()) => println!("Cache cleared: {}", cache_path.display()),
537 Err(e) => eprintln!("Failed to clear cache ({}): {}", cache_path.display(), e),
538 }
539 } else {
540 println!("No cache directory found at {}", cache_path.display());
541 }
542 return Ok(());
543 }
544
545 if std::env::args().len() == 1 && config.is_none() {
546 Args::command().print_help()?;
547 return Ok(());
548 }
549
550 let resolution = crate::config_resolver::resolve_final_config(args, config.clone());
552
553 let silent = std::env::var("CB_SILENT")
555 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
556 .unwrap_or(false);
557
558 if !silent {
559 for warning in &resolution.warnings {
560 eprintln!("Warning: {}", warning);
561 }
562 }
563
564 let final_args = Args {
566 input: resolution.config.input,
567 output: resolution.config.output,
568 filter: resolution.config.filter,
569 ignore: resolution.config.ignore,
570 line_numbers: resolution.config.line_numbers,
571 preview: resolution.config.preview,
572 token_count: resolution.config.token_count,
573 yes: resolution.config.yes,
574 diff_only: resolution.config.diff_only,
575 clear_cache: resolution.config.clear_cache,
576 };
577
578 let final_config = Config {
580 auto_diff: Some(resolution.config.auto_diff),
581 diff_context_lines: Some(resolution.config.diff_context_lines),
582 ..config.unwrap_or_default()
583 };
584
585 run_with_args(final_args, final_config, &DefaultPrompter)
586}
587
588#[cfg(test)]
589mod tests {
590 use super::*;
591 use std::io::Result;
592 use tempfile::tempdir;
593
594 struct MockPrompter {
596 confirm_processing_response: bool,
597 confirm_overwrite_response: bool,
598 }
599
600 impl MockPrompter {
601 fn new(processing: bool, overwrite: bool) -> Self {
602 Self {
603 confirm_processing_response: processing,
604 confirm_overwrite_response: overwrite,
605 }
606 }
607 }
608
609 impl Prompter for MockPrompter {
610 fn confirm_processing(&self, _file_count: usize) -> Result<bool> {
611 Ok(self.confirm_processing_response)
612 }
613
614 fn confirm_overwrite(&self, _file_path: &str) -> Result<bool> {
615 Ok(self.confirm_overwrite_response)
616 }
617 }
618
619 #[test]
620 fn test_diff_config_default() {
621 let config = DiffConfig::default();
622 assert_eq!(config.context_lines, 3);
623 assert!(!config.enabled);
624 assert!(!config.diff_only);
625 }
626
627 #[test]
628 fn test_diff_config_custom() {
629 let config = DiffConfig {
630 context_lines: 5,
631 enabled: true,
632 diff_only: true,
633 };
634 assert_eq!(config.context_lines, 5);
635 assert!(config.enabled);
636 assert!(config.diff_only);
637 }
638
639 #[test]
640 fn test_default_prompter() {
641 let prompter = DefaultPrompter;
642
643 let result = prompter.confirm_processing(50);
645 assert!(result.is_ok());
646 assert!(result.unwrap());
647 }
648
649 #[test]
650 fn test_run_with_args_nonexistent_directory() {
651 let args = Args {
652 input: "/nonexistent/directory".to_string(),
653 output: "test.md".to_string(),
654 filter: vec![],
655 ignore: vec![],
656 line_numbers: false,
657 preview: false,
658 token_count: false,
659 yes: false,
660 diff_only: false,
661 clear_cache: false,
662 };
663 let config = Config::default();
664 let prompter = MockPrompter::new(true, true);
665
666 let result = run_with_args(args, config, &prompter);
667 assert!(result.is_err());
668 assert!(result.unwrap_err().to_string().contains("does not exist"));
669 }
670
671 #[test]
672 fn test_run_with_args_preview_mode() {
673 let temp_dir = tempdir().unwrap();
674 let base_path = temp_dir.path();
675
676 fs::write(base_path.join("test.rs"), "fn main() {}").unwrap();
678 fs::create_dir(base_path.join("src")).unwrap();
679 fs::write(base_path.join("src/lib.rs"), "pub fn hello() {}").unwrap();
680
681 let args = Args {
682 input: base_path.to_string_lossy().to_string(),
683 output: "test.md".to_string(),
684 filter: vec![],
685 ignore: vec![],
686 line_numbers: false,
687 preview: true,
688 token_count: false,
689 yes: false,
690 diff_only: false,
691 clear_cache: false,
692 };
693 let config = Config::default();
694 let prompter = MockPrompter::new(true, true);
695
696 unsafe {
698 std::env::set_var("CB_SILENT", "1");
699 }
700 let result = run_with_args(args, config, &prompter);
701 unsafe {
702 std::env::remove_var("CB_SILENT");
703 }
704
705 assert!(result.is_ok());
706 }
707
708 #[test]
709 fn test_run_with_args_token_count_mode() {
710 let temp_dir = tempdir().unwrap();
711 let base_path = temp_dir.path();
712
713 fs::write(base_path.join("small.txt"), "Hello world").unwrap();
715
716 let args = Args {
717 input: base_path.to_string_lossy().to_string(),
718 output: "test.md".to_string(),
719 filter: vec![],
720 ignore: vec![],
721 line_numbers: false,
722 preview: false,
723 token_count: true,
724 yes: false,
725 diff_only: false,
726 clear_cache: false,
727 };
728 let config = Config::default();
729 let prompter = MockPrompter::new(true, true);
730
731 unsafe {
732 std::env::set_var("CB_SILENT", "1");
733 }
734 let result = run_with_args(args, config, &prompter);
735 unsafe {
736 std::env::remove_var("CB_SILENT");
737 }
738
739 assert!(result.is_ok());
740 }
741
742 #[test]
743 fn test_run_with_args_preview_and_token_count() {
744 let temp_dir = tempdir().unwrap();
745 let base_path = temp_dir.path();
746
747 fs::write(base_path.join("test.txt"), "content").unwrap();
748
749 let args = Args {
750 input: base_path.to_string_lossy().to_string(),
751 output: "test.md".to_string(),
752 filter: vec![],
753 ignore: vec![],
754 line_numbers: false,
755 preview: true,
756 token_count: true,
757 yes: false,
758 diff_only: false,
759 clear_cache: false,
760 };
761 let config = Config::default();
762 let prompter = MockPrompter::new(true, true);
763
764 unsafe {
765 std::env::set_var("CB_SILENT", "1");
766 }
767 let result = run_with_args(args, config, &prompter);
768 unsafe {
769 std::env::remove_var("CB_SILENT");
770 }
771
772 assert!(result.is_ok());
773 }
774
775 #[test]
776 fn test_run_with_args_user_cancels_overwrite() {
777 let temp_dir = tempdir().unwrap();
778 let base_path = temp_dir.path();
779 let output_path = temp_dir.path().join("existing.md");
780
781 fs::write(base_path.join("test.txt"), "content").unwrap();
783 fs::write(&output_path, "existing content").unwrap();
784
785 let args = Args {
786 input: base_path.to_string_lossy().to_string(),
787 output: output_path.to_string_lossy().to_string(),
788 filter: vec![],
789 ignore: vec![],
790 line_numbers: false,
791 preview: false,
792 token_count: false,
793 yes: false,
794 diff_only: false,
795 clear_cache: false,
796 };
797 let config = Config::default();
798 let prompter = MockPrompter::new(true, false); unsafe {
801 std::env::set_var("CB_SILENT", "1");
802 }
803 let result = run_with_args(args, config, &prompter);
804 unsafe {
805 std::env::remove_var("CB_SILENT");
806 }
807
808 assert!(result.is_err());
809 assert!(result.unwrap_err().to_string().contains("cancelled"));
810 }
811
812 #[test]
813 fn test_run_with_args_user_cancels_processing() {
814 let temp_dir = tempdir().unwrap();
815 let base_path = temp_dir.path();
816
817 for i in 0..105 {
819 fs::write(base_path.join(format!("file{}.txt", i)), "content").unwrap();
820 }
821
822 let args = Args {
823 input: base_path.to_string_lossy().to_string(),
824 output: "test.md".to_string(),
825 filter: vec![],
826 ignore: vec![],
827 line_numbers: false,
828 preview: false,
829 token_count: false,
830 yes: false,
831 diff_only: false,
832 clear_cache: false,
833 };
834 let config = Config::default();
835 let prompter = MockPrompter::new(false, true); unsafe {
838 std::env::set_var("CB_SILENT", "1");
839 }
840 let result = run_with_args(args, config, &prompter);
841 unsafe {
842 std::env::remove_var("CB_SILENT");
843 }
844
845 assert!(result.is_err());
846 assert!(result.unwrap_err().to_string().contains("cancelled"));
847 }
848
849 #[test]
850 fn test_run_with_args_with_yes_flag() {
851 let temp_dir = tempdir().unwrap();
852 let base_path = temp_dir.path();
853 let output_path = temp_dir.path().join("output.md");
854
855 fs::write(base_path.join("test.txt"), "Hello world").unwrap();
856
857 let args = Args {
858 input: base_path.to_string_lossy().to_string(),
859 output: output_path.to_string_lossy().to_string(),
860 filter: vec![],
861 ignore: vec![],
862 line_numbers: false,
863 preview: false,
864 token_count: false,
865 yes: true, diff_only: false,
867 clear_cache: false,
868 };
869 let config = Config::default();
870 let prompter = MockPrompter::new(true, true);
871
872 unsafe {
873 std::env::set_var("CB_SILENT", "1");
874 }
875 let result = run_with_args(args, config, &prompter);
876 unsafe {
877 std::env::remove_var("CB_SILENT");
878 }
879
880 assert!(result.is_ok());
881 assert!(output_path.exists());
882
883 let content = fs::read_to_string(&output_path).unwrap();
884 assert!(content.contains("Directory Structure Report"));
885 assert!(content.contains("test.txt"));
886 }
887
888 #[test]
889 fn test_run_with_args_with_filters() {
890 let temp_dir = tempdir().unwrap();
891 let base_path = temp_dir.path();
892 let output_path = temp_dir.path().join("filtered.md");
893
894 fs::write(base_path.join("code.rs"), "fn main() {}").unwrap();
895 fs::write(base_path.join("readme.md"), "# README").unwrap();
896 fs::write(base_path.join("data.json"), r#"{"key": "value"}"#).unwrap();
897
898 let args = Args {
899 input: base_path.to_string_lossy().to_string(),
900 output: output_path.to_string_lossy().to_string(),
901 filter: vec!["rs".to_string(), "md".to_string()],
902 ignore: vec![],
903 line_numbers: true,
904 preview: false,
905 token_count: false,
906 yes: true,
907 diff_only: false,
908 clear_cache: false,
909 };
910 let config = Config::default();
911 let prompter = MockPrompter::new(true, true);
912
913 unsafe {
914 std::env::set_var("CB_SILENT", "1");
915 }
916 let result = run_with_args(args, config, &prompter);
917 unsafe {
918 std::env::remove_var("CB_SILENT");
919 }
920
921 assert!(result.is_ok());
922
923 let content = fs::read_to_string(&output_path).unwrap();
924 assert!(content.contains("code.rs"));
925 assert!(content.contains("readme.md"));
926 assert!(!content.contains("data.json")); assert!(content.contains(" 1 |")); }
929
930 #[test]
931 fn test_run_with_args_with_ignores() {
932 let temp_dir = tempdir().unwrap();
933 let base_path = temp_dir.path();
934 let output_path = temp_dir.path().join("ignored.md");
935
936 fs::write(base_path.join("important.txt"), "important content").unwrap();
937 fs::write(base_path.join("secret.txt"), "secret content").unwrap();
938
939 let args = Args {
940 input: base_path.to_string_lossy().to_string(),
941 output: output_path.to_string_lossy().to_string(),
942 filter: vec![],
943 ignore: vec!["secret.txt".to_string()],
944 line_numbers: false,
945 preview: false,
946 token_count: false,
947 yes: true,
948 diff_only: false,
949 clear_cache: false,
950 };
951 let config = Config::default();
952 let prompter = MockPrompter::new(true, true);
953
954 unsafe {
955 std::env::set_var("CB_SILENT", "1");
956 }
957 let result = run_with_args(args, config, &prompter);
958 unsafe {
959 std::env::remove_var("CB_SILENT");
960 }
961
962 assert!(result.is_ok());
963
964 let content = fs::read_to_string(&output_path).unwrap();
965 assert!(content.contains("important.txt"));
966 }
969
970 #[test]
971 fn test_auto_diff_without_previous_state() {
972 let temp_dir = tempdir().unwrap();
973 let base_path = temp_dir.path();
974 let output_path = temp_dir.path().join("autodiff.md");
975
976 fs::write(base_path.join("new.txt"), "new content").unwrap();
977
978 let args = Args {
979 input: base_path.to_string_lossy().to_string(),
980 output: output_path.to_string_lossy().to_string(),
981 filter: vec![],
982 ignore: vec![],
983 line_numbers: false,
984 preview: false,
985 token_count: false,
986 yes: true,
987 diff_only: false,
988 clear_cache: false,
989 };
990 let config = Config {
991 auto_diff: Some(true),
992 diff_context_lines: Some(5),
993 ..Default::default()
994 };
995 let prompter = MockPrompter::new(true, true);
996
997 unsafe {
998 std::env::set_var("CB_SILENT", "1");
999 }
1000 let result = run_with_args(args, config, &prompter);
1001 unsafe {
1002 std::env::remove_var("CB_SILENT");
1003 }
1004
1005 assert!(result.is_ok());
1006 assert!(output_path.exists());
1007
1008 let content = fs::read_to_string(&output_path).unwrap();
1009 assert!(content.contains("new.txt"));
1010 }
1011
1012 #[test]
1013 fn test_run_creates_output_directory() {
1014 let temp_dir = tempdir().unwrap();
1015 let base_path = temp_dir.path();
1016 let output_dir = temp_dir.path().join("nested").join("output");
1017 let output_path = output_dir.join("result.md");
1018
1019 fs::write(base_path.join("test.txt"), "content").unwrap();
1020
1021 let args = Args {
1022 input: base_path.to_string_lossy().to_string(),
1023 output: output_path.to_string_lossy().to_string(),
1024 filter: vec![],
1025 ignore: vec![],
1026 line_numbers: false,
1027 preview: false,
1028 token_count: false,
1029 yes: true,
1030 diff_only: false,
1031 clear_cache: false,
1032 };
1033 let config = Config::default();
1034 let prompter = MockPrompter::new(true, true);
1035
1036 unsafe {
1037 std::env::set_var("CB_SILENT", "1");
1038 }
1039 let result = run_with_args(args, config, &prompter);
1040 unsafe {
1041 std::env::remove_var("CB_SILENT");
1042 }
1043
1044 assert!(result.is_ok());
1045 assert!(output_path.exists());
1046 assert!(output_dir.exists());
1047 }
1048
1049 #[test]
1050 fn test_generate_markdown_with_diff_no_comparison() {
1051 let temp_dir = tempdir().unwrap();
1052 let base_path = temp_dir.path();
1053
1054 fs::write(base_path.join("test.rs"), "fn main() {}").unwrap();
1055
1056 let files = collect_files(base_path, &[], &[]).unwrap();
1057 let file_tree = build_file_tree(&files, base_path);
1058 let config = Config::default();
1059 let state = ProjectState::from_files(&files, base_path, &config, false).unwrap();
1060
1061 let args = Args {
1062 input: base_path.to_string_lossy().to_string(),
1063 output: "test.md".to_string(),
1064 filter: vec![],
1065 ignore: vec![],
1066 line_numbers: false,
1067 preview: false,
1068 token_count: false,
1069 yes: true,
1070 diff_only: false,
1071 clear_cache: false,
1072 };
1073
1074 let diff_config = DiffConfig::default();
1075
1076 let result = generate_markdown_with_diff(&state, None, &args, &file_tree, &diff_config);
1077 assert!(result.is_ok());
1078
1079 let content = result.unwrap();
1080 assert!(content.contains("Directory Structure Report"));
1081 assert!(content.contains("test.rs"));
1082 }
1083}