Skip to main content

rskit_config/strict/merge/
include_merge.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use rskit_codec::value::{ArrayStrategy, merge_with};
4use rskit_errors::{AppError, AppResult};
5use rskit_util::collections::ensure_unique_by;
6use serde_json::Value;
7
8use super::MergeIdentity;
9
10/// Identity-aware include-merge configuration.
11///
12/// Merges config documents with deterministic, schema-aware rules:
13///
14/// - tables merge recursively; on a scalar key collision the overlay value wins (last-wins scalars);
15/// - array-of-tables sections registered via [`IncludeMerge::with_identity`] are concatenated across documents
16///   and hard-error on duplicate identity;
17/// - map sections registered via [`IncludeMerge::with_unique_keys`] hard-error when the same map key is contributed by more than one document (a duplicate identity for sections keyed by name rather than by an array element);
18/// - any other array is replaced wholesale by the overlay.
19#[derive(Default)]
20pub struct IncludeMerge {
21    identity_sections: BTreeMap<String, Box<dyn MergeIdentity>>,
22    unique_key_sections: BTreeSet<String>,
23}
24
25impl std::fmt::Debug for IncludeMerge {
26    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27        f.debug_struct("IncludeMerge")
28            .field("identity_sections", &self.identity_sections.keys())
29            .field("unique_key_sections", &self.unique_key_sections)
30            .finish()
31    }
32}
33
34impl IncludeMerge {
35    /// Create an include-merge with no identity-keyed sections.
36    pub fn new() -> Self {
37        Self::default()
38    }
39
40    /// Register `section` as an identity-keyed array-of-tables.
41    ///
42    /// Such sections are concatenated across documents
43    /// and hard-error on a duplicate identity (see [`IdentityKey`](super::IdentityKey) / [`CompositeKey`](super::CompositeKey)).
44    #[must_use]
45    pub fn with_identity(
46        mut self,
47        section: impl Into<String>,
48        identity: impl MergeIdentity + 'static,
49    ) -> Self {
50        self.identity_sections
51            .insert(section.into(), Box::new(identity));
52        self
53    }
54
55    /// Register `section` as a map whose keys must be unique across documents.
56    ///
57    /// Use this for sections modelled as a table-of-tables keyed by name (for example `[groups.<name>]`):
58    /// a key contributed by two documents is a duplicate identity and a hard error,
59    /// rather than a silent recursive merge.
60    #[must_use]
61    pub fn with_unique_keys(mut self, section: impl Into<String>) -> Self {
62        self.unique_key_sections.insert(section.into());
63        self
64    }
65
66    /// Merge `overlay` onto `base`, returning the combined document.
67    ///
68    /// Delegates the value-tree mechanics to [`rskit_codec::value::merge_with`]: objects merge recursively,
69    /// scalars are last-wins,
70    /// and arrays under a registered identity section are concatenated (all others replaced).
71    /// Before merging,
72    /// any [`with_unique_keys`](Self::with_unique_keys) section is checked for keys present in both documents,
73    /// since the recursive merge would otherwise silently collapse the collision.
74    ///
75    /// # Errors
76    ///
77    /// Returns [`AppError`] when a unique-key section receives the same key from both documents.
78    pub fn merge(&self, base: Value, overlay: Value) -> AppResult<Value> {
79        if !self.unique_key_sections.is_empty() {
80            self.check_unique_keys(&base, &overlay)?;
81        }
82        let identity_sections = &self.identity_sections;
83        Ok(merge_with(base, overlay, |key| {
84            if identity_sections.contains_key(key) {
85                ArrayStrategy::Concat
86            } else {
87                ArrayStrategy::Replace
88            }
89        }))
90    }
91
92    /// Validate identity-keyed sections in a (possibly merged) document.
93    ///
94    /// Detects duplicate identities within every registered array section, covering both single-document
95    /// and merged-document cases.
96    pub(crate) fn validate(&self, value: &Value) -> AppResult<()> {
97        match value {
98            Value::Object(table) => {
99                for (key, child) in table {
100                    if let (Some(identity), Value::Array(elements)) =
101                        (self.identity_sections.get(key), child)
102                    {
103                        check_unique_identities(key, identity.as_ref(), elements)?;
104                    }
105                    self.validate(child)?;
106                }
107            }
108            // Recurse into array elements
109            // so identity-keyed sections nested inside a list (e.g. an object under an array) are still checked.
110            Value::Array(elements) => {
111                for element in elements {
112                    self.validate(element)?;
113                }
114            }
115            _ => {}
116        }
117        Ok(())
118    }
119
120    /// Reject keys contributed to a unique-key section by both documents.
121    ///
122    /// Walks `base` and `overlay` in lockstep: for each registered section that is a table in both,
123    /// an overlapping member key is a duplicate identity. Recurses through shared objects
124    /// so a nested registered section is still found.
125    fn check_unique_keys(&self, base: &Value, overlay: &Value) -> AppResult<()> {
126        let (Value::Object(base), Value::Object(overlay)) = (base, overlay) else {
127            return Ok(());
128        };
129        for (key, overlay_child) in overlay {
130            let Some(base_child) = base.get(key) else {
131                continue;
132            };
133            if self.unique_key_sections.contains(key)
134                && let (Value::Object(base_section), Value::Object(overlay_section)) =
135                    (base_child, overlay_child)
136            {
137                for member in overlay_section.keys() {
138                    if base_section.contains_key(member) {
139                        return Err(AppError::invalid_input(
140                            key,
141                            format!(
142                                "duplicate '{member}' in section '{key}' across merged documents"
143                            ),
144                        ));
145                    }
146                }
147            }
148            self.check_unique_keys(base_child, overlay_child)?;
149        }
150        Ok(())
151    }
152}
153
154fn check_unique_identities(
155    section: &str,
156    identity: &dyn MergeIdentity,
157    elements: &[Value],
158) -> AppResult<()> {
159    let identities = elements
160        .iter()
161        .filter_map(|element| identity.identity_of(element));
162    ensure_unique_by(identities, Clone::clone).map_err(|duplicate| {
163        AppError::invalid_input(
164            section,
165            format!(
166                "duplicate {} identity '{duplicate}' in section '{section}'",
167                identity.label()
168            ),
169        )
170    })
171}