Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
691f6ff
fix: don't abort when stderr or stdout is closed early (#40)
igorls Oct 11, 2026
7dc748b
test: don't depend on path separator in closed-pipe test
igorls Oct 11, 2026
4e96ed0
fix: treat BrokenPipe on the truncation notice like other writes (rev…
igorls Oct 11, 2026
a83ffbe
fix: quieter tree-sitter notice, single readable errors, effective tr…
igorls Oct 11, 2026
e61208b
fix: path in write errors, tree-sitter notice for auto-diff, single c…
igorls Oct 11, 2026
4572d97
test: in-process console/closed-pipe coverage; testable BrokenPipe he…
igorls Oct 11, 2026
6c4024a
test: CountingWriter names the output file on failure (#21)
igorls Oct 11, 2026
c5b97cb
Merge fix/40-closed-stderr into fix/21-diagnostics
igorls Oct 11, 2026
6e22561
refactor: console macros call functions so call sites keep coverage a…
igorls Oct 11, 2026
cfe785e
refactor: console helpers are functions, not macros, so call sites ke…
igorls Oct 11, 2026
455cec2
test: cover debug tracing, diff-only warning and cache-write warning …
igorls Oct 11, 2026
87470ef
test: make the command-line path runnable in-process (run_from) and c…
igorls Oct 11, 2026
b66edff
Merge branch 'test/run-in-process' into fix/40-closed-stderr
igorls Oct 11, 2026
bdbeebb
Merge fix/40-closed-stderr into fix/21-diagnostics
igorls Oct 11, 2026
f24786b
fix: single readable errors, quieter tree-sitter notice, path in writ…
igorls Oct 11, 2026
affcc9c
merge: supersede the stacked history with the master-based change
igorls Oct 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <message>` (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"
Expand Down
71 changes: 42 additions & 29 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
Expand All @@ -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",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
14 changes: 11 additions & 3 deletions src/main.rs
Original file line number Diff line number Diff line change
@@ -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}");
Comment thread
igorls marked this conversation as resolved.
ExitCode::FAILURE
}
}
}
85 changes: 80 additions & 5 deletions src/markdown.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,17 +43,28 @@ pub struct TreeSitterConfig {
struct CountingWriter<W> {
inner: W,
count: usize,
/// Output file, so a failed write names it (`None` for stdout).
path: Option<std::path::PathBuf>,
}

impl<W> CountingWriter<W> {
fn with_path(&self, e: io::Error) -> io::Error {
match &self.path {
Some(path) => crate::write_error(path, e),
None => e,
}
}
}

impl<W: Write> Write for CountingWriter<W> {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
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))
}
}

Expand Down Expand Up @@ -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))?,
)
Comment thread
igorls marked this conversation as resolved.
};
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()?;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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<usize> {
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::*;
Expand Down
117 changes: 117 additions & 0 deletions tests/test_diagnostics.rs
Original file line number Diff line number Diff line change
@@ -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}");
}
Loading