1use std::collections::{BTreeMap, BTreeSet};
15use std::fmt;
16
17use crate::render_path;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
28pub enum Strategy {
29 Append,
31 Replace,
33 Fail,
35}
36
37impl fmt::Display for Strategy {
38 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
39 f.write_str(match self {
40 Self::Append => "append",
41 Self::Replace => "replace",
42 Self::Fail => "fail",
43 })
44 }
45}
46
47#[derive(Debug, Clone, Default, PartialEq, Eq)]
53pub struct Rules {
54 strategy: Option<Strategy>,
55 children: BTreeMap<String, Rules>,
56}
57
58impl Rules {
59 pub const EMPTY: Self = Self {
61 strategy: None,
62 children: BTreeMap::new(),
63 };
64
65 pub fn build(
76 rules: impl IntoIterator<Item = (Vec<String>, Strategy)>,
77 ) -> Result<Self, RuleErrors> {
78 let mut by_path: BTreeMap<Vec<String>, BTreeSet<Strategy>> = BTreeMap::new();
79 for (path, strategy) in rules {
80 by_path.entry(path).or_default().insert(strategy);
81 }
82
83 let mut errors = Vec::new();
84 for (path, strategies) in &by_path {
85 if strategies.len() > 1 {
86 errors.push(RuleError::Conflict {
87 path: path.clone(),
88 strategies: strategies.clone(),
89 });
90 }
91 if let Some((blocked_by, blocker)) = blocking_prefix(&by_path, path) {
92 errors.push(RuleError::Unreachable {
93 path: path.clone(),
94 blocked_by,
95 blocker,
96 });
97 }
98 }
99 if !errors.is_empty() {
100 errors.sort_by(|a, b| a.sort_key().cmp(&b.sort_key()));
101 return Err(RuleErrors(errors));
102 }
103
104 let mut root = Self::default();
105 for (path, strategies) in by_path {
106 let strategy = strategies
107 .into_iter()
108 .next()
109 .expect("a path in the map has at least one strategy");
110 root.insert(path, strategy);
111 }
112 Ok(root)
113 }
114
115 fn insert(&mut self, path: Vec<String>, strategy: Strategy) {
116 let mut node = self;
117 for segment in path {
118 node = node.children.entry(segment).or_default();
119 }
120 node.strategy = Some(strategy);
121 }
122
123 pub(crate) fn child(&self, key: &str) -> Option<&Self> {
125 self.children.get(key)
126 }
127
128 pub(crate) fn children(&self) -> impl Iterator<Item = (&str, &Self)> {
132 self.children.iter().map(|(k, v)| (k.as_str(), v))
133 }
134
135 pub(crate) fn strategy(&self) -> Option<Strategy> {
137 self.strategy
138 }
139}
140
141fn blocking_prefix(
148 by_path: &BTreeMap<Vec<String>, BTreeSet<Strategy>>,
149 path: &[String],
150) -> Option<(Vec<String>, Strategy)> {
151 (0..path.len()).find_map(|depth| {
152 let prefix = &path[..depth];
153 let blocker = *by_path.get(prefix)?.iter().next()?;
154 Some((prefix.to_vec(), blocker))
155 })
156}
157
158fn render_strategies(strategies: &BTreeSet<Strategy>) -> String {
161 let quoted: Vec<String> = strategies.iter().map(|s| format!("`{s}`")).collect();
162 match quoted.split_last() {
163 Some((last, [])) => last.clone(),
164 Some((last, rest)) => format!("{} and {last}", rest.join(", ")),
165 None => String::new(),
166 }
167}
168
169#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
174pub enum RuleError {
175 #[error(
179 "conflicting strategies at `{}`: {}",
180 render_path(path),
181 render_strategies(strategies)
182 )]
183 Conflict {
184 path: Vec<String>,
185 strategies: BTreeSet<Strategy>,
186 },
187 #[error(
190 "rule at `{}` can never fire: `{}` is `{blocker}`, which does not recurse",
191 render_path(path),
192 render_path(blocked_by)
193 )]
194 Unreachable {
195 path: Vec<String>,
196 blocked_by: Vec<String>,
197 blocker: Strategy,
198 },
199}
200
201impl RuleError {
202 pub fn path(&self) -> &[String] {
204 match self {
205 Self::Conflict { path, .. } | Self::Unreachable { path, .. } => path,
206 }
207 }
208
209 fn sort_key(&self) -> (&[String], u8) {
212 match self {
213 Self::Conflict { path, .. } => (path, 0),
214 Self::Unreachable { path, .. } => (path, 1),
215 }
216 }
217}
218
219#[derive(Debug, Clone, PartialEq, Eq)]
224pub struct RuleErrors(Vec<RuleError>);
225
226impl RuleErrors {
227 pub fn errors(&self) -> &[RuleError] {
228 &self.0
229 }
230}
231
232impl std::error::Error for RuleErrors {}
233
234impl fmt::Display for RuleErrors {
235 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
236 for (i, error) in self.0.iter().enumerate() {
237 if i > 0 {
238 writeln!(f)?;
239 }
240 write!(f, "{error}")?;
241 }
242 Ok(())
243 }
244}
245
246#[cfg(test)]
247mod tests {
248 use super::*;
249
250 fn path(dotted: &str) -> Vec<String> {
251 dotted.split('.').map(str::to_string).collect()
252 }
253
254 fn build(rules: &[(&str, Strategy)]) -> Result<Rules, RuleErrors> {
255 Rules::build(rules.iter().map(|(p, s)| (path(p), *s)))
256 }
257
258 fn errors(rules: &[(&str, Strategy)]) -> Vec<RuleError> {
259 build(rules).expect_err("rule set should be rejected").0
260 }
261
262 #[test]
263 fn a_rule_is_found_at_its_own_path_only() {
264 let rules = build(&[("a.b", Strategy::Append)]).expect("valid");
265 let a = rules.child("a").expect("a exists");
266 assert_eq!(a.strategy(), None);
267 assert_eq!(
268 a.child("b").expect("a.b exists").strategy(),
269 Some(Strategy::Append)
270 );
271 assert_eq!(a.child("c"), None);
272 assert_eq!(rules.child("b"), None);
273 }
274
275 #[test]
276 fn duplicate_paths_with_one_strategy_are_accepted() {
277 let rules = build(&[("db", Strategy::Replace), ("db", Strategy::Replace)]).expect("valid");
278 assert_eq!(
279 rules.child("db").expect("db exists").strategy(),
280 Some(Strategy::Replace)
281 );
282 }
283
284 #[test]
287 fn one_path_two_strategies_conflicts_in_both_orders() {
288 let forward = errors(&[("db", Strategy::Append), ("db", Strategy::Replace)]);
289 let backward = errors(&[("db", Strategy::Replace), ("db", Strategy::Append)]);
290 assert_eq!(forward, backward);
291 assert_eq!(
292 forward,
293 [RuleError::Conflict {
294 path: path("db"),
295 strategies: BTreeSet::from([Strategy::Append, Strategy::Replace]),
296 }]
297 );
298 }
299
300 #[test]
303 fn a_three_way_conflict_names_every_strategy() {
304 let found = build(&[
305 ("x", Strategy::Fail),
306 ("x", Strategy::Append),
307 ("x", Strategy::Replace),
308 ])
309 .expect_err("rejected");
310 assert_eq!(
311 found.to_string(),
312 "conflicting strategies at `x`: `append`, `replace` and `fail`"
313 );
314 }
315
316 #[test]
319 fn a_rule_under_a_terminal_rule_is_unreachable_in_both_orders() {
320 let forward = errors(&[("db", Strategy::Replace), ("db.plugins", Strategy::Append)]);
321 let backward = errors(&[("db.plugins", Strategy::Append), ("db", Strategy::Replace)]);
322 assert_eq!(forward, backward);
323 assert_eq!(
324 forward,
325 [RuleError::Unreachable {
326 path: path("db.plugins"),
327 blocked_by: path("db"),
328 blocker: Strategy::Replace,
329 }]
330 );
331 }
332
333 #[test]
335 fn a_sibling_of_a_terminal_rule_is_reachable() {
336 build(&[("a.b", Strategy::Replace), ("a.c", Strategy::Append)]).expect("valid");
337 }
338
339 #[test]
342 fn the_shallowest_terminal_rule_is_the_blocker() {
343 let found = errors(&[
344 ("a", Strategy::Replace),
345 ("a.b", Strategy::Fail),
346 ("a.b.c", Strategy::Append),
347 ]);
348 assert_eq!(
349 found,
350 [
351 RuleError::Unreachable {
352 path: path("a.b"),
353 blocked_by: path("a"),
354 blocker: Strategy::Replace,
355 },
356 RuleError::Unreachable {
357 path: path("a.b.c"),
358 blocked_by: path("a"),
359 blocker: Strategy::Replace,
360 },
361 ]
362 );
363 }
364
365 #[test]
367 fn multiple_errors_are_reported_sorted_and_order_independently() {
368 let rules = [
369 ("z.deep", Strategy::Append),
370 ("a", Strategy::Fail),
371 ("z", Strategy::Replace),
372 ("a", Strategy::Append),
373 ];
374 let mut reversed = rules;
375 reversed.reverse();
376
377 let found = build(&rules).expect_err("rejected");
378 assert_eq!(found, build(&reversed).expect_err("rejected"));
379 assert_eq!(
380 found.to_string(),
381 "conflicting strategies at `a`: `append` and `fail`\n\
382 rule at `z.deep` can never fire: `z` is `replace`, which does not recurse"
383 );
384 }
385
386 #[test]
388 fn a_root_rule_blocks_everything_below_it() {
389 let found = Rules::build([(vec![], Strategy::Replace), (path("a"), Strategy::Append)])
390 .expect_err("rejected");
391 assert_eq!(
392 found.to_string(),
393 "rule at `a` can never fire: `<root>` is `replace`, which does not recurse"
394 );
395 }
396}