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/// The old names `yaml` uses. Text without the word skips the second parse,
58/// and text that does not parse is left to the schema's own error.
59///
60/// # Errors
61/// [`PolicyError::OldAndNewName`] when the document has both `ask:` and `review:`.
62pub(super) fn find(yaml: &str) -> Result<OldNames, PolicyError> {
63    if !yaml.contains(OLD_REVIEW) {
64        return Ok(OldNames::default());
65    }
66    let Ok(probe) = crate::yaml::from_str::<Probe>(yaml) else {
67        return Ok(OldNames::default());
68    };
69    if probe.ask.is_some() && probe.review.is_some() {
70        return Err(PolicyError::OldAndNewName {
71            old: OLD_REVIEW,
72            new: "review",
73        });
74    }
75    let ask_default = match probe.defaults {
76        Some(ProbeDefaults::All(v)) => v == OLD_REVIEW,
77        Some(ProbeDefaults::PerKind(map)) => map.values().any(|v| v == OLD_REVIEW),
78        Some(ProbeDefaults::Other(_)) | None => false,
79    };
80    Ok(OldNames {
81        ask_list: probe.ask.is_some(),
82        ask_default,
83    })
84}
85
86#[cfg(test)]
87mod tests {
88    use super::OldNames;
89    use crate::lint::warnings;
90    use crate::{Defaults, Policy, PolicyError, RepoPolicy, Verdict};
91
92    const OLD: &str = "version: 1\ndefaults: { '*': ask, net: deny }\n\
93                       ask:\n  - id: publish\n    shell: ['cargo publish']\n";
94
95    #[test]
96    fn a_policy_with_the_old_names_loads_as_review_and_lint_says_so() {
97        let p = Policy::parse(OLD).unwrap();
98        assert_eq!(p.review.len(), 1);
99        assert_eq!(p.review[0].id, "publish");
100        let Defaults::PerKind(map) = &p.defaults else {
101            panic!("{:?}", p.defaults)
102        };
103        assert_eq!(map["*"], Verdict::Review);
104        assert_eq!(
105            p.old_names,
106            OldNames {
107                ask_list: true,
108                ask_default: true
109            }
110        );
111        let w = warnings(&p);
112        let rules: Vec<&str> = w.iter().map(|w| w.rule.as_str()).collect();
113        assert_eq!(rules, ["ask", "defaults"], "{w:?}");
114        assert!(
115            w.iter()
116                .all(|w| w.message.contains("openmoat policy upgrade")),
117            "{w:?}"
118        );
119    }
120
121    #[test]
122    fn the_new_names_and_a_word_ask_elsewhere_warn_of_nothing() {
123        let new = OLD.replace("ask", "review");
124        let p = Policy::parse(&new).unwrap();
125        assert!(!p.old_names.any());
126        assert!(warnings(&p).is_empty(), "{:?}", warnings(&p));
127        // `ask` in a comment, a pattern or a reason is not the old name.
128        let other = "version: 1\n# ask me\ndefaults: deny\nreview:\n  - id: ask\n    \
129                     reason: ask first\n    shell: ['ask *']\n";
130        assert!(!Policy::parse(other).unwrap().old_names.any());
131        let one = Policy::parse("version: 1\ndefaults: ask\n").unwrap();
132        assert_eq!(one.defaults, Defaults::All(Verdict::Review));
133        assert!(one.old_names.ask_default && !one.old_names.ask_list);
134    }
135
136    #[test]
137    fn a_policy_with_both_ask_and_review_is_refused() {
138        let both = format!("{OLD}review:\n  - id: push\n    shell: ['git push']\n");
139        let err = Policy::parse(&both).unwrap_err();
140        assert!(matches!(err, PolicyError::OldAndNewName { .. }), "{err:?}");
141        let message = err.to_string();
142        assert!(message.contains("both `ask:` and `review:`"), "{message}");
143        // An empty list counts too: which one was meant cannot be told.
144        assert!(Policy::parse("version: 1\nask: []\nreview: []\n").is_err());
145        assert!(RepoPolicy::parse(&both).is_err());
146    }
147
148    #[test]
149    fn a_repository_policy_reads_the_old_list_name() {
150        let repo =
151            RepoPolicy::parse("version: 1\nask:\n  - id: rel\n    shell: ['make release']\n")
152                .unwrap();
153        assert_eq!(repo.review[0].id, "rel");
154    }
155}