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, join_path};
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/// How a **non-numeric** path segment resolves against an array.
17///
18/// A non-empty ASCII-decimal segment is always an index and never consults
19/// this (overflow is an error, not a slug fallback). Everything else has two
20/// possible sources, tried in this order:
21///
22/// - [`keyed_lists`](Self::keyed_lists) — the caller's own declarations, e.g.
23///   "the array at `identities` is keyed by each element's `identity` field".
24///   A JSON or TOML document does not say this about itself, so only the
25///   caller can, and it names one exact array by path.
26/// - [`array_rule`](Self::array_rule) — the *format's* rule, for a format whose
27///   value is a tree afdata synthesized and therefore knows the shape of. Only
28///   Markdown has one today; it applies to every array in the document rather
29///   than to one named path.
30///
31/// With neither, a non-numeric segment against an array is
32/// [`DocumentError::UnregisteredArray`](crate::document::DocumentError::UnregisteredArray)
33/// — afdata will not scan an array it was told nothing about.
34#[derive(Debug, Clone, Copy, Default)]
35pub struct Addressing<'a> {
36    pub keyed_lists: &'a [KeyedList<'a>],
37    pub array_rule: Option<ArrayRule<'a>>,
38}
39
40impl<'a> Addressing<'a> {
41    /// Indices only: no keyed lists, no format rule.
42    pub const INDEX_ONLY: Addressing<'static> = Addressing {
43        keyed_lists: &[],
44        array_rule: None,
45    };
46
47    /// Caller-declared keyed lists, with no format rule.
48    #[must_use]
49    pub const fn keyed(keyed_lists: &'a [KeyedList<'a>]) -> Self {
50        Addressing {
51            keyed_lists,
52            array_rule: None,
53        }
54    }
55
56    /// The same addressing plus a format's built-in array rule.
57    #[must_use]
58    pub const fn with_array_rule(self, array_rule: Option<ArrayRule<'a>>) -> Self {
59        Addressing { array_rule, ..self }
60    }
61}
62
63/// A format's built-in rule for resolving a non-numeric segment against an
64/// array: compare the segment to [`field`](Self::field) on each element.
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub struct ArrayRule<'a> {
67    /// Element field the segment is compared against. An element lacking it,
68    /// or holding a non-string there, simply does not match.
69    pub field: &'a str,
70    pub match_kind: MatchKind,
71}
72
73/// How an [`ArrayRule`] compares a segment to an element's field.
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub enum MatchKind {
76    /// The field equals the segment.
77    Exact,
78    /// The field contains the segment after Unicode lowercase conversion — so
79    /// `h2.look`
80    /// finds `## A Quick Look`. Prose headings are long and get reworded;
81    /// a memorable word out of one is a far steadier address than either its
82    /// position or its full text.
83    ///
84    /// Exactly one element must match. Several is
85    /// [`DocumentError::AmbiguousMatch`](crate::document::DocumentError::AmbiguousMatch),
86    /// never the first one.
87    Contains,
88}
89
90impl ArrayRule<'_> {
91    /// Whether `element` matches `segment` under this rule.
92    #[must_use]
93    pub fn matches(&self, element: &Value, segment: &str) -> bool {
94        let Some(field) = element.get(self.field).and_then(Value::as_str) else {
95            return false;
96        };
97        match self.match_kind {
98            MatchKind::Exact => field == segment,
99            // An empty segment is not an address. `contains("")` is true for
100            // every element, so without this a path built by interpolation —
101            // which is how both consumers build theirs — resolves to a
102            // confident wrong answer on any single-element array, and only
103            // becomes an error once a second element exists. `Exact` already
104            // rejects it by construction (nothing equals ""), so this keeps
105            // the two modes agreeing about what is addressable.
106            MatchKind::Contains if segment.is_empty() => false,
107            MatchKind::Contains => field.to_lowercase().contains(&segment.to_lowercase()),
108        }
109    }
110}
111
112/// Path segments a keyed-list prefix names, where the empty prefix means the
113/// document root is itself the keyed array.
114///
115/// `parse_path` rejects an empty path, which is right for an address but wrong
116/// for this prefix: `traverse::keyed_prefix_matches` has always accepted
117/// `KeyedList { prefix: "" }`, so a root array could be read by slug while
118/// `add`/`remove` answered `EmptyPath` for the very same registration.
119fn keyed_prefix_segments(prefix: &str) -> DocumentResult<Vec<String>> {
120    if prefix.is_empty() {
121        return Ok(Vec::new());
122    }
123    crate::document::parse_path(prefix)
124}
125
126/// Add a new element to a keyed list.
127///
128/// The new element is built in three layers:
129/// 1. `seed` fields (if provided) — default template values
130/// 2. `{ slug_field: slug }` — always set, overrides any slug value in seed
131/// 3. explicit `fields` — override both seed and slug (except the slug field)
132pub fn add_keyed(
133    root: &mut Value,
134    prefix: &str,
135    slug: &str,
136    keyed_lists: &[KeyedList<'_>],
137    seed: Option<&Value>,
138    fields: &[(String, Value)],
139) -> DocumentResult<()> {
140    // Resolve the prefix through the single path grammar so top-level and nested
141    // (dotted or escaped) prefixes are all matched by their normalized segments.
142    let segments = keyed_prefix_segments(prefix)?;
143    let registered = keyed_lists
144        .iter()
145        .any(|list| crate::document::keyed_prefix_matches(list, &segments));
146    if !registered {
147        return Err(DocumentError::UnregisteredArray {
148            path: prefix.to_string(),
149        });
150    }
151
152    add_keyed_segments(root, &segments, 0, slug, seed, fields, keyed_lists)
153}
154
155/// Remove the unique element named by `slug` and return its former index.
156///
157/// The index lets a source-preserving backend remove the exact same element
158/// from its syntax tree without independently repeating the identity lookup.
159pub fn remove_keyed(
160    root: &mut Value,
161    prefix: &str,
162    slug: &str,
163    keyed_lists: &[KeyedList<'_>],
164) -> DocumentResult<usize> {
165    let segments = keyed_prefix_segments(prefix)?;
166    let registered = keyed_lists
167        .iter()
168        .any(|list| crate::document::keyed_prefix_matches(list, &segments));
169    if !registered {
170        return Err(DocumentError::UnregisteredArray {
171            path: prefix.to_string(),
172        });
173    }
174
175    remove_keyed_segments(root, &segments, 0, slug, keyed_lists)
176}
177
178fn add_keyed_segments(
179    current: &mut Value,
180    segments: &[String],
181    index: usize,
182    slug: &str,
183    seed: Option<&Value>,
184    fields: &[(String, Value)],
185    keyed_lists: &[KeyedList<'_>],
186) -> DocumentResult<()> {
187    if index + 1 < segments.len() {
188        let Value::Object(object) = current else {
189            return Err(DocumentError::NotTraversable {
190                path: join_path(&segments[..=index]),
191                got: current.kind_name().to_string(),
192            });
193        };
194        let next = object
195            .entry(segments[index].clone())
196            .or_insert_with(|| Value::Object(Default::default()));
197        return add_keyed_segments(next, segments, index + 1, slug, seed, fields, keyed_lists);
198    }
199    let array = if segments.is_empty() {
200        current
201    } else {
202        let Value::Object(object) = current else {
203            return Err(DocumentError::NotTraversable {
204                path: join_path(segments),
205                got: current.kind_name().to_string(),
206            });
207        };
208        object
209            .entry(segments[index].clone())
210            .or_insert_with(|| Value::Array(Vec::new()))
211    };
212    let Value::Array(array) = array else {
213        return Err(DocumentError::NotTraversable {
214            path: join_path(segments),
215            got: array.kind_name().to_string(),
216        });
217    };
218    let registration = keyed_lists
219        .iter()
220        .find(|list| crate::document::keyed_prefix_matches(list, segments))
221        .ok_or_else(|| DocumentError::UnregisteredArray {
222            path: join_path(segments),
223        })?;
224    if array.iter().any(|entry| {
225        entry
226            .as_object()
227            .and_then(|object| object.get(registration.slug_field))
228            .and_then(Value::as_str)
229            == Some(slug)
230    }) {
231        return Err(DocumentError::SlugAlreadyExists {
232            prefix: join_path(segments),
233            slug: slug.to_string(),
234        });
235    }
236    let mut element = Value::Object(Default::default());
237    let object = element
238        .as_object_mut()
239        .ok_or_else(|| DocumentError::NotTraversable {
240            path: join_path(segments),
241            got: "failed to create object".to_string(),
242        })?;
243    if let Some(seed) = seed.and_then(Value::as_object) {
244        for (key, value) in seed {
245            if key != registration.slug_field {
246                object.insert(key.clone(), value.clone());
247            }
248        }
249    }
250    object.insert(
251        registration.slug_field.to_string(),
252        Value::String(slug.to_string()),
253    );
254    for (key, value) in fields {
255        if key == registration.slug_field {
256            return Err(DocumentError::InvalidArgument {
257                detail: format!("field `{key}` cannot override slug field"),
258            });
259        }
260        object.insert(key.clone(), value.clone());
261    }
262    array.push(element);
263    Ok(())
264}
265
266fn remove_keyed_segments(
267    current: &mut Value,
268    segments: &[String],
269    index: usize,
270    slug: &str,
271    keyed_lists: &[KeyedList<'_>],
272) -> DocumentResult<usize> {
273    if index + 1 < segments.len() {
274        let Value::Object(object) = current else {
275            return Err(DocumentError::NotTraversable {
276                path: join_path(&segments[..=index]),
277                got: current.kind_name().to_string(),
278            });
279        };
280        let next = object
281            .get_mut(&segments[index])
282            .ok_or_else(|| DocumentError::PathNotFound {
283                path: join_path(segments),
284            })?;
285        return remove_keyed_segments(next, segments, index + 1, slug, keyed_lists);
286    }
287    let target = if segments.is_empty() {
288        current
289    } else {
290        let Value::Object(object) = current else {
291            return Err(DocumentError::NotTraversable {
292                path: join_path(segments),
293                got: current.kind_name().to_string(),
294            });
295        };
296        object
297            .get_mut(&segments[index])
298            .ok_or_else(|| DocumentError::PathNotFound {
299                path: join_path(segments),
300            })?
301    };
302    let Value::Array(array) = target else {
303        return Err(DocumentError::NotTraversable {
304            path: join_path(segments),
305            got: target.kind_name().to_string(),
306        });
307    };
308    let registration = keyed_lists
309        .iter()
310        .find(|list| crate::document::keyed_prefix_matches(list, segments))
311        .ok_or_else(|| DocumentError::UnregisteredArray {
312            path: join_path(segments),
313        })?;
314    let matches: Vec<usize> = array
315        .iter()
316        .enumerate()
317        .filter(|(_, entry)| {
318            entry
319                .as_object()
320                .and_then(|object| object.get(registration.slug_field))
321                .and_then(Value::as_str)
322                == Some(slug)
323        })
324        .map(|(index, _)| index)
325        .collect();
326    let index = match matches.as_slice() {
327        [] => {
328            return Err(DocumentError::SlugNotFound {
329                prefix: join_path(segments),
330                slug: slug.to_string(),
331            });
332        }
333        [index] => *index,
334        _ => {
335            return Err(DocumentError::AmbiguousMatch {
336                prefix: join_path(segments),
337                segment: slug.to_string(),
338                indices: matches,
339            });
340        }
341    };
342    array.remove(index);
343    Ok(index)
344}
345
346#[cfg(test)]
347mod tests {
348    #![allow(clippy::unwrap_used, clippy::panic)]
349    use super::*;
350
351    #[test]
352    fn test_add_keyed() {
353        let mut root = Value::Object(Default::default());
354        let keyed = [KeyedList {
355            prefix: "identities",
356            slug_field: "identity",
357        }];
358
359        root.as_object_mut()
360            .unwrap()
361            .insert("identities".to_string(), Value::Array(vec![]));
362
363        add_keyed(
364            &mut root,
365            "identities",
366            "me",
367            &keyed,
368            None,
369            &[
370                (
371                    "email".to_string(),
372                    Value::String("me@example.com".to_string()),
373                ),
374                ("name".to_string(), Value::String("Me".to_string())),
375            ],
376        )
377        .unwrap();
378
379        let arr = root.get("identities").unwrap().as_array().unwrap();
380        assert_eq!(arr.len(), 1);
381
382        let elem = &arr[0];
383        assert_eq!(elem.get("identity").unwrap().as_str().unwrap(), "me");
384        assert_eq!(
385            elem.get("email").unwrap().as_str().unwrap(),
386            "me@example.com"
387        );
388    }
389
390    #[test]
391    fn test_add_keyed_with_seed() {
392        let mut root = Value::Object(Default::default());
393        let keyed = [KeyedList {
394            prefix: "identities",
395            slug_field: "identity",
396        }];
397        root.as_object_mut()
398            .unwrap()
399            .insert("identities".to_string(), Value::Array(vec![]));
400
401        let mut seed_obj = std::collections::BTreeMap::new();
402        seed_obj.insert("enabled".to_string(), Value::Bool(true));
403        seed_obj.insert("role".to_string(), Value::String("user".to_string()));
404        seed_obj.insert(
405            "email".to_string(),
406            Value::String("default@example.com".to_string()),
407        );
408        let seed = Value::Object(seed_obj);
409
410        add_keyed(
411            &mut root,
412            "identities",
413            "alice",
414            &keyed,
415            Some(&seed),
416            &[(
417                "email".to_string(),
418                Value::String("alice@example.com".to_string()),
419            )], // overrides seed
420        )
421        .unwrap();
422
423        let elem = &root.get("identities").unwrap().as_array().unwrap()[0];
424        assert_eq!(elem.get("identity").unwrap().as_str().unwrap(), "alice");
425        assert_eq!(elem.get("role").unwrap().as_str().unwrap(), "user"); // from seed
426        assert!(elem.get("enabled").unwrap().as_bool().unwrap()); // from seed
427        assert_eq!(
428            elem.get("email").unwrap().as_str().unwrap(),
429            "alice@example.com"
430        ); // fields override seed
431    }
432
433    #[test]
434    fn test_remove_keyed() {
435        let mut root = Value::Object(Default::default());
436        let keyed = [KeyedList {
437            prefix: "identities",
438            slug_field: "identity",
439        }];
440
441        let mut elem1 = Value::Object(Default::default());
442        elem1
443            .as_object_mut()
444            .unwrap()
445            .insert("identity".to_string(), Value::String("me".to_string()));
446
447        let mut elem2 = Value::Object(Default::default());
448        elem2
449            .as_object_mut()
450            .unwrap()
451            .insert("identity".to_string(), Value::String("other".to_string()));
452
453        root.as_object_mut()
454            .unwrap()
455            .insert("identities".to_string(), Value::Array(vec![elem1, elem2]));
456
457        let removed_index = remove_keyed(&mut root, "identities", "me", &keyed).unwrap();
458
459        assert_eq!(removed_index, 0);
460        let arr = root.get("identities").unwrap().as_array().unwrap();
461        assert_eq!(arr.len(), 1);
462        assert_eq!(arr[0].get("identity").unwrap().as_str().unwrap(), "other");
463    }
464
465    #[test]
466    fn test_remove_keyed_refuses_duplicate_slug_without_mutating() {
467        let item = |email: &str| {
468            let mut value = Value::Object(Default::default());
469            let object = value.as_object_mut().unwrap();
470            object.insert("identity".to_string(), Value::String("me".to_string()));
471            object.insert("email".to_string(), Value::String(email.to_string()));
472            value
473        };
474        let mut original = Value::Object(Default::default());
475        original.as_object_mut().unwrap().insert(
476            "identities".to_string(),
477            Value::Array(vec![item("first"), item("second")]),
478        );
479        let mut root = original.clone();
480        let keyed = [KeyedList {
481            prefix: "identities",
482            slug_field: "identity",
483        }];
484
485        let error = remove_keyed(&mut root, "identities", "me", &keyed).unwrap_err();
486
487        assert!(matches!(
488            error,
489            DocumentError::AmbiguousMatch {
490                prefix,
491                segment,
492                indices
493            } if prefix == "identities"
494                && segment == "me"
495                && indices == vec![0, 1]
496        ));
497        assert_eq!(root, original);
498    }
499
500    #[test]
501    fn test_add_and_remove_keyed_nested_dotted_prefix() {
502        // A plain dotted (unescaped) nested prefix must route through the same
503        // normalized-segment matcher as top-level and escaped prefixes.
504        let mut root = Value::Object(Default::default());
505        let keyed = [KeyedList {
506            prefix: "cfg.users",
507            slug_field: "uid",
508        }];
509
510        add_keyed(
511            &mut root,
512            "cfg.users",
513            "bob",
514            &keyed,
515            None,
516            &[("role".to_string(), Value::String("dev".to_string()))],
517        )
518        .unwrap();
519
520        let arr = root
521            .get("cfg")
522            .unwrap()
523            .get("users")
524            .unwrap()
525            .as_array()
526            .unwrap();
527        assert_eq!(arr.len(), 1);
528        assert_eq!(arr[0].get("uid").unwrap().as_str().unwrap(), "bob");
529        assert_eq!(arr[0].get("role").unwrap().as_str().unwrap(), "dev");
530
531        remove_keyed(&mut root, "cfg.users", "bob", &keyed).unwrap();
532        let arr = root
533            .get("cfg")
534            .unwrap()
535            .get("users")
536            .unwrap()
537            .as_array()
538            .unwrap();
539        assert!(arr.is_empty());
540    }
541}
542
543#[cfg(test)]
544mod root_array_tests {
545    #![allow(clippy::unwrap_used)]
546    use super::*;
547    use crate::document::{Addressing, get_path};
548    use std::collections::BTreeMap;
549
550    fn element(id: &str) -> Value {
551        Value::Object(BTreeMap::from([(
552            "id".to_string(),
553            Value::String(id.to_string()),
554        )]))
555    }
556
557    #[test]
558    fn a_root_array_reads_and_edits_through_the_same_registration() {
559        // `KeyedList { prefix: "" }` has always resolved on the read walk;
560        // `add`/`remove` rejected it with `EmptyPath` because they ran the
561        // prefix through `parse_path`, which refuses an empty address. One
562        // registration must mean one thing to both.
563        let lists = [KeyedList {
564            prefix: "",
565            slug_field: "id",
566        }];
567        let mut root = Value::Array(vec![element("a"), element("b")]);
568
569        assert_eq!(
570            get_path(&root, "a.id", Addressing::keyed(&lists)).unwrap(),
571            Value::String("a".to_string())
572        );
573        assert_eq!(remove_keyed(&mut root, "", "a", &lists).unwrap(), 0);
574        assert_eq!(root.as_array().map(Vec::len), Some(1));
575
576        add_keyed(&mut root, "", "c", &lists, None, &[]).unwrap();
577        assert_eq!(
578            get_path(&root, "c.id", Addressing::keyed(&lists)).unwrap(),
579            Value::String("c".to_string())
580        );
581
582        // An unregistered root array is still refused, not scanned.
583        let mut bare = Value::Array(vec![element("a")]);
584        assert_eq!(
585            remove_keyed(&mut bare, "", "a", &[]).unwrap_err().code(),
586            "document_path_not_found"
587        );
588    }
589}