Skip to main content

amont_runtime/
check.rs

1//! What a check IS, as one value rather than four tables.
2//!
3//! Before this, a check was spread across `REGISTRY` (name → fn), two ordered
4//! name lists, and a language table in the fleet crate — four places keyed by
5//! the same string, held together by reconciliation tests. Those tests were
6//! good, but they policed a shape that should not have been splittable. With
7//! the metadata attached to the check, there is nothing left to reconcile.
8//!
9//! It also gives external checks somewhere to exist. A third party cannot add a
10//! Rust module without rebuilding the binary, so extension means a declared
11//! command implementing this same trait — and the dispatcher not caring which
12//! kind it is holding.
13
14use crate::registry::Ctx;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum Stage {
18    PreCommit,
19    PrePush,
20}
21
22impl Stage {
23    pub fn as_str(self) -> &'static str {
24        match self {
25            Stage::PreCommit => "pre-commit",
26            Stage::PrePush => "pre-push",
27        }
28    }
29}
30
31/// When a check is relevant, declared rather than reimplemented by every
32/// reader.
33///
34/// A CONJUNCTION, not a choice: ruff is `.py` files AND a ruff config; clippy
35/// is `.rs` AND `Cargo.toml`. An earlier design offered these as alternatives
36/// plus a `Custom` escape hatch, which would have swallowed nearly every check
37/// and left the dashboard knowing nothing.
38/// A git operation that is part-way through.
39///
40/// Detected from the marker files git writes into `$GIT_DIR`, which is how git
41/// itself and every prompt-writer answers the question.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum GitState {
44    Merge,
45    Rebase,
46    CherryPick,
47    Revert,
48    Bisect,
49}
50
51impl GitState {
52    /// The marker git writes. `rebase-merge` and `rebase-apply` are
53    /// DIRECTORIES; the rest are files, and `Path::exists` covers both.
54    pub fn markers(self) -> &'static [&'static str] {
55        match self {
56            GitState::Merge => &["MERGE_HEAD"],
57            GitState::Rebase => &["REBASE_HEAD", "rebase-merge", "rebase-apply"],
58            GitState::CherryPick => &["CHERRY_PICK_HEAD"],
59            GitState::Revert => &["REVERT_HEAD"],
60            GitState::Bisect => &["BISECT_LOG"],
61        }
62    }
63
64    pub fn as_str(self) -> &'static str {
65        match self {
66            GitState::Merge => "a merge",
67            GitState::Rebase => "a rebase",
68            GitState::CherryPick => "a cherry-pick",
69            GitState::Revert => "a revert",
70            GitState::Bisect => "a bisect",
71        }
72    }
73
74    pub const ALL: [GitState; 5] = [
75        GitState::Merge,
76        GitState::Rebase,
77        GitState::CherryPick,
78        GitState::Revert,
79        GitState::Bisect,
80    ];
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub struct Scope {
85    /// Extensions that trigger it. Empty means any change.
86    pub files: &'static [&'static str],
87    /// Config paths that opt a repository in. Empty means always on.
88    pub opt_in: &'static [&'static str],
89    /// Git operations during which this check does not run.
90    ///
91    /// The other half of "when does this apply". `files` and `opt_in` say which
92    /// REPOSITORIES and which CHANGES; this says which repository STATES — a
93    /// question that used to be answered by one hard-coded `CHERRY_PICK_HEAD`
94    /// test in one dispatcher, with the other carrying a comment admitting it
95    /// had none because the shell version had none.
96    pub not_during: &'static [GitState],
97}
98
99impl Scope {
100    pub const ALWAYS: Scope = Scope {
101        files: &[],
102        opt_in: &[],
103        not_during: &[],
104    };
105
106    pub const fn files(files: &'static [&'static str]) -> Scope {
107        Scope {
108            files,
109            opt_in: &[],
110            not_during: &[],
111        }
112    }
113
114    pub const fn new(files: &'static [&'static str], opt_in: &'static [&'static str]) -> Scope {
115        Scope {
116            files,
117            opt_in,
118            not_during: &[],
119        }
120    }
121
122    /// The same scope, silent during these operations.
123    pub const fn not_during(self, states: &'static [GitState]) -> Scope {
124        Scope {
125            files: self.files,
126            opt_in: self.opt_in,
127            not_during: states,
128        }
129    }
130
131    /// Would this check ever fire, given the paths a repository contains?
132    ///
133    /// Deliberately coarse for checks that resolve an ancestor at run time —
134    /// `cargo-fmt` declares `Cargo.toml` meaning "somewhere here" while
135    /// enforcing "nearest above the staged file". The dispatcher asks the
136    /// precise question by running the check; this answers the dashboard's
137    /// question, "would it ever fire", where over-approximating is the safe
138    /// direction.
139    pub fn matches(&self, paths: &[String]) -> bool {
140        let by_ext = self.files.is_empty()
141            || paths
142                .iter()
143                .any(|path| self.files.iter().any(|ext| path.ends_with(ext)));
144        let opted_in = self.opt_in.is_empty()
145            || paths.iter().any(|p| {
146                let name = p.rsplit('/').next().unwrap_or(p);
147                self.opt_in.iter().any(|c| {
148                    // A trailing `*` is a prefix match: `.kube-linter*.yaml`.
149                    match c.split_once('*') {
150                        Some((pre, suf)) => name.starts_with(pre) && name.ends_with(suf),
151                        None => name == *c,
152                    }
153                })
154            });
155        by_ext && opted_in
156    }
157}
158
159/// What a check meant, as opposed to what it printed.
160///
161/// The fourth variant is the point. Fifteen sites used to warn and return 0,
162/// collapsing two different situations: "I ran and found something you should
163/// know" and "I could not run at all". `ruff config found but no ruff binary`
164/// was indistinguishable from ruff running clean — to the dispatcher, and to
165/// the dashboard. A repository where a check has silently never executed read
166/// as one where it passes.
167///
168/// That is the same invisibility `hook.skip` had before skipped checks were
169/// announced, and it took three PRs to notice there.
170///
171/// The check still prints its own message; this only classifies the result.
172///
173/// Deliberately NO `Default`. It used to be `Failed`, to fill the slot of a
174/// check whose thread died — a real rule, but `Default` means "the neutral
175/// value" to every reader and to every `#[derive(Default)]` that might later
176/// contain one. The rule is now written where it applies, in the runner.
177#[derive(Debug, Clone, Copy, PartialEq, Eq)]
178pub enum Outcome {
179    Passed,
180    /// Ran, found a problem. Whether that blocks is `Severity`, not this.
181    Failed,
182    /// Ran, found something worth saying, which does not block.
183    Warned,
184    /// Ran, found a problem, and REPAIRED it. The commit proceeds with the
185    /// repair staged, which is neither `Passed` (something happened, and the
186    /// author should know their files changed) nor `Failed`.
187    Fixed,
188    /// COULD NOT RUN — a tool is missing, or the opt-in config is absent.
189    Unavailable,
190}
191
192/// What a HOOK concluded — the only thing git actually reads.
193///
194/// Distinct from `Outcome`, which is what one CHECK concluded. Git has exactly
195/// two questions to ask a hook, so this has exactly two answers, and the `i32`
196/// that expresses them lives at the process boundary rather than being threaded
197/// through every hook, dispatcher and handler as it used to be.
198#[derive(Debug, Clone, Copy, PartialEq, Eq)]
199pub enum Verdict {
200    Proceed,
201    Block,
202}
203
204impl Verdict {
205    /// The exit code git reads. The ONLY place a hook result becomes a number.
206    pub fn exit_code(self) -> i32 {
207        match self {
208            Verdict::Proceed => 0,
209            Verdict::Block => 1,
210        }
211    }
212
213    pub fn blocking(blocked: bool) -> Verdict {
214        if blocked {
215            Verdict::Block
216        } else {
217            Verdict::Proceed
218        }
219    }
220}
221
222/// Whether a failing check stops the commit or merely reports.
223///
224/// Declared per check and overridable per repository with
225/// `git config amont.severity.<check> warn`. That is a better escape hatch
226/// than `hook.skip`, which is all-or-nothing and invisible enough that
227/// `hook.skip = e` disables all twenty checks: a downgrade keeps the signal and
228/// removes only the block.
229#[derive(Debug, Clone, Copy, PartialEq, Eq)]
230pub enum Severity {
231    Block,
232    Warn,
233}
234
235impl Severity {
236    /// The ONE mapping from configured text to severity.
237    ///
238    /// There were four: this key's reader, the manifest's severity column, the
239    /// dashboard's copy, and the dashboard's reverse mapping for `--json`. They
240    /// agreed, but nothing made them — and the dashboard's copy is its
241    /// prediction of what the dispatcher will do, which is the one thing it must
242    /// never get wrong.
243    ///
244    /// `None` for anything else, deliberately: git validates nothing here, so an
245    /// unrecognised value must fall back to the declared severity rather than
246    /// silently disable a check.
247    pub fn parse(value: &str) -> Option<Severity> {
248        match value {
249            "warn" => Some(Severity::Warn),
250            "block" => Some(Severity::Block),
251            _ => None,
252        }
253    }
254
255    /// How it is written in config and in `--json`.
256    pub fn as_str(self) -> &'static str {
257        match self {
258            Severity::Block => "block",
259            Severity::Warn => "warn",
260        }
261    }
262}
263
264/// Whether a check can rewrite the files it inspects.
265///
266/// Off by default and per check, never global: a hook that edits your files
267/// without being asked is a larger surprise than one that complains.
268#[derive(Debug, Clone, Copy, PartialEq, Eq)]
269pub enum Fix {
270    /// Reports only. Every check, until somebody declares otherwise.
271    None,
272    /// Runs a command that rewrites files, and stages what it changed.
273    ///
274    /// Only reachable from a `Stage::PreCommit` declaration. A pre-push hook
275    /// must not modify the worktree or index: the pushed commit would then
276    /// differ from the tree the developer is looking at.
277    Rewrite,
278}
279
280impl Fix {
281    /// How it is written in `--json`. No `parse()`: nothing reads a `Fix`
282    /// back out of text — the manifest's `fix` marker has its own, unrelated
283    /// parsing path in `manifest.rs`.
284    pub fn as_str(self) -> &'static str {
285        match self {
286            Fix::None => "none",
287            Fix::Rewrite => "rewrite",
288        }
289    }
290}
291
292/// One check, whether compiled in or declared by the repository.
293///
294/// `Sync` because `pre-commit` hands every check to its own thread. Both
295/// implementations satisfy it for free, and requiring it here is what lets the
296/// dispatcher hold `&'static dyn Check` without caring which kind it has.
297pub trait Check: Sync {
298    fn name(&self) -> &str;
299    fn stage(&self) -> Stage;
300    fn scope(&self) -> Scope;
301    fn severity(&self) -> Severity;
302    /// Whether this check can repair what it finds. `None` for almost all.
303    fn fix(&self) -> Fix {
304        Fix::None
305    }
306    fn run(&self, ctx: &Ctx) -> Outcome;
307}
308
309/// A check compiled into the binary.
310pub struct Builtin {
311    pub name: &'static str,
312    pub stage: Stage,
313    pub scope: Scope,
314    pub severity: Severity,
315    pub run: fn(&Ctx) -> Outcome,
316    /// Almost always `Fix::None`; see `CHECKS`.
317    pub fix: Fix,
318}
319
320impl Check for Builtin {
321    fn name(&self) -> &str {
322        self.name
323    }
324    fn stage(&self) -> Stage {
325        self.stage
326    }
327    fn scope(&self) -> Scope {
328        self.scope
329    }
330    fn severity(&self) -> Severity {
331        self.severity
332    }
333    fn fix(&self) -> Fix {
334        self.fix
335    }
336    fn run(&self, ctx: &Ctx) -> Outcome {
337        (self.run)(ctx)
338    }
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344
345    /// Round-trips, and refuses everything else. A `Some` for an unknown value
346    /// would turn a typo into a silent disable.
347    #[test]
348    fn severity_parses_exactly_the_two_words_it_documents() {
349        for s in [Severity::Block, Severity::Warn] {
350            assert_eq!(Severity::parse(s.as_str()), Some(s));
351        }
352        for bad in ["", "Warn", "WARN", "advisory", "true", "1", " warn"] {
353            assert_eq!(Severity::parse(bad), None, "{bad:?} must not parse");
354        }
355    }
356
357    #[test]
358    fn fix_says_how_it_is_written_in_json() {
359        assert_eq!(Fix::None.as_str(), "none");
360        assert_eq!(Fix::Rewrite.as_str(), "rewrite");
361    }
362
363    #[test]
364    fn always_matches_anything() {
365        assert!(Scope::ALWAYS.matches(&[]));
366        assert!(Scope::ALWAYS.matches(&["README.md".into()]));
367    }
368
369    #[test]
370    fn extensions_gate_on_the_file_type() {
371        let s = Scope::files(&[".rs"]);
372        assert!(s.matches(&["src/main.rs".into()]));
373        assert!(!s.matches(&["README.md".into()]));
374    }
375
376    /// The case the enum could not express: BOTH conditions must hold.
377    #[test]
378    fn files_and_opt_in_are_a_conjunction() {
379        let ruff = Scope::new(&[".py"], &["ruff.toml", "pyproject.toml"]);
380        assert!(
381            !ruff.matches(&["a.py".into()]),
382            "python alone is not enough — the repo must opt in"
383        );
384        assert!(
385            !ruff.matches(&["pyproject.toml".into()]),
386            "and a config alone is not enough without python"
387        );
388        assert!(ruff.matches(&["a.py".into(), "pyproject.toml".into()]));
389    }
390
391    /// `.kube-linter*.yaml` is a real config name in this repo's own hooks.
392    #[test]
393    fn a_trailing_star_is_a_prefix_match() {
394        let s = Scope::new(&[".yaml"], &[".kube-linter*.yaml"]);
395        assert!(s.matches(&["k8s/x.yaml".into(), ".kube-linter-prod.yaml".into()]));
396        assert!(!s.matches(&["k8s/x.yaml".into(), ".kube-lint.yaml".into()]));
397    }
398
399    /// Opt-in matches a BASENAME anywhere, which is what makes the coarse
400    /// answer right for a check that resolves an ancestor when it runs.
401    #[test]
402    fn opt_in_matches_a_nested_manifest() {
403        let cargo = Scope::new(&[".rs"], &["Cargo.toml"]);
404        assert!(cargo.matches(&["crates/a/src/lib.rs".into(), "crates/a/Cargo.toml".into()]));
405    }
406}