Skip to main content

openmoat_core/policy/
old_names.rs

1//! `ask`, the name the `review` verdict and rule list had before ADR-053.
2//!
3//! A policy written before then still loads and means the same: `ask:` is
4//! read as `review:` and a verdict `ask` as `review` (a serde alias on each).
5//! What this module adds is knowing that the old name was used, so `policy
6//! lint` can say so and `policy upgrade` can rename it, and refusing a file
7//! that has both `ask:` and `review:`, which no reading could make mean one
8//! thing without guessing.
9
10use std::collections::BTreeMap;
11
12use serde::Deserialize;
13use serde::de::IgnoredAny;
14
15use super::PolicyError;
16use crate::verdict::OLD_REVIEW;
17
18/// Where a policy still uses `ask`, the old name of `review`.
19#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
20#[non_exhaustive]
21pub struct OldNames {
22    /// The rule list is `ask:` rather than `review:`.
23    pub ask_list: bool,
24    /// `defaults` names the verdict `ask`.
25    pub ask_default: bool,
26}
27
28impl OldNames {
29    /// Whether the policy uses an old name anywhere.
30    #[must_use]
31    pub fn any(self) -> bool {
32        self.ask_list || self.ask_default
33    }
34}
35
36/// Just the keys the old names live in; every other key is ignored, so this
37/// reads any document [`super::Policy`] or [`crate::RepoPolicy`] reads.
38#[derive(Deserialize)]
39struct Probe {
40    #[serde(default)]
41    ask: Option<IgnoredAny>,
42    #[serde(default)]
43    review: Option<IgnoredAny>,
44    #[serde(default)]
45    defaults: Option<ProbeDefaults>,
46}
47
48#[derive(Deserialize)]
49#[serde(untagged)]
50enum ProbeDefaults {
51    All(String),
52    PerKind(BTreeMap<String, String>),
53    // Anything else is the schema's to reject, with its own message.
54    Other(IgnoredAny),
55}
56
57/// Whether `yaml` holds `ask` as a word of its own, as the old key and the old
58/// verdict both are, whatever quotes, colon or braces surround them. `asks`,
59/// `masking` or `task` do not count: the shipped default has those (in
60/// comments) and no word `ask`, so loading it skips the second parse. A comment
61/// or a reason with the word only costs that parse; it changes no result.
62fn has_word(yaml: &str) -> bool {
63    let is_word = |c: char| c.is_alphanumeric() || c == '_';
64    yaml.match_indices(OLD_REVIEW).any(|(at, word)| {
65        let before = yaml.get(..at).and_then(|s| s.chars().next_back());
66        let after = yaml.get(at + word.len()..).and_then(|s| s.chars().next());
67        !before.is_some_and(is_word) && !after.is_some_and(is_word)
68    })
69}
70
71/// The old names `yaml` uses. Text without the word skips the second parse,
72/// and text that does not parse is left to the schema's own error.
73///
74/// # Errors
75/// [`PolicyError::OldAndNewName`] when the document has both `ask:` and `review:`.
76pub(super) fn find(yaml: &str) -> Result<OldNames, PolicyError> {
77    if !has_word(yaml) {
78        return Ok(OldNames::default());
79    }
80    let Ok(probe) = crate::yaml::from_str::<Probe>(yaml) else {
81        return Ok(OldNames::default());
82    };
83    if probe.ask.is_some() && probe.review.is_some() {
84        return Err(PolicyError::OldAndNewName {
85            old: OLD_REVIEW,
86            new: "review",
87        });
88    }
89    let ask_default = match probe.defaults {
90        Some(ProbeDefaults::All(v)) => v == OLD_REVIEW,
91        Some(ProbeDefaults::PerKind(map)) => map.values().any(|v| v == OLD_REVIEW),
92        Some(ProbeDefaults::Other(_)) | None => false,
93    };
94    Ok(OldNames {
95        ask_list: probe.ask.is_some(),
96        ask_default,
97    })
98}
99
100#[cfg(test)]
101mod tests {
102    use super::OldNames;
103    use crate::lint::warnings;
104    use crate::{Defaults, Policy, PolicyError, RepoPolicy, Verdict};
105
106    const OLD: &str = "version: 1\ndefaults: { '*': ask, net: deny }\n\
107                       ask:\n  - id: publish\n    shell: ['cargo publish']\n";
108
109    #[test]
110    fn a_policy_with_the_old_names_loads_as_review_and_lint_says_so() {
111        let p = Policy::parse(OLD).unwrap();
112        assert_eq!(p.review.len(), 1);
113        assert_eq!(p.review[0].id, "publish");
114        let Defaults::PerKind(map) = &p.defaults else {
115            panic!("{:?}", p.defaults)
116        };
117        assert_eq!(map["*"], Verdict::Review);
118        assert_eq!(
119            p.old_names,
120            OldNames {
121                ask_list: true,
122                ask_default: true
123            }
124        );
125        let w = warnings(&p);
126        let rules: Vec<&str> = w.iter().map(|w| w.rule.as_str()).collect();
127        assert_eq!(rules, ["ask", "defaults"], "{w:?}");
128        assert!(
129            w.iter()
130                .all(|w| w.message.contains("openmoat policy upgrade")),
131            "{w:?}"
132        );
133    }
134
135    #[test]
136    fn the_new_names_and_a_word_ask_elsewhere_warn_of_nothing() {
137        let new = OLD.replace("ask", "review");
138        let p = Policy::parse(&new).unwrap();
139        assert!(!p.old_names.any());
140        assert!(warnings(&p).is_empty(), "{:?}", warnings(&p));
141        // `ask` in a comment, a pattern or a reason is not the old name.
142        let other = "version: 1\n# ask me\ndefaults: deny\nreview:\n  - id: ask\n    \
143                     reason: ask first\n    shell: ['ask *']\n";
144        assert!(!Policy::parse(other).unwrap().old_names.any());
145        let one = Policy::parse("version: 1\ndefaults: ask\n").unwrap();
146        assert_eq!(one.defaults, Defaults::All(Verdict::Review));
147        assert!(one.old_names.ask_default && !one.old_names.ask_list);
148    }
149
150    #[test]
151    fn only_a_word_ask_takes_the_second_parse() {
152        use super::has_word;
153        // The shipped default has `asks` and `masking`, never the word: loading
154        // it, the most common parse there is, reads the YAML once.
155        assert!(!has_word(crate::DEFAULT_POLICY));
156        for text in [
157            "# asks first",
158            "masking: x",
159            "task: y",
160            "ask_me: z",
161            "basket",
162        ] {
163            assert!(!has_word(text), "{text}");
164        }
165        for text in [
166            "ask:\n",
167            "defaults: ask",
168            "defaults: { '*': ask, net: deny }",
169            "{\"ask\": []}",
170            "defaults: {net: ask}",
171            "ask",
172            "x: \u{e9}ask",
173        ] {
174            assert_eq!(has_word(text), !text.contains('\u{e9}'), "{text}");
175        }
176        // Every spelling the probe reads is still found through `find`.
177        for text in [
178            "version: 1\n\"ask\": []\n",
179            "version: 1\ndefaults: {net: 'ask'}\n",
180        ] {
181            assert!(super::find(text).unwrap().any(), "{text}");
182        }
183    }
184
185    #[test]
186    fn a_policy_with_both_ask_and_review_is_refused() {
187        let both = format!("{OLD}review:\n  - id: push\n    shell: ['git push']\n");
188        let err = Policy::parse(&both).unwrap_err();
189        assert!(matches!(err, PolicyError::OldAndNewName { .. }), "{err:?}");
190        let message = err.to_string();
191        assert!(message.contains("both `ask:` and `review:`"), "{message}");
192        // An empty list counts too: which one was meant cannot be told.
193        assert!(Policy::parse("version: 1\nask: []\nreview: []\n").is_err());
194        assert!(RepoPolicy::parse(&both).is_err());
195    }
196
197    #[test]
198    fn a_repository_policy_reads_the_old_list_name() {
199        let repo =
200            RepoPolicy::parse("version: 1\nask:\n  - id: rel\n    shell: ['make release']\n")
201                .unwrap();
202        assert_eq!(repo.review[0].id, "rel");
203    }
204}