//! EngramClient trait — thin abstraction over the Engram graph engine. //! //! el-identity does not depend on the Engram crate directly. Instead, it //! defines this trait and accepts any implementor. In production, the host //! application wires in a real Engram client. In tests, `MockEngramClient` //! provides an in-memory HashMap-backed implementation. use crate::error::IdentityError; use std::collections::HashMap; use std::sync::{Arc, RwLock}; /// Minimal graph operations required by el-identity. pub trait EngramClient: Send + Sync { /// Fetch a node by its ID. Returns `None` if the node does not exist. fn get_node(&self, id: &str) -> Result, IdentityError>; /// Create a new node of the given type with the given data. /// Returns the new node's ID. fn create_node( &self, node_type: &str, data: serde_json::Value, ) -> Result; /// Create a directed edge between two nodes. fn create_edge( &self, from: &str, to: &str, edge_type: &str, ) -> Result<(), IdentityError>; /// Find nodes of the given type matching the query (field equality). fn find_nodes( &self, node_type: &str, query: serde_json::Value, ) -> Result, IdentityError>; /// Delete a node by ID. Edges referencing it are also removed. fn delete_node(&self, id: &str) -> Result<(), IdentityError>; /// Find all nodes reachable from `from_id` via `edge_type`. fn find_connected( &self, from_id: &str, edge_type: &str, ) -> Result, IdentityError>; } // ── MockEngramClient ────────────────────────────────────────────────────────── #[derive(Debug)] struct NodeEntry { node_type: String, data: serde_json::Value, } #[derive(Debug, Clone)] struct Edge { from: String, to: String, edge_type: String, } /// In-memory Engram client for unit tests. /// /// Stores nodes in a `HashMap` and edges in a `Vec`. /// Thread-safe via `RwLock`. #[derive(Debug, Default)] pub struct MockEngramClient { nodes: RwLock>, edges: RwLock>, } impl MockEngramClient { pub fn new() -> Self { Self::default() } /// Count nodes of a specific type (useful in tests). pub fn count_nodes(&self, node_type: &str) -> usize { self.nodes .read() .expect("nodes lock poisoned") .values() .filter(|n| n.node_type == node_type) .count() } /// Count edges of a specific type. pub fn count_edges(&self, edge_type: &str) -> usize { self.edges .read() .expect("edges lock poisoned") .iter() .filter(|e| e.edge_type == edge_type) .count() } } impl EngramClient for MockEngramClient { fn get_node(&self, id: &str) -> Result, IdentityError> { let nodes = self.nodes.read().expect("nodes lock poisoned"); Ok(nodes.get(id).map(|n| n.data.clone())) } fn create_node( &self, node_type: &str, data: serde_json::Value, ) -> Result { // Extract the node's own "id" field if present, otherwise generate one. let id = data .get("id") .and_then(|v| v.as_str()) .map(|s| s.to_string()) .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); self.nodes .write() .expect("nodes lock poisoned") .insert( id.clone(), NodeEntry { node_type: node_type.to_string(), data, }, ); Ok(id) } fn create_edge( &self, from: &str, to: &str, edge_type: &str, ) -> Result<(), IdentityError> { self.edges.write().expect("edges lock poisoned").push(Edge { from: from.to_string(), to: to.to_string(), edge_type: edge_type.to_string(), }); Ok(()) } fn find_nodes( &self, node_type: &str, query: serde_json::Value, ) -> Result, IdentityError> { let nodes = self.nodes.read().expect("nodes lock poisoned"); let results = nodes .values() .filter(|n| n.node_type == node_type) .filter(|n| matches_query(&n.data, &query)) .map(|n| n.data.clone()) .collect(); Ok(results) } fn delete_node(&self, id: &str) -> Result<(), IdentityError> { self.nodes .write() .expect("nodes lock poisoned") .remove(id); // Remove any edges referencing this node. self.edges .write() .expect("edges lock poisoned") .retain(|e| e.from != id && e.to != id); Ok(()) } fn find_connected( &self, from_id: &str, edge_type: &str, ) -> Result, IdentityError> { let edges = self.edges.read().expect("edges lock poisoned"); let to_ids: Vec = edges .iter() .filter(|e| e.from == from_id && e.edge_type == edge_type) .map(|e| e.to.clone()) .collect(); drop(edges); let nodes = self.nodes.read().expect("nodes lock poisoned"); let results = to_ids .iter() .filter_map(|id| nodes.get(id).map(|n| n.data.clone())) .collect(); Ok(results) } } /// Check whether a node's data matches all key-value pairs in the query. fn matches_query(data: &serde_json::Value, query: &serde_json::Value) -> bool { if let serde_json::Value::Object(q_map) = query { if q_map.is_empty() { return true; } if let serde_json::Value::Object(data_map) = data { return q_map.iter().all(|(k, v)| data_map.get(k) == Some(v)); } return false; } true // empty or non-object query matches everything } /// Convenience: wrap a MockEngramClient in Arc for trait object use. pub fn mock_client() -> Arc { Arc::new(MockEngramClient::new()) }