Skip to main content

tramlines_core/
finding.rs

1//! `Finding`, `Severity` and `Locator`: plain data that passes through
2//! the cache.
3
4use serde::{Deserialize, Serialize};
5
6use crate::RepoPath;
7
8// sokf-implements: CORE-8_AC-8
9// sokf:begin finding
10/// How a finding is reported.
11#[derive(
12    Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
13)]
14pub enum Severity {
15    /// A failure. Exit 1.
16    #[default]
17    Error,
18    /// What the validator cannot settle alone.
19    Warning,
20}
21// sokf:end finding
22
23// sokf:begin finding
24impl Severity {
25    /// How a finding line names the severity: `error` or `warning`.
26    pub fn as_str(self) -> &'static str {
27        // sokf:end finding
28        // sokf-implements: CORE-8_AC-10
29        match self {
30            Severity::Error => "error",
31            Severity::Warning => "warning",
32        }
33    }
34}
35
36// sokf:begin finding
37/// Where in a schema the rule that failed sits: the `id` of the schema,
38/// and the rule's `rule-id`, else its section and block position. A
39/// finding that a schema rule raises ends with it.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct Locator {
42    /// The `id` of the schema.
43    pub schema: String,
44    /// Where the rule sits in the schema, when the check knows.
45    pub rule: Option<String>,
46}
47// sokf:end finding
48
49impl Locator {
50    /// The schema `id`, with no rule named.
51    pub fn schema(id: impl Into<String>) -> Locator {
52        Locator {
53            schema: id.into(),
54            rule: None,
55        }
56    }
57
58    /// The same schema, at the rule `at`.
59    pub fn at(&self, at: impl Into<String>) -> Locator {
60        Locator {
61            schema: self.schema.clone(),
62            rule: Some(at.into()),
63        }
64    }
65
66    fn suffix(&self) -> String {
67        match &self.rule {
68            // sokf-implements: CORE-8_AC-2
69            None => format!("(schema {})", self.schema),
70            // The reader the finding sends to the schema lands in a
71            // schema of hundreds of lines; the locator says where.
72            // sokf-implements: CORE-8_AC-3
73            // sokf-implements: TL_73
74            Some(at) => format!("(schema {}, {at})", self.schema),
75        }
76    }
77}
78
79// Each finding names the criterion of its one fault, which the
80// specification keeps to one fault with one fix.
81// sokf-implements: TL_100
82// sokf:begin finding
83/// One line of the report.
84#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
85pub struct Finding {
86    /// The file the finding is on.
87    pub path: RepoPath,
88    /// The line, when one applies.
89    pub line: Option<usize>,
90    /// Error or warning.
91    pub severity: Severity,
92    /// The key of the criterion that the finding breaks, as `TLMD_57`.
93    pub criterion: String,
94    /// The message, ending with the location of its rule in the schema
95    /// when a schema rule raised it.
96    pub message: String,
97    /// The line in the schema of the rule that raised it, where the check
98    /// knows one: what a finding on the schema itself sits on when the
99    /// schema's own scaffold fails the rule. The report does not print it.
100    #[serde(skip)]
101    pub rule_line: Option<usize>,
102}
103// sokf:end finding
104
105impl Finding {
106    /// Build a finding that breaks `criterion`.
107    // sokf-implements: TL_72
108    // sokf-implements: CORE-8_AC-1
109    // sokf:begin finding
110    pub fn new(
111        path: RepoPath,
112        line: Option<usize>,
113        severity: Severity,
114        criterion: &str,
115        message: impl Into<String>,
116    ) -> Self {
117        // sokf:end finding
118        // sokf-implements: CORE-8_AC-9
119        debug_assert!(
120            is_criterion(criterion),
121            "`{criterion}` is not a criterion key"
122        );
123        Finding {
124            path,
125            line,
126            severity,
127            criterion: criterion.to_owned(),
128            message: message.into(),
129            rule_line: None,
130        }
131    }
132
133    /// An error finding.
134    // sokf-implements: CORE-8_AC-7
135    pub fn error(
136        path: RepoPath,
137        line: Option<usize>,
138        criterion: &str,
139        message: impl Into<String>,
140    ) -> Self {
141        Self::new(path, line, Severity::Error, criterion, message)
142    }
143
144    /// A warning finding.
145    // sokf-implements: CORE-8_AC-7
146    pub fn warning(
147        path: RepoPath,
148        line: Option<usize>,
149        criterion: &str,
150        message: impl Into<String>,
151    ) -> Self {
152        Self::new(path, line, Severity::Warning, criterion, message)
153    }
154
155    /// The finding, raised by the schema rule on line `line` of the
156    /// schema; a rule with no line of its own, at `0`, names none.
157    #[must_use]
158    pub fn of_rule(mut self, line: usize) -> Self {
159        self.rule_line = (line > 0).then_some(line);
160        self
161    }
162
163    /// The finding, with the location of the schema rule that raised it
164    /// appended to its message.
165    #[must_use]
166    pub fn at(mut self, locator: &Locator) -> Self {
167        self.message = format!("{} {}", self.message, locator.suffix());
168        self
169    }
170}
171
172/// Whether `key` has the form of a criterion key: upper-case words joined
173/// by `_`, an optional `-n` part, then `_n`, `_AC-n` or `_P-n`, as
174/// `TLMD_57`, `CLI-4_AC-5` or `SOKF_FORMAT_53`.
175fn is_criterion(key: &str) -> bool {
176    let Some((head, tail)) = key.rsplit_once('_') else {
177        return false;
178    };
179    let number = tail
180        .strip_prefix("AC-")
181        .or_else(|| tail.strip_prefix("P-"))
182        .unwrap_or(tail);
183    let (words, part) = head.split_once('-').unwrap_or((head, "1"));
184    !number.is_empty()
185        && number.bytes().all(|b| b.is_ascii_digit())
186        && !part.is_empty()
187        && part.bytes().all(|b| b.is_ascii_digit())
188        && words
189            .split('_')
190            .all(|w| !w.is_empty() && w.bytes().all(|b| b.is_ascii_uppercase()))
191}
192
193/// A failure found before the file it is on is known: the criterion it
194/// breaks, and what failed.
195#[derive(Debug, Clone, PartialEq, Eq)]
196pub struct Problem {
197    /// The key of the criterion, as `TL_25`.
198    pub criterion: &'static str,
199    /// What failed.
200    pub message: String,
201}
202
203impl Problem {
204    /// A problem that breaks `criterion`.
205    pub fn new(criterion: &'static str, message: impl Into<String>) -> Self {
206        Problem {
207            criterion,
208            message: message.into(),
209        }
210    }
211
212    /// The problem, with `prefix` before its message.
213    #[must_use]
214    pub fn prefixed(mut self, prefix: &str) -> Self {
215        self.message = format!("{prefix}{}", self.message);
216        self
217    }
218
219    /// The problem as an error finding on `path` at `line`.
220    pub fn error(self, path: RepoPath, line: Option<usize>) -> Finding {
221        Finding::error(path, line, self.criterion, self.message)
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    fn p(s: &str) -> RepoPath {
230        RepoPath::parse(s).unwrap()
231    }
232
233    // sokf-tests: TL_72
234    // sokf-tests: TL_73
235    #[test]
236    fn a_finding_carries_its_criterion_and_its_rule() {
237        let f = Finding::error(p("/a.md"), Some(3), "TLMD_109", "bad");
238        assert_eq!(
239            (f.criterion.as_str(), f.message.as_str()),
240            ("TLMD_109", "bad")
241        );
242        assert_eq!(f.severity, Severity::Error);
243        let readme = Locator::schema("readme");
244        let f = Finding::warning(p("/a.md"), None, "TL_74", "bad").at(&readme);
245        assert_eq!(f.message, "bad (schema readme)");
246        assert_eq!(f.severity, Severity::Warning);
247        let f = Finding::error(p("/a.md"), None, "TL_74", "bad").at(&readme.at("section \"A\""));
248        assert_eq!(f.message, "bad (schema readme, section \"A\")");
249    }
250
251    #[test]
252    fn a_criterion_key_has_its_form() {
253        for key in [
254            "TL_1",
255            "TLMD_57",
256            "CLI-4_AC-5",
257            "CORE_P-2",
258            "SOKF_FORMAT_53",
259        ] {
260            assert!(is_criterion(key), "{key}");
261        }
262        for key in ["unit", "TL", "TL_", "tl_1", "CLI-4", "CLI-x_AC-1", "_1"] {
263            assert!(!is_criterion(key), "{key}");
264        }
265    }
266
267    proptest::proptest! {
268        // sokf-tests: CORE_P-5
269        #[test]
270        fn a_locator_ends_the_message(message in "[ -~]{0,40}", schema in "[a-z]{1,8}") {
271            let f = Finding::error(p("/a.md"), None, "TL_74", message.clone())
272                .at(&Locator::schema(schema.clone()));
273            proptest::prop_assert_eq!(f.criterion.as_str(), "TL_74");
274            proptest::prop_assert!(f.message.starts_with(&message));
275            let suffix = format!("(schema {schema})");
276            proptest::prop_assert!(f.message.ends_with(&suffix));
277        }
278    }
279
280    // sokf-tests: CORE_P-1
281    // sokf-tests: CORE-10_AC-2
282    #[test]
283    fn findings_round_trip_through_serde() {
284        let f = Finding::error(p("/a.md"), Some(3), "TL_53", "bad");
285        let json = serde_json::to_string(&f).unwrap();
286        let back: Finding = serde_json::from_str(&json).unwrap();
287        assert_eq!(back, f);
288    }
289}