Skip to main content

rskit_config/strict/merge/
identity.rs

1//! Identity rules for array-of-tables sections during include-merge.
2//!
3//! A [`MergeIdentity`] extracts a stable identity token from one array element
4//! so the merge layer can concatenate registered sections across documents
5//! and reject duplicate identities. [`IdentityKey`] covers the common "single named field" case;
6//! [`CompositeKey`] covers multi-field
7//! and nested identities (for example a cross-reference keyed by `{ from.ecosystem, from.module, to.ecosystem, to.module }`).
8
9use serde_json::Value;
10
11/// Identity rule for an array-of-tables section during include-merge.
12///
13/// Sections registered with an identity are concatenated across documents rather than overwritten,
14/// and duplicate identities are a hard error. Implement this trait for custom identity logic,
15/// or use [`IdentityKey`] / [`CompositeKey`].
16pub trait MergeIdentity: Send + Sync {
17    /// Human-facing label for the identity, used in duplicate-error messages.
18    fn label(&self) -> &str;
19
20    /// Extract the identity token of `element`,
21    /// or `None` when the element lacks a complete identity (a missing/non-scalar field is a schema error surfaced by the typed deserialize step, so the merge layer simply skips it here).
22    fn identity_of(&self, element: &Value) -> Option<String>;
23}
24
25/// A simple [`MergeIdentity`] naming a single identity field directly.
26#[derive(Debug, Clone)]
27pub struct IdentityKey(String);
28
29impl IdentityKey {
30    /// Identify array elements by the value of `field`.
31    pub fn new(field: impl Into<String>) -> Self {
32        Self(field.into())
33    }
34}
35
36impl MergeIdentity for IdentityKey {
37    fn label(&self) -> &str {
38        &self.0
39    }
40
41    fn identity_of(&self, element: &Value) -> Option<String> {
42        scalar_token(element.get(&self.0)?)
43    }
44}
45
46/// A [`MergeIdentity`] over several fields, each addressed by a dotted path.
47///
48/// Use this when no single field identifies an element —
49/// for example an edge identified by both of its structured endpoints.
50/// Each field is a `.`-separated path walked through nested objects (`"from.ecosystem"` reads `element["from"]["ecosystem"]`);
51/// the resulting scalar tokens are combined into one injective composite identity.
52/// An element missing any field has no identity and is skipped (the typed schema step reports it);
53/// likewise a key built from an empty field list has no identity and never reports duplicates.
54#[derive(Debug, Clone)]
55pub struct CompositeKey {
56    fields: Vec<Vec<String>>,
57    label: String,
58}
59
60impl CompositeKey {
61    /// Identify array elements by the joined values of `fields`.
62    ///
63    /// Each entry is a dotted path into the element (`"to.module"`).
64    pub fn new<I, S>(fields: I) -> Self
65    where
66        I: IntoIterator<Item = S>,
67        S: Into<String>,
68    {
69        let raw: Vec<String> = fields.into_iter().map(Into::into).collect();
70        let label = raw.join("+");
71        let fields = raw
72            .iter()
73            .map(|field| field.split('.').map(ToOwned::to_owned).collect())
74            .collect();
75        Self { fields, label }
76    }
77}
78
79impl MergeIdentity for CompositeKey {
80    fn label(&self) -> &str {
81        &self.label
82    }
83
84    fn identity_of(&self, element: &Value) -> Option<String> {
85        // An empty field list cannot identify anything:
86        // returning an empty token here would make every element collide as a "duplicate".
87        // Treat it as no identity (consistent with a missing field)
88        // so such a section simply concatenates instead of spuriously failing the merge.
89        if self.fields.is_empty() {
90            return None;
91        }
92        let mut token = String::new();
93        for path in &self.fields {
94            let part = scalar_token(lookup(element, path)?)?;
95            // Length-prefix each part so the encoding is injective: the byte
96            // length unambiguously delimits the part, so no scalar value can
97            // forge a field boundary regardless of the characters it contains.
98            // A raw separator join would let e.g. `["a\u{1f}b", "c"]` collide
99            // with `["a", "b\u{1f}c"]` and reject a valid config as a duplicate.
100            token.push_str(part.len().to_string().as_str());
101            token.push(':');
102            token.push_str(&part);
103        }
104        Some(token)
105    }
106}
107
108/// Walk a dotted-path of object keys, returning the addressed value.
109fn lookup<'a>(value: &'a Value, path: &[String]) -> Option<&'a Value> {
110    let mut current = value;
111    for segment in path {
112        current = current.get(segment)?;
113    }
114    Some(current)
115}
116
117/// Render a JSON scalar as a stable identity token; non-scalars yield `None`.
118fn scalar_token(value: &Value) -> Option<String> {
119    match value {
120        Value::String(text) => Some(text.clone()),
121        Value::Number(number) => Some(number.to_string()),
122        Value::Bool(flag) => Some(flag.to_string()),
123        _ => None,
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130    use serde_json::json;
131
132    #[test]
133    fn identity_key_reads_named_field() {
134        let key = IdentityKey::new("name");
135        assert_eq!(
136            key.identity_of(&json!({ "name": "a" })),
137            Some("a".to_string())
138        );
139        assert_eq!(key.identity_of(&json!({ "other": "a" })), None);
140        assert_eq!(key.label(), "name");
141    }
142
143    #[test]
144    fn composite_key_joins_nested_fields() {
145        let key = CompositeKey::new(["from.ecosystem", "from.module", "to.ecosystem", "to.module"]);
146        let element = json!({
147            "from": { "ecosystem": "go", "module": "api" },
148            "to": { "ecosystem": "rust", "module": "shared" },
149        });
150
151        let token = key.identity_of(&element).expect("complete identity");
152        assert!(token.contains("go"));
153        assert!(token.contains("rust"));
154        assert_eq!(
155            key.label(),
156            "from.ecosystem+from.module+to.ecosystem+to.module"
157        );
158    }
159
160    #[test]
161    fn composite_key_is_none_when_a_field_is_missing() {
162        let key = CompositeKey::new(["from.ecosystem", "to.ecosystem"]);
163        let element = json!({ "from": { "ecosystem": "go" } });
164
165        assert_eq!(key.identity_of(&element), None);
166    }
167
168    #[test]
169    fn composite_key_with_no_fields_has_no_identity() {
170        // An empty field list must not collapse every element to the same empty token,
171        // which would make any multi-element section look duplicated.
172        let key = CompositeKey::new(Vec::<String>::new());
173
174        assert_eq!(key.identity_of(&json!({ "name": "a" })), None);
175        assert_eq!(key.identity_of(&json!({ "name": "b" })), None);
176    }
177
178    #[test]
179    fn composite_key_separates_distinct_tuples() {
180        let key = CompositeKey::new(["a", "b"]);
181        let first = key.identity_of(&json!({ "a": "x", "b": "yz" }));
182        let second = key.identity_of(&json!({ "a": "xy", "b": "z" }));
183
184        assert!(first.is_some());
185        assert_ne!(first, second);
186    }
187
188    #[test]
189    fn composite_key_is_injective_for_separator_like_values() {
190        let key = CompositeKey::new(["a", "b"]);
191
192        // A raw separator join (U+001F) would let a value containing the
193        // separator forge a field boundary; the length-prefixed encoding must
194        // keep these distinct tuples distinct.
195        let with_sep = key.identity_of(&json!({ "a": "x\u{1f}y", "b": "z" }));
196        let split = key.identity_of(&json!({ "a": "x", "b": "y\u{1f}z" }));
197        assert!(with_sep.is_some());
198        assert_ne!(with_sep, split);
199
200        // The same guarantee must hold for the length-prefix delimiter itself,
201        // so a value that looks like an encoded part cannot collide.
202        let looks_encoded = key.identity_of(&json!({ "a": "1:x", "b": "y" }));
203        let genuinely_split = key.identity_of(&json!({ "a": "1", "b": "x1:y" }));
204        assert_ne!(looks_encoded, genuinely_split);
205    }
206}