Skip to main content

pamoja_profile/
format.rs

1//! The format a manifest or a rule file is written in, and the wording of what the parser
2//! refuses in one.
3
4/// The version of the profile manifest and rule file formats this build reads and writes.
5pub const FORMAT: u32 = 1;
6
7/// Checks the `$schema` a file names against the format this build reads.
8///
9/// A file may leave `$schema` out, name the published schema, or name a copy of it by the
10/// same file name, such as `./profile-1.json` for an editor working offline.
11///
12/// # Arguments
13///
14/// * `schema` - the `$schema` the file names, if any.
15/// * `file` - the kind of file, `"profile"` or `"rules"`, which is the schema's file stem.
16/// * `published` - the address of the schema this build writes.
17///
18/// # Returns
19///
20/// Nothing when the file is written in the format this build reads.
21///
22/// # Errors
23///
24/// The reason, naming the format the file is written in or the schema it names.
25pub(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/// Rewrites a parse error so a misspelled field or value names the one it was probably
51/// meant to be.
52///
53/// # Arguments
54///
55/// * `error` - the error the JSON parser returned.
56///
57/// # Returns
58///
59/// The parser's own message, with the nearest known name added when a field or a value
60/// was not one the file allows and one of the allowed names is a letter or two away.
61#[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
84/// Picks the allowed name a given one is most likely a misspelling of.
85///
86/// # Arguments
87///
88/// * `given` - the name the file used.
89/// * `allowed` - the names the file may use there.
90///
91/// # Returns
92///
93/// The closest allowed name, or `None` when none is within two edits and closer than half
94/// the given name's length.
95pub(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
108/// Counts the single-character insertions, deletions, and substitutions between two names.
109fn 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}