use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicUsize, Ordering};
const BINARY: &str = env!("CARGO_BIN_EXE_numbers-le");
static COUNTER: AtomicUsize = AtomicUsize::new(0);
struct Tree {
root: PathBuf,
}
impl Tree {
fn new(name: &str) -> Self {
let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
let root = std::env::temp_dir().join(format!(
"numbers-le-contract-{name}-{}-{unique}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).expect("a temporary directory");
Self {
root: std::fs::canonicalize(&root).expect("a canonical directory"),
}
}
fn path(&self) -> &Path {
&self.root
}
fn write(&self, relative: &str, contents: &str) -> PathBuf {
self.write_bytes(relative, contents.as_bytes())
}
fn write_bytes(&self, relative: &str, contents: &[u8]) -> PathBuf {
let target = self.root.join(relative);
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent).expect("a parent directory");
}
std::fs::write(&target, contents).expect("a file");
target
}
}
impl Drop for Tree {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.root);
}
}
struct Run {
code: i32,
stdout: String,
stderr: String,
}
fn run(args: &[&str]) -> Run {
let output = Command::new(BINARY)
.args(args)
.output()
.expect("the binary runs");
Run {
code: output.status.code().expect("an exit code"),
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
}
}
fn reports(run: &Run) -> Vec<serde_json::Value> {
run.stdout
.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| serde_json::from_str(line).expect("stdout carries only JSON"))
.collect()
}
fn audit_tree(name: &str) -> Tree {
let tree = Tree::new(name);
tree.write("config.json", "{\"port\":8080,\"quoted\":\"42\"}\n");
tree.write("rates.env", "VAT=0.2\nNAME=standard\n");
tree.write("src/pricing.ts", "const MARKUP: number = 1.15;\n");
tree.write("notes.md", "no digits in this prose at all\n");
tree
}
#[test]
fn a_tree_with_numbers_exits_zero() {
let tree = audit_tree("found");
let run = run(&[&tree.path().to_string_lossy()]);
assert_eq!(run.code, 0, "{}", run.stderr);
let total: u64 = reports(&run)
.iter()
.filter_map(|report| report["summary"]["numbers"].as_u64())
.sum();
assert_eq!(
total, 3,
"the port, the VAT rate and the markup — the quoted 42 is data"
);
}
#[test]
fn a_tree_with_none_exits_one() {
let tree = Tree::new("none");
tree.write("docs/a.md", "no digits here\n");
let run = run(&[&tree.path().to_string_lossy()]);
assert_eq!(run.code, 1);
assert!(run.stderr.contains("0 numbers"), "{}", run.stderr);
}
#[test]
fn numbers_are_printed_the_way_javascript_prints_them() {
let tree = Tree::new("rendering");
tree.write("a.json", "{\"big\":1e21,\"small\":1e-7,\"plain\":100}\n");
let run = run(&["--values", &tree.path().to_string_lossy()]);
assert_eq!(
run.stdout.lines().collect::<Vec<_>>(),
["1e+21", "1e-7", "100"]
);
}
#[test]
fn a_quoted_number_counts_in_an_untyped_format_only() {
let tree = Tree::new("coercion");
tree.write("a.json", "{\"a\":\"42\"}\n");
tree.write("b.env", "A=42\n");
let run = run(&["--values", &tree.path().to_string_lossy()]);
assert_eq!(run.stdout.lines().collect::<Vec<_>>(), ["42"]);
}
#[test]
fn a_source_file_yields_its_constants_through_its_language() {
let tree = audit_tree("source");
let source = reports(&run(&[&tree.path().to_string_lossy()]))
.into_iter()
.find(|report| {
report["file"]
.as_str()
.is_some_and(|file| file.ends_with("pricing.ts"))
})
.expect("the .ts file was read");
assert_eq!(source["format"], "typescript");
assert_eq!(source["numbers"][0]["value"], "1.15");
assert_eq!(source["numbers"][0]["notation"], "decimal");
assert_eq!(
source["summary"]["numbers"], 1,
"the `number` type annotation is not the number 0"
);
}
#[test]
fn a_binary_file_is_counted_and_a_broken_text_file_is_reported() {
let tree = Tree::new("binary");
tree.write("rates.env", "VAT=0.2\n");
tree.write_bytes("logo.png", &[0x89, 0x50, 0x4e, 0x47, 0x00, 0x1a, 0x0a]);
let clean = run(&[&tree.path().to_string_lossy()]);
assert_eq!(clean.code, 0, "{}", clean.stderr);
let scanned = reports(&clean);
let files: Vec<String> = scanned
.iter()
.filter_map(|report| report["file"].as_str())
.map(|file| file.rsplit(['/', '\\']).next().unwrap_or(file).to_string())
.collect();
assert_eq!(files, ["rates.env"], "the PNG produced no report line");
assert!(
clean.stderr.contains("1 binary file skipped"),
"the count is the reader's only sign coverage was narrower: {}",
clean.stderr
);
let strict = run(&["--strict", &tree.path().to_string_lossy()]);
assert_eq!(strict.code, 0, "a binary file never fails --strict");
tree.write_bytes("notes.txt", &[0x68, 0x69, 0xff, 0xfe]);
let broken = run(&["--strict", &tree.path().to_string_lossy()]);
assert_eq!(broken.code, 2, "{}", broken.stderr);
assert!(
broken.stderr.contains("not UTF-8 text"),
"{}",
broken.stderr
);
}
#[test]
fn values_only_prints_numbers_and_no_json() {
let tree = audit_tree("values");
let run = run(&["--values", &tree.path().to_string_lossy()]);
assert_eq!(run.code, 0);
assert!(!run.stdout.contains('{'), "{}", run.stdout);
assert_eq!(run.stdout.lines().count(), 3, "{}", run.stdout);
}
#[test]
fn an_unreadable_input_exits_two() {
assert_eq!(run(&["/no/such/place-xyz"]).code, 2);
}
#[test]
fn a_broken_document_warns_without_failing_the_run() {
let tree = Tree::new("broken");
tree.write("bad.json", "{not json\n");
tree.write("good.json", "{\"a\":1}\n");
let run = run(&[&tree.path().to_string_lossy()]);
assert_eq!(run.code, 0, "{}", run.stderr);
assert!(
run.stderr.contains("Failed to parse JSON"),
"{}",
run.stderr
);
}
#[test]
fn an_unknown_flag_exits_two_and_names_itself() {
let tree = audit_tree("badflag");
let run = run(&["--dedup", &tree.path().to_string_lossy()]);
assert_eq!(run.code, 2);
assert!(run.stderr.contains("--dedup"), "{}", run.stderr);
assert!(run.stdout.is_empty(), "a refusal writes no report");
}
#[test]
fn an_unknown_format_falls_back_rather_than_exiting_two() {
let tree = audit_tree("badformat");
let run = run(&["--format", "klingon", &tree.path().to_string_lossy()]);
assert_eq!(run.code, 0, "{}", run.stderr);
assert!(
reports(&run)
.iter()
.all(|report| report["format"] == "unknown")
);
}
#[test]
fn no_flag_asks_for_a_judgment() {
let tree = audit_tree("nojudgment");
for attempt in ["--magic", "--min", "--max", "--round", "--fix"] {
assert_eq!(
run(&[attempt, &tree.path().to_string_lossy()]).code,
2,
"{attempt} was accepted"
);
}
}
#[test]
fn dedupe_collapses_repeats() {
let tree = Tree::new("dedupe");
tree.write("a.json", "{\"a\":5,\"b\":5}\n");
let kept: u64 = reports(&run(&[&tree.path().to_string_lossy()]))[0]["summary"]["numbers"]
.as_u64()
.expect("a count");
let deduped: u64 =
reports(&run(&["--dedupe", &tree.path().to_string_lossy()]))[0]["summary"]["numbers"]
.as_u64()
.expect("a count");
assert_eq!((kept, deduped), (2, 1));
}
#[test]
fn values_the_scanner_cannot_see_are_reported_as_unlocated() {
let tree = Tree::new("unlocated");
tree.write("a.toml", "a = 0x1A\n");
let run = run(&[&tree.path().to_string_lossy()]);
assert_eq!(reports(&run)[0]["numbers"][0]["value"], "26");
assert_eq!(reports(&run)[0]["summary"]["unlocated"], 1);
assert!(
run.stderr.contains("could not be located"),
"{}",
run.stderr
);
}
#[test]
fn version_and_help_exit_clear() {
let version = run(&["--version"]);
assert_eq!(version.code, 0);
assert!(version.stdout.contains("numbers-le"));
let help = run(&["--help"]);
assert_eq!(help.code, 0);
assert!(help.stdout.contains("usage: numbers-le"));
assert!(
help.stdout.contains("grep"),
"the exit convention is stated"
);
}
#[test]
fn stdout_carries_only_reports_and_stderr_only_the_summary() {
let tree = audit_tree("streams");
let run = run(&[&tree.path().to_string_lossy()]);
assert!(!reports(&run).is_empty());
assert!(!run.stderr.contains('{'), "{}", run.stderr);
assert!(run.stderr.contains("numbers in"), "{}", run.stderr);
}
#[test]
fn a_document_on_stdin_is_scanned() {
let mut child = Command::new(BINARY)
.args(["--stdin", "--format", "json"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("the binary runs");
child
.stdin
.as_mut()
.expect("stdin")
.write_all(br#"{"a":8080}"#)
.expect("written");
let output = child.wait_with_output().expect("finishes");
assert_eq!(output.status.code(), Some(0));
let report: serde_json::Value =
serde_json::from_slice(&output.stdout).expect("stdout carries JSON");
assert_eq!(report["file"], "<stdin>");
assert_eq!(report["numbers"][0]["value"], "8080");
}
#[test]
fn the_cli_and_the_mcp_server_report_the_same_thing() {
let tree = audit_tree("agreement");
let cli = run(&[&tree.path().to_string_lossy()]);
let from_cli = reports(&cli);
let request = serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "numbers_le_scan",
"arguments": { "path": tree.path().to_string_lossy() },
},
});
let mut child = Command::new(BINARY)
.arg("mcp")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("the server starts");
writeln!(child.stdin.as_mut().expect("stdin"), "{request}").expect("written");
let output = child.wait_with_output().expect("finishes");
let response: serde_json::Value = serde_json::from_slice(
output
.stdout
.split(|byte| *byte == b'\n')
.next()
.expect("a line"),
)
.expect("the reply is JSON");
let from_mcp = response["result"]["structuredContent"]["data"]["reports"]
.as_array()
.expect("reports")
.clone();
assert_eq!(from_mcp, from_cli, "the two surfaces disagree");
}