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}