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,
BadPolicyLine(&'static str),
UnsettableKey(String),
FixOnPrePush,
BadTreeLine(&'static str),
BadTreeTool(String),
BadTreeName(String),
BadTreeOption(String),
BadTreeCache,
}
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 `/`), \
optionally followed by `+<file>` the repository must carry"
),
ParseError::BadTool => write!(
f,
"a tool pin is exactly `tool <program> <version-substring>`"
),
ParseError::BadPolicyLine(usage) => write!(f, "a policy line is `{usage}`"),
ParseError::UnsettableKey(k) => write!(
f,
"set {k:?} is not a policy-settable key — see docs/custom-checks.md"
),
ParseError::BadTreeLine(what) => write!(
f,
"a tree gate is `tree <name> <tool> <scope> attest [cwd=<dir>] \
[inputs=<path>,...] <command>` — {what}"
),
ParseError::BadTreeTool(t) => write!(
f,
"tree tool {t:?} must be one of: {}",
TreeTool::ALL
.iter()
.map(|t| t.as_str())
.collect::<Vec<_>>()
.join(", ")
),
ParseError::BadTreeName(n) => write!(
f,
"tree gate name {n:?} must match [A-Za-z0-9][A-Za-z0-9._-]{{0,58}}"
),
ParseError::BadTreeOption(o) => write!(
f,
"tree option {o:?}: only `cwd=<relative dir>` and \
`inputs=<path>,...` (literal paths, no globs, no `..`)"
),
ParseError::BadTreeCache => write!(
f,
"`{{cache}}` may appear once, and only for eslint or prettier"
),
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 opt_in: 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, Copy, PartialEq, Eq)]
pub enum TreeTool {
Eslint,
Prettier,
Ruff,
Pyright,
Gofmt,
}
impl TreeTool {
pub const ALL: &'static [TreeTool] = &[
TreeTool::Eslint,
TreeTool::Prettier,
TreeTool::Ruff,
TreeTool::Pyright,
TreeTool::Gofmt,
];
pub fn parse(token: &str) -> Option<TreeTool> {
TreeTool::ALL.iter().copied().find(|t| t.as_str() == token)
}
pub fn as_str(self) -> &'static str {
match self {
TreeTool::Eslint => "eslint",
TreeTool::Prettier => "prettier",
TreeTool::Ruff => "ruff",
TreeTool::Pyright => "pyright",
TreeTool::Gofmt => "gofmt",
}
}
pub fn has_cache(self) -> bool {
matches!(self, TreeTool::Eslint | TreeTool::Prettier)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TreeGate {
pub name: String,
pub tool: TreeTool,
pub exts: Vec<String>,
pub names: Vec<String>,
pub opt_in: Vec<String>,
pub cwd: Option<String>,
pub inputs: Vec<String>,
pub command: String,
pub lineno: usize,
}
pub const CACHE_PLACEHOLDER: &str = "{cache}";
impl TreeGate {
pub fn id(&self) -> String {
format!("tree-{}", self.name)
}
pub fn stamp_key(&self) -> String {
format!("tree:{}", self.name)
}
pub fn normalized(&self) -> String {
normalize_command(&self.command)
}
}
pub fn normalize_command(command: &str) -> String {
let mut words: Vec<&str> = command
.split_whitespace()
.filter(|w| *w != CACHE_PLACEHOLDER)
.collect();
if words.last() == Some(&"--") {
words.pop();
}
words.join(" ")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PolicyLine {
Severity { target: String, severity: Severity },
Skip { target: String },
Set { key: String, value: String },
}
pub const SETTABLE: &[&str] = &[
"largeFileWarn",
"largeFileBlock",
"commit.gitmoji",
"commit.subjectMax",
"commit.descriptionMax",
"commit.bodyWrap",
"autoRebase",
"timeout",
"testPushedTree",
"minVersion",
"snapshotPrepare",
"snapshotDeps",
"snapshotCarry",
"order",
"treeLint",
"treeLintSlack",
"treeLintWait",
"treeLintRehearsalTimeout",
"snapshotPrepareOutputs",
];
impl PolicyLine {
pub fn describe(&self) -> String {
match self {
PolicyLine::Severity { target, severity } => {
format!("severity {target} {}", severity.as_str())
}
PolicyLine::Skip { target } => format!("skip {target}"),
PolicyLine::Set { key, value } => format!("set {key} {value}"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Line {
Usable(Declared),
Tool(ToolPin),
Policy {
what: PolicyLine,
lineno: usize,
},
Tree(TreeGate),
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::Policy {
what: PolicyLine::Severity { target, .. } | PolicyLine::Skip { target },
..
} => target,
Line::Policy {
what: PolicyLine::Set { key, .. },
..
} => key,
Line::Tree(gate) => &gate.name,
Line::Broken { name, .. } => name,
}
}
pub fn is_check(&self) -> bool {
matches!(self, Line::Usable(_) | Line::Broken { .. })
}
pub fn stage(&self) -> Stage {
match self {
Line::Usable(d) => d.stage,
Line::Tool(_) | Line::Policy { .. } | Line::Tree(_) => Stage::PreCommit,
Line::Broken { stage, .. } => *stage,
}
}
pub fn broken(&self) -> Option<String> {
match self {
Line::Usable(_) | Line::Tool(_) | Line::Policy { .. } | Line::Tree(_) => 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::Policy { what, .. } => Err(format!("policy: {}", what.describe())),
Line::Tree(gate) => Err(format!("tree gate: {} {}", gate.name, gate.command)),
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,
},
}
fn vouched_for_staged_tree() -> &'static std::collections::HashSet<String> {
static VOUCHED: std::sync::OnceLock<std::collections::HashSet<String>> =
std::sync::OnceLock::new();
VOUCHED.get_or_init(crate::gate_stamp::vouched_for_staged_tree)
}
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 settings = ctx.settings;
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(settings) {
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 opted_in =
crate::hooks::common::tracked_files().map_or(true, |tracked| scope.opted_in(&tracked));
if !opted_in {
return Outcome::Passed;
}
let root = crate::hooks::common::repo_root();
match self.stage {
Stage::PreCommit => {
let staged = crate::hooks::common::staged_files(&[]);
let Some(matched) = files_to_judge(scope, files, &staged) else {
return Outcome::Passed;
};
if self.severity() == Severity::Block
&& crate::gate_stamp::commit_stamps_enabled(settings)
&& vouched_for_staged_tree().contains(&self.short_name)
{
crate::say!(
"{} {} passed on this exact tree earlier — not repeating it here",
crate::ui::valid_sign(),
crate::ui::highlight(&self.short_name),
);
return Outcome::Passed;
}
self.execute(
settings,
program,
args,
fix,
files,
&matched,
Path::new(&root),
)
}
Stage::PrePush => {
let zero = crate::git::stdout(&["hash-object", "--stdin"])
.map(|h| "0".repeat(h.len()))
.unwrap_or_else(|| "0".repeat(40));
for r in ctx.push.get() {
if r.local_oid == zero {
continue; }
let changed = crate::pushrefs::changed_files_for(r, &zero);
let Some(matched) = files_to_judge(scope, files, &changed) else {
continue;
};
let (run_in, _guard) =
crate::pushed_tree::where_to_run(settings, &r.local_oid, &root, &self.id);
match self.execute(settings, program, args, fix, files, &matched, &run_in) {
Outcome::Passed => {}
other => return other,
}
}
Outcome::Passed
}
}
}
}
fn files_to_judge(scope: &Scope, files: bool, in_scope: &[String]) -> Option<Vec<String>> {
if !scope.touches(in_scope) {
return None;
}
let matched = scoped(scope, in_scope);
if files && matched.is_empty() {
return None;
}
Some(matched)
}
impl External {
#[allow(clippy::too_many_arguments)]
fn execute(
&self,
settings: &crate::config::Settings,
program: &str,
args: &[String],
fix: Fix,
files: bool,
matched: &[String],
run_in: &Path,
) -> Outcome {
let mut cmd = Command::new(crate::hooks::common::program(program));
cmd.args(args).current_dir(run_in).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(settings, &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(settings) {
match crate::hooks::common::restage(matched) {
Restaged::Staged => {
crate::hooks::common::ok(
settings,
&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())
}
type ParsedScope = (Vec<String>, Vec<String>, Vec<String>);
fn parse_scope(token: &str) -> Result<ParsedScope, ParseError> {
let (triggers, opt_in) = match token.split_once('+') {
None => (token, Vec::new()),
Some((left, right)) => {
let opts: Vec<String> = right
.split(',')
.filter(|p| !p.is_empty())
.map(str::to_string)
.collect();
if opts.is_empty() || right.contains('+') {
return Err(ParseError::BadScope(token.to_string()));
}
for o in &opts {
if o.contains(['*', '?', '[', '/']) {
return Err(ParseError::BadScope(o.clone()));
}
}
(if left.is_empty() { "*" } else { left }, opts)
}
};
if triggers == "*" {
return Ok((Vec::new(), Vec::new(), opt_in));
}
let mut exts = Vec::new();
let mut names = Vec::new();
for part in triggers.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, opt_in))
}
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,
),
};
}
if line == "severity" || line.starts_with("severity ") || line.starts_with("severity\t") {
let mut it = line.split_whitespace().skip(1);
return match (it.next(), it.next(), it.next()) {
(Some(target), Some(word), None) => match Severity::parse(word) {
Some(severity) => Line::Policy {
what: PolicyLine::Severity {
target: target.to_string(),
severity,
},
lineno,
},
None => broken_at(
lineno,
format!("{MANIFEST}:{lineno}"),
None,
ParseError::BadSeverity(word.to_string()),
),
},
_ => broken_at(
lineno,
format!("{MANIFEST}:{lineno}"),
None,
ParseError::BadPolicyLine("severity <check> warn|block"),
),
};
}
if line == "skip" || line.starts_with("skip ") || line.starts_with("skip\t") {
let mut it = line.split_whitespace().skip(1);
return match (it.next(), it.next()) {
(Some(target), None) => Line::Policy {
what: PolicyLine::Skip {
target: target.to_string(),
},
lineno,
},
_ => broken_at(
lineno,
format!("{MANIFEST}:{lineno}"),
None,
ParseError::BadPolicyLine("skip <check>"),
),
};
}
if line == "tree" || line.starts_with("tree ") || line.starts_with("tree\t") {
return parse_tree_line(lineno, line, earlier);
}
if line == "set" || line.starts_with("set ") || line.starts_with("set\t") {
let rest = line[3..].trim();
let split = rest
.split_once(char::is_whitespace)
.map(|(k, v)| (k, v.trim()))
.filter(|(_, v)| !v.is_empty());
return match split {
Some((key, value)) => {
match SETTABLE.iter().find(|k| k.eq_ignore_ascii_case(key)) {
Some(canonical) => Line::Policy {
what: PolicyLine::Set {
key: format!("amont.{canonical}"),
value: value.to_string(),
},
lineno,
},
None => broken_at(
lineno,
format!("{MANIFEST}:{lineno}"),
None,
ParseError::UnsettableKey(key.to_string()),
),
}
}
None => broken_at(
lineno,
format!("{MANIFEST}:{lineno}"),
None,
ParseError::BadPolicyLine("set <key> <value>"),
),
};
}
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, opt_in) = 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,
opt_in,
program,
args: argv.collect(),
})
}
fn split_token(s: &str) -> Option<(&str, &str)> {
let s = s.trim_start();
if s.is_empty() {
return None;
}
let end = s.find(char::is_whitespace).unwrap_or(s.len());
Some((&s[..end], &s[end..]))
}
fn tree_name_valid(name: &str) -> bool {
let mut chars = name.chars();
matches!(chars.next(), Some(c) if c.is_ascii_alphanumeric())
&& name.len() <= 59
&& chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
}
fn tree_path_safe(path: &str) -> bool {
!path.is_empty()
&& !path.starts_with('/')
&& !path.contains(['*', '?', '[', '\\'])
&& !path.split('/').any(|seg| seg == "..")
}
fn parse_tree_line(lineno: usize, line: &str, earlier: &[Line]) -> Line {
let position = format!("{MANIFEST}:{lineno}");
let fail = |name: &str, why| {
broken_at(
lineno,
if name.is_empty() {
position.clone()
} else {
name.to_string()
},
None,
why,
)
};
let rest = &line[4..];
let Some((name, rest)) = split_token(rest) else {
return fail("", ParseError::BadTreeLine("missing name"));
};
if !tree_name_valid(name) {
return fail(name, ParseError::BadTreeName(name.to_string()));
}
if name_says_its_trigger(name) {
return fail(name, ParseError::TriggerInName(name.to_string()));
}
if earlier
.iter()
.any(|l| matches!(l, Line::Tree(g) if g.name == name))
{
return fail(name, ParseError::Duplicate(name.to_string()));
}
let Some((tool_tok, rest)) = split_token(rest) else {
return fail(name, ParseError::BadTreeLine("missing tool"));
};
let Some(tool) = TreeTool::parse(tool_tok) else {
return fail(name, ParseError::BadTreeTool(tool_tok.to_string()));
};
let Some((scope_tok, rest)) = split_token(rest) else {
return fail(name, ParseError::BadTreeLine("missing scope"));
};
let (exts, names, opt_in) = match parse_scope(scope_tok) {
Ok(s) => s,
Err(why) => return fail(name, why),
};
match split_token(rest) {
Some(("attest", _)) => {}
_ => {
return fail(
name,
ParseError::BadTreeLine("the fifth column must be `attest`"),
)
}
}
let mut rest = split_token(rest).map(|(_, r)| r).unwrap_or("");
let mut cwd = None;
let mut inputs = Vec::new();
while let Some((tok, after)) = split_token(rest) {
let Some((key, value)) = tok.split_once('=') else {
break;
};
if key.is_empty() || !key.chars().all(|c| c.is_ascii_lowercase()) {
break;
}
match key {
"cwd" if cwd.is_none() && tree_path_safe(value) => {
cwd = Some(value.trim_end_matches('/').to_string());
}
"inputs" if inputs.is_empty() => {
let paths: Vec<String> = value.split(',').map(str::to_string).collect();
if !paths.iter().all(|p| tree_path_safe(p)) {
return fail(name, ParseError::BadTreeOption(tok.to_string()));
}
inputs = paths;
}
_ => return fail(name, ParseError::BadTreeOption(tok.to_string())),
}
rest = after;
}
let command = rest.trim();
if command.is_empty() {
return fail(name, ParseError::BadTreeLine("missing command"));
}
let placeholders = command
.split_whitespace()
.filter(|w| *w == CACHE_PLACEHOLDER)
.count();
if placeholders > 1 || (placeholders == 1 && !tool.has_cache()) {
return fail(name, ParseError::BadTreeCache);
}
if normalize_command(command).is_empty() {
return fail(name, ParseError::BadTreeLine("missing command"));
}
Line::Tree(TreeGate {
name: name.to_string(),
tool,
exts,
names,
opt_in,
cwd,
inputs,
command: command.to_string(),
lineno,
})
}
pub fn tree_gates(text: &str) -> Vec<TreeGate> {
parse_lines(text)
.into_iter()
.filter_map(|l| match l {
Line::Tree(g) => Some(g),
_ => None,
})
.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() && d.opt_in.is_empty() {
Scope::ALWAYS
} else {
Scope {
files: leak(d.exts),
names: leak(d.names),
opt_in: leak(d.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(Line::is_check)
.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 tree: Vec<TreeGate>,
pub declared: bool,
pub policy: crate::policy::Policy,
pub policy_withheld: Option<&'static str>,
pub policy_notes: Vec<String>,
}
pub fn load(root: &Path) -> Manifest {
let declared = root.join(MANIFEST).exists();
let Ok(bytes) = std::fs::read(root.join(MANIFEST)) else {
return Manifest {
declared,
..Manifest::default()
};
};
let Ok(text) = String::from_utf8(bytes.clone()) else {
return Manifest {
declared,
..Manifest::default()
};
};
let state = crate::trust::state_of(root, &bytes);
let lines = parse_lines(&text);
let externals = gate(
lines
.iter()
.filter(|l| l.is_check())
.cloned()
.map(External::from)
.collect(),
state,
);
let trusted = state == crate::trust::State::Trusted;
let pins = if trusted {
lines
.iter()
.filter_map(|l| match l {
Line::Tool(pin) => Some(pin.clone()),
_ => None,
})
.collect()
} else {
Vec::new()
};
let tree = if trusted {
lines
.iter()
.filter_map(|l| match l {
Line::Tree(gate) => Some(gate.clone()),
_ => None,
})
.collect()
} else {
Vec::new()
};
let has_policy = lines.iter().any(|l| matches!(l, Line::Policy { .. }));
let (policy, policy_notes) = if trusted {
crate::policy::Policy::from_lines(&lines)
} else {
(crate::policy::Policy::default(), Vec::new())
};
let policy_withheld = if has_policy && !trusted {
crate::trust::why(state)
} else {
None
};
Manifest {
externals,
pins,
tree,
declared,
policy,
policy_withheld,
policy_notes,
}
}
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),
Line::Policy { what, .. } => {
panic!("{} parsed as policy, not a broken line", what.describe())
}
Line::Tree(g) => panic!("{} parsed as a tree gate, not a broken line", g.name),
}
}
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),
Line::Policy { what, .. } => {
panic!("{} is policy, not a declaration", what.describe())
}
Line::Tree(g) => panic!("{} is a tree gate, not a declaration", g.name),
}
}
fn tree(l: &Line) -> &TreeGate {
match l {
Line::Tree(g) => g,
Line::Broken { name, why, .. } => panic!("{name} failed to parse: {why}"),
other => panic!("{} is not a tree gate", other.name()),
}
}
#[test]
fn tree_full_line_parses_with_options() {
let l = one("tree eslint eslint *.ts,*.tsx attest cwd=web/ inputs=tsconfig.json,.eslintignore npm run lint -- {cache}");
let g = tree(&l);
assert_eq!(g.name, "eslint");
assert_eq!(g.tool, TreeTool::Eslint);
assert_eq!(g.exts, vec![".ts".to_string(), ".tsx".to_string()]);
assert_eq!(g.cwd.as_deref(), Some("web"));
assert_eq!(
g.inputs,
vec!["tsconfig.json".to_string(), ".eslintignore".to_string()]
);
assert_eq!(g.command, "npm run lint -- {cache}");
assert_eq!(g.id(), "tree-eslint");
assert_eq!(g.stamp_key(), "tree:eslint");
assert!(!l.is_check());
}
#[test]
fn tree_normalizes_cache_dangling_dashes_and_whitespace() {
assert_eq!(normalize_command("npm run lint -- {cache}"), "npm run lint");
assert_eq!(normalize_command("npx eslint . {cache}"), "npx eslint .");
assert_eq!(
normalize_command("uvx ruff@0.16.0 check packages"),
"uvx ruff@0.16.0 check packages"
);
assert_eq!(
normalize_command("npm run lint -- --quiet"),
"npm run lint -- --quiet"
);
}
#[test]
fn tree_env_assignment_begins_the_command() {
let l =
one("tree eslint eslint * attest NODE_OPTIONS=--max-old-space-size=4096 npm run lint");
let g = tree(&l);
assert_eq!(g.cwd, None);
assert_eq!(
g.command,
"NODE_OPTIONS=--max-old-space-size=4096 npm run lint"
);
}
#[test]
fn tree_cache_refused_for_a_tool_without_one() {
let l = one("tree ruff-check ruff * attest uvx ruff@0.16.0 check packages {cache}");
assert_eq!(why(&l), ParseError::BadTreeCache);
}
#[test]
fn tree_cache_twice_refused() {
let l = one("tree eslint eslint * attest npx eslint {cache} . {cache}");
assert_eq!(why(&l), ParseError::BadTreeCache);
}
#[test]
fn tree_bad_tool_refused() {
let l = one("tree lint biome * attest npx biome check .");
assert_eq!(why(&l), ParseError::BadTreeTool("biome".into()));
}
#[test]
fn tree_missing_attest_refused() {
let l = one("tree eslint eslint * block npx eslint .");
assert!(matches!(why(&l), ParseError::BadTreeLine(_)));
}
#[test]
fn tree_absolute_cwd_refused() {
let l = one("tree eslint eslint * attest cwd=/etc npx eslint .");
assert_eq!(why(&l), ParseError::BadTreeOption("cwd=/etc".into()));
}
#[test]
fn tree_parent_dir_in_cwd_refused() {
let l = one("tree eslint eslint * attest cwd=web/../.. npx eslint .");
assert_eq!(why(&l), ParseError::BadTreeOption("cwd=web/../..".into()));
}
#[test]
fn tree_glob_in_inputs_refused() {
let l = one("tree eslint eslint * attest inputs=src/*.json npx eslint .");
assert_eq!(
why(&l),
ParseError::BadTreeOption("inputs=src/*.json".into())
);
}
#[test]
fn tree_unknown_option_refused() {
let l = one("tree eslint eslint * attest shell=bash npx eslint .");
assert_eq!(why(&l), ParseError::BadTreeOption("shell=bash".into()));
}
#[test]
fn tree_duplicate_name_refused() {
let v = parse_lines(
"tree eslint eslint * attest npx eslint .\ntree eslint eslint * attest npx eslint src",
);
assert_eq!(v.len(), 2);
tree(&v[0]);
assert_eq!(why(&v[1]), ParseError::Duplicate("eslint".into()));
}
#[test]
fn tree_name_saying_its_trigger_refused() {
let l = one("tree pre-commit-lint eslint * attest npx eslint .");
assert_eq!(why(&l), ParseError::TriggerInName("pre-commit-lint".into()));
}
#[test]
fn tree_bad_name_refused() {
let l = one("tree -lint eslint * attest npx eslint .");
assert_eq!(why(&l), ParseError::BadTreeName("-lint".into()));
}
#[test]
fn tree_gates_are_not_runnable_checks() {
let text = "tree eslint eslint * attest npx eslint .\npre-commit smoke * block true";
let checks = parse(text);
assert_eq!(checks.len(), 1);
assert_eq!(checks[0].id, "pre-commit-smoke");
let gates = tree_gates(text);
assert_eq!(gates.len(), 1);
assert_eq!(gates[0].normalized(), "npx eslint .");
}
#[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_scope_can_name_what_the_repository_must_carry() {
let line = one("pre-commit rubocop *.rb+.rubocop.yml block rubocop\n");
let d = usable(&line);
assert_eq!(d.exts, [".rb"]);
assert!(d.names.is_empty());
assert_eq!(d.opt_in, [".rubocop.yml"]);
let line = one("pre-commit x *.rb,Rakefile+.rubocop.yml,.rubocop_todo.yml block ./x\n");
let d = usable(&line);
assert_eq!(d.exts, [".rb"]);
assert_eq!(d.names, ["Rakefile"]);
assert_eq!(d.opt_in, [".rubocop.yml", ".rubocop_todo.yml"]);
let line = one("pre-commit x +Gemfile block ./x\n");
let d = usable(&line);
assert!(d.exts.is_empty() && d.names.is_empty());
assert_eq!(d.opt_in, ["Gemfile"]);
}
#[test]
fn a_scope_without_a_plus_is_unchanged() {
let line = one("pre-commit x *.ts,package.json block ./x\n");
let d = usable(&line);
assert!(d.opt_in.is_empty());
let line = one("pre-commit x * block ./x\n");
let d = usable(&line);
assert!(d.opt_in.is_empty() && d.exts.is_empty() && d.names.is_empty());
}
#[test]
fn a_malformed_opt_in_half_is_refused() {
for bad in [
"*.rb+", "*.rb+,", "*.rb+a+b", "*.rb+sub/dir.yml", "*.rb+*.yml", ] {
let line = one(&format!("pre-commit x {bad} block ./x\n"));
assert!(
matches!(line, Line::Broken { .. }),
"{bad} should be refused, got {line:?}"
);
}
}
#[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 lint-shell *.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, "lint-shell");
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 lint-shell *.sh block\n",
ParseError::MissingFields,
),
(
"nonsense lint-shell *.sh block x\n",
ParseError::BadStage("nonsense".into()),
),
(
"pre-commit lint-shell ?.sh block x\n",
ParseError::BadScope("?.sh".into()),
),
(
"pre-commit lint-shell *.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 lint-shell *.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 pull-rebase * block x\n")),
ParseError::NameTaken("pull-rebase".into())
);
assert!(matches!(
one("pre-commit pull-rebase * 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 lint-shell *.sh block make lint\n");
let tabbed = one("pre-commit\tlint-shell\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 lint-shell *.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 lint-shell *.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);
}
}
}
}