use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use crate::render_path;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Strategy {
Append,
Replace,
Fail,
}
impl fmt::Display for Strategy {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Append => "append",
Self::Replace => "replace",
Self::Fail => "fail",
})
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Rules {
strategy: Option<Strategy>,
children: BTreeMap<String, Rules>,
}
impl Rules {
pub const EMPTY: Self = Self {
strategy: None,
children: BTreeMap::new(),
};
pub fn build(
rules: impl IntoIterator<Item = (Vec<String>, Strategy)>,
) -> Result<Self, RuleErrors> {
let mut by_path: BTreeMap<Vec<String>, BTreeSet<Strategy>> = BTreeMap::new();
for (path, strategy) in rules {
by_path.entry(path).or_default().insert(strategy);
}
let mut errors = Vec::new();
for (path, strategies) in &by_path {
if strategies.len() > 1 {
errors.push(RuleError::Conflict {
path: path.clone(),
strategies: strategies.clone(),
});
}
if let Some((blocked_by, blocker)) = blocking_prefix(&by_path, path) {
errors.push(RuleError::Unreachable {
path: path.clone(),
blocked_by,
blocker,
});
}
}
if !errors.is_empty() {
errors.sort_by(|a, b| a.sort_key().cmp(&b.sort_key()));
return Err(RuleErrors(errors));
}
let mut root = Self::default();
for (path, strategies) in by_path {
let strategy = strategies
.into_iter()
.next()
.expect("a path in the map has at least one strategy");
root.insert(path, strategy);
}
Ok(root)
}
fn insert(&mut self, path: Vec<String>, strategy: Strategy) {
let mut node = self;
for segment in path {
node = node.children.entry(segment).or_default();
}
node.strategy = Some(strategy);
}
pub(crate) fn child(&self, key: &str) -> Option<&Self> {
self.children.get(key)
}
pub(crate) fn children(&self) -> impl Iterator<Item = (&str, &Self)> {
self.children.iter().map(|(k, v)| (k.as_str(), v))
}
pub(crate) fn strategy(&self) -> Option<Strategy> {
self.strategy
}
}
fn blocking_prefix(
by_path: &BTreeMap<Vec<String>, BTreeSet<Strategy>>,
path: &[String],
) -> Option<(Vec<String>, Strategy)> {
(0..path.len()).find_map(|depth| {
let prefix = &path[..depth];
let blocker = *by_path.get(prefix)?.iter().next()?;
Some((prefix.to_vec(), blocker))
})
}
fn render_strategies(strategies: &BTreeSet<Strategy>) -> String {
let quoted: Vec<String> = strategies.iter().map(|s| format!("`{s}`")).collect();
match quoted.split_last() {
Some((last, [])) => last.clone(),
Some((last, rest)) => format!("{} and {last}", rest.join(", ")),
None => String::new(),
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum RuleError {
#[error(
"conflicting strategies at `{}`: {}",
render_path(path),
render_strategies(strategies)
)]
Conflict {
path: Vec<String>,
strategies: BTreeSet<Strategy>,
},
#[error(
"rule at `{}` can never fire: `{}` is `{blocker}`, which does not recurse",
render_path(path),
render_path(blocked_by)
)]
Unreachable {
path: Vec<String>,
blocked_by: Vec<String>,
blocker: Strategy,
},
}
impl RuleError {
pub fn path(&self) -> &[String] {
match self {
Self::Conflict { path, .. } | Self::Unreachable { path, .. } => path,
}
}
fn sort_key(&self) -> (&[String], u8) {
match self {
Self::Conflict { path, .. } => (path, 0),
Self::Unreachable { path, .. } => (path, 1),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuleErrors(Vec<RuleError>);
impl RuleErrors {
pub fn errors(&self) -> &[RuleError] {
&self.0
}
}
impl std::error::Error for RuleErrors {}
impl fmt::Display for RuleErrors {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
for (i, error) in self.0.iter().enumerate() {
if i > 0 {
writeln!(f)?;
}
write!(f, "{error}")?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn path(dotted: &str) -> Vec<String> {
dotted.split('.').map(str::to_string).collect()
}
fn build(rules: &[(&str, Strategy)]) -> Result<Rules, RuleErrors> {
Rules::build(rules.iter().map(|(p, s)| (path(p), *s)))
}
fn errors(rules: &[(&str, Strategy)]) -> Vec<RuleError> {
build(rules).expect_err("rule set should be rejected").0
}
#[test]
fn a_rule_is_found_at_its_own_path_only() {
let rules = build(&[("a.b", Strategy::Append)]).expect("valid");
let a = rules.child("a").expect("a exists");
assert_eq!(a.strategy(), None);
assert_eq!(
a.child("b").expect("a.b exists").strategy(),
Some(Strategy::Append)
);
assert_eq!(a.child("c"), None);
assert_eq!(rules.child("b"), None);
}
#[test]
fn duplicate_paths_with_one_strategy_are_accepted() {
let rules = build(&[("db", Strategy::Replace), ("db", Strategy::Replace)]).expect("valid");
assert_eq!(
rules.child("db").expect("db exists").strategy(),
Some(Strategy::Replace)
);
}
#[test]
fn one_path_two_strategies_conflicts_in_both_orders() {
let forward = errors(&[("db", Strategy::Append), ("db", Strategy::Replace)]);
let backward = errors(&[("db", Strategy::Replace), ("db", Strategy::Append)]);
assert_eq!(forward, backward);
assert_eq!(
forward,
[RuleError::Conflict {
path: path("db"),
strategies: BTreeSet::from([Strategy::Append, Strategy::Replace]),
}]
);
}
#[test]
fn a_three_way_conflict_names_every_strategy() {
let found = build(&[
("x", Strategy::Fail),
("x", Strategy::Append),
("x", Strategy::Replace),
])
.expect_err("rejected");
assert_eq!(
found.to_string(),
"conflicting strategies at `x`: `append`, `replace` and `fail`"
);
}
#[test]
fn a_rule_under_a_terminal_rule_is_unreachable_in_both_orders() {
let forward = errors(&[("db", Strategy::Replace), ("db.plugins", Strategy::Append)]);
let backward = errors(&[("db.plugins", Strategy::Append), ("db", Strategy::Replace)]);
assert_eq!(forward, backward);
assert_eq!(
forward,
[RuleError::Unreachable {
path: path("db.plugins"),
blocked_by: path("db"),
blocker: Strategy::Replace,
}]
);
}
#[test]
fn a_sibling_of_a_terminal_rule_is_reachable() {
build(&[("a.b", Strategy::Replace), ("a.c", Strategy::Append)]).expect("valid");
}
#[test]
fn the_shallowest_terminal_rule_is_the_blocker() {
let found = errors(&[
("a", Strategy::Replace),
("a.b", Strategy::Fail),
("a.b.c", Strategy::Append),
]);
assert_eq!(
found,
[
RuleError::Unreachable {
path: path("a.b"),
blocked_by: path("a"),
blocker: Strategy::Replace,
},
RuleError::Unreachable {
path: path("a.b.c"),
blocked_by: path("a"),
blocker: Strategy::Replace,
},
]
);
}
#[test]
fn multiple_errors_are_reported_sorted_and_order_independently() {
let rules = [
("z.deep", Strategy::Append),
("a", Strategy::Fail),
("z", Strategy::Replace),
("a", Strategy::Append),
];
let mut reversed = rules;
reversed.reverse();
let found = build(&rules).expect_err("rejected");
assert_eq!(found, build(&reversed).expect_err("rejected"));
assert_eq!(
found.to_string(),
"conflicting strategies at `a`: `append` and `fail`\n\
rule at `z.deep` can never fire: `z` is `replace`, which does not recurse"
);
}
#[test]
fn a_root_rule_blocks_everything_below_it() {
let found = Rules::build([(vec![], Strategy::Replace), (path("a"), Strategy::Append)])
.expect_err("rejected");
assert_eq!(
found.to_string(),
"rule at `a` can never fire: `<root>` is `replace`, which does not recurse"
);
}
}