//! chrs-graph library implementation using Dolt for graph persistence. use chrono::Utc; use serde_json::Value; use std::{path::Path, process::Command}; use thiserror::Error; use uuid::Uuid; /// Enumeration of possible errors that can arise while interacting with the `DoltGraph`. /// /// Each variant wraps an underlying error source, making it easier for callers to /// understand the failure context and decide on remedial actions. #[derive(Error, Debug)] pub enum GraphError { /// Propagates I/O errors from the standard library (e.g., filesystem access). #[error("IO error: {0}")] Io(#[from] std::io::Error), /// Represents a failure when executing a Dolt command. #[error("Command failed: {0}")] CommandFailed(String), /// Propagates JSON (de)serialization errors from `serde_json`. #[error("Serde JSON error: {0}")] SerdeJson(#[from] serde_json::Error), /// A generic catch‑all for errors that don't fit the other categories. #[error("Other error: {0}")] Other(String), } /// Wrapper around a Dolt repository that stores graph data. /// /// The `DoltGraph` type encapsulates a path to a Dolt repo and provides high‑level /// operations such as initializing the repo, committing changes, creating tables, and /// inserting nodes expressed as JSON objects. /// /// # Architectural Rationale /// Dolt offers a Git‑like version‑controlled SQL database, which aligns well with CHORUS's /// need for an immutable, query‑able history of graph mutations. By wrapping Dolt commands in /// this struct we isolate the rest of the codebase from the command‑line interface, making the /// graph layer portable and easier to test. pub struct DoltGraph { /// Filesystem path to the root of the Dolt repository. pub repo_path: std::path::PathBuf, } impl DoltGraph { /// Initialise (or open) a Dolt repository at the given `path`. /// /// If the directory does not already contain a `.dolt` sub‑directory, the function runs /// `dolt init` to create a new repository. Errors from the underlying command are wrapped in /// `GraphError::CommandFailed`. pub fn init(path: &Path) -> Result { if !path.join(".dolt").exists() { let status = Command::new("dolt") .arg("init") .current_dir(path) .status()?; if !status.success() { return Err(GraphError::CommandFailed(format!( "dolt init failed with status {:?}", status ))); } } Ok(Self { repo_path: path.to_path_buf(), }) } /// Execute a Dolt command with the specified arguments. /// /// This helper centralises command execution and error handling. It runs `dolt` with the /// provided argument slice, captures stdout/stderr, and returns `GraphError::CommandFailed` /// when the command exits with a non‑zero status. fn run_cmd(&self, args: &[&str]) -> Result<(), GraphError> { let output = Command::new("dolt") .args(args) .current_dir(&self.repo_path) .output()?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); return Err(GraphError::CommandFailed(stderr.to_string())); } Ok(()) } /// Stage all changes and commit them with the provided `message`. /// /// The method first runs `dolt add -A` to stage modifications, then `dolt commit -m`. /// Any failure in these steps propagates as a `GraphError`. pub fn commit(&self, message: &str) -> Result<(), GraphError> { self.run_cmd(&["add", "-A"])?; self.run_cmd(&["commit", "-m", message])?; Ok(()) } /// Create a SQL table within the Dolt repository. /// /// `schema` should be a comma‑separated column definition list (e.g., `"id INT PRIMARY KEY, name TEXT"`). /// If the table already exists, the function treats it as a no‑op and returns `Ok(())`. pub fn create_table(&self, table_name: &str, schema: &str) -> Result<(), GraphError> { let query = format!("CREATE TABLE {} ({})", table_name, schema); if let Err(e) = self.run_cmd(&["sql", "-q", &query]) { if e.to_string().contains("already exists") { // Table is already present – not an error for our use‑case. return Ok(()); } return Err(e); } self.commit(&format!("Create table {}", table_name))?; Ok(()) } /// Insert a node represented by a JSON object into the specified `table`. /// /// The JSON `data` must be an object where keys correspond to column names. Supported value /// types are strings, numbers, booleans, and null. Complex JSON structures are rejected because /// they cannot be directly mapped to SQL scalar columns. pub fn insert_node(&self, table: &str, data: Value) -> Result<(), GraphError> { let obj = data .as_object() .ok_or_else(|| GraphError::Other("Data must be a JSON object".into()))?; let columns: Vec = obj.keys().cloned().collect(); let mut values: Vec = Vec::new(); for key in &columns { let v = &obj[key]; let sql_val = match v { Value::String(s) => format!("'{}'", s.replace('\'', "''")), Value::Number(n) => n.to_string(), Value::Bool(b) => { if *b { "TRUE".into() } else { "FALSE".into() } } Value::Null => "NULL".into(), _ => return Err(GraphError::Other("Unsupported JSON value type".into())), }; values.push(sql_val); } let query = format!( "INSERT INTO {} ({}) VALUES ({})", table, columns.join(", "), values.join(", ") ); self.run_cmd(&["sql", "-q", &query])?; Ok(()) } } #[cfg(test)] mod tests { use super::*; use tempfile::TempDir; #[test] fn test_init_create_table_and_commit() { let dir = TempDir::new().unwrap(); // Initialise a Dolt repository in a temporary directory. let graph = DoltGraph::init(dir.path()).expect("init failed"); // Create a simple `nodes` table. graph .create_table("nodes", "id INT PRIMARY KEY, name TEXT") .expect("create table failed"); } }