use std::path::Path;
use std::process::{Command, Stdio};
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),
BadTool,
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 `*`, `*.<ext>`, or a bare filename (no `/`)"
),
ParseError::BadTool => write!(
f,
"a tool pin is exactly `tool <program> <version-substring>`"
),
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 files: bool,
pub name: String,
pub stage: Stage,
pub severity: Severity,
pub exts: Vec<String>,
pub names: 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 struct ToolPin {
pub program: String,
pub want: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Line {
Usable(Declared),
Tool(ToolPin),
Broken {
name: String,
stage: Stage,
lineno: usize,
why: ParseError,
},
}
impl Line {
pub fn name(&self) -> &str {
match self {
Line::Usable(d) => &d.name,
Line::Tool(pin) => &pin.program,
Line::Broken { name, .. } => name,
}
}
pub fn stage(&self) -> Stage {
match self {
Line::Usable(d) => d.stage,
Line::Tool(_) => Stage::PreCommit,
Line::Broken { stage, .. } => *stage,
}
}
pub fn broken(&self) -> Option<String> {
match self {
Line::Usable(_) | Line::Tool(_) => 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::Tool(pin) => Err(format!("tool pin: {} {}", pin.program, pin.want)),
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,
files: bool,
},
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, files) = match &self.kind {
Kind::Runnable {
scope,
program,
args,
fix,
files,
..
} => (scope, program, args, *fix, *files),
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.is_unscoped() && !scope.matches(&in_scope) {
return Outcome::Passed;
}
let matched = scoped(scope, &in_scope);
if files && matched.is_empty() {
return Outcome::Passed;
}
let root = crate::hooks::common::repo_root();
let mut cmd = Command::new(crate::hooks::common::program(program));
cmd.args(args).current_dir(&root).stdin(Stdio::null());
let joined = matched.join("\n");
cmd.env(
"AMONT_FILES",
if joined.len() <= 100_000 {
joined.as_str()
} else {
""
},
);
if files {
cmd.args(&matched);
}
crate::hooks::common::strip_git_env(&mut cmd);
let status = match crate::hooks::common::status_streamed(&mut cmd) {
Ok(crate::hooks::common::Ran::Status(s)) => Ok(s),
Ok(crate::hooks::common::Ran::TimedOut(budget)) => {
crate::hooks::common::say_timed_out(&self.short_name, budget);
return Outcome::Failed;
}
Err(e) => Err(e),
};
match 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(&matched) {
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.is_unscoped() {
return paths.to_vec();
}
paths.iter().filter(|p| scope.covers(p)).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>, Vec<String>), ParseError> {
if token == "*" {
return Ok((Vec::new(), Vec::new()));
}
let mut exts = Vec::new();
let mut names = Vec::new();
for part in token.split(',') {
if let Some(ext) = part.strip_prefix('*').filter(|ext| ext.starts_with('.')) {
exts.push(ext.to_string());
continue;
}
if !part.is_empty() && !part.contains(['*', '?', '[', '/']) {
names.push(part.to_string());
continue;
}
return Err(ParseError::BadScope(part.to_string()));
}
Ok((exts, names))
}
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 {
if line == "tool" || line.starts_with("tool ") || line.starts_with("tool\t") {
let mut it = line.split_whitespace().skip(1);
return match (it.next(), it.next(), it.next()) {
(Some(program), Some(want), None) => Line::Tool(ToolPin {
program: program.to_string(),
want: want.to_string(),
}),
_ => broken_at(
lineno,
format!("{MANIFEST}:{lineno}"),
None,
ParseError::BadTool,
),
};
}
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, names) = 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 mut command = command;
let mut wants_fix = false;
let mut wants_files = false;
loop {
if let Some(rest) = command.strip_prefix("fix ") {
command = rest.trim_start();
wants_fix = true;
continue;
}
if let Some(rest) = command.strip_prefix("files ") {
command = rest.trim_start();
wants_files = true;
continue;
}
break;
}
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 },
files: wants_files,
name: declared.to_string(),
stage,
severity,
exts,
names,
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() && d.names.is_empty() {
Scope::ALWAYS
} else {
Scope {
files: leak(d.exts),
names: leak(d.names),
opt_in: &[],
not_during: &[],
}
},
severity: d.severity,
program: d.program,
args: d.args,
fix: d.fix,
files: d.files,
},
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()
.filter(|l| !matches!(l, Line::Tool(_)))
.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()
}
#[derive(Default)]
pub struct Manifest {
pub externals: Vec<External>,
pub pins: Vec<ToolPin>,
}
pub fn load(root: &Path) -> Manifest {
let Ok(bytes) = std::fs::read(root.join(MANIFEST)) else {
return Manifest::default();
};
let Ok(text) = String::from_utf8(bytes.clone()) else {
return Manifest::default();
};
let state = crate::trust::state_of(root, &bytes);
let externals = gate(parse(&text), state);
let pins = if state == crate::trust::State::Trusted {
parse_lines(&text)
.into_iter()
.filter_map(|l| match l {
Line::Tool(pin) => Some(pin),
_ => None,
})
.collect()
} else {
Vec::new()
};
Manifest { externals, pins }
}
pub fn verify_tool_pins(pins: &[ToolPin]) {
for pin in pins {
match version_of(&pin.program) {
None => crate::hooks::common::warn(&format!(
"{} is pinned to {} in {MANIFEST}, but `{} --version` would not run",
crate::ui::highlight(&pin.program),
crate::ui::sanitize(&pin.want),
crate::ui::sanitize(&pin.program),
)),
Some(v) if !v.contains(&pin.want) => crate::hooks::common::warn(&format!(
"{} reports {} — {MANIFEST} pins {}; this machine may disagree with CI",
crate::ui::highlight(&pin.program),
crate::ui::sanitize(&v),
crate::ui::sanitize(&pin.want),
)),
_ => {}
}
}
}
fn version_of(program: &str) -> Option<String> {
let out = Command::new(crate::hooks::common::program(program))
.arg("--version")
.stdin(Stdio::null())
.output()
.ok()?;
if !out.status.success() {
return None;
}
let text = String::from_utf8_lossy(&out.stdout);
let line = text.lines().next().unwrap_or("").trim();
if line.is_empty() {
return None;
}
Some(line.to_string())
}
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),
Line::Tool(pin) => panic!("{} parsed as a pin, not a broken line", pin.program),
}
}
fn usable(l: &Line) -> &Declared {
match l {
Line::Usable(d) => d,
Line::Broken { name, why, .. } => panic!("{name} failed to parse: {why}"),
Line::Tool(pin) => panic!("{} is a tool pin, not a declaration", pin.program),
}
}
#[test]
fn a_bare_scope_token_is_an_exact_filename() {
let line = one("pre-commit lockcheck package.json block ./check.sh\n");
let d = usable(&line);
assert!(d.exts.is_empty());
assert_eq!(d.names, ["package.json"]);
let line = one("pre-commit x *.ts,package.json,.prettierrc block ./x\n");
let d = usable(&line);
assert_eq!(d.exts, [".ts"]);
assert_eq!(d.names, ["package.json", ".prettierrc"]);
assert_eq!(
why(&one("pre-commit x src/package.json block ./x\n")),
ParseError::BadScope("src/package.json".into())
);
assert_eq!(
why(&one("pre-commit x pkg* block ./x\n")),
ParseError::BadScope("pkg*".into())
);
}
#[test]
fn a_tool_pin_parses_and_a_malformed_one_is_broken() {
let line = one("tool ruff 0.6.\n");
assert_eq!(
line,
Line::Tool(ToolPin {
program: "ruff".into(),
want: "0.6.".into()
})
);
assert_eq!(why(&one("tool ruff\n")), ParseError::BadTool);
assert_eq!(why(&one("tool ruff 0.6. extra\n")), ParseError::BadTool);
let line = one("pre-commit tool * block make tool\n");
assert_eq!(usable(&line).name, "tool");
}
#[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 the_files_marker_parses_alone_and_in_either_order_with_fix() {
let line = one("pre-commit sc *.sh block files shellcheck\n");
let declared = usable(&line);
assert!(declared.files);
assert_eq!(declared.fix, Fix::None);
assert_eq!(declared.program, "shellcheck");
for text in [
"pre-commit fmt * block fix files prettier --write\n",
"pre-commit fmt * block files fix prettier --write\n",
] {
let line = one(text);
let declared = usable(&line);
assert!(declared.files, "for {text:?}");
assert_eq!(declared.fix, Fix::Rewrite, "for {text:?}");
assert_eq!(declared.program, "prettier", "for {text:?}");
assert_eq!(declared.args, ["--write"], "for {text:?}");
}
}
#[test]
fn a_command_that_merely_starts_with_files_is_not_a_marker() {
let line = one("pre-commit x * block files-checker --strict\n");
let declared = usable(&line);
assert!(!declared.files);
assert_eq!(declared.program, "files-checker");
}
#[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);
}
}
}
}