Skip to main content

amont_runtime/
content.rs

1//! The content checks, asked about a FILE rather than about the index.
2//!
3//! Every check reachable from here is pure: give it a path and some bytes and
4//! it answers, with no git, no index, no stash, and no opinion about what is
5//! staged. That is the whole difference from [`crate::dispatch`], which
6//! rehearses a commit and is entitled to all of those things.
7//!
8//! The separation is deliberate and load-bearing. `amont run` answers "is this
9//! commit ready", which is a question about the index; an editor asks "what is
10//! wrong with this buffer", which is a question about content that may not be
11//! staged and may never have been saved. Conflating them would drag index
12//! fidelity — the stash, the restore path, the staged-only hold — into what
13//! ought to be a read-only lookup.
14//!
15//! Not every check can come here, and that is fine. `branch-pattern`,
16//! `pull-rebase` and `package-lock` are not about the contents of a file at
17//! all, and `clippy`/`ruff`/`eslint` already have editor integrations of their
18//! own. What is left is the set amont uniquely owns — which is also the set
19//! `docs/ci.md` says CI deliberately does not reproduce.
20
21use crate::finding::Finding;
22use crate::hooks::{ban_terms, large_files, merge_conflict, secrets};
23
24/// Every finding for one file, ordered for reading.
25///
26/// `bytes` rather than `&str` because two of the four decisions are made on
27/// bytes: a file's SIZE is a byte count, and whether a file is text at all is
28/// git's NUL heuristic. Converting first would answer both questions wrongly
29/// for exactly the files where they matter.
30pub fn scan(file: &str, bytes: &[u8]) -> Vec<Finding> {
31    let mut out = Vec::new();
32
33    // Size first, and unconditionally: it is the one thing still worth saying
34    // about a file too big or too binary for everything below.
35    if let Some(f) = large_files::scan(file, bytes.len() as u64) {
36        out.push(f);
37    }
38    if !secrets::is_scannable(bytes) {
39        return sorted(out);
40    }
41
42    let text = String::from_utf8_lossy(bytes);
43    // Each check's own short name is passed as the self-exclusion key. In a
44    // hook that key is the hook's name; here there is no stage, so the check
45    // names itself. The only visible difference is in amont's own repository,
46    // where a fixture named after a hook rather than after a check is no
47    // longer skipped — which is the correct answer for a verb that was asked
48    // about that file by name.
49    out.extend(ban_terms::scan(file, &text, ban_terms::NAME));
50    out.extend(merge_conflict::scan(file, &text, merge_conflict::NAME));
51    out.extend(secrets::findings(file, &text));
52    sorted(out)
53}
54
55/// By position, then by check, so output is stable across runs.
56///
57/// Stability is not cosmetic here: a diagnostic list that reorders between
58/// invocations makes an editor's gutter flicker and makes a diff of two runs
59/// unreadable. Findings without a line sort first — they are about the whole
60/// file, so they belong above anything inside it.
61fn sorted(mut findings: Vec<Finding>) -> Vec<Finding> {
62    findings.sort_by(|a, b| {
63        a.file
64            .cmp(&b.file)
65            .then(a.line.cmp(&b.line))
66            .then(a.column.cmp(&b.column))
67            .then(a.check.cmp(b.check))
68    });
69    findings
70}
71
72#[cfg(test)]
73mod tests {
74    use super::*;
75    use crate::check::Severity;
76
77    #[test]
78    fn a_clean_file_has_nothing_to_say() {
79        assert!(scan("app.js", b"const x = 1;\n").is_empty());
80    }
81
82    #[test]
83    fn a_banned_term_is_placed() {
84        let f = scan("app.js", b"const x = 1;\n\n  debugger;\n");
85        assert_eq!(f.len(), 1);
86        assert_eq!(f[0].check, "ban-terms");
87        assert_eq!(f[0].line, Some(3));
88        assert_eq!(f[0].column, Some(3));
89    }
90
91    /// The extension gate still applies: `debugger;` in a `.txt` is prose.
92    #[test]
93    fn scope_is_respected_outside_the_index_too() {
94        assert!(scan("notes.txt", b"debugger;\n").is_empty());
95    }
96
97    /// A binary blob gets a size verdict and no text scanning — reading it as
98    /// text would produce nonsense positions in nonexistent lines.
99    #[test]
100    fn a_binary_file_is_sized_but_not_read() {
101        let mut bytes = vec![0u8; 200];
102        bytes.extend_from_slice(b"debugger;");
103        let f = scan("blob.js", &bytes);
104        assert!(f.iter().all(|f| f.check != "ban-terms"), "{f:?}");
105    }
106
107    /// Whole-file findings sort above positioned ones.
108    #[test]
109    fn findings_are_ordered_for_reading() {
110        let whole = Finding::new("large-files", "a.js", Severity::Warn, "x");
111        let late = Finding::new("ban-terms", "a.js", Severity::Block, "y").at_line(9);
112        let early = Finding::new("secrets", "a.js", Severity::Block, "z").at_line(2);
113        let out = sorted(vec![late, whole, early]);
114        assert_eq!(
115            out.iter().map(|f| f.line).collect::<Vec<_>>(),
116            vec![None, Some(2), Some(9)]
117        );
118    }
119}