use crate::document::{DocumentError, DocumentResult, Value, join_path};
#[derive(Debug, Clone, Copy)]
pub struct KeyedList<'a> {
pub prefix: &'a str,
pub slug_field: &'a str,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct Addressing<'a> {
pub keyed_lists: &'a [KeyedList<'a>],
pub array_rule: Option<ArrayRule<'a>>,
}
impl<'a> Addressing<'a> {
pub const INDEX_ONLY: Addressing<'static> = Addressing {
keyed_lists: &[],
array_rule: None,
};
#[must_use]
pub const fn keyed(keyed_lists: &'a [KeyedList<'a>]) -> Self {
Addressing {
keyed_lists,
array_rule: None,
}
}
#[must_use]
pub const fn with_array_rule(self, array_rule: Option<ArrayRule<'a>>) -> Self {
Addressing { array_rule, ..self }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ArrayRule<'a> {
pub field: &'a str,
pub match_kind: MatchKind,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MatchKind {
Exact,
Contains,
}
impl ArrayRule<'_> {
#[must_use]
pub fn matches(&self, element: &Value, segment: &str) -> bool {
let Some(field) = element.get(self.field).and_then(Value::as_str) else {
return false;
};
match self.match_kind {
MatchKind::Exact => field == segment,
MatchKind::Contains if segment.is_empty() => false,
MatchKind::Contains => field.to_lowercase().contains(&segment.to_lowercase()),
}
}
}
fn keyed_prefix_segments(prefix: &str) -> DocumentResult<Vec<String>> {
if prefix.is_empty() {
return Ok(Vec::new());
}
crate::document::parse_path(prefix)
}
pub fn add_keyed(
root: &mut Value,
prefix: &str,
slug: &str,
keyed_lists: &[KeyedList<'_>],
seed: Option<&Value>,
fields: &[(String, Value)],
) -> DocumentResult<()> {
let segments = keyed_prefix_segments(prefix)?;
let registered = keyed_lists
.iter()
.any(|list| crate::document::keyed_prefix_matches(list, &segments));
if !registered {
return Err(DocumentError::UnregisteredArray {
path: prefix.to_string(),
});
}
add_keyed_segments(root, &segments, 0, slug, seed, fields, keyed_lists)
}
pub fn remove_keyed(
root: &mut Value,
prefix: &str,
slug: &str,
keyed_lists: &[KeyedList<'_>],
) -> DocumentResult<usize> {
let segments = keyed_prefix_segments(prefix)?;
let registered = keyed_lists
.iter()
.any(|list| crate::document::keyed_prefix_matches(list, &segments));
if !registered {
return Err(DocumentError::UnregisteredArray {
path: prefix.to_string(),
});
}
remove_keyed_segments(root, &segments, 0, slug, keyed_lists)
}
fn add_keyed_segments(
current: &mut Value,
segments: &[String],
index: usize,
slug: &str,
seed: Option<&Value>,
fields: &[(String, Value)],
keyed_lists: &[KeyedList<'_>],
) -> DocumentResult<()> {
if index + 1 < segments.len() {
let Value::Object(object) = current else {
return Err(DocumentError::NotTraversable {
path: join_path(&segments[..=index]),
got: current.kind_name().to_string(),
});
};
let next = object
.entry(segments[index].clone())
.or_insert_with(|| Value::Object(Default::default()));
return add_keyed_segments(next, segments, index + 1, slug, seed, fields, keyed_lists);
}
let array = if segments.is_empty() {
current
} else {
let Value::Object(object) = current else {
return Err(DocumentError::NotTraversable {
path: join_path(segments),
got: current.kind_name().to_string(),
});
};
object
.entry(segments[index].clone())
.or_insert_with(|| Value::Array(Vec::new()))
};
let Value::Array(array) = array else {
return Err(DocumentError::NotTraversable {
path: join_path(segments),
got: array.kind_name().to_string(),
});
};
let registration = keyed_lists
.iter()
.find(|list| crate::document::keyed_prefix_matches(list, segments))
.ok_or_else(|| DocumentError::UnregisteredArray {
path: join_path(segments),
})?;
if array.iter().any(|entry| {
entry
.as_object()
.and_then(|object| object.get(registration.slug_field))
.and_then(Value::as_str)
== Some(slug)
}) {
return Err(DocumentError::SlugAlreadyExists {
prefix: join_path(segments),
slug: slug.to_string(),
});
}
let mut element = Value::Object(Default::default());
let object = element
.as_object_mut()
.ok_or_else(|| DocumentError::NotTraversable {
path: join_path(segments),
got: "failed to create object".to_string(),
})?;
if let Some(seed) = seed.and_then(Value::as_object) {
for (key, value) in seed {
if key != registration.slug_field {
object.insert(key.clone(), value.clone());
}
}
}
object.insert(
registration.slug_field.to_string(),
Value::String(slug.to_string()),
);
for (key, value) in fields {
if key == registration.slug_field {
return Err(DocumentError::InvalidArgument {
detail: format!("field `{key}` cannot override slug field"),
});
}
object.insert(key.clone(), value.clone());
}
array.push(element);
Ok(())
}
fn remove_keyed_segments(
current: &mut Value,
segments: &[String],
index: usize,
slug: &str,
keyed_lists: &[KeyedList<'_>],
) -> DocumentResult<usize> {
if index + 1 < segments.len() {
let Value::Object(object) = current else {
return Err(DocumentError::NotTraversable {
path: join_path(&segments[..=index]),
got: current.kind_name().to_string(),
});
};
let next = object
.get_mut(&segments[index])
.ok_or_else(|| DocumentError::PathNotFound {
path: join_path(segments),
})?;
return remove_keyed_segments(next, segments, index + 1, slug, keyed_lists);
}
let target = if segments.is_empty() {
current
} else {
let Value::Object(object) = current else {
return Err(DocumentError::NotTraversable {
path: join_path(segments),
got: current.kind_name().to_string(),
});
};
object
.get_mut(&segments[index])
.ok_or_else(|| DocumentError::PathNotFound {
path: join_path(segments),
})?
};
let Value::Array(array) = target else {
return Err(DocumentError::NotTraversable {
path: join_path(segments),
got: target.kind_name().to_string(),
});
};
let registration = keyed_lists
.iter()
.find(|list| crate::document::keyed_prefix_matches(list, segments))
.ok_or_else(|| DocumentError::UnregisteredArray {
path: join_path(segments),
})?;
let matches: Vec<usize> = array
.iter()
.enumerate()
.filter(|(_, entry)| {
entry
.as_object()
.and_then(|object| object.get(registration.slug_field))
.and_then(Value::as_str)
== Some(slug)
})
.map(|(index, _)| index)
.collect();
let index = match matches.as_slice() {
[] => {
return Err(DocumentError::SlugNotFound {
prefix: join_path(segments),
slug: slug.to_string(),
});
}
[index] => *index,
_ => {
return Err(DocumentError::AmbiguousMatch {
prefix: join_path(segments),
segment: slug.to_string(),
indices: matches,
});
}
};
array.remove(index);
Ok(index)
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::panic)]
use super::*;
#[test]
fn test_add_keyed() {
let mut root = Value::Object(Default::default());
let keyed = [KeyedList {
prefix: "identities",
slug_field: "identity",
}];
root.as_object_mut()
.unwrap()
.insert("identities".to_string(), Value::Array(vec![]));
add_keyed(
&mut root,
"identities",
"me",
&keyed,
None,
&[
(
"email".to_string(),
Value::String("me@example.com".to_string()),
),
("name".to_string(), Value::String("Me".to_string())),
],
)
.unwrap();
let arr = root.get("identities").unwrap().as_array().unwrap();
assert_eq!(arr.len(), 1);
let elem = &arr[0];
assert_eq!(elem.get("identity").unwrap().as_str().unwrap(), "me");
assert_eq!(
elem.get("email").unwrap().as_str().unwrap(),
"me@example.com"
);
}
#[test]
fn test_add_keyed_with_seed() {
let mut root = Value::Object(Default::default());
let keyed = [KeyedList {
prefix: "identities",
slug_field: "identity",
}];
root.as_object_mut()
.unwrap()
.insert("identities".to_string(), Value::Array(vec![]));
let mut seed_obj = std::collections::BTreeMap::new();
seed_obj.insert("enabled".to_string(), Value::Bool(true));
seed_obj.insert("role".to_string(), Value::String("user".to_string()));
seed_obj.insert(
"email".to_string(),
Value::String("default@example.com".to_string()),
);
let seed = Value::Object(seed_obj);
add_keyed(
&mut root,
"identities",
"alice",
&keyed,
Some(&seed),
&[(
"email".to_string(),
Value::String("alice@example.com".to_string()),
)], )
.unwrap();
let elem = &root.get("identities").unwrap().as_array().unwrap()[0];
assert_eq!(elem.get("identity").unwrap().as_str().unwrap(), "alice");
assert_eq!(elem.get("role").unwrap().as_str().unwrap(), "user"); assert!(elem.get("enabled").unwrap().as_bool().unwrap()); assert_eq!(
elem.get("email").unwrap().as_str().unwrap(),
"alice@example.com"
); }
#[test]
fn test_remove_keyed() {
let mut root = Value::Object(Default::default());
let keyed = [KeyedList {
prefix: "identities",
slug_field: "identity",
}];
let mut elem1 = Value::Object(Default::default());
elem1
.as_object_mut()
.unwrap()
.insert("identity".to_string(), Value::String("me".to_string()));
let mut elem2 = Value::Object(Default::default());
elem2
.as_object_mut()
.unwrap()
.insert("identity".to_string(), Value::String("other".to_string()));
root.as_object_mut()
.unwrap()
.insert("identities".to_string(), Value::Array(vec![elem1, elem2]));
let removed_index = remove_keyed(&mut root, "identities", "me", &keyed).unwrap();
assert_eq!(removed_index, 0);
let arr = root.get("identities").unwrap().as_array().unwrap();
assert_eq!(arr.len(), 1);
assert_eq!(arr[0].get("identity").unwrap().as_str().unwrap(), "other");
}
#[test]
fn test_remove_keyed_refuses_duplicate_slug_without_mutating() {
let item = |email: &str| {
let mut value = Value::Object(Default::default());
let object = value.as_object_mut().unwrap();
object.insert("identity".to_string(), Value::String("me".to_string()));
object.insert("email".to_string(), Value::String(email.to_string()));
value
};
let mut original = Value::Object(Default::default());
original.as_object_mut().unwrap().insert(
"identities".to_string(),
Value::Array(vec![item("first"), item("second")]),
);
let mut root = original.clone();
let keyed = [KeyedList {
prefix: "identities",
slug_field: "identity",
}];
let error = remove_keyed(&mut root, "identities", "me", &keyed).unwrap_err();
assert!(matches!(
error,
DocumentError::AmbiguousMatch {
prefix,
segment,
indices
} if prefix == "identities"
&& segment == "me"
&& indices == vec![0, 1]
));
assert_eq!(root, original);
}
#[test]
fn test_add_and_remove_keyed_nested_dotted_prefix() {
let mut root = Value::Object(Default::default());
let keyed = [KeyedList {
prefix: "cfg.users",
slug_field: "uid",
}];
add_keyed(
&mut root,
"cfg.users",
"bob",
&keyed,
None,
&[("role".to_string(), Value::String("dev".to_string()))],
)
.unwrap();
let arr = root
.get("cfg")
.unwrap()
.get("users")
.unwrap()
.as_array()
.unwrap();
assert_eq!(arr.len(), 1);
assert_eq!(arr[0].get("uid").unwrap().as_str().unwrap(), "bob");
assert_eq!(arr[0].get("role").unwrap().as_str().unwrap(), "dev");
remove_keyed(&mut root, "cfg.users", "bob", &keyed).unwrap();
let arr = root
.get("cfg")
.unwrap()
.get("users")
.unwrap()
.as_array()
.unwrap();
assert!(arr.is_empty());
}
}
#[cfg(test)]
mod root_array_tests {
#![allow(clippy::unwrap_used)]
use super::*;
use crate::document::{Addressing, get_path};
use std::collections::BTreeMap;
fn element(id: &str) -> Value {
Value::Object(BTreeMap::from([(
"id".to_string(),
Value::String(id.to_string()),
)]))
}
#[test]
fn a_root_array_reads_and_edits_through_the_same_registration() {
let lists = [KeyedList {
prefix: "",
slug_field: "id",
}];
let mut root = Value::Array(vec![element("a"), element("b")]);
assert_eq!(
get_path(&root, "a.id", Addressing::keyed(&lists)).unwrap(),
Value::String("a".to_string())
);
assert_eq!(remove_keyed(&mut root, "", "a", &lists).unwrap(), 0);
assert_eq!(root.as_array().map(Vec::len), Some(1));
add_keyed(&mut root, "", "c", &lists, None, &[]).unwrap();
assert_eq!(
get_path(&root, "c.id", Addressing::keyed(&lists)).unwrap(),
Value::String("c".to_string())
);
let mut bare = Value::Array(vec![element("a")]);
assert_eq!(
remove_keyed(&mut bare, "", "a", &[]).unwrap_err().code(),
"document_path_not_found"
);
}
}