openmoat_core/policy/
old_names.rs1use std::collections::BTreeMap;
11
12use serde::Deserialize;
13use serde::de::IgnoredAny;
14
15use super::PolicyError;
16use crate::verdict::OLD_REVIEW;
17
18#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
20#[non_exhaustive]
21pub struct OldNames {
22 pub ask_list: bool,
24 pub ask_default: bool,
26}
27
28impl OldNames {
29 #[must_use]
31 pub fn any(self) -> bool {
32 self.ask_list || self.ask_default
33 }
34}
35
36#[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 Other(IgnoredAny),
55}
56
57fn 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
71pub(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 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 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 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 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}