From 691f6ff277ca1b0534b11aa56f3bb6cf1c420cc1 Mon Sep 17 00:00:00 2001 From: Igor Lins e Silva <4753812+igorls@users.noreply.github.com> Date: Sat, 10 Oct 2026 22:33:14 -0300 Subject: [PATCH 01/11] fix: don't abort when stderr or stdout is closed early (#40) --- CHANGELOG.md | 4 ++ src/cache.rs | 4 +- src/config.rs | 4 +- src/console.rs | 36 ++++++++++++ src/content_filter.rs | 2 +- src/file_utils.rs | 2 +- src/lib.rs | 117 +++++++++++++++++++------------------ src/main.rs | 7 ++- src/markdown.rs | 15 ++++- src/tree.rs | 4 +- tests/test_closed_pipes.rs | 89 ++++++++++++++++++++++++++++ 11 files changed, 216 insertions(+), 68 deletions(-) create mode 100644 src/console.rs create mode 100644 tests/test_closed_pipes.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cdccd2..c4996ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ All notable changes to this project will be documented in this file. +## Unreleased + +- **A closed stderr or stdout pipe no longer aborts the run (#40).** `context-builder … 2>&1 | head -3` used to panic with exit code 101 and could leave the output file missing. Progress and warning lines now ignore write errors, and `-o - | head` ends with exit code 0. + ## v0.11.0 (2026-10-10) — "Quiet Defaults" v0.11.0 trims what a default run puts in the document and removes the surprises around it. **Breaking changes** (details below): diff --git a/src/cache.rs b/src/cache.rs index 7ff96ca..f57d404 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -117,7 +117,7 @@ impl CacheManager { for pattern in &old_cache_patterns { let old_cache_path = self.cache_dir.join(pattern); if old_cache_path.exists() { - eprintln!("Migrating old cache format: removing {}", pattern); + errln!("Migrating old cache format: removing {}", pattern); let _ = fs::remove_file(&old_cache_path); } } @@ -128,7 +128,7 @@ impl CacheManager { let file_name = entry.file_name(); let name = file_name.to_string_lossy(); if name.ends_with(".md") && (name.contains("_20") || name.starts_with("output_")) { - eprintln!("Migrating old cache format: removing {}", name); + errln!("Migrating old cache format: removing {}", name); let _ = fs::remove_file(entry.path()); } } diff --git a/src/config.rs b/src/config.rs index e8e9a68..05f1562 100644 --- a/src/config.rs +++ b/src/config.rs @@ -222,7 +222,7 @@ pub fn load_config() -> Option { match toml::from_str(&content) { Ok(config) => Some(config), Err(e) => { - eprintln!( + errln!( "⚠️ Failed to parse context-builder.toml: {}. Config will be ignored.", e ); @@ -243,7 +243,7 @@ pub fn load_config_from_path(project_root: &Path) -> Option { match toml::from_str(&content) { Ok(config) => Some(config), Err(e) => { - eprintln!( + errln!( "⚠️ Failed to parse {}: {}. Config will be ignored.", config_path.display(), e diff --git a/src/console.rs b/src/console.rs new file mode 100644 index 0000000..9aa0248 --- /dev/null +++ b/src/console.rs @@ -0,0 +1,36 @@ +//! Console output that never panics. +//! +//! `eprintln!` / `println!` panic when the stream is closed (for example +//! `context-builder … 2>&1 | head -3`), which killed the run with exit +//! code 101 before the document was finished. Diagnostics and progress lines +//! are best-effort, so these macros drop the write error instead. Document +//! content written to stdout (`-o -`) still reports errors through `io::Result`. + +macro_rules! errln { + () => {{ + use ::std::io::Write as _; + let _ = ::std::writeln!(::std::io::stderr()); + }}; + ($($arg:tt)*) => {{ + use ::std::io::Write as _; + let _ = ::std::writeln!(::std::io::stderr(), $($arg)*); + }}; +} + +macro_rules! err { + ($($arg:tt)*) => {{ + use ::std::io::Write as _; + let _ = ::std::write!(::std::io::stderr(), $($arg)*); + }}; +} + +macro_rules! outln { + () => {{ + use ::std::io::Write as _; + let _ = ::std::writeln!(::std::io::stdout()); + }}; + ($($arg:tt)*) => {{ + use ::std::io::Write as _; + let _ = ::std::writeln!(::std::io::stdout(), $($arg)*); + }}; +} diff --git a/src/content_filter.rs b/src/content_filter.rs index fcfec51..59576c0 100644 --- a/src/content_filter.rs +++ b/src/content_filter.rs @@ -423,7 +423,7 @@ pub fn report_skips(skipped: &[SkippedFile], silent: bool) { return; } for line in report_lines(skipped) { - eprintln!("{line}"); + errln!("{line}"); } } diff --git a/src/file_utils.rs b/src/file_utils.rs index 656feb4..93b0991 100644 --- a/src/file_utils.rs +++ b/src/file_utils.rs @@ -753,7 +753,7 @@ fn can_prompt(stdin_tty: bool, stderr_tty: bool) -> bool { /// Prompts must not go to stdout: `-o -` and any caller capturing stdout would /// otherwise treat the question as document content. fn prompt_yes(prompt: &str) -> io::Result { - eprint!("{prompt}"); + err!("{prompt}"); io::stderr().flush()?; let mut input = String::new(); io::stdin().read_line(&mut input)?; diff --git a/src/lib.rs b/src/lib.rs index 1dc6e65..de00432 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,6 +5,8 @@ use std::io::{self, Write}; use std::path::{Component, Path, PathBuf}; use std::time::Instant; +#[macro_use] +mod console; pub mod cache; pub mod cli; pub mod config; @@ -174,7 +176,7 @@ pub fn run_with_args(args: Args, config: Config, prompter: &impl Prompter) -> io && !matches!(strat.as_str(), "detect" | "strict" | "skip") && !silent { - eprintln!( + errln!( "⚠️ Unknown encoding_strategy '{strat}' in config; expected one of: detect, strict, skip. Falling back to 'detect'." ); } @@ -198,7 +200,7 @@ pub fn run_with_args(args: Args, config: Config, prompter: &impl Prompter) -> io if !base_path.exists() || !base_path.is_dir() { if !silent { - eprintln!( + errln!( "Error: The specified input directory '{}' does not exist or is not a directory.", final_args.input ); @@ -230,7 +232,7 @@ pub fn run_with_args(args: Args, config: Config, prompter: &impl Prompter) -> io && !prompter.confirm_overwrite(&final_args.output)? { if !silent { - eprintln!("Operation cancelled."); + errln!("Operation cancelled."); } return Err(io::Error::new( io::ErrorKind::Interrupted, @@ -260,7 +262,7 @@ pub fn run_with_args(args: Args, config: Config, prompter: &impl Prompter) -> io if !silent && let Some(notice) = crate::file_utils::lockfile_skip_notice(collected.skipped_lockfiles) { - eprintln!("{notice}"); + errln!("{notice}"); } let files = collected.files; let policy = ContentPolicy::new( @@ -274,7 +276,7 @@ pub fn run_with_args(args: Args, config: Config, prompter: &impl Prompter) -> io // write the (empty) document. Name the filters when any were given. if !silent && files.is_empty() && skipped.is_empty() { if final_args.filter.is_empty() { - eprintln!("Warning: No files matched; check .gitignore, --ignore, and --filter"); + errln!("Warning: No files matched; check .gitignore, --ignore, and --filter"); } else { let quoted = final_args .filter @@ -287,17 +289,17 @@ pub fn run_with_args(args: Args, config: Config, prompter: &impl Prompter) -> io } else { "filters" }; - eprintln!("Warning: no files matched {noun} {quoted}."); + errln!("Warning: no files matched {noun} {quoted}."); } } let debug_config = std::env::var("CB_DEBUG_CONFIG").is_ok(); if debug_config { - eprintln!("[DEBUG][CONFIG] Args: {:?}", final_args); - eprintln!("[DEBUG][CONFIG] Raw Config: {:?}", config); - eprintln!("[DEBUG][CONFIG] Auto-ignores: {:?}", auto_ignores); - eprintln!("[DEBUG][CONFIG] Collected {} files", files.len()); + errln!("[DEBUG][CONFIG] Args: {:?}", final_args); + errln!("[DEBUG][CONFIG] Raw Config: {:?}", config); + errln!("[DEBUG][CONFIG] Auto-ignores: {:?}", auto_ignores); + errln!("[DEBUG][CONFIG] Collected {} files", files.len()); for f in &files { - eprintln!("[DEBUG][CONFIG] - {}", f.path().display()); + errln!("[DEBUG][CONFIG] - {}", f.path().display()); } } @@ -325,18 +327,18 @@ pub fn run_with_args(args: Args, config: Config, prompter: &impl Prompter) -> io if !large_files.is_empty() { large_files.sort_by_key(|b| std::cmp::Reverse(b.1)); // Sort by size descending - eprintln!( + errln!( "\n⚠ {} large file(s) detected (>{} KB):", large_files.len(), LARGE_FILE_THRESHOLD / 1024 ); for (path, size) in large_files.iter().take(5) { - eprintln!(" {:>8} KB {}", size / 1024, path); + errln!(" {:>8} KB {}", size / 1024, path); } if large_files.len() > 5 { - eprintln!(" ... and {} more", large_files.len() - 5); + errln!(" ... and {} more", large_files.len() - 5); } - eprintln!( + errln!( " Total context size: {} KB across {} files\n", total_size / 1024, files.len() @@ -347,7 +349,7 @@ pub fn run_with_args(args: Args, config: Config, prompter: &impl Prompter) -> io if final_args.preview { if !silent { - println!("\n# File Tree Structure (Preview)\n"); + outln!("\n# File Tree Structure (Preview)\n"); print_tree(&file_tree, 0); } if !final_args.token_count { @@ -368,7 +370,7 @@ pub fn run_with_args(args: Args, config: Config, prompter: &impl Prompter) -> io file_metadata: final_args.file_metadata, }; let enc_strategy = config.encoding_strategy.as_deref(); - println!("\n# Token Count Estimation\n"); + outln!("\n# Token Count Estimation\n"); let mut total_tokens = 0; total_tokens += estimate_tokens(encoding, &format!("{}\n\n", markdown::REPORT_TITLE_LINE)); @@ -422,9 +424,9 @@ pub fn run_with_args(args: Args, config: Config, prompter: &impl Prompter) -> io }) .sum(); total_tokens += file_tokens; - println!("Estimated total tokens: {}", total_tokens); - println!("File tree tokens: {}", tree_tokens); - println!("File content tokens: {}", file_tokens); + outln!("Estimated total tokens: {}", total_tokens); + outln!("File tree tokens: {}", tree_tokens); + outln!("File content tokens: {}", file_tokens); } return Ok(()); } @@ -436,7 +438,7 @@ pub fn run_with_args(args: Args, config: Config, prompter: &impl Prompter) -> io // B8: --diff-only only takes effect together with auto_diff (+ timestamped // output). Warn instead of silently emitting full file contents. if final_args.diff_only && !config.auto_diff.unwrap_or(false) && !silent { - eprintln!( + errln!( "⚠️ --diff-only has no effect without auto_diff (it also needs timestamped_output). \ Full file contents will be emitted. Enable auto_diff = true + timestamped_output = true to use diff-only mode." ); @@ -483,7 +485,7 @@ pub fn run_with_args(args: Args, config: Config, prompter: &impl Prompter) -> io Ok(state) => state, Err(e) => { if !silent { - eprintln!( + errln!( "Warning: Failed to read cache (proceeding without diff): {}", e ); @@ -512,35 +514,35 @@ pub fn run_with_args(args: Args, config: Config, prompter: &impl Prompter) -> io let debug_autodiff = std::env::var("CB_DEBUG_AUTODIFF").is_ok(); if debug_autodiff { - eprintln!( + errln!( "[DEBUG][AUTODIFF] cache file: {}", cache_manager.debug_cache_file_path().display() ); - eprintln!( + errln!( "[DEBUG][AUTODIFF] config_hash current={} prev={:?} invalidated={}", current_state.config_hash, previous_state.as_ref().map(|s| s.config_hash.clone()), effective_previous.is_none() && previous_state.is_some() ); - eprintln!("[DEBUG][AUTODIFF] effective_config: {:?}", effective_config); + errln!("[DEBUG][AUTODIFF] effective_config: {:?}", effective_config); if let Some(prev) = previous_state.as_ref() { - eprintln!("[DEBUG][AUTODIFF] raw previous files: {}", prev.files.len()); + errln!("[DEBUG][AUTODIFF] raw previous files: {}", prev.files.len()); } if let Some(prev) = effective_previous { - eprintln!( + errln!( "[DEBUG][AUTODIFF] effective previous files: {}", prev.files.len() ); for k in prev.files.keys() { - eprintln!(" PREV: {}", k.display()); + errln!(" PREV: {}", k.display()); } } - eprintln!( + errln!( "[DEBUG][AUTODIFF] current files: {}", current_state.files.len() ); for k in current_state.files.keys() { - eprintln!(" CURR: {}", k.display()); + errln!(" CURR: {}", k.display()); } } @@ -636,30 +638,31 @@ pub fn run_with_args(args: Args, config: Config, prompter: &impl Prompter) -> io if let Err(e) = cache_manager.write_cache(¤t_state) && !silent { - eprintln!("Warning: failed to update state cache: {}", e); + errln!("Warning: failed to update state cache: {}", e); } let duration = start_time.elapsed(); if !silent && !to_stdout { if let Some(comp) = &comparison { if comp.summary.has_changes() { - println!( + outln!( "Documentation created successfully with {} changes: {}", - comp.summary.total_changes, final_args.output + comp.summary.total_changes, + final_args.output ); } else { - println!( + outln!( "Documentation created successfully (no changes detected): {}", final_args.output ); } } else { - println!( + outln!( "Documentation created successfully (initial state): {}", final_args.output ); } - println!("Processing time: {:.2?}", duration); + outln!("Processing time: {:.2?}", duration); } if !silent { // Non-blocking. File count is not a cost proxy; this estimate is. @@ -683,9 +686,9 @@ pub fn run_with_args(args: Args, config: Config, prompter: &impl Prompter) -> io if !silent && (ts_config.signatures || ts_config.structure || ts_config.truncate == "smart") { #[cfg(not(feature = "tree-sitter-base"))] { - eprintln!("⚠️ --signatures/--structure/--truncate smart require tree-sitter support."); - eprintln!(" Build with: cargo build --features tree-sitter-all"); - eprintln!(" Falling back to standard output.\n"); + errln!("⚠️ --signatures/--structure/--truncate smart require tree-sitter support."); + errln!(" Build with: cargo build --features tree-sitter-all"); + errln!(" Falling back to standard output.\n"); } } @@ -707,8 +710,8 @@ pub fn run_with_args(args: Args, config: Config, prompter: &impl Prompter) -> io let duration = start_time.elapsed(); if !silent && !to_stdout { - println!("Documentation created successfully: {}", final_args.output); - println!("Processing time: {:.2?}", duration); + outln!("Documentation created successfully: {}", final_args.output); + outln!("Processing time: {:.2?}", duration); } if !silent { // Non-blocking. File count is not a cost proxy; this estimate is. @@ -740,7 +743,7 @@ fn print_context_window_warning( let estimated_tokens = output_bytes / 4; // Stderr only: this notice must never mix into a captured or piped document. - eprintln!("Estimated tokens: ~{}K", estimated_tokens / 1000); + errln!("Estimated tokens: ~{}K", estimated_tokens / 1000); // If the user already set --max-tokens, they're managing their budget if max_tokens.is_some() { @@ -755,17 +758,17 @@ fn print_context_window_warning( let paths: Vec<&Path> = files.iter().map(|entry| entry.path()).collect(); - eprintln!(); - eprintln!( + errln!(); + errln!( "⚠️ Output is ~{}K tokens — recommended limit is 128K for effective LLM context.", estimated_tokens / 1000 ); - eprintln!(" Large contexts degrade response quality. Consider narrowing the scope:"); - eprintln!(); + errln!(" Large contexts degrade response quality. Consider narrowing the scope:"); + errln!(); for line in context_window_suggestions(&paths) { - eprintln!(" • {line}"); + errln!(" • {line}"); } - eprintln!(); + errln!(); } /// `--filter` extensions to suggest for this run: the most common extensions @@ -1041,11 +1044,11 @@ pub fn run() -> io::Result<()> { let cache_path = project_root.join(".context-builder").join("cache"); if cache_path.exists() { match fs::remove_dir_all(&cache_path) { - Ok(()) => println!("Cache cleared: {}", cache_path.display()), - Err(e) => eprintln!("Failed to clear cache ({}): {}", cache_path.display(), e), + Ok(()) => outln!("Cache cleared: {}", cache_path.display()), + Err(e) => errln!("Failed to clear cache ({}): {}", cache_path.display(), e), } } else { - println!("No cache directory found at {}", cache_path.display()); + outln!("No cache directory found at {}", cache_path.display()); } return Ok(()); } @@ -1065,7 +1068,7 @@ pub fn run() -> io::Result<()> { if !silent { for warning in &resolution.warnings { - eprintln!("Warning: {}", warning); + errln!("Warning: {}", warning); } } @@ -1145,8 +1148,8 @@ fn init_config() -> io::Result<()> { let config_path = Path::new("context-builder.toml"); if config_path.exists() { - println!("Config file already exists at {}", config_path.display()); - println!("If you want to replace it, please remove it manually first."); + outln!("Config file already exists at {}", config_path.display()); + outln!("If you want to replace it, please remove it manually first."); return Ok(()); } @@ -1215,9 +1218,9 @@ file_metadata = false let mut file = File::create(config_path)?; file.write_all(default_config_content.as_bytes())?; - println!("Config file created at {}", config_path.display()); - println!("Detected file types: {}", filter_suggestions.join(", ")); - println!("You can now customize it according to your project needs."); + outln!("Config file created at {}", config_path.display()); + outln!("Detected file types: {}", filter_suggestions.join(", ")); + outln!("You can now customize it according to your project needs."); Ok(()) } diff --git a/src/main.rs b/src/main.rs index 37a18c9..6763566 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,10 @@ use std::io; fn main() -> io::Result<()> { - context_builder::run() + match context_builder::run() { + // The reader of `-o -` went away (`context-builder -o - | head`). + // That ends a pipeline; it is not a failure of ours. + Err(e) if e.kind() == io::ErrorKind::BrokenPipe => Ok(()), + other => other, + } } diff --git a/src/markdown.rs b/src/markdown.rs index 607c5b0..21eae9f 100644 --- a/src/markdown.rs +++ b/src/markdown.rs @@ -247,6 +247,9 @@ pub fn generate_markdown( let mut completed_chunks = std::collections::BTreeMap::new(); let mut next_index = 0; let mut errors = Vec::new(); + // The reader of `-o -` went away; remember it so the caller sees + // `BrokenPipe` instead of a wall of per-file write errors. + let mut pipe_closed = false; let mut tokens_used: usize = header_tokens; let mut budget_exceeded = false; @@ -304,6 +307,8 @@ pub fn generate_markdown( ); if let Some(limited_tokens) = fits { if let Err(e) = output.write_all(&limited_buf) { + pipe_closed |= + e.kind() == io::ErrorKind::BrokenPipe; errors.push(format!( "Failed to write output for file index {}: {}", next_index, e @@ -331,6 +336,7 @@ pub fn generate_markdown( tokens_used += chunk_tokens; if let Err(e) = output.write_all(&buf) { + pipe_closed |= e.kind() == io::ErrorKind::BrokenPipe; errors.push(format!( "Failed to write output for file index {}: {}", next_index, e @@ -351,6 +357,10 @@ pub fn generate_markdown( } } + if pipe_closed { + return Err(io::Error::from(io::ErrorKind::BrokenPipe)); + } + if !errors.is_empty() { error!( "Encountered {} errors during parallel processing:", @@ -981,11 +991,12 @@ pub fn write_tree_sitter_enrichment( // eprintln (not warn!/log) so it is visible without RUST_LOG — // env_logger defaults to error level, and a user-style default run // would otherwise never see this warning. - eprintln!( + errln!( "⚠️ --visibility {} has no effect for '{}' files: the extractor \ does not classify visibility yet (planned for v0.11). \ Signatures will include all symbols.", - ts_config.visibility, extension + ts_config.visibility, + extension ); } diff --git a/src/tree.rs b/src/tree.rs index 9d3d4e8..9c1b882 100644 --- a/src/tree.rs +++ b/src/tree.rs @@ -59,10 +59,10 @@ pub fn print_tree(tree: &FileTree, depth: usize) { let indent = " ".repeat(depth); match node { FileNode::File => { - println!("{}- 📄 {}", indent, name); + outln!("{}- 📄 {}", indent, name); } FileNode::Directory(children) => { - println!("{}- 📁 {}", indent, name); + outln!("{}- 📁 {}", indent, name); print_tree(children, depth + 1); } } diff --git a/tests/test_closed_pipes.rs b/tests/test_closed_pipes.rs new file mode 100644 index 0000000..0139881 --- /dev/null +++ b/tests/test_closed_pipes.rs @@ -0,0 +1,89 @@ +//! End-to-end checks of the built binary when the reader of stderr or stdout +//! goes away (`context-builder … 2>&1 | head -3`, `context-builder -o - | head`). +//! Previously a failed `eprintln!` panicked and the run ended with exit code 101 +//! before the document was finished. + +use std::fs; +use std::io::pipe; +use std::path::Path; +use std::process::{Command, Stdio}; + +fn fixture(dir: &Path) { + fs::create_dir_all(dir.join("src")).unwrap(); + for i in 0..40 { + fs::write( + dir.join("src").join(format!("f{i}.rs")), + format!("pub fn f{i}() {{}}\n"), + ) + .unwrap(); + } + // These make the run write notices to stderr (lockfile and asset skips). + fs::write(dir.join("Cargo.lock"), "# lock\n").unwrap(); + fs::write(dir.join("logo.png"), b"\x89PNG\r\n\x1a\n\0\0").unwrap(); +} + +fn bin() -> Command { + Command::new(env!("CARGO_BIN_EXE_context-builder")) +} + +#[test] +fn closed_stderr_pipe_does_not_abort_the_run() { + let dir = tempfile::tempdir().unwrap(); + fixture(dir.path()); + let out = dir.path().join("out.md"); + + // A pipe whose read end is already gone: every stderr write fails with EPIPE. + let (reader, writer) = pipe().unwrap(); + drop(reader); + let status = bin() + .args([ + "-d", + dir.path().to_str().unwrap(), + "-o", + out.to_str().unwrap(), + ]) + .current_dir(dir.path()) + .stderr(writer) + .stdout(Stdio::null()) + .status() + .unwrap(); + + assert!(status.success(), "exit status was {status:?}"); + let doc = fs::read_to_string(&out).expect("output file must be written"); + assert!(doc.contains("### File: `src/f39.rs`"), "document truncated"); + assert!(doc.contains("## Skipped"), "skip section missing"); +} + +#[test] +fn closed_stdout_pipe_ends_quietly() { + let dir = tempfile::tempdir().unwrap(); + fixture(dir.path()); + + let (reader, writer) = pipe().unwrap(); + drop(reader); + let output = bin() + .args(["-d", dir.path().to_str().unwrap(), "-o", "-"]) + .current_dir(dir.path()) + .stdout(writer) + .stderr(Stdio::piped()) + .output() + .unwrap(); + + assert!( + output.status.success(), + "exit status was {:?}", + output.status + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(!stderr.contains("panicked"), "{stderr}"); + assert!(!stderr.contains("Failed to write output"), "{stderr}"); +} + +#[test] +fn error_exit_code_is_unchanged_for_real_failures() { + let output = bin() + .args(["-d", "/definitely/not/here/cb-test"]) + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(1)); +} From 7dc748ba49025a6c0cd0f42a2f90d0cff87f6727 Mon Sep 17 00:00:00 2001 From: Igor Lins e Silva <4753812+igorls@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:12:58 -0300 Subject: [PATCH 02/11] test: don't depend on path separator in closed-pipe test --- tests/test_closed_pipes.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_closed_pipes.rs b/tests/test_closed_pipes.rs index 0139881..36dd06c 100644 --- a/tests/test_closed_pipes.rs +++ b/tests/test_closed_pipes.rs @@ -50,7 +50,7 @@ fn closed_stderr_pipe_does_not_abort_the_run() { assert!(status.success(), "exit status was {status:?}"); let doc = fs::read_to_string(&out).expect("output file must be written"); - assert!(doc.contains("### File: `src/f39.rs`"), "document truncated"); + assert!(doc.contains("f39.rs"), "document truncated"); assert!(doc.contains("## Skipped"), "skip section missing"); } From 4e96ed0cd4eebc0cde9b02e553de00477d83d61c Mon Sep 17 00:00:00 2001 From: Igor Lins e Silva <4753812+igorls@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:14:23 -0300 Subject: [PATCH 03/11] fix: treat BrokenPipe on the truncation notice like other writes (review) --- src/markdown.rs | 2 ++ tests/test_closed_pipes.rs | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/src/markdown.rs b/src/markdown.rs index 21eae9f..8469e12 100644 --- a/src/markdown.rs +++ b/src/markdown.rs @@ -324,6 +324,8 @@ pub fn generate_markdown( max, remaining ); if let Err(e) = output.write_all(notice.as_bytes()) { + pipe_closed |= + e.kind() == io::ErrorKind::BrokenPipe; errors.push(format!( "Failed to write truncation notice: {}", e diff --git a/tests/test_closed_pipes.rs b/tests/test_closed_pipes.rs index 36dd06c..2aa2b8e 100644 --- a/tests/test_closed_pipes.rs +++ b/tests/test_closed_pipes.rs @@ -87,3 +87,35 @@ fn error_exit_code_is_unchanged_for_real_failures() { .unwrap(); assert_eq!(output.status.code(), Some(1)); } + +#[test] +fn closed_stdout_pipe_with_a_token_budget_ends_quietly() { + let dir = tempfile::tempdir().unwrap(); + fixture(dir.path()); + + let (reader, writer) = pipe().unwrap(); + drop(reader); + // A tiny budget makes the run write the truncation notice as well. + let output = bin() + .args([ + "-d", + dir.path().to_str().unwrap(), + "-o", + "-", + "--max-tokens", + "300", + ]) + .current_dir(dir.path()) + .stdout(writer) + .stderr(Stdio::piped()) + .output() + .unwrap(); + + assert!( + output.status.success(), + "exit status was {:?}", + output.status + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(!stderr.contains("Failed to write"), "{stderr}"); +} From 4572d975ffab9f9ce2ec7af091e58ab439586b79 Mon Sep 17 00:00:00 2001 From: Igor Lins e Silva <4753812+igorls@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:18:42 -0300 Subject: [PATCH 04/11] test: in-process console/closed-pipe coverage; testable BrokenPipe helpers (#40) --- src/lib.rs | 11 ++++ src/main.rs | 7 +-- src/markdown.rs | 99 +++++++++++++++++++++++------- tests/test_console_inproc.rs | 113 +++++++++++++++++++++++++++++++++++ 4 files changed, 204 insertions(+), 26 deletions(-) create mode 100644 tests/test_console_inproc.rs diff --git a/src/lib.rs b/src/lib.rs index de00432..3363122 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -157,6 +157,17 @@ fn normalize_lexically(path: &Path) -> PathBuf { out } +/// Maps the result of [`run`] to the process result. +/// +/// The reader of `-o -` going away (`context-builder -o - | head`) ends a +/// pipeline; it is not a failure of ours, so `BrokenPipe` is success. +pub fn finish(result: io::Result<()>) -> io::Result<()> { + match result { + Err(e) if e.kind() == io::ErrorKind::BrokenPipe => Ok(()), + other => other, + } +} + pub fn run_with_args(args: Args, config: Config, prompter: &impl Prompter) -> io::Result<()> { let start_time = Instant::now(); diff --git a/src/main.rs b/src/main.rs index 6763566..6c9810f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,10 +1,5 @@ use std::io; fn main() -> io::Result<()> { - match context_builder::run() { - // The reader of `-o -` went away (`context-builder -o - | head`). - // That ends a pipeline; it is not a failure of ours. - Err(e) if e.kind() == io::ErrorKind::BrokenPipe => Ok(()), - other => other, - } + context_builder::finish(context_builder::run()) } diff --git a/src/markdown.rs b/src/markdown.rs index 8469e12..864db98 100644 --- a/src/markdown.rs +++ b/src/markdown.rs @@ -307,12 +307,15 @@ pub fn generate_markdown( ); if let Some(limited_tokens) = fits { if let Err(e) = output.write_all(&limited_buf) { - pipe_closed |= - e.kind() == io::ErrorKind::BrokenPipe; - errors.push(format!( - "Failed to write output for file index {}: {}", - next_index, e - )); + note_write_failure( + &mut errors, + &mut pipe_closed, + format!( + "Failed to write output for file index {}", + next_index + ), + &e, + ); } tokens_used += limited_tokens; } @@ -324,12 +327,12 @@ pub fn generate_markdown( max, remaining ); if let Err(e) = output.write_all(notice.as_bytes()) { - pipe_closed |= - e.kind() == io::ErrorKind::BrokenPipe; - errors.push(format!( - "Failed to write truncation notice: {}", - e - )); + note_write_failure( + &mut errors, + &mut pipe_closed, + "Failed to write truncation notice".to_string(), + &e, + ); } budget_exceeded = true; next_index += 1; @@ -338,11 +341,15 @@ pub fn generate_markdown( tokens_used += chunk_tokens; if let Err(e) = output.write_all(&buf) { - pipe_closed |= e.kind() == io::ErrorKind::BrokenPipe; - errors.push(format!( - "Failed to write output for file index {}: {}", - next_index, e - )); + note_write_failure( + &mut errors, + &mut pipe_closed, + format!( + "Failed to write output for file index {}", + next_index + ), + &e, + ); } } Err(e) => { @@ -359,9 +366,7 @@ pub fn generate_markdown( } } - if pipe_closed { - return Err(io::Error::from(io::ErrorKind::BrokenPipe)); - } + fail_if_pipe_closed(pipe_closed)?; if !errors.is_empty() { error!( @@ -819,6 +824,27 @@ pub fn process_file_with_content_limit( Ok(()) } +/// Records a failed write. A closed pipe is remembered separately so the run +/// can end quietly (`head`-style consumers) instead of reporting an error. +fn note_write_failure( + errors: &mut Vec, + pipe_closed: &mut bool, + what: String, + e: &io::Error, +) { + *pipe_closed |= e.kind() == io::ErrorKind::BrokenPipe; + errors.push(format!("{what}: {e}")); +} + +/// `Err(BrokenPipe)` when the reader went away, so callers stop early. +fn fail_if_pipe_closed(pipe_closed: bool) -> io::Result<()> { + if pipe_closed { + Err(io::Error::from(io::ErrorKind::BrokenPipe)) + } else { + Ok(()) + } +} + /// Compute the byte cut point for truncating `content` to `limit` bytes, /// honoring the `--truncate` mode. /// @@ -2897,3 +2923,36 @@ mod tests { } } } + +#[cfg(test)] +mod write_failure_tests { + use super::*; + + #[test] + fn broken_pipe_is_remembered_and_still_listed() { + let mut errors = Vec::new(); + let mut pipe_closed = false; + let e = io::Error::from(io::ErrorKind::BrokenPipe); + note_write_failure(&mut errors, &mut pipe_closed, "Failed to write".into(), &e); + assert!(pipe_closed); + assert_eq!(errors.len(), 1); + assert!(errors[0].starts_with("Failed to write: "), "{errors:?}"); + } + + #[test] + fn other_write_errors_do_not_mark_the_pipe_closed() { + let mut errors = Vec::new(); + let mut pipe_closed = false; + let e = io::Error::from(io::ErrorKind::PermissionDenied); + note_write_failure(&mut errors, &mut pipe_closed, "x".into(), &e); + assert!(!pipe_closed); + assert_eq!(errors.len(), 1); + } + + #[test] + fn closed_pipe_ends_the_run_with_broken_pipe() { + assert!(fail_if_pipe_closed(false).is_ok()); + let err = fail_if_pipe_closed(true).unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::BrokenPipe); + } +} diff --git a/tests/test_console_inproc.rs b/tests/test_console_inproc.rs new file mode 100644 index 0000000..903bf09 --- /dev/null +++ b/tests/test_console_inproc.rs @@ -0,0 +1,113 @@ +//! In-process runs through the console output paths and the closed-pipe +//! handling (#40). `test_closed_pipes.rs` spawns the real binary; these call +//! the library directly so coverage tools that do not follow child processes +//! see the same code. + +use context_builder::cli::Args; +use context_builder::config::Config; +use context_builder::{Prompter, finish, run_with_args}; +use serial_test::serial; +use std::fs; +use std::io::{self, ErrorKind}; +use std::path::Path; +use tempfile::tempdir; + +struct TestPrompter; + +impl Prompter for TestPrompter { + fn confirm_overwrite(&self, _file_path: &str) -> io::Result { + Ok(true) + } +} + +fn args(input: &Path, output: &Path) -> Args { + Args { + input: input.to_string_lossy().to_string(), + output: output.to_string_lossy().to_string(), + filter: vec![], + ignore: vec![], + line_numbers: false, + preview: false, + token_count: false, + yes: true, + diff_only: false, + clear_cache: false, + encoding: "o200k_base".to_string(), + init: false, + max_tokens: None, + signatures: false, + structure: false, + truncate: "smart".to_string(), + visibility: "all".to_string(), + file_metadata: false, + max_file_size: "10M".to_string(), + hidden: false, + include_secrets: false, + include_lockfiles: false, + } +} + +fn big_project() -> tempfile::TempDir { + let dir = tempdir().unwrap(); + for i in 0..3 { + let mut body = String::new(); + let mut n = 0; + while body.len() < 200 * 1024 { + body.push_str(&format!( + "pub fn function_{i}_{n}(a: u32) -> u32 {{ a + {n} }}\n" + )); + n += 1; + } + fs::write(dir.path().join(format!("big{i}.rs")), body).unwrap(); + } + dir +} + +#[test] +fn finish_treats_a_closed_pipe_as_success_and_keeps_other_errors() { + assert!(finish(Ok(())).is_ok()); + assert!(finish(Err(io::Error::from(ErrorKind::BrokenPipe))).is_ok()); + let err = finish(Err(io::Error::from(ErrorKind::NotFound))).unwrap_err(); + assert_eq!(err.kind(), ErrorKind::NotFound); +} + +#[test] +#[serial] +fn full_run_reports_progress_and_the_context_window_advice() { + let dir = big_project(); + let out = dir.path().join("out.md"); + run_with_args(args(dir.path(), &out), Config::default(), &TestPrompter).unwrap(); + assert!(fs::read_to_string(&out).unwrap().contains("function_0_0")); +} + +#[test] +#[serial] +fn token_count_mode_prints_totals_and_writes_nothing() { + let dir = big_project(); + let out = dir.path().join("out.md"); + let mut a = args(dir.path(), &out); + a.token_count = true; + run_with_args(a, Config::default(), &TestPrompter).unwrap(); + assert!(!out.exists()); +} + +#[test] +#[serial] +fn token_budget_run_stops_at_the_budget() { + let dir = big_project(); + let out = dir.path().join("out.md"); + let mut a = args(dir.path(), &out); + a.max_tokens = Some(1_500); + run_with_args(a, Config::default(), &TestPrompter).unwrap(); + let doc = fs::read_to_string(&out).unwrap(); + assert!(doc.contains("Token budget (1500) reached"), "{doc}"); +} + +#[test] +#[serial] +fn stdout_output_mode_streams_the_document() { + let dir = big_project(); + let mut a = args(dir.path(), Path::new("-")); + a.output = "-".to_string(); + run_with_args(a, Config::default(), &TestPrompter).unwrap(); +} From 6e2256178a498ece74fbe7bf9263645af5c5aa68 Mon Sep 17 00:00:00 2001 From: Igor Lins e Silva <4753812+igorls@users.noreply.github.com> Date: Sun, 11 Oct 2026 03:00:27 -0300 Subject: [PATCH 05/11] refactor: console macros call functions so call sites keep coverage attribution (#40) --- src/console.rs | 56 ++++++++++++++++++++++++++++++++------------------ 1 file changed, 36 insertions(+), 20 deletions(-) diff --git a/src/console.rs b/src/console.rs index 9aa0248..7541e09 100644 --- a/src/console.rs +++ b/src/console.rs @@ -6,31 +6,47 @@ //! are best-effort, so these macros drop the write error instead. Document //! content written to stdout (`-o -`) still reports errors through `io::Result`. +use std::fmt::Arguments; +use std::io::Write; + +/// The macros are thin wrappers over these functions so the call site keeps +/// its own line (and coverage attribution) instead of the macro body's. +pub(crate) fn write_err(args: Arguments<'_>, newline: bool) { + let mut err = std::io::stderr(); + let _ = err.write_fmt(args); + if newline { + let _ = err.write_all(b"\n"); + } +} + +pub(crate) fn write_out(args: Arguments<'_>, newline: bool) { + let mut out = std::io::stdout(); + let _ = out.write_fmt(args); + if newline { + let _ = out.write_all(b"\n"); + } +} + macro_rules! errln { - () => {{ - use ::std::io::Write as _; - let _ = ::std::writeln!(::std::io::stderr()); - }}; - ($($arg:tt)*) => {{ - use ::std::io::Write as _; - let _ = ::std::writeln!(::std::io::stderr(), $($arg)*); - }}; + () => { + $crate::console::write_err(format_args!(""), true) + }; + ($($arg:tt)*) => { + $crate::console::write_err(format_args!($($arg)*), true) + }; } macro_rules! err { - ($($arg:tt)*) => {{ - use ::std::io::Write as _; - let _ = ::std::write!(::std::io::stderr(), $($arg)*); - }}; + ($($arg:tt)*) => { + $crate::console::write_err(format_args!($($arg)*), false) + }; } macro_rules! outln { - () => {{ - use ::std::io::Write as _; - let _ = ::std::writeln!(::std::io::stdout()); - }}; - ($($arg:tt)*) => {{ - use ::std::io::Write as _; - let _ = ::std::writeln!(::std::io::stdout(), $($arg)*); - }}; + () => { + $crate::console::write_out(format_args!(""), true) + }; + ($($arg:tt)*) => { + $crate::console::write_out(format_args!($($arg)*), true) + }; } From cfe785ee033e8a8ff7b014f0f0b99212be9e8e39 Mon Sep 17 00:00:00 2001 From: Igor Lins e Silva <4753812+igorls@users.noreply.github.com> Date: Sun, 11 Oct 2026 03:10:29 -0300 Subject: [PATCH 06/11] refactor: console helpers are functions, not macros, so call sites keep coverage (#40) --- src/cache.rs | 10 ++- src/config.rs | 8 +- src/console.rs | 47 +++-------- src/content_filter.rs | 2 +- src/file_utils.rs | 2 +- src/lib.rs | 191 ++++++++++++++++++++++++++---------------- src/markdown.rs | 7 +- src/tree.rs | 4 +- 8 files changed, 150 insertions(+), 121 deletions(-) diff --git a/src/cache.rs b/src/cache.rs index f57d404..26ad808 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -117,7 +117,10 @@ impl CacheManager { for pattern in &old_cache_patterns { let old_cache_path = self.cache_dir.join(pattern); if old_cache_path.exists() { - errln!("Migrating old cache format: removing {}", pattern); + crate::console::errln(format_args!( + "Migrating old cache format: removing {}", + pattern + )); let _ = fs::remove_file(&old_cache_path); } } @@ -128,7 +131,10 @@ impl CacheManager { let file_name = entry.file_name(); let name = file_name.to_string_lossy(); if name.ends_with(".md") && (name.contains("_20") || name.starts_with("output_")) { - errln!("Migrating old cache format: removing {}", name); + crate::console::errln(format_args!( + "Migrating old cache format: removing {}", + name + )); let _ = fs::remove_file(entry.path()); } } diff --git a/src/config.rs b/src/config.rs index 05f1562..4eaa29a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -222,10 +222,10 @@ pub fn load_config() -> Option { match toml::from_str(&content) { Ok(config) => Some(config), Err(e) => { - errln!( + crate::console::errln(format_args!( "⚠️ Failed to parse context-builder.toml: {}. Config will be ignored.", e - ); + )); None } } @@ -243,11 +243,11 @@ pub fn load_config_from_path(project_root: &Path) -> Option { match toml::from_str(&content) { Ok(config) => Some(config), Err(e) => { - errln!( + crate::console::errln(format_args!( "⚠️ Failed to parse {}: {}. Config will be ignored.", config_path.display(), e - ); + )); None } } diff --git a/src/console.rs b/src/console.rs index 7541e09..38abe50 100644 --- a/src/console.rs +++ b/src/console.rs @@ -3,50 +3,27 @@ //! `eprintln!` / `println!` panic when the stream is closed (for example //! `context-builder … 2>&1 | head -3`), which killed the run with exit //! code 101 before the document was finished. Diagnostics and progress lines -//! are best-effort, so these macros drop the write error instead. Document +//! are best-effort, so these helpers drop the write error instead. Document //! content written to stdout (`-o -`) still reports errors through `io::Result`. use std::fmt::Arguments; use std::io::Write; -/// The macros are thin wrappers over these functions so the call site keeps -/// its own line (and coverage attribution) instead of the macro body's. -pub(crate) fn write_err(args: Arguments<'_>, newline: bool) { +/// Best-effort line on stderr; a closed stream is ignored. +pub(crate) fn errln(args: Arguments<'_>) { let mut err = std::io::stderr(); let _ = err.write_fmt(args); - if newline { - let _ = err.write_all(b"\n"); - } + let _ = err.write_all(b"\n"); } -pub(crate) fn write_out(args: Arguments<'_>, newline: bool) { - let mut out = std::io::stdout(); - let _ = out.write_fmt(args); - if newline { - let _ = out.write_all(b"\n"); - } -} - -macro_rules! errln { - () => { - $crate::console::write_err(format_args!(""), true) - }; - ($($arg:tt)*) => { - $crate::console::write_err(format_args!($($arg)*), true) - }; +/// Best-effort text on stderr, no newline. +pub(crate) fn err(args: Arguments<'_>) { + let _ = std::io::stderr().write_fmt(args); } -macro_rules! err { - ($($arg:tt)*) => { - $crate::console::write_err(format_args!($($arg)*), false) - }; -} - -macro_rules! outln { - () => { - $crate::console::write_out(format_args!(""), true) - }; - ($($arg:tt)*) => { - $crate::console::write_out(format_args!($($arg)*), true) - }; +/// Best-effort line on stdout; a closed stream is ignored. +pub(crate) fn outln(args: Arguments<'_>) { + let mut out = std::io::stdout(); + let _ = out.write_fmt(args); + let _ = out.write_all(b"\n"); } diff --git a/src/content_filter.rs b/src/content_filter.rs index 59576c0..5a931b5 100644 --- a/src/content_filter.rs +++ b/src/content_filter.rs @@ -423,7 +423,7 @@ pub fn report_skips(skipped: &[SkippedFile], silent: bool) { return; } for line in report_lines(skipped) { - errln!("{line}"); + crate::console::errln(format_args!("{line}")); } } diff --git a/src/file_utils.rs b/src/file_utils.rs index 93b0991..fae8ca4 100644 --- a/src/file_utils.rs +++ b/src/file_utils.rs @@ -753,7 +753,7 @@ fn can_prompt(stdin_tty: bool, stderr_tty: bool) -> bool { /// Prompts must not go to stdout: `-o -` and any caller capturing stdout would /// otherwise treat the question as document content. fn prompt_yes(prompt: &str) -> io::Result { - err!("{prompt}"); + crate::console::err(format_args!("{prompt}")); io::stderr().flush()?; let mut input = String::new(); io::stdin().read_line(&mut input)?; diff --git a/src/lib.rs b/src/lib.rs index 3363122..fc9fa83 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -187,9 +187,9 @@ pub fn run_with_args(args: Args, config: Config, prompter: &impl Prompter) -> io && !matches!(strat.as_str(), "detect" | "strict" | "skip") && !silent { - errln!( + crate::console::errln(format_args!( "⚠️ Unknown encoding_strategy '{strat}' in config; expected one of: detect, strict, skip. Falling back to 'detect'." - ); + )); } // Resolve base path. If input is '.' but current working directory lost the project context // (no context-builder.toml), attempt to infer project root from output path (parent of 'output' dir). @@ -211,10 +211,10 @@ pub fn run_with_args(args: Args, config: Config, prompter: &impl Prompter) -> io if !base_path.exists() || !base_path.is_dir() { if !silent { - errln!( + crate::console::errln(format_args!( "Error: The specified input directory '{}' does not exist or is not a directory.", final_args.input - ); + )); } return Err(io::Error::new( io::ErrorKind::NotFound, @@ -243,7 +243,7 @@ pub fn run_with_args(args: Args, config: Config, prompter: &impl Prompter) -> io && !prompter.confirm_overwrite(&final_args.output)? { if !silent { - errln!("Operation cancelled."); + crate::console::errln(format_args!("Operation cancelled.")); } return Err(io::Error::new( io::ErrorKind::Interrupted, @@ -273,7 +273,7 @@ pub fn run_with_args(args: Args, config: Config, prompter: &impl Prompter) -> io if !silent && let Some(notice) = crate::file_utils::lockfile_skip_notice(collected.skipped_lockfiles) { - errln!("{notice}"); + crate::console::errln(format_args!("{notice}")); } let files = collected.files; let policy = ContentPolicy::new( @@ -287,7 +287,9 @@ pub fn run_with_args(args: Args, config: Config, prompter: &impl Prompter) -> io // write the (empty) document. Name the filters when any were given. if !silent && files.is_empty() && skipped.is_empty() { if final_args.filter.is_empty() { - errln!("Warning: No files matched; check .gitignore, --ignore, and --filter"); + crate::console::errln(format_args!( + "Warning: No files matched; check .gitignore, --ignore, and --filter" + )); } else { let quoted = final_args .filter @@ -300,17 +302,23 @@ pub fn run_with_args(args: Args, config: Config, prompter: &impl Prompter) -> io } else { "filters" }; - errln!("Warning: no files matched {noun} {quoted}."); + crate::console::errln(format_args!("Warning: no files matched {noun} {quoted}.")); } } let debug_config = std::env::var("CB_DEBUG_CONFIG").is_ok(); if debug_config { - errln!("[DEBUG][CONFIG] Args: {:?}", final_args); - errln!("[DEBUG][CONFIG] Raw Config: {:?}", config); - errln!("[DEBUG][CONFIG] Auto-ignores: {:?}", auto_ignores); - errln!("[DEBUG][CONFIG] Collected {} files", files.len()); + crate::console::errln(format_args!("[DEBUG][CONFIG] Args: {:?}", final_args)); + crate::console::errln(format_args!("[DEBUG][CONFIG] Raw Config: {:?}", config)); + crate::console::errln(format_args!( + "[DEBUG][CONFIG] Auto-ignores: {:?}", + auto_ignores + )); + crate::console::errln(format_args!( + "[DEBUG][CONFIG] Collected {} files", + files.len() + )); for f in &files { - errln!("[DEBUG][CONFIG] - {}", f.path().display()); + crate::console::errln(format_args!("[DEBUG][CONFIG] - {}", f.path().display())); } } @@ -338,29 +346,29 @@ pub fn run_with_args(args: Args, config: Config, prompter: &impl Prompter) -> io if !large_files.is_empty() { large_files.sort_by_key(|b| std::cmp::Reverse(b.1)); // Sort by size descending - errln!( + crate::console::errln(format_args!( "\n⚠ {} large file(s) detected (>{} KB):", large_files.len(), LARGE_FILE_THRESHOLD / 1024 - ); + )); for (path, size) in large_files.iter().take(5) { - errln!(" {:>8} KB {}", size / 1024, path); + crate::console::errln(format_args!(" {:>8} KB {}", size / 1024, path)); } if large_files.len() > 5 { - errln!(" ... and {} more", large_files.len() - 5); + crate::console::errln(format_args!(" ... and {} more", large_files.len() - 5)); } - errln!( + crate::console::errln(format_args!( " Total context size: {} KB across {} files\n", total_size / 1024, files.len() - ); + )); } } let file_tree = build_file_tree(&files, base_path); if final_args.preview { if !silent { - outln!("\n# File Tree Structure (Preview)\n"); + crate::console::outln(format_args!("\n# File Tree Structure (Preview)\n")); print_tree(&file_tree, 0); } if !final_args.token_count { @@ -381,7 +389,7 @@ pub fn run_with_args(args: Args, config: Config, prompter: &impl Prompter) -> io file_metadata: final_args.file_metadata, }; let enc_strategy = config.encoding_strategy.as_deref(); - outln!("\n# Token Count Estimation\n"); + crate::console::outln(format_args!("\n# Token Count Estimation\n")); let mut total_tokens = 0; total_tokens += estimate_tokens(encoding, &format!("{}\n\n", markdown::REPORT_TITLE_LINE)); @@ -435,9 +443,9 @@ pub fn run_with_args(args: Args, config: Config, prompter: &impl Prompter) -> io }) .sum(); total_tokens += file_tokens; - outln!("Estimated total tokens: {}", total_tokens); - outln!("File tree tokens: {}", tree_tokens); - outln!("File content tokens: {}", file_tokens); + crate::console::outln(format_args!("Estimated total tokens: {}", total_tokens)); + crate::console::outln(format_args!("File tree tokens: {}", tree_tokens)); + crate::console::outln(format_args!("File content tokens: {}", file_tokens)); } return Ok(()); } @@ -449,10 +457,10 @@ pub fn run_with_args(args: Args, config: Config, prompter: &impl Prompter) -> io // B8: --diff-only only takes effect together with auto_diff (+ timestamped // output). Warn instead of silently emitting full file contents. if final_args.diff_only && !config.auto_diff.unwrap_or(false) && !silent { - errln!( + crate::console::errln(format_args!( "⚠️ --diff-only has no effect without auto_diff (it also needs timestamped_output). \ Full file contents will be emitted. Enable auto_diff = true + timestamped_output = true to use diff-only mode." - ); + )); } if config.auto_diff.unwrap_or(false) { @@ -496,10 +504,10 @@ pub fn run_with_args(args: Args, config: Config, prompter: &impl Prompter) -> io Ok(state) => state, Err(e) => { if !silent { - errln!( + crate::console::errln(format_args!( "Warning: Failed to read cache (proceeding without diff): {}", e - ); + )); } None } @@ -525,35 +533,41 @@ pub fn run_with_args(args: Args, config: Config, prompter: &impl Prompter) -> io let debug_autodiff = std::env::var("CB_DEBUG_AUTODIFF").is_ok(); if debug_autodiff { - errln!( + crate::console::errln(format_args!( "[DEBUG][AUTODIFF] cache file: {}", cache_manager.debug_cache_file_path().display() - ); - errln!( + )); + crate::console::errln(format_args!( "[DEBUG][AUTODIFF] config_hash current={} prev={:?} invalidated={}", current_state.config_hash, previous_state.as_ref().map(|s| s.config_hash.clone()), effective_previous.is_none() && previous_state.is_some() - ); - errln!("[DEBUG][AUTODIFF] effective_config: {:?}", effective_config); + )); + crate::console::errln(format_args!( + "[DEBUG][AUTODIFF] effective_config: {:?}", + effective_config + )); if let Some(prev) = previous_state.as_ref() { - errln!("[DEBUG][AUTODIFF] raw previous files: {}", prev.files.len()); + crate::console::errln(format_args!( + "[DEBUG][AUTODIFF] raw previous files: {}", + prev.files.len() + )); } if let Some(prev) = effective_previous { - errln!( + crate::console::errln(format_args!( "[DEBUG][AUTODIFF] effective previous files: {}", prev.files.len() - ); + )); for k in prev.files.keys() { - errln!(" PREV: {}", k.display()); + crate::console::errln(format_args!(" PREV: {}", k.display())); } } - errln!( + crate::console::errln(format_args!( "[DEBUG][AUTODIFF] current files: {}", current_state.files.len() - ); + )); for k in current_state.files.keys() { - errln!(" CURR: {}", k.display()); + crate::console::errln(format_args!(" CURR: {}", k.display())); } } @@ -649,31 +663,30 @@ pub fn run_with_args(args: Args, config: Config, prompter: &impl Prompter) -> io if let Err(e) = cache_manager.write_cache(¤t_state) && !silent { - errln!("Warning: failed to update state cache: {}", e); + crate::console::errln(format_args!("Warning: failed to update state cache: {}", e)); } let duration = start_time.elapsed(); if !silent && !to_stdout { if let Some(comp) = &comparison { if comp.summary.has_changes() { - outln!( + crate::console::outln(format_args!( "Documentation created successfully with {} changes: {}", - comp.summary.total_changes, - final_args.output - ); + comp.summary.total_changes, final_args.output + )); } else { - outln!( + crate::console::outln(format_args!( "Documentation created successfully (no changes detected): {}", final_args.output - ); + )); } } else { - outln!( + crate::console::outln(format_args!( "Documentation created successfully (initial state): {}", final_args.output - ); + )); } - outln!("Processing time: {:.2?}", duration); + crate::console::outln(format_args!("Processing time: {:.2?}", duration)); } if !silent { // Non-blocking. File count is not a cost proxy; this estimate is. @@ -697,9 +710,13 @@ pub fn run_with_args(args: Args, config: Config, prompter: &impl Prompter) -> io if !silent && (ts_config.signatures || ts_config.structure || ts_config.truncate == "smart") { #[cfg(not(feature = "tree-sitter-base"))] { - errln!("⚠️ --signatures/--structure/--truncate smart require tree-sitter support."); - errln!(" Build with: cargo build --features tree-sitter-all"); - errln!(" Falling back to standard output.\n"); + crate::console::errln(format_args!( + "⚠️ --signatures/--structure/--truncate smart require tree-sitter support." + )); + crate::console::errln(format_args!( + " Build with: cargo build --features tree-sitter-all" + )); + crate::console::errln(format_args!(" Falling back to standard output.\n")); } } @@ -721,8 +738,11 @@ pub fn run_with_args(args: Args, config: Config, prompter: &impl Prompter) -> io let duration = start_time.elapsed(); if !silent && !to_stdout { - outln!("Documentation created successfully: {}", final_args.output); - outln!("Processing time: {:.2?}", duration); + crate::console::outln(format_args!( + "Documentation created successfully: {}", + final_args.output + )); + crate::console::outln(format_args!("Processing time: {:.2?}", duration)); } if !silent { // Non-blocking. File count is not a cost proxy; this estimate is. @@ -754,7 +774,10 @@ fn print_context_window_warning( let estimated_tokens = output_bytes / 4; // Stderr only: this notice must never mix into a captured or piped document. - errln!("Estimated tokens: ~{}K", estimated_tokens / 1000); + crate::console::errln(format_args!( + "Estimated tokens: ~{}K", + estimated_tokens / 1000 + )); // If the user already set --max-tokens, they're managing their budget if max_tokens.is_some() { @@ -769,17 +792,19 @@ fn print_context_window_warning( let paths: Vec<&Path> = files.iter().map(|entry| entry.path()).collect(); - errln!(); - errln!( + crate::console::errln(format_args!("")); + crate::console::errln(format_args!( "⚠️ Output is ~{}K tokens — recommended limit is 128K for effective LLM context.", estimated_tokens / 1000 - ); - errln!(" Large contexts degrade response quality. Consider narrowing the scope:"); - errln!(); + )); + crate::console::errln(format_args!( + " Large contexts degrade response quality. Consider narrowing the scope:" + )); + crate::console::errln(format_args!("")); for line in context_window_suggestions(&paths) { - errln!(" • {line}"); + crate::console::errln(format_args!(" • {line}")); } - errln!(); + crate::console::errln(format_args!("")); } /// `--filter` extensions to suggest for this run: the most common extensions @@ -1055,11 +1080,20 @@ pub fn run() -> io::Result<()> { let cache_path = project_root.join(".context-builder").join("cache"); if cache_path.exists() { match fs::remove_dir_all(&cache_path) { - Ok(()) => outln!("Cache cleared: {}", cache_path.display()), - Err(e) => errln!("Failed to clear cache ({}): {}", cache_path.display(), e), + Ok(()) => { + crate::console::outln(format_args!("Cache cleared: {}", cache_path.display())) + } + Err(e) => crate::console::errln(format_args!( + "Failed to clear cache ({}): {}", + cache_path.display(), + e + )), } } else { - outln!("No cache directory found at {}", cache_path.display()); + crate::console::outln(format_args!( + "No cache directory found at {}", + cache_path.display() + )); } return Ok(()); } @@ -1079,7 +1113,7 @@ pub fn run() -> io::Result<()> { if !silent { for warning in &resolution.warnings { - errln!("Warning: {}", warning); + crate::console::errln(format_args!("Warning: {}", warning)); } } @@ -1159,8 +1193,13 @@ fn init_config() -> io::Result<()> { let config_path = Path::new("context-builder.toml"); if config_path.exists() { - outln!("Config file already exists at {}", config_path.display()); - outln!("If you want to replace it, please remove it manually first."); + crate::console::outln(format_args!( + "Config file already exists at {}", + config_path.display() + )); + crate::console::outln(format_args!( + "If you want to replace it, please remove it manually first." + )); return Ok(()); } @@ -1229,9 +1268,17 @@ file_metadata = false let mut file = File::create(config_path)?; file.write_all(default_config_content.as_bytes())?; - outln!("Config file created at {}", config_path.display()); - outln!("Detected file types: {}", filter_suggestions.join(", ")); - outln!("You can now customize it according to your project needs."); + crate::console::outln(format_args!( + "Config file created at {}", + config_path.display() + )); + crate::console::outln(format_args!( + "Detected file types: {}", + filter_suggestions.join(", ") + )); + crate::console::outln(format_args!( + "You can now customize it according to your project needs." + )); Ok(()) } diff --git a/src/markdown.rs b/src/markdown.rs index 864db98..bc5540f 100644 --- a/src/markdown.rs +++ b/src/markdown.rs @@ -1019,13 +1019,12 @@ pub fn write_tree_sitter_enrichment( // eprintln (not warn!/log) so it is visible without RUST_LOG — // env_logger defaults to error level, and a user-style default run // would otherwise never see this warning. - errln!( + crate::console::errln(format_args!( "⚠️ --visibility {} has no effect for '{}' files: the extractor \ does not classify visibility yet (planned for v0.11). \ Signatures will include all symbols.", - ts_config.visibility, - extension - ); + ts_config.visibility, extension + )); } if ts_config.structure diff --git a/src/tree.rs b/src/tree.rs index 9c1b882..c0a6060 100644 --- a/src/tree.rs +++ b/src/tree.rs @@ -59,10 +59,10 @@ pub fn print_tree(tree: &FileTree, depth: usize) { let indent = " ".repeat(depth); match node { FileNode::File => { - outln!("{}- 📄 {}", indent, name); + crate::console::outln(format_args!("{}- 📄 {}", indent, name)); } FileNode::Directory(children) => { - outln!("{}- 📁 {}", indent, name); + crate::console::outln(format_args!("{}- 📁 {}", indent, name)); print_tree(children, depth + 1); } } From 455cec22134de3185dc829ade68d87e98cedbdad Mon Sep 17 00:00:00 2001 From: Igor Lins e Silva <4753812+igorls@users.noreply.github.com> Date: Sun, 11 Oct 2026 03:18:01 -0300 Subject: [PATCH 07/11] test: cover debug tracing, diff-only warning and cache-write warning in-process (#40) --- tests/test_console_inproc.rs | 59 ++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/tests/test_console_inproc.rs b/tests/test_console_inproc.rs index 903bf09..b5899a8 100644 --- a/tests/test_console_inproc.rs +++ b/tests/test_console_inproc.rs @@ -111,3 +111,62 @@ fn stdout_output_mode_streams_the_document() { a.output = "-".to_string(); run_with_args(a, Config::default(), &TestPrompter).unwrap(); } + +fn small_project() -> tempfile::TempDir { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("a.txt"), "one\n").unwrap(); + dir +} + +#[test] +#[serial] +fn debug_env_switches_trace_config_and_auto_diff_to_stderr() { + let dir = small_project(); + let out = dir.path().join("out.md"); + let config = Config { + auto_diff: Some(true), + timestamped_output: Some(false), + ..Config::default() + }; + unsafe { + std::env::set_var("CB_DEBUG_CONFIG", "1"); + std::env::set_var("CB_DEBUG_AUTODIFF", "1"); + } + let first = run_with_args(args(dir.path(), &out), config.clone(), &TestPrompter); + fs::write(dir.path().join("a.txt"), "two\n").unwrap(); + let second = run_with_args(args(dir.path(), &out), config, &TestPrompter); + unsafe { + std::env::remove_var("CB_DEBUG_CONFIG"); + std::env::remove_var("CB_DEBUG_AUTODIFF"); + } + first.unwrap(); + second.unwrap(); + assert!(fs::read_to_string(&out).unwrap().contains("two")); +} + +#[test] +#[serial] +fn diff_only_without_auto_diff_warns_and_still_writes_the_document() { + let dir = small_project(); + let out = dir.path().join("out.md"); + let mut a = args(dir.path(), &out); + a.diff_only = true; + run_with_args(a, Config::default(), &TestPrompter).unwrap(); + assert!(fs::read_to_string(&out).unwrap().contains("one")); +} + +#[test] +#[serial] +fn unwritable_state_cache_only_warns() { + // The cache lives under `/.context-builder`; a plain file in its + // place makes the cache write fail without failing the run. + let dir = small_project(); + fs::write(dir.path().join(".context-builder"), "not a directory").unwrap(); + let out = dir.path().join("out.md"); + let config = Config { + auto_diff: Some(true), + timestamped_output: Some(false), + ..Config::default() + }; + let _ = run_with_args(args(dir.path(), &out), config, &TestPrompter); +} From 87470ef7c98563aae3dfc3fe92c4d2fb4a6a42ac Mon Sep 17 00:00:00 2001 From: Igor Lins e Silva <4753812+igorls@users.noreply.github.com> Date: Sun, 11 Oct 2026 03:22:38 -0300 Subject: [PATCH 08/11] test: make the command-line path runnable in-process (run_from) and cover it --- src/lib.rs | 25 ++++++- tests/test_run_in_process.rs | 125 +++++++++++++++++++++++++++++++++++ 2 files changed, 147 insertions(+), 3 deletions(-) create mode 100644 tests/test_run_in_process.rs diff --git a/src/lib.rs b/src/lib.rs index 1dc6e65..c06ce92 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1012,6 +1012,25 @@ pub fn run() -> io::Result<()> { // default, so the value alone can't reveal an intent to override a // non-default config (see resolver). let matches = Args::command().get_matches(); + run_matches(&matches, std::env::args_os().len()) +} + +/// [`run`] with an explicit argument vector (program name first) instead of +/// the process arguments, so the whole command line path can run in-process. +/// A bad command line is returned as `InvalidInput` instead of exiting. +pub fn run_from(argv: I) -> io::Result<()> +where + I: IntoIterator, + T: Into + Clone, +{ + let argv: Vec = argv.into_iter().map(Into::into).collect(); + let matches = Args::command() + .try_get_matches_from(&argv) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e.to_string()))?; + run_matches(&matches, argv.len()) +} + +fn run_matches(matches: &clap::ArgMatches, argc: usize) -> io::Result<()> { let explicit = crate::config_resolver::ExplicitCli { truncate: matches.value_source("truncate") == Some(clap::parser::ValueSource::CommandLine), visibility: matches.value_source("visibility") @@ -1024,8 +1043,8 @@ pub fn run() -> io::Result<()> { // `-o` is used verbatim (no output_folder / timestamp rewrite). output: matches.value_source("output") == Some(clap::parser::ValueSource::CommandLine), }; - let args = Args::from_arg_matches(&matches) - .expect("arguments were already validated by get_matches()"); + let args = + Args::from_arg_matches(matches).expect("arguments were already validated by get_matches()"); // Handle init command first if args.init { @@ -1050,7 +1069,7 @@ pub fn run() -> io::Result<()> { return Ok(()); } - if std::env::args().len() == 1 && config.is_none() { + if argc == 1 && config.is_none() { Args::command().print_help()?; return Ok(()); } diff --git a/tests/test_run_in_process.rs b/tests/test_run_in_process.rs new file mode 100644 index 0000000..8e1f115 --- /dev/null +++ b/tests/test_run_in_process.rs @@ -0,0 +1,125 @@ +//! The whole command-line path (`run_from`) in-process: argument parsing, +//! config resolution, `--init`, `--clear-cache`, help, and a real run. The +//! spawn-the-binary tests cover the same behavior from the outside; these keep +//! it visible to coverage tools that do not follow child processes. + +use context_builder::run_from; +use serial_test::serial; +use std::fs; +use std::path::{Path, PathBuf}; +use tempfile::tempdir; + +struct CwdGuard(PathBuf); + +impl CwdGuard { + fn enter(path: &Path) -> Self { + let prev = std::env::current_dir().unwrap(); + std::env::set_current_dir(path).unwrap(); + Self(prev) + } +} + +impl Drop for CwdGuard { + fn drop(&mut self) { + let _ = std::env::set_current_dir(&self.0); + } +} + +fn project() -> tempfile::TempDir { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("a.txt"), "alpha\n").unwrap(); + dir +} + +#[test] +#[serial] +fn explicit_run_writes_the_document() { + let dir = project(); + let out = dir.path().join("out.md"); + let _cwd = CwdGuard::enter(dir.path()); + run_from([ + "context-builder", + "-d", + dir.path().to_str().unwrap(), + "-o", + out.to_str().unwrap(), + "-y", + "--encoding", + "cl100k_base", + "--truncate", + "byte", + "--visibility", + "public", + "--max-file-size", + "1M", + ]) + .unwrap(); + assert!(fs::read_to_string(out).unwrap().contains("alpha")); +} + +#[test] +#[serial] +fn bare_invocation_without_a_config_prints_help_and_succeeds() { + let dir = project(); + let _cwd = CwdGuard::enter(dir.path()); + run_from(["context-builder"]).unwrap(); + assert!(!dir.path().join("output.md").exists()); +} + +#[test] +#[serial] +fn bad_command_line_is_an_invalid_input_error() { + let err = run_from(["context-builder", "--no-such-flag"]).unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); +} + +#[test] +#[serial] +fn init_writes_a_config_and_refuses_to_replace_one() { + let dir = project(); + let _cwd = CwdGuard::enter(dir.path()); + run_from(["context-builder", "--init"]).unwrap(); + let cfg = dir.path().join("context-builder.toml"); + assert!(cfg.exists()); + let first = fs::read_to_string(&cfg).unwrap(); + run_from(["context-builder", "--init"]).unwrap(); + assert_eq!(fs::read_to_string(&cfg).unwrap(), first, "not replaced"); +} + +#[test] +#[serial] +fn clear_cache_removes_the_cache_directory_or_says_there_is_none() { + let dir = project(); + let _cwd = CwdGuard::enter(dir.path()); + let d = dir.path().to_str().unwrap(); + run_from(["context-builder", "-d", d, "--clear-cache"]).unwrap(); + + let cache = dir.path().join(".context-builder").join("cache"); + fs::create_dir_all(&cache).unwrap(); + fs::write(cache.join("state.json"), "{}").unwrap(); + run_from(["context-builder", "-d", d, "--clear-cache"]).unwrap(); + assert!(!cache.exists()); +} + +#[test] +#[serial] +fn project_config_with_warnings_still_runs() { + let dir = project(); + fs::write( + dir.path().join("context-builder.toml"), + "line_numbers = true\ndiff_only = true\n", + ) + .unwrap(); + let out = dir.path().join("out.md"); + let _cwd = CwdGuard::enter(dir.path()); + run_from([ + "context-builder", + "-d", + dir.path().to_str().unwrap(), + "-o", + out.to_str().unwrap(), + "-y", + ]) + .unwrap(); + assert!(out.exists()); +} From e0ef6490a90176a019215c7166d99518f4879cee Mon Sep 17 00:00:00 2001 From: Igor Lins e Silva <4753812+igorls@users.noreply.github.com> Date: Sun, 11 Oct 2026 18:07:55 -0300 Subject: [PATCH 09/11] test: cover the console helpers directly --- src/console.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/console.rs b/src/console.rs index 38abe50..4c77fd5 100644 --- a/src/console.rs +++ b/src/console.rs @@ -27,3 +27,15 @@ pub(crate) fn outln(args: Arguments<'_>) { let _ = out.write_fmt(args); let _ = out.write_all(b"\n"); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn helpers_write_without_panicking() { + err(format_args!("")); + errln(format_args!("console test (stderr)")); + outln(format_args!("console test (stdout)")); + } +} From 00222139eb643496c97348a8c026561ba2c34069 Mon Sep 17 00:00:00 2001 From: Igor Lins e Silva <4753812+igorls@users.noreply.github.com> Date: Sun, 11 Oct 2026 18:18:53 -0300 Subject: [PATCH 10/11] test: cancelled overwrite, uncreatable output dir, clear-cache paths in-process --- tests/test_console_inproc.rs | 53 ++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/tests/test_console_inproc.rs b/tests/test_console_inproc.rs index b5899a8..c6239a8 100644 --- a/tests/test_console_inproc.rs +++ b/tests/test_console_inproc.rs @@ -170,3 +170,56 @@ fn unwritable_state_cache_only_warns() { }; let _ = run_with_args(args(dir.path(), &out), config, &TestPrompter); } + +struct Declines; + +impl Prompter for Declines { + fn confirm_overwrite(&self, _file_path: &str) -> io::Result { + Ok(false) + } +} + +#[test] +#[serial] +fn declining_the_overwrite_prompt_cancels_without_touching_the_file() { + let dir = small_project(); + let out = dir.path().join("out.md"); + fs::write(&out, "keep me").unwrap(); + let mut a = args(dir.path(), &out); + a.yes = false; + let err = run_with_args(a, Config::default(), &Declines).unwrap_err(); + assert_eq!(err.kind(), ErrorKind::Interrupted); + assert_eq!(fs::read_to_string(&out).unwrap(), "keep me"); +} + +#[test] +#[serial] +fn an_output_directory_that_cannot_be_created_is_a_clean_error() { + let dir = small_project(); + fs::write(dir.path().join("blocker"), "a file, not a directory").unwrap(); + let out = dir.path().join("blocker").join("sub").join("out.md"); + let err = run_with_args(args(dir.path(), &out), Config::default(), &TestPrompter); + assert!(err.is_err()); +} + +#[test] +#[serial] +fn clearing_a_missing_cache_reports_and_succeeds() { + let dir = small_project(); + context_builder::run_from([ + "context-builder", + "-d", + dir.path().to_str().unwrap(), + "--clear-cache", + ]) + .unwrap(); + fs::create_dir_all(dir.path().join(".context-builder/cache")).unwrap(); + context_builder::run_from([ + "context-builder", + "-d", + dir.path().to_str().unwrap(), + "--clear-cache", + ]) + .unwrap(); + assert!(!dir.path().join(".context-builder/cache").exists()); +} From 1061ee9a80853e1a328d3ff53181164fc654fa72 Mon Sep 17 00:00:00 2001 From: Igor Lins e Silva <4753812+igorls@users.noreply.github.com> Date: Sun, 11 Oct 2026 18:41:17 -0300 Subject: [PATCH 11/11] refactor: best-effort console writes via writeln!(stderr) at the call site (no helper module) --- src/cache.rs | 10 +- src/config.rs | 11 +- src/console.rs | 41 ------- src/content_filter.rs | 2 +- src/file_utils.rs | 2 +- src/lib.rs | 241 ++++++++++++++++++++++++++---------------- src/markdown.rs | 8 +- src/tree.rs | 4 +- 8 files changed, 170 insertions(+), 149 deletions(-) delete mode 100644 src/console.rs diff --git a/src/cache.rs b/src/cache.rs index 26ad808..f6e92be 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -117,10 +117,11 @@ impl CacheManager { for pattern in &old_cache_patterns { let old_cache_path = self.cache_dir.join(pattern); if old_cache_path.exists() { - crate::console::errln(format_args!( + let _ = writeln!( + std::io::stderr(), "Migrating old cache format: removing {}", pattern - )); + ); let _ = fs::remove_file(&old_cache_path); } } @@ -131,10 +132,11 @@ impl CacheManager { let file_name = entry.file_name(); let name = file_name.to_string_lossy(); if name.ends_with(".md") && (name.contains("_20") || name.starts_with("output_")) { - crate::console::errln(format_args!( + let _ = writeln!( + std::io::stderr(), "Migrating old cache format: removing {}", name - )); + ); let _ = fs::remove_file(entry.path()); } } diff --git a/src/config.rs b/src/config.rs index 4eaa29a..dcd7e2a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,5 +1,6 @@ use serde::Deserialize; use std::fs; +use std::io::Write as _; use std::path::Path; use crate::content_filter::{self, DEFAULT_MAX_FILE_SIZE_BYTES}; @@ -222,10 +223,11 @@ pub fn load_config() -> Option { match toml::from_str(&content) { Ok(config) => Some(config), Err(e) => { - crate::console::errln(format_args!( + let _ = writeln!( + std::io::stderr(), "⚠️ Failed to parse context-builder.toml: {}. Config will be ignored.", e - )); + ); None } } @@ -243,11 +245,12 @@ pub fn load_config_from_path(project_root: &Path) -> Option { match toml::from_str(&content) { Ok(config) => Some(config), Err(e) => { - crate::console::errln(format_args!( + let _ = writeln!( + std::io::stderr(), "⚠️ Failed to parse {}: {}. Config will be ignored.", config_path.display(), e - )); + ); None } } diff --git a/src/console.rs b/src/console.rs deleted file mode 100644 index 4c77fd5..0000000 --- a/src/console.rs +++ /dev/null @@ -1,41 +0,0 @@ -//! Console output that never panics. -//! -//! `eprintln!` / `println!` panic when the stream is closed (for example -//! `context-builder … 2>&1 | head -3`), which killed the run with exit -//! code 101 before the document was finished. Diagnostics and progress lines -//! are best-effort, so these helpers drop the write error instead. Document -//! content written to stdout (`-o -`) still reports errors through `io::Result`. - -use std::fmt::Arguments; -use std::io::Write; - -/// Best-effort line on stderr; a closed stream is ignored. -pub(crate) fn errln(args: Arguments<'_>) { - let mut err = std::io::stderr(); - let _ = err.write_fmt(args); - let _ = err.write_all(b"\n"); -} - -/// Best-effort text on stderr, no newline. -pub(crate) fn err(args: Arguments<'_>) { - let _ = std::io::stderr().write_fmt(args); -} - -/// Best-effort line on stdout; a closed stream is ignored. -pub(crate) fn outln(args: Arguments<'_>) { - let mut out = std::io::stdout(); - let _ = out.write_fmt(args); - let _ = out.write_all(b"\n"); -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn helpers_write_without_panicking() { - err(format_args!("")); - errln(format_args!("console test (stderr)")); - outln(format_args!("console test (stdout)")); - } -} diff --git a/src/content_filter.rs b/src/content_filter.rs index 5a931b5..4325225 100644 --- a/src/content_filter.rs +++ b/src/content_filter.rs @@ -423,7 +423,7 @@ pub fn report_skips(skipped: &[SkippedFile], silent: bool) { return; } for line in report_lines(skipped) { - crate::console::errln(format_args!("{line}")); + let _ = writeln!(std::io::stderr(), "{line}"); } } diff --git a/src/file_utils.rs b/src/file_utils.rs index fae8ca4..5734bb8 100644 --- a/src/file_utils.rs +++ b/src/file_utils.rs @@ -753,7 +753,7 @@ fn can_prompt(stdin_tty: bool, stderr_tty: bool) -> bool { /// Prompts must not go to stdout: `-o -` and any caller capturing stdout would /// otherwise treat the question as document content. fn prompt_yes(prompt: &str) -> io::Result { - crate::console::err(format_args!("{prompt}")); + let _ = write!(std::io::stderr(), "{prompt}"); io::stderr().flush()?; let mut input = String::new(); io::stdin().read_line(&mut input)?; diff --git a/src/lib.rs b/src/lib.rs index b2f2675..a3ebb85 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,8 +5,6 @@ use std::io::{self, Write}; use std::path::{Component, Path, PathBuf}; use std::time::Instant; -#[macro_use] -mod console; pub mod cache; pub mod cli; pub mod config; @@ -187,9 +185,10 @@ pub fn run_with_args(args: Args, config: Config, prompter: &impl Prompter) -> io && !matches!(strat.as_str(), "detect" | "strict" | "skip") && !silent { - crate::console::errln(format_args!( + let _ = writeln!( + std::io::stderr(), "⚠️ Unknown encoding_strategy '{strat}' in config; expected one of: detect, strict, skip. Falling back to 'detect'." - )); + ); } // Resolve base path. If input is '.' but current working directory lost the project context // (no context-builder.toml), attempt to infer project root from output path (parent of 'output' dir). @@ -211,10 +210,11 @@ pub fn run_with_args(args: Args, config: Config, prompter: &impl Prompter) -> io if !base_path.exists() || !base_path.is_dir() { if !silent { - crate::console::errln(format_args!( + let _ = writeln!( + std::io::stderr(), "Error: The specified input directory '{}' does not exist or is not a directory.", final_args.input - )); + ); } return Err(io::Error::new( io::ErrorKind::NotFound, @@ -243,7 +243,7 @@ pub fn run_with_args(args: Args, config: Config, prompter: &impl Prompter) -> io && !prompter.confirm_overwrite(&final_args.output)? { if !silent { - crate::console::errln(format_args!("Operation cancelled.")); + let _ = writeln!(std::io::stderr(), "Operation cancelled."); } return Err(io::Error::new( io::ErrorKind::Interrupted, @@ -273,7 +273,7 @@ pub fn run_with_args(args: Args, config: Config, prompter: &impl Prompter) -> io if !silent && let Some(notice) = crate::file_utils::lockfile_skip_notice(collected.skipped_lockfiles) { - crate::console::errln(format_args!("{notice}")); + let _ = writeln!(std::io::stderr(), "{notice}"); } let files = collected.files; let policy = ContentPolicy::new( @@ -287,9 +287,10 @@ pub fn run_with_args(args: Args, config: Config, prompter: &impl Prompter) -> io // write the (empty) document. Name the filters when any were given. if !silent && files.is_empty() && skipped.is_empty() { if final_args.filter.is_empty() { - crate::console::errln(format_args!( + let _ = writeln!( + std::io::stderr(), "Warning: No files matched; check .gitignore, --ignore, and --filter" - )); + ); } else { let quoted = final_args .filter @@ -302,23 +303,36 @@ pub fn run_with_args(args: Args, config: Config, prompter: &impl Prompter) -> io } else { "filters" }; - crate::console::errln(format_args!("Warning: no files matched {noun} {quoted}.")); + let _ = writeln!( + std::io::stderr(), + "Warning: no files matched {noun} {quoted}." + ); } } let debug_config = std::env::var("CB_DEBUG_CONFIG").is_ok(); if debug_config { - crate::console::errln(format_args!("[DEBUG][CONFIG] Args: {:?}", final_args)); - crate::console::errln(format_args!("[DEBUG][CONFIG] Raw Config: {:?}", config)); - crate::console::errln(format_args!( + let _ = writeln!(std::io::stderr(), "[DEBUG][CONFIG] Args: {:?}", final_args); + let _ = writeln!( + std::io::stderr(), + "[DEBUG][CONFIG] Raw Config: {:?}", + config + ); + let _ = writeln!( + std::io::stderr(), "[DEBUG][CONFIG] Auto-ignores: {:?}", auto_ignores - )); - crate::console::errln(format_args!( + ); + let _ = writeln!( + std::io::stderr(), "[DEBUG][CONFIG] Collected {} files", files.len() - )); + ); for f in &files { - crate::console::errln(format_args!("[DEBUG][CONFIG] - {}", f.path().display())); + let _ = writeln!( + std::io::stderr(), + "[DEBUG][CONFIG] - {}", + f.path().display() + ); } } @@ -346,29 +360,35 @@ pub fn run_with_args(args: Args, config: Config, prompter: &impl Prompter) -> io if !large_files.is_empty() { large_files.sort_by_key(|b| std::cmp::Reverse(b.1)); // Sort by size descending - crate::console::errln(format_args!( + let _ = writeln!( + std::io::stderr(), "\n⚠ {} large file(s) detected (>{} KB):", large_files.len(), LARGE_FILE_THRESHOLD / 1024 - )); + ); for (path, size) in large_files.iter().take(5) { - crate::console::errln(format_args!(" {:>8} KB {}", size / 1024, path)); + let _ = writeln!(std::io::stderr(), " {:>8} KB {}", size / 1024, path); } if large_files.len() > 5 { - crate::console::errln(format_args!(" ... and {} more", large_files.len() - 5)); + let _ = writeln!( + std::io::stderr(), + " ... and {} more", + large_files.len() - 5 + ); } - crate::console::errln(format_args!( + let _ = writeln!( + std::io::stderr(), " Total context size: {} KB across {} files\n", total_size / 1024, files.len() - )); + ); } } let file_tree = build_file_tree(&files, base_path); if final_args.preview { if !silent { - crate::console::outln(format_args!("\n# File Tree Structure (Preview)\n")); + let _ = writeln!(std::io::stdout(), "\n# File Tree Structure (Preview)\n"); print_tree(&file_tree, 0); } if !final_args.token_count { @@ -389,7 +409,7 @@ pub fn run_with_args(args: Args, config: Config, prompter: &impl Prompter) -> io file_metadata: final_args.file_metadata, }; let enc_strategy = config.encoding_strategy.as_deref(); - crate::console::outln(format_args!("\n# Token Count Estimation\n")); + let _ = writeln!(std::io::stdout(), "\n# Token Count Estimation\n"); let mut total_tokens = 0; total_tokens += estimate_tokens(encoding, &format!("{}\n\n", markdown::REPORT_TITLE_LINE)); @@ -443,9 +463,13 @@ pub fn run_with_args(args: Args, config: Config, prompter: &impl Prompter) -> io }) .sum(); total_tokens += file_tokens; - crate::console::outln(format_args!("Estimated total tokens: {}", total_tokens)); - crate::console::outln(format_args!("File tree tokens: {}", tree_tokens)); - crate::console::outln(format_args!("File content tokens: {}", file_tokens)); + let _ = writeln!( + std::io::stdout(), + "Estimated total tokens: {}", + total_tokens + ); + let _ = writeln!(std::io::stdout(), "File tree tokens: {}", tree_tokens); + let _ = writeln!(std::io::stdout(), "File content tokens: {}", file_tokens); } return Ok(()); } @@ -457,10 +481,11 @@ pub fn run_with_args(args: Args, config: Config, prompter: &impl Prompter) -> io // B8: --diff-only only takes effect together with auto_diff (+ timestamped // output). Warn instead of silently emitting full file contents. if final_args.diff_only && !config.auto_diff.unwrap_or(false) && !silent { - crate::console::errln(format_args!( + let _ = writeln!( + std::io::stderr(), "⚠️ --diff-only has no effect without auto_diff (it also needs timestamped_output). \ Full file contents will be emitted. Enable auto_diff = true + timestamped_output = true to use diff-only mode." - )); + ); } if config.auto_diff.unwrap_or(false) { @@ -504,10 +529,11 @@ pub fn run_with_args(args: Args, config: Config, prompter: &impl Prompter) -> io Ok(state) => state, Err(e) => { if !silent { - crate::console::errln(format_args!( + let _ = writeln!( + std::io::stderr(), "Warning: Failed to read cache (proceeding without diff): {}", e - )); + ); } None } @@ -533,41 +559,47 @@ pub fn run_with_args(args: Args, config: Config, prompter: &impl Prompter) -> io let debug_autodiff = std::env::var("CB_DEBUG_AUTODIFF").is_ok(); if debug_autodiff { - crate::console::errln(format_args!( + let _ = writeln!( + std::io::stderr(), "[DEBUG][AUTODIFF] cache file: {}", cache_manager.debug_cache_file_path().display() - )); - crate::console::errln(format_args!( + ); + let _ = writeln!( + std::io::stderr(), "[DEBUG][AUTODIFF] config_hash current={} prev={:?} invalidated={}", current_state.config_hash, previous_state.as_ref().map(|s| s.config_hash.clone()), effective_previous.is_none() && previous_state.is_some() - )); - crate::console::errln(format_args!( + ); + let _ = writeln!( + std::io::stderr(), "[DEBUG][AUTODIFF] effective_config: {:?}", effective_config - )); + ); if let Some(prev) = previous_state.as_ref() { - crate::console::errln(format_args!( + let _ = writeln!( + std::io::stderr(), "[DEBUG][AUTODIFF] raw previous files: {}", prev.files.len() - )); + ); } if let Some(prev) = effective_previous { - crate::console::errln(format_args!( + let _ = writeln!( + std::io::stderr(), "[DEBUG][AUTODIFF] effective previous files: {}", prev.files.len() - )); + ); for k in prev.files.keys() { - crate::console::errln(format_args!(" PREV: {}", k.display())); + let _ = writeln!(std::io::stderr(), " PREV: {}", k.display()); } } - crate::console::errln(format_args!( + let _ = writeln!( + std::io::stderr(), "[DEBUG][AUTODIFF] current files: {}", current_state.files.len() - )); + ); for k in current_state.files.keys() { - crate::console::errln(format_args!(" CURR: {}", k.display())); + let _ = writeln!(std::io::stderr(), " CURR: {}", k.display()); } } @@ -663,30 +695,38 @@ pub fn run_with_args(args: Args, config: Config, prompter: &impl Prompter) -> io if let Err(e) = cache_manager.write_cache(¤t_state) && !silent { - crate::console::errln(format_args!("Warning: failed to update state cache: {}", e)); + let _ = writeln!( + std::io::stderr(), + "Warning: failed to update state cache: {}", + e + ); } let duration = start_time.elapsed(); if !silent && !to_stdout { if let Some(comp) = &comparison { if comp.summary.has_changes() { - crate::console::outln(format_args!( + let _ = writeln!( + std::io::stdout(), "Documentation created successfully with {} changes: {}", - comp.summary.total_changes, final_args.output - )); + comp.summary.total_changes, + final_args.output + ); } else { - crate::console::outln(format_args!( + let _ = writeln!( + std::io::stdout(), "Documentation created successfully (no changes detected): {}", final_args.output - )); + ); } } else { - crate::console::outln(format_args!( + let _ = writeln!( + std::io::stdout(), "Documentation created successfully (initial state): {}", final_args.output - )); + ); } - crate::console::outln(format_args!("Processing time: {:.2?}", duration)); + let _ = writeln!(std::io::stdout(), "Processing time: {:.2?}", duration); } if !silent { // Non-blocking. File count is not a cost proxy; this estimate is. @@ -710,13 +750,15 @@ pub fn run_with_args(args: Args, config: Config, prompter: &impl Prompter) -> io if !silent && (ts_config.signatures || ts_config.structure || ts_config.truncate == "smart") { #[cfg(not(feature = "tree-sitter-base"))] { - crate::console::errln(format_args!( + let _ = writeln!( + std::io::stderr(), "⚠️ --signatures/--structure/--truncate smart require tree-sitter support." - )); - crate::console::errln(format_args!( + ); + let _ = writeln!( + std::io::stderr(), " Build with: cargo build --features tree-sitter-all" - )); - crate::console::errln(format_args!(" Falling back to standard output.\n")); + ); + let _ = writeln!(std::io::stderr(), " Falling back to standard output.\n"); } } @@ -738,11 +780,12 @@ pub fn run_with_args(args: Args, config: Config, prompter: &impl Prompter) -> io let duration = start_time.elapsed(); if !silent && !to_stdout { - crate::console::outln(format_args!( + let _ = writeln!( + std::io::stdout(), "Documentation created successfully: {}", final_args.output - )); - crate::console::outln(format_args!("Processing time: {:.2?}", duration)); + ); + let _ = writeln!(std::io::stdout(), "Processing time: {:.2?}", duration); } if !silent { // Non-blocking. File count is not a cost proxy; this estimate is. @@ -774,10 +817,11 @@ fn print_context_window_warning( let estimated_tokens = output_bytes / 4; // Stderr only: this notice must never mix into a captured or piped document. - crate::console::errln(format_args!( + let _ = writeln!( + std::io::stderr(), "Estimated tokens: ~{}K", estimated_tokens / 1000 - )); + ); // If the user already set --max-tokens, they're managing their budget if max_tokens.is_some() { @@ -792,19 +836,21 @@ fn print_context_window_warning( let paths: Vec<&Path> = files.iter().map(|entry| entry.path()).collect(); - crate::console::errln(format_args!("")); - crate::console::errln(format_args!( + let _ = writeln!(std::io::stderr()); + let _ = writeln!( + std::io::stderr(), "⚠️ Output is ~{}K tokens — recommended limit is 128K for effective LLM context.", estimated_tokens / 1000 - )); - crate::console::errln(format_args!( + ); + let _ = writeln!( + std::io::stderr(), " Large contexts degrade response quality. Consider narrowing the scope:" - )); - crate::console::errln(format_args!("")); + ); + let _ = writeln!(std::io::stderr()); for line in context_window_suggestions(&paths) { - crate::console::errln(format_args!(" • {line}")); + let _ = writeln!(std::io::stderr(), " • {line}"); } - crate::console::errln(format_args!("")); + let _ = writeln!(std::io::stderr()); } /// `--filter` extensions to suggest for this run: the most common extensions @@ -1112,19 +1158,23 @@ fn run_matches(matches: &clap::ArgMatches, argc: usize) -> io::Result<()> { if cache_path.exists() { match fs::remove_dir_all(&cache_path) { Ok(()) => { - crate::console::outln(format_args!("Cache cleared: {}", cache_path.display())) + let _ = writeln!(std::io::stdout(), "Cache cleared: {}", cache_path.display()); + } + Err(e) => { + let _ = writeln!( + std::io::stderr(), + "Failed to clear cache ({}): {}", + cache_path.display(), + e + ); } - Err(e) => crate::console::errln(format_args!( - "Failed to clear cache ({}): {}", - cache_path.display(), - e - )), } } else { - crate::console::outln(format_args!( + let _ = writeln!( + std::io::stdout(), "No cache directory found at {}", cache_path.display() - )); + ); } return Ok(()); } @@ -1144,7 +1194,7 @@ fn run_matches(matches: &clap::ArgMatches, argc: usize) -> io::Result<()> { if !silent { for warning in &resolution.warnings { - crate::console::errln(format_args!("Warning: {}", warning)); + let _ = writeln!(std::io::stderr(), "Warning: {}", warning); } } @@ -1224,13 +1274,15 @@ fn init_config() -> io::Result<()> { let config_path = Path::new("context-builder.toml"); if config_path.exists() { - crate::console::outln(format_args!( + let _ = writeln!( + std::io::stdout(), "Config file already exists at {}", config_path.display() - )); - crate::console::outln(format_args!( + ); + let _ = writeln!( + std::io::stdout(), "If you want to replace it, please remove it manually first." - )); + ); return Ok(()); } @@ -1299,17 +1351,20 @@ file_metadata = false let mut file = File::create(config_path)?; file.write_all(default_config_content.as_bytes())?; - crate::console::outln(format_args!( + let _ = writeln!( + std::io::stdout(), "Config file created at {}", config_path.display() - )); - crate::console::outln(format_args!( + ); + let _ = writeln!( + std::io::stdout(), "Detected file types: {}", filter_suggestions.join(", ") - )); - crate::console::outln(format_args!( + ); + let _ = writeln!( + std::io::stdout(), "You can now customize it according to your project needs." - )); + ); Ok(()) } diff --git a/src/markdown.rs b/src/markdown.rs index bc5540f..df72c13 100644 --- a/src/markdown.rs +++ b/src/markdown.rs @@ -1019,12 +1019,14 @@ pub fn write_tree_sitter_enrichment( // eprintln (not warn!/log) so it is visible without RUST_LOG — // env_logger defaults to error level, and a user-style default run // would otherwise never see this warning. - crate::console::errln(format_args!( + let _ = writeln!( + std::io::stderr(), "⚠️ --visibility {} has no effect for '{}' files: the extractor \ does not classify visibility yet (planned for v0.11). \ Signatures will include all symbols.", - ts_config.visibility, extension - )); + ts_config.visibility, + extension + ); } if ts_config.structure diff --git a/src/tree.rs b/src/tree.rs index c0a6060..e617242 100644 --- a/src/tree.rs +++ b/src/tree.rs @@ -59,10 +59,10 @@ pub fn print_tree(tree: &FileTree, depth: usize) { let indent = " ".repeat(depth); match node { FileNode::File => { - crate::console::outln(format_args!("{}- 📄 {}", indent, name)); + let _ = writeln!(std::io::stdout(), "{}- 📄 {}", indent, name); } FileNode::Directory(children) => { - crate::console::outln(format_args!("{}- 📁 {}", indent, name)); + let _ = writeln!(std::io::stdout(), "{}- 📁 {}", indent, name); print_tree(children, depth + 1); } }