use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::sync::atomic::{AtomicUsize, Ordering};
static NEXT_ID: AtomicUsize = AtomicUsize::new(0);
struct Fixture {
root: PathBuf,
}
impl Fixture {
fn new() -> Self {
let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
let root = std::env::temp_dir().join(format!("asrch-test-{}-{id}", std::process::id()));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(&root).unwrap();
Self { root }
}
fn write(&self, path: &str, content: &str) {
let path = self.root.join(path);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, content).unwrap();
}
fn run(&self, args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_asrch"))
.args(args)
.current_dir(&self.root)
.output()
.unwrap()
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.root);
}
}
fn stdout(output: &Output) -> String {
assert!(
output.status.success(),
"status={:?}\nstderr={}",
output.status,
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8(output.stdout.clone()).unwrap()
}
fn stderr(output: &Output) -> String {
String::from_utf8_lossy(&output.stderr).into_owned()
}
fn assert_bounded(text: &str, max_lines: usize, max_bytes: usize) {
assert!(
text.lines().count() <= max_lines,
"{} lines",
text.lines().count()
);
assert!(text.len() <= max_bytes, "{} bytes", text.len());
}
#[test]
fn survey_compares_terms_without_printing_matches() {
let fixture = Fixture::new();
fixture.write("src/a.rs", "alpha alpha\nbeta\n");
fixture.write("src/b.rs", "alpha\n");
let text = stdout(&fixture.run(&["survey", "--term", "alpha", "--term", "beta", "."]));
assert!(text.contains("survey:"));
assert!(text.contains(" terms: 2"));
assert!(text.contains("alpha"));
assert!(text.contains("2"));
assert!(text.contains("beta"));
assert!(!text.contains("alpha alpha"));
}
#[test]
fn survey_identifier_mode_avoids_partial_matches() {
let fixture = Fixture::new();
fixture.write("src/a.rs", "erf performed erfc\n");
let text = stdout(&fixture.run(&["survey", "--term", "erf", ".", "--identifier"]));
assert!(text.contains("erf"));
assert!(text.contains("1"));
assert!(!text.contains("3"));
}
#[test]
fn survey_reports_overall_and_by_path_for_multiple_paths() {
let fixture = Fixture::new();
fixture.write("crates/rschem/src/a.rs", "cam_coef\ncam_coef\nhyb_cam\n");
fixture.write("crates/libxc/src/b.rs", "hyb_cam\n");
fixture.write("docs/rsh.md", "cam_coef\n");
let text = stdout(&fixture.run(&[
"survey",
"--term",
"cam_coef",
"--term",
"hyb_cam",
"crates/rschem",
"crates/libxc",
"docs",
"--identifier",
]));
assert!(text.contains("survey:"));
assert!(text.contains(" terms: 2"));
assert!(text.contains(" paths: 3"));
assert!(text.contains(" mode: identifier"));
assert!(text.contains("overall[term,matches,files,dominant_path]:"));
assert!(text.contains("by_path:"));
assert!(text.contains("crates/rschem[term,matches,files,top_directory]:"));
assert!(text.contains("crates/libxc[term,matches,files,top_directory]:"));
assert!(text.contains("docs[term,matches,files,top_directory]:"));
assert!(text.contains("cam_coef,3,2,crates/rschem"));
assert!(text.contains("crates/rschem/src"));
assert!(text.contains("next: choose one useful term and path"));
}
#[test]
fn survey_omits_zero_rows_in_by_path_output() {
let fixture = Fixture::new();
fixture.write("src/a.rs", "alpha\n");
fixture.write("docs/a.md", "beta\n");
let text =
stdout(&fixture.run(&["survey", "--term", "alpha", "--term", "beta", "src", "docs"]));
let docs_section = text
.split(" docs[term,matches,files,top_directory]:")
.nth(1)
.unwrap()
.split(" src[term,matches,files,top_directory]:")
.next()
.unwrap();
assert!(docs_section.contains("beta"));
assert!(!docs_section.contains("\"alpha\""));
let src_section = text
.split(" src[term,matches,files,top_directory]:")
.nth(1)
.unwrap();
assert!(src_section.contains("alpha"));
assert!(!src_section.contains("beta,"));
}
#[test]
fn survey_quotes_terms_only_when_needed_for_toon_rows() {
let fixture = Fixture::new();
fixture.write("docs/a.md", "range separated\ncam_coef\n");
let text = stdout(&fixture.run(&[
"survey",
"--term",
"range separated",
"--term",
"cam_coef",
"docs",
]));
assert!(text.contains("\"range separated\",1,1,docs"));
assert!(text.contains("cam_coef,1,1,docs"));
}
#[test]
fn survey_warns_when_one_term_dominates() {
let fixture = Fixture::new();
fixture.write("src/a.rs", "alpha\nalpha\nalpha\nalpha\nalpha\nbeta\n");
let text = stdout(&fixture.run(&["survey", "--term", "alpha", "--term", "beta"]));
assert!(text.contains("dominates"));
}
#[test]
fn survey_warns_about_short_partial_match_terms() {
let fixture = Fixture::new();
fixture.write("src/a.rs", "erf performed\n");
let text = stdout(&fixture.run(&["survey", "--term", "erf"]));
assert!(text.contains("short partial-match"));
assert!(text.contains("--identifier"));
}
#[test]
fn survey_limits_the_number_of_terms() {
let fixture = Fixture::new();
let mut args = vec!["survey"];
for _ in 0..13 {
args.extend(["--term", "needle"]);
}
let output = fixture.run(&args);
assert!(!output.status.success());
assert!(stderr(&output).contains("at most 12 terms"));
}
#[test]
fn survey_limits_the_number_of_paths() {
let fixture = Fixture::new();
let mut args = vec!["survey", "--term", "needle"];
for index in 0..9 {
let path = format!("dir{index}");
fs::create_dir_all(fixture.root.join(&path)).unwrap();
args.push(Box::leak(path.into_boxed_str()));
}
let output = fixture.run(&args);
assert!(!output.status.success());
assert!(stderr(&output).contains("at most 8 paths"));
}
#[test]
fn scout_defaults_to_fixed_string_search() {
let fixture = Fixture::new();
fixture.write("src/a.rs", "call(foo)\ncallXfoo\n");
let text = stdout(&fixture.run(&["scout", "call(foo)"]));
assert!(text.contains("scout:"));
assert!(text.contains(" matches: 1"));
assert!(text.contains(" files: 1"));
}
#[test]
fn scout_summarizes_directories_and_files() {
let fixture = Fixture::new();
fixture.write("src/a.rs", "needle\nneedle\n");
fixture.write("tests/b.rs", "needle\n");
let text = stdout(&fixture.run(&["scout", "needle"]));
assert!(text.contains(" matches: 3"));
assert!(text.contains(" files: 2"));
assert!(text.contains("top_directories[path,matches]:"));
assert!(text.contains("top_files[path,matches]:"));
}
#[test]
fn regex_or_is_rejected_for_single_query_commands() {
let fixture = Fixture::new();
fixture.write("src/a.rs", "alpha\nbeta\n");
let output = fixture.run(&["scout", "alpha|beta", "--regex"]);
assert!(!output.status.success());
assert!(stderr(&output).contains("survey"));
}
#[test]
fn sample_shows_deterministic_cluster_context() {
let fixture = Fixture::new();
fixture.write(
"src/a.rs",
"before\nneedle one\nneedle two\nafter\n\nfar before\nneedle far\nfar after\n",
);
let text = stdout(&fixture.run(&["sample", "needle", "src/a.rs"]));
assert!(text.contains("-- src/a.rs:2:"));
assert!(text.contains("needle one"));
assert!(text.contains("needle far"));
assert!(text.contains("before"));
}
#[test]
fn sample_context_error_points_to_show() {
let fixture = Fixture::new();
fixture.write("src/a.rs", "needle\n");
let output = fixture.run(&["sample", "needle", "src/a.rs", "--context", "3"]);
assert!(!output.status.success());
let error = stderr(&output);
assert!(error.contains("sample does not accept --context"));
assert!(error.contains("asrch show"));
}
#[test]
fn show_requires_a_file_and_rejects_many_matches() {
let fixture = Fixture::new();
fixture.write("src/a.rs", &"needle\n".repeat(25));
fs::create_dir_all(fixture.root.join("src/dir")).unwrap();
let directory = fixture.run(&["show", "needle", "src"]);
assert!(!directory.status.success());
assert!(stderr(&directory).contains("file path"));
let broad = fixture.run(&["show", "needle", "src/a.rs"]);
assert!(!broad.status.success());
assert!(stderr(&broad).contains("too many matches"));
}
#[test]
fn show_prints_small_focused_snippets() {
let fixture = Fixture::new();
fixture.write("src/a.rs", "before\nneedle\nafter\n");
let text = stdout(&fixture.run(&["show", "needle", "src/a.rs", "--context", "1"]));
assert!(text.contains("-- src/a.rs:2:"));
assert!(text.contains("> 2 | needle"));
assert!(text.contains(" 1 | before"));
}
#[test]
fn show_finishes_a_started_snippet_even_past_the_line_limit() {
let fixture = Fixture::new();
fixture.write(
"src/a.rs",
"ctx1\nctx2\nctx3\nctx4\nctx5\nneedle\nctx7\nctx8\nctx9\nctx10\nctx11\n",
);
let text = stdout(&fixture.run(&[
"show",
"needle",
"src/a.rs",
"--context",
"5",
"--max-lines",
"3",
]));
assert!(text.lines().count() > 3);
assert!(text.contains("ctx11"));
assert!(text.contains("> 6 | needle"));
}
#[test]
fn removed_commands_are_rejected() {
let fixture = Fixture::new();
for command in ["count", "terms"] {
let output = fixture.run(&[command, "needle"]);
assert!(!output.status.success());
assert!(stderr(&output).contains("unknown command"));
}
}
#[test]
fn empty_queries_are_rejected() {
let fixture = Fixture::new();
let output = fixture.run(&["scout", ""]);
assert!(!output.status.success());
assert!(stderr(&output).contains("must not be empty"));
}
#[test]
fn noisy_paths_and_extensions_are_excluded_by_default() {
let fixture = Fixture::new();
fixture.write("src/keep.rs", "needle\n");
fixture.write(".git/config", "needle\n");
fixture.write("target/output.txt", "needle\n");
fixture.write("scratch/note.txt", "needle\n");
fixture.write("run.log", "needle\n");
fixture.write("data.jsonl", "needle\n");
fixture.write("data.xml", "needle\n");
let text = stdout(&fixture.run(&["scout", "needle"]));
assert!(text.contains(" matches: 1"));
assert!(text.contains(" files: 1"));
assert!(text.contains("src/keep.rs"));
assert!(!text.contains("target"));
}
#[test]
fn output_limits_are_hard_caps() {
let fixture = Fixture::new();
for index in 0..200 {
fixture.write(
&format!("src/file_{index:03}.rs"),
&format!("needle {}\n", "x".repeat(1_000)),
);
}
let text = stdout(&fixture.run(&[
"sample",
"needle",
".",
"--max-lines",
"9999",
"--max-bytes",
"999999",
]));
assert_bounded(&text, 40, 8_000);
}
#[test]
fn broad_queries_request_narrowing_instead_of_dumping_matches() {
let fixture = Fixture::new();
for index in 0..110 {
fixture.write(&format!("src/file_{index:03}.rs"), "needle\n");
}
let text = stdout(&fixture.run(&["sample", "needle"]));
assert!(text.lines().nth(1).unwrap().contains("Query is broad"));
assert_bounded(&text, 20, 6_000);
}
#[test]
fn error_output_is_bounded() {
let fixture = Fixture::new();
let long_invalid_regex = format!("({}", "x".repeat(20_000));
let output = fixture.run(&["scout", &long_invalid_regex, "--regex"]);
assert!(!output.status.success());
assert!(output.stderr.len() <= 401);
}
#[test]
fn help_describes_the_new_workflow() {
let fixture = Fixture::new();
let text = stdout(&fixture.run(&["--help"]));
assert!(text.contains("asrch survey"));
assert!(text.contains("asrch scout"));
assert!(!text.contains("asrch terms"));
assert_bounded(&text, 20, 6_000);
}
#[test]
fn test_fixture_paths_are_valid() {
let fixture = Fixture::new();
assert!(Path::new(&fixture.root).is_dir());
}