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(settings: &crate::config::Settings, 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(settings, 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
76    fn test_settings() -> crate::config::Settings {
77        crate::config::Settings::default()
78    }
79
80    use crate::check::Severity;
81
82    #[test]
83    fn a_clean_file_has_nothing_to_say() {
84        assert!(scan(&test_settings(), "app.js", b"const x = 1;\n").is_empty());
85    }
86
87    #[test]
88    fn a_banned_term_is_placed() {
89        let f = scan(&test_settings(), "app.js", b"const x = 1;\n\n  debugger;\n");
90        assert_eq!(f.len(), 1);
91        assert_eq!(f[0].check, "ban-terms");
92        assert_eq!(f[0].line, Some(3));
93        assert_eq!(f[0].column, Some(3));
94    }
95
96    /// The extension gate still applies: `debugger;` in a `.txt` is prose.
97    #[test]
98    fn scope_is_respected_outside_the_index_too() {
99        assert!(scan(&test_settings(), "notes.txt", b"debugger;\n").is_empty());
100    }
101
102    /// A binary blob gets a size verdict and no text scanning — reading it as
103    /// text would produce nonsense positions in nonexistent lines.
104    #[test]
105    fn a_binary_file_is_sized_but_not_read() {
106        let mut bytes = vec![0u8; 200];
107        bytes.extend_from_slice(b"debugger;");
108        let f = scan(&test_settings(), "blob.js", &bytes);
109        assert!(f.iter().all(|f| f.check != "ban-terms"), "{f:?}");
110    }
111
112    /// Whole-file findings sort above positioned ones.
113    #[test]
114    fn findings_are_ordered_for_reading() {
115        let whole = Finding::new("large-files", "a.js", Severity::Warn, "x");
116        let late = Finding::new("ban-terms", "a.js", Severity::Block, "y").at_line(9);
117        let early = Finding::new("secrets", "a.js", Severity::Block, "z").at_line(2);
118        let out = sorted(vec![late, whole, early]);
119        assert_eq!(
120            out.iter().map(|f| f.line).collect::<Vec<_>>(),
121            vec![None, Some(2), Some(9)]
122        );
123    }
124}