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 1/4] 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 4ffcb9676eb898a94668ebc7f5aca8f24554641f Mon Sep 17 00:00:00 2001 From: Igor Lins e Silva <4753812+igorls@users.noreply.github.com> Date: Sun, 11 Oct 2026 12:32:25 -0300 Subject: [PATCH 2/4] fix: skip binary files with a Skipped entry; header names the directory, not its path (#21 P11) --- CHANGELOG.md | 4 + src/content_filter.rs | 27 +++++++ src/lib.rs | 4 +- src/markdown.rs | 24 +++--- tests/test_binary_and_header.rs | 127 ++++++++++++++++++++++++++++++++ tests/test_parallel_memory.rs | 6 +- 6 files changed, 177 insertions(+), 15 deletions(-) create mode 100644 tests/test_binary_and_header.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cdccd2..8a48965 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ All notable changes to this project will be documented in this file. +## Unreleased + +- **Binary files are reported, not dumped; the header no longer leaks absolute paths (#21 P11).** A file with NUL bytes in its first 8 KiB (UTF-16/32 text with a BOM excepted) is listed under `## Skipped` as `binary` instead of a `` block in the body (a repo without `.git` produced hundreds of those). The header says `from the `my proj` directory` rather than the absolute path passed with `-d`. + ## 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/content_filter.rs b/src/content_filter.rs index fcfec51..77ea8bd 100644 --- a/src/content_filter.rs +++ b/src/content_filter.rs @@ -222,6 +222,10 @@ pub enum SkipReason { TooLarge, /// Filename (or `.npmrc` / `.pypirc` contents) looks like a credential. Secret, + /// Not a known asset type, but the content is binary (NUL bytes in the + /// first 8 KiB, outside UTF-16/32 text). Rendering it would only print a + /// placeholder block. + Binary, } impl SkipReason { @@ -231,6 +235,7 @@ impl SkipReason { SkipReason::Asset => "asset", SkipReason::TooLarge => "too large", SkipReason::Secret => "secret", + SkipReason::Binary => "binary", } } } @@ -395,9 +400,26 @@ pub fn classify( { return Some((SkipReason::TooLarge, "too large")); } + if looks_binary(path) { + return Some((SkipReason::Binary, "binary")); + } None } +/// Sniff the start of the file. Unreadable files are not reported as binary +/// (the renderer deals with them). +fn looks_binary(path: &Path) -> bool { + use std::io::Read; + let Ok(file) = std::fs::File::open(path) else { + return false; + }; + let mut head = Vec::with_capacity(8192); + if file.take(8192).read_to_end(&mut head).is_err() { + return false; + } + crate::markdown::is_binary_content(&head) +} + /// Stderr lines for a skip list: one warning per secret (path and category /// only), then a single summary line. Empty when nothing was skipped. pub fn report_lines(skipped: &[SkippedFile]) -> Vec { @@ -453,11 +475,13 @@ pub fn summary_line(skipped: &[SkippedFile]) -> Option { let mut assets = 0usize; let mut large = 0usize; let mut secrets = 0usize; + let mut binaries = 0usize; for item in skipped { match item.reason { SkipReason::Asset => assets += 1, SkipReason::TooLarge => large += 1, SkipReason::Secret => secrets += 1, + SkipReason::Binary => binaries += 1, } } let mut parts = Vec::new(); @@ -470,6 +494,9 @@ pub fn summary_line(skipped: &[SkippedFile]) -> Option { if secrets > 0 { parts.push(count_phrase(secrets, "secret", "secrets")); } + if binaries > 0 { + parts.push(count_phrase(binaries, "binary", "binary")); + } let noun = if skipped.len() == 1 { "file" } else { "files" }; Some(format!( "Skipped {} {} ({}).", diff --git a/src/lib.rs b/src/lib.rs index 1dc6e65..cfbdb49 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -377,7 +377,7 @@ pub fn run_with_args(args: Args, config: Config, prompter: &impl Prompter) -> io encoding, &format!( "This document contains files from the {} directory with extensions: {} \n", - fences::inline_code(&final_args.input), + fences::inline_code(&markdown::directory_display_name(&final_args.input)), final_args.filter.join(", ") ), ); @@ -386,7 +386,7 @@ pub fn run_with_args(args: Args, config: Config, prompter: &impl Prompter) -> io encoding, &format!( "This document contains all files from the {} directory, optimized for LLM consumption.\n", - fences::inline_code(&final_args.input) + fences::inline_code(&markdown::directory_display_name(&final_args.input)) ), ); } diff --git a/src/markdown.rs b/src/markdown.rs index 607c5b0..80d28c5 100644 --- a/src/markdown.rs +++ b/src/markdown.rs @@ -90,16 +90,7 @@ pub fn generate_markdown( }; let mut output = CountingWriter { inner, count: 0 }; - let input_dir_name = if input_dir == "." { - let current_dir = std::env::current_dir()?; - current_dir - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or_else(|| current_dir.to_str().unwrap_or("project")) - .to_string() - } else { - input_dir.to_string() - }; + let input_dir_name = directory_display_name(input_dir); // --- Header + file tree (buffered) --- // // Build the header and tree into a buffer first so we can (a) write them in @@ -1039,6 +1030,19 @@ fn first_visibility_warning_for(ext: &str) -> bool { guard.insert(ext.to_string()) } +/// The name shown for the input directory in the document header: its last +/// path component, never the absolute path (which leaks the user's home +/// directory and username into a document that is meant to be shared). +pub(crate) fn directory_display_name(input_dir: &str) -> String { + let path = Path::new(input_dir); + let resolved = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); + resolved + .file_name() + .and_then(|n| n.to_str()) + .map(str::to_string) + .unwrap_or_else(|| input_dir.to_string()) +} + /// Bytes read from the start of a file when deciding text vs binary. const BINARY_SNIFF_LEN: usize = 8192; diff --git a/tests/test_binary_and_header.rs b/tests/test_binary_and_header.rs new file mode 100644 index 0000000..4fd47a9 --- /dev/null +++ b/tests/test_binary_and_header.rs @@ -0,0 +1,127 @@ +//! Binary files are reported, not dumped as placeholder blocks, and the header +//! names the directory instead of leaking an absolute path (#21 P11). + +use context_builder::cli::Args; +use context_builder::config::Config; +use context_builder::content_filter::{ContentPolicy, SkipReason, classify}; +use context_builder::{Prompter, run_with_args}; +use std::fs; +use std::path::Path; +use std::process::Command; + +struct Yes; + +impl Prompter for Yes { + fn confirm_overwrite(&self, _file_path: &str) -> std::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: "256K".to_string(), + hidden: false, + include_secrets: false, + include_lockfiles: false, + } +} + +fn project() -> tempfile::TempDir { + let dir = tempfile::Builder::new() + .prefix("my proj ") + .tempdir() + .unwrap(); + fs::write(dir.path().join("a.txt"), "hello\n").unwrap(); + // Not a known asset extension, but clearly binary. + fs::write(dir.path().join("blob.dat"), [0u8, 1, 2, 3, 0, 0, 255, 254]).unwrap(); + // NUL-padded UTF-16 with a BOM is text. + let mut utf16 = vec![0xFF, 0xFE]; + for c in "wide text\n".encode_utf16() { + utf16.extend_from_slice(&c.to_le_bytes()); + } + fs::write(dir.path().join("wide.txt"), utf16).unwrap(); + dir +} + +#[test] +fn classify_marks_nul_content_as_binary_but_not_utf16_or_plain_text() { + let dir = project(); + let policy = ContentPolicy::new("256K", &[], false); + let verdict = |name: &str| { + let path = dir.path().join(name); + classify(&path, fs::metadata(&path).unwrap().len(), &policy).map(|(r, _)| r) + }; + assert_eq!(verdict("blob.dat"), Some(SkipReason::Binary)); + assert_eq!(verdict("a.txt"), None); + assert_eq!(verdict("wide.txt"), None); + // A path that cannot be opened is left to the renderer. + assert_eq!(classify(&dir.path().join("missing.dat"), 10, &policy), None); +} + +#[test] +fn document_skips_binary_in_the_body_and_lists_it() { + let dir = project(); + let out = dir.path().join("out.md"); + run_with_args(args(dir.path(), &out), Config::default(), &Yes).unwrap(); + let doc = fs::read_to_string(&out).unwrap(); + assert!(doc.contains("- `blob.dat` — binary"), "{doc}"); + assert!(!doc.contains("### File: `blob.dat`"), "{doc}"); + assert!(!doc.contains(" Date: Sun, 11 Oct 2026 16:41:25 -0300 Subject: [PATCH 3/4] fix: lexical directory name in the header; plural 'binaries' --- src/content_filter.rs | 2 +- src/markdown.rs | 19 +++++++++----- tests/test_binary_and_header.rs | 46 +++++++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 7 deletions(-) diff --git a/src/content_filter.rs b/src/content_filter.rs index 77ea8bd..3c4e80e 100644 --- a/src/content_filter.rs +++ b/src/content_filter.rs @@ -495,7 +495,7 @@ pub fn summary_line(skipped: &[SkippedFile]) -> Option { parts.push(count_phrase(secrets, "secret", "secrets")); } if binaries > 0 { - parts.push(count_phrase(binaries, "binary", "binary")); + parts.push(count_phrase(binaries, "binary", "binaries")); } let noun = if skipped.len() == 1 { "file" } else { "files" }; Some(format!( diff --git a/src/markdown.rs b/src/markdown.rs index 80d28c5..79b8330 100644 --- a/src/markdown.rs +++ b/src/markdown.rs @@ -1034,13 +1034,20 @@ fn first_visibility_warning_for(ext: &str) -> bool { /// path component, never the absolute path (which leaks the user's home /// directory and username into a document that is meant to be shared). pub(crate) fn directory_display_name(input_dir: &str) -> String { + use std::path::Component; let path = Path::new(input_dir); - let resolved = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); - resolved - .file_name() - .and_then(|n| n.to_str()) - .map(str::to_string) - .unwrap_or_else(|| input_dir.to_string()) + // The lexical name the user typed wins (a symlink keeps its own name). + let last = path.components().next_back(); + if let Some(Component::Normal(name)) = last + && let Some(name) = name.to_str() + { + return name.to_string(); + } + // `.`, `..`, `/`, `C:\`: resolve, and never fall back to a path. + fs::canonicalize(path) + .ok() + .and_then(|p| p.file_name().and_then(|n| n.to_str().map(str::to_string))) + .unwrap_or_else(|| "project".to_string()) } /// Bytes read from the start of a file when deciding text vs binary. diff --git a/tests/test_binary_and_header.rs b/tests/test_binary_and_header.rs index 4fd47a9..4f502cd 100644 --- a/tests/test_binary_and_header.rs +++ b/tests/test_binary_and_header.rs @@ -125,3 +125,49 @@ fn binary_prints_summary_with_binary_count_and_clean_header() { assert!(!doc.contains(&parent), "{doc}"); assert!(!doc.contains(" Date: Mon, 12 Oct 2026 06:35:41 -0300 Subject: [PATCH 4/4] test: add stats field to Args literal --- tests/test_binary_and_header.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_binary_and_header.rs b/tests/test_binary_and_header.rs index 4f502cd..3f0ef00 100644 --- a/tests/test_binary_and_header.rs +++ b/tests/test_binary_and_header.rs @@ -41,6 +41,7 @@ fn args(input: &Path, output: &Path) -> Args { hidden: false, include_secrets: false, include_lockfiles: false, + stats: None, } }