Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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

- **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 `<Binary file or unsupported encoding>` 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`.
- **`--stats[=N]` per-file token breakdown; real token count after a run (#21 P7).** `--stats` lists the largest files by tokens (20 by default; `--stats=0` lists all) and, with `--max-tokens`, which files fit, which is cut first and what is omitted; it writes no document. The `Estimated tokens` line after a run is now the tokenizer's count of the written document (it was bytes/4, off by −11% to −75% on real code); output piped with `-o -` keeps the `~NK` estimate.
- **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.
- **`--structure` lists imports and exports (#22 B15).** The help promised them but only counts were printed (and the internal lists held placeholders). Each file now gets `**Imports:**` / `**Exports:**` lists (Rust `use`/`pub use`, Python, JS/TS `import`/`export`, Go, Java, C/C++ `#include`), capped at 20 entries. Top-level declarations only.
Expand Down
27 changes: 27 additions & 0 deletions src/content_filter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -231,6 +235,7 @@ impl SkipReason {
SkipReason::Asset => "asset",
SkipReason::TooLarge => "too large",
SkipReason::Secret => "secret",
SkipReason::Binary => "binary",
}
}
}
Expand Down Expand Up @@ -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<String> {
Expand Down Expand Up @@ -453,11 +475,13 @@ pub fn summary_line(skipped: &[SkippedFile]) -> Option<String> {
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();
Expand All @@ -470,6 +494,9 @@ pub fn summary_line(skipped: &[SkippedFile]) -> Option<String> {
if secrets > 0 {
parts.push(count_phrase(secrets, "secret", "secrets"));
}
if binaries > 0 {
parts.push(count_phrase(binaries, "binary", "binaries"));
}
let noun = if skipped.len() == 1 { "file" } else { "files" };
Some(format!(
"Skipped {} {} ({}).",
Expand Down
4 changes: 2 additions & 2 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -423,7 +423,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(", ")
),
);
Expand All @@ -432,7 +432,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))
),
);
}
Expand Down
31 changes: 21 additions & 10 deletions src/markdown.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,16 +113,7 @@ pub fn generate_markdown(
path: (output_path != "-").then(|| std::path::PathBuf::from(output_path)),
};

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
Expand Down Expand Up @@ -1109,6 +1100,26 @@ 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 {
use std::path::Component;
let path = Path::new(input_dir);
// 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.
const BINARY_SNIFF_LEN: usize = 8192;

Expand Down
174 changes: 174 additions & 0 deletions tests/test_binary_and_header.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
//! 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<bool> {
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,
stats: None,
}
}

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("<Binary file"), "{doc}");
assert!(doc.contains("### File: `a.txt`"));
assert!(doc.contains("### File: `wide.txt`"), "UTF-16 text stays");
}

#[test]
fn header_names_the_directory_not_its_absolute_path() {
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();
let name = dir
.path()
.file_name()
.unwrap()
.to_string_lossy()
.to_string();
let parent = dir.path().parent().unwrap().to_string_lossy().to_string();
assert!(
doc.contains(&format!("from the `{name}` directory")),
"{doc}"
);
assert!(!doc.contains(&parent), "absolute path leaked:\n{doc}");
}

#[test]
fn binary_prints_summary_with_binary_count_and_clean_header() {
let dir = project();
let out = Command::new(env!("CARGO_BIN_EXE_context-builder"))
.args(["-d", dir.path().to_str().unwrap(), "-o", "-", "-y"])
.current_dir(std::env::temp_dir())
.output()
.unwrap();
assert!(out.status.success());
let doc = String::from_utf8_lossy(&out.stdout);
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains("1 binary"), "{err}");
let parent = dir.path().parent().unwrap().to_string_lossy().to_string();
assert!(!doc.contains(&parent), "{doc}");
assert!(!doc.contains("<Binary file"));
}

#[test]
fn header_keeps_a_symlink_name_and_never_falls_back_to_a_path() {
let dir = project();
let cwd = dir.path().join("sub");
fs::create_dir(&cwd).unwrap();
fs::write(cwd.join("x.txt"), "x").unwrap();
// `..` resolves to the parent's name; `/` has no name and must not print `/`.
let out = dir.path().join("out.md");
let mut a = args(&cwd.join(".."), &out);
a.input = format!("{}/..", cwd.display());
run_with_args(a, Config::default(), &Yes).unwrap();
let doc = fs::read_to_string(&out).unwrap();
let name = dir
.path()
.file_name()
.unwrap()
.to_string_lossy()
.to_string();
assert!(
doc.contains(&format!("from the `{name}` directory")),
"{doc}"
);

#[cfg(unix)]
{
let link = dir.path().join("alias-name");
std::os::unix::fs::symlink(&cwd, &link).unwrap();
let out2 = dir.path().join("out2.md");
run_with_args(args(&link, &out2), Config::default(), &Yes).unwrap();
let doc2 = fs::read_to_string(&out2).unwrap();
assert!(doc2.contains("from the `alias-name` directory"), "{doc2}");
}
}

#[test]
fn several_binaries_are_summarized_in_the_plural() {
let dir = project();
fs::write(dir.path().join("two.dat"), [0u8, 9, 9]).unwrap();
let out = Command::new(env!("CARGO_BIN_EXE_context-builder"))
.args(["-d", dir.path().to_str().unwrap(), "-o", "-", "-y"])
.current_dir(std::env::temp_dir())
.output()
.unwrap();
assert!(String::from_utf8_lossy(&out.stderr).contains("(2 binaries)"));
}
6 changes: 3 additions & 3 deletions tests/test_parallel_memory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -210,9 +210,9 @@ fn test_parallel_error_handling() {
assert!(output_content.contains("fn good1()"));
assert!(output_content.contains("fn good2()"));

// Verify binary file is handled with placeholder
assert!(output_content.contains("### File: `binary.rs`"));
assert!(output_content.contains("<Binary file or unsupported encoding:"));
// The binary file is left out of the body and reported under `## Skipped`.
assert!(!output_content.contains("### File: `binary.rs`"));
assert!(output_content.contains("- `binary.rs` — binary"));
}

#[cfg(feature = "parallel")]
Expand Down
Loading