Skip to main content

amont_runtime/
manifest.rs

1//! `amont.conf` — checks a repository declares for itself.
2//!
3//! A third party cannot add a Rust module without rebuilding this binary, so
4//! extension means declared commands.
5//!
6//! The manifest is **committed at the repository root**, and that is the point.
7//! `.git/hooks` is not committed, so under the old filename-prefix mechanism a
8//! team could never actually share a custom hook — every member had to install
9//! it by hand, and nothing told them when it changed. That flaw mattered more
10//! than the lexicographic ordering usually cited against prefixes.
11//!
12//! ```text
13//! # stage       name        scope   severity  command
14//! pre-commit    shellcheck  *.sh    block     scripts/lint-shell.sh
15//! pre-push      smoke       *       warn      make smoke
16//! ```
17//!
18//! Whitespace-delimited, in file order. TOML would be nicer to write and costs a
19//! dependency tree that would then run on every commit in ninety-six
20//! repositories; for four fields and a command, the twenty lines of parsing win.
21//! See `scripts/check-no-deps.sh` for why that trade is the default here.
22//!
23//! ## No shell
24//!
25//! The command is split on whitespace and executed directly. There is no shell,
26//! so no pipes, no redirection, no globbing and no quoting. Two reasons, and the
27//! second is the one that decided it: Windows has no `sh`, and every emulation
28//! of one this project has tried has been a source of bugs; and a manifest line
29//! that silently gained shell semantics would be a much larger thing to have
30//! introduced than it looks. A pipeline belongs in a script the line invokes.
31//!
32//! ## A line that cannot be understood is not skipped
33//!
34//! A malformed line means a check the repository asked for is not running, which
35//! is precisely the "looks verified, enforced nothing" failure `Outcome` exists
36//! to name. So a broken line still produces a check — one that runs to
37//! `Unavailable` and says why. It appears in the dispatcher's "could not run"
38//! roll-up like any other gap, rather than needing a mechanism of its own.
39
40use std::path::Path;
41use std::process::{Command, Stdio};
42
43use crate::check::{Check, Fix, Outcome, Scope, Severity, Stage};
44use crate::hooks::common::Restaged;
45use crate::registry::{Ctx, CHECKS, ENTRYPOINTS};
46
47pub const MANIFEST: &str = "amont.conf";
48
49/// Why a line could not be used.
50///
51/// A type rather than a `String`: the prose belongs in `Display`, and a caller
52/// that wants to ask "was this a duplicate?" should not have to grep for the
53/// word. The tests used to assert on substrings, which coupled them to wording
54/// and would have kept passing if the wording stayed while the meaning changed.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum ParseError {
57    MissingFields,
58    MissingName,
59    /// Names a check compiled into the binary.
60    NameTaken(String),
61    /// The name is a trigger, or carries one as a prefix.
62    ///
63    /// `pre-commit  pre-commit-clippy  …` would declare a check whose SHORT
64    /// name is another check's full id, so `hook.skip pre-commit-clippy` would
65    /// mean two things at once. The stage column supplies the trigger; writing
66    /// it again in the name is the one way to make an id ambiguous.
67    TriggerInName(String),
68    /// A second USABLE line claiming a name already claimed ON THE SAME
69    /// TRIGGER. The same name on both triggers is two checks, not a clash.
70    Duplicate(String),
71    BadStage(String),
72    BadScope(String),
73    BadSeverity(String),
74    /// A `tool` line with the wrong shape.
75    BadTool,
76    /// A `severity`/`skip`/`set` line with the wrong shape; carries the usage.
77    BadPolicyLine(&'static str),
78    /// A `set` line naming a key policy may not reach.
79    UnsettableKey(String),
80    /// A `pre-push` line asked to rewrite files.
81    ///
82    /// Refused HERE, beside `NameTaken` and `Duplicate`, rather than as a
83    /// runtime "contract violation" at push time: same fact, discovered
84    /// earlier, by more people, at the moment it is cheapest to fix. A pre-push
85    /// hook must not modify the worktree or index — the pushed commit would
86    /// then differ from the tree the developer is looking at.
87    FixOnPrePush,
88}
89
90impl std::fmt::Display for ParseError {
91    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92        match self {
93            ParseError::MissingFields => {
94                write!(f, "expected 5 fields: stage name scope severity command")
95            }
96            ParseError::MissingName => write!(f, "missing name"),
97            ParseError::NameTaken(n) => write!(f, "{n:?} already names a check"),
98            ParseError::TriggerInName(n) => write!(
99                f,
100                "{n:?} must not be a trigger or start with one — the stage column says which"
101            ),
102            ParseError::Duplicate(n) => write!(f, "{n:?} is declared twice on one trigger"),
103            ParseError::BadStage(t) => {
104                write!(f, "stage {t:?} must be `pre-commit` or `pre-push`")
105            }
106            ParseError::BadScope(t) => write!(
107                f,
108                "scope {t:?} must be `*`, `*.<ext>`, or a bare filename (no `/`)"
109            ),
110            ParseError::BadTool => write!(
111                f,
112                "a tool pin is exactly `tool <program> <version-substring>`"
113            ),
114            ParseError::BadPolicyLine(usage) => write!(f, "a policy line is `{usage}`"),
115            ParseError::UnsettableKey(k) => write!(
116                f,
117                "set {k:?} is not a policy-settable key — see docs/custom-checks.md"
118            ),
119            ParseError::FixOnPrePush => write!(
120                f,
121                "`fix` is only for pre-commit — a pre-push hook must not rewrite files"
122            ),
123            ParseError::BadSeverity(t) => {
124                write!(f, "severity {t:?} must be `block` or `warn`")
125            }
126        }
127    }
128}
129
130/// A line that parsed. Every field means something.
131///
132/// `program` and `args` rather than one `argv`: a runnable check must have a
133/// command, and splitting the head off makes that structural instead of a
134/// `split_first` guard that can only ever be dead code.
135#[derive(Debug, Clone, PartialEq, Eq)]
136pub struct Declared {
137    /// `Fix::Rewrite` when the command column began `fix `.
138    pub fix: Fix,
139    /// The command column carried the `files` marker.
140    pub files: bool,
141    pub name: String,
142    pub stage: Stage,
143    pub severity: Severity,
144    /// Extensions that gate it. Empty means any change — the `*` scope.
145    pub exts: Vec<String>,
146    /// Exact filenames that gate it — the scope column's bare tokens.
147    pub names: Vec<String>,
148    pub program: String,
149    pub args: Vec<String>,
150}
151
152impl Declared {
153    /// `<trigger>-<name>`, the same shape a built-in has.
154    ///
155    /// This is what `hook.skip` and `amont.severity.<key>` resolve against,
156    /// so a declared check answers to its trigger and its short name exactly as
157    /// a compiled-in one does. Before it had an id, `hook.skip pre-commit`
158    /// silenced fifteen built-ins and left every declared check running.
159    pub fn id(&self) -> String {
160        format!("{}-{}", self.stage.as_str(), self.name)
161    }
162
163    /// The command as written, for display.
164    pub fn command(&self) -> String {
165        std::iter::once(self.program.as_str())
166            .chain(self.args.iter().map(String::as_str))
167            .collect::<Vec<_>>()
168            .join(" ")
169    }
170}
171
172/// One manifest line: usable, or not.
173///
174/// A SUM, not a struct with an `Option<why>` beside the fields. The struct
175/// form let a broken line carry a severity, a scope and an argv that meant
176/// nothing — and it produced a wrong diagnosis: because broken and usable
177/// entries shared one list, a valid line was rejected as "declared twice" for
178/// colliding with a line that could not run. Dedup now sees only `Usable`.
179///
180/// Separate from `External` because the fleet reads ninety-six manifests and may
181/// re-read them on every refresh, while `External` holds a `Scope` whose
182/// `&'static` slices are LEAKED.
183/// `tool <program> <version-substring>` — a version this repository expects
184/// of a tool its checks drive, so cross-machine skew is a printed fact
185/// instead of "the hook is flaky here". Verified once per hook run, warn-only.
186#[derive(Debug, Clone, PartialEq, Eq)]
187pub struct ToolPin {
188    pub program: String,
189    /// A substring `<program> --version`'s first line must contain — `0.6.`
190    /// pins a minor, `0.6.3` pins a patch. Substring, not semver: the point
191    /// is agreement between machines, not range arithmetic.
192    pub want: String,
193}
194
195/// A committed policy statement about a BUILT-IN (or declared) check — the
196/// team's decision, shipped with the repository, trust-gated like everything
197/// else the manifest says. See `policy`.
198#[derive(Debug, Clone, PartialEq, Eq)]
199pub enum PolicyLine {
200    /// `severity <check|short-name|trigger> warn|block`
201    Severity { target: String, severity: Severity },
202    /// `skip <check|short-name|trigger>`
203    Skip { target: String },
204    /// `set <key> <value>` — a committed default for an allowlisted
205    /// `amont.*` config key. `key` is the FULL git key (`amont.timeout`),
206    /// canonicalised from whatever case the file used, because git keys are
207    /// case-insensitive and a policy file stricter than git is a trap.
208    Set { key: String, value: String },
209}
210
211/// The keys `set` may reach, in canonical spelling. Deliberately absent:
212/// `fix` (a committed file must not change what already-trusted commands may
213/// DO to your working tree — a different consent than "I read these
214/// commands"), `trusted`, `conventions`, `recordBypasses`, `progress`,
215/// `knownIdentity`, and the `severity.*` family (its own line kind).
216pub const SETTABLE: &[&str] = &[
217    "largeFileWarn",
218    "largeFileBlock",
219    "commit.gitmoji",
220    "commit.subjectMax",
221    "commit.descriptionMax",
222    "commit.bodyWrap",
223    "autoRebase",
224    "timeout",
225    "testPushedTree",
226    "minVersion",
227];
228
229impl PolicyLine {
230    /// The one-line rendering the trust prompt shows — consent must see the
231    /// policy it is granting.
232    pub fn describe(&self) -> String {
233        match self {
234            PolicyLine::Severity { target, severity } => {
235                format!("severity  {target}  {}", severity.as_str())
236            }
237            PolicyLine::Skip { target } => format!("skip      {target}"),
238            PolicyLine::Set { key, value } => format!("set       {key}  {value}"),
239        }
240    }
241}
242
243#[derive(Debug, Clone, PartialEq, Eq)]
244pub enum Line {
245    Usable(Declared),
246    /// A tool version pin — carries no check.
247    Tool(ToolPin),
248    /// A policy statement — carries no check either. The line number rides
249    /// along because a "names no check here" note must point somewhere.
250    Policy {
251        what: PolicyLine,
252        lineno: usize,
253    },
254    Broken {
255        /// The declared name, or `<file>:<lineno>` when the line has none — a
256        /// gap has to be nameable to be reportable.
257        name: String,
258        /// Broken lines land on pre-commit unless the stage token parsed: seen
259        /// on every commit beats seen on every push.
260        stage: Stage,
261        lineno: usize,
262        why: ParseError,
263    },
264}
265
266impl Line {
267    pub fn name(&self) -> &str {
268        match self {
269            Line::Usable(d) => &d.name,
270            Line::Tool(pin) => &pin.program,
271            Line::Policy {
272                what: PolicyLine::Severity { target, .. } | PolicyLine::Skip { target },
273                ..
274            } => target,
275            Line::Policy {
276                what: PolicyLine::Set { key, .. },
277                ..
278            } => key,
279            Line::Broken { name, .. } => name,
280        }
281    }
282
283    /// Does this line declare a CHECK? `Tool` and `Policy` lines do not, and
284    /// every consumer that projects lines into checks — `parse`, the trust
285    /// prompt, the fleet's declared column — must filter on this rather than
286    /// pattern-match variants it will forget to extend. A `Broken` line IS a
287    /// check (one that runs to Unavailable and says why).
288    pub fn is_check(&self) -> bool {
289        matches!(self, Line::Usable(_) | Line::Broken { .. })
290    }
291    pub fn stage(&self) -> Stage {
292        match self {
293            Line::Usable(d) => d.stage,
294            // A pin has no stage; it is verified at both. The value only
295            // feeds displays that will not ask a pin for one. Policy lines
296            // likewise — `is_check()` keeps both out of anything that would.
297            Line::Tool(_) | Line::Policy { .. } => Stage::PreCommit,
298            Line::Broken { stage, .. } => *stage,
299        }
300    }
301    /// `Some(reason)` when this line declares a check that cannot run.
302    pub fn broken(&self) -> Option<String> {
303        match self {
304            Line::Usable(_) | Line::Tool(_) | Line::Policy { .. } => None,
305            Line::Broken { lineno, why, .. } => Some(format!("line {lineno}: {why}")),
306        }
307    }
308
309    /// `<trigger>-<name>`, matching `Declared::id` and `External::id`. A broken
310    /// line has one too: `hook.skip pre-commit` should silence its nag exactly
311    /// as it silences the checks that do run.
312    pub fn id(&self) -> String {
313        format!("{}-{}", self.stage().as_str(), self.name())
314    }
315
316    /// Consume into the identity every line has, and either the declaration or
317    /// the reason there is none.
318    ///
319    /// Both consumers — `External::from` and the fleet's projection — used to
320    /// destructure this by hand, and both carried an arm for a combination the
321    /// type forbids, because they computed the reason BEFORE matching. Written
322    /// once, that arm has nowhere to appear.
323    pub fn into_parts(self) -> (String, Stage, Result<Declared, String>) {
324        let name = self.name().to_string();
325        let stage = self.stage();
326        let parsed = match self {
327            Line::Usable(d) => Ok(d),
328            // Reachable only through a consumer that skipped `is_check()` —
329            // kept total so the type stays honest, worded so a leak is
330            // recognisable in whatever display it lands in.
331            Line::Tool(pin) => Err(format!("tool pin: {} {}", pin.program, pin.want)),
332            Line::Policy { what, .. } => Err(format!("policy: {}", what.describe())),
333            Line::Broken { lineno, why, .. } => Err(format!("line {lineno}: {why}")),
334        };
335        (name, stage, parsed)
336    }
337}
338
339/// A check a repository declares, rather than one compiled in.
340pub struct External {
341    /// `<trigger>-<name>` — what `hook.skip` and `amont.severity.<key>`
342    /// resolve against, and what `Check::name` returns. Built-ins have had this
343    /// shape all along; declared checks answering to a bare name were invisible
344    /// to `hook.skip pre-commit`.
345    pub id: String,
346    /// The name as written in the manifest — the "short name" of the vocabulary
347    /// — used for messages. A line too malformed to name itself falls back to
348    /// its position, and reading `pre-commit-amont.conf:3` back to somebody
349    /// helps nobody.
350    pub short_name: String,
351    pub stage: Stage,
352    pub kind: Kind,
353}
354
355/// The two things an external can be. `Scope` and `Severity` live only on the
356/// runnable side, so a broken external cannot carry a severity nobody applies.
357pub enum Kind {
358    Runnable {
359        scope: Scope,
360        severity: Severity,
361        program: String,
362        args: Vec<String>,
363        fix: Fix,
364        /// The command column began `files ` — append the matched paths to
365        /// the argv, the way a builtin hands its tool the staged list.
366        files: bool,
367    },
368    Unusable {
369        why: String,
370    },
371}
372
373impl Check for External {
374    fn name(&self) -> &str {
375        &self.id
376    }
377    fn stage(&self) -> Stage {
378        self.stage
379    }
380    /// Derived for an unusable check rather than stored: it never runs, so its
381    /// scope is a question with no answer, and computing one here keeps the
382    /// DATA from carrying a value that means nothing.
383    fn scope(&self) -> Scope {
384        match &self.kind {
385            Kind::Runnable { scope, .. } => *scope,
386            Kind::Unusable { .. } => Scope::ALWAYS,
387        }
388    }
389    fn severity(&self) -> Severity {
390        match &self.kind {
391            Kind::Runnable { severity, .. } => *severity,
392            // Never consulted: an unusable check reports `Unavailable`, which
393            // no severity can turn into a block.
394            Kind::Unusable { .. } => Severity::Warn,
395        }
396    }
397
398    fn run(&self, ctx: &Ctx) -> Outcome {
399        let (scope, program, args, fix, files) = match &self.kind {
400            Kind::Runnable {
401                scope,
402                program,
403                args,
404                fix,
405                files,
406                ..
407            } => (scope, program, args, *fix, *files),
408            Kind::Unusable { why } => {
409                crate::hooks::common::warn(&format!(
410                    "{MANIFEST}: {} — {}",
411                    crate::ui::highlight(&self.short_name),
412                    // Carries repo tokens: `BadStage("…")` quotes the manifest.
413                    crate::ui::sanitize(why)
414                ));
415                return Outcome::Unavailable;
416            }
417        };
418
419        // The scope gate lives HERE, unlike a built-in's, which enforces its own
420        // in its first three lines. A declared command has no way to know what
421        // was staged, so if this did not gate it, `*.sh` would run on every
422        // commit and the column would be decoration.
423        //
424        // Which files to test against depends on the stage: what is staged for
425        // a commit, what is being pushed for a push. `*` short-circuits before
426        // either is computed, which is the common case.
427        // A check whose whole job is to rewrite has nothing to say when nobody
428        // asked for rewriting — so it does not RUN, rather than running and
429        // having its result discarded. Gating only the re-staging let the
430        // command edit files with `amont.fix` off, which is precisely the
431        // surprise the gate exists to prevent.
432        //
433        // `Unavailable`, not `Passed`. `check.rs` defines `Unavailable` as
434        // "COULD NOT RUN — a tool is missing, or the opt-in config is absent",
435        // which is exactly this; `Passed` is the one verdict it must not
436        // report, because the dispatcher's roll-up and the fleet dashboard
437        // then show a check that never executed as clean. With a message,
438        // because every other `Unavailable` in this codebase says what was
439        // missing and an unexplained count on every commit is worse than none.
440        if fix == Fix::Rewrite && !crate::hooks::common::fixing_enabled() {
441            crate::hooks::common::warn(&format!(
442                "{}: declares fix, and {} is off — not run",
443                crate::ui::highlight(&self.short_name),
444                crate::ui::highlight("amont.fix")
445            ));
446            return Outcome::Unavailable;
447        }
448
449        let in_scope = match self.stage {
450            Stage::PreCommit => crate::hooks::common::staged_files(&[]),
451            Stage::PrePush => crate::pushrefs::changed_files(ctx.push.get()),
452        };
453        if !scope.is_unscoped() && !scope.matches(&in_scope) {
454            return Outcome::Passed;
455        }
456        // The paths the declaration's scope actually matched — what a builtin
457        // would be handed. Computed here, after the gate, and given to the
458        // command two ways: `$AMONT_FILES` always (newline-separated, so a
459        // wrapper script never re-derives `git diff --cached` and diverges
460        // from the set this gate judged — `amont run --all-files` overrides
461        // the set in-process, invisibly to any child that asks git itself),
462        // and appended to the argv when the declaration carries the `files`
463        // marker.
464        let matched = scoped(scope, &in_scope);
465        // A files-taking command with no files has nothing to judge — running
466        // it bare would make most linters error on an empty argv, blocking a
467        // commit over nothing. The builtin convention, applied here.
468        if files && matched.is_empty() {
469            return Outcome::Passed;
470        }
471        let root = crate::hooks::common::repo_root();
472        // Through `program()`, exactly like every builtin: on Windows,
473        // `Command::new("npx")` cannot start `npx.cmd`, and an external that
474        // fails to SPAWN reports Unavailable — warn, never block — so the
475        // check would silently never run. The guard test that enforces this
476        // for builtins scans only `src/hooks/`; this call is the manifest's
477        // half of the same rule.
478        let mut cmd = Command::new(crate::hooks::common::program(program));
479        cmd.args(args).current_dir(&root).stdin(Stdio::null());
480        // Env, not only argv: newline-separated so ordinary shell loops can
481        // read it. A path with a newline in its name would split wrong — the
482        // list is repo-controlled and such a path is already hostile input —
483        // and a change set too large for an environment variable (rare, but
484        // E2BIG kills the spawn outright) travels as an empty variable
485        // instead, which a wrapper treats exactly like "derive it yourself".
486        let joined = matched.join("\n");
487        cmd.env(
488            "AMONT_FILES",
489            if joined.len() <= 100_000 {
490                joined.as_str()
491            } else {
492                ""
493            },
494        );
495        if files {
496            cmd.args(&matched);
497        }
498        crate::hooks::common::strip_git_env(&mut cmd);
499        // Under the deadline: repo-authored code that outlives the budget is
500        // killed and FAILS — "hung" must not read as "passed", and pre-push
501        // runs these serially where one hang stalls the entire push.
502        let status = match crate::hooks::common::status_streamed(&mut cmd) {
503            Ok(crate::hooks::common::Ran::Status(s)) => Ok(s),
504            Ok(crate::hooks::common::Ran::TimedOut(budget)) => {
505                crate::hooks::common::say_timed_out(&self.short_name, budget);
506                return Outcome::Failed;
507            }
508            Err(e) => Err(e),
509        };
510        match status {
511            // A command that could not be STARTED has not judged anything. This
512            // is the distinction `Outcome` was added for: reporting a missing
513            // `shellcheck` as a lint failure sends someone hunting for a lint
514            // error that does not exist.
515            Err(e) => {
516                crate::hooks::common::warn(&format!(
517                    "{MANIFEST}: {} could not run {} — {}",
518                    crate::ui::highlight(&self.short_name),
519                    crate::ui::highlight(program),
520                    // The io error's text embeds the program name it tried.
521                    crate::ui::sanitize(&e.to_string())
522                ));
523                Outcome::Unavailable
524            }
525            Ok(s) if s.success() => {
526                // A declared fixer that ran clean may still have rewritten
527                // something; re-stage exactly what moved. Only its own scope,
528                // so it cannot stage a file it never looked at.
529                if fix == Fix::Rewrite && crate::hooks::common::fixing_enabled() {
530                    match crate::hooks::common::restage(&matched) {
531                        Restaged::Staged => {
532                            crate::hooks::common::ok(&format!(
533                                "{} fixed and re-staged",
534                                crate::ui::highlight(&self.short_name)
535                            ));
536                            return Outcome::Fixed;
537                        }
538                        // `git add` failed, so the index still holds whatever
539                        // the command has already replaced on disk. This used
540                        // to be indistinguishable from "nothing moved" and was
541                        // reported as a pass.
542                        Restaged::Failed(stuck) => {
543                            crate::hooks::common::fail(&format!(
544                                "{} rewrote files but {} failed — the index still holds the \
545                                 OLD content: {}",
546                                crate::ui::highlight(&self.short_name),
547                                crate::ui::highlight("git add"),
548                                crate::ui::sanitize(&stuck.join(", "))
549                            ));
550                            return Outcome::Failed;
551                        }
552                        Restaged::Nothing => {}
553                    }
554                }
555                Outcome::Passed
556            }
557            Ok(_) => {
558                crate::hooks::common::fail(&format!(
559                    "{} failed (output above)",
560                    crate::ui::highlight(&self.short_name)
561                ));
562                Outcome::Failed
563            }
564        }
565    }
566}
567
568/// The paths this check's scope actually covers.
569fn scoped(scope: &Scope, paths: &[String]) -> Vec<String> {
570    if scope.is_unscoped() {
571        return paths.to_vec();
572    }
573    paths.iter().filter(|p| scope.covers(p)).cloned().collect()
574}
575
576/// `Scope` holds `&'static` slices so a built-in can be a `const`. A parsed
577/// manifest has neither, so its extension list is leaked.
578///
579/// This is bounded and deliberate: the manifest is read at most once per
580/// process, holds a handful of short strings, and the process is a git hook that
581/// exits in milliseconds. The alternative — a lifetime on `Scope` — would
582/// propagate through the trait, both dispatchers and the fleet crate to buy back
583/// a few hundred bytes that the kernel reclaims moments later.
584fn leak(exts: Vec<String>) -> &'static [&'static str] {
585    let refs: Vec<&'static str> = exts
586        .into_iter()
587        .map(|s| &*Box::leak(s.into_boxed_str()))
588        .collect();
589    Box::leak(refs.into_boxed_slice())
590}
591
592/// `*` means any change; `*.sh` or `*.sh,*.bash` gate on extensions.
593///
594/// No `opt_in` counterpart, because the manifest IS the opt-in: a repository
595/// that does not want the check deletes the line.
596///
597/// Returns owned extensions rather than a `Scope`, so validating a manifest
598/// costs nothing permanent. Only `External::from` turns these into the
599/// `&'static` form `Scope` requires.
600fn parse_scope(token: &str) -> Result<(Vec<String>, Vec<String>), ParseError> {
601    if token == "*" {
602        return Ok((Vec::new(), Vec::new()));
603    }
604    let mut exts = Vec::new();
605    let mut names = Vec::new();
606    for part in token.split(',') {
607        if let Some(ext) = part.strip_prefix('*').filter(|ext| ext.starts_with('.')) {
608            exts.push(ext.to_string());
609            continue;
610        }
611        // A bare token is an exact FILENAME — `package.json`, `Dockerfile`,
612        // `.prettierrc` — matched against the basename. Directories are not
613        // expressible (a `/` is refused), and anything that LOOKS like a glob
614        // (`*`, `?`, `[`) is refused as the typo it almost certainly is —
615        // this grammar deliberately has no globs to mis-guess.
616        if !part.is_empty() && !part.contains(['*', '?', '[', '/']) {
617            names.push(part.to_string());
618            continue;
619        }
620        return Err(ParseError::BadScope(part.to_string()));
621    }
622    Ok((exts, names))
623}
624
625fn parse_stage(token: &str) -> Option<Stage> {
626    match token {
627        "pre-commit" => Some(Stage::PreCommit),
628        "pre-push" => Some(Stage::PrePush),
629        _ => None,
630    }
631}
632
633/// An identity already spoken for. An external must not be able to shadow
634/// `pre-push-branch-protect` — nor silently lose to it, which is what a
635/// first-match lookup would do without this.
636///
637/// Judged on the ID, which is why `clippy` on `pre-push` is now legal: it is
638/// `pre-push-clippy`, a different check from `pre-commit-clippy`. Judged on the
639/// bare name, as it was, the two collided and the second was refused.
640///
641fn name_is_taken(id: &str) -> bool {
642    CHECKS.iter().any(|c| c.name == id) || ENTRYPOINTS.iter().any(|(n, _)| *n == id)
643}
644
645/// A short name that says its own trigger — either by being one, or by starting
646/// with one.
647///
648/// Both make an id ambiguous rather than merely ugly. `pre-commit` as a name
649/// gives `hook.skip pre-commit` two readings; `pre-commit-clippy` as a name
650/// gives a check whose SHORT name is the built-in's FULL id, so one skip
651/// silences both. The stage column already says which trigger this is.
652fn name_says_its_trigger(name: &str) -> bool {
653    crate::TRIGGERS
654        .iter()
655        .any(|t| name == *t || name.starts_with(&format!("{t}-")))
656}
657
658/// The four leading tokens and the untouched remainder, or `None` when the line
659/// does not have them.
660///
661/// The arity is in the TYPE. Returning a `Vec` made "four or it is malformed"
662/// a rule every caller had to remember and none could be checked against.
663///
664/// NOT `splitn(5, char::is_whitespace)`: that splits at the FIRST whitespace
665/// character every time, so a file aligned into columns — which is how the
666/// format invites you to write it — yields empty fields for every run of
667/// spaces after the first.
668fn tokenise(line: &str) -> Option<([&str; 4], &str)> {
669    let mut fields: [&str; 4] = [""; 4];
670    let mut rest = line;
671    for slot in fields.iter_mut() {
672        rest = rest.trim_start();
673        let i = rest.find(char::is_whitespace)?;
674        *slot = &rest[..i];
675        rest = &rest[i..];
676    }
677    let command = rest.trim();
678    (!command.is_empty()).then_some((fields, command))
679}
680
681pub fn parse_lines(text: &str) -> Vec<Line> {
682    let mut out: Vec<Line> = Vec::new();
683    for (i, raw) in text.lines().enumerate() {
684        let line = raw.trim();
685        if line.is_empty() || line.starts_with('#') {
686            continue;
687        }
688        let lineno = i + 1;
689        out.push(parse_line(lineno, line, &out));
690    }
691    out
692}
693
694/// One line, given the lines already accepted.
695///
696/// `earlier` is read ONLY for the duplicate check, and only its `Usable`
697/// entries — a line that cannot run does not reserve its name. Before this, a
698/// valid declaration was rejected as "declared twice" for colliding with a
699/// broken one, which pointed the reader at the wrong line entirely.
700fn parse_line(lineno: usize, line: &str, earlier: &[Line]) -> Line {
701    // `tool` was never a valid stage, so claiming it as a keyword breaks no
702    // manifest that ever parsed. Handled before `tokenise`, whose five-column
703    // shape a three-token pin does not have.
704    if line == "tool" || line.starts_with("tool ") || line.starts_with("tool\t") {
705        let mut it = line.split_whitespace().skip(1);
706        return match (it.next(), it.next(), it.next()) {
707            (Some(program), Some(want), None) => Line::Tool(ToolPin {
708                program: program.to_string(),
709                want: want.to_string(),
710            }),
711            _ => broken_at(
712                lineno,
713                format!("{MANIFEST}:{lineno}"),
714                None,
715                ParseError::BadTool,
716            ),
717        };
718    }
719    // `severity` and `skip` were never valid stages either — the same
720    // keyword claim `tool` made. Fixed arity; anything else is a positional
721    // gap, never a phantom check named after its target (a broken
722    // `severity clippy loud` must not mint `pre-commit-clippy`).
723    if line == "severity" || line.starts_with("severity ") || line.starts_with("severity\t") {
724        let mut it = line.split_whitespace().skip(1);
725        return match (it.next(), it.next(), it.next()) {
726            (Some(target), Some(word), None) => match Severity::parse(word) {
727                Some(severity) => Line::Policy {
728                    what: PolicyLine::Severity {
729                        target: target.to_string(),
730                        severity,
731                    },
732                    lineno,
733                },
734                None => broken_at(
735                    lineno,
736                    format!("{MANIFEST}:{lineno}"),
737                    None,
738                    ParseError::BadSeverity(word.to_string()),
739                ),
740            },
741            _ => broken_at(
742                lineno,
743                format!("{MANIFEST}:{lineno}"),
744                None,
745                ParseError::BadPolicyLine("severity <check> warn|block"),
746            ),
747        };
748    }
749    if line == "skip" || line.starts_with("skip ") || line.starts_with("skip\t") {
750        let mut it = line.split_whitespace().skip(1);
751        return match (it.next(), it.next()) {
752            (Some(target), None) => Line::Policy {
753                what: PolicyLine::Skip {
754                    target: target.to_string(),
755                },
756                lineno,
757            },
758            _ => broken_at(
759                lineno,
760                format!("{MANIFEST}:{lineno}"),
761                None,
762                ParseError::BadPolicyLine("skip <check>"),
763            ),
764        };
765    }
766    if line == "set" || line.starts_with("set ") || line.starts_with("set\t") {
767        let mut it = line.split_whitespace().skip(1);
768        return match (it.next(), it.next(), it.next()) {
769            (Some(key), Some(value), None) => {
770                // Case-insensitive against the allowlist, canonical spelling
771                // stored — `set commit.subjectmax 72` must work, because git
772                // would have accepted the key in any case.
773                match SETTABLE.iter().find(|k| k.eq_ignore_ascii_case(key)) {
774                    Some(canonical) => Line::Policy {
775                        what: PolicyLine::Set {
776                            key: format!("amont.{canonical}"),
777                            value: value.to_string(),
778                        },
779                        lineno,
780                    },
781                    None => broken_at(
782                        lineno,
783                        format!("{MANIFEST}:{lineno}"),
784                        None,
785                        ParseError::UnsettableKey(key.to_string()),
786                    ),
787                }
788            }
789            _ => broken_at(
790                lineno,
791                format!("{MANIFEST}:{lineno}"),
792                None,
793                ParseError::BadPolicyLine("set <key> <value>"),
794            ),
795        };
796    }
797    let (fields, command) = match tokenise(line) {
798        Some(t) => t,
799        // No name to report: fall back to the position, which is the only
800        // handle a reader has on a line this malformed.
801        None => {
802            return broken_at(
803                lineno,
804                name_or_position(tokenise(line).map(|(f, _)| f[1]).unwrap_or(""), lineno),
805                None,
806                ParseError::MissingFields,
807            )
808        }
809    };
810    let [stage_tok, declared, scope_tok, severity_tok] = fields;
811    let stage = parse_stage(stage_tok);
812    let name = name_or_position(declared, lineno);
813    let fail = |why| broken_at(lineno, name.clone(), stage, why);
814
815    if declared.is_empty() {
816        return fail(ParseError::MissingName);
817    }
818    // The stage is settled BEFORE the name is judged, because the identity
819    // being judged is `<trigger>-<name>` and there is no such thing without a
820    // trigger. Ordered the other way, a line with an unusable stage was refused
821    // for a name clash that could not be assessed yet.
822    let Some(stage) = stage else {
823        return fail(ParseError::BadStage(stage_tok.to_string()));
824    };
825    if name_says_its_trigger(declared) {
826        return fail(ParseError::TriggerInName(declared.to_string()));
827    }
828    let id = format!("{}-{}", stage.as_str(), declared);
829    if name_is_taken(&id) {
830        return fail(ParseError::NameTaken(declared.to_string()));
831    }
832    if earlier
833        .iter()
834        .any(|l| matches!(l, Line::Usable(d) if d.id() == id))
835    {
836        return fail(ParseError::Duplicate(declared.to_string()));
837    }
838    let (exts, names) = match parse_scope(scope_tok) {
839        Ok(e) => e,
840        Err(why) => return fail(why),
841    };
842    let Some(severity) = Severity::parse(severity_tok) else {
843        return fail(ParseError::BadSeverity(severity_tok.to_string()));
844    };
845    // `fix` and `files` are leading markers on the command column rather
846    // than extra fields, so every manifest written before them still parses.
847    // A loop, so `fix files cmd` and `files fix cmd` both work — two markers
848    // whose order carries no meaning must not be order-sensitive.
849    let mut command = command;
850    let mut wants_fix = false;
851    let mut wants_files = false;
852    loop {
853        if let Some(rest) = command.strip_prefix("fix ") {
854            command = rest.trim_start();
855            wants_fix = true;
856            continue;
857        }
858        if let Some(rest) = command.strip_prefix("files ") {
859            command = rest.trim_start();
860            wants_files = true;
861            continue;
862        }
863        break;
864    }
865    if wants_fix && stage == Stage::PrePush {
866        return fail(ParseError::FixOnPrePush);
867    }
868    // `tokenise` guarantees a non-empty command, so the split cannot fail.
869    let mut argv = command.split_whitespace().map(str::to_owned);
870    let Some(program) = argv.next() else {
871        return fail(ParseError::MissingFields);
872    };
873    Line::Usable(Declared {
874        fix: if wants_fix { Fix::Rewrite } else { Fix::None },
875        files: wants_files,
876        name: declared.to_string(),
877        stage,
878        severity,
879        exts,
880        names,
881        program,
882        args: argv.collect(),
883    })
884}
885
886fn name_or_position(declared: &str, lineno: usize) -> String {
887    if declared.is_empty() {
888        format!("{MANIFEST}:{lineno}")
889    } else {
890        declared.to_string()
891    }
892}
893
894fn broken_at(lineno: usize, name: String, stage: Option<Stage>, why: ParseError) -> Line {
895    Line::Broken {
896        name,
897        stage: stage.unwrap_or(Stage::PreCommit),
898        lineno,
899        why,
900    }
901}
902
903impl From<Line> for External {
904    fn from(l: Line) -> External {
905        let (name, stage, parsed) = l.into_parts();
906        let kind = match parsed {
907            Ok(d) => Kind::Runnable {
908                scope: if d.exts.is_empty() && d.names.is_empty() {
909                    Scope::ALWAYS
910                } else {
911                    Scope {
912                        files: leak(d.exts),
913                        names: leak(d.names),
914                        opt_in: &[],
915                        not_during: &[],
916                    }
917                },
918                severity: d.severity,
919                program: d.program,
920                args: d.args,
921                fix: d.fix,
922                files: d.files,
923            },
924            Err(why) => Kind::Unusable { why },
925        };
926        let id = format!("{}-{}", stage.as_str(), name);
927        External {
928            id,
929            short_name: name,
930            stage,
931            kind,
932        }
933    }
934}
935
936pub fn parse(text: &str) -> Vec<External> {
937    parse_lines(text)
938        .into_iter()
939        .filter(Line::is_check)
940        .map(External::from)
941        .collect()
942}
943
944/// The manifest for `root`, or an empty list. Read once per process.
945pub fn read(root: &Path) -> Vec<External> {
946    std::fs::read_to_string(root.join(MANIFEST))
947        .map(|t| parse(&t))
948        .unwrap_or_default()
949}
950
951/// The same file, without building the `Scope`s — for a reader that inspects
952/// many repositories and must not leak once per manifest per refresh.
953pub fn read_lines(root: &Path) -> Vec<Line> {
954    std::fs::read_to_string(root.join(MANIFEST))
955        .map(|t| parse_lines(&t))
956        .unwrap_or_default()
957}
958
959/// Everything one repository's manifest declares, parsed and trust-gated
960/// ONCE, owned by the entrypoint that loaded it and lent down through `Ctx`.
961///
962/// This replaced two process-global `OnceLock`s keyed on the working
963/// directory at first call — safe in a hook, which handles one repository
964/// and exits, and a trap for anything that walks many. Owned data has no
965/// first-call: the caller says which repository it means, every time.
966#[derive(Default)]
967pub struct Manifest {
968    /// Declared checks — untrusted ones present but `Unusable`, so the
969    /// decision waiting on the reader stays visible. See [`gate`].
970    pub externals: Vec<External>,
971    /// Tool version pins — DROPPED entirely when untrusted, because verifying
972    /// one executes `<program> --version` for a name the repository chose,
973    /// which is exactly the consent the trust model exists to collect.
974    pub pins: Vec<ToolPin>,
975    /// Whether an `amont.conf` EXISTS in this repository — the committed
976    /// declaration that this project subscribes to amont's conventions.
977    /// Presence, not content: an empty file declares, and declaring executes
978    /// nothing, so this is safe to read before any trust decision. What the
979    /// file SAYS stays trust-gated above.
980    pub declared: bool,
981    /// Committed `severity`/`skip` policy — POPULATED ONLY WHEN TRUSTED,
982    /// like the pins: applying a repository's opinion about your safety net
983    /// is exactly the consent the trust model collects. See `policy`.
984    pub policy: crate::policy::Policy,
985    /// `trust::why(state)` when policy lines exist but the manifest is not
986    /// trusted — the dispatchers say it once per stage, because policy that
987    /// silently does not apply is a silent behaviour change.
988    pub policy_withheld: Option<&'static str>,
989    /// `severity`/`skip` targets that name no check here, with positions —
990    /// a typo in committed policy must be loud, not a phantom check.
991    pub policy_notes: Vec<String>,
992}
993
994/// Read and trust-gate `root`'s manifest.
995///
996/// ONE read. The bytes that get PARSED and the bytes that get HASHED are the
997/// same bytes: an earlier shape `read(root)`-ed and then let `trust::state`
998/// open the file a second time, so anything that changed it in between — a
999/// `git checkout`, a watcher, a `make` target already running — produced a
1000/// trust decision about content that is not the content about to be
1001/// executed.
1002///
1003/// Each call leaks the `Scope` slices it builds (see [`leak`]) — pennies for
1004/// the hook path, which loads once per process, and the reason the fleet
1005/// keeps reading [`read_lines`] instead: a scanner must not leak once per
1006/// repository per refresh.
1007pub fn load(root: &Path) -> Manifest {
1008    // Non-UTF-8 yields nothing, as it always has: `parse` takes a `&str`,
1009    // and a manifest we cannot read as text is one we cannot act on. Not
1010    // lossy — that would invent a manifest nobody wrote.
1011    let declared = root.join(MANIFEST).exists();
1012    let Ok(bytes) = std::fs::read(root.join(MANIFEST)) else {
1013        return Manifest {
1014            declared,
1015            ..Manifest::default()
1016        };
1017    };
1018    let Ok(text) = String::from_utf8(bytes.clone()) else {
1019        return Manifest {
1020            declared,
1021            ..Manifest::default()
1022        };
1023    };
1024    let state = crate::trust::state_of(root, &bytes);
1025    let lines = parse_lines(&text);
1026    let externals = gate(
1027        lines
1028            .iter()
1029            .filter(|l| l.is_check())
1030            .cloned()
1031            .map(External::from)
1032            .collect(),
1033        state,
1034    );
1035    let trusted = state == crate::trust::State::Trusted;
1036    let pins = if trusted {
1037        lines
1038            .iter()
1039            .filter_map(|l| match l {
1040                Line::Tool(pin) => Some(pin.clone()),
1041                _ => None,
1042            })
1043            .collect()
1044    } else {
1045        Vec::new()
1046    };
1047    let has_policy = lines.iter().any(|l| matches!(l, Line::Policy { .. }));
1048    let (policy, policy_notes) = if trusted {
1049        crate::policy::Policy::from_lines(&lines)
1050    } else {
1051        (crate::policy::Policy::default(), Vec::new())
1052    };
1053    // NOTE: load() computes but never INSTALLS the policy — the entrypoints
1054    // do, immediately after this returns and before any config read. A
1055    // multi-repo walker (the fleet) reads `read_lines` and must never seed
1056    // the process-global store.
1057    let policy_withheld = if has_policy && !trusted {
1058        crate::trust::why(state)
1059    } else {
1060        None
1061    };
1062    Manifest {
1063        externals,
1064        pins,
1065        declared,
1066        policy,
1067        policy_withheld,
1068        policy_notes,
1069    }
1070}
1071
1072/// Check every trusted pin against the tool actually on this machine, and say
1073/// what disagrees. Warn-only, once per hook run, at BOTH stages: skew never
1074/// blocks a commit — its cost is a check disagreeing with CI, and the fix is
1075/// a human decision — but it stops being invisible, which is the whole point.
1076pub fn verify_tool_pins(pins: &[ToolPin]) {
1077    for pin in pins {
1078        match version_of(&pin.program) {
1079            None => crate::hooks::common::warn(&format!(
1080                "{} is pinned to {} in {MANIFEST}, but `{} --version` would not run",
1081                crate::ui::highlight(&pin.program),
1082                crate::ui::sanitize(&pin.want),
1083                crate::ui::sanitize(&pin.program),
1084            )),
1085            Some(v) if !v.contains(&pin.want) => crate::hooks::common::warn(&format!(
1086                "{} reports {} — {MANIFEST} pins {}; this machine may disagree with CI",
1087                crate::ui::highlight(&pin.program),
1088                crate::ui::sanitize(&v),
1089                crate::ui::sanitize(&pin.want),
1090            )),
1091            _ => {}
1092        }
1093    }
1094}
1095
1096/// First line of `<program> --version`, resolved the way every check resolves
1097/// a tool. A version probe answers in milliseconds or not at all, so it is
1098/// deliberately not under the check deadline.
1099fn version_of(program: &str) -> Option<String> {
1100    let out = Command::new(crate::hooks::common::program(program))
1101        .arg("--version")
1102        .stdin(Stdio::null())
1103        .output()
1104        .ok()?;
1105    if !out.status.success() {
1106        return None;
1107    }
1108    let text = String::from_utf8_lossy(&out.stdout);
1109    let line = text.lines().next().unwrap_or("").trim();
1110    if line.is_empty() {
1111        return None;
1112    }
1113    Some(line.to_string())
1114}
1115
1116/// Apply a trust verdict to what the manifest declared.
1117///
1118/// Untrusted declarations are kept and DISABLED, not dropped. The names stay
1119/// visible in `amont list`, in the dashboard and in the "could not run"
1120/// roll-up, because a repository quietly declaring checks that never run is the
1121/// failure this project is arranged against — and the reader needs to know
1122/// there is a decision waiting for them.
1123///
1124/// Split out of [`load`] so the rule can be asserted without a repository on
1125/// disk — and kept split even now that `load` takes an explicit root, because
1126/// a trust verdict is one input among several there and this is the part with
1127/// the rule in it.
1128pub(crate) fn gate(declared: Vec<External>, state: crate::trust::State) -> Vec<External> {
1129    match crate::trust::why(state) {
1130        None => declared,
1131        Some(reason) => declared
1132            .into_iter()
1133            .map(|external| External {
1134                kind: Kind::Unusable {
1135                    why: reason.to_string(),
1136                },
1137                ..external
1138            })
1139            .collect(),
1140    }
1141}
1142
1143#[cfg(test)]
1144mod tests {
1145    use super::*;
1146
1147    fn one(text: &str) -> Line {
1148        let mut v = parse_lines(text);
1149        assert_eq!(v.len(), 1, "expected one entry from {text:?}");
1150        v.pop().expect("one")
1151    }
1152
1153    /// The error a line produced, as a VALUE. Tests used to match on the prose,
1154    /// which coupled them to wording and would have kept passing if the wording
1155    /// stayed while the meaning changed.
1156    fn why(l: &Line) -> ParseError {
1157        match l {
1158            Line::Broken { why, .. } => why.clone(),
1159            Line::Usable(d) => panic!("{} parsed when it should not have", d.name),
1160            Line::Tool(pin) => panic!("{} parsed as a pin, not a broken line", pin.program),
1161            Line::Policy { what, .. } => {
1162                panic!("{} parsed as policy, not a broken line", what.describe())
1163            }
1164        }
1165    }
1166
1167    fn usable(l: &Line) -> &Declared {
1168        match l {
1169            Line::Usable(d) => d,
1170            Line::Broken { name, why, .. } => panic!("{name} failed to parse: {why}"),
1171            Line::Tool(pin) => panic!("{} is a tool pin, not a declaration", pin.program),
1172            Line::Policy { what, .. } => {
1173                panic!("{} is policy, not a declaration", what.describe())
1174            }
1175        }
1176    }
1177
1178    /// The scope column's bare tokens are exact filenames — basename-matched,
1179    /// so `package.json` cannot be counterfeited by `not-package.json` — and
1180    /// they mix freely with extensions. Directories and free `*` stay refused.
1181    #[test]
1182    fn a_bare_scope_token_is_an_exact_filename() {
1183        let line = one("pre-commit  lockcheck  package.json  block  ./check.sh\n");
1184        let d = usable(&line);
1185        assert!(d.exts.is_empty());
1186        assert_eq!(d.names, ["package.json"]);
1187
1188        let line = one("pre-commit  x  *.ts,package.json,.prettierrc  block  ./x\n");
1189        let d = usable(&line);
1190        assert_eq!(d.exts, [".ts"]);
1191        assert_eq!(d.names, ["package.json", ".prettierrc"]);
1192
1193        assert_eq!(
1194            why(&one("pre-commit  x  src/package.json  block  ./x\n")),
1195            ParseError::BadScope("src/package.json".into())
1196        );
1197        assert_eq!(
1198            why(&one("pre-commit  x  pkg*  block  ./x\n")),
1199            ParseError::BadScope("pkg*".into())
1200        );
1201    }
1202
1203    /// The pin grammar: exactly three tokens, claimed from a first token that
1204    /// was never a valid stage — no manifest that ever parsed changes meaning.
1205    #[test]
1206    fn a_tool_pin_parses_and_a_malformed_one_is_broken() {
1207        let line = one("tool  ruff  0.6.\n");
1208        assert_eq!(
1209            line,
1210            Line::Tool(ToolPin {
1211                program: "ruff".into(),
1212                want: "0.6.".into()
1213            })
1214        );
1215        assert_eq!(why(&one("tool ruff\n")), ParseError::BadTool);
1216        assert_eq!(why(&one("tool ruff 0.6. extra\n")), ParseError::BadTool);
1217        // `tool` is only a keyword in column one — a check NAMED tool still
1218        // parses as the check it always was.
1219        let line = one("pre-commit  tool  *  block  make tool\n");
1220        assert_eq!(usable(&line).name, "tool");
1221    }
1222
1223    #[test]
1224    fn parses_the_documented_example() {
1225        let v = parse_lines(
1226            "# stage       name        scope   severity  command\n\
1227             pre-commit    shellcheck  *.sh    block     scripts/lint-shell.sh\n\
1228             pre-push      smoke       *       warn      make smoke\n",
1229        );
1230        assert_eq!(v.len(), 2);
1231
1232        let a = usable(&v[0]);
1233        assert_eq!(a.name, "shellcheck");
1234        assert_eq!(a.stage, Stage::PreCommit);
1235        assert_eq!(a.severity, Severity::Block);
1236        assert_eq!(a.program, "scripts/lint-shell.sh");
1237        assert!(a.args.is_empty());
1238        assert_eq!(a.exts, [".sh"]);
1239
1240        let b = usable(&v[1]);
1241        assert_eq!(b.stage, Stage::PrePush);
1242        assert_eq!(b.severity, Severity::Warn);
1243        // A command with arguments is split, not handed to a shell.
1244        assert_eq!(b.program, "make");
1245        assert_eq!(b.args, ["smoke"]);
1246        assert!(b.exts.is_empty(), "`*` gates on nothing");
1247    }
1248
1249    /// Blank lines and comments are not entries, and must not become broken
1250    /// ones — a file that is mostly documentation would otherwise report a
1251    /// dozen gaps.
1252    #[test]
1253    fn comments_and_blank_lines_produce_nothing() {
1254        assert!(parse_lines("\n  \n# just a comment\n\t# indented\n").is_empty());
1255    }
1256
1257    /// The rule the module commits to: a line that cannot be understood still
1258    /// yields a check, so its absence is visible. Matched by VARIANT.
1259    #[test]
1260    fn a_malformed_line_becomes_a_visible_gap() {
1261        let cases: [(&str, ParseError); 4] = [
1262            (
1263                "pre-commit shellcheck *.sh block\n",
1264                ParseError::MissingFields,
1265            ),
1266            (
1267                "nonsense shellcheck *.sh block x\n",
1268                ParseError::BadStage("nonsense".into()),
1269            ),
1270            (
1271                "pre-commit shellcheck ?.sh block x\n",
1272                ParseError::BadScope("?.sh".into()),
1273            ),
1274            (
1275                "pre-commit shellcheck *.sh loud x\n",
1276                ParseError::BadSeverity("loud".into()),
1277            ),
1278        ];
1279        for (text, expected) in cases {
1280            assert_eq!(why(&one(text)), expected, "for {text:?}");
1281        }
1282    }
1283
1284    /// The prose still has to locate the line, even though the tests no longer
1285    /// depend on its wording.
1286    #[test]
1287    fn a_gap_reports_where_it_is() {
1288        let l = one("pre-commit shellcheck *.sh loud x\n");
1289        let said = l.broken().expect("broken");
1290        assert!(said.contains("line 1"), "{said}");
1291        assert!(said.contains("severity"), "{said}");
1292    }
1293
1294    /// `fix` on a pre-push line is refused where every other bad declaration is
1295    /// refused — on every commit, named and located — rather than as a runtime
1296    /// "contract violation" discovered later at push time by fewer people.
1297    #[test]
1298    fn fix_is_refused_on_a_pre_push_line() {
1299        assert_eq!(
1300            why(&one("pre-push  smoke  *  block  fix make smoke\n")),
1301            ParseError::FixOnPrePush
1302        );
1303        // …and accepted on pre-commit.
1304        let line = one("pre-commit  fmt  *  block  fix make format\n");
1305        let declared = usable(&line);
1306        assert_eq!(declared.fix, Fix::Rewrite);
1307        assert_eq!(declared.program, "make");
1308        assert_eq!(declared.args, ["format"]);
1309    }
1310
1311    /// Every manifest written before `fix` existed must still parse the same.
1312    #[test]
1313    fn a_command_that_merely_starts_with_fix_is_not_a_marker() {
1314        let line = one("pre-commit  x  *  block  fixup-tool --check\n");
1315        let declared = usable(&line);
1316        assert_eq!(declared.fix, Fix::None);
1317        assert_eq!(declared.program, "fixup-tool");
1318    }
1319
1320    /// The `files` marker, alone and stacked with `fix` in either order —
1321    /// two markers whose order carries no meaning must not be
1322    /// order-sensitive.
1323    #[test]
1324    fn the_files_marker_parses_alone_and_in_either_order_with_fix() {
1325        let line = one("pre-commit  sc  *.sh  block  files shellcheck\n");
1326        let declared = usable(&line);
1327        assert!(declared.files);
1328        assert_eq!(declared.fix, Fix::None);
1329        assert_eq!(declared.program, "shellcheck");
1330
1331        for text in [
1332            "pre-commit  fmt  *  block  fix files prettier --write\n",
1333            "pre-commit  fmt  *  block  files fix prettier --write\n",
1334        ] {
1335            let line = one(text);
1336            let declared = usable(&line);
1337            assert!(declared.files, "for {text:?}");
1338            assert_eq!(declared.fix, Fix::Rewrite, "for {text:?}");
1339            assert_eq!(declared.program, "prettier", "for {text:?}");
1340            assert_eq!(declared.args, ["--write"], "for {text:?}");
1341        }
1342    }
1343
1344    /// A command that merely starts with `files` keeps its name, exactly as
1345    /// `fixup-tool` keeps its own.
1346    #[test]
1347    fn a_command_that_merely_starts_with_files_is_not_a_marker() {
1348        let line = one("pre-commit  x  *  block  files-checker --strict\n");
1349        let declared = usable(&line);
1350        assert!(!declared.files);
1351        assert_eq!(declared.program, "files-checker");
1352    }
1353
1354    /// A gap with no name cannot be reported, and a line this broken has none.
1355    #[test]
1356    fn a_nameless_line_is_named_after_its_position() {
1357        let l = one("pre-commit\n");
1358        assert_eq!(l.name(), "amont.conf:1");
1359        assert_eq!(why(&l), ParseError::MissingFields);
1360    }
1361
1362    /// An external must not be able to take a built-in's id — it would either
1363    /// shadow `pre-push-branch-protect` or silently lose to it, and neither is
1364    /// something a repository should be able to do by editing a text file.
1365    ///
1366    /// Judged on the id, so the same declaration is refused on one trigger and
1367    /// accepted on the other. That is not a loophole: `pre-push-clippy` is a
1368    /// different check from `pre-commit-clippy`, and nothing is shadowed.
1369    #[test]
1370    fn a_built_in_id_is_refused() {
1371        assert_eq!(
1372            why(&one("pre-commit clippy *.rs block x\n")),
1373            ParseError::NameTaken("clippy".into())
1374        );
1375        assert!(matches!(
1376            one("pre-push clippy *.rs block x\n"),
1377            Line::Usable(_)
1378        ));
1379        // And a pre-push built-in is protected on pre-push, not on pre-commit,
1380        // for the same reason.
1381        assert_eq!(
1382            why(&one("pre-push branch-protect * block x\n")),
1383            ParseError::NameTaken("branch-protect".into())
1384        );
1385        assert!(matches!(
1386            one("pre-commit branch-protect * block x\n"),
1387            Line::Usable(_)
1388        ));
1389    }
1390
1391    /// The stage column says which trigger a line is for. Saying it again in
1392    /// the name is the one way to make an id ambiguous: `pre-commit-clippy` as
1393    /// a NAME is a check whose short name is the built-in's full id, so one
1394    /// `hook.skip` would silence both.
1395    #[test]
1396    fn a_name_that_says_its_own_trigger_is_refused() {
1397        for name in ["pre-commit", "pre-push", "pre-commit-clippy", "pre-push-x"] {
1398            assert_eq!(
1399                why(&one(&format!("pre-commit {name} * block x\n"))),
1400                ParseError::TriggerInName(name.into()),
1401                "{name}"
1402            );
1403        }
1404        // A name that merely begins with the same letters is fine — the trigger
1405        // has to be followed by the separator to count.
1406        assert!(matches!(
1407            one("pre-commit pre-commitish * block x\n"),
1408            Line::Usable(_)
1409        ));
1410    }
1411
1412    /// Two USABLE lines with one ID: the second cannot be addressed by
1413    /// `hook.skip` or by a severity override, so it is refused.
1414    #[test]
1415    fn a_duplicate_id_is_refused() {
1416        let v = parse_lines(
1417            "pre-commit smoke * block a\n\
1418             pre-commit smoke * block b\n",
1419        );
1420        assert_eq!(v.len(), 2);
1421        assert_eq!(usable(&v[0]).id(), "pre-commit-smoke");
1422        assert_eq!(why(&v[1]), ParseError::Duplicate("smoke".into()));
1423    }
1424
1425    /// The same name on both triggers is TWO checks, and this used to refuse
1426    /// the second. Somebody wanting a `show-unicorn` on commit and on push had
1427    /// no way to write it, and no way to skip or downgrade one without the
1428    /// other — the bare name could not tell them apart.
1429    #[test]
1430    fn the_same_name_on_two_triggers_is_allowed() {
1431        let v = parse_lines(
1432            "pre-commit show-unicorn * block a\n\
1433             pre-push   show-unicorn * block b\n",
1434        );
1435        assert_eq!(v.len(), 2);
1436        assert_eq!(usable(&v[0]).id(), "pre-commit-show-unicorn");
1437        assert_eq!(usable(&v[1]).id(), "pre-push-show-unicorn");
1438
1439        // And each is separately addressable, while the short name takes both —
1440        // which is the whole vocabulary, applied to declared checks.
1441        for (id, only) in [
1442            ("pre-commit-show-unicorn", "pre-push-show-unicorn"),
1443            ("pre-push-show-unicorn", "pre-commit-show-unicorn"),
1444        ] {
1445            assert!(crate::skip_suppresses(id, id));
1446            assert!(!crate::skip_suppresses(only, id));
1447        }
1448        assert!(crate::skip_suppresses(
1449            "pre-commit-show-unicorn",
1450            "show-unicorn"
1451        ));
1452        assert!(crate::skip_suppresses(
1453            "pre-push-show-unicorn",
1454            "show-unicorn"
1455        ));
1456        assert!(crate::skip_suppresses(
1457            "pre-commit-show-unicorn",
1458            "pre-commit"
1459        ));
1460        assert!(!crate::skip_suppresses(
1461            "pre-push-show-unicorn",
1462            "pre-commit"
1463        ));
1464    }
1465
1466    /// A line that cannot run does not RESERVE its name.
1467    ///
1468    /// It used to: broken and usable entries shared one list, so a valid
1469    /// declaration was rejected as "declared twice" for colliding with a line
1470    /// that could never execute — pointing the reader at the wrong line, and
1471    /// forcing them to fix the first before the second would work at all.
1472    #[test]
1473    fn a_broken_line_does_not_reserve_its_name() {
1474        let v = parse_lines(
1475            "pre-commit smoke * LOUD   make a\n\
1476             pre-commit smoke * block  make b\n",
1477        );
1478        assert_eq!(v.len(), 2);
1479        assert_eq!(why(&v[0]), ParseError::BadSeverity("LOUD".into()));
1480        let good = usable(&v[1]);
1481        assert_eq!(good.name, "smoke");
1482        assert_eq!(good.program, "make");
1483    }
1484
1485    /// Alignment is cosmetic. A file someone has lined up with tabs, or not
1486    /// lined up at all, must parse identically.
1487    #[test]
1488    fn field_alignment_does_not_matter() {
1489        let spaced = one("pre-commit      shellcheck    *.sh      block     make lint\n");
1490        let tabbed = one("pre-commit\tshellcheck\t*.sh\tblock\tmake lint\n");
1491        assert_eq!(usable(&spaced), usable(&tabbed));
1492        assert_eq!(usable(&spaced).args, ["lint"]);
1493    }
1494
1495    #[test]
1496    fn several_extensions_can_gate_one_check() {
1497        let e = External::from(one("pre-commit shell *.sh,*.bash block make lint\n"));
1498        assert!(e.scope().matches(&["a.bash".into()]));
1499        assert!(e.scope().matches(&["a.sh".into()]));
1500        assert!(!e.scope().matches(&["a.zsh".into()]));
1501    }
1502
1503    /// `tokenise` states its arity in the type, so "four tokens then a command"
1504    /// is checked rather than remembered.
1505    #[test]
1506    fn tokenise_wants_four_fields_and_a_command() {
1507        assert!(tokenise("a b c").is_none(), "too few fields");
1508        assert!(tokenise("a b c d").is_none(), "four fields, no command");
1509        // Trailing whitespace reaches the four fields but still leaves nothing
1510        // to run — the case the `?` on the last field cannot catch.
1511        assert!(
1512            tokenise("a b c d   ").is_none(),
1513            "command is all whitespace"
1514        );
1515        assert!(tokenise("a b c d\t").is_none(), "command is a tab");
1516        let (fields, cmd) = tokenise("a  b\tc   d   run it").expect("four and a command");
1517        assert_eq!(fields, ["a", "b", "c", "d"]);
1518        assert_eq!(cmd, "run it");
1519    }
1520
1521    /// An unusable line carries no command at all — the type has nowhere to put
1522    /// one, which is the point of the split.
1523    #[test]
1524    fn an_unusable_external_holds_no_command() {
1525        let e = External::from(one("pre-commit shellcheck *.sh loud echo hi\n"));
1526        assert!(matches!(e.kind, Kind::Unusable { .. }));
1527        // And it can never block, whatever severity anyone configures.
1528        assert_eq!(e.severity(), Severity::Warn);
1529    }
1530
1531    /// A missing manifest is the normal case and must not be an error.
1532    #[test]
1533    fn a_repository_with_no_manifest_declares_nothing() {
1534        assert!(read(Path::new("/nonexistent-c8f2")).is_empty());
1535        assert!(read_lines(Path::new("/nonexistent-c8f2")).is_empty());
1536    }
1537
1538    /// `Line` exists to spare the dashboard a leak, not to become a second
1539    /// opinion about what a manifest says.
1540    #[test]
1541    fn the_leaking_and_non_leaking_parsers_agree() {
1542        let text = "pre-commit  shellcheck  *.sh,*.bash  block  make lint\n\
1543                    pre-push    smoke       *            warn   make smoke\n\
1544                    pre-commit  broken      ?            block  x\n";
1545        let lines = parse_lines(text);
1546        let externals = parse(text);
1547        assert_eq!(lines.len(), externals.len());
1548        for (l, e) in lines.iter().zip(&externals) {
1549            assert_eq!(l.id(), e.name(), "the id is what a check answers to");
1550            assert_eq!(
1551                l.name(),
1552                e.short_name,
1553                "and the short name is what it is called"
1554            );
1555            assert_eq!(l.stage(), e.stage());
1556            assert_eq!(
1557                l.broken().is_some(),
1558                matches!(e.kind, Kind::Unusable { .. })
1559            );
1560            if let Line::Usable(d) = l {
1561                assert_eq!(d.severity, e.severity());
1562                // The scope the dashboard would DESCRIBE is the scope the
1563                // dispatcher would ENFORCE.
1564                assert_eq!(d.exts, e.scope().files);
1565            }
1566        }
1567    }
1568}