diff --git a/CHANGELOG.md b/CHANGELOG.md index c4996ef..04750be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file. ## Unreleased +- **Diagnostics (#21: B13, B17, B18).** The tree-sitter notice no longer prints on every default run: only for `--signatures`/`--structure`, or `--truncate smart` with `--max-tokens`, and it suggests `cargo install context-builder --features tree-sitter-all`. Errors print once as `Error: ` (not Debug), and output-path errors name the path. In builds without tree-sitter, truncation notices say `mode: byte`. - **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" diff --git a/src/lib.rs b/src/lib.rs index a3ebb85..0ae5a90 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -209,13 +209,6 @@ pub fn run_with_args(args: Args, config: Config, prompter: &impl Prompter) -> io let base_path = resolved_base.as_path(); if !base_path.exists() || !base_path.is_dir() { - if !silent { - 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, format!( @@ -242,9 +235,7 @@ pub fn run_with_args(args: Args, config: Config, prompter: &impl Prompter) -> io && !final_args.yes && !prompter.confirm_overwrite(&final_args.output)? { - if !silent { - let _ = writeln!(std::io::stderr(), "Operation cancelled."); - } + // No separate message: `main` prints the error once. return Err(io::Error::new( io::ErrorKind::Interrupted, "Operation cancelled by user", @@ -488,6 +479,27 @@ pub fn run_with_args(args: Args, config: Config, prompter: &impl Prompter) -> io ); } + // Graceful degradation (before the auto-diff branch so both paths warn): warn if tree-sitter flags are used without the feature. + // `--truncate` defaults to "smart", so it only matters (and only warns) when a + // token budget is actually being applied; a plain default run stays quiet. + #[cfg_attr(feature = "tree-sitter-base", allow(unused_variables))] + let smart_truncate_requested = + final_args.truncate == "smart" && final_args.max_tokens.is_some(); + if !silent && (final_args.signatures || final_args.structure || smart_truncate_requested) { + #[cfg(not(feature = "tree-sitter-base"))] + { + let _ = writeln!( + std::io::stderr(), + "⚠️ --signatures/--structure/--truncate smart require tree-sitter support." + ); + let _ = writeln!( + std::io::stderr(), + " Install with: cargo install context-builder --features tree-sitter-all" + ); + let _ = writeln!(std::io::stderr(), " Falling back to standard output.\n"); + } + } + if config.auto_diff.unwrap_or(false) { // Build an effective config that mirrors the *actual* file selection coming // from resolved CLI args, so the cache/diff fingerprint reflects real @@ -687,8 +699,11 @@ pub fn run_with_args(args: Args, config: Config, prompter: &impl Prompter) -> io e ))); } - let mut final_output = fs::File::create(output_path)?; - final_output.write_all(final_doc.as_bytes())?; + let mut final_output = + fs::File::create(output_path).map_err(|e| write_error(output_path, e))?; + final_output + .write_all(final_doc.as_bytes()) + .map_err(|e| write_error(output_path, e))?; } // 6. Update cache with current state @@ -741,27 +756,17 @@ pub fn run_with_args(args: Args, config: Config, prompter: &impl Prompter) -> io let ts_config = markdown::TreeSitterConfig { signatures: final_args.signatures, structure: final_args.structure, - truncate: final_args.truncate.clone(), + // Without tree-sitter support "smart" truncation cannot happen; report the + // mode that is actually used (byte) in truncation notices. + truncate: if cfg!(feature = "tree-sitter-base") { + final_args.truncate.clone() + } else { + "byte".to_string() + }, visibility: final_args.visibility.clone(), file_metadata: final_args.file_metadata, }; - // Graceful degradation: warn if tree-sitter flags are used without the feature - if !silent && (ts_config.signatures || ts_config.structure || ts_config.truncate == "smart") { - #[cfg(not(feature = "tree-sitter-base"))] - { - let _ = writeln!( - std::io::stderr(), - "⚠️ --signatures/--structure/--truncate smart require tree-sitter support." - ); - let _ = writeln!( - std::io::stderr(), - " Build with: cargo build --features tree-sitter-all" - ); - let _ = writeln!(std::io::stderr(), " Falling back to standard output.\n"); - } - } - let output_bytes = generate_markdown( &final_args.output, &final_args.input, @@ -1089,6 +1094,14 @@ fn generate_markdown_with_diff( Ok(output) } +/// An output-path error naming the path (the bare OS error does not). +pub(crate) fn write_error(path: &Path, err: io::Error) -> io::Error { + io::Error::new( + err.kind(), + format!("cannot write '{}': {err}", path.display()), + ) +} + pub fn run() -> io::Result<()> { env_logger::init(); // Parse via `ArgMatches` (not `Args::parse`) so we can tell whether the diff --git a/src/main.rs b/src/main.rs index 6c9810f..5f6e650 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,13 @@ -use std::io; +use std::io::{self, Write}; +use std::process::ExitCode; -fn main() -> io::Result<()> { - context_builder::finish(context_builder::run()) +fn main() -> ExitCode { + match context_builder::finish(context_builder::run()) { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + // Once, human-readable, and never a panic if stderr is closed. + let _ = writeln!(io::stderr(), "Error: {e}"); + ExitCode::FAILURE + } + } } diff --git a/src/markdown.rs b/src/markdown.rs index df72c13..1c7b653 100644 --- a/src/markdown.rs +++ b/src/markdown.rs @@ -43,17 +43,28 @@ pub struct TreeSitterConfig { struct CountingWriter { inner: W, count: usize, + /// Output file, so a failed write names it (`None` for stdout). + path: Option, +} + +impl CountingWriter { + fn with_path(&self, e: io::Error) -> io::Error { + match &self.path { + Some(path) => crate::write_error(path, e), + None => e, + } + } } impl Write for CountingWriter { fn write(&mut self, buf: &[u8]) -> io::Result { - let n = self.inner.write(buf)?; + let n = self.inner.write(buf).map_err(|e| self.with_path(e))?; self.count += n; Ok(n) } fn flush(&mut self) -> io::Result<()> { - self.inner.flush() + self.inner.flush().map_err(|e| self.with_path(e)) } } @@ -84,11 +95,18 @@ pub fn generate_markdown( if let Some(parent) = Path::new(output_path).parent() && !parent.exists() { - fs::create_dir_all(parent)?; + fs::create_dir_all(parent).map_err(|e| crate::write_error(parent, e))?; } - Box::new(fs::File::create(output_path)?) + Box::new( + fs::File::create(output_path) + .map_err(|e| crate::write_error(Path::new(output_path), e))?, + ) + }; + let mut output = CountingWriter { + inner, + count: 0, + path: (output_path != "-").then(|| std::path::PathBuf::from(output_path)), }; - let mut output = CountingWriter { inner, count: 0 }; let input_dir_name = if input_dir == "." { let current_dir = std::env::current_dir()?; @@ -1517,6 +1535,7 @@ mod tests { let mut writer = CountingWriter { inner: Vec::new(), count: 0, + path: None, }; writer.write_all(b"hello ").unwrap(); writer.write_all(b"world").unwrap(); @@ -2925,6 +2944,62 @@ mod tests { } } +#[cfg(test)] +mod counting_writer_error_tests { + use super::*; + + struct Failing; + + impl Write for Failing { + fn write(&mut self, _: &[u8]) -> io::Result { + Err(io::Error::from(io::ErrorKind::StorageFull)) + } + fn flush(&mut self) -> io::Result<()> { + Err(io::Error::from(io::ErrorKind::StorageFull)) + } + } + + #[test] + fn failed_write_and_flush_name_the_output_file_and_keep_the_kind() { + let mut w = CountingWriter { + inner: Failing, + count: 0, + path: Some(std::path::PathBuf::from("out/report.md")), + }; + for err in [w.write(b"x").unwrap_err(), w.flush().unwrap_err()] { + assert_eq!(err.kind(), io::ErrorKind::StorageFull); + assert!( + err.to_string().contains("cannot write 'out/report.md'"), + "{err}" + ); + } + assert_eq!(w.count, 0); + } + + #[test] + fn stdout_writer_errors_pass_through_unchanged() { + let mut w = CountingWriter { + inner: Failing, + count: 0, + path: None, + }; + let err = w.write(b"x").unwrap_err(); + assert!(!err.to_string().contains("cannot write"), "{err}"); + } + + #[test] + fn successful_writes_are_counted() { + let mut w = CountingWriter { + inner: Vec::new(), + count: 0, + path: None, + }; + w.write_all(b"hello").unwrap(); + w.flush().unwrap(); + assert_eq!(w.count, 5); + } +} + #[cfg(test)] mod write_failure_tests { use super::*; diff --git a/tests/test_diagnostics.rs b/tests/test_diagnostics.rs new file mode 100644 index 0000000..31ca6ae --- /dev/null +++ b/tests/test_diagnostics.rs @@ -0,0 +1,117 @@ +//! Diagnostics of the built binary: warnings only when relevant, errors once +//! and readable (#21: B13, B17, B18). + +use std::fs; +use std::path::Path; +use std::process::{Command, Output}; + +fn run(dir: &Path, args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_context-builder")) + .args(args) + .current_dir(dir) + .output() + .unwrap() +} + +fn text(bytes: &[u8]) -> String { + String::from_utf8_lossy(bytes).into_owned() +} + +fn project() -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("a.rs"), "fn a() {}\n").unwrap(); + dir +} + +#[test] +fn plain_run_does_not_mention_tree_sitter() { + let dir = project(); + let out = run(dir.path(), &["-o", "-"]); + assert!(out.status.success()); + let stderr = text(&out.stderr); + assert!(!stderr.contains("tree-sitter"), "{stderr}"); +} + +#[cfg(not(feature = "tree-sitter-base"))] +#[test] +fn tree_sitter_warning_only_for_flags_that_need_it_and_suggests_install() { + let dir = project(); + let signatures = text(&run(dir.path(), &["-o", "-", "--signatures"]).stderr); + assert!( + signatures.contains("require tree-sitter support"), + "{signatures}" + ); + assert!( + signatures.contains("cargo install context-builder --features tree-sitter-all"), + "{signatures}" + ); + assert!(!signatures.contains("cargo build"), "{signatures}"); + + let budget = text(&run(dir.path(), &["-o", "-", "--max-tokens", "5000"]).stderr); + assert!(budget.contains("require tree-sitter support"), "{budget}"); +} + +#[cfg(not(feature = "tree-sitter-base"))] +#[test] +fn truncation_notice_reports_the_effective_mode() { + let dir = tempfile::tempdir().unwrap(); + let body = "pub fn filler() { let _ = 1; }\n".repeat(2_000); + fs::write(dir.path().join("big.rs"), body).unwrap(); + let out = run(dir.path(), &["-o", "-", "--max-tokens", "2000"]); + let doc = text(&out.stdout); + assert!(doc.contains("File content truncated"), "{doc}"); + assert!(doc.contains("(mode: byte)"), "{doc}"); + assert!(!doc.contains("(mode: smart)"), "{doc}"); +} + +#[test] +fn missing_input_dir_error_is_printed_once_and_readable() { + let dir = tempfile::tempdir().unwrap(); + let out = run(dir.path(), &["-d", "no-such-dir"]); + assert_eq!(out.status.code(), Some(1)); + let stderr = text(&out.stderr); + assert_eq!(stderr.matches("Error:").count(), 1, "{stderr}"); + assert!(stderr.contains("no-such-dir"), "{stderr}"); + assert!(!stderr.contains("Custom {"), "{stderr}"); +} + +#[test] +fn unwritable_output_error_names_the_path_without_debug_formatting() { + let dir = project(); + // A regular file used as a directory component cannot be created under. + fs::write(dir.path().join("blocker"), "x").unwrap(); + let out = run(dir.path(), &["-o", "blocker/out.md"]); + assert_eq!(out.status.code(), Some(1)); + let stderr = text(&out.stderr); + assert_eq!(stderr.matches("Error:").count(), 1, "{stderr}"); + assert!(stderr.contains("blocker"), "{stderr}"); + assert!(!stderr.contains("Os {"), "{stderr}"); + assert!(!stderr.contains("Custom {"), "{stderr}"); +} + +#[cfg(unix)] +#[test] +fn write_failure_after_open_names_the_output_path() { + let dir = project(); + // `/dev/full` opens fine and fails every write with ENOSPC. + if !Path::new("/dev/full").exists() { + return; + } + let out = run(dir.path(), &["-o", "/dev/full"]); + assert_eq!(out.status.code(), Some(1)); + let stderr = text(&out.stderr); + assert!(stderr.contains("/dev/full"), "{stderr}"); +} + +#[cfg(not(feature = "tree-sitter-base"))] +#[test] +fn auto_diff_runs_also_get_the_tree_sitter_notice() { + let dir = project(); + fs::write( + dir.path().join("context-builder.toml"), + "output_folder = \"out\"\ntimestamped_output = true\nauto_diff = true\n", + ) + .unwrap(); + let stderr = text(&run(dir.path(), &["-y", "--signatures"]).stderr); + assert!(stderr.contains("require tree-sitter support"), "{stderr}"); +}