use std::path::Path;
use std::process::{Command, Stdio};
use std::sync::OnceLock;
use crate::check::{Check, Fix, Outcome, Scope, Severity, Stage};
use crate::hooks::common::Restaged;
use crate::registry::{Ctx, CHECKS, ENTRYPOINTS};
pub const MANIFEST: &str = "amont.conf";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ParseError {
MissingFields,
MissingName,
NameTaken(String),
TriggerInName(String),
Duplicate(String),
BadStage(String),
BadScope(String),
BadSeverity(String),
FixOnPrePush,
}
impl std::fmt::Display for ParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ParseError::MissingFields => {
write!(f, "expected 5 fields: stage name scope severity command")
}
ParseError::MissingName => write!(f, "missing name"),
ParseError::NameTaken(n) => write!(f, "{n:?} already names a check"),
ParseError::TriggerInName(n) => write!(
f,
"{n:?} must not be a trigger or start with one — the stage column says which"
),
ParseError::Duplicate(n) => write!(f, "{n:?} is declared twice on one trigger"),
ParseError::BadStage(t) => {
write!(f, "stage {t:?} must be `pre-commit` or `pre-push`")
}
ParseError::BadScope(t) => write!(f, "scope {t:?} must be `*` or `*.<ext>`"),
ParseError::FixOnPrePush => write!(
f,
"`fix` is only for pre-commit — a pre-push hook must not rewrite files"
),
ParseError::BadSeverity(t) => {
write!(f, "severity {t:?} must be `block` or `warn`")
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Declared {
pub fix: Fix,
pub name: String,
pub stage: Stage,
pub severity: Severity,
pub exts: Vec<String>,
pub program: String,
pub args: Vec<String>,
}
impl Declared {
pub fn id(&self) -> String {
format!("{}-{}", self.stage.as_str(), self.name)
}
pub fn command(&self) -> String {
std::iter::once(self.program.as_str())
.chain(self.args.iter().map(String::as_str))
.collect::<Vec<_>>()
.join(" ")
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Line {
Usable(Declared),
Broken {
name: String,
stage: Stage,
lineno: usize,
why: ParseError,
},
}
impl Line {
pub fn name(&self) -> &str {
match self {
Line::Usable(d) => &d.name,
Line::Broken { name, .. } => name,
}
}
pub fn stage(&self) -> Stage {
match self {
Line::Usable(d) => d.stage,
Line::Broken { stage, .. } => *stage,
}
}
pub fn broken(&self) -> Option<String> {
match self {
Line::Usable(_) => None,
Line::Broken { lineno, why, .. } => Some(format!("line {lineno}: {why}")),
}
}
pub fn id(&self) -> String {
format!("{}-{}", self.stage().as_str(), self.name())
}
pub fn into_parts(self) -> (String, Stage, Result<Declared, String>) {
let name = self.name().to_string();
let stage = self.stage();
let parsed = match self {
Line::Usable(d) => Ok(d),
Line::Broken { lineno, why, .. } => Err(format!("line {lineno}: {why}")),
};
(name, stage, parsed)
}
}
pub struct External {
pub id: String,
pub short_name: String,
pub stage: Stage,
pub kind: Kind,
}
pub enum Kind {
Runnable {
scope: Scope,
severity: Severity,
program: String,
args: Vec<String>,
fix: Fix,
},
Unusable {
why: String,
},
}
impl Check for External {
fn name(&self) -> &str {
&self.id
}
fn stage(&self) -> Stage {
self.stage
}
fn scope(&self) -> Scope {
match &self.kind {
Kind::Runnable { scope, .. } => *scope,
Kind::Unusable { .. } => Scope::ALWAYS,
}
}
fn severity(&self) -> Severity {
match &self.kind {
Kind::Runnable { severity, .. } => *severity,
Kind::Unusable { .. } => Severity::Warn,
}
}
fn run(&self, ctx: &Ctx) -> Outcome {
let (scope, program, args, fix) = match &self.kind {
Kind::Runnable {
scope,
program,
args,
fix,
..
} => (scope, program, args, *fix),
Kind::Unusable { why } => {
crate::hooks::common::warn(&format!(
"{MANIFEST}: {} — {}",
crate::ui::highlight(&self.short_name),
crate::ui::sanitize(why)
));
return Outcome::Unavailable;
}
};
if fix == Fix::Rewrite && !crate::hooks::common::fixing_enabled() {
crate::hooks::common::warn(&format!(
"{}: declares fix, and {} is off — not run",
crate::ui::highlight(&self.short_name),
crate::ui::highlight("amont.fix")
));
return Outcome::Unavailable;
}
let in_scope = match self.stage {
Stage::PreCommit => crate::hooks::common::staged_files(&[]),
Stage::PrePush => crate::pushrefs::changed_files(ctx.push.get()),
};
if !scope.files.is_empty() && !scope.matches(&in_scope) {
return Outcome::Passed;
}
let root = crate::hooks::common::repo_root();
let mut cmd = Command::new(program);
cmd.args(args).current_dir(&root).stdin(Stdio::null());
crate::hooks::common::strip_git_env(&mut cmd);
match cmd.status() {
Err(e) => {
crate::hooks::common::warn(&format!(
"{MANIFEST}: {} could not run {} — {}",
crate::ui::highlight(&self.short_name),
crate::ui::highlight(program),
crate::ui::sanitize(&e.to_string())
));
Outcome::Unavailable
}
Ok(s) if s.success() => {
if fix == Fix::Rewrite && crate::hooks::common::fixing_enabled() {
match crate::hooks::common::restage(&scoped(scope, &in_scope)) {
Restaged::Staged => {
crate::hooks::common::ok(&format!(
"{} fixed and re-staged",
crate::ui::highlight(&self.short_name)
));
return Outcome::Fixed;
}
Restaged::Failed(stuck) => {
crate::hooks::common::fail(&format!(
"{} rewrote files but {} failed — the index still holds the \
OLD content: {}",
crate::ui::highlight(&self.short_name),
crate::ui::highlight("git add"),
crate::ui::sanitize(&stuck.join(", "))
));
return Outcome::Failed;
}
Restaged::Nothing => {}
}
}
Outcome::Passed
}
Ok(_) => {
crate::hooks::common::fail(&format!(
"{} failed (output above)",
crate::ui::highlight(&self.short_name)
));
Outcome::Failed
}
}
}
}
fn scoped(scope: &Scope, paths: &[String]) -> Vec<String> {
if scope.files.is_empty() {
return paths.to_vec();
}
paths
.iter()
.filter(|p| scope.files.iter().any(|e| p.ends_with(e)))
.cloned()
.collect()
}
fn leak(exts: Vec<String>) -> &'static [&'static str] {
let refs: Vec<&'static str> = exts
.into_iter()
.map(|s| &*Box::leak(s.into_boxed_str()))
.collect();
Box::leak(refs.into_boxed_slice())
}
fn parse_scope(token: &str) -> Result<Vec<String>, ParseError> {
if token == "*" {
return Ok(Vec::new());
}
let mut exts = Vec::new();
for part in token.split(',') {
let ext = part
.strip_prefix('*')
.filter(|ext| ext.starts_with('.'))
.ok_or_else(|| ParseError::BadScope(part.to_string()))?;
exts.push(ext.to_string());
}
Ok(exts)
}
fn parse_stage(token: &str) -> Option<Stage> {
match token {
"pre-commit" => Some(Stage::PreCommit),
"pre-push" => Some(Stage::PrePush),
_ => None,
}
}
fn name_is_taken(id: &str) -> bool {
CHECKS.iter().any(|c| c.name == id) || ENTRYPOINTS.iter().any(|(n, _)| *n == id)
}
fn name_says_its_trigger(name: &str) -> bool {
crate::TRIGGERS
.iter()
.any(|t| name == *t || name.starts_with(&format!("{t}-")))
}
fn tokenise(line: &str) -> Option<([&str; 4], &str)> {
let mut fields: [&str; 4] = [""; 4];
let mut rest = line;
for slot in fields.iter_mut() {
rest = rest.trim_start();
let i = rest.find(char::is_whitespace)?;
*slot = &rest[..i];
rest = &rest[i..];
}
let command = rest.trim();
(!command.is_empty()).then_some((fields, command))
}
pub fn parse_lines(text: &str) -> Vec<Line> {
let mut out: Vec<Line> = Vec::new();
for (i, raw) in text.lines().enumerate() {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let lineno = i + 1;
out.push(parse_line(lineno, line, &out));
}
out
}
fn parse_line(lineno: usize, line: &str, earlier: &[Line]) -> Line {
let (fields, command) = match tokenise(line) {
Some(t) => t,
None => {
return broken_at(
lineno,
name_or_position(tokenise(line).map(|(f, _)| f[1]).unwrap_or(""), lineno),
None,
ParseError::MissingFields,
)
}
};
let [stage_tok, declared, scope_tok, severity_tok] = fields;
let stage = parse_stage(stage_tok);
let name = name_or_position(declared, lineno);
let fail = |why| broken_at(lineno, name.clone(), stage, why);
if declared.is_empty() {
return fail(ParseError::MissingName);
}
let Some(stage) = stage else {
return fail(ParseError::BadStage(stage_tok.to_string()));
};
if name_says_its_trigger(declared) {
return fail(ParseError::TriggerInName(declared.to_string()));
}
let id = format!("{}-{}", stage.as_str(), declared);
if name_is_taken(&id) {
return fail(ParseError::NameTaken(declared.to_string()));
}
if earlier
.iter()
.any(|l| matches!(l, Line::Usable(d) if d.id() == id))
{
return fail(ParseError::Duplicate(declared.to_string()));
}
let exts = match parse_scope(scope_tok) {
Ok(e) => e,
Err(why) => return fail(why),
};
let Some(severity) = Severity::parse(severity_tok) else {
return fail(ParseError::BadSeverity(severity_tok.to_string()));
};
let (command, wants_fix) = match command.strip_prefix("fix ") {
Some(rest) => (rest.trim(), true),
None => (command, false),
};
if wants_fix && stage == Stage::PrePush {
return fail(ParseError::FixOnPrePush);
}
let mut argv = command.split_whitespace().map(str::to_owned);
let Some(program) = argv.next() else {
return fail(ParseError::MissingFields);
};
Line::Usable(Declared {
fix: if wants_fix { Fix::Rewrite } else { Fix::None },
name: declared.to_string(),
stage,
severity,
exts,
program,
args: argv.collect(),
})
}
fn name_or_position(declared: &str, lineno: usize) -> String {
if declared.is_empty() {
format!("{MANIFEST}:{lineno}")
} else {
declared.to_string()
}
}
fn broken_at(lineno: usize, name: String, stage: Option<Stage>, why: ParseError) -> Line {
Line::Broken {
name,
stage: stage.unwrap_or(Stage::PreCommit),
lineno,
why,
}
}
impl From<Line> for External {
fn from(l: Line) -> External {
let (name, stage, parsed) = l.into_parts();
let kind = match parsed {
Ok(d) => Kind::Runnable {
scope: if d.exts.is_empty() {
Scope::ALWAYS
} else {
Scope::files(leak(d.exts))
},
severity: d.severity,
program: d.program,
args: d.args,
fix: d.fix,
},
Err(why) => Kind::Unusable { why },
};
let id = format!("{}-{}", stage.as_str(), name);
External {
id,
short_name: name,
stage,
kind,
}
}
}
pub fn parse(text: &str) -> Vec<External> {
parse_lines(text).into_iter().map(External::from).collect()
}
pub fn read(root: &Path) -> Vec<External> {
std::fs::read_to_string(root.join(MANIFEST))
.map(|t| parse(&t))
.unwrap_or_default()
}
pub fn read_lines(root: &Path) -> Vec<Line> {
std::fs::read_to_string(root.join(MANIFEST))
.map(|t| parse_lines(&t))
.unwrap_or_default()
}
pub(crate) fn externals() -> &'static [External] {
static EXTERNALS: OnceLock<Vec<External>> = OnceLock::new();
EXTERNALS.get_or_init(|| {
let root = crate::hooks::common::repo_root();
let root = Path::new(&root);
let Ok(bytes) = std::fs::read(root.join(MANIFEST)) else {
return Vec::new();
};
let Ok(text) = String::from_utf8(bytes.clone()) else {
return Vec::new();
};
gate(parse(&text), crate::trust::state_of(root, &bytes))
})
}
pub(crate) fn gate(declared: Vec<External>, state: crate::trust::State) -> Vec<External> {
match crate::trust::why(state) {
None => declared,
Some(reason) => declared
.into_iter()
.map(|external| External {
kind: Kind::Unusable {
why: reason.to_string(),
},
..external
})
.collect(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn one(text: &str) -> Line {
let mut v = parse_lines(text);
assert_eq!(v.len(), 1, "expected one entry from {text:?}");
v.pop().expect("one")
}
fn why(l: &Line) -> ParseError {
match l {
Line::Broken { why, .. } => why.clone(),
Line::Usable(d) => panic!("{} parsed when it should not have", d.name),
}
}
fn usable(l: &Line) -> &Declared {
match l {
Line::Usable(d) => d,
Line::Broken { name, why, .. } => panic!("{name} failed to parse: {why}"),
}
}
#[test]
fn parses_the_documented_example() {
let v = parse_lines(
"# stage name scope severity command\n\
pre-commit shellcheck *.sh block scripts/lint-shell.sh\n\
pre-push smoke * warn make smoke\n",
);
assert_eq!(v.len(), 2);
let a = usable(&v[0]);
assert_eq!(a.name, "shellcheck");
assert_eq!(a.stage, Stage::PreCommit);
assert_eq!(a.severity, Severity::Block);
assert_eq!(a.program, "scripts/lint-shell.sh");
assert!(a.args.is_empty());
assert_eq!(a.exts, [".sh"]);
let b = usable(&v[1]);
assert_eq!(b.stage, Stage::PrePush);
assert_eq!(b.severity, Severity::Warn);
assert_eq!(b.program, "make");
assert_eq!(b.args, ["smoke"]);
assert!(b.exts.is_empty(), "`*` gates on nothing");
}
#[test]
fn comments_and_blank_lines_produce_nothing() {
assert!(parse_lines("\n \n# just a comment\n\t# indented\n").is_empty());
}
#[test]
fn a_malformed_line_becomes_a_visible_gap() {
let cases: [(&str, ParseError); 4] = [
(
"pre-commit shellcheck *.sh block\n",
ParseError::MissingFields,
),
(
"nonsense shellcheck *.sh block x\n",
ParseError::BadStage("nonsense".into()),
),
(
"pre-commit shellcheck ?.sh block x\n",
ParseError::BadScope("?.sh".into()),
),
(
"pre-commit shellcheck *.sh loud x\n",
ParseError::BadSeverity("loud".into()),
),
];
for (text, expected) in cases {
assert_eq!(why(&one(text)), expected, "for {text:?}");
}
}
#[test]
fn a_gap_reports_where_it_is() {
let l = one("pre-commit shellcheck *.sh loud x\n");
let said = l.broken().expect("broken");
assert!(said.contains("line 1"), "{said}");
assert!(said.contains("severity"), "{said}");
}
#[test]
fn fix_is_refused_on_a_pre_push_line() {
assert_eq!(
why(&one("pre-push smoke * block fix make smoke\n")),
ParseError::FixOnPrePush
);
let line = one("pre-commit fmt * block fix make format\n");
let declared = usable(&line);
assert_eq!(declared.fix, Fix::Rewrite);
assert_eq!(declared.program, "make");
assert_eq!(declared.args, ["format"]);
}
#[test]
fn a_command_that_merely_starts_with_fix_is_not_a_marker() {
let line = one("pre-commit x * block fixup-tool --check\n");
let declared = usable(&line);
assert_eq!(declared.fix, Fix::None);
assert_eq!(declared.program, "fixup-tool");
}
#[test]
fn a_nameless_line_is_named_after_its_position() {
let l = one("pre-commit\n");
assert_eq!(l.name(), "amont.conf:1");
assert_eq!(why(&l), ParseError::MissingFields);
}
#[test]
fn a_built_in_id_is_refused() {
assert_eq!(
why(&one("pre-commit clippy *.rs block x\n")),
ParseError::NameTaken("clippy".into())
);
assert!(matches!(
one("pre-push clippy *.rs block x\n"),
Line::Usable(_)
));
assert_eq!(
why(&one("pre-push branch-protect * block x\n")),
ParseError::NameTaken("branch-protect".into())
);
assert!(matches!(
one("pre-commit branch-protect * block x\n"),
Line::Usable(_)
));
}
#[test]
fn a_name_that_says_its_own_trigger_is_refused() {
for name in ["pre-commit", "pre-push", "pre-commit-clippy", "pre-push-x"] {
assert_eq!(
why(&one(&format!("pre-commit {name} * block x\n"))),
ParseError::TriggerInName(name.into()),
"{name}"
);
}
assert!(matches!(
one("pre-commit pre-commitish * block x\n"),
Line::Usable(_)
));
}
#[test]
fn a_duplicate_id_is_refused() {
let v = parse_lines(
"pre-commit smoke * block a\n\
pre-commit smoke * block b\n",
);
assert_eq!(v.len(), 2);
assert_eq!(usable(&v[0]).id(), "pre-commit-smoke");
assert_eq!(why(&v[1]), ParseError::Duplicate("smoke".into()));
}
#[test]
fn the_same_name_on_two_triggers_is_allowed() {
let v = parse_lines(
"pre-commit show-unicorn * block a\n\
pre-push show-unicorn * block b\n",
);
assert_eq!(v.len(), 2);
assert_eq!(usable(&v[0]).id(), "pre-commit-show-unicorn");
assert_eq!(usable(&v[1]).id(), "pre-push-show-unicorn");
for (id, only) in [
("pre-commit-show-unicorn", "pre-push-show-unicorn"),
("pre-push-show-unicorn", "pre-commit-show-unicorn"),
] {
assert!(crate::skip_suppresses(id, id));
assert!(!crate::skip_suppresses(only, id));
}
assert!(crate::skip_suppresses(
"pre-commit-show-unicorn",
"show-unicorn"
));
assert!(crate::skip_suppresses(
"pre-push-show-unicorn",
"show-unicorn"
));
assert!(crate::skip_suppresses(
"pre-commit-show-unicorn",
"pre-commit"
));
assert!(!crate::skip_suppresses(
"pre-push-show-unicorn",
"pre-commit"
));
}
#[test]
fn a_broken_line_does_not_reserve_its_name() {
let v = parse_lines(
"pre-commit smoke * LOUD make a\n\
pre-commit smoke * block make b\n",
);
assert_eq!(v.len(), 2);
assert_eq!(why(&v[0]), ParseError::BadSeverity("LOUD".into()));
let good = usable(&v[1]);
assert_eq!(good.name, "smoke");
assert_eq!(good.program, "make");
}
#[test]
fn field_alignment_does_not_matter() {
let spaced = one("pre-commit shellcheck *.sh block make lint\n");
let tabbed = one("pre-commit\tshellcheck\t*.sh\tblock\tmake lint\n");
assert_eq!(usable(&spaced), usable(&tabbed));
assert_eq!(usable(&spaced).args, ["lint"]);
}
#[test]
fn several_extensions_can_gate_one_check() {
let e = External::from(one("pre-commit shell *.sh,*.bash block make lint\n"));
assert!(e.scope().matches(&["a.bash".into()]));
assert!(e.scope().matches(&["a.sh".into()]));
assert!(!e.scope().matches(&["a.zsh".into()]));
}
#[test]
fn tokenise_wants_four_fields_and_a_command() {
assert!(tokenise("a b c").is_none(), "too few fields");
assert!(tokenise("a b c d").is_none(), "four fields, no command");
assert!(
tokenise("a b c d ").is_none(),
"command is all whitespace"
);
assert!(tokenise("a b c d\t").is_none(), "command is a tab");
let (fields, cmd) = tokenise("a b\tc d run it").expect("four and a command");
assert_eq!(fields, ["a", "b", "c", "d"]);
assert_eq!(cmd, "run it");
}
#[test]
fn an_unusable_external_holds_no_command() {
let e = External::from(one("pre-commit shellcheck *.sh loud echo hi\n"));
assert!(matches!(e.kind, Kind::Unusable { .. }));
assert_eq!(e.severity(), Severity::Warn);
}
#[test]
fn a_repository_with_no_manifest_declares_nothing() {
assert!(read(Path::new("/nonexistent-c8f2")).is_empty());
assert!(read_lines(Path::new("/nonexistent-c8f2")).is_empty());
}
#[test]
fn the_leaking_and_non_leaking_parsers_agree() {
let text = "pre-commit shellcheck *.sh,*.bash block make lint\n\
pre-push smoke * warn make smoke\n\
pre-commit broken ? block x\n";
let lines = parse_lines(text);
let externals = parse(text);
assert_eq!(lines.len(), externals.len());
for (l, e) in lines.iter().zip(&externals) {
assert_eq!(l.id(), e.name(), "the id is what a check answers to");
assert_eq!(
l.name(),
e.short_name,
"and the short name is what it is called"
);
assert_eq!(l.stage(), e.stage());
assert_eq!(
l.broken().is_some(),
matches!(e.kind, Kind::Unusable { .. })
);
if let Line::Usable(d) = l {
assert_eq!(d.severity, e.severity());
assert_eq!(d.exts, e.scope().files);
}
}
}
}