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