Skip to main content

agent_first_data/document/
keyed.rs

1//! KeyedList operations for slug-based array access.
2
3use crate::document::{DocumentError, DocumentResult, Value};
4
5/// Declares that an array at `prefix` is keyed by `slug_field`.
6///
7/// Example: `KeyedList { prefix: "identities", slug_field: "identity" }`
8/// enables path `identities.me.email` to find the element where
9/// `element["identity"] == "me"`, then read/write `element["email"]`.
10#[derive(Debug, Clone, Copy)]
11pub struct KeyedList<'a> {
12    pub prefix: &'a str,
13    pub slug_field: &'a str,
14}
15
16/// Add a new element to a keyed list.
17///
18/// The new element is built in three layers:
19/// 1. `seed` fields (if provided) — default template values
20/// 2. `{ slug_field: slug }` — always set, overrides any slug value in seed
21/// 3. explicit `fields` — override both seed and slug (except the slug field)
22pub fn add_keyed(
23    root: &mut Value,
24    prefix: &str,
25    slug: &str,
26    keyed_lists: &[KeyedList<'_>],
27    seed: Option<&Value>,
28    fields: &[(String, Value)],
29) -> DocumentResult<()> {
30    // Resolve the prefix through the single path grammar so top-level and nested
31    // (dotted or escaped) prefixes are all matched by their normalized segments.
32    let segments = crate::document::parse_path(prefix)?;
33    let registered = keyed_lists.iter().any(|list| {
34        crate::document::parse_path(list.prefix).ok().as_deref() == Some(segments.as_slice())
35    });
36    if !registered {
37        return Err(DocumentError::UnregisteredArray {
38            path: prefix.to_string(),
39        });
40    }
41
42    // '.' is the path separator — a slug containing it would be unreachable via get/set_path.
43    if slug.contains('.') {
44        return Err(DocumentError::ParseError {
45            format: "slug".to_string(),
46            detail: format!("slug `{slug}` must not contain '.' (path separator)"),
47        });
48    }
49
50    add_keyed_segments(root, &segments, 0, slug, seed, fields, keyed_lists)
51}
52
53/// Remove an element from a keyed list by slug.
54pub fn remove_keyed(
55    root: &mut Value,
56    prefix: &str,
57    slug: &str,
58    keyed_lists: &[KeyedList<'_>],
59) -> DocumentResult<()> {
60    let segments = crate::document::parse_path(prefix)?;
61    let registered = keyed_lists.iter().any(|list| {
62        crate::document::parse_path(list.prefix).ok().as_deref() == Some(segments.as_slice())
63    });
64    if !registered {
65        return Err(DocumentError::UnregisteredArray {
66            path: prefix.to_string(),
67        });
68    }
69
70    remove_keyed_segments(root, &segments, 0, slug, keyed_lists)
71}
72
73fn add_keyed_segments(
74    current: &mut Value,
75    segments: &[String],
76    index: usize,
77    slug: &str,
78    seed: Option<&Value>,
79    fields: &[(String, Value)],
80    keyed_lists: &[KeyedList<'_>],
81) -> DocumentResult<()> {
82    if index + 1 < segments.len() {
83        let Value::Object(object) = current else {
84            return Err(DocumentError::NotTraversable {
85                path: segments[..=index].join("."),
86                got: "not an object".to_string(),
87            });
88        };
89        let next = object
90            .entry(segments[index].clone())
91            .or_insert_with(|| Value::Object(Default::default()));
92        return add_keyed_segments(next, segments, index + 1, slug, seed, fields, keyed_lists);
93    }
94    let Value::Object(object) = current else {
95        return Err(DocumentError::NotTraversable {
96            path: segments.join("."),
97            got: "not an object".to_string(),
98        });
99    };
100    let array = object
101        .entry(segments[index].clone())
102        .or_insert_with(|| Value::Array(Vec::new()));
103    let Value::Array(array) = array else {
104        return Err(DocumentError::NotTraversable {
105            path: segments.join("."),
106            got: "not an array".to_string(),
107        });
108    };
109    let registration = keyed_lists
110        .iter()
111        .find(|list| crate::document::parse_path(list.prefix).ok().as_deref() == Some(segments))
112        .ok_or_else(|| DocumentError::UnregisteredArray {
113            path: segments.join("."),
114        })?;
115    if array.iter().any(|entry| {
116        entry
117            .as_object()
118            .and_then(|object| object.get(registration.slug_field))
119            .and_then(Value::as_str)
120            == Some(slug)
121    }) {
122        return Err(DocumentError::SlugAlreadyExists {
123            prefix: segments.join("."),
124            slug: slug.to_string(),
125        });
126    }
127    let mut element = Value::Object(Default::default());
128    let object = element
129        .as_object_mut()
130        .ok_or_else(|| DocumentError::NotTraversable {
131            path: segments.join("."),
132            got: "failed to create object".to_string(),
133        })?;
134    if let Some(seed) = seed.and_then(Value::as_object) {
135        for (key, value) in seed {
136            if key != registration.slug_field {
137                object.insert(key.clone(), value.clone());
138            }
139        }
140    }
141    object.insert(
142        registration.slug_field.to_string(),
143        Value::String(slug.to_string()),
144    );
145    for (key, value) in fields {
146        if key == registration.slug_field {
147            return Err(DocumentError::ParseError {
148                format: "keyed list".to_string(),
149                detail: format!("field `{key}` cannot override slug field"),
150            });
151        }
152        object.insert(key.clone(), value.clone());
153    }
154    array.push(element);
155    Ok(())
156}
157
158fn remove_keyed_segments(
159    current: &mut Value,
160    segments: &[String],
161    index: usize,
162    slug: &str,
163    keyed_lists: &[KeyedList<'_>],
164) -> DocumentResult<()> {
165    if index + 1 < segments.len() {
166        let Value::Object(object) = current else {
167            return Err(DocumentError::NotTraversable {
168                path: segments[..=index].join("."),
169                got: "not an object".to_string(),
170            });
171        };
172        let next = object
173            .get_mut(&segments[index])
174            .ok_or_else(|| DocumentError::PathNotFound {
175                path: segments.join("."),
176            })?;
177        return remove_keyed_segments(next, segments, index + 1, slug, keyed_lists);
178    }
179    let Value::Object(object) = current else {
180        return Err(DocumentError::NotTraversable {
181            path: segments.join("."),
182            got: "not an object".to_string(),
183        });
184    };
185    let array = object
186        .get_mut(&segments[index])
187        .and_then(Value::as_array_mut)
188        .ok_or_else(|| DocumentError::PathNotFound {
189            path: segments.join("."),
190        })?;
191    let registration = keyed_lists
192        .iter()
193        .find(|list| crate::document::parse_path(list.prefix).ok().as_deref() == Some(segments))
194        .ok_or_else(|| DocumentError::UnregisteredArray {
195            path: segments.join("."),
196        })?;
197    let before = array.len();
198    array.retain(|entry| {
199        entry
200            .as_object()
201            .and_then(|object| object.get(registration.slug_field))
202            .and_then(Value::as_str)
203            != Some(slug)
204    });
205    if before == array.len() {
206        return Err(DocumentError::SlugNotFound {
207            prefix: segments.join("."),
208            slug: slug.to_string(),
209        });
210    }
211    Ok(())
212}
213
214#[cfg(test)]
215mod tests {
216    #![allow(clippy::unwrap_used, clippy::panic)]
217    use super::*;
218
219    #[test]
220    fn test_add_keyed() {
221        let mut root = Value::Object(Default::default());
222        let keyed = [KeyedList {
223            prefix: "identities",
224            slug_field: "identity",
225        }];
226
227        root.as_object_mut()
228            .unwrap()
229            .insert("identities".to_string(), Value::Array(vec![]));
230
231        add_keyed(
232            &mut root,
233            "identities",
234            "me",
235            &keyed,
236            None,
237            &[
238                (
239                    "email".to_string(),
240                    Value::String("me@example.com".to_string()),
241                ),
242                ("name".to_string(), Value::String("Me".to_string())),
243            ],
244        )
245        .unwrap();
246
247        let arr = root.get("identities").unwrap().as_array().unwrap();
248        assert_eq!(arr.len(), 1);
249
250        let elem = &arr[0];
251        assert_eq!(elem.get("identity").unwrap().as_str().unwrap(), "me");
252        assert_eq!(
253            elem.get("email").unwrap().as_str().unwrap(),
254            "me@example.com"
255        );
256    }
257
258    #[test]
259    fn test_add_keyed_with_seed() {
260        let mut root = Value::Object(Default::default());
261        let keyed = [KeyedList {
262            prefix: "identities",
263            slug_field: "identity",
264        }];
265        root.as_object_mut()
266            .unwrap()
267            .insert("identities".to_string(), Value::Array(vec![]));
268
269        let mut seed_obj = std::collections::BTreeMap::new();
270        seed_obj.insert("enabled".to_string(), Value::Bool(true));
271        seed_obj.insert("role".to_string(), Value::String("user".to_string()));
272        seed_obj.insert(
273            "email".to_string(),
274            Value::String("default@example.com".to_string()),
275        );
276        let seed = Value::Object(seed_obj);
277
278        add_keyed(
279            &mut root,
280            "identities",
281            "alice",
282            &keyed,
283            Some(&seed),
284            &[(
285                "email".to_string(),
286                Value::String("alice@example.com".to_string()),
287            )], // overrides seed
288        )
289        .unwrap();
290
291        let elem = &root.get("identities").unwrap().as_array().unwrap()[0];
292        assert_eq!(elem.get("identity").unwrap().as_str().unwrap(), "alice");
293        assert_eq!(elem.get("role").unwrap().as_str().unwrap(), "user"); // from seed
294        assert!(elem.get("enabled").unwrap().as_bool().unwrap()); // from seed
295        assert_eq!(
296            elem.get("email").unwrap().as_str().unwrap(),
297            "alice@example.com"
298        ); // fields override seed
299    }
300
301    #[test]
302    fn test_remove_keyed() {
303        let mut root = Value::Object(Default::default());
304        let keyed = [KeyedList {
305            prefix: "identities",
306            slug_field: "identity",
307        }];
308
309        let mut elem1 = Value::Object(Default::default());
310        elem1
311            .as_object_mut()
312            .unwrap()
313            .insert("identity".to_string(), Value::String("me".to_string()));
314
315        let mut elem2 = Value::Object(Default::default());
316        elem2
317            .as_object_mut()
318            .unwrap()
319            .insert("identity".to_string(), Value::String("other".to_string()));
320
321        root.as_object_mut()
322            .unwrap()
323            .insert("identities".to_string(), Value::Array(vec![elem1, elem2]));
324
325        remove_keyed(&mut root, "identities", "me", &keyed).unwrap();
326
327        let arr = root.get("identities").unwrap().as_array().unwrap();
328        assert_eq!(arr.len(), 1);
329        assert_eq!(arr[0].get("identity").unwrap().as_str().unwrap(), "other");
330    }
331
332    #[test]
333    fn test_add_and_remove_keyed_nested_dotted_prefix() {
334        // A plain dotted (unescaped) nested prefix must route through the same
335        // normalized-segment matcher as top-level and escaped prefixes.
336        let mut root = Value::Object(Default::default());
337        let keyed = [KeyedList {
338            prefix: "cfg.users",
339            slug_field: "uid",
340        }];
341
342        add_keyed(
343            &mut root,
344            "cfg.users",
345            "bob",
346            &keyed,
347            None,
348            &[("role".to_string(), Value::String("dev".to_string()))],
349        )
350        .unwrap();
351
352        let arr = root
353            .get("cfg")
354            .unwrap()
355            .get("users")
356            .unwrap()
357            .as_array()
358            .unwrap();
359        assert_eq!(arr.len(), 1);
360        assert_eq!(arr[0].get("uid").unwrap().as_str().unwrap(), "bob");
361        assert_eq!(arr[0].get("role").unwrap().as_str().unwrap(), "dev");
362
363        remove_keyed(&mut root, "cfg.users", "bob", &keyed).unwrap();
364        let arr = root
365            .get("cfg")
366            .unwrap()
367            .get("users")
368            .unwrap()
369            .as_array()
370            .unwrap();
371        assert!(arr.is_empty());
372    }
373}