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}