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