Skip to main content

amont_runtime/
finding.rs

1//! One problem, in one place, in one file.
2//!
3//! [`Outcome`](crate::check::Outcome) answers what a CHECK concluded, in five
4//! variants and no payload. That is the right shape for git — a hook has to
5//! resolve to proceed-or-block — but it is the reason a report could only ever
6//! name the file:
7//!
8//! ```text
9//! ✗ Unwanted terms found
10//!   The following files contains 'debugger' in them:
11//!   - app.js
12//! ```
13//!
14//! The line was known. `ban_terms`'s comment-blanking preserves length and line
15//! count precisely so offsets stay valid, and `secrets::scan` has always
16//! returned line numbers — but with nowhere to put them, the position was
17//! computed and dropped at the boundary. This type is that place.
18//!
19//! Positions are a REPORTING concern and never a decision input. A check
20//! decides pass or fail exactly as it did before; a finding says where. Where a
21//! position cannot be pinned down — a whole-file judgement like `large-files`,
22//! or a match the locator cannot resolve to one offset — [`Finding::line`] is
23//! `None` and the report degrades to naming the file, which is what it did for
24//! everything until now.
25
26use crate::check::Severity;
27
28/// The one spelling of a severity for consumers. `error`/`warning` rather than
29/// `block`/`warn` because this crosses a boundary into other people's tools,
30/// where those two words already mean this.
31fn severity_word(severity: Severity) -> &'static str {
32    match severity {
33        Severity::Block => "error",
34        Severity::Warn => "warning",
35    }
36}
37
38/// A single problem, addressable.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct Finding {
41    /// The check's short name — `ban-terms`, not `pre-commit-ban-terms`. The
42    /// stage is not a property of the finding: the same content problem is the
43    /// same problem whichever hook noticed it, and an editor has no stages.
44    pub check: &'static str,
45    /// Repository-relative, as git spells it.
46    pub file: String,
47    /// 1-based, as every editor and terminal counts. `None` when the finding is
48    /// about the file as a whole.
49    pub line: Option<usize>,
50    /// 1-based, counted in CHARACTERS rather than bytes — see [`line_col`].
51    pub column: Option<usize>,
52    pub severity: Severity,
53    /// One line, no trailing period, safe to print. Anything derived from file
54    /// content must be sanitised by its producer before it gets here: this type
55    /// will happily print whatever it is given, and `secrets` in particular
56    /// must never put the matched text in it.
57    pub message: String,
58}
59
60impl Finding {
61    pub fn new(
62        check: &'static str,
63        file: impl Into<String>,
64        severity: Severity,
65        message: impl Into<String>,
66    ) -> Self {
67        Finding {
68            check,
69            file: file.into(),
70            line: None,
71            column: None,
72            severity,
73            message: message.into(),
74        }
75    }
76
77    /// Place this finding at a byte offset within `content`.
78    pub fn at_offset(mut self, content: &str, offset: usize) -> Self {
79        let (line, column) = line_col(content, offset);
80        self.line = Some(line);
81        self.column = Some(column);
82        self
83    }
84
85    /// Place it on a line whose column is not known or not meaningful.
86    pub fn at_line(mut self, line: usize) -> Self {
87        self.line = Some(line);
88        self
89    }
90
91    /// `file:line:col` — just the address.
92    ///
93    /// Used inside a hook's own report, which already says which check spoke
94    /// and how loudly; repeating either there would be noise. `render` is the
95    /// form for anything outside amont.
96    pub fn location(&self) -> String {
97        let mut out = self.file.clone();
98        if let Some(line) = self.line {
99            out.push_str(&format!(":{line}"));
100            if let Some(column) = self.column {
101                out.push_str(&format!(":{column}"));
102            }
103        }
104        out
105    }
106
107    /// `file:line:col: severity: message [check]`
108    ///
109    /// The shape every editor's error parser already understands, and which
110    /// every modern terminal turns into a clickable link. That is the whole
111    /// reason for choosing it over something prettier: it needs no adapter.
112    pub fn render(&self) -> String {
113        let mut out = self.file.clone();
114        if let Some(line) = self.line {
115            out.push_str(&format!(":{line}"));
116            if let Some(column) = self.column {
117                out.push_str(&format!(":{column}"));
118            }
119        }
120        format!(
121            "{out}: {}: {} [{}]",
122            severity_word(self.severity),
123            self.message,
124            self.check
125        )
126    }
127}
128
129/// A byte offset into 1-based (line, column).
130///
131/// The column counts CHARACTERS, not bytes. Editors place a cursor by
132/// character, so a byte column puts the caret in the wrong place on any line
133/// containing a non-ASCII character — an accented identifier, a comment in a
134/// language that is not English, an emoji in a string. Byte offsets are what
135/// the matchers produce and characters are what the reader wants, so the
136/// conversion belongs here rather than at each call site.
137///
138/// An offset past the end clamps to the last position rather than panicking:
139/// this is a reporting path, and a wrong column is a far better outcome than
140/// taking down a commit hook.
141pub fn line_col(content: &str, offset: usize) -> (usize, usize) {
142    let offset = offset.min(content.len());
143    let before = &content[..offset];
144    let line = before.matches('\n').count() + 1;
145    let line_start = before.rfind('\n').map(|i| i + 1).unwrap_or(0);
146    let column = content[line_start..offset].chars().count() + 1;
147    (line, column)
148}
149
150/// Findings as JSON, for anything that would rather not parse a line.
151///
152/// Hand-rolled to keep the commit path dependency-free, and versioned like
153/// `amont-list-v1` for the same reason: a consumer that does not recognise the
154/// format string should say so rather than guess at the shape.
155pub fn to_json(findings: &[Finding]) -> String {
156    let items: Vec<String> = findings
157        .iter()
158        .map(|f| {
159            format!(
160                "{{{},{},{},{},{},{}}}",
161                crate::json::string_field("check", f.check),
162                crate::json::string_field("file", &f.file),
163                crate::json::opt_int_field("line", f.line.map(|l| l as i64)),
164                crate::json::opt_int_field("column", f.column.map(|c| c as i64)),
165                crate::json::string_field("severity", severity_word(f.severity)),
166                crate::json::string_field("message", &f.message),
167            )
168        })
169        .collect();
170    format!(
171        "{{{},\"findings\":[{}]}}",
172        crate::json::string_field("format", "amont-check-v1"),
173        items.join(",")
174    )
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    #[test]
182    fn offsets_become_one_based_positions() {
183        let src = "abc\ndef\nghi";
184        assert_eq!(line_col(src, 0), (1, 1));
185        assert_eq!(line_col(src, 2), (1, 3));
186        assert_eq!(line_col(src, 4), (2, 1)); // first char of line 2
187        assert_eq!(line_col(src, 8), (3, 1));
188    }
189
190    /// The reason the column counts characters. With a byte column the caret
191    /// lands mid-codepoint and the editor highlights the wrong token.
192    #[test]
193    fn the_column_counts_characters_not_bytes() {
194        let src = "let café = dbg!(1);";
195        let at = src.find("dbg!").unwrap();
196        assert_eq!(at, 12); // twelve BYTES in, because é is two
197        assert_eq!(line_col(src, at), (1, 12)); // but eleven characters
198    }
199
200    /// A reporting path must not be able to panic a commit hook.
201    #[test]
202    fn an_offset_past_the_end_clamps() {
203        let src = "abc";
204        assert_eq!(line_col(src, 99), (1, 4));
205        assert_eq!(line_col("", 5), (1, 1));
206    }
207
208    #[test]
209    fn render_degrades_when_there_is_no_position() {
210        let whole = Finding::new("large-files", "big.bin", Severity::Block, "12 MB");
211        assert_eq!(whole.render(), "big.bin: error: 12 MB [large-files]");
212        let placed = Finding::new("ban-terms", "app.js", Severity::Block, "'debugger'")
213            .at_offset("a\nb\ndebugger;", 4);
214        assert_eq!(placed.render(), "app.js:3:1: error: 'debugger' [ban-terms]");
215        let lined = Finding::new("secrets", "k.pem", Severity::Warn, "a private key").at_line(7);
216        assert_eq!(lined.render(), "k.pem:7: warning: a private key [secrets]");
217    }
218
219    #[test]
220    fn json_is_versioned_and_escapes_its_input() {
221        assert_eq!(
222            to_json(&[]),
223            "{\"format\":\"amont-check-v1\",\"findings\":[]}"
224        );
225        let f = Finding::new("ban-terms", "a\"b.js", Severity::Block, "x").at_line(2);
226        let json = to_json(&[f]);
227        assert!(json.contains(r#""file":"a\"b.js""#), "{json}");
228        assert!(json.contains(r#""line":2,"column":null"#), "{json}");
229    }
230}