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/7] 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 fb401bc35539e132bf54e1157463059a08c965b5 Mon Sep 17 00:00:00 2001 From: Igor Lins e Silva <4753812+igorls@users.noreply.github.com> Date: Sun, 11 Oct 2026 12:25:02 -0300 Subject: [PATCH 2/7] fix: auto-diff budgets --max-tokens per file with the real tokenizer (#20 B14) --- CHANGELOG.md | 4 + src/lib.rs | 169 +++++++++++++++++--------- src/markdown.rs | 70 ++++++----- tests/test_autodiff_budget.rs | 221 ++++++++++++++++++++++++++++++++++ 4 files changed, 377 insertions(+), 87 deletions(-) create mode 100644 tests/test_autodiff_budget.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cdccd2..b54a57d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ All notable changes to this project will be documented in this file. +## Unreleased + +- **Auto-diff honors `--max-tokens` like the standard renderer (#20 B14).** The auto-diff path used a bytes/4 cut over the finished document (mid-file, ignoring `--encoding`, omissions unreported). Files are now budgeted one by one with the real tokenizer: a file that would cross the budget is clamped at a `--truncate` boundary (and says so) or omitted, and the notice counts the omitted files. The budget search is shared (`markdown::fit_chunk_to_budget`) with the standard path. + ## 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/lib.rs b/src/lib.rs index 1dc6e65..cfd7079 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -588,12 +588,12 @@ pub fn run_with_args(args: Args, config: Config, prompter: &impl Prompter) -> io &skipped, )?; - // Enforce max_tokens budget (same ~4 bytes/token heuristic as parallel path) + // Files are budgeted one by one inside the renderer. This is only the + // safety net for a header/diff/tree that alone exceeds the budget. if let Some(max_tokens) = final_args.max_tokens { - let max_bytes = max_tokens.saturating_mul(4); - if final_doc.len() > max_bytes { - // Truncate at a valid UTF-8 boundary - let mut truncate_at = max_bytes; + let encoding = final_args.encoding.parse::().unwrap_or_default(); + if estimate_tokens(encoding, &final_doc) > max_tokens { + let mut truncate_at = final_doc.len().min(max_tokens.saturating_mul(4)); while truncate_at > 0 && !final_doc.is_char_boundary(truncate_at) { truncate_at -= 1; } @@ -607,7 +607,7 @@ pub fn run_with_args(args: Args, config: Config, prompter: &impl Prompter) -> io final_doc.push_str("\n---\n\n"); final_doc.push_str(&format!( - "_Output truncated: exceeded {} token budget (estimated)._\n", + "_Output truncated: exceeded {} token budget._\n", max_tokens )); } @@ -945,62 +945,123 @@ fn generate_markdown_with_diff( if !diff_config.diff_only { output.push_str("## File Contents\n\n"); + // Same per-file, tokenizer-based budget as the standard renderer: a file + // that would cross `--max-tokens` is clamped to what is left (or omitted) + // and the rest are reported, instead of cutting the document mid-file. + let budget = args.max_tokens.map(|max| { + let encoding = args.encoding.parse::().unwrap_or_default(); + (max, encoding, estimate_tokens(encoding, &output)) + }); + let mut tokens_used = budget.map_or(0, |(_, _, used)| used); + // Iterate in relevance order (from sorted_paths) instead of // BTreeMap's alphabetical order — preserves file_relevance_category ordering. - for path in sorted_paths { - if let Some(file_state) = current_state.files.get(path) { - output.push_str(&format!( - "### File: {}\n\n", - fences::inline_code(&path.display().to_string()) - )); - // Same opt-in as the standard renderer. Off by default so an - // mtime-only change does not rewrite the document. The cache - // compares content hashes (file bytes), not mtime. - if args.file_metadata { - output.push_str(&format!("- Size: {} bytes\n", file_state.size)); - output.push_str(&format!("- Modified: {:?}\n\n", file_state.modified)); - } + let present: Vec<(&PathBuf, &state::FileState)> = sorted_paths + .iter() + .filter_map(|path| current_state.files.get(path).map(|state| (path, state))) + .collect(); + for (idx, (path, file_state)) in present.iter().enumerate() { + let section = diff_file_section(path, file_state, args, ts_config, None)?; + let Some((max, encoding, _)) = budget else { + output.push_str(§ion); + continue; + }; + let section_tokens = estimate_tokens(encoding, §ion); + if tokens_used + section_tokens <= max { + tokens_used += section_tokens; + output.push_str(§ion); + continue; + } + // Crossing file: clamp it into the remaining allowance, else omit it. + let remaining_tokens = max.saturating_sub(tokens_used); + let fitted = markdown::fit_chunk_to_budget(remaining_tokens, encoding, |limit| { + diff_file_section(path, file_state, args, ts_config, limit).ok() + }); + let emitted = fitted.is_some(); + if let Some((chunk, _)) = fitted { + output.push_str(&chunk); + } + output.push_str("---\n\n"); + output.push_str(&format!( + "_⚠️ Token budget ({}) reached. {} remaining files omitted._\n\n", + max, + present.len() - idx - usize::from(emitted) + )); + break; + } + } - // Determine language from file extension (canonical map — - // same fence language as the main rendering path) - let extension = path.extension().and_then(|s| s.to_str()).unwrap_or("text"); - let language = crate::languages::language_for_extension(extension); - - // When --signatures is active, only suppress content for supported code files - let signatures_only = - ts_config.signatures && crate::tree_sitter::is_supported_extension(extension); - - if !signatures_only { - // `output` is a String (`fmt::Write`); the shared writer speaks `io::Write`. - let mut rendered = Vec::new(); - markdown::write_text_content( - &mut rendered, - &file_state.content, - language, - args.line_numbers, - )?; - let rendered = std::str::from_utf8(&rendered) - .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; - output.push_str(rendered); - } + Ok(output) +} - // Tree-sitter enrichment (same as standard path) - let mut enrichment_buf = Vec::new(); - markdown::write_tree_sitter_enrichment( - &mut enrichment_buf, - &file_state.content, - extension, - ts_config, - )?; - if !enrichment_buf.is_empty() { - output.push_str(&String::from_utf8_lossy(&enrichment_buf)); - } +/// One `### File:` section of the auto-diff document. `content_limit` clamps +/// the file's content to that many bytes (at a boundary chosen by `--truncate`) +/// and says so, like the standard renderer does for a file that crosses the +/// token budget. +fn diff_file_section( + path: &Path, + file_state: &state::FileState, + args: &Args, + ts_config: &markdown::TreeSitterConfig, + content_limit: Option, +) -> io::Result { + let mut output = String::new(); + output.push_str(&format!( + "### File: {}\n\n", + fences::inline_code(&path.display().to_string()) + )); + // Same opt-in as the standard renderer. Off by default so an + // mtime-only change does not rewrite the document. The cache + // compares content hashes (file bytes), not mtime. + if args.file_metadata { + output.push_str(&format!("- Size: {} bytes\n", file_state.size)); + output.push_str(&format!("- Modified: {:?}\n\n", file_state.modified)); + } + + // Determine language from file extension (canonical map — + // same fence language as the main rendering path) + let extension = path.extension().and_then(|s| s.to_str()).unwrap_or("text"); + let language = crate::languages::language_for_extension(extension); + + let full = file_state.content.as_str(); + let (content, truncated) = match content_limit { + Some(limit) if full.len() > limit => { + let cut = + markdown::content_cut_point(full, limit, extension, ts_config.truncate.as_str()); + (&full[..cut], cut < full.len()) + } + _ => (full, false), + }; - output.push('\n'); - } + // When --signatures is active, only suppress content for supported code files + let signatures_only = + ts_config.signatures && crate::tree_sitter::is_supported_extension(extension); + + if !signatures_only { + // `output` is a String (`fmt::Write`); the shared writer speaks `io::Write`. + let mut rendered = Vec::new(); + markdown::write_text_content(&mut rendered, content, language, args.line_numbers)?; + let rendered = std::str::from_utf8(&rendered) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + output.push_str(rendered); + if truncated { + output.push_str(&format!( + "\n_⚠️ File content truncated: {} of {} bytes (mode: {})._\n\n", + content.len(), + full.len(), + ts_config.truncate + )); } } + // Tree-sitter enrichment (same as standard path), over what is displayed + let mut enrichment_buf = Vec::new(); + markdown::write_tree_sitter_enrichment(&mut enrichment_buf, content, extension, ts_config)?; + if !enrichment_buf.is_empty() { + output.push_str(&String::from_utf8_lossy(&enrichment_buf)); + } + + output.push('\n'); Ok(output) } diff --git a/src/markdown.rs b/src/markdown.rs index 607c5b0..23fb99b 100644 --- a/src/markdown.rs +++ b/src/markdown.rs @@ -877,27 +877,46 @@ pub fn render_truncated_to_fit( token_encoding: TokenEncoding, remaining_tokens: usize, ) -> Option { + let (chunk, tokens) = fit_chunk_to_budget(remaining_tokens, token_encoding, |limit| { + let mut probe: Vec = Vec::new(); + process_file_with_content_limit( + base_path, + file_path, + &mut probe, + line_numbers, + encoding_strategy, + ts_config, + limit, + ) + .ok()?; + Some(String::from_utf8_lossy(&probe).into_owned()) + })?; + *buf = chunk.into_bytes(); + Some(tokens) +} + +/// The budget search shared by every renderer: find a rendering of one file +/// section whose real token count fits `remaining_tokens`. +/// +/// `render(limit)` returns the section with its content clamped to `limit` +/// bytes (`None` = no clamp) or `None` if it cannot be rendered. The fixed +/// header cost is measured with a zero-byte probe, the content allowance is +/// what is left at ~4 bytes/token, and it shrinks proportionally (with +/// margin) while the tokenizer says the chunk overshoots. Gives up (omit the +/// file) when even an empty section does not fit. +pub(crate) fn fit_chunk_to_budget( + remaining_tokens: usize, + token_encoding: TokenEncoding, + mut render: impl FnMut(Option) -> Option, +) -> Option<(String, usize)> { if remaining_tokens == 0 { return None; } // 1. Measure the fixed section cost (header + empty fence + marker) by // rendering with a zero-byte content allowance. - let mut base_probe: Vec = Vec::new(); - if process_file_with_content_limit( - base_path, - file_path, - &mut base_probe, - line_numbers, - encoding_strategy, - ts_config, - Some(0), - ) - .is_err() - { - return None; - } - let base_tokens = estimate_tokens(token_encoding, &String::from_utf8_lossy(&base_probe)); + let base_probe = render(Some(0))?; + let base_tokens = estimate_tokens(token_encoding, &base_probe); // Even an empty section would overdraw — omit the file instead. if base_tokens >= remaining_tokens { @@ -911,25 +930,10 @@ pub fn render_truncated_to_fit( // tokenizer; shrink proportionally on overshoot. Bounded attempts — // if it still doesn't fit, omission is safer than an over-budget emit. for _ in 0..5 { - let mut probe: Vec = Vec::new(); - if process_file_with_content_limit( - base_path, - file_path, - &mut probe, - line_numbers, - encoding_strategy, - ts_config, - Some(byte_allowance), - ) - .is_err() - { - return None; - } - - let chunk_tokens = estimate_tokens(token_encoding, &String::from_utf8_lossy(&probe)); + let probe = render(Some(byte_allowance))?; + let chunk_tokens = estimate_tokens(token_encoding, &probe); if chunk_tokens <= remaining_tokens { - *buf = probe; - return Some(chunk_tokens); + return Some((probe, chunk_tokens)); } if byte_allowance == 0 { diff --git a/tests/test_autodiff_budget.rs b/tests/test_autodiff_budget.rs new file mode 100644 index 0000000..ca6bec0 --- /dev/null +++ b/tests/test_autodiff_budget.rs @@ -0,0 +1,221 @@ +//! `--max-tokens` with auto-diff budgets file by file with the real tokenizer, +//! like the standard renderer (#20 B14): no cut mid-fence, an overrunning file +//! is clamped and says so, omitted files are counted. + +use context_builder::cli::Args; +use context_builder::config::Config; +use context_builder::token_count::{Encoding, estimate_tokens}; +use context_builder::{Prompter, run_with_args}; +use serial_test::serial; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use tempfile::tempdir; + +/// The auto-diff cache lives under the current directory, so each test runs +/// inside its own project (and serially). +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); + } +} + +struct Yes; + +impl Prompter for Yes { + fn confirm_overwrite(&self, _file_path: &str) -> std::io::Result { + Ok(true) + } +} + +fn args(input: &Path, output: &Path, max_tokens: Option, encoding: &str) -> 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: encoding.to_string(), + init: false, + max_tokens, + 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 = tempdir().unwrap(); + for i in 1..=4 { + let mut body = format!("fn f{i}() {{\n"); + for j in 0..300 { + body.push_str(&format!(" let v{i}_{j} = {j};\n")); + } + body.push_str("}\n"); + fs::write(dir.path().join(format!("f{i}.rs")), body).unwrap(); + } + dir +} + +fn auto_diff() -> Config { + Config { + auto_diff: Some(true), + ..Default::default() + } +} + +fn fence_lines(doc: &str) -> usize { + doc.lines().filter(|l| l.starts_with("```")).count() +} + +#[test] +#[serial] +fn budget_is_met_by_the_real_tokenizer_and_fences_stay_closed() { + let dir = project(); + let _cwd = CwdGuard::enter(dir.path()); + let out = dir.path().join("out.md"); + run_with_args( + args(dir.path(), &out, Some(3000), "o200k_base"), + auto_diff(), + &Yes, + ) + .unwrap(); + let doc = fs::read_to_string(&out).unwrap(); + + let tokens = estimate_tokens(Encoding::O200kBase, &doc); + assert!(tokens <= 3000, "{tokens} tokens exceed the 3000 budget"); + assert!(tokens > 1500, "budget should be used, got {tokens}"); + assert_eq!(fence_lines(&doc) % 2, 0, "unbalanced fences:\n{doc}"); + assert!(doc.contains("Token budget (3000) reached"), "{doc}"); +} + +#[test] +#[serial] +fn crossing_file_is_clamped_and_the_rest_counted() { + let dir = project(); + let _cwd = CwdGuard::enter(dir.path()); + let out = dir.path().join("out.md"); + run_with_args( + args(dir.path(), &out, Some(5000), "o200k_base"), + auto_diff(), + &Yes, + ) + .unwrap(); + let doc = fs::read_to_string(&out).unwrap(); + + assert!(doc.contains("### File: `f1.rs`"), "first file kept"); + assert!( + doc.contains("File content truncated"), + "clamped file says so:\n{doc}" + ); + // f1 fits, f2 is clamped; f3 and f4 are omitted and reported as such. + assert!(doc.contains("2 remaining files omitted"), "{doc}"); + assert!(!doc.contains("### File: `f3.rs`") && !doc.contains("### File: `f4.rs`")); +} + +#[test] +#[serial] +fn a_large_budget_changes_nothing() { + // Separate projects: a second run in one would diff against the first. + let (a, b) = (project(), project()); + let full = a.path().join("full.md"); + let capped = b.path().join("capped.md"); + run_with_args(args(a.path(), &full, None, "o200k_base"), auto_diff(), &Yes).unwrap(); + run_with_args( + args(b.path(), &capped, Some(1_000_000), "o200k_base"), + auto_diff(), + &Yes, + ) + .unwrap(); + let strip = |p: &Path| { + fs::read_to_string(p) + .unwrap() + .lines() + .filter(|l| { + !l.starts_with("**Generated:**") + && !l.starts_with("**Project:**") + && !l.starts_with("Content hash") + }) + .collect::>() + .join("\n") + }; + assert_eq!(strip(&full), strip(&capped)); +} + +#[test] +#[serial] +fn encoding_is_honored_for_the_budget() { + let dir = project(); + let _cwd = CwdGuard::enter(dir.path()); + let out = dir.path().join("out.md"); + run_with_args( + args(dir.path(), &out, Some(3000), "cl100k_base"), + auto_diff(), + &Yes, + ) + .unwrap(); + let doc = fs::read_to_string(&out).unwrap(); + let tokens = estimate_tokens(Encoding::Cl100kBase, &doc); + assert!(tokens <= 3000, "{tokens} cl100k tokens exceed the budget"); +} + +#[test] +#[serial] +fn header_larger_than_the_budget_still_ends_cleanly() { + let dir = project(); + let _cwd = CwdGuard::enter(dir.path()); + let out = dir.path().join("out.md"); + run_with_args( + args(dir.path(), &out, Some(40), "o200k_base"), + auto_diff(), + &Yes, + ) + .unwrap(); + let doc = fs::read_to_string(&out).unwrap(); + assert!( + doc.contains("Output truncated: exceeded 40 token budget"), + "{doc}" + ); + assert_eq!(fence_lines(&doc) % 2, 0); +} + +#[test] +fn binary_with_auto_diff_config_respects_the_budget() { + let dir = project(); + fs::write( + dir.path().join("context-builder.toml"), + "auto_diff = true\n", + ) + .unwrap(); + let out = Command::new(env!("CARGO_BIN_EXE_context-builder")) + .args(["-d", ".", "-y", "-o", "-", "--max-tokens", "3000"]) + .current_dir(dir.path()) + .output() + .unwrap(); + assert!(out.status.success()); + let doc = String::from_utf8_lossy(&out.stdout); + let tokens = estimate_tokens(Encoding::O200kBase, &doc); + assert!(tokens <= 3000, "{tokens}"); + assert!(doc.contains("remaining files omitted"), "{doc}"); +} From 2cea8e4915ade053aa2bb3b8e7ad811b010985c9 Mon Sep 17 00:00:00 2001 From: Igor Lins e Silva <4753812+igorls@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:46:58 -0300 Subject: [PATCH 3/7] refactor: simpler budget loop and single-line formats for coverage attribution; test metadata lines (#20) --- src/lib.rs | 63 ++++++++++++++++++++++++++---------------------------- 1 file changed, 30 insertions(+), 33 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index cfd7079..2ca3671 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -962,38 +962,37 @@ fn generate_markdown_with_diff( .collect(); for (idx, (path, file_state)) in present.iter().enumerate() { let section = diff_file_section(path, file_state, args, ts_config, None)?; - let Some((max, encoding, _)) = budget else { - output.push_str(§ion); - continue; - }; - let section_tokens = estimate_tokens(encoding, §ion); - if tokens_used + section_tokens <= max { + let section_tokens = + budget.map_or(0, |(_, encoding, _)| estimate_tokens(encoding, §ion)); + let fits = budget.is_none_or(|(max, _, _)| tokens_used + section_tokens <= max); + if fits { tokens_used += section_tokens; output.push_str(§ion); - continue; - } - // Crossing file: clamp it into the remaining allowance, else omit it. - let remaining_tokens = max.saturating_sub(tokens_used); - let fitted = markdown::fit_chunk_to_budget(remaining_tokens, encoding, |limit| { - diff_file_section(path, file_state, args, ts_config, limit).ok() - }); - let emitted = fitted.is_some(); - if let Some((chunk, _)) = fitted { - output.push_str(&chunk); + } else if let Some((max, encoding, _)) = budget { + // Crossing file: clamp it into the remaining allowance, else omit it. + let remaining_tokens = max.saturating_sub(tokens_used); + let fitted = markdown::fit_chunk_to_budget(remaining_tokens, encoding, |limit| { + diff_file_section(path, file_state, args, ts_config, limit).ok() + }); + let omitted = present.len() - idx - usize::from(fitted.is_some()); + if let Some((chunk, _)) = fitted { + output.push_str(&chunk); + } + output.push_str("---\n\n"); + output.push_str(&budget_notice(max, omitted)); + break; } - output.push_str("---\n\n"); - output.push_str(&format!( - "_⚠️ Token budget ({}) reached. {} remaining files omitted._\n\n", - max, - present.len() - idx - usize::from(emitted) - )); - break; } } Ok(output) } +/// The notice that ends a document whose budget ran out. +fn budget_notice(max: usize, omitted: usize) -> String { + format!("_⚠️ Token budget ({max}) reached. {omitted} remaining files omitted._\n\n") +} + /// One `### File:` section of the auto-diff document. `content_limit` clamps /// the file's content to that many bytes (at a boundary chosen by `--truncate`) /// and says so, like the standard renderer does for a file that crosses the @@ -1006,16 +1005,16 @@ fn diff_file_section( content_limit: Option, ) -> io::Result { let mut output = String::new(); - output.push_str(&format!( - "### File: {}\n\n", - fences::inline_code(&path.display().to_string()) - )); + let heading = fences::inline_code(&path.display().to_string()); + output.push_str(&format!("### File: {heading}\n\n")); // Same opt-in as the standard renderer. Off by default so an // mtime-only change does not rewrite the document. The cache // compares content hashes (file bytes), not mtime. if args.file_metadata { - output.push_str(&format!("- Size: {} bytes\n", file_state.size)); - output.push_str(&format!("- Modified: {:?}\n\n", file_state.modified)); + let (size, modified) = (file_state.size, file_state.modified); + output.push_str(&format!( + "- Size: {size} bytes\n- Modified: {modified:?}\n\n" + )); } // Determine language from file extension (canonical map — @@ -1045,11 +1044,9 @@ fn diff_file_section( .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; output.push_str(rendered); if truncated { + let (shown, total, mode) = (content.len(), full.len(), &ts_config.truncate); output.push_str(&format!( - "\n_⚠️ File content truncated: {} of {} bytes (mode: {})._\n\n", - content.len(), - full.len(), - ts_config.truncate + "\n_⚠️ File content truncated: {shown} of {total} bytes (mode: {mode})._\n\n" )); } } From e594d84f7dfceb2d25c0317299d3e1790af44d03 Mon Sep 17 00:00:00 2001 From: Igor Lins e Silva <4753812+igorls@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:47:23 -0300 Subject: [PATCH 4/7] test: metadata lines in budgeted auto-diff sections (#20) --- tests/test_autodiff_budget.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/test_autodiff_budget.rs b/tests/test_autodiff_budget.rs index ca6bec0..0781fa7 100644 --- a/tests/test_autodiff_budget.rs +++ b/tests/test_autodiff_budget.rs @@ -219,3 +219,18 @@ fn binary_with_auto_diff_config_respects_the_budget() { assert!(tokens <= 3000, "{tokens}"); assert!(doc.contains("remaining files omitted"), "{doc}"); } + +#[test] +#[serial] +fn file_metadata_is_rendered_in_budgeted_sections() { + let dir = project(); + let _cwd = CwdGuard::enter(dir.path()); + let out = dir.path().join("out.md"); + let mut a = args(dir.path(), &out, Some(100_000), "o200k_base"); + a.file_metadata = true; + run_with_args(a, auto_diff(), &Yes).unwrap(); + let doc = fs::read_to_string(&out).unwrap(); + assert!(doc.contains("- Size: "), "{doc}"); + assert!(doc.contains("- Modified: "), "{doc}"); + assert!(!doc.contains("Token budget"), "plenty of room, nothing cut"); +} From b8fbe9ec74567555d9eb2811235df45414539d4e Mon Sep 17 00:00:00 2001 From: Igor Lins e Silva <4753812+igorls@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:40:18 -0300 Subject: [PATCH 5/7] fix: tokenizer-verified safety cut; keep the truncation marker when signatures replace the body --- src/lib.rs | 57 +++++++++++++++++++++-------------- src/markdown.rs | 20 ++++++------ tests/test_autodiff_budget.rs | 22 ++++++++++++++ 3 files changed, 67 insertions(+), 32 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index f9eb936..c2d4ff8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -593,23 +593,7 @@ pub fn run_with_args(args: Args, config: Config, prompter: &impl Prompter) -> io if let Some(max_tokens) = final_args.max_tokens { let encoding = final_args.encoding.parse::().unwrap_or_default(); if estimate_tokens(encoding, &final_doc) > max_tokens { - let mut truncate_at = final_doc.len().min(max_tokens.saturating_mul(4)); - while truncate_at > 0 && !final_doc.is_char_boundary(truncate_at) { - truncate_at -= 1; - } - final_doc.truncate(truncate_at); - - // Close any open code fence so the truncation notice is not - // swallowed by the block. The closer matches the opening - // fence's length — a fixed ``` would not close a longer fence - // chosen because the file itself contains ```. - fences::close_unmatched_backtick_fence(&mut final_doc); - - final_doc.push_str("\n---\n\n"); - final_doc.push_str(&format!( - "_Output truncated: exceeded {} token budget._\n", - max_tokens - )); + final_doc = truncate_to_token_budget(&final_doc, max_tokens, encoding); } } @@ -988,6 +972,31 @@ fn generate_markdown_with_diff( Ok(output) } +/// Cut `doc` so that, with its fence closed and the notice appended, the real +/// token count is within `max_tokens`. Starts from the bytes/4 guess and +/// shrinks until the tokenizer agrees (or nothing is left but the notice). +fn truncate_to_token_budget(doc: &str, max_tokens: usize, encoding: Encoding) -> String { + let mut cut = doc.len().min(max_tokens.saturating_mul(4)); + loop { + while cut > 0 && !doc.is_char_boundary(cut) { + cut -= 1; + } + let mut out = doc[..cut].to_string(); + // Close any open code fence so the notice is not swallowed by the + // block. The closer matches the opening fence's length: a fixed ``` + // would not close a longer fence chosen because the file contains ```. + fences::close_unmatched_backtick_fence(&mut out); + out.push_str("\n---\n\n"); + out.push_str(&format!( + "_Output truncated: exceeded {max_tokens} token budget._\n" + )); + if cut == 0 || estimate_tokens(encoding, &out) <= max_tokens { + return out; + } + cut -= (cut / 10).max(1); + } +} + /// The notice that ends a document whose budget ran out. fn budget_notice(max: usize, omitted: usize) -> String { format!("_⚠️ Token budget ({max}) reached. {omitted} remaining files omitted._\n\n") @@ -1043,12 +1052,14 @@ fn diff_file_section( let rendered = std::str::from_utf8(&rendered) .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; output.push_str(rendered); - if truncated { - let (shown, total, mode) = (content.len(), full.len(), &ts_config.truncate); - output.push_str(&format!( - "\n_⚠️ File content truncated: {shown} of {total} bytes (mode: {mode})._\n\n" - )); - } + } + // The marker is about the file, not about whether its body is shown: with + // --signatures the signatures below are computed from the clamped content. + if truncated { + let (shown, total, mode) = (content.len(), full.len(), &ts_config.truncate); + output.push_str(&format!( + "\n_⚠️ File content truncated: {shown} of {total} bytes (mode: {mode})._\n\n" + )); } // Tree-sitter enrichment (same as standard path), over what is displayed diff --git a/src/markdown.rs b/src/markdown.rs index 23fb99b..0062175 100644 --- a/src/markdown.rs +++ b/src/markdown.rs @@ -773,15 +773,17 @@ pub fn process_file_with_content_limit( if !signatures_only { write_text_content(output, content, language, line_numbers)?; - if truncated { - writeln!( - output, - "\n_⚠️ File content truncated: {} of {} bytes (mode: {})._\n", - content.len(), - original_len, - ts_config.truncate - )?; - } + } + // About the file, not about whether its body is shown: with + // --signatures the block below is computed from the clamped content. + if truncated { + writeln!( + output, + "\n_⚠️ File content truncated: {} of {} bytes (mode: {})._\n", + content.len(), + original_len, + ts_config.truncate + )?; } // Tree-sitter enrichment: signatures and/or structure diff --git a/tests/test_autodiff_budget.rs b/tests/test_autodiff_budget.rs index 0781fa7..c3ee102 100644 --- a/tests/test_autodiff_budget.rs +++ b/tests/test_autodiff_budget.rs @@ -234,3 +234,25 @@ fn file_metadata_is_rendered_in_budgeted_sections() { assert!(doc.contains("- Modified: "), "{doc}"); assert!(!doc.contains("Token budget"), "plenty of room, nothing cut"); } + +#[test] +#[serial] +fn truncation_marker_is_kept_when_signatures_replace_the_body() { + let dir = tempdir().unwrap(); + let mut body = String::new(); + for j in 0..300 { + body.push_str(&format!( + "fn function_number_{j}(a: u32) -> u32 {{ a + {j} }}\n" + )); + } + fs::write(dir.path().join("many.rs"), body).unwrap(); + let _cwd = CwdGuard::enter(dir.path()); + let out = dir.path().join("out.md"); + let mut a = args(dir.path(), &out, Some(600), "o200k_base"); + a.signatures = true; + run_with_args(a, auto_diff(), &Yes).unwrap(); + let doc = fs::read_to_string(&out).unwrap(); + assert!(doc.contains("File content truncated"), "{doc}"); + assert!(doc.contains("**Signatures:**"), "{doc}"); + assert!(estimate_tokens(Encoding::O200kBase, &doc) <= 600); +} From 268f93077e451b6b75f2e641ffed1639c2d6237a Mon Sep 17 00:00:00 2001 From: Igor Lins e Silva <4753812+igorls@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:54:56 -0300 Subject: [PATCH 6/7] test: gate the signatures marker test on tree-sitter-rust --- tests/test_autodiff_budget.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_autodiff_budget.rs b/tests/test_autodiff_budget.rs index c3ee102..ecc249e 100644 --- a/tests/test_autodiff_budget.rs +++ b/tests/test_autodiff_budget.rs @@ -237,6 +237,7 @@ fn file_metadata_is_rendered_in_budgeted_sections() { #[test] #[serial] +#[cfg(feature = "tree-sitter-rust")] fn truncation_marker_is_kept_when_signatures_replace_the_body() { let dir = tempdir().unwrap(); let mut body = String::new(); From 234c6108711e39880e536166a70ecec4e81bb918 Mon Sep 17 00:00:00 2001 From: Igor Lins e Silva <4753812+igorls@users.noreply.github.com> Date: Mon, 12 Oct 2026 06:01:44 -0300 Subject: [PATCH 7/7] fix merge of master: keep auto-diff budget refactor plus config flags, help/version, metadata stamp --- src/lib.rs | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 4afcbe4..0b69b99 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1377,18 +1377,12 @@ pub fn run() -> io::Result<()> { // `--encoding o200k_base` and `-o output.md` carry the same value as the // default, so the value alone can't reveal an intent to override a // non-default config (see resolver). - let matches = Args::command().get_matches(); -} - let matches = command_with_config_flags().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. -} - /// A bad command line is returned as `InvalidInput` instead of exiting; /// `--help` and `--version` print and return `Ok`. pub fn run_from(argv: I) -> io::Result<()> @@ -1397,11 +1391,6 @@ where 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()))?; -} - let matches = match command_with_config_flags().try_get_matches_from(&argv) { Ok(matches) => matches, // `--help` / `--version` are requests, not mistakes: print and succeed. @@ -1472,9 +1461,6 @@ fn run_matches(matches: &clap::ArgMatches, argc: usize) -> io::Result<()> { } if argc == 1 && config.is_none() { - Args::command().print_help()?; -} - command_with_config_flags().print_help()?; return Ok(()); }