Skip to main content

knf_core/
rules.rs

1//! Per-path merge strategies.
2//!
3//! A [`Rules`] set narrows alongside the merge's own descent: one lookup per
4//! level, and `None` short-circuits an entire subtree, so a document with three
5//! rules pays almost nothing.
6//!
7//! The map is a [`BTreeMap`] and the contrast with [`Map`](crate::Map) is the
8//! point. The document map is an `IndexMap` because input order is meaningful;
9//! the rule map is a `BTreeMap` because rule order must be *meaningless*. The
10//! whole set is validated by [`Rules::build`] in one pass rather than rule by
11//! rule, so the same rules always produce the same result — and the same errors,
12//! in the same order — whatever order they arrived in.
13
14use std::collections::{BTreeMap, BTreeSet};
15use std::fmt;
16
17use crate::render_path;
18
19/// What to do where a layer supplies a value for a path that already has one.
20///
21/// Every strategy is *terminal*: it consumes the overlay whole and never
22/// recurses, so no rule below one can ever fire. The default merge is the
23/// absence of a rule, not a variant here.
24///
25/// The order of the variants is the tie-break used when reporting conflicts, so
26/// that the message does not depend on the order rules were given in.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
28pub enum Strategy {
29    /// Concatenate base ++ overlay. Both sides must be arrays.
30    Append,
31    /// Assign wholesale, no recursion, even object over object.
32    Replace,
33    /// Error. The first layer to define the path pins it.
34    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/// A validated set of per-path strategies, shaped as a trie.
48///
49/// Exact paths only: two rules can meet at one node at most once, so "most
50/// specific wins" never has to arbitrate. The trie shape is what makes adding
51/// globs later a change to lookup rather than to the data.
52#[derive(Debug, Clone, Default, PartialEq, Eq)]
53pub struct Rules {
54    strategy: Option<Strategy>,
55    children: BTreeMap<String, Rules>,
56}
57
58impl Rules {
59    /// No rules at all: the default merge everywhere.
60    pub const EMPTY: Self = Self {
61        strategy: None,
62        children: BTreeMap::new(),
63    };
64
65    /// Validates a whole rule set at once and builds the trie.
66    ///
67    /// Validating the finished set rather than each insertion is what makes
68    /// order-independence structural rather than a property to be maintained:
69    /// unreachability can be created from either direction (`db` then
70    /// `db.plugins`, or the reverse) and both are caught without an insert-time
71    /// symmetry argument.
72    ///
73    /// A duplicate path with the *same* strategy is accepted — scripts
74    /// accumulate flags. Only differing strategies conflict.
75    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    /// The subtree of rules under `key`, or `None` if nothing is nested there.
124    pub(crate) fn child(&self, key: &str) -> Option<&Self> {
125        self.children.get(key)
126    }
127
128    /// Every direct child, keyed by segment, in the same order the trie
129    /// itself is ordered — so a caller walking multiple children picks a
130    /// deterministic one regardless of rule arrival order.
131    pub(crate) fn children(&self) -> impl Iterator<Item = (&str, &Self)> {
132        self.children.iter().map(|(k, v)| (k.as_str(), v))
133    }
134
135    /// The strategy at this node, if a rule names it exactly.
136    pub(crate) fn strategy(&self) -> Option<Strategy> {
137        self.strategy
138    }
139}
140
141/// The shallowest rule strictly above `path`, if any.
142///
143/// Every strategy is terminal, so any ancestor rule blocks. Shallowest rather
144/// than nearest because that is the rule that actually stops the walk first;
145/// with `--replace a --fail a.b`, `a.b.c` is blocked by `a`. A conflicted
146/// ancestor reports its lowest strategy — its conflict is a separate error.
147fn 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
158/// Renders a strategy set as a backticked list, `a`, `b` and `c` — quoted to
159/// match the `blocker` in [`RuleError::Unreachable`].
160fn 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/// Why a rule set was rejected.
170///
171/// Carries key paths and strategy names and nothing else — no flag names. The
172/// caller knows what it called its flags.
173#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
174pub enum RuleError {
175    /// One path, more than one strategy. The whole set is carried, and reported
176    /// in [`Strategy`] order rather than the order the rules arrived in: a user
177    /// given only two of three offending flags cannot tell how many to drop.
178    #[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    /// A rule beneath another rule. Every strategy is terminal, so it could
188    /// never fire.
189    #[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    /// The path the rule set was rejected at.
203    pub fn path(&self) -> &[String] {
204        match self {
205            Self::Conflict { path, .. } | Self::Unreachable { path, .. } => path,
206        }
207    }
208
209    /// Sorted by path, then by kind, so a set's errors do not depend on the
210    /// order its rules were given in.
211    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/// Every problem with a rule set, sorted.
220///
221/// All of them rather than the first: a rule set is given up front, so there is
222/// no reason to make the user rediscover it one flag at a time.
223#[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    /// Conflict detected from either direction, reporting the same set in the
285    /// same order both times.
286    #[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    /// The whole set, not a pair: a message naming two of three flags leaves the
301    /// user to rediscover the third on the next run.
302    #[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    /// The case from the plan: `--replace db --append db.plugins`, and the same
317    /// set written the other way round.
318    #[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    /// A sibling is not below anything; only strict prefixes block.
334    #[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    /// The blocker is the outermost terminal rule, because that is the one the
340    /// walk hits first.
341    #[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    /// Every offender, sorted by path, identical whatever order the flags came in.
366    #[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    /// The root is spelled as the empty path, like every other error in the crate.
387    #[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}