use std::collections::BTreeMap;
use agentstategraph_core::{Atom, Node, Object, ObjectId, PathComponent, StatePath};
use agentstategraph_storage::{ObjectStore, StorageError};
pub const LIST_PATHS_HARD_CEILING: usize = 128;
pub const LIST_PATHS_MAX_RESULTS: usize = 10_000;
pub fn tree_get(
store: &dyn ObjectStore,
root: &ObjectId,
path: &StatePath,
) -> Result<Object, TreeError> {
let root_obj = store
.get_object(root)?
.ok_or(TreeError::ObjectNotFound(*root))?;
if path.is_root() {
return Ok(root_obj);
}
let mut current = root_obj;
for (i, component) in path.components().iter().enumerate() {
current = navigate(¤t, component, store, path, i)?;
}
Ok(current)
}
pub fn tree_set(
store: &dyn ObjectStore,
root: &ObjectId,
path: &StatePath,
value: &Object,
) -> Result<ObjectId, TreeError> {
let value_id = store.put_object(value)?;
if path.is_root() {
return Ok(value_id);
}
let root_obj = store
.get_object(root)?
.ok_or(TreeError::ObjectNotFound(*root))?;
let new_root = set_recursive(store, &root_obj, path.components(), 0, value_id)?;
let new_root_id = store.put_object(&new_root)?;
Ok(new_root_id)
}
pub fn tree_delete(
store: &dyn ObjectStore,
root: &ObjectId,
path: &StatePath,
) -> Result<ObjectId, TreeError> {
if path.is_root() {
let empty = Object::empty_map();
let id = store.put_object(&empty)?;
return Ok(id);
}
let root_obj = store
.get_object(root)?
.ok_or(TreeError::ObjectNotFound(*root))?;
let new_root = delete_recursive(store, &root_obj, path.components(), 0)?;
let new_root_id = store.put_object(&new_root)?;
Ok(new_root_id)
}
pub fn tree_to_json(store: &dyn ObjectStore, obj: &Object) -> Result<serde_json::Value, TreeError> {
match obj {
Object::Atom(atom) => Ok(atom_to_json(atom)),
Object::Node(node) => match node {
Node::Map(entries) => {
let mut map = serde_json::Map::new();
for (key, child_id) in entries {
let child = store
.get_object(child_id)?
.ok_or(TreeError::ObjectNotFound(*child_id))?;
map.insert(key.clone(), tree_to_json(store, &child)?);
}
Ok(serde_json::Value::Object(map))
}
Node::List(items) => {
let mut arr = Vec::new();
for child_id in items {
let child = store
.get_object(child_id)?
.ok_or(TreeError::ObjectNotFound(*child_id))?;
arr.push(tree_to_json(store, &child)?);
}
Ok(serde_json::Value::Array(arr))
}
Node::Set(items) => {
let mut arr = Vec::new();
for child_id in items {
let child = store
.get_object(child_id)?
.ok_or(TreeError::ObjectNotFound(*child_id))?;
arr.push(tree_to_json(store, &child)?);
}
Ok(serde_json::Value::Array(arr))
}
},
}
}
pub fn tree_to_json_capped(
store: &dyn ObjectStore,
obj: &Object,
max_depth: usize,
) -> Result<serde_json::Value, TreeError> {
to_json_capped(store, obj, max_depth, 0)
}
fn to_json_capped(
store: &dyn ObjectStore,
obj: &Object,
max_depth: usize,
depth: usize,
) -> Result<serde_json::Value, TreeError> {
match obj {
Object::Atom(atom) => Ok(atom_to_json(atom)),
Object::Node(node) => {
if depth >= max_depth {
let summary = match node {
Node::Map(entries) => serde_json::json!({
"_truncated": true,
"_keys": entries.keys().cloned().collect::<Vec<_>>(),
}),
Node::List(items) | Node::Set(items) => serde_json::json!({
"_truncated": true,
"_len": items.len(),
}),
};
return Ok(summary);
}
match node {
Node::Map(entries) => {
let mut map = serde_json::Map::new();
for (key, child_id) in entries {
let child = store
.get_object(child_id)?
.ok_or(TreeError::ObjectNotFound(*child_id))?;
map.insert(
key.clone(),
to_json_capped(store, &child, max_depth, depth + 1)?,
);
}
Ok(serde_json::Value::Object(map))
}
Node::List(items) | Node::Set(items) => {
let mut arr = Vec::new();
for child_id in items {
let child = store
.get_object(child_id)?
.ok_or(TreeError::ObjectNotFound(*child_id))?;
arr.push(to_json_capped(store, &child, max_depth, depth + 1)?);
}
Ok(serde_json::Value::Array(arr))
}
}
}
}
}
pub fn tree_list_paths(
store: &dyn ObjectStore,
root: &ObjectId,
prefix: &str,
max_depth: usize,
) -> Result<Vec<String>, TreeError> {
let root_obj = store
.get_object(root)?
.ok_or(TreeError::ObjectNotFound(*root))?;
let (start_obj, base_path) = if prefix.is_empty() || prefix == "/" {
(root_obj, String::new())
} else {
let path = StatePath::parse(prefix).map_err(|e| TreeError::PathNotFound(e.to_string()))?;
let obj = tree_get(store, root, &path)?;
(obj, prefix.to_string())
};
let depth = max_depth.min(LIST_PATHS_HARD_CEILING);
let mut paths = Vec::new();
collect_paths(store, &start_obj, &base_path, depth, 0, &mut paths)?;
if paths.len() > LIST_PATHS_MAX_RESULTS {
paths.truncate(LIST_PATHS_MAX_RESULTS);
}
Ok(paths)
}
fn collect_paths(
store: &dyn ObjectStore,
obj: &Object,
current_path: &str,
max_depth: usize,
depth: usize,
paths: &mut Vec<String>,
) -> Result<(), TreeError> {
if depth > max_depth {
return Ok(());
}
if paths.len() >= LIST_PATHS_MAX_RESULTS {
return Ok(());
}
match obj {
Object::Atom(_) => {
let path = if current_path.is_empty() {
"/".to_string()
} else {
current_path.to_string()
};
paths.push(path);
}
Object::Node(node) => match node {
Node::Map(entries) => {
if entries.is_empty() {
let path = if current_path.is_empty() {
"/".to_string()
} else {
current_path.to_string()
};
paths.push(path);
} else {
for (key, child_id) in entries {
let child_path = format!("{}/{}", current_path, key);
let child = store
.get_object(child_id)?
.ok_or(TreeError::ObjectNotFound(*child_id))?;
collect_paths(store, &child, &child_path, max_depth, depth + 1, paths)?;
}
}
}
Node::List(items) => {
for (i, child_id) in items.iter().enumerate() {
let child_path = format!("{}/{}", current_path, i);
let child = store
.get_object(child_id)?
.ok_or(TreeError::ObjectNotFound(*child_id))?;
collect_paths(store, &child, &child_path, max_depth, depth + 1, paths)?;
}
}
Node::Set(items) => {
for (i, child_id) in items.iter().enumerate() {
let child_path = format!("{}/{}", current_path, i);
let child = store
.get_object(child_id)?
.ok_or(TreeError::ObjectNotFound(*child_id))?;
collect_paths(store, &child, &child_path, max_depth, depth + 1, paths)?;
}
}
},
}
Ok(())
}
pub fn tree_search_values(
store: &dyn ObjectStore,
root: &ObjectId,
query: &str,
max_results: usize,
) -> Result<Vec<(String, String)>, TreeError> {
let root_obj = store
.get_object(root)?
.ok_or(TreeError::ObjectNotFound(*root))?;
let query_lower = query.to_lowercase();
let limit = max_results.min(LIST_PATHS_MAX_RESULTS);
let mut results = Vec::new();
search_recursive(store, &root_obj, "", &query_lower, limit, 0, &mut results)?;
Ok(results)
}
fn search_recursive(
store: &dyn ObjectStore,
obj: &Object,
current_path: &str,
query: &str,
max_results: usize,
depth: usize,
results: &mut Vec<(String, String)>,
) -> Result<(), TreeError> {
if results.len() >= max_results {
return Ok(());
}
if depth > LIST_PATHS_HARD_CEILING {
return Ok(());
}
match obj {
Object::Atom(atom) => {
let value_str = match atom {
Atom::String(s) => s.clone(),
Atom::Int(i) => i.to_string(),
Atom::UInt(u) => u.to_string(),
Atom::Float(f) => f.to_string(),
Atom::Bool(b) => b.to_string(),
_ => return Ok(()),
};
if value_str.to_lowercase().contains(query) {
let path = if current_path.is_empty() {
"/".to_string()
} else {
current_path.to_string()
};
results.push((path, value_str));
}
}
Object::Node(node) => match node {
Node::Map(entries) => {
for (key, child_id) in entries {
if results.len() >= max_results {
break;
}
if key.to_lowercase().contains(query) {
let path = format!("{}/{}", current_path, key);
results.push((path.clone(), format!("[key match: {}]", key)));
}
let child = store
.get_object(child_id)?
.ok_or(TreeError::ObjectNotFound(*child_id))?;
let child_path = format!("{}/{}", current_path, key);
search_recursive(
store,
&child,
&child_path,
query,
max_results,
depth + 1,
results,
)?;
}
}
Node::List(items) | Node::Set(items) => {
for (i, child_id) in items.iter().enumerate() {
if results.len() >= max_results {
break;
}
let child = store
.get_object(child_id)?
.ok_or(TreeError::ObjectNotFound(*child_id))?;
let child_path = format!("{}/{}", current_path, i);
search_recursive(
store,
&child,
&child_path,
query,
max_results,
depth + 1,
results,
)?;
}
}
},
}
Ok(())
}
pub fn tree_collect_leaves(
store: &dyn ObjectStore,
root: &ObjectId,
) -> Result<Vec<(String, String)>, TreeError> {
let root_obj = store
.get_object(root)?
.ok_or(TreeError::ObjectNotFound(*root))?;
let mut out = Vec::new();
collect_leaves_recursive(store, &root_obj, "", 0, &mut out)?;
Ok(out)
}
fn collect_leaves_recursive(
store: &dyn ObjectStore,
obj: &Object,
current_path: &str,
depth: usize,
out: &mut Vec<(String, String)>,
) -> Result<(), TreeError> {
if depth > LIST_PATHS_HARD_CEILING {
return Ok(());
}
match obj {
Object::Atom(atom) => {
let value_str = match atom {
Atom::String(s) => s.clone(),
Atom::Int(i) => i.to_string(),
Atom::UInt(u) => u.to_string(),
Atom::Float(f) => f.to_string(),
Atom::Bool(b) => b.to_string(),
_ => return Ok(()),
};
let path = if current_path.is_empty() {
"/".to_string()
} else {
current_path.to_string()
};
out.push((path, value_str));
}
Object::Node(node) => match node {
Node::Map(entries) => {
for (key, child_id) in entries {
let child = store
.get_object(child_id)?
.ok_or(TreeError::ObjectNotFound(*child_id))?;
let child_path = format!("{}/{}", current_path, key);
collect_leaves_recursive(store, &child, &child_path, depth + 1, out)?;
}
}
Node::List(items) | Node::Set(items) => {
for (i, child_id) in items.iter().enumerate() {
let child = store
.get_object(child_id)?
.ok_or(TreeError::ObjectNotFound(*child_id))?;
let child_path = format!("{}/{}", current_path, i);
collect_leaves_recursive(store, &child, &child_path, depth + 1, out)?;
}
}
},
}
Ok(())
}
fn atom_value_string(atom: &Atom) -> Option<String> {
match atom {
Atom::String(s) => Some(s.clone()),
Atom::Int(i) => Some(i.to_string()),
Atom::UInt(u) => Some(u.to_string()),
Atom::Float(f) => Some(f.to_string()),
Atom::Bool(b) => Some(b.to_string()),
_ => None,
}
}
fn collect_leaf_paths(
store: &dyn ObjectStore,
obj: &Object,
current_path: &str,
depth: usize,
out: &mut Vec<String>,
) -> Result<(), TreeError> {
if depth > LIST_PATHS_HARD_CEILING {
return Ok(());
}
match obj {
Object::Atom(atom) => {
if atom_value_string(atom).is_some() {
out.push(if current_path.is_empty() {
"/".to_string()
} else {
current_path.to_string()
});
}
}
Object::Node(node) => {
let children: Vec<(String, ObjectId)> = match node {
Node::Map(entries) => entries
.iter()
.map(|(k, id)| (format!("{}/{}", current_path, k), *id))
.collect(),
Node::List(items) | Node::Set(items) => items
.iter()
.enumerate()
.map(|(i, id)| (format!("{}/{}", current_path, i), *id))
.collect(),
};
for (child_path, child_id) in children {
let child = store
.get_object(&child_id)?
.ok_or(TreeError::ObjectNotFound(child_id))?;
collect_leaf_paths(store, &child, &child_path, depth + 1, out)?;
}
}
}
Ok(())
}
pub fn tree_diff_leaves(
store: &dyn ObjectStore,
old_root: Option<&ObjectId>,
new_root: &ObjectId,
) -> Result<(Vec<String>, Vec<(String, String)>), TreeError> {
let mut removed = Vec::new();
let mut added = Vec::new();
diff_recursive(
store,
old_root.copied(),
Some(*new_root),
"",
0,
&mut removed,
&mut added,
)?;
Ok((removed, added))
}
#[allow(clippy::too_many_arguments)]
fn diff_recursive(
store: &dyn ObjectStore,
old_id: Option<ObjectId>,
new_id: Option<ObjectId>,
path: &str,
depth: usize,
removed: &mut Vec<String>,
added: &mut Vec<(String, String)>,
) -> Result<(), TreeError> {
if old_id == new_id {
return Ok(());
}
if depth > LIST_PATHS_HARD_CEILING {
return Ok(());
}
let old_obj = match old_id {
Some(id) => store.get_object(&id)?,
None => None,
};
let new_obj = match new_id {
Some(id) => store.get_object(&id)?,
None => None,
};
match (old_obj, new_obj) {
(None, None) => {}
(Some(o), None) => collect_leaf_paths(store, &o, path, depth, removed)?,
(None, Some(n)) => collect_leaves_recursive(store, &n, path, depth, added)?,
(Some(Object::Atom(_)), Some(Object::Atom(new_atom))) => {
let leaf = if path.is_empty() { "/" } else { path }.to_string();
removed.push(leaf.clone());
if let Some(v) = atom_value_string(&new_atom) {
added.push((leaf, v));
}
}
(Some(o @ Object::Node(_)), Some(Object::Atom(new_atom))) => {
collect_leaf_paths(store, &o, path, depth, removed)?;
let leaf = if path.is_empty() { "/" } else { path }.to_string();
if let Some(v) = atom_value_string(&new_atom) {
added.push((leaf, v));
}
}
(Some(Object::Atom(_)), Some(n @ Object::Node(_))) => {
let leaf = if path.is_empty() { "/" } else { path }.to_string();
removed.push(leaf);
collect_leaves_recursive(store, &n, path, depth, added)?;
}
(Some(Object::Node(on)), Some(Object::Node(nn))) => match (on, nn) {
(Node::Map(oe), Node::Map(ne)) => {
let mut keys: std::collections::BTreeSet<&String> = oe.keys().collect();
keys.extend(ne.keys());
for k in keys {
let child_path = format!("{}/{}", path, k);
diff_recursive(
store,
oe.get(k).copied(),
ne.get(k).copied(),
&child_path,
depth + 1,
removed,
added,
)?;
}
}
(Node::List(oi) | Node::Set(oi), Node::List(ni) | Node::Set(ni)) => {
let max = oi.len().max(ni.len());
for i in 0..max {
let child_path = format!("{}/{}", path, i);
diff_recursive(
store,
oi.get(i).copied(),
ni.get(i).copied(),
&child_path,
depth + 1,
removed,
added,
)?;
}
}
(old_node, new_node) => {
collect_leaf_paths(store, &Object::Node(old_node), path, depth, removed)?;
collect_leaves_recursive(store, &Object::Node(new_node), path, depth, added)?;
}
},
}
Ok(())
}
pub fn json_to_tree(
store: &dyn ObjectStore,
value: &serde_json::Value,
) -> Result<ObjectId, TreeError> {
let obj = json_to_object(store, value)?;
let id = store.put_object(&obj)?;
Ok(id)
}
fn navigate(
current: &Object,
component: &PathComponent,
store: &dyn ObjectStore,
path: &StatePath,
_depth: usize,
) -> Result<Object, TreeError> {
match (current, component) {
(Object::Node(Node::Map(entries)), PathComponent::Key(key)) => {
let child_id = entries
.get(key)
.ok_or_else(|| TreeError::PathNotFound(path.to_string()))?;
store
.get_object(child_id)?
.ok_or(TreeError::ObjectNotFound(*child_id))
}
(Object::Node(Node::List(items)), PathComponent::Index(idx)) => {
let child_id = items.get(*idx).ok_or(TreeError::IndexOutOfBounds {
index: *idx,
length: items.len(),
})?;
store
.get_object(child_id)?
.ok_or(TreeError::ObjectNotFound(*child_id))
}
(Object::Node(Node::Map(_)), PathComponent::Index(_)) => Err(TreeError::TypeMismatch {
path: path.to_string(),
expected: "list".to_string(),
found: "map".to_string(),
}),
(Object::Node(Node::List(_)), PathComponent::Key(_)) => Err(TreeError::TypeMismatch {
path: path.to_string(),
expected: "map".to_string(),
found: "list".to_string(),
}),
(Object::Atom(_), _) => Err(TreeError::CannotNavigateAtom(path.to_string())),
_ => Err(TreeError::PathNotFound(path.to_string())),
}
}
fn set_recursive(
store: &dyn ObjectStore,
current: &Object,
components: &[PathComponent],
depth: usize,
value_id: ObjectId,
) -> Result<Object, TreeError> {
if depth >= components.len() {
return store
.get_object(&value_id)?
.ok_or(TreeError::ObjectNotFound(value_id));
}
let component = &components[depth];
let is_last = depth == components.len() - 1;
match (current, component) {
(Object::Node(Node::Map(entries)), PathComponent::Key(key)) => {
let child_value_id = if is_last {
value_id
} else {
let child = if let Some(child_id) = entries.get(key) {
store
.get_object(child_id)?
.ok_or(TreeError::ObjectNotFound(*child_id))?
} else {
Object::empty_map()
};
let new_child = set_recursive(store, &child, components, depth + 1, value_id)?;
store.put_object(&new_child)?
};
let mut new_entries = entries.clone();
new_entries.insert(key.clone(), child_value_id);
Ok(Object::map(new_entries))
}
(Object::Node(Node::List(items)), PathComponent::Index(idx)) => {
if *idx >= items.len() {
return Err(TreeError::IndexOutOfBounds {
index: *idx,
length: items.len(),
});
}
let child_value_id = if is_last {
value_id
} else {
let child = store
.get_object(&items[*idx])?
.ok_or_else(|| TreeError::ObjectNotFound(items[*idx]))?;
let new_child = set_recursive(store, &child, components, depth + 1, value_id)?;
store.put_object(&new_child)?
};
let mut new_items = items.clone();
new_items[*idx] = child_value_id;
Ok(Object::list(new_items))
}
(Object::Atom(Atom::Null), PathComponent::Key(key)) => {
let mut entries = BTreeMap::new();
if is_last {
entries.insert(key.clone(), value_id);
} else {
let child = Object::empty_map();
let new_child = set_recursive(store, &child, components, depth + 1, value_id)?;
let child_id = store.put_object(&new_child)?;
entries.insert(key.clone(), child_id);
}
Ok(Object::map(entries))
}
(Object::Atom(atom), _) => Err(TreeError::TypeMismatch {
path: format!("at depth {}", depth),
expected: "map or list".to_string(),
found: format!("a {} value", atom_kind(atom)),
}),
_ => Err(TreeError::TypeMismatch {
path: format!("at depth {}", depth),
expected: "map or list".to_string(),
found: "incompatible type".to_string(),
}),
}
}
fn atom_kind(atom: &Atom) -> &'static str {
match atom {
Atom::Null => "null",
Atom::Bool(_) => "boolean",
Atom::Int(_) | Atom::UInt(_) | Atom::Float(_) => "number",
Atom::String(_) => "string",
Atom::Bytes(_) => "bytes",
}
}
fn delete_recursive(
store: &dyn ObjectStore,
current: &Object,
components: &[PathComponent],
depth: usize,
) -> Result<Object, TreeError> {
let component = &components[depth];
let is_last = depth == components.len() - 1;
match (current, component) {
(Object::Node(Node::Map(entries)), PathComponent::Key(key)) => {
if !entries.contains_key(key) {
return Err(TreeError::PathNotFound(format!("key '{}' not found", key)));
}
if is_last {
let mut new_entries = entries.clone();
new_entries.remove(key);
Ok(Object::map(new_entries))
} else {
let child_id = entries.get(key).unwrap();
let child = store
.get_object(child_id)?
.ok_or(TreeError::ObjectNotFound(*child_id))?;
let new_child = delete_recursive(store, &child, components, depth + 1)?;
let new_child_id = store.put_object(&new_child)?;
let mut new_entries = entries.clone();
new_entries.insert(key.clone(), new_child_id);
Ok(Object::map(new_entries))
}
}
(Object::Node(Node::List(items)), PathComponent::Index(idx)) => {
if *idx >= items.len() {
return Err(TreeError::IndexOutOfBounds {
index: *idx,
length: items.len(),
});
}
if is_last {
let mut new_items = items.clone();
new_items.remove(*idx);
Ok(Object::list(new_items))
} else {
let child = store
.get_object(&items[*idx])?
.ok_or_else(|| TreeError::ObjectNotFound(items[*idx]))?;
let new_child = delete_recursive(store, &child, components, depth + 1)?;
let new_child_id = store.put_object(&new_child)?;
let mut new_items = items.clone();
new_items[*idx] = new_child_id;
Ok(Object::list(new_items))
}
}
_ => Err(TreeError::PathNotFound(format!(
"cannot navigate at depth {}",
depth
))),
}
}
fn atom_to_json(atom: &Atom) -> serde_json::Value {
match atom {
Atom::Null => serde_json::Value::Null,
Atom::Bool(b) => serde_json::Value::Bool(*b),
Atom::Int(i) => serde_json::json!(*i),
Atom::UInt(u) => serde_json::json!(*u),
Atom::Float(f) => serde_json::json!(*f),
Atom::String(s) => serde_json::Value::String(s.clone()),
Atom::Bytes(b) => {
use serde_json::Value;
Value::String(format!("base64:{}", base64_encode(b)))
}
}
}
fn json_to_object(store: &dyn ObjectStore, value: &serde_json::Value) -> Result<Object, TreeError> {
match value {
serde_json::Value::Null => Ok(Object::null()),
serde_json::Value::Bool(b) => Ok(Object::bool(*b)),
serde_json::Value::Number(n) => Ok(Object::from_json_number(n)),
serde_json::Value::String(s) => Ok(Object::string(s.clone())),
serde_json::Value::Array(arr) => {
let mut ids = Vec::new();
for item in arr {
let child = json_to_object(store, item)?;
let id = store.put_object(&child)?;
ids.push(id);
}
Ok(Object::list(ids))
}
serde_json::Value::Object(map) => {
let mut entries = BTreeMap::new();
for (key, val) in map {
let child = json_to_object(store, val)?;
let id = store.put_object(&child)?;
entries.insert(key.clone(), id);
}
Ok(Object::map(entries))
}
}
}
fn base64_encode(data: &[u8]) -> String {
const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut result = String::new();
for chunk in data.chunks(3) {
let b0 = chunk[0] as u32;
let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
let triple = (b0 << 16) | (b1 << 8) | b2;
result.push(CHARS[((triple >> 18) & 0x3F) as usize] as char);
result.push(CHARS[((triple >> 12) & 0x3F) as usize] as char);
if chunk.len() > 1 {
result.push(CHARS[((triple >> 6) & 0x3F) as usize] as char);
} else {
result.push('=');
}
if chunk.len() > 2 {
result.push(CHARS[(triple & 0x3F) as usize] as char);
} else {
result.push('=');
}
}
result
}
#[derive(Debug, thiserror::Error)]
pub enum TreeError {
#[error("object not found: {0}")]
ObjectNotFound(ObjectId),
#[error("path not found: {0}")]
PathNotFound(String),
#[error("index {index} out of bounds (length {length})")]
IndexOutOfBounds { index: usize, length: usize },
#[error("type mismatch at {path}: expected {expected}, found {found}")]
TypeMismatch {
path: String,
expected: String,
found: String,
},
#[error("cannot navigate into atom at: {0}")]
CannotNavigateAtom(String),
#[error("invalid JSON: {0}")]
InvalidJson(String),
#[error("storage error: {0}")]
Storage(#[from] StorageError),
}
#[cfg(test)]
mod tests {
use super::*;
use agentstategraph_storage::SqliteStorage;
fn setup() -> (SqliteStorage, ObjectId) {
let store = SqliteStorage::in_memory().expect("in-memory sqlite");
let root_id = json_to_tree(
&store,
&serde_json::json!({
"name": "test-cluster",
"nodes": [
{ "hostname": "jetson-01", "status": "healthy" },
{ "hostname": "jetson-02", "status": "unhealthy" }
],
"config": {
"network": { "subnet": "10.0.0.0/24" }
}
}),
)
.unwrap();
(store, root_id)
}
#[test]
fn test_get_root() {
let (store, root_id) = setup();
let root = tree_get(&store, &root_id, &StatePath::root()).unwrap();
assert!(matches!(root, Object::Node(Node::Map(_))));
}
#[test]
fn test_get_simple_key() {
let (store, root_id) = setup();
let path = StatePath::parse("/name").unwrap();
let obj = tree_get(&store, &root_id, &path).unwrap();
assert_eq!(obj, Object::string("test-cluster"));
}
#[test]
fn test_get_nested_path() {
let (store, root_id) = setup();
let path = StatePath::parse("/config/network/subnet").unwrap();
let obj = tree_get(&store, &root_id, &path).unwrap();
assert_eq!(obj, Object::string("10.0.0.0/24"));
}
#[test]
fn test_get_array_index() {
let (store, root_id) = setup();
let path = StatePath::parse("/nodes/0/hostname").unwrap();
let obj = tree_get(&store, &root_id, &path).unwrap();
assert_eq!(obj, Object::string("jetson-01"));
}
#[test]
fn test_get_nonexistent_path() {
let (store, root_id) = setup();
let path = StatePath::parse("/nonexistent").unwrap();
assert!(tree_get(&store, &root_id, &path).is_err());
}
#[test]
fn test_set_existing_value() {
let (store, root_id) = setup();
let path = StatePath::parse("/nodes/1/status").unwrap();
let new_root = tree_set(&store, &root_id, &path, &Object::string("healthy")).unwrap();
let obj = tree_get(&store, &new_root, &path).unwrap();
assert_eq!(obj, Object::string("healthy"));
let old_obj = tree_get(&store, &root_id, &path).unwrap();
assert_eq!(old_obj, Object::string("unhealthy"));
}
#[test]
fn test_set_new_key() {
let (store, root_id) = setup();
let path = StatePath::parse("/version").unwrap();
let new_root = tree_set(&store, &root_id, &path, &Object::string("1.0")).unwrap();
let obj = tree_get(&store, &new_root, &path).unwrap();
assert_eq!(obj, Object::string("1.0"));
let name = tree_get(&store, &new_root, &StatePath::parse("/name").unwrap()).unwrap();
assert_eq!(name, Object::string("test-cluster"));
}
#[test]
fn test_set_creates_intermediate_maps() {
let (store, root_id) = setup();
let path = StatePath::parse("/new/deeply/nested/value").unwrap();
let new_root = tree_set(&store, &root_id, &path, &Object::int(42)).unwrap();
let obj = tree_get(&store, &new_root, &path).unwrap();
assert_eq!(obj, Object::int(42));
}
#[test]
fn test_structural_sharing() {
let (store, root_id) = setup();
let path = StatePath::parse("/name").unwrap();
let new_root = tree_set(&store, &root_id, &path, &Object::string("new-name")).unwrap();
let old_root_obj = store.get_object(&root_id).unwrap().unwrap();
let new_root_obj = store.get_object(&new_root).unwrap().unwrap();
if let (Object::Node(Node::Map(old_entries)), Object::Node(Node::Map(new_entries))) =
(&old_root_obj, &new_root_obj)
{
assert_eq!(
old_entries.get("config"),
new_entries.get("config"),
"unmodified subtrees should share the same ObjectId (structural sharing)"
);
assert_ne!(
old_entries.get("name"),
new_entries.get("name"),
"modified values should have different ObjectIds"
);
} else {
panic!("roots should be maps");
}
}
#[test]
fn test_delete_key() {
let (store, root_id) = setup();
let path = StatePath::parse("/name").unwrap();
let new_root = tree_delete(&store, &root_id, &path).unwrap();
assert!(tree_get(&store, &new_root, &path).is_err());
let subnet = tree_get(
&store,
&new_root,
&StatePath::parse("/config/network/subnet").unwrap(),
)
.unwrap();
assert_eq!(subnet, Object::string("10.0.0.0/24"));
}
#[test]
fn test_delete_array_element() {
let (store, root_id) = setup();
let path = StatePath::parse("/nodes/0").unwrap();
let new_root = tree_delete(&store, &root_id, &path).unwrap();
let hostname = tree_get(
&store,
&new_root,
&StatePath::parse("/nodes/0/hostname").unwrap(),
)
.unwrap();
assert_eq!(hostname, Object::string("jetson-02"));
}
#[test]
fn test_list_paths_truncates_at_max_results() {
let store = SqliteStorage::in_memory().expect("in-memory sqlite");
let mut obj = serde_json::Map::new();
let wide = LIST_PATHS_MAX_RESULTS + 500;
for i in 0..wide {
obj.insert(format!("k{}", i), serde_json::Value::from(i as i64));
}
let root_id = json_to_tree(&store, &serde_json::Value::Object(obj)).unwrap();
let paths = tree_list_paths(&store, &root_id, "/", 10).unwrap();
assert_eq!(
paths.len(),
LIST_PATHS_MAX_RESULTS,
"list_paths must cap results at LIST_PATHS_MAX_RESULTS"
);
}
#[test]
fn test_list_paths_clamps_usize_max_depth() {
let (store, root_id) = setup();
let paths = tree_list_paths(&store, &root_id, "/", usize::MAX).unwrap();
assert!(!paths.is_empty());
assert!(paths.len() <= LIST_PATHS_MAX_RESULTS);
}
#[test]
fn test_to_json_capped_truncates_nodes_at_depth() {
let (store, root_id) = setup();
let root = tree_get(&store, &root_id, &StatePath::root()).unwrap();
let capped = tree_to_json_capped(&store, &root, 1).unwrap();
assert_eq!(capped["name"], "test-cluster");
assert_eq!(capped["nodes"]["_truncated"], true);
assert_eq!(capped["nodes"]["_len"], 2);
assert!(capped["nodes"].get("0").is_none());
assert_eq!(capped["config"]["_truncated"], true);
assert_eq!(capped["config"]["_keys"][0], "network");
assert!(capped["config"]["network"].is_null());
}
#[test]
fn test_to_json_capped_deep_enough_matches_full() {
let (store, root_id) = setup();
let root = tree_get(&store, &root_id, &StatePath::root()).unwrap();
let full = tree_to_json(&store, &root).unwrap();
let capped = tree_to_json_capped(&store, &root, 64).unwrap();
assert_eq!(full, capped);
}
#[test]
fn test_json_roundtrip() {
let (store, root_id) = setup();
let root = tree_get(&store, &root_id, &StatePath::root()).unwrap();
let json = tree_to_json(&store, &root).unwrap();
let new_root_id = json_to_tree(&store, &json).unwrap();
assert_eq!(
root_id, new_root_id,
"JSON roundtrip should produce same ObjectId"
);
}
}