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};
42use std::sync::OnceLock;
43
44use crate::check::{Check, Fix, Outcome, Scope, Severity, Stage};
45use crate::hooks::common::Restaged;
46use crate::registry::{Ctx, CHECKS, ENTRYPOINTS};
47
48pub const MANIFEST: &str = "amont.conf";
49
50/// Why a line could not be used.
51///
52/// A type rather than a `String`: the prose belongs in `Display`, and a caller
53/// that wants to ask "was this a duplicate?" should not have to grep for the
54/// word. The tests used to assert on substrings, which coupled them to wording
55/// and would have kept passing if the wording stayed while the meaning changed.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub enum ParseError {
58    MissingFields,
59    MissingName,
60    /// Names a check compiled into the binary.
61    NameTaken(String),
62    /// The name is a trigger, or carries one as a prefix.
63    ///
64    /// `pre-commit  pre-commit-clippy  …` would declare a check whose SHORT
65    /// name is another check's full id, so `hook.skip pre-commit-clippy` would
66    /// mean two things at once. The stage column supplies the trigger; writing
67    /// it again in the name is the one way to make an id ambiguous.
68    TriggerInName(String),
69    /// A second USABLE line claiming a name already claimed ON THE SAME
70    /// TRIGGER. The same name on both triggers is two checks, not a clash.
71    Duplicate(String),
72    BadStage(String),
73    BadScope(String),
74    BadSeverity(String),
75    /// A `pre-push` line asked to rewrite files.
76    ///
77    /// Refused HERE, beside `NameTaken` and `Duplicate`, rather than as a
78    /// runtime "contract violation" at push time: same fact, discovered
79    /// earlier, by more people, at the moment it is cheapest to fix. A pre-push
80    /// hook must not modify the worktree or index — the pushed commit would
81    /// then differ from the tree the developer is looking at.
82    FixOnPrePush,
83}
84
85impl std::fmt::Display for ParseError {
86    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87        match self {
88            ParseError::MissingFields => {
89                write!(f, "expected 5 fields: stage name scope severity command")
90            }
91            ParseError::MissingName => write!(f, "missing name"),
92            ParseError::NameTaken(n) => write!(f, "{n:?} already names a check"),
93            ParseError::TriggerInName(n) => write!(
94                f,
95                "{n:?} must not be a trigger or start with one — the stage column says which"
96            ),
97            ParseError::Duplicate(n) => write!(f, "{n:?} is declared twice on one trigger"),
98            ParseError::BadStage(t) => {
99                write!(f, "stage {t:?} must be `pre-commit` or `pre-push`")
100            }
101            ParseError::BadScope(t) => write!(f, "scope {t:?} must be `*` or `*.<ext>`"),
102            ParseError::FixOnPrePush => write!(
103                f,
104                "`fix` is only for pre-commit — a pre-push hook must not rewrite files"
105            ),
106            ParseError::BadSeverity(t) => {
107                write!(f, "severity {t:?} must be `block` or `warn`")
108            }
109        }
110    }
111}
112
113/// A line that parsed. Every field means something.
114///
115/// `program` and `args` rather than one `argv`: a runnable check must have a
116/// command, and splitting the head off makes that structural instead of a
117/// `split_first` guard that can only ever be dead code.
118#[derive(Debug, Clone, PartialEq, Eq)]
119pub struct Declared {
120    /// `Fix::Rewrite` when the command column began `fix `.
121    pub fix: Fix,
122    pub name: String,
123    pub stage: Stage,
124    pub severity: Severity,
125    /// Extensions that gate it. Empty means any change — the `*` scope.
126    pub exts: Vec<String>,
127    pub program: String,
128    pub args: Vec<String>,
129}
130
131impl Declared {
132    /// `<trigger>-<name>`, the same shape a built-in has.
133    ///
134    /// This is what `hook.skip` and `amont.severity.<key>` resolve against,
135    /// so a declared check answers to its trigger and its short name exactly as
136    /// a compiled-in one does. Before it had an id, `hook.skip pre-commit`
137    /// silenced fifteen built-ins and left every declared check running.
138    pub fn id(&self) -> String {
139        format!("{}-{}", self.stage.as_str(), self.name)
140    }
141
142    /// The command as written, for display.
143    pub fn command(&self) -> String {
144        std::iter::once(self.program.as_str())
145            .chain(self.args.iter().map(String::as_str))
146            .collect::<Vec<_>>()
147            .join(" ")
148    }
149}
150
151/// One manifest line: usable, or not.
152///
153/// A SUM, not a struct with an `Option<why>` beside the fields. The struct
154/// form let a broken line carry a severity, a scope and an argv that meant
155/// nothing — and it produced a wrong diagnosis: because broken and usable
156/// entries shared one list, a valid line was rejected as "declared twice" for
157/// colliding with a line that could not run. Dedup now sees only `Usable`.
158///
159/// Separate from `External` because the fleet reads ninety-six manifests and may
160/// re-read them on every refresh, while `External` holds a `Scope` whose
161/// `&'static` slices are LEAKED.
162#[derive(Debug, Clone, PartialEq, Eq)]
163pub enum Line {
164    Usable(Declared),
165    Broken {
166        /// The declared name, or `<file>:<lineno>` when the line has none — a
167        /// gap has to be nameable to be reportable.
168        name: String,
169        /// Broken lines land on pre-commit unless the stage token parsed: seen
170        /// on every commit beats seen on every push.
171        stage: Stage,
172        lineno: usize,
173        why: ParseError,
174    },
175}
176
177impl Line {
178    pub fn name(&self) -> &str {
179        match self {
180            Line::Usable(d) => &d.name,
181            Line::Broken { name, .. } => name,
182        }
183    }
184    pub fn stage(&self) -> Stage {
185        match self {
186            Line::Usable(d) => d.stage,
187            Line::Broken { stage, .. } => *stage,
188        }
189    }
190    /// `Some(reason)` when this line declares a check that cannot run.
191    pub fn broken(&self) -> Option<String> {
192        match self {
193            Line::Usable(_) => None,
194            Line::Broken { lineno, why, .. } => Some(format!("line {lineno}: {why}")),
195        }
196    }
197
198    /// `<trigger>-<name>`, matching `Declared::id` and `External::id`. A broken
199    /// line has one too: `hook.skip pre-commit` should silence its nag exactly
200    /// as it silences the checks that do run.
201    pub fn id(&self) -> String {
202        format!("{}-{}", self.stage().as_str(), self.name())
203    }
204
205    /// Consume into the identity every line has, and either the declaration or
206    /// the reason there is none.
207    ///
208    /// Both consumers — `External::from` and the fleet's projection — used to
209    /// destructure this by hand, and both carried an arm for a combination the
210    /// type forbids, because they computed the reason BEFORE matching. Written
211    /// once, that arm has nowhere to appear.
212    pub fn into_parts(self) -> (String, Stage, Result<Declared, String>) {
213        let name = self.name().to_string();
214        let stage = self.stage();
215        let parsed = match self {
216            Line::Usable(d) => Ok(d),
217            Line::Broken { lineno, why, .. } => Err(format!("line {lineno}: {why}")),
218        };
219        (name, stage, parsed)
220    }
221}
222
223/// A check a repository declares, rather than one compiled in.
224pub struct External {
225    /// `<trigger>-<name>` — what `hook.skip` and `amont.severity.<key>`
226    /// resolve against, and what `Check::name` returns. Built-ins have had this
227    /// shape all along; declared checks answering to a bare name were invisible
228    /// to `hook.skip pre-commit`.
229    pub id: String,
230    /// The name as written in the manifest — the "short name" of the vocabulary
231    /// — used for messages. A line too malformed to name itself falls back to
232    /// its position, and reading `pre-commit-amont.conf:3` back to somebody
233    /// helps nobody.
234    pub short_name: String,
235    pub stage: Stage,
236    pub kind: Kind,
237}
238
239/// The two things an external can be. `Scope` and `Severity` live only on the
240/// runnable side, so a broken external cannot carry a severity nobody applies.
241pub enum Kind {
242    Runnable {
243        scope: Scope,
244        severity: Severity,
245        program: String,
246        args: Vec<String>,
247        fix: Fix,
248    },
249    Unusable {
250        why: String,
251    },
252}
253
254impl Check for External {
255    fn name(&self) -> &str {
256        &self.id
257    }
258    fn stage(&self) -> Stage {
259        self.stage
260    }
261    /// Derived for an unusable check rather than stored: it never runs, so its
262    /// scope is a question with no answer, and computing one here keeps the
263    /// DATA from carrying a value that means nothing.
264    fn scope(&self) -> Scope {
265        match &self.kind {
266            Kind::Runnable { scope, .. } => *scope,
267            Kind::Unusable { .. } => Scope::ALWAYS,
268        }
269    }
270    fn severity(&self) -> Severity {
271        match &self.kind {
272            Kind::Runnable { severity, .. } => *severity,
273            // Never consulted: an unusable check reports `Unavailable`, which
274            // no severity can turn into a block.
275            Kind::Unusable { .. } => Severity::Warn,
276        }
277    }
278
279    fn run(&self, ctx: &Ctx) -> Outcome {
280        let (scope, program, args, fix) = match &self.kind {
281            Kind::Runnable {
282                scope,
283                program,
284                args,
285                fix,
286                ..
287            } => (scope, program, args, *fix),
288            Kind::Unusable { why } => {
289                crate::hooks::common::warn(&format!(
290                    "{MANIFEST}: {} — {}",
291                    crate::ui::highlight(&self.short_name),
292                    // Carries repo tokens: `BadStage("…")` quotes the manifest.
293                    crate::ui::sanitize(why)
294                ));
295                return Outcome::Unavailable;
296            }
297        };
298
299        // The scope gate lives HERE, unlike a built-in's, which enforces its own
300        // in its first three lines. A declared command has no way to know what
301        // was staged, so if this did not gate it, `*.sh` would run on every
302        // commit and the column would be decoration.
303        //
304        // Which files to test against depends on the stage: what is staged for
305        // a commit, what is being pushed for a push. `*` short-circuits before
306        // either is computed, which is the common case.
307        // A check whose whole job is to rewrite has nothing to say when nobody
308        // asked for rewriting — so it does not RUN, rather than running and
309        // having its result discarded. Gating only the re-staging let the
310        // command edit files with `amont.fix` off, which is precisely the
311        // surprise the gate exists to prevent.
312        //
313        // `Unavailable`, not `Passed`. `check.rs` defines `Unavailable` as
314        // "COULD NOT RUN — a tool is missing, or the opt-in config is absent",
315        // which is exactly this; `Passed` is the one verdict it must not
316        // report, because the dispatcher's roll-up and the fleet dashboard
317        // then show a check that never executed as clean. With a message,
318        // because every other `Unavailable` in this codebase says what was
319        // missing and an unexplained count on every commit is worse than none.
320        if fix == Fix::Rewrite && !crate::hooks::common::fixing_enabled() {
321            crate::hooks::common::warn(&format!(
322                "{}: declares fix, and {} is off — not run",
323                crate::ui::highlight(&self.short_name),
324                crate::ui::highlight("amont.fix")
325            ));
326            return Outcome::Unavailable;
327        }
328
329        let in_scope = match self.stage {
330            Stage::PreCommit => crate::hooks::common::staged_files(&[]),
331            Stage::PrePush => crate::pushrefs::changed_files(ctx.push.get()),
332        };
333        if !scope.files.is_empty() && !scope.matches(&in_scope) {
334            return Outcome::Passed;
335        }
336        let root = crate::hooks::common::repo_root();
337        let mut cmd = Command::new(program);
338        cmd.args(args).current_dir(&root).stdin(Stdio::null());
339        crate::hooks::common::strip_git_env(&mut cmd);
340        match cmd.status() {
341            // A command that could not be STARTED has not judged anything. This
342            // is the distinction `Outcome` was added for: reporting a missing
343            // `shellcheck` as a lint failure sends someone hunting for a lint
344            // error that does not exist.
345            Err(e) => {
346                crate::hooks::common::warn(&format!(
347                    "{MANIFEST}: {} could not run {} — {}",
348                    crate::ui::highlight(&self.short_name),
349                    crate::ui::highlight(program),
350                    // The io error's text embeds the program name it tried.
351                    crate::ui::sanitize(&e.to_string())
352                ));
353                Outcome::Unavailable
354            }
355            Ok(s) if s.success() => {
356                // A declared fixer that ran clean may still have rewritten
357                // something; re-stage exactly what moved. Only its own scope,
358                // so it cannot stage a file it never looked at.
359                if fix == Fix::Rewrite && crate::hooks::common::fixing_enabled() {
360                    match crate::hooks::common::restage(&scoped(scope, &in_scope)) {
361                        Restaged::Staged => {
362                            crate::hooks::common::ok(&format!(
363                                "{} fixed and re-staged",
364                                crate::ui::highlight(&self.short_name)
365                            ));
366                            return Outcome::Fixed;
367                        }
368                        // `git add` failed, so the index still holds whatever
369                        // the command has already replaced on disk. This used
370                        // to be indistinguishable from "nothing moved" and was
371                        // reported as a pass.
372                        Restaged::Failed(stuck) => {
373                            crate::hooks::common::fail(&format!(
374                                "{} rewrote files but {} failed — the index still holds the \
375                                 OLD content: {}",
376                                crate::ui::highlight(&self.short_name),
377                                crate::ui::highlight("git add"),
378                                crate::ui::sanitize(&stuck.join(", "))
379                            ));
380                            return Outcome::Failed;
381                        }
382                        Restaged::Nothing => {}
383                    }
384                }
385                Outcome::Passed
386            }
387            Ok(_) => {
388                crate::hooks::common::fail(&format!(
389                    "{} failed (output above)",
390                    crate::ui::highlight(&self.short_name)
391                ));
392                Outcome::Failed
393            }
394        }
395    }
396}
397
398/// The paths this check's scope actually covers.
399fn scoped(scope: &Scope, paths: &[String]) -> Vec<String> {
400    if scope.files.is_empty() {
401        return paths.to_vec();
402    }
403    paths
404        .iter()
405        .filter(|p| scope.files.iter().any(|e| p.ends_with(e)))
406        .cloned()
407        .collect()
408}
409
410/// `Scope` holds `&'static` slices so a built-in can be a `const`. A parsed
411/// manifest has neither, so its extension list is leaked.
412///
413/// This is bounded and deliberate: the manifest is read at most once per
414/// process, holds a handful of short strings, and the process is a git hook that
415/// exits in milliseconds. The alternative — a lifetime on `Scope` — would
416/// propagate through the trait, both dispatchers and the fleet crate to buy back
417/// a few hundred bytes that the kernel reclaims moments later.
418fn leak(exts: Vec<String>) -> &'static [&'static str] {
419    let refs: Vec<&'static str> = exts
420        .into_iter()
421        .map(|s| &*Box::leak(s.into_boxed_str()))
422        .collect();
423    Box::leak(refs.into_boxed_slice())
424}
425
426/// `*` means any change; `*.sh` or `*.sh,*.bash` gate on extensions.
427///
428/// No `opt_in` counterpart, because the manifest IS the opt-in: a repository
429/// that does not want the check deletes the line.
430///
431/// Returns owned extensions rather than a `Scope`, so validating a manifest
432/// costs nothing permanent. Only `External::from` turns these into the
433/// `&'static` form `Scope` requires.
434fn parse_scope(token: &str) -> Result<Vec<String>, ParseError> {
435    if token == "*" {
436        return Ok(Vec::new());
437    }
438    let mut exts = Vec::new();
439    for part in token.split(',') {
440        let ext = part
441            .strip_prefix('*')
442            .filter(|ext| ext.starts_with('.'))
443            .ok_or_else(|| ParseError::BadScope(part.to_string()))?;
444        exts.push(ext.to_string());
445    }
446    Ok(exts)
447}
448
449fn parse_stage(token: &str) -> Option<Stage> {
450    match token {
451        "pre-commit" => Some(Stage::PreCommit),
452        "pre-push" => Some(Stage::PrePush),
453        _ => None,
454    }
455}
456
457/// An identity already spoken for. An external must not be able to shadow
458/// `pre-push-branch-protect` — nor silently lose to it, which is what a
459/// first-match lookup would do without this.
460///
461/// Judged on the ID, which is why `clippy` on `pre-push` is now legal: it is
462/// `pre-push-clippy`, a different check from `pre-commit-clippy`. Judged on the
463/// bare name, as it was, the two collided and the second was refused.
464///
465fn name_is_taken(id: &str) -> bool {
466    CHECKS.iter().any(|c| c.name == id) || ENTRYPOINTS.iter().any(|(n, _)| *n == id)
467}
468
469/// A short name that says its own trigger — either by being one, or by starting
470/// with one.
471///
472/// Both make an id ambiguous rather than merely ugly. `pre-commit` as a name
473/// gives `hook.skip pre-commit` two readings; `pre-commit-clippy` as a name
474/// gives a check whose SHORT name is the built-in's FULL id, so one skip
475/// silences both. The stage column already says which trigger this is.
476fn name_says_its_trigger(name: &str) -> bool {
477    crate::TRIGGERS
478        .iter()
479        .any(|t| name == *t || name.starts_with(&format!("{t}-")))
480}
481
482/// The four leading tokens and the untouched remainder, or `None` when the line
483/// does not have them.
484///
485/// The arity is in the TYPE. Returning a `Vec` made "four or it is malformed"
486/// a rule every caller had to remember and none could be checked against.
487///
488/// NOT `splitn(5, char::is_whitespace)`: that splits at the FIRST whitespace
489/// character every time, so a file aligned into columns — which is how the
490/// format invites you to write it — yields empty fields for every run of
491/// spaces after the first.
492fn tokenise(line: &str) -> Option<([&str; 4], &str)> {
493    let mut fields: [&str; 4] = [""; 4];
494    let mut rest = line;
495    for slot in fields.iter_mut() {
496        rest = rest.trim_start();
497        let i = rest.find(char::is_whitespace)?;
498        *slot = &rest[..i];
499        rest = &rest[i..];
500    }
501    let command = rest.trim();
502    (!command.is_empty()).then_some((fields, command))
503}
504
505pub fn parse_lines(text: &str) -> Vec<Line> {
506    let mut out: Vec<Line> = Vec::new();
507    for (i, raw) in text.lines().enumerate() {
508        let line = raw.trim();
509        if line.is_empty() || line.starts_with('#') {
510            continue;
511        }
512        let lineno = i + 1;
513        out.push(parse_line(lineno, line, &out));
514    }
515    out
516}
517
518/// One line, given the lines already accepted.
519///
520/// `earlier` is read ONLY for the duplicate check, and only its `Usable`
521/// entries — a line that cannot run does not reserve its name. Before this, a
522/// valid declaration was rejected as "declared twice" for colliding with a
523/// broken one, which pointed the reader at the wrong line entirely.
524fn parse_line(lineno: usize, line: &str, earlier: &[Line]) -> Line {
525    let (fields, command) = match tokenise(line) {
526        Some(t) => t,
527        // No name to report: fall back to the position, which is the only
528        // handle a reader has on a line this malformed.
529        None => {
530            return broken_at(
531                lineno,
532                name_or_position(tokenise(line).map(|(f, _)| f[1]).unwrap_or(""), lineno),
533                None,
534                ParseError::MissingFields,
535            )
536        }
537    };
538    let [stage_tok, declared, scope_tok, severity_tok] = fields;
539    let stage = parse_stage(stage_tok);
540    let name = name_or_position(declared, lineno);
541    let fail = |why| broken_at(lineno, name.clone(), stage, why);
542
543    if declared.is_empty() {
544        return fail(ParseError::MissingName);
545    }
546    // The stage is settled BEFORE the name is judged, because the identity
547    // being judged is `<trigger>-<name>` and there is no such thing without a
548    // trigger. Ordered the other way, a line with an unusable stage was refused
549    // for a name clash that could not be assessed yet.
550    let Some(stage) = stage else {
551        return fail(ParseError::BadStage(stage_tok.to_string()));
552    };
553    if name_says_its_trigger(declared) {
554        return fail(ParseError::TriggerInName(declared.to_string()));
555    }
556    let id = format!("{}-{}", stage.as_str(), declared);
557    if name_is_taken(&id) {
558        return fail(ParseError::NameTaken(declared.to_string()));
559    }
560    if earlier
561        .iter()
562        .any(|l| matches!(l, Line::Usable(d) if d.id() == id))
563    {
564        return fail(ParseError::Duplicate(declared.to_string()));
565    }
566    let exts = match parse_scope(scope_tok) {
567        Ok(e) => e,
568        Err(why) => return fail(why),
569    };
570    let Some(severity) = Severity::parse(severity_tok) else {
571        return fail(ParseError::BadSeverity(severity_tok.to_string()));
572    };
573    // `fix` is a trailing marker on the command column rather than a sixth
574    // field, so every manifest written before this still parses.
575    let (command, wants_fix) = match command.strip_prefix("fix ") {
576        Some(rest) => (rest.trim(), true),
577        None => (command, false),
578    };
579    if wants_fix && stage == Stage::PrePush {
580        return fail(ParseError::FixOnPrePush);
581    }
582    // `tokenise` guarantees a non-empty command, so the split cannot fail.
583    let mut argv = command.split_whitespace().map(str::to_owned);
584    let Some(program) = argv.next() else {
585        return fail(ParseError::MissingFields);
586    };
587    Line::Usable(Declared {
588        fix: if wants_fix { Fix::Rewrite } else { Fix::None },
589        name: declared.to_string(),
590        stage,
591        severity,
592        exts,
593        program,
594        args: argv.collect(),
595    })
596}
597
598fn name_or_position(declared: &str, lineno: usize) -> String {
599    if declared.is_empty() {
600        format!("{MANIFEST}:{lineno}")
601    } else {
602        declared.to_string()
603    }
604}
605
606fn broken_at(lineno: usize, name: String, stage: Option<Stage>, why: ParseError) -> Line {
607    Line::Broken {
608        name,
609        stage: stage.unwrap_or(Stage::PreCommit),
610        lineno,
611        why,
612    }
613}
614
615impl From<Line> for External {
616    fn from(l: Line) -> External {
617        let (name, stage, parsed) = l.into_parts();
618        let kind = match parsed {
619            Ok(d) => Kind::Runnable {
620                scope: if d.exts.is_empty() {
621                    Scope::ALWAYS
622                } else {
623                    Scope::files(leak(d.exts))
624                },
625                severity: d.severity,
626                program: d.program,
627                args: d.args,
628                fix: d.fix,
629            },
630            Err(why) => Kind::Unusable { why },
631        };
632        let id = format!("{}-{}", stage.as_str(), name);
633        External {
634            id,
635            short_name: name,
636            stage,
637            kind,
638        }
639    }
640}
641
642pub fn parse(text: &str) -> Vec<External> {
643    parse_lines(text).into_iter().map(External::from).collect()
644}
645
646/// The manifest for `root`, or an empty list. Read once per process.
647pub fn read(root: &Path) -> Vec<External> {
648    std::fs::read_to_string(root.join(MANIFEST))
649        .map(|t| parse(&t))
650        .unwrap_or_default()
651}
652
653/// The same file, without building the `Scope`s — for a reader that inspects
654/// many repositories and must not leak once per manifest per refresh.
655pub fn read_lines(root: &Path) -> Vec<Line> {
656    std::fs::read_to_string(root.join(MANIFEST))
657        .map(|t| parse_lines(&t))
658        .unwrap_or_default()
659}
660
661/// Every external declared by the repository this process is running in.
662///
663/// A `static OnceLock` rather than a leak: the borrow is genuinely `'static`
664/// because the storage is, and it also guarantees the file is read once however
665/// many checks ask for it.
666///
667/// `pub(crate)`, NOT `pub`. The answer depends on the working directory at the
668/// FIRST call and is then cached for the life of the process — safe in a hook,
669/// which handles one repository and exits, and a trap for anything that walks
670/// many. The fleet crate reads `read_lines(path)` instead, which takes the
671/// repository it means.
672pub(crate) fn externals() -> &'static [External] {
673    static EXTERNALS: OnceLock<Vec<External>> = OnceLock::new();
674    EXTERNALS.get_or_init(|| {
675        let root = crate::hooks::common::repo_root();
676        let root = Path::new(&root);
677        // ONE read. The bytes that get PARSED and the bytes that get HASHED
678        // have to be the same bytes: this used to `read(root)` and then let
679        // `trust::state` open the file a second time, so anything that changed
680        // it in between — a `git checkout`, a watcher, a `make` target already
681        // running — produced a trust decision about content that is not the
682        // content about to be executed. `record_verified` closes this at trust
683        // time and has a test named for it; the run path had the same gap.
684        let Ok(bytes) = std::fs::read(root.join(MANIFEST)) else {
685            return Vec::new();
686        };
687        // Non-UTF-8 yields no externals, as it always has: `parse` takes a
688        // `&str`, and a manifest we cannot read as text is one we cannot act
689        // on. Not lossy — that would invent a manifest nobody wrote.
690        let Ok(text) = String::from_utf8(bytes.clone()) else {
691            return Vec::new();
692        };
693        gate(parse(&text), crate::trust::state_of(root, &bytes))
694    })
695}
696
697/// Apply a trust verdict to what the manifest declared.
698///
699/// Untrusted declarations are kept and DISABLED, not dropped. The names stay
700/// visible in `amont list`, in the dashboard and in the "could not run"
701/// roll-up, because a repository quietly declaring checks that never run is the
702/// failure this project is arranged against — and the reader needs to know
703/// there is a decision waiting for them.
704///
705/// Split out from `externals` because that function is a `OnceLock` keyed on
706/// the process's own repository and so cannot be tested; this is the part with
707/// the rule in it.
708pub(crate) fn gate(declared: Vec<External>, state: crate::trust::State) -> Vec<External> {
709    match crate::trust::why(state) {
710        None => declared,
711        Some(reason) => declared
712            .into_iter()
713            .map(|external| External {
714                kind: Kind::Unusable {
715                    why: reason.to_string(),
716                },
717                ..external
718            })
719            .collect(),
720    }
721}
722
723#[cfg(test)]
724mod tests {
725    use super::*;
726
727    fn one(text: &str) -> Line {
728        let mut v = parse_lines(text);
729        assert_eq!(v.len(), 1, "expected one entry from {text:?}");
730        v.pop().expect("one")
731    }
732
733    /// The error a line produced, as a VALUE. Tests used to match on the prose,
734    /// which coupled them to wording and would have kept passing if the wording
735    /// stayed while the meaning changed.
736    fn why(l: &Line) -> ParseError {
737        match l {
738            Line::Broken { why, .. } => why.clone(),
739            Line::Usable(d) => panic!("{} parsed when it should not have", d.name),
740        }
741    }
742
743    fn usable(l: &Line) -> &Declared {
744        match l {
745            Line::Usable(d) => d,
746            Line::Broken { name, why, .. } => panic!("{name} failed to parse: {why}"),
747        }
748    }
749
750    #[test]
751    fn parses_the_documented_example() {
752        let v = parse_lines(
753            "# stage       name        scope   severity  command\n\
754             pre-commit    shellcheck  *.sh    block     scripts/lint-shell.sh\n\
755             pre-push      smoke       *       warn      make smoke\n",
756        );
757        assert_eq!(v.len(), 2);
758
759        let a = usable(&v[0]);
760        assert_eq!(a.name, "shellcheck");
761        assert_eq!(a.stage, Stage::PreCommit);
762        assert_eq!(a.severity, Severity::Block);
763        assert_eq!(a.program, "scripts/lint-shell.sh");
764        assert!(a.args.is_empty());
765        assert_eq!(a.exts, [".sh"]);
766
767        let b = usable(&v[1]);
768        assert_eq!(b.stage, Stage::PrePush);
769        assert_eq!(b.severity, Severity::Warn);
770        // A command with arguments is split, not handed to a shell.
771        assert_eq!(b.program, "make");
772        assert_eq!(b.args, ["smoke"]);
773        assert!(b.exts.is_empty(), "`*` gates on nothing");
774    }
775
776    /// Blank lines and comments are not entries, and must not become broken
777    /// ones — a file that is mostly documentation would otherwise report a
778    /// dozen gaps.
779    #[test]
780    fn comments_and_blank_lines_produce_nothing() {
781        assert!(parse_lines("\n  \n# just a comment\n\t# indented\n").is_empty());
782    }
783
784    /// The rule the module commits to: a line that cannot be understood still
785    /// yields a check, so its absence is visible. Matched by VARIANT.
786    #[test]
787    fn a_malformed_line_becomes_a_visible_gap() {
788        let cases: [(&str, ParseError); 4] = [
789            (
790                "pre-commit shellcheck *.sh block\n",
791                ParseError::MissingFields,
792            ),
793            (
794                "nonsense shellcheck *.sh block x\n",
795                ParseError::BadStage("nonsense".into()),
796            ),
797            (
798                "pre-commit shellcheck ?.sh block x\n",
799                ParseError::BadScope("?.sh".into()),
800            ),
801            (
802                "pre-commit shellcheck *.sh loud x\n",
803                ParseError::BadSeverity("loud".into()),
804            ),
805        ];
806        for (text, expected) in cases {
807            assert_eq!(why(&one(text)), expected, "for {text:?}");
808        }
809    }
810
811    /// The prose still has to locate the line, even though the tests no longer
812    /// depend on its wording.
813    #[test]
814    fn a_gap_reports_where_it_is() {
815        let l = one("pre-commit shellcheck *.sh loud x\n");
816        let said = l.broken().expect("broken");
817        assert!(said.contains("line 1"), "{said}");
818        assert!(said.contains("severity"), "{said}");
819    }
820
821    /// `fix` on a pre-push line is refused where every other bad declaration is
822    /// refused — on every commit, named and located — rather than as a runtime
823    /// "contract violation" discovered later at push time by fewer people.
824    #[test]
825    fn fix_is_refused_on_a_pre_push_line() {
826        assert_eq!(
827            why(&one("pre-push  smoke  *  block  fix make smoke\n")),
828            ParseError::FixOnPrePush
829        );
830        // …and accepted on pre-commit.
831        let line = one("pre-commit  fmt  *  block  fix make format\n");
832        let declared = usable(&line);
833        assert_eq!(declared.fix, Fix::Rewrite);
834        assert_eq!(declared.program, "make");
835        assert_eq!(declared.args, ["format"]);
836    }
837
838    /// Every manifest written before `fix` existed must still parse the same.
839    #[test]
840    fn a_command_that_merely_starts_with_fix_is_not_a_marker() {
841        let line = one("pre-commit  x  *  block  fixup-tool --check\n");
842        let declared = usable(&line);
843        assert_eq!(declared.fix, Fix::None);
844        assert_eq!(declared.program, "fixup-tool");
845    }
846
847    /// A gap with no name cannot be reported, and a line this broken has none.
848    #[test]
849    fn a_nameless_line_is_named_after_its_position() {
850        let l = one("pre-commit\n");
851        assert_eq!(l.name(), "amont.conf:1");
852        assert_eq!(why(&l), ParseError::MissingFields);
853    }
854
855    /// An external must not be able to take a built-in's id — it would either
856    /// shadow `pre-push-branch-protect` or silently lose to it, and neither is
857    /// something a repository should be able to do by editing a text file.
858    ///
859    /// Judged on the id, so the same declaration is refused on one trigger and
860    /// accepted on the other. That is not a loophole: `pre-push-clippy` is a
861    /// different check from `pre-commit-clippy`, and nothing is shadowed.
862    #[test]
863    fn a_built_in_id_is_refused() {
864        assert_eq!(
865            why(&one("pre-commit clippy *.rs block x\n")),
866            ParseError::NameTaken("clippy".into())
867        );
868        assert!(matches!(
869            one("pre-push clippy *.rs block x\n"),
870            Line::Usable(_)
871        ));
872        // And a pre-push built-in is protected on pre-push, not on pre-commit,
873        // for the same reason.
874        assert_eq!(
875            why(&one("pre-push branch-protect * block x\n")),
876            ParseError::NameTaken("branch-protect".into())
877        );
878        assert!(matches!(
879            one("pre-commit branch-protect * block x\n"),
880            Line::Usable(_)
881        ));
882    }
883
884    /// The stage column says which trigger a line is for. Saying it again in
885    /// the name is the one way to make an id ambiguous: `pre-commit-clippy` as
886    /// a NAME is a check whose short name is the built-in's full id, so one
887    /// `hook.skip` would silence both.
888    #[test]
889    fn a_name_that_says_its_own_trigger_is_refused() {
890        for name in ["pre-commit", "pre-push", "pre-commit-clippy", "pre-push-x"] {
891            assert_eq!(
892                why(&one(&format!("pre-commit {name} * block x\n"))),
893                ParseError::TriggerInName(name.into()),
894                "{name}"
895            );
896        }
897        // A name that merely begins with the same letters is fine — the trigger
898        // has to be followed by the separator to count.
899        assert!(matches!(
900            one("pre-commit pre-commitish * block x\n"),
901            Line::Usable(_)
902        ));
903    }
904
905    /// Two USABLE lines with one ID: the second cannot be addressed by
906    /// `hook.skip` or by a severity override, so it is refused.
907    #[test]
908    fn a_duplicate_id_is_refused() {
909        let v = parse_lines(
910            "pre-commit smoke * block a\n\
911             pre-commit smoke * block b\n",
912        );
913        assert_eq!(v.len(), 2);
914        assert_eq!(usable(&v[0]).id(), "pre-commit-smoke");
915        assert_eq!(why(&v[1]), ParseError::Duplicate("smoke".into()));
916    }
917
918    /// The same name on both triggers is TWO checks, and this used to refuse
919    /// the second. Somebody wanting a `show-unicorn` on commit and on push had
920    /// no way to write it, and no way to skip or downgrade one without the
921    /// other — the bare name could not tell them apart.
922    #[test]
923    fn the_same_name_on_two_triggers_is_allowed() {
924        let v = parse_lines(
925            "pre-commit show-unicorn * block a\n\
926             pre-push   show-unicorn * block b\n",
927        );
928        assert_eq!(v.len(), 2);
929        assert_eq!(usable(&v[0]).id(), "pre-commit-show-unicorn");
930        assert_eq!(usable(&v[1]).id(), "pre-push-show-unicorn");
931
932        // And each is separately addressable, while the short name takes both —
933        // which is the whole vocabulary, applied to declared checks.
934        for (id, only) in [
935            ("pre-commit-show-unicorn", "pre-push-show-unicorn"),
936            ("pre-push-show-unicorn", "pre-commit-show-unicorn"),
937        ] {
938            assert!(crate::skip_suppresses(id, id));
939            assert!(!crate::skip_suppresses(only, id));
940        }
941        assert!(crate::skip_suppresses(
942            "pre-commit-show-unicorn",
943            "show-unicorn"
944        ));
945        assert!(crate::skip_suppresses(
946            "pre-push-show-unicorn",
947            "show-unicorn"
948        ));
949        assert!(crate::skip_suppresses(
950            "pre-commit-show-unicorn",
951            "pre-commit"
952        ));
953        assert!(!crate::skip_suppresses(
954            "pre-push-show-unicorn",
955            "pre-commit"
956        ));
957    }
958
959    /// A line that cannot run does not RESERVE its name.
960    ///
961    /// It used to: broken and usable entries shared one list, so a valid
962    /// declaration was rejected as "declared twice" for colliding with a line
963    /// that could never execute — pointing the reader at the wrong line, and
964    /// forcing them to fix the first before the second would work at all.
965    #[test]
966    fn a_broken_line_does_not_reserve_its_name() {
967        let v = parse_lines(
968            "pre-commit smoke * LOUD   make a\n\
969             pre-commit smoke * block  make b\n",
970        );
971        assert_eq!(v.len(), 2);
972        assert_eq!(why(&v[0]), ParseError::BadSeverity("LOUD".into()));
973        let good = usable(&v[1]);
974        assert_eq!(good.name, "smoke");
975        assert_eq!(good.program, "make");
976    }
977
978    /// Alignment is cosmetic. A file someone has lined up with tabs, or not
979    /// lined up at all, must parse identically.
980    #[test]
981    fn field_alignment_does_not_matter() {
982        let spaced = one("pre-commit      shellcheck    *.sh      block     make lint\n");
983        let tabbed = one("pre-commit\tshellcheck\t*.sh\tblock\tmake lint\n");
984        assert_eq!(usable(&spaced), usable(&tabbed));
985        assert_eq!(usable(&spaced).args, ["lint"]);
986    }
987
988    #[test]
989    fn several_extensions_can_gate_one_check() {
990        let e = External::from(one("pre-commit shell *.sh,*.bash block make lint\n"));
991        assert!(e.scope().matches(&["a.bash".into()]));
992        assert!(e.scope().matches(&["a.sh".into()]));
993        assert!(!e.scope().matches(&["a.zsh".into()]));
994    }
995
996    /// `tokenise` states its arity in the type, so "four tokens then a command"
997    /// is checked rather than remembered.
998    #[test]
999    fn tokenise_wants_four_fields_and_a_command() {
1000        assert!(tokenise("a b c").is_none(), "too few fields");
1001        assert!(tokenise("a b c d").is_none(), "four fields, no command");
1002        // Trailing whitespace reaches the four fields but still leaves nothing
1003        // to run — the case the `?` on the last field cannot catch.
1004        assert!(
1005            tokenise("a b c d   ").is_none(),
1006            "command is all whitespace"
1007        );
1008        assert!(tokenise("a b c d\t").is_none(), "command is a tab");
1009        let (fields, cmd) = tokenise("a  b\tc   d   run it").expect("four and a command");
1010        assert_eq!(fields, ["a", "b", "c", "d"]);
1011        assert_eq!(cmd, "run it");
1012    }
1013
1014    /// An unusable line carries no command at all — the type has nowhere to put
1015    /// one, which is the point of the split.
1016    #[test]
1017    fn an_unusable_external_holds_no_command() {
1018        let e = External::from(one("pre-commit shellcheck *.sh loud echo hi\n"));
1019        assert!(matches!(e.kind, Kind::Unusable { .. }));
1020        // And it can never block, whatever severity anyone configures.
1021        assert_eq!(e.severity(), Severity::Warn);
1022    }
1023
1024    /// A missing manifest is the normal case and must not be an error.
1025    #[test]
1026    fn a_repository_with_no_manifest_declares_nothing() {
1027        assert!(read(Path::new("/nonexistent-c8f2")).is_empty());
1028        assert!(read_lines(Path::new("/nonexistent-c8f2")).is_empty());
1029    }
1030
1031    /// `Line` exists to spare the dashboard a leak, not to become a second
1032    /// opinion about what a manifest says.
1033    #[test]
1034    fn the_leaking_and_non_leaking_parsers_agree() {
1035        let text = "pre-commit  shellcheck  *.sh,*.bash  block  make lint\n\
1036                    pre-push    smoke       *            warn   make smoke\n\
1037                    pre-commit  broken      ?            block  x\n";
1038        let lines = parse_lines(text);
1039        let externals = parse(text);
1040        assert_eq!(lines.len(), externals.len());
1041        for (l, e) in lines.iter().zip(&externals) {
1042            assert_eq!(l.id(), e.name(), "the id is what a check answers to");
1043            assert_eq!(
1044                l.name(),
1045                e.short_name,
1046                "and the short name is what it is called"
1047            );
1048            assert_eq!(l.stage(), e.stage());
1049            assert_eq!(
1050                l.broken().is_some(),
1051                matches!(e.kind, Kind::Unusable { .. })
1052            );
1053            if let Line::Usable(d) = l {
1054                assert_eq!(d.severity, e.severity());
1055                // The scope the dashboard would DESCRIBE is the scope the
1056                // dispatcher would ENFORCE.
1057                assert_eq!(d.exts, e.scope().files);
1058            }
1059        }
1060    }
1061}