use assert_cmd::Command;
use predicates::prelude::*;
struct Corpus {
dir: tempfile::TempDir,
index: tempfile::TempDir,
}
fn corpus() -> Corpus {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("alpha.log"),
"one ERROR here\nclean line\nanother ERROR line\ntail\n",
)
.unwrap();
std::fs::write(
dir.path().join("beta.log"),
"all quiet\nnothing to see\na.b literal\n",
)
.unwrap();
std::fs::write(dir.path().join("gamma.txt"), "ERROR ERROR double\n").unwrap();
let index = tempfile::tempdir().unwrap();
holys3()
.args(["index"])
.arg(dir.path())
.arg("--out")
.arg(index.path())
.assert()
.success();
Corpus { dir, index }
}
fn holys3() -> Command {
Command::cargo_bin("holys3").unwrap()
}
fn search(c: &Corpus) -> Command {
let mut cmd = holys3();
cmd.arg("ERROR")
.arg(c.dir.path())
.arg("--index")
.arg(c.index.path());
cmd
}
fn search_pattern(c: &Corpus, pattern: &str) -> Command {
let mut cmd = holys3();
cmd.arg(pattern)
.arg(c.dir.path())
.arg("--index")
.arg(c.index.path());
cmd
}
fn key(c: &Corpus, name: &str) -> String {
c.dir
.path()
.join(name)
.display()
.to_string()
.replace('\\', "/")
}
fn sorted_lines(output: &[u8]) -> Vec<String> {
let mut lines: Vec<String> = String::from_utf8_lossy(output)
.lines()
.map(str::to_owned)
.collect();
lines.sort();
lines
}
#[test]
fn exit_codes() {
let c = corpus();
search(&c).assert().success();
search_pattern(&c, "NO_SUCH_TOKEN").assert().code(1);
search_pattern(&c, "(").assert().code(2);
holys3().args(["x", "s3://"]).assert().code(2);
holys3().assert().code(2); }
#[test]
fn piped_default_format_and_flags() {
let c = corpus();
let out = search(&c).output().unwrap();
assert_eq!(
sorted_lines(&out.stdout),
vec![
format!("{}:another ERROR line", key(&c, "alpha.log")),
format!("{}:one ERROR here", key(&c, "alpha.log")),
format!("{}:ERROR ERROR double", key(&c, "gamma.txt")),
]
);
let out = search(&c).arg("-n").output().unwrap();
assert!(
sorted_lines(&out.stdout).contains(&format!("{}:1:one ERROR here", key(&c, "alpha.log")))
);
let out = search(&c).args(["--column"]).output().unwrap();
assert!(
sorted_lines(&out.stdout).contains(&format!("{}:1:5:one ERROR here", key(&c, "alpha.log")))
);
let out = search_pattern(&c, "one ERROR")
.arg("--heading")
.arg("-N")
.output()
.unwrap();
assert_eq!(
String::from_utf8_lossy(&out.stdout),
format!("{}\none ERROR here\n", key(&c, "alpha.log"))
);
}
#[test]
fn context_rendering() {
let c = corpus();
let out = search_pattern(&c, "ERROR")
.args(["-C", "1", "-g", "alpha.log", "-n"])
.output()
.unwrap();
let k = key(&c, "alpha.log");
assert_eq!(
String::from_utf8_lossy(&out.stdout),
format!("{k}:1:one ERROR here\n{k}-2-clean line\n{k}:3:another ERROR line\n{k}-4-tail\n")
);
let out = search_pattern(&c, "another")
.args(["-B", "1", "-g", "alpha.log", "-n"])
.output()
.unwrap();
assert_eq!(
String::from_utf8_lossy(&out.stdout),
format!("{k}-2-clean line\n{k}:3:another ERROR line\n")
);
}
#[test]
fn pattern_semantics() {
let c = corpus();
let out = search_pattern(&c, "a.b")
.arg("-F")
.arg("-l")
.output()
.unwrap();
assert_eq!(sorted_lines(&out.stdout), vec![key(&c, "beta.log")]);
search_pattern(&c, "error").assert().code(1);
search_pattern(&c, "error").arg("-i").assert().success();
search_pattern(&c, "error").arg("-S").assert().success();
search_pattern(&c, "Error").arg("-S").assert().code(1);
search_pattern(&c, "ERRO").assert().success();
search_pattern(&c, "ERRO").arg("-w").assert().code(1);
let mut cmd = holys3();
cmd.args(["-e", "quiet", "-e", "tail"])
.arg(c.dir.path())
.arg("--index")
.arg(c.index.path())
.arg("-l");
let out = cmd.output().unwrap();
assert_eq!(
sorted_lines(&out.stdout),
vec![key(&c, "alpha.log"), key(&c, "beta.log")]
);
let out = search(&c)
.args(["-m", "1", "-g", "alpha.log"])
.output()
.unwrap();
assert_eq!(sorted_lines(&out.stdout).len(), 1);
}
#[test]
fn modes() {
let c = corpus();
let out = search(&c).arg("-l").output().unwrap();
assert_eq!(
sorted_lines(&out.stdout),
vec![key(&c, "alpha.log"), key(&c, "gamma.txt")]
);
let out = search(&c).arg("-c").output().unwrap();
assert_eq!(
sorted_lines(&out.stdout),
vec![
format!("{}:2", key(&c, "alpha.log")),
format!("{}:1", key(&c, "gamma.txt"))
]
);
let out = search(&c).arg("--count-matches").output().unwrap();
assert_eq!(
sorted_lines(&out.stdout),
vec![
format!("{}:2", key(&c, "alpha.log")),
format!("{}:2", key(&c, "gamma.txt"))
]
);
let out = search(&c).arg("-q").output().unwrap();
assert!(out.stdout.is_empty());
assert!(out.status.success());
search_pattern(&c, "NO_SUCH_TOKEN")
.arg("-q")
.assert()
.code(1);
}
#[test]
fn glob_filters() {
let c = corpus();
let out = search(&c).args(["-l", "-g", "*.txt"]).output().unwrap();
assert_eq!(sorted_lines(&out.stdout), vec![key(&c, "gamma.txt")]);
let out = search(&c).args(["-l", "-g", "!*.txt"]).output().unwrap();
assert_eq!(sorted_lines(&out.stdout), vec![key(&c, "alpha.log")]);
}
#[test]
fn json_wire_format() {
let c = corpus();
let out = search(&c).arg("--json").output().unwrap();
let lines: Vec<serde_json::Value> = String::from_utf8_lossy(&out.stdout)
.lines()
.map(|l| serde_json::from_str(l).unwrap())
.collect();
assert_eq!(lines.last().unwrap()["type"], "summary");
let types: Vec<&str> = lines.iter().map(|v| v["type"].as_str().unwrap()).collect();
assert!(types.contains(&"begin") && types.contains(&"match") && types.contains(&"end"));
let m = lines.iter().find(|v| v["type"] == "match").unwrap();
let data = &m["data"];
assert!(data["line_number"].as_u64().is_some());
let text = data["lines"]["text"].as_str().unwrap();
let sub = &data["submatches"][0];
let (s, e) = (
sub["start"].as_u64().unwrap() as usize,
sub["end"].as_u64().unwrap() as usize,
);
assert_eq!(&text[s..e], sub["match"]["text"].as_str().unwrap());
let summary = &lines.last().unwrap()["data"]["stats"];
assert_eq!(summary["matched_lines"].as_u64().unwrap(), 3);
assert_eq!(summary["matches"].as_u64().unwrap(), 4);
}
#[test]
fn color_control() {
let c = corpus();
let out = search(&c).args(["--color", "always"]).output().unwrap();
assert!(String::from_utf8_lossy(&out.stdout).contains("\x1b["));
let out = search(&c).output().unwrap();
assert!(!String::from_utf8_lossy(&out.stdout).contains("\x1b["));
}
#[test]
fn anchors_match_at_line_boundaries() {
let c = corpus();
let out = search_pattern(&c, "^tail").arg("-l").output().unwrap();
assert_eq!(sorted_lines(&out.stdout), vec![key(&c, "alpha.log")]);
let out = search_pattern(&c, "ERROR line$")
.arg("-l")
.output()
.unwrap();
assert_eq!(sorted_lines(&out.stdout), vec![key(&c, "alpha.log")]);
search_pattern(&c, "^nowhere$").assert().code(1);
}
#[test]
fn max_count_zero_yields_nothing_in_every_mode() {
let c = corpus();
for flags in [
vec!["-m", "0"],
vec!["-m", "0", "-l"],
vec!["-m", "0", "-q"],
vec!["-m", "0", "-c"],
] {
let out = search(&c).args(&flags).output().unwrap();
assert!(out.stdout.is_empty(), "stdout not empty for {flags:?}");
assert_eq!(out.status.code(), Some(1), "exit code for {flags:?}");
}
}
#[test]
fn json_emits_context_messages() {
let c = corpus();
let out = search_pattern(&c, "another")
.args(["--json", "-C", "1"])
.output()
.unwrap();
let types: Vec<String> = String::from_utf8_lossy(&out.stdout)
.lines()
.map(|l| {
serde_json::from_str::<serde_json::Value>(l).unwrap()["type"]
.as_str()
.unwrap()
.to_owned()
})
.collect();
assert!(
types.contains(&"context".to_owned()),
"no context messages: {types:?}"
);
}
#[test]
fn nonexistent_local_target_errors() {
let c = corpus();
let mut cmd = holys3();
cmd.arg("ERROR")
.arg("/definitely/not/a/real/dir")
.arg("--index")
.arg(c.index.path());
cmd.assert().code(2);
}
#[test]
fn literal_newline_in_pattern_is_rejected() {
let c = corpus();
search_pattern(&c, "ERROR\ntail").assert().code(2);
}
#[test]
fn broken_pipe_is_not_an_error() {
let c = corpus();
let assert = search(&c).assert();
assert.success().stdout(predicate::str::contains("ERROR"));
}