Skip to main content

nap_core/merge/
path.rs

1//! SDL-aware path resolution for three-way merge.
2//!
3//! Paths are resolved using identity keys (not array indices) when the
4//! SDL declares an identity mode for an array property.  This prevents
5//! path instability — adding or removing items before a given index
6//! does not invalidate previously resolved paths.
7//!
8//! # Path Format
9//!
10//! Canonical paths use dot notation for object keys:
11//!
12//! ```text
13//! name
14//! properties.homeworld
15//! properties.settings.visual.fog_color
16//! ```
17//!
18//! For array items the identity value is used in brackets:
19//!
20//! ```text
21//! characters[obiwan].name
22//! references[scene_4].participants
23//! ```
24//!
25//! # Index-based access is NEVER used
26//!
27//! Array indices (`characters[0]`) are not produced and not resolved
28//! by the canonical path system.
29
30use std::collections::BTreeMap;
31
32use serde_json::Value;
33
34use crate::merge::sdl::{IdentityRule, MergeStrategyType, SdlDocument};
35
36/// A resolved segment of a canonical path.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum PathSegment {
39    /// A named object key.  e.g. `properties`, `name`
40    Key(String),
41    /// An array item identified by its identity value.  e.g. `obiwan`
42    Identity(String),
43}
44
45/// A parsed canonical path as a sequence of segments.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct CanonicalPath {
48    segments: Vec<PathSegment>,
49}
50
51impl CanonicalPath {
52    /// Parse a canonical path string into segments.
53    ///
54    /// Supports:
55    /// - `key.subkey`  → [Key("key"), Key("subkey")]
56    /// - `key[identity]` → [Key("key"), Identity("identity")]
57    /// - `key[identity].subkey` → [Key("key"), Identity("identity"), Key("subkey")]
58    pub fn parse(path: &str) -> Result<Self, PathError> {
59        if path.is_empty() {
60            return Err(PathError::EmptyPath);
61        }
62
63        let mut segments = Vec::new();
64        let mut remaining = path;
65
66        while !remaining.is_empty() {
67            // Check for bracket (identity access)
68            if remaining.starts_with('[') {
69                let close = remaining
70                    .find(']')
71                    .ok_or_else(|| PathError::InvalidSyntax {
72                        path: path.to_string(),
73                        detail: "unclosed bracket".to_string(),
74                    })?;
75                let identity = &remaining[1..close];
76                if identity.is_empty() {
77                    return Err(PathError::InvalidSyntax {
78                        path: path.to_string(),
79                        detail: "empty identity in brackets".to_string(),
80                    });
81                }
82                segments.push(PathSegment::Identity(identity.to_string()));
83                remaining = &remaining[close + 1..];
84                // Expect either end-of-string or a dot+key
85                if !remaining.is_empty() {
86                    if remaining.starts_with('.') {
87                        remaining = &remaining[1..]; // consume dot
88                    } else {
89                        return Err(PathError::InvalidSyntax {
90                            path: path.to_string(),
91                            detail: format!(
92                                "expected '.' or end after ']', got '{}'",
93                                &remaining[..1]
94                            ),
95                        });
96                    }
97                }
98            } else {
99                // Read key up to next dot or bracket
100                let end = remaining.find(['.', '[']).unwrap_or(remaining.len());
101                let key = &remaining[..end];
102                if key.is_empty() {
103                    return Err(PathError::InvalidSyntax {
104                        path: path.to_string(),
105                        detail: "empty key segment".to_string(),
106                    });
107                }
108                segments.push(PathSegment::Key(key.to_string()));
109                remaining = &remaining[end..];
110                if remaining.starts_with('.') {
111                    remaining = &remaining[1..]; // consume dot
112                }
113            }
114        }
115
116        Ok(CanonicalPath { segments })
117    }
118
119    /// Return the segments of this path.
120    pub fn segments(&self) -> &[PathSegment] {
121        &self.segments
122    }
123
124    /// Returns the parent path (all segments except the last), if any.
125    pub fn parent(&self) -> Option<CanonicalPath> {
126        if self.segments.len() <= 1 {
127            return None;
128        }
129        Some(CanonicalPath {
130            segments: self.segments[..self.segments.len() - 1].to_vec(),
131        })
132    }
133
134    /// Returns the last segment of the path.
135    pub fn last_segment(&self) -> Option<&PathSegment> {
136        self.segments.last()
137    }
138}
139
140impl std::fmt::Display for CanonicalPath {
141    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
142        for (i, seg) in self.segments.iter().enumerate() {
143            if i > 0 {
144                // Identity segments use bracket notation without dot
145                // Key segments use dot separator
146                match seg {
147                    PathSegment::Identity(_) => {}
148                    PathSegment::Key(_) => write!(f, ".")?,
149                }
150            }
151            match seg {
152                PathSegment::Key(k) => write!(f, "{k}")?,
153                PathSegment::Identity(id) => write!(f, "[{id}]")?,
154            }
155        }
156        Ok(())
157    }
158}
159
160// ── Path Building ──────────────────────────────────────────────────────
161
162/// Build canonical paths for every leaf value in a JSON tree.
163///
164/// Returns a flat `BTreeMap` from canonical path string to value.
165/// For array items with identity rules, uses the identity value as
166/// the path segment instead of the array index.
167pub fn build_path_map(value: &Value, schema: &SdlDocument) -> BTreeMap<String, Value> {
168    let mut map = BTreeMap::new();
169    build_path_map_inner(value, schema, "", &mut map);
170    map
171}
172
173fn build_path_map_inner(
174    value: &Value,
175    schema: &SdlDocument,
176    prefix: &str,
177    map: &mut BTreeMap<String, Value>,
178) {
179    match value {
180        Value::Object(obj) => {
181            for (key, val) in obj {
182                let path = if prefix.is_empty() {
183                    key.clone()
184                } else {
185                    format!("{prefix}.{key}")
186                };
187                build_path_map_inner(val, schema, &path, map);
188            }
189        }
190        Value::Array(arr) => {
191            // Look up merge strategy for this path to find identity rules.
192            let identity_rule = schema.property_def(prefix).and_then(|def| {
193                if matches!(
194                    def.merge.strategy_type,
195                    MergeStrategyType::OrderedUnique
196                        | MergeStrategyType::SetUnion
197                        | MergeStrategyType::EdgeList
198                ) {
199                    def.merge.identity.clone()
200                } else {
201                    None
202                }
203            });
204
205            match identity_rule {
206                Some(IdentityRule::PrimitiveValue) => {
207                    // Store the array itself for whole-array merge
208                    map.insert(prefix.to_string(), Value::Array(arr.clone()));
209                    // Also emit individual item paths for detailed diff
210                    for val in arr {
211                        let identity = value_to_identity_string(val);
212                        let path = format!("{prefix}[{identity}]");
213                        map.insert(path, val.clone());
214                    }
215                }
216                Some(IdentityRule::Key { key: identity_key }) => {
217                    // Store the array itself for whole-array merge
218                    map.insert(prefix.to_string(), Value::Array(arr.clone()));
219                    // Also emit individual item paths and sub-paths for detailed diff
220                    for val in arr {
221                        if let Some(identity) = val.get(&identity_key).and_then(|v| v.as_str()) {
222                            let path = format!("{prefix}[{identity}]");
223                            map.insert(path.clone(), val.clone());
224                            build_path_map_inner(val, schema, &path, map);
225                        }
226                    }
227                }
228                None => {
229                    // No identity rules — use index-based paths
230                    for (idx, val) in arr.iter().enumerate() {
231                        let path = format!("{prefix}[{idx}]");
232                        map.insert(path.clone(), val.clone());
233                        build_path_map_inner(val, schema, &path, map);
234                    }
235                }
236            }
237        }
238        _ => {
239            // Leaf value — store the path
240            map.insert(prefix.to_string(), value.clone());
241        }
242    }
243}
244
245/// Convert a JSON value to a string suitable for use as an identity in paths.
246fn value_to_identity_string(val: &Value) -> String {
247    match val {
248        Value::String(s) => s.clone(),
249        Value::Number(n) => n.to_string(),
250        Value::Bool(b) => b.to_string(),
251        Value::Null => "null".to_string(),
252        _ => {
253            // For complex values used as identity (rare), use JSON
254            serde_json::to_string(val).unwrap_or_else(|_| "unknown".to_string())
255        }
256    }
257}
258
259/// Resolve a canonical path string against a JSON value tree.
260pub fn resolve_path<'a>(root: &'a Value, path: &CanonicalPath) -> Option<&'a Value> {
261    let mut current = root;
262
263    for segment in path.segments() {
264        current = match segment {
265            PathSegment::Key(key) => match current {
266                Value::Object(map) => map.get(key)?,
267                _ => return None,
268            },
269            PathSegment::Identity(id) => match current {
270                Value::Array(arr) => {
271                    // Identity-based lookup: search array for matching identity.
272                    // This is O(n) per lookup, which is acceptable for typical
273                    // manifest sizes.  For 10k+ items, build an index.
274                    // Heuristic: try to interpret id as a numeric index first.
275                    // This covers the fallback case where identity rules were
276                    // not available at path-building time.
277                    if idx_from_str(id) < arr.len() {
278                        // Check if this index matches (identity might be numeric)
279                        let candidate = &arr[idx_from_str(id)];
280                        // Only use index if it looks plausible (no identity rules were used)
281                        if matches!(
282                            candidate,
283                            Value::String(_) | Value::Number(_) | Value::Bool(_)
284                        ) {
285                            return Some(candidate);
286                        }
287                    }
288                    // Full scan by identity value
289                    arr.iter().find(|item| item_has_identity(item, id))?
290                }
291                _ => return None,
292            },
293        };
294    }
295
296    Some(current)
297}
298
299/// Check if a JSON value matches a given identity string.
300fn item_has_identity(item: &Value, identity: &str) -> bool {
301    match item {
302        Value::String(s) => s == identity,
303        Value::Number(n) => n.to_string() == identity,
304        Value::Bool(b) => b.to_string() == identity,
305        Value::Object(map) => {
306            // Check if any string field matches the identity
307            map.values().any(|v| match v {
308                Value::String(s) => s == identity,
309                Value::Number(n) => n.to_string() == identity,
310                _ => false,
311            })
312        }
313        _ => false,
314    }
315}
316
317fn idx_from_str(s: &str) -> usize {
318    s.parse().unwrap_or(usize::MAX)
319}
320
321// ── Path Union ─────────────────────────────────────────────────────────
322
323/// Compute the union of all paths across multiple path maps.
324pub fn path_union(maps: &[&BTreeMap<String, Value>]) -> Vec<String> {
325    let mut seen: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
326    for map in maps {
327        for key in map.keys() {
328            seen.insert(key.as_str());
329        }
330    }
331    seen.into_iter().map(String::from).collect()
332}
333
334// ── Errors ─────────────────────────────────────────────────────────────
335
336#[derive(Debug, thiserror::Error)]
337pub enum PathError {
338    #[error("path cannot be empty")]
339    EmptyPath,
340
341    #[error("invalid path syntax '{path}': {detail}")]
342    InvalidSyntax { path: String, detail: String },
343}
344
345// ── Tests ──────────────────────────────────────────────────────────────
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350    use crate::merge::sdl::SdlDocument;
351    use serde_json::json;
352
353    fn test_sdl() -> SdlDocument {
354        SdlDocument::from_yaml(
355            r#"
356schema:
357  version: "1.0"
358  required: []
359  properties:
360    characters:
361      type: array
362      merge:
363        type: ordered_unique
364        identity:
365          mode: key
366          key: id
367    tags:
368      type: array
369      merge:
370        type: ordered_unique
371        identity:
372          mode: primitive_value
373"#,
374        )
375        .unwrap()
376    }
377
378    #[test]
379    fn test_parse_simple_path() {
380        let path = CanonicalPath::parse("name").unwrap();
381        assert_eq!(path.segments(), &[PathSegment::Key("name".to_string())]);
382    }
383
384    #[test]
385    fn test_parse_nested_path() {
386        let path = CanonicalPath::parse("properties.homeworld").unwrap();
387        assert_eq!(
388            path.segments(),
389            &[
390                PathSegment::Key("properties".to_string()),
391                PathSegment::Key("homeworld".to_string()),
392            ]
393        );
394    }
395
396    #[test]
397    fn test_parse_identity_path() {
398        let path = CanonicalPath::parse("characters[obiwan]").unwrap();
399        assert_eq!(
400            path.segments(),
401            &[
402                PathSegment::Key("characters".to_string()),
403                PathSegment::Identity("obiwan".to_string()),
404            ]
405        );
406    }
407
408    #[test]
409    fn test_parse_identity_with_subpath() {
410        let path = CanonicalPath::parse("characters[obiwan].name").unwrap();
411        assert_eq!(
412            path.segments(),
413            &[
414                PathSegment::Key("characters".to_string()),
415                PathSegment::Identity("obiwan".to_string()),
416                PathSegment::Key("name".to_string()),
417            ]
418        );
419    }
420
421    #[test]
422    fn test_path_to_string() {
423        let path = CanonicalPath::parse("characters[obiwan].name").unwrap();
424        assert_eq!(path.to_string(), "characters[obiwan].name");
425    }
426
427    #[test]
428    fn test_build_path_map_with_identity_keys() {
429        let value = json!({
430            "name": "Luke",
431            "characters": [
432                {"id": "obiwan", "name": "Obi-Wan"},
433                {"id": "anakin", "name": "Anakin"}
434            ],
435            "tags": ["jedi", "force"]
436        });
437
438        let schema = test_sdl();
439        let map = build_path_map(&value, &schema);
440
441        // Simple keys
442        assert_eq!(map.get("name"), Some(&json!("Luke")));
443
444        // Identity-keyed array items
445        assert_eq!(map.get("characters[obiwan].name"), Some(&json!("Obi-Wan")));
446        assert_eq!(map.get("characters[anakin].name"), Some(&json!("Anakin")));
447
448        // Primitive identity array
449        assert!(map.contains_key("tags[jedi]"));
450        assert!(map.contains_key("tags[force]"));
451
452        // The full item objects should also be in the map
453        assert!(map.contains_key("characters[obiwan]"));
454        assert!(map.contains_key("characters[anakin]"));
455    }
456
457    #[test]
458    fn test_resolve_simple_path() {
459        let value = json!({"name": "Luke", "homeworld": "Tatooine"});
460        let path = CanonicalPath::parse("name").unwrap();
461        assert_eq!(resolve_path(&value, &path), Some(&json!("Luke")));
462    }
463
464    #[test]
465    fn test_resolve_nested_path() {
466        let value = json!({"properties": {"homeworld": "Tatooine"}});
467        let path = CanonicalPath::parse("properties.homeworld").unwrap();
468        assert_eq!(resolve_path(&value, &path), Some(&json!("Tatooine")));
469    }
470
471    #[test]
472    fn test_resolve_identity_path() {
473        let value = json!({
474            "characters": [
475                {"id": "obiwan", "name": "Obi-Wan"},
476            ]
477        });
478        let path = CanonicalPath::parse("characters[obiwan].name").unwrap();
479        assert_eq!(resolve_path(&value, &path), Some(&json!("Obi-Wan")));
480    }
481
482    #[test]
483    fn test_resolve_nonexistent_path() {
484        let value = json!({"name": "Luke"});
485        let path = CanonicalPath::parse("nonexistent").unwrap();
486        assert_eq!(resolve_path(&value, &path), None);
487    }
488
489    #[test]
490    fn test_parent_path() {
491        let path = CanonicalPath::parse("characters[obiwan].name").unwrap();
492        let parent = path.parent().unwrap();
493        assert_eq!(parent.to_string(), "characters[obiwan]");
494
495        let grandparent = parent.parent().unwrap();
496        assert_eq!(grandparent.to_string(), "characters");
497    }
498
499    #[test]
500    fn test_parent_of_root_is_none() {
501        let path = CanonicalPath::parse("name").unwrap();
502        assert!(path.parent().is_none());
503    }
504
505    #[test]
506    fn test_path_union() {
507        let map_a: BTreeMap<String, Value> =
508            [("a".to_string(), json!(1)), ("b".to_string(), json!(2))]
509                .into_iter()
510                .collect();
511        let map_b: BTreeMap<String, Value> =
512            [("b".to_string(), json!(20)), ("c".to_string(), json!(3))]
513                .into_iter()
514                .collect();
515
516        let union = path_union(&[&map_a, &map_b]);
517        assert_eq!(union, vec!["a", "b", "c"]);
518    }
519
520    #[test]
521    fn test_empty_path_error() {
522        assert!(CanonicalPath::parse("").is_err());
523    }
524}