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: current.kind_name().to_string(),
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) construct the typed `Value` first, via
114/// [`crate::document::coerce::value_from_type`] (an explicit `--value-type`)
115/// or a bare `Value::String` (zero coercion), before calling this.
116pub fn set_path(
117    root: &mut Value,
118    path: &str,
119    value: &Value,
120    keyed_lists: &[KeyedList<'_>],
121) -> DocumentResult<()> {
122    if path.is_empty() {
123        return Err(DocumentError::EmptyPath);
124    }
125
126    let segments = parse_path(path)?;
127    set_path_recursive(root, &segments, 0, &mut String::new(), keyed_lists, value)
128}
129
130fn set_path_recursive(
131    current: &mut Value,
132    segments: &[String],
133    idx: usize,
134    accumulated_prefix: &mut String,
135    keyed_lists: &[KeyedList<'_>],
136    value: &Value,
137) -> DocumentResult<()> {
138    if idx >= segments.len() {
139        return Err(DocumentError::EmptyPath);
140    }
141
142    let current_seg = segments[idx].as_str();
143    let is_last = idx == segments.len() - 1;
144
145    match current {
146        Value::Object(obj) => {
147            // Path parsing makes dotted keys explicit via `\\.`.
148            let key_to_use = current_seg.to_string();
149
150            let segments_to_consume = 1;
151
152            if is_last {
153                // At leaf: insert the typed value directly.
154                obj.insert(key_to_use, value.clone());
155                Ok(())
156            } else {
157                // Not at leaf: ensure key exists and recurse
158                let next_idx = idx + segments_to_consume;
159                if next_idx >= segments.len() {
160                    return Err(DocumentError::EmptyPath);
161                }
162
163                if !accumulated_prefix.is_empty() {
164                    accumulated_prefix.push('.');
165                }
166                accumulated_prefix.push_str(&key_to_use);
167
168                // Use entry API to avoid double borrow
169                use std::collections::btree_map::Entry;
170                match obj.entry(key_to_use) {
171                    Entry::Occupied(mut ent) => set_path_recursive(
172                        ent.get_mut(),
173                        segments,
174                        next_idx,
175                        accumulated_prefix,
176                        keyed_lists,
177                        value,
178                    ),
179                    Entry::Vacant(ent) => {
180                        let mut new_obj = Value::Object(Default::default());
181                        let result = set_path_recursive(
182                            &mut new_obj,
183                            segments,
184                            next_idx,
185                            accumulated_prefix,
186                            keyed_lists,
187                            value,
188                        );
189                        if result.is_ok() {
190                            ent.insert(new_obj);
191                        }
192                        result
193                    }
194                }
195            }
196        }
197        Value::Array(arr) => {
198            // Numeric index takes priority over keyed-list slug.
199            if let Ok(arr_idx) = current_seg.parse::<usize>() {
200                if arr_idx >= arr.len() {
201                    return Err(DocumentError::IndexOutOfBounds {
202                        path: accumulated_prefix.clone(),
203                        index: arr_idx,
204                        len: arr.len(),
205                    });
206                }
207                if is_last {
208                    arr[arr_idx] = value.clone();
209                    Ok(())
210                } else {
211                    accumulated_prefix.push('.');
212                    accumulated_prefix.push_str(current_seg);
213                    set_path_recursive(
214                        &mut arr[arr_idx],
215                        segments,
216                        idx + 1,
217                        accumulated_prefix,
218                        keyed_lists,
219                        value,
220                    )
221                }
222            } else {
223                let registration = keyed_lists
224                    .iter()
225                    .find(|kl| keyed_prefix_matches(kl, accumulated_prefix))
226                    .ok_or_else(|| DocumentError::UnregisteredArray {
227                        path: accumulated_prefix.clone(),
228                    })?;
229
230                let slug = current_seg;
231                let elem_idx = arr
232                    .iter()
233                    .position(|e| {
234                        if let Some(elem_obj) = e.as_object() {
235                            if let Some(Value::String(s)) = elem_obj.get(registration.slug_field) {
236                                s == slug
237                            } else {
238                                false
239                            }
240                        } else {
241                            false
242                        }
243                    })
244                    .ok_or_else(|| DocumentError::SlugNotFound {
245                        prefix: accumulated_prefix.clone(),
246                        slug: slug.to_string(),
247                    })?;
248
249                if is_last {
250                    Err(DocumentError::UnsupportedOperation {
251                        format: "keyed list".to_string(),
252                        operation: "set".to_string(),
253                        detail:
254                            "a keyed-list slug resolves to an element; set a child field instead"
255                                .to_string(),
256                    })
257                } else {
258                    accumulated_prefix.push('.');
259                    accumulated_prefix.push_str(slug);
260                    set_path_recursive(
261                        &mut arr[elem_idx],
262                        segments,
263                        idx + 1,
264                        accumulated_prefix,
265                        keyed_lists,
266                        value,
267                    )
268                }
269            }
270        }
271        _ => Err(DocumentError::NotTraversable {
272            path: accumulated_prefix.clone(),
273            got: current.kind_name().to_string(),
274        }),
275    }
276}
277
278fn keyed_prefix_matches(registration: &KeyedList<'_>, semantic_prefix: &str) -> bool {
279    registration.prefix == semantic_prefix
280        || crate::document::parse_path(registration.prefix)
281            .ok()
282            .is_some_and(|segments| segments.join(".") == semantic_prefix)
283}
284
285/// Remove the key at the given dot-path from its parent object.
286///
287/// This is the free-fn "remove a key" verb (paired with keyed-element removal
288/// via [`crate::document::remove_keyed`]).
289pub fn unset_path(root: &mut Value, path: &str) -> DocumentResult<()> {
290    if path.is_empty() {
291        return Err(DocumentError::EmptyPath);
292    }
293    let segments = parse_path(path)?;
294    unset_path_recursive(root, &segments, 0, &mut String::new())
295}
296
297fn unset_path_recursive(
298    current: &mut Value,
299    segments: &[String],
300    idx: usize,
301    accumulated_prefix: &mut String,
302) -> DocumentResult<()> {
303    if idx >= segments.len() {
304        return Err(DocumentError::EmptyPath);
305    }
306    let current_seg = segments[idx].as_str();
307    let is_last = idx == segments.len() - 1;
308
309    match current {
310        Value::Object(obj) => {
311            let key_to_use = current_seg.to_string();
312            let segments_to_consume = 1;
313
314            if is_last {
315                if obj.remove(&key_to_use).is_none() {
316                    return Err(DocumentError::PathNotFound { path: key_to_use });
317                }
318                Ok(())
319            } else {
320                let next_idx = idx + segments_to_consume;
321                if !accumulated_prefix.is_empty() {
322                    accumulated_prefix.push('.');
323                }
324                accumulated_prefix.push_str(&key_to_use);
325                if let Some(next) = obj.get_mut(&key_to_use) {
326                    unset_path_recursive(next, segments, next_idx, accumulated_prefix)
327                } else {
328                    Err(DocumentError::PathNotFound {
329                        path: accumulated_prefix.clone(),
330                    })
331                }
332            }
333        }
334        Value::Array(arr) => {
335            if let Ok(arr_idx) = current_seg.parse::<usize>() {
336                if arr_idx >= arr.len() {
337                    return Err(DocumentError::IndexOutOfBounds {
338                        path: accumulated_prefix.clone(),
339                        index: arr_idx,
340                        len: arr.len(),
341                    });
342                }
343                if is_last {
344                    arr.remove(arr_idx);
345                    Ok(())
346                } else {
347                    accumulated_prefix.push('.');
348                    accumulated_prefix.push_str(current_seg);
349                    unset_path_recursive(&mut arr[arr_idx], segments, idx + 1, accumulated_prefix)
350                }
351            } else {
352                Err(DocumentError::UnregisteredArray {
353                    path: accumulated_prefix.clone(),
354                })
355            }
356        }
357        _ => Err(DocumentError::NotTraversable {
358            path: accumulated_prefix.clone(),
359            got: current.kind_name().to_string(),
360        }),
361    }
362}
363
364#[cfg(test)]
365mod tests {
366    #![allow(
367        clippy::unwrap_used,
368        clippy::panic,
369        clippy::expect_used,
370        clippy::bool_assert_comparison
371    )]
372    use super::*;
373
374    fn make_test_object() -> Value {
375        let mut root = Value::Object(Default::default());
376        let mut imap = Value::Object(Default::default());
377        imap.as_object_mut().unwrap().insert(
378            "host".to_string(),
379            Value::String("mail.example.com".to_string()),
380        );
381        imap.as_object_mut()
382            .unwrap()
383            .insert("port".to_string(), Value::Integer(993));
384
385        root.as_object_mut()
386            .unwrap()
387            .insert("imap".to_string(), imap);
388
389        root
390    }
391
392    #[test]
393    fn test_get_path_simple() {
394        let root = make_test_object();
395        let result = get_path(&root, "imap.host", &[]).unwrap();
396        assert_eq!(result.as_str().unwrap(), "mail.example.com");
397    }
398
399    #[test]
400    fn test_get_path_integer() {
401        let root = make_test_object();
402        let result = get_path(&root, "imap.port", &[]).unwrap();
403        assert_eq!(result.as_integer().unwrap(), 993);
404    }
405
406    #[test]
407    fn test_set_path_new_key() {
408        let mut root = make_test_object();
409        set_path(&mut root, "imap.tls", &Value::Bool(true), &[]).unwrap();
410
411        let result = get_path(&root, "imap.tls", &[]).unwrap();
412        assert_eq!(result.as_bool().unwrap(), true);
413    }
414
415    #[test]
416    fn test_set_path_overwrite() {
417        let mut root = make_test_object();
418        set_path(&mut root, "imap.port", &Value::Integer(587), &[]).unwrap();
419
420        let result = get_path(&root, "imap.port", &[]).unwrap();
421        assert_eq!(result.as_integer().unwrap(), 587);
422    }
423
424    #[test]
425    fn test_set_path_array_value() {
426        let mut root = Value::Object(Default::default());
427        let value = Value::Array(vec![
428            Value::String("dev".to_string()),
429            Value::String("staging".to_string()),
430        ]);
431        set_path(&mut root, "tags", &value, &[]).unwrap();
432
433        let result = get_path(&root, "tags", &[]).unwrap();
434        let arr = result.as_array().unwrap();
435        assert_eq!(arr.len(), 2);
436    }
437
438    fn make_steps_object() -> Value {
439        // { "steps": [{"name": "a", "port": 1}, {"name": "b", "port": 2}] }
440        let mut root = Value::Object(Default::default());
441        let mut s0 = Value::Object(Default::default());
442        s0.as_object_mut()
443            .unwrap()
444            .insert("name".to_string(), Value::String("a".to_string()));
445        s0.as_object_mut()
446            .unwrap()
447            .insert("port".to_string(), Value::Integer(1));
448        let mut s1 = Value::Object(Default::default());
449        s1.as_object_mut()
450            .unwrap()
451            .insert("name".to_string(), Value::String("b".to_string()));
452        s1.as_object_mut()
453            .unwrap()
454            .insert("port".to_string(), Value::Integer(2));
455        root.as_object_mut()
456            .unwrap()
457            .insert("steps".to_string(), Value::Array(vec![s0, s1]));
458        root
459    }
460
461    #[test]
462    fn test_get_path_numeric_index() {
463        let root = make_steps_object();
464        let name = get_path(&root, "steps.0.name", &[]).unwrap();
465        assert_eq!(name.as_str().unwrap(), "a");
466        let port = get_path(&root, "steps.1.port", &[]).unwrap();
467        assert_eq!(port.as_integer().unwrap(), 2);
468    }
469
470    #[test]
471    fn test_set_path_numeric_index() {
472        let mut root = make_steps_object();
473        set_path(&mut root, "steps.0.port", &Value::Integer(99), &[]).unwrap();
474        let result = get_path(&root, "steps.0.port", &[]).unwrap();
475        assert_eq!(result.as_integer().unwrap(), 99);
476        // other element unchanged
477        let other = get_path(&root, "steps.1.port", &[]).unwrap();
478        assert_eq!(other.as_integer().unwrap(), 2);
479    }
480
481    #[test]
482    fn test_get_path_index_out_of_bounds() {
483        let root = make_steps_object();
484        let err = get_path(&root, "steps.5.name", &[]).unwrap_err();
485        assert!(matches!(
486            err,
487            DocumentError::IndexOutOfBounds {
488                index: 5,
489                len: 2,
490                ..
491            }
492        ));
493    }
494
495    #[test]
496    fn test_remove_path_numeric_index() {
497        let mut root = make_steps_object();
498        unset_path(&mut root, "steps.0").unwrap();
499        let arr = get_path(&root, "steps", &[]).unwrap();
500        let arr = arr.as_array().unwrap();
501        assert_eq!(arr.len(), 1);
502        assert_eq!(arr[0].get("name").unwrap().as_str().unwrap(), "b");
503    }
504}