1use std::fmt;
11
12use serde::Serialize;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
16#[serde(rename_all = "kebab-case")]
17#[non_exhaustive]
18pub enum Code {
19 UnknownField,
21 RemovedField,
23 MissingField,
25 InvalidValue,
27 Conflict,
29 UnknownReference,
31 SensitiveValue,
34 Refused,
36}
37
38impl Code {
39 #[must_use]
41 pub fn id(self) -> &'static str {
42 match self {
43 Code::UnknownField => "unknown-field",
44 Code::RemovedField => "removed-field",
45 Code::MissingField => "missing-field",
46 Code::InvalidValue => "invalid-value",
47 Code::Conflict => "conflict",
48 Code::UnknownReference => "unknown-reference",
49 Code::SensitiveValue => "sensitive-value",
50 Code::Refused => "refused",
51 }
52 }
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
57pub struct Span {
58 pub line: usize,
60 pub column: usize,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize)]
66#[serde(transparent)]
67pub struct FieldPath(String);
68
69impl FieldPath {
70 #[must_use]
72 pub fn new(path: impl Into<String>) -> FieldPath {
73 FieldPath(path.into())
74 }
75
76 #[must_use]
78 pub fn key(&self, key: &str) -> FieldPath {
79 if self.0.is_empty() {
80 FieldPath(key.to_owned())
81 } else {
82 FieldPath(format!("{}.{key}", self.0))
83 }
84 }
85
86 #[must_use]
88 pub fn index(&self, i: usize) -> FieldPath {
89 FieldPath(format!("{}[{i}]", self.0))
90 }
91
92 #[must_use]
94 pub fn as_str(&self) -> &str {
95 &self.0
96 }
97
98 #[must_use]
100 pub fn parent(&self) -> Option<FieldPath> {
101 let cut = self.0.rfind(['.', '['])?;
102 self.0.get(..cut).map(|parent| FieldPath(parent.to_owned()))
103 }
104
105 #[must_use]
107 pub fn within(&self, other: &FieldPath) -> bool {
108 self.0 == other.0 || self.0.strip_prefix(other.0.as_str()).is_some_and(|rest| rest.starts_with(['.', '[']))
109 }
110}
111
112impl fmt::Display for FieldPath {
113 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
114 f.write_str(&self.0)
115 }
116}
117
118#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
120pub struct Problem {
121 pub code: Code,
123 pub path: FieldPath,
125 #[serde(flatten, skip_serializing_if = "Option::is_none")]
128 pub span: Option<Span>,
129 pub reason: String,
131 #[serde(skip_serializing_if = "Option::is_none")]
133 pub fix: Option<String>,
134}
135
136impl Problem {
137 #[must_use]
139 pub fn new(code: Code, path: FieldPath, reason: impl Into<String>) -> Problem {
140 Problem {
141 code,
142 path,
143 span: None,
144 reason: reason.into(),
145 fix: None,
146 }
147 }
148
149 #[must_use]
151 pub fn fix(mut self, fix: impl Into<String>) -> Problem {
152 self.fix = Some(fix.into());
153 self
154 }
155
156 #[must_use]
158 pub fn render(&self, file: &str) -> String {
159 let head = format!("error[{}] {}", self.code.id(), self.path);
160 lines(&head, self.span, file, &self.reason, self.fix.as_deref())
161 }
162
163 #[must_use]
166 pub fn line(&self) -> String {
167 format!("{}: {}", self.path, self.reason)
168 }
169
170 #[must_use]
172 pub fn section(&self) -> &str {
173 self.path.as_str().split(['.', '[']).next().unwrap_or_default()
174 }
175}
176
177impl fmt::Display for Problem {
178 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
179 f.write_str(&self.render("isoloom.yml"))
180 }
181}
182
183pub type Problems = Vec<Problem>;
185
186#[must_use]
188pub fn closest<'a>(word: &str, known: &[&'a str]) -> Option<&'a str> {
189 known
190 .iter()
191 .map(|k| (distance(word, k), *k))
192 .filter(|(d, k)| *d <= 2.max(k.len() / 4))
193 .min_by_key(|(d, _)| *d)
194 .map(|(_, k)| k)
195}
196
197fn distance(a: &str, b: &str) -> usize {
199 let b: Vec<char> = b.chars().collect();
200 let mut row: Vec<usize> = (0..=b.len()).collect();
203 for (i, ca) in a.chars().enumerate() {
204 let mut next = Vec::with_capacity(row.len());
205 next.push(i + 1);
206 for ((cb, diagonal), above) in b.iter().zip(&row).zip(row.iter().skip(1)) {
207 let left = next.last().copied().unwrap_or_default();
208 next.push((diagonal + usize::from(ca != *cb)).min(above + 1).min(left + 1));
209 }
210 row = next;
211 }
212 row.last().copied().unwrap_or_default()
213}
214
215pub(crate) fn lines(head: &str, span: Option<Span>, file: &str, reason: &str, fix: Option<&str>) -> String {
218 let place = span.map(|s| format!(" ({file}:{}:{})", s.line, s.column)).unwrap_or_default();
219 let mut out = format!("{head}{place}\n {reason}");
220 if let Some(fix) = fix {
221 out.push_str("\n fix: ");
222 out.push_str(fix);
223 }
224 out
225}
226
227#[cfg(test)]
228mod tests {
229 use super::*;
230
231 #[test]
232 fn a_problem_reads_on_one_line_as_its_path_and_reason() {
233 let p = Problem::new(Code::Refused, FieldPath::new("machines.web.app.build"), "a Dockerfile builds a container only");
234 assert_eq!(p.line(), "machines.web.app.build: a Dockerfile builds a container only");
235 assert_eq!(p.to_string(), p.render("isoloom.yml"));
236 assert_eq!(p.to_string(), "error[refused] machines.web.app.build\n a Dockerfile builds a container only");
237 }
238
239 #[test]
240 fn a_problem_reads_as_three_lines() {
241 let mut p = Problem::new(Code::RemovedField, FieldPath::new("machines.web.docker"), "`docker:` is gone").fix("`app: { image: <ref> }`");
242 assert_eq!(
243 p.render("isoloom.yml"),
244 "error[removed-field] machines.web.docker\n `docker:` is gone\n fix: `app: { image: <ref> }`"
245 );
246 p.span = Some(Span { line: 12, column: 5 });
247 assert!(
248 p.render("isoloom.yml")
249 .starts_with("error[removed-field] machines.web.docker (isoloom.yml:12:5)\n")
250 );
251 let json = serde_json::to_value(&p).unwrap();
252 assert_eq!(json["code"], "removed-field");
253 assert_eq!((json["line"].as_u64(), json["column"].as_u64()), (Some(12), Some(5)));
254 assert_eq!(p.section(), "machines");
255 }
256
257 #[test]
258 fn a_typo_finds_the_name_it_meant() {
259 assert_eq!(closest("nested-virtualisatio", &["nested_virtualization"]), Some("nested_virtualization"));
261 assert_eq!((distance("a", "b"), distance("on", "no"), distance("flaw", "lawn")), (1, 2, 2));
262 assert_eq!(closest("netwrks", &["networks", "ports"]), Some("networks"));
263 assert_eq!(closest("dc1", &["dc01", "ws01"]), Some("dc01"));
264 assert_eq!(closest("zzz", &["networks"]), None);
265 assert_eq!(distance("kitten", "sitting"), 3);
266 assert_eq!((distance("", "abc"), distance("abc", "")), (3, 3));
267 }
268
269 #[test]
270 fn paths_nest_and_contain() {
271 let web = FieldPath::new("machines").key("web");
272 let file = web.key("files").index(0);
273 assert_eq!(file.as_str(), "machines.web.files[0]");
274 assert!(file.within(&web) && !web.within(&file) && !FieldPath::new("machines.webs").within(&web));
275 assert_eq!(file.parent(), Some(web.key("files")));
276 }
277}