use std::path::{Path, PathBuf};
pub const HEADER: &str = "# amont-agent-cases-v1";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Verdict {
Match,
NoMatch,
Unreviewed,
}
impl Verdict {
pub fn as_str(self) -> &'static str {
match self {
Verdict::Match => "match",
Verdict::NoMatch => "nomatch",
Verdict::Unreviewed => "?",
}
}
fn parse(s: &str) -> Option<Verdict> {
match s {
"match" => Some(Verdict::Match),
"nomatch" => Some(Verdict::NoMatch),
"?" => Some(Verdict::Unreviewed),
_ => None,
}
}
}
pub struct Case {
pub verdict: Verdict,
pub command: String,
pub line: usize,
}
pub fn path_for(rule: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("corpus")
.join(format!("{rule}.cases"))
}
const EMBEDDED: &[(&str, &str)] = &[
(
"pipe-to-tail",
include_str!("../tests/corpus/pipe-to-tail.cases"),
),
(
"poll-blank-verdict",
include_str!("../tests/corpus/poll-blank-verdict.cases"),
),
(
"bare-stash-pop",
include_str!("../tests/corpus/bare-stash-pop.cases"),
),
(
"gh-pr-merge-auto",
include_str!("../tests/corpus/gh-pr-merge-auto.cases"),
),
("no-verify", include_str!("../tests/corpus/no-verify.cases")),
(
"git-add-broad",
include_str!("../tests/corpus/git-add-broad.cases"),
),
(
"stale-base",
include_str!("../tests/corpus/stale-base.cases"),
),
(
"push-preflight",
include_str!("../tests/corpus/push-preflight.cases"),
),
(
"foreground-poll",
include_str!("../tests/corpus/foreground-poll.cases"),
),
(
"sed-in-place",
include_str!("../tests/corpus/sed-in-place.cases"),
),
(
"kubectl-gitops",
include_str!("../tests/corpus/kubectl-gitops.cases"),
),
(
"tag-after-commit",
include_str!("../tests/corpus/tag-after-commit.cases"),
),
(
"worktree-remove-force",
include_str!("../tests/corpus/worktree-remove-force.cases"),
),
(
"amend-pushed",
include_str!("../tests/corpus/amend-pushed.cases"),
),
(
"branch-force-delete",
include_str!("../tests/corpus/branch-force-delete.cases"),
),
];
pub fn embedded(rule: &str) -> Option<&'static str> {
EMBEDDED
.iter()
.find(|(id, _)| *id == rule)
.map(|(_, text)| *text)
}
pub fn checkout_present() -> bool {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("corpus")
.is_dir()
}
pub fn escape(command: &str) -> String {
let mut out = String::with_capacity(command.len());
for c in command.chars() {
match c {
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c => out.push(c),
}
}
out
}
pub fn unescape(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut chars = text.chars();
while let Some(c) = chars.next() {
if c != '\\' {
out.push(c);
continue;
}
match chars.next() {
Some('n') => out.push('\n'),
Some('r') => out.push('\r'),
Some('t') => out.push('\t'),
Some('\\') => out.push('\\'),
Some(other) => {
out.push('\\');
out.push(other);
}
None => out.push('\\'),
}
}
out
}
pub fn line_for(verdict: Verdict, command: &str) -> String {
format!("{}\t{}\n", verdict.as_str(), escape(command))
}
pub fn parse(text: &str) -> Vec<Case> {
let mut out = Vec::new();
for (i, line) in text.lines().enumerate() {
let line = line.trim_end();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some((verdict, command)) = line.split_once('\t') else {
continue;
};
let Some(verdict) = Verdict::parse(verdict.trim()) else {
continue;
};
out.push(Case {
verdict,
command: unescape(command),
line: i + 1,
});
}
out
}
pub fn read(rule: &str) -> Vec<Case> {
let path = path_for(rule);
if path.exists() {
return read_at(&path);
}
embedded(rule).map(parse).unwrap_or_default()
}
pub fn read_at(path: &Path) -> Vec<Case> {
std::fs::read_to_string(path)
.map(|t| parse(&t))
.unwrap_or_default()
}
pub struct Score {
pub reviewed: usize,
pub negatives: usize,
pub unreviewed: usize,
pub disagreements: Vec<Disagreement>,
}
pub struct Disagreement {
pub line: usize,
pub expected: Verdict,
pub command: String,
}
impl Score {
pub fn agrees(&self) -> bool {
self.disagreements.is_empty()
}
pub fn precision(&self) -> Option<f64> {
let claimed = self
.disagreements
.iter()
.filter(|d| d.expected == Verdict::NoMatch)
.count();
let matched = self.reviewed - self.negatives;
let total = matched + claimed;
if total == 0 {
None
} else {
Some(matched as f64 / total as f64)
}
}
}
pub fn score(rule: &crate::rules::Rule) -> Score {
score_cases(rule, &read(rule.id))
}
pub fn score_cases(rule: &crate::rules::Rule, cases: &[Case]) -> Score {
let mut score = Score {
reviewed: 0,
negatives: 0,
unreviewed: 0,
disagreements: Vec::new(),
};
for case in cases {
if case.verdict == Verdict::Unreviewed {
score.unreviewed += 1;
continue;
}
score.reviewed += 1;
if case.verdict == Verdict::NoMatch {
score.negatives += 1;
}
let parsed = crate::shell::lex(&case.command);
let fired = (rule.examine)(&parsed).is_some();
let expected = case.verdict == Verdict::Match;
if fired != expected {
score.disagreements.push(Disagreement {
line: case.line,
expected: case.verdict,
command: case.command.clone(),
});
}
}
score
}
#[cfg(test)]
mod tests {
#[test]
fn every_rule_has_an_embedded_corpus() {
for rule in crate::rules::RULES {
let text = super::embedded(rule.id)
.unwrap_or_else(|| panic!("rule `{}` has no entry in EMBEDDED", rule.id));
assert!(
text.starts_with(super::HEADER),
"embedded corpus for `{}` lacks the header",
rule.id
);
}
}
#[test]
fn the_embedded_corpus_is_the_file() {
for rule in crate::rules::RULES {
let from_file = super::read_at(&super::path_for(rule.id));
let from_binary = super::parse(super::embedded(rule.id).unwrap());
assert_eq!(from_file.len(), from_binary.len(), "{}", rule.id);
}
}
use super::*;
#[test]
fn a_command_survives_the_round_trip() {
for command in [
"git push | tail -1",
"git commit -F- <<'MSG' 2>&1 | tail -8\nsubject\n\nbody\nMSG\n",
"echo 'a\tb' && git push",
"a\\nb literal backslash-n",
"trailing backslash \\",
] {
let line = line_for(Verdict::Match, command);
assert_eq!(line.matches('\n').count(), 1, "one line per case");
let back = parse(&line);
assert_eq!(back.len(), 1);
assert_eq!(back[0].command, command, "round trip changed {command:?}");
}
}
#[test]
fn comments_and_blank_lines_are_not_cases() {
let text = format!("{HEADER}\n\n# a note\nmatch\tgit push | tail -1\n");
let cases = parse(&text);
assert_eq!(cases.len(), 1);
assert_eq!(cases[0].verdict, Verdict::Match);
}
#[test]
fn an_unreviewed_case_counts_as_no_evidence() {
let rule = crate::rules::by_id("pipe-to-tail").unwrap();
let cases = parse("?\tgit push | tail -1\n?\tgit status\n");
let s = score_cases(rule, &cases);
assert_eq!(s.reviewed, 0);
assert_eq!(s.unreviewed, 2);
assert!(s.agrees(), "nothing was claimed, so nothing can disagree");
assert_eq!(s.precision(), None, "unmeasured, not perfect");
}
#[test]
fn a_disagreement_names_the_line_and_the_command() {
let rule = crate::rules::by_id("pipe-to-tail").unwrap();
let cases = parse("nomatch\tgit push origin main | tail -1\n");
let s = score_cases(rule, &cases);
assert!(!s.agrees());
assert_eq!(s.disagreements[0].line, 1);
assert_eq!(s.disagreements[0].expected, Verdict::NoMatch);
}
}