slop-ai 0.2.0

Rust SDK for the SLOP protocol — let AI observe and interact with your app's state
Documentation
//! Recursive diff of two SLOP trees producing JSON Patch operations.
//!
//! Paths in generated ops use node IDs for children segments (not array indices),
//! matching the SLOP patch convention.

use crate::types::{PatchOp, PatchOpKind, SlopNode};

/// RFC 6901 JSON Pointer escape for property-key segments.
/// Node ID segments must not contain '/' or '~' and are not escaped.
pub fn escape_pointer_segment(key: &str) -> String {
    key.replace('~', "~0").replace('/', "~1")
}

/// Reverse of `escape_pointer_segment`.
pub fn unescape_pointer_segment(segment: &str) -> String {
    segment.replace("~1", "/").replace("~0", "~")
}

/// Recursively diff two trees and return patch operations.
pub fn diff_nodes(old: &SlopNode, new: &SlopNode, base_path: &str) -> Vec<PatchOp> {
    let mut ops = Vec::new();

    // --- properties ---
    diff_properties(old, new, base_path, &mut ops);

    // --- affordances (replace entire list if changed) ---
    let old_aff = old
        .affordances
        .as_ref()
        .map(|a| serde_json::to_value(a).unwrap());
    let new_aff = new
        .affordances
        .as_ref()
        .map(|a| serde_json::to_value(a).unwrap());
    if old_aff != new_aff {
        match (&old_aff, &new_aff) {
            (_, Some(val)) => ops.push(PatchOp {
                op: if old_aff.is_some() {
                    PatchOpKind::Replace
                } else {
                    PatchOpKind::Add
                },
                path: format!("{base_path}/affordances"),
                value: Some(val.clone()),
                index: None,
            }),
            (Some(_), None) => ops.push(PatchOp {
                op: PatchOpKind::Remove,
                path: format!("{base_path}/affordances"),
                value: None,
                index: None,
            }),
            (None, None) => {}
        }
    }

    // --- meta (replace entire object if changed) ---
    let old_meta = old.meta.as_ref().map(|m| serde_json::to_value(m).unwrap());
    let new_meta = new.meta.as_ref().map(|m| serde_json::to_value(m).unwrap());
    if old_meta != new_meta {
        match (&old_meta, &new_meta) {
            (_, Some(val)) => ops.push(PatchOp {
                op: if old_meta.is_some() {
                    PatchOpKind::Replace
                } else {
                    PatchOpKind::Add
                },
                path: format!("{base_path}/meta"),
                value: Some(val.clone()),
                index: None,
            }),
            (Some(_), None) => ops.push(PatchOp {
                op: PatchOpKind::Remove,
                path: format!("{base_path}/meta"),
                value: None,
                index: None,
            }),
            (None, None) => {}
        }
    }

    // --- children (ordered; emit remove/add(index)/move to preserve order) ---
    let old_children = old.children.as_deref().unwrap_or(&[]);
    let new_children = new.children.as_deref().unwrap_or(&[]);

    let old_ids: std::collections::HashMap<&str, &SlopNode> =
        old_children.iter().map(|c| (c.id.as_str(), c)).collect();
    let new_ids: std::collections::HashMap<&str, &SlopNode> =
        new_children.iter().map(|c| (c.id.as_str(), c)).collect();

    let mut working: Vec<String> = Vec::new();
    for child in old_children {
        if !new_ids.contains_key(child.id.as_str()) {
            ops.push(PatchOp {
                op: PatchOpKind::Remove,
                path: format!("{base_path}/{}", child.id),
                value: None,
                index: None,
            });
        } else {
            working.push(child.id.clone());
        }
    }

    for (i, child) in new_children.iter().enumerate() {
        if !old_ids.contains_key(child.id.as_str()) {
            ops.push(PatchOp {
                op: PatchOpKind::Add,
                path: format!("{base_path}/{}", child.id),
                value: Some(serde_json::to_value(child).unwrap()),
                index: Some(i),
            });
            working.insert(i, child.id.clone());
        }
    }

    for (i, child) in new_children.iter().enumerate() {
        if working.get(i).map(String::as_str) == Some(child.id.as_str()) {
            continue;
        }
        let current_idx = working.iter().position(|id| id == &child.id);
        if let Some(ci) = current_idx {
            ops.push(PatchOp {
                op: PatchOpKind::Move,
                path: format!("{base_path}/{}", child.id),
                value: None,
                index: Some(i),
            });
            let id = working.remove(ci);
            working.insert(i, id);
        }
    }

    for child in new_children {
        if let Some(old_child) = old_ids.get(child.id.as_str()) {
            let child_path = format!("{base_path}/{}", child.id);
            ops.extend(diff_nodes(old_child, child, &child_path));
        }
    }

    ops
}

fn diff_properties(old: &SlopNode, new: &SlopNode, base_path: &str, ops: &mut Vec<PatchOp>) {
    let empty_map = serde_json::Map::new();
    let old_props = old.properties.as_ref().unwrap_or(&empty_map);
    let new_props = new.properties.as_ref().unwrap_or(&empty_map);

    let mut all_keys: Vec<&String> = old_props.keys().chain(new_props.keys()).collect();
    all_keys.sort();
    all_keys.dedup();

    for key in all_keys {
        let old_val = old_props.get(key);
        let new_val = new_props.get(key);
        let esc = escape_pointer_segment(key);
        match (old_val, new_val) {
            (None, Some(v)) => ops.push(PatchOp {
                op: PatchOpKind::Add,
                path: format!("{base_path}/properties/{esc}"),
                value: Some(v.clone()),
                index: None,
            }),
            (Some(_), None) => ops.push(PatchOp {
                op: PatchOpKind::Remove,
                path: format!("{base_path}/properties/{esc}"),
                value: None,
                index: None,
            }),
            (Some(old_v), Some(new_v)) if old_v != new_v => ops.push(PatchOp {
                op: PatchOpKind::Replace,
                path: format!("{base_path}/properties/{esc}"),
                value: Some(new_v.clone()),
                index: None,
            }),
            _ => {}
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::types::{Affordance, NodeMeta};
    use serde_json::{json, Value};

    fn node(id: &str) -> SlopNode {
        SlopNode::new(id, "group")
    }

    fn node_with_props(id: &str, props: serde_json::Map<String, Value>) -> SlopNode {
        SlopNode {
            properties: Some(props),
            ..SlopNode::new(id, "group")
        }
    }

    fn props(pairs: Vec<(&str, Value)>) -> serde_json::Map<String, Value> {
        pairs
            .into_iter()
            .map(|(k, v): (&str, Value)| (k.to_string(), v))
            .collect()
    }

    #[test]
    fn test_no_changes() {
        let n = node_with_props("x", props(vec![("a", json!(1))]));
        let ops = diff_nodes(&n, &n, "");
        assert!(ops.is_empty());
    }

    #[test]
    fn test_property_added() {
        let old = node_with_props("x", props(vec![("a", json!(1))]));
        let new = node_with_props("x", props(vec![("a", json!(1)), ("b", json!(2))]));
        let ops = diff_nodes(&old, &new, "");
        assert_eq!(ops.len(), 1);
        assert_eq!(ops[0].op, PatchOpKind::Add);
        assert_eq!(ops[0].path, "/properties/b");
    }

    #[test]
    fn test_property_removed() {
        let old = node_with_props("x", props(vec![("a", json!(1)), ("b", json!(2))]));
        let new = node_with_props("x", props(vec![("a", json!(1))]));
        let ops = diff_nodes(&old, &new, "");
        assert_eq!(ops.len(), 1);
        assert_eq!(ops[0].op, PatchOpKind::Remove);
        assert_eq!(ops[0].path, "/properties/b");
    }

    #[test]
    fn test_property_changed() {
        let old = node_with_props("x", props(vec![("a", json!(1))]));
        let new = node_with_props("x", props(vec![("a", json!(2))]));
        let ops = diff_nodes(&old, &new, "");
        assert_eq!(ops.len(), 1);
        assert_eq!(ops[0].op, PatchOpKind::Replace);
        assert_eq!(ops[0].value, Some(json!(2)));
    }

    #[test]
    fn test_child_added() {
        let old = SlopNode {
            children: Some(vec![]),
            ..node("x")
        };
        let child = node("c1");
        let new = SlopNode {
            children: Some(vec![child]),
            ..node("x")
        };
        let ops = diff_nodes(&old, &new, "");
        assert_eq!(ops.len(), 1);
        assert_eq!(ops[0].op, PatchOpKind::Add);
        assert_eq!(ops[0].path, "/c1");
    }

    #[test]
    fn test_child_removed() {
        let child = node("c1");
        let old = SlopNode {
            children: Some(vec![child]),
            ..node("x")
        };
        let new = SlopNode {
            children: Some(vec![]),
            ..node("x")
        };
        let ops = diff_nodes(&old, &new, "");
        assert_eq!(ops.len(), 1);
        assert_eq!(ops[0].op, PatchOpKind::Remove);
        assert_eq!(ops[0].path, "/c1");
    }

    #[test]
    fn test_nested_diff() {
        let old = SlopNode {
            children: Some(vec![SlopNode {
                children: Some(vec![node_with_props("b", props(vec![("x", json!(1))]))]),
                ..node("a")
            }]),
            ..node("root")
        };
        let new = SlopNode {
            children: Some(vec![SlopNode {
                children: Some(vec![node_with_props("b", props(vec![("x", json!(2))]))]),
                ..node("a")
            }]),
            ..node("root")
        };
        let ops = diff_nodes(&old, &new, "");
        assert_eq!(ops.len(), 1);
        assert_eq!(ops[0].path, "/a/b/properties/x");
        assert_eq!(ops[0].value, Some(json!(2)));
    }

    #[test]
    fn test_meta_changed() {
        let old = SlopNode {
            meta: Some(NodeMeta {
                salience: Some(0.5),
                ..NodeMeta::new()
            }),
            ..node("x")
        };
        let new = SlopNode {
            meta: Some(NodeMeta {
                salience: Some(0.9),
                ..NodeMeta::new()
            }),
            ..node("x")
        };
        let ops = diff_nodes(&old, &new, "");
        assert_eq!(ops.len(), 1);
        assert_eq!(ops[0].op, PatchOpKind::Replace);
        assert_eq!(ops[0].path, "/meta");
    }

    #[test]
    fn test_affordances_changed() {
        let old = SlopNode {
            affordances: Some(vec![Affordance::new("open")]),
            ..node("x")
        };
        let new = SlopNode {
            affordances: Some(vec![Affordance::new("open"), Affordance::new("delete")]),
            ..node("x")
        };
        let ops = diff_nodes(&old, &new, "");
        assert_eq!(ops.len(), 1);
        assert_eq!(ops[0].op, PatchOpKind::Replace);
        assert_eq!(ops[0].path, "/affordances");
    }
}