Skip to main content

agent_first_data/document/
traverse.rs

1//! Core dot-path traversal for get/set operations.
2
3use crate::document::{DocumentError, DocumentResult, Value, keyed::KeyedList, path::parse_path};
4
5/// Get a value at the given dot-path.
6///
7/// Handles:
8/// - Object field access (any level of nesting)
9/// - KeyedList access (Vec<T> slug routing)
10/// - Greedy key matching for keys containing '.'
11pub fn get_path_ref<'a>(
12    root: &'a Value,
13    path: &str,
14    keyed_lists: &[KeyedList<'_>],
15) -> DocumentResult<&'a Value> {
16    if path.is_empty() {
17        return Err(DocumentError::EmptyPath);
18    }
19
20    let segments = parse_path(path)?;
21    let mut current = root;
22    let mut accumulated_prefix = String::new();
23    let mut seg_idx = 0;
24
25    while seg_idx < segments.len() {
26        let current_seg = segments[seg_idx].as_str();
27
28        match current {
29            Value::Object(obj) => {
30                // Try exact match first
31                if let Some(next) = obj.get(current_seg) {
32                    if !accumulated_prefix.is_empty() {
33                        accumulated_prefix.push('.');
34                    }
35                    accumulated_prefix.push_str(current_seg);
36                    current = next;
37                    seg_idx += 1;
38                } else {
39                    return Err(DocumentError::UnknownSegment {
40                        path: path.to_string(),
41                        segment: current_seg.to_string(),
42                    });
43                }
44            }
45            Value::Array(arr) => {
46                // Numeric index takes priority over keyed-list slug.
47                if let Ok(arr_idx) = current_seg.parse::<usize>() {
48                    let elem = arr
49                        .get(arr_idx)
50                        .ok_or_else(|| DocumentError::IndexOutOfBounds {
51                            path: accumulated_prefix.clone(),
52                            index: arr_idx,
53                            len: arr.len(),
54                        })?;
55                    if !accumulated_prefix.is_empty() {
56                        accumulated_prefix.push('.');
57                    }
58                    accumulated_prefix.push_str(current_seg);
59                    current = elem;
60                    seg_idx += 1;
61                } else {
62                    let registration = keyed_lists
63                        .iter()
64                        .find(|kl| keyed_prefix_matches(kl, &accumulated_prefix))
65                        .ok_or_else(|| DocumentError::UnregisteredArray {
66                            path: accumulated_prefix.clone(),
67                        })?;
68
69                    let slug = current_seg;
70                    let elem = arr
71                        .iter()
72                        .find(|e| {
73                            if let Some(obj) = e.as_object() {
74                                if let Some(Value::String(s)) = obj.get(registration.slug_field) {
75                                    s == slug
76                                } else {
77                                    false
78                                }
79                            } else {
80                                false
81                            }
82                        })
83                        .ok_or_else(|| DocumentError::SlugNotFound {
84                            prefix: accumulated_prefix.clone(),
85                            slug: slug.to_string(),
86                        })?;
87
88                    current = elem;
89                    accumulated_prefix.push('.');
90                    accumulated_prefix.push_str(slug);
91                    seg_idx += 1;
92                }
93            }
94            _ => {
95                return Err(DocumentError::NotTraversable {
96                    path: path.to_string(),
97                    got: format!("{:?}", current),
98                });
99            }
100        }
101    }
102
103    Ok(current)
104}
105
106/// Get a cloned value at the given dot-path.
107pub fn get_path(root: &Value, path: &str, keyed_lists: &[KeyedList<'_>]) -> DocumentResult<Value> {
108    Ok(get_path_ref(root, path, keyed_lists)?.clone())
109}
110
111/// Set a value at the given dot-path. `value` is inserted as-is at the leaf —
112/// no coercion happens here; callers that accept CLI strings (e.g. the
113/// `afdata` binary) coerce them toward the existing type via
114/// [`crate::document::coerce::coerce_values_typed`] before calling this.
115pub fn set_path(
116    root: &mut Value,
117    path: &str,
118    value: &Value,
119    keyed_lists: &[KeyedList<'_>],
120) -> DocumentResult<()> {
121    if path.is_empty() {
122        return Err(DocumentError::EmptyPath);
123    }
124
125    let segments = parse_path(path)?;
126    set_path_recursive(root, &segments, 0, &mut String::new(), keyed_lists, value)
127}
128
129fn set_path_recursive(
130    current: &mut Value,
131    segments: &[String],
132    idx: usize,
133    accumulated_prefix: &mut String,
134    keyed_lists: &[KeyedList<'_>],
135    value: &Value,
136) -> DocumentResult<()> {
137    if idx >= segments.len() {
138        return Err(DocumentError::EmptyPath);
139    }
140
141    let current_seg = segments[idx].as_str();
142    let is_last = idx == segments.len() - 1;
143
144    match current {
145        Value::Object(obj) => {
146            // Path parsing makes dotted keys explicit via `\\.`.
147            let key_to_use = current_seg.to_string();
148
149            let segments_to_consume = 1;
150
151            if is_last {
152                // At leaf: insert the typed value directly.
153                obj.insert(key_to_use, value.clone());
154                Ok(())
155            } else {
156                // Not at leaf: ensure key exists and recurse
157                let next_idx = idx + segments_to_consume;
158                if next_idx >= segments.len() {
159                    return Err(DocumentError::EmptyPath);
160                }
161
162                if !accumulated_prefix.is_empty() {
163                    accumulated_prefix.push('.');
164                }
165                accumulated_prefix.push_str(&key_to_use);
166
167                // Use entry API to avoid double borrow
168                use std::collections::btree_map::Entry;
169                match obj.entry(key_to_use) {
170                    Entry::Occupied(mut ent) => set_path_recursive(
171                        ent.get_mut(),
172                        segments,
173                        next_idx,
174                        accumulated_prefix,
175                        keyed_lists,
176                        value,
177                    ),
178                    Entry::Vacant(ent) => {
179                        let mut new_obj = Value::Object(Default::default());
180                        let result = set_path_recursive(
181                            &mut new_obj,
182                            segments,
183                            next_idx,
184                            accumulated_prefix,
185                            keyed_lists,
186                            value,
187                        );
188                        if result.is_ok() {
189                            ent.insert(new_obj);
190                        }
191                        result
192                    }
193                }
194            }
195        }
196        Value::Array(arr) => {
197            // Numeric index takes priority over keyed-list slug.
198            if let Ok(arr_idx) = current_seg.parse::<usize>() {
199                if arr_idx >= arr.len() {
200                    return Err(DocumentError::IndexOutOfBounds {
201                        path: accumulated_prefix.clone(),
202                        index: arr_idx,
203                        len: arr.len(),
204                    });
205                }
206                if is_last {
207                    arr[arr_idx] = value.clone();
208                    Ok(())
209                } else {
210                    accumulated_prefix.push('.');
211                    accumulated_prefix.push_str(current_seg);
212                    set_path_recursive(
213                        &mut arr[arr_idx],
214                        segments,
215                        idx + 1,
216                        accumulated_prefix,
217                        keyed_lists,
218                        value,
219                    )
220                }
221            } else {
222                let registration = keyed_lists
223                    .iter()
224                    .find(|kl| keyed_prefix_matches(kl, accumulated_prefix))
225                    .ok_or_else(|| DocumentError::UnregisteredArray {
226                        path: accumulated_prefix.clone(),
227                    })?;
228
229                let slug = current_seg;
230                let elem_idx = arr
231                    .iter()
232                    .position(|e| {
233                        if let Some(elem_obj) = e.as_object() {
234                            if let Some(Value::String(s)) = elem_obj.get(registration.slug_field) {
235                                s == slug
236                            } else {
237                                false
238                            }
239                        } else {
240                            false
241                        }
242                    })
243                    .ok_or_else(|| DocumentError::SlugNotFound {
244                        prefix: accumulated_prefix.clone(),
245                        slug: slug.to_string(),
246                    })?;
247
248                if is_last {
249                    Err(DocumentError::UnsupportedOperation {
250                        format: "keyed list".to_string(),
251                        operation: "set".to_string(),
252                        detail:
253                            "a keyed-list slug resolves to an element; set a child field instead"
254                                .to_string(),
255                    })
256                } else {
257                    accumulated_prefix.push('.');
258                    accumulated_prefix.push_str(slug);
259                    set_path_recursive(
260                        &mut arr[elem_idx],
261                        segments,
262                        idx + 1,
263                        accumulated_prefix,
264                        keyed_lists,
265                        value,
266                    )
267                }
268            }
269        }
270        _ => Err(DocumentError::NotTraversable {
271            path: accumulated_prefix.clone(),
272            got: format!("{:?}", current),
273        }),
274    }
275}
276
277fn keyed_prefix_matches(registration: &KeyedList<'_>, semantic_prefix: &str) -> bool {
278    registration.prefix == semantic_prefix
279        || crate::document::parse_path(registration.prefix)
280            .ok()
281            .is_some_and(|segments| segments.join(".") == semantic_prefix)
282}
283
284/// Remove the key at the given dot-path from its parent object.
285///
286/// This is the free-fn "remove a key" verb (paired with keyed-element removal
287/// via [`crate::document::remove_keyed`]).
288pub fn unset_path(root: &mut Value, path: &str) -> DocumentResult<()> {
289    if path.is_empty() {
290        return Err(DocumentError::EmptyPath);
291    }
292    let segments = parse_path(path)?;
293    unset_path_recursive(root, &segments, 0, &mut String::new())
294}
295
296fn unset_path_recursive(
297    current: &mut Value,
298    segments: &[String],
299    idx: usize,
300    accumulated_prefix: &mut String,
301) -> DocumentResult<()> {
302    if idx >= segments.len() {
303        return Err(DocumentError::EmptyPath);
304    }
305    let current_seg = segments[idx].as_str();
306    let is_last = idx == segments.len() - 1;
307
308    match current {
309        Value::Object(obj) => {
310            let key_to_use = current_seg.to_string();
311            let segments_to_consume = 1;
312
313            if is_last {
314                if obj.remove(&key_to_use).is_none() {
315                    return Err(DocumentError::PathNotFound { path: key_to_use });
316                }
317                Ok(())
318            } else {
319                let next_idx = idx + segments_to_consume;
320                if !accumulated_prefix.is_empty() {
321                    accumulated_prefix.push('.');
322                }
323                accumulated_prefix.push_str(&key_to_use);
324                if let Some(next) = obj.get_mut(&key_to_use) {
325                    unset_path_recursive(next, segments, next_idx, accumulated_prefix)
326                } else {
327                    Err(DocumentError::PathNotFound {
328                        path: accumulated_prefix.clone(),
329                    })
330                }
331            }
332        }
333        Value::Array(arr) => {
334            if let Ok(arr_idx) = current_seg.parse::<usize>() {
335                if arr_idx >= arr.len() {
336                    return Err(DocumentError::IndexOutOfBounds {
337                        path: accumulated_prefix.clone(),
338                        index: arr_idx,
339                        len: arr.len(),
340                    });
341                }
342                if is_last {
343                    arr.remove(arr_idx);
344                    Ok(())
345                } else {
346                    accumulated_prefix.push('.');
347                    accumulated_prefix.push_str(current_seg);
348                    unset_path_recursive(&mut arr[arr_idx], segments, idx + 1, accumulated_prefix)
349                }
350            } else {
351                Err(DocumentError::UnregisteredArray {
352                    path: accumulated_prefix.clone(),
353                })
354            }
355        }
356        _ => Err(DocumentError::NotTraversable {
357            path: accumulated_prefix.clone(),
358            got: format!("{:?}", current),
359        }),
360    }
361}
362
363#[cfg(test)]
364mod tests {
365    #![allow(
366        clippy::unwrap_used,
367        clippy::panic,
368        clippy::expect_used,
369        clippy::bool_assert_comparison
370    )]
371    use super::*;
372
373    fn make_test_object() -> Value {
374        let mut root = Value::Object(Default::default());
375        let mut imap = Value::Object(Default::default());
376        imap.as_object_mut().unwrap().insert(
377            "host".to_string(),
378            Value::String("mail.example.com".to_string()),
379        );
380        imap.as_object_mut()
381            .unwrap()
382            .insert("port".to_string(), Value::Integer(993));
383
384        root.as_object_mut()
385            .unwrap()
386            .insert("imap".to_string(), imap);
387
388        root
389    }
390
391    #[test]
392    fn test_get_path_simple() {
393        let root = make_test_object();
394        let result = get_path(&root, "imap.host", &[]).unwrap();
395        assert_eq!(result.as_str().unwrap(), "mail.example.com");
396    }
397
398    #[test]
399    fn test_get_path_integer() {
400        let root = make_test_object();
401        let result = get_path(&root, "imap.port", &[]).unwrap();
402        assert_eq!(result.as_integer().unwrap(), 993);
403    }
404
405    #[test]
406    fn test_set_path_new_key() {
407        let mut root = make_test_object();
408        set_path(&mut root, "imap.tls", &Value::Bool(true), &[]).unwrap();
409
410        let result = get_path(&root, "imap.tls", &[]).unwrap();
411        assert_eq!(result.as_bool().unwrap(), true);
412    }
413
414    #[test]
415    fn test_set_path_overwrite() {
416        let mut root = make_test_object();
417        set_path(&mut root, "imap.port", &Value::Integer(587), &[]).unwrap();
418
419        let result = get_path(&root, "imap.port", &[]).unwrap();
420        assert_eq!(result.as_integer().unwrap(), 587);
421    }
422
423    #[test]
424    fn test_set_path_array_value() {
425        let mut root = Value::Object(Default::default());
426        let value = Value::Array(vec![
427            Value::String("dev".to_string()),
428            Value::String("staging".to_string()),
429        ]);
430        set_path(&mut root, "tags", &value, &[]).unwrap();
431
432        let result = get_path(&root, "tags", &[]).unwrap();
433        let arr = result.as_array().unwrap();
434        assert_eq!(arr.len(), 2);
435    }
436
437    fn make_steps_object() -> Value {
438        // { "steps": [{"name": "a", "port": 1}, {"name": "b", "port": 2}] }
439        let mut root = Value::Object(Default::default());
440        let mut s0 = Value::Object(Default::default());
441        s0.as_object_mut()
442            .unwrap()
443            .insert("name".to_string(), Value::String("a".to_string()));
444        s0.as_object_mut()
445            .unwrap()
446            .insert("port".to_string(), Value::Integer(1));
447        let mut s1 = Value::Object(Default::default());
448        s1.as_object_mut()
449            .unwrap()
450            .insert("name".to_string(), Value::String("b".to_string()));
451        s1.as_object_mut()
452            .unwrap()
453            .insert("port".to_string(), Value::Integer(2));
454        root.as_object_mut()
455            .unwrap()
456            .insert("steps".to_string(), Value::Array(vec![s0, s1]));
457        root
458    }
459
460    #[test]
461    fn test_get_path_numeric_index() {
462        let root = make_steps_object();
463        let name = get_path(&root, "steps.0.name", &[]).unwrap();
464        assert_eq!(name.as_str().unwrap(), "a");
465        let port = get_path(&root, "steps.1.port", &[]).unwrap();
466        assert_eq!(port.as_integer().unwrap(), 2);
467    }
468
469    #[test]
470    fn test_set_path_numeric_index() {
471        let mut root = make_steps_object();
472        set_path(&mut root, "steps.0.port", &Value::Integer(99), &[]).unwrap();
473        let result = get_path(&root, "steps.0.port", &[]).unwrap();
474        assert_eq!(result.as_integer().unwrap(), 99);
475        // other element unchanged
476        let other = get_path(&root, "steps.1.port", &[]).unwrap();
477        assert_eq!(other.as_integer().unwrap(), 2);
478    }
479
480    #[test]
481    fn test_get_path_index_out_of_bounds() {
482        let root = make_steps_object();
483        let err = get_path(&root, "steps.5.name", &[]).unwrap_err();
484        assert!(matches!(
485            err,
486            DocumentError::IndexOutOfBounds {
487                index: 5,
488                len: 2,
489                ..
490            }
491        ));
492    }
493
494    #[test]
495    fn test_remove_path_numeric_index() {
496        let mut root = make_steps_object();
497        unset_path(&mut root, "steps.0").unwrap();
498        let arr = get_path(&root, "steps", &[]).unwrap();
499        let arr = arr.as_array().unwrap();
500        assert_eq!(arr.len(), 1);
501        assert_eq!(arr[0].get("name").unwrap().as_str().unwrap(), "b");
502    }
503}