1use serde::{Deserialize, Serialize};
5
6use crate::RepoPath;
7
8#[derive(
12 Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
13)]
14pub enum Severity {
15 #[default]
17 Error,
18 Warning,
20}
21impl Severity {
25 pub fn as_str(self) -> &'static str {
27 match self {
30 Severity::Error => "error",
31 Severity::Warning => "warning",
32 }
33 }
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct Locator {
42 pub schema: String,
44 pub rule: Option<String>,
46}
47impl Locator {
50 pub fn schema(id: impl Into<String>) -> Locator {
52 Locator {
53 schema: id.into(),
54 rule: None,
55 }
56 }
57
58 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 None => format!("(schema {})", self.schema),
70 Some(at) => format!("(schema {}, {at})", self.schema),
75 }
76 }
77}
78
79#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
85pub struct Finding {
86 pub path: RepoPath,
88 pub line: Option<usize>,
90 pub severity: Severity,
92 pub criterion: String,
94 pub message: String,
97 #[serde(skip)]
101 pub rule_line: Option<usize>,
102}
103impl Finding {
106 pub fn new(
111 path: RepoPath,
112 line: Option<usize>,
113 severity: Severity,
114 criterion: &str,
115 message: impl Into<String>,
116 ) -> Self {
117 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 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 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 #[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 #[must_use]
166 pub fn at(mut self, locator: &Locator) -> Self {
167 self.message = format!("{} {}", self.message, locator.suffix());
168 self
169 }
170}
171
172fn 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#[derive(Debug, Clone, PartialEq, Eq)]
196pub struct Problem {
197 pub criterion: &'static str,
199 pub message: String,
201}
202
203impl Problem {
204 pub fn new(criterion: &'static str, message: impl Into<String>) -> Self {
206 Problem {
207 criterion,
208 message: message.into(),
209 }
210 }
211
212 #[must_use]
214 pub fn prefixed(mut self, prefix: &str) -> Self {
215 self.message = format!("{prefix}{}", self.message);
216 self
217 }
218
219 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 #[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 #[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 #[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}