1pub const FORMAT: u32 = 1;
6
7pub(crate) fn check_schema(
26 schema: Option<&str>,
27 file: &str,
28 published: &str,
29) -> Result<(), String> {
30 let Some(schema) = schema else {
31 return Ok(());
32 };
33 let name = schema.rsplit('/').next().unwrap_or(schema);
34 let version = name
35 .strip_prefix(file)
36 .and_then(|rest| rest.strip_prefix('-'))
37 .and_then(|rest| rest.strip_suffix(".json"))
38 .and_then(|digits| digits.parse::<u32>().ok());
39 match version {
40 Some(FORMAT) => Ok(()),
41 Some(other) => Err(format!(
42 "this file is written in {file} format {other}, and this build of pamoja reads format {FORMAT}"
43 )),
44 None => Err(format!(
45 "`$schema` is `{schema}`, which is not a pamoja {file} schema such as `{published}`"
46 )),
47 }
48}
49
50#[cfg(feature = "json")]
62pub(crate) fn explain(error: &serde_json::Error) -> String {
63 let text = error.to_string();
64 let Some((what, rest)) = ["unknown field `", "unknown variant `"]
65 .into_iter()
66 .find_map(|prefix| text.strip_prefix(prefix).map(|rest| (prefix, rest)))
67 else {
68 return text;
69 };
70 let Some((given, rest)) = rest.split_once('`') else {
71 return text;
72 };
73 let allowed = rest.split('`').skip(1).step_by(2);
74 let Some(nearest) = nearest(given, allowed) else {
75 return text;
76 };
77 let at = text
78 .rfind(" at line ")
79 .map(|index| &text[index..])
80 .unwrap_or_default();
81 format!("{what}{given}`, did you mean `{nearest}`?{at}")
82}
83
84pub(crate) fn nearest<'a>(
96 given: &str,
97 allowed: impl IntoIterator<Item = &'a str>,
98) -> Option<&'a str> {
99 let limit = (given.chars().count() / 2).clamp(1, 2);
100 allowed
101 .into_iter()
102 .map(|name| (distance(given, name), name))
103 .filter(|(edits, _)| *edits <= limit)
104 .min_by_key(|(edits, _)| *edits)
105 .map(|(_, name)| name)
106}
107
108fn distance(a: &str, b: &str) -> usize {
110 let b: Vec<char> = b.chars().collect();
111 let mut previous: Vec<usize> = (0..=b.len()).collect();
112 for (i, left) in a.chars().enumerate() {
113 let mut current = vec![i + 1];
114 for (j, right) in b.iter().enumerate() {
115 let substitute = previous[j] + usize::from(left != *right);
116 current.push(substitute.min(previous[j + 1] + 1).min(current[j] + 1));
117 }
118 previous = current;
119 }
120 previous[b.len()]
121}
122
123#[cfg(test)]
124mod tests {
125 use super::*;
126
127 const PUBLISHED: &str = "https://pamoja.molex.cloud/schema/profile-1.json";
128
129 #[test]
130 fn a_schema_names_the_format_the_file_is_written_in() {
131 assert_eq!(check_schema(None, "profile", PUBLISHED), Ok(()));
132 assert_eq!(check_schema(Some(PUBLISHED), "profile", PUBLISHED), Ok(()));
133 assert_eq!(
134 check_schema(Some("./profile-1.json"), "profile", PUBLISHED),
135 Ok(())
136 );
137 let newer = check_schema(
138 Some("https://pamoja.molex.cloud/schema/profile-2.json"),
139 "profile",
140 PUBLISHED,
141 )
142 .unwrap_err();
143 assert!(newer.contains("profile format 2"), "{newer}");
144 let other = check_schema(
145 Some("https://pamoja.molex.cloud/schema/rules-1.json"),
146 "profile",
147 PUBLISHED,
148 )
149 .unwrap_err();
150 assert!(other.contains("not a pamoja profile schema"), "{other}");
151 }
152
153 #[test]
154 fn a_misspelling_is_matched_to_the_name_it_was_meant_to_be() {
155 let fields = ["active_secs", "saver_secs", "saver_below", "hysteresis"];
156 assert_eq!(nearest("saver_bellow", fields), Some("saver_below"));
157 assert_eq!(nearest("hysterisis", fields), Some("hysteresis"));
158 assert_eq!(nearest("colour", fields), None);
159 assert_eq!(nearest("ab", ["ac", "xy"]), Some("ac"));
160 assert_eq!(distance("", "abc"), 3);
161 assert_eq!(distance("kitten", "sitting"), 3);
162 }
163
164 #[cfg(feature = "json")]
165 #[test]
166 fn a_parse_error_names_the_field_a_typo_was_meant_to_be() {
167 #[derive(Debug, serde::Deserialize)]
168 #[serde(deny_unknown_fields)]
169 #[allow(dead_code)]
170 struct Power {
171 saver_below: f32,
172 critical_below: f32,
173 }
174 let error = serde_json::from_str::<Power>(r#"{ "saver_bellow": 0.3 }"#).unwrap_err();
175 assert_eq!(
176 explain(&error),
177 "unknown field `saver_bellow`, did you mean `saver_below`? at line 1 column 16"
178 );
179 let error = serde_json::from_str::<Power>(r#"{ "colour": 1 }"#).unwrap_err();
180 assert!(explain(&error).starts_with("unknown field `colour`, expected"));
181 }
182}