chrs_graph/
lib.rs

1//! chrs-graph library implementation using Dolt for graph persistence.
2
3use chrono::Utc;
4use serde_json::Value;
5use std::{path::Path, process::Command};
6use thiserror::Error;
7use uuid::Uuid;
8
9/// Enumeration of possible errors that can arise while interacting with the `DoltGraph`.
10///
11/// Each variant wraps an underlying error source, making it easier for callers to
12/// understand the failure context and decide on remedial actions.
13#[derive(Error, Debug)]
14pub enum GraphError {
15    /// Propagates I/O errors from the standard library (e.g., filesystem access).
16    #[error("IO error: {0}")]
17    Io(#[from] std::io::Error),
18    /// Represents a failure when executing a Dolt command.
19    #[error("Command failed: {0}")]
20    CommandFailed(String),
21    /// Propagates JSON (de)serialization errors from `serde_json`.
22    #[error("Serde JSON error: {0}")]
23    SerdeJson(#[from] serde_json::Error),
24    /// A generic catch‑all for errors that don't fit the other categories.
25    #[error("Other error: {0}")]
26    Other(String),
27}
28
29/// Wrapper around a Dolt repository that stores graph data.
30///
31/// The `DoltGraph` type encapsulates a path to a Dolt repo and provides high‑level
32/// operations such as initializing the repo, committing changes, creating tables, and
33/// inserting nodes expressed as JSON objects.
34///
35/// # Architectural Rationale
36/// Dolt offers a Git‑like version‑controlled SQL database, which aligns well with CHORUS's
37/// need for an immutable, query‑able history of graph mutations. By wrapping Dolt commands in
38/// this struct we isolate the rest of the codebase from the command‑line interface, making the
39/// graph layer portable and easier to test.
40pub struct DoltGraph {
41    /// Filesystem path to the root of the Dolt repository.
42    pub repo_path: std::path::PathBuf,
43}
44
45impl DoltGraph {
46    /// Initialise (or open) a Dolt repository at the given `path`.
47    ///
48    /// If the directory does not already contain a `.dolt` sub‑directory, the function runs
49    /// `dolt init` to create a new repository. Errors from the underlying command are wrapped in
50    /// `GraphError::CommandFailed`.
51    pub fn init(path: &Path) -> Result<Self, GraphError> {
52        if !path.join(".dolt").exists() {
53            let status = Command::new("dolt")
54                .arg("init")
55                .current_dir(path)
56                .status()?;
57            if !status.success() {
58                return Err(GraphError::CommandFailed(format!(
59                    "dolt init failed with status {:?}",
60                    status
61                )));
62            }
63        }
64        Ok(Self {
65            repo_path: path.to_path_buf(),
66        })
67    }
68
69    /// Execute a Dolt command with the specified arguments.
70    ///
71    /// This helper centralises command execution and error handling. It runs `dolt` with the
72    /// provided argument slice, captures stdout/stderr, and returns `GraphError::CommandFailed`
73    /// when the command exits with a non‑zero status.
74    fn run_cmd(&self, args: &[&str]) -> Result<(), GraphError> {
75        let output = Command::new("dolt")
76            .args(args)
77            .current_dir(&self.repo_path)
78            .output()?;
79        if !output.status.success() {
80            let stderr = String::from_utf8_lossy(&output.stderr);
81            return Err(GraphError::CommandFailed(stderr.to_string()));
82        }
83        Ok(())
84    }
85
86    /// Stage all changes and commit them with the provided `message`.
87    ///
88    /// The method first runs `dolt add -A` to stage modifications, then `dolt commit -m`.
89    /// Any failure in these steps propagates as a `GraphError`.
90    pub fn commit(&self, message: &str) -> Result<(), GraphError> {
91        self.run_cmd(&["add", "-A"])?;
92        self.run_cmd(&["commit", "-m", message])?;
93        Ok(())
94    }
95
96    /// Create a SQL table within the Dolt repository.
97    ///
98    /// `schema` should be a comma‑separated column definition list (e.g., `"id INT PRIMARY KEY, name TEXT"`).
99    /// If the table already exists, the function treats it as a no‑op and returns `Ok(())`.
100    pub fn create_table(&self, table_name: &str, schema: &str) -> Result<(), GraphError> {
101        let query = format!("CREATE TABLE {} ({})", table_name, schema);
102        if let Err(e) = self.run_cmd(&["sql", "-q", &query]) {
103            if e.to_string().contains("already exists") {
104                // Table is already present – not an error for our use‑case.
105                return Ok(());
106            }
107            return Err(e);
108        }
109        self.commit(&format!("Create table {}", table_name))?;
110        Ok(())
111    }
112
113    /// Insert a node represented by a JSON object into the specified `table`.
114    ///
115    /// The JSON `data` must be an object where keys correspond to column names. Supported value
116    /// types are strings, numbers, booleans, and null. Complex JSON structures are rejected because
117    /// they cannot be directly mapped to SQL scalar columns.
118    pub fn insert_node(&self, table: &str, data: Value) -> Result<(), GraphError> {
119        let obj = data
120            .as_object()
121            .ok_or_else(|| GraphError::Other("Data must be a JSON object".into()))?;
122        let columns: Vec<String> = obj.keys().cloned().collect();
123        let mut values: Vec<String> = Vec::new();
124        for key in &columns {
125            let v = &obj[key];
126            let sql_val = match v {
127                Value::String(s) => format!("'{}'", s.replace('\'', "''")),
128                Value::Number(n) => n.to_string(),
129                Value::Bool(b) => {
130                    if *b {
131                        "TRUE".into()
132                    } else {
133                        "FALSE".into()
134                    }
135                }
136                Value::Null => "NULL".into(),
137                _ => return Err(GraphError::Other("Unsupported JSON value type".into())),
138            };
139            values.push(sql_val);
140        }
141        let query = format!(
142            "INSERT INTO {} ({}) VALUES ({})",
143            table,
144            columns.join(", "),
145            values.join(", ")
146        );
147        self.run_cmd(&["sql", "-q", &query])?;
148        Ok(())
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use tempfile::TempDir;
156
157    #[test]
158    fn test_init_create_table_and_commit() {
159        let dir = TempDir::new().unwrap();
160        // Initialise a Dolt repository in a temporary directory.
161        let graph = DoltGraph::init(dir.path()).expect("init failed");
162        // Create a simple `nodes` table.
163        graph
164            .create_table("nodes", "id INT PRIMARY KEY, name TEXT")
165            .expect("create table failed");
166    }
167}