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}