use crate::error::{Result, SpliceError};
use crate::symbol_id;
use rusqlite::{params, Connection};
use std::path::Path;
pub const DB_FILENAME: &str = "operations.db";
pub fn init_execution_log_db(db_dir: &Path) -> Result<Connection> {
if !db_dir.exists() {
std::fs::create_dir_all(db_dir).map_err(|e| SpliceError::IoContext {
context: format!(
"failed to create execution log directory: {}",
db_dir.display()
),
source: e,
})?;
}
let db_path = db_dir.join(DB_FILENAME);
let conn = Connection::open(&db_path).map_err(|e| SpliceError::ExecutionLogError {
message: format!(
"failed to open execution log database: {}",
db_path.display()
),
source: Some(Box::new(e) as Box<dyn std::error::Error + Send + Sync>),
})?;
create_tables(&conn)?;
Ok(conn)
}
fn create_tables(conn: &Connection) -> Result<()> {
conn.execute(
r#"
CREATE TABLE IF NOT EXISTS execution_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
execution_id TEXT NOT NULL UNIQUE,
operation_type TEXT NOT NULL,
status TEXT NOT NULL,
timestamp TEXT NOT NULL,
workspace TEXT,
command_line TEXT,
parameters TEXT,
result_summary TEXT,
error_details TEXT,
duration_ms INTEGER,
created_at INTEGER NOT NULL
)
"#,
[],
)
.map_err(|e| SpliceError::Other(format!("failed to create execution_log table: {}", e)))?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_execution_log_execution_id ON execution_log(execution_id)",
[],
)
.map_err(|e| SpliceError::Other(format!("failed to create execution_id index: {}", e)))?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_execution_log_operation_type ON execution_log(operation_type)",
[],
)
.map_err(|e| SpliceError::Other(format!("failed to create operation_type index: {}", e)))?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_execution_log_timestamp ON execution_log(created_at)",
[],
)
.map_err(|e| SpliceError::Other(format!("failed to create timestamp index: {}", e)))?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_execution_log_status ON execution_log(status)",
[],
)
.map_err(|e| SpliceError::Other(format!("failed to create status index: {}", e)))?;
Ok(())
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct ExecutionLog {
pub id: i64,
pub execution_id: String,
pub operation_type: String,
pub status: String,
pub timestamp: String,
pub workspace: Option<String>,
pub command_line: Option<String>,
pub parameters: Option<serde_json::Value>,
pub result_summary: Option<serde_json::Value>,
pub error_details: Option<serde_json::Value>,
pub duration_ms: Option<i64>,
pub created_at: i64,
}
pub struct ExecutionLogBuilder {
execution_id: String,
operation_type: String,
status: String,
timestamp: String,
workspace: Option<String>,
command_line: Option<String>,
parameters: Option<serde_json::Value>,
result_summary: Option<serde_json::Value>,
error_details: Option<serde_json::Value>,
duration_ms: Option<i64>,
}
impl ExecutionLogBuilder {
pub fn new(execution_id: String, operation_type: String) -> Self {
Self {
execution_id,
operation_type,
status: "ok".to_string(),
timestamp: chrono::Utc::now().to_rfc3339(),
workspace: None,
command_line: None,
parameters: None,
result_summary: None,
error_details: None,
duration_ms: None,
}
}
pub fn status(mut self, status: String) -> Self {
self.status = status;
self
}
pub fn timestamp(mut self, timestamp: String) -> Self {
self.timestamp = timestamp;
self
}
pub fn workspace(mut self, workspace: String) -> Self {
self.workspace = Some(workspace);
self
}
pub fn command_line(mut self, command_line: String) -> Self {
self.command_line = Some(command_line);
self
}
pub fn parameters(mut self, parameters: serde_json::Value) -> Self {
self.parameters = Some(parameters);
self
}
pub fn result_summary(mut self, result_summary: serde_json::Value) -> Self {
self.result_summary = Some(result_summary);
self
}
pub fn error_details(mut self, error_details: serde_json::Value) -> Self {
self.error_details = Some(error_details);
self
}
pub fn duration_ms(mut self, duration_ms: i64) -> Self {
self.duration_ms = Some(duration_ms);
self
}
pub fn build(self) -> ExecutionLog {
let created_at = chrono::Utc::now().timestamp();
ExecutionLog {
id: 0, execution_id: self.execution_id,
operation_type: self.operation_type,
status: self.status,
timestamp: self.timestamp,
workspace: self.workspace,
command_line: self.command_line,
parameters: self.parameters,
result_summary: self.result_summary,
error_details: self.error_details,
duration_ms: self.duration_ms,
created_at,
}
}
}
pub fn generate_delegated_execution_id() -> String {
symbol_id::generate_execution_id()
}
pub fn insert_execution_log(conn: &Connection, log: &ExecutionLog) -> Result<i64> {
let parameters_json = log
.parameters
.as_ref()
.map(|v| serde_json::to_string(v))
.transpose()
.map_err(|e| {
SpliceError::Other(format!("failed to serialize parameters to JSON: {}", e))
})?;
let result_summary_json = log
.result_summary
.as_ref()
.map(|v| serde_json::to_string(v))
.transpose()
.map_err(|e| {
SpliceError::Other(format!("failed to serialize result_summary to JSON: {}", e))
})?;
let error_details_json = log
.error_details
.as_ref()
.map(|v| serde_json::to_string(v))
.transpose()
.map_err(|e| {
SpliceError::Other(format!("failed to serialize error_details to JSON: {}", e))
})?;
conn.execute(
r#"
INSERT INTO execution_log (
execution_id, operation_type, status, timestamp, workspace,
command_line, parameters, result_summary, error_details,
duration_ms, created_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
"#,
params![
&log.execution_id,
&log.operation_type,
&log.status,
&log.timestamp,
&log.workspace,
&log.command_line,
¶meters_json,
&result_summary_json,
&error_details_json,
&log.duration_ms,
&log.created_at,
],
)
.map_err(|e| {
if e.to_string().contains("UNIQUE constraint failed") {
SpliceError::Other(format!(
"execution_id '{}' already exists in execution log",
log.execution_id
))
} else {
SpliceError::Other(format!("failed to insert execution log: {}", e))
}
})?;
Ok(conn.last_insert_rowid())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_table_creation() {
let temp_dir = TempDir::new().unwrap();
let db_dir = temp_dir.path();
let conn = init_execution_log_db(db_dir).unwrap();
let table_exists: bool = conn
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='execution_log'",
[],
|row| row.get(0),
)
.unwrap();
assert!(table_exists, "execution_log table should be created");
}
#[test]
fn test_execution_log_builder() {
let execution_id = uuid::Uuid::new_v4().to_string();
let builder = ExecutionLogBuilder::new(execution_id.clone(), "patch".to_string())
.status("ok".to_string())
.workspace("/path/to/workspace".to_string())
.command_line("splice patch foo bar".to_string())
.duration_ms(1234);
let log = builder.build();
assert_eq!(log.execution_id, execution_id);
assert_eq!(log.operation_type, "patch");
assert_eq!(log.status, "ok");
assert_eq!(log.workspace, Some("/path/to/workspace".to_string()));
assert_eq!(log.command_line, Some("splice patch foo bar".to_string()));
assert_eq!(log.duration_ms, Some(1234));
assert!(log.created_at > 0);
}
#[test]
fn test_indexes_created() {
let temp_dir = TempDir::new().unwrap();
let db_dir = temp_dir.path();
let conn = init_execution_log_db(db_dir).unwrap();
let mut check_index = |index_name: &str| -> bool {
conn.query_row(
&format!(
"SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='{}'",
index_name
),
[],
|row| row.get(0),
)
.unwrap()
};
assert!(
check_index("idx_execution_log_execution_id"),
"execution_id index should exist"
);
assert!(
check_index("idx_execution_log_operation_type"),
"operation_type index should exist"
);
assert!(
check_index("idx_execution_log_timestamp"),
"timestamp index should exist"
);
assert!(
check_index("idx_execution_log_status"),
"status index should exist"
);
}
#[test]
fn test_insert_execution_log() {
let temp_dir = TempDir::new().unwrap();
let db_dir = temp_dir.path();
let conn = init_execution_log_db(db_dir).unwrap();
let execution_id = uuid::Uuid::new_v4().to_string();
let log = ExecutionLogBuilder::new(execution_id.clone(), "delete".to_string())
.status("error".to_string())
.error_details(serde_json::json!({"message": "test error"}))
.build();
let row_id = insert_execution_log(&conn, &log).unwrap();
assert!(row_id > 0, "insert should return valid row ID");
let retrieved: String = conn
.query_row(
"SELECT execution_id FROM execution_log WHERE id = ?1",
params![row_id],
|row| row.get(0),
)
.unwrap();
assert_eq!(retrieved, execution_id);
}
#[test]
fn test_unique_execution_id() {
let temp_dir = TempDir::new().unwrap();
let db_dir = temp_dir.path();
let conn = init_execution_log_db(db_dir).unwrap();
let execution_id = uuid::Uuid::new_v4().to_string();
let log1 = ExecutionLogBuilder::new(execution_id.clone(), "patch".to_string()).build();
insert_execution_log(&conn, &log1).unwrap();
let log2 = ExecutionLogBuilder::new(execution_id, "delete".to_string()).build();
let result = insert_execution_log(&conn, &log2);
assert!(result.is_err(), "duplicate execution_id should fail");
}
#[test]
fn test_execution_log_error_display() {
use crate::error::SpliceError;
let error = SpliceError::ExecutionLogError {
message: "database corrupted".to_string(),
source: None,
};
let error_string = error.to_string();
assert!(
error_string.contains("Execution log database error"),
"error should contain descriptive message"
);
assert!(
error_string.contains("database corrupted"),
"error should contain specific message"
);
}
#[test]
fn test_execution_not_found_error() {
use crate::error::SpliceError;
let execution_id = "non-existent-id";
let error = SpliceError::ExecutionNotFound {
execution_id: execution_id.to_string(),
};
let error_string = error.to_string();
assert!(
error_string.contains(execution_id),
"error should contain execution ID"
);
assert!(
error_string.contains("not found"),
"error should indicate not found"
);
}
#[test]
fn test_delegated_execution_id_format() {
let exec_id = generate_delegated_execution_id();
assert_eq!(
exec_id.len(),
13,
"Delegated execution ID should be 13 characters (8-1-4)"
);
let re = regex::Regex::new(r"^[0-9a-f]{8}-[0-9a-f]{4}$").unwrap();
assert!(
re.is_match(&exec_id),
"Delegated execution ID should match format {{8-hex}}-{{4-hex}}"
);
let parts: Vec<&str> = exec_id.split('-').collect();
assert_eq!(parts.len(), 2, "Should have exactly one dash separator");
assert_eq!(parts[0].len(), 8, "Timestamp part should be 8 characters");
assert_eq!(parts[1].len(), 4, "PID part should be 4 characters");
}
#[test]
fn test_delegated_execution_id_uniqueness() {
let id1 = generate_delegated_execution_id();
let parts1: Vec<&str> = id1.split('-').collect();
assert_eq!(parts1.len(), 2);
let id2 = generate_delegated_execution_id();
let parts2: Vec<&str> = id2.split('-').collect();
assert_eq!(parts2.len(), 2);
assert_eq!(parts1[1], parts2[1], "PID part should be consistent");
let re = regex::Regex::new(r"^[0-9a-f]{8}-[0-9a-f]{4}$").unwrap();
assert!(re.is_match(&id1), "First ID should have valid format");
assert!(re.is_match(&id2), "Second ID should have valid format");
}
#[test]
fn test_delegated_execution_id_timestamp_valid() {
let exec_id = generate_delegated_execution_id();
let parts: Vec<&str> = exec_id.split('-').collect();
let timestamp_hex = parts[0];
let timestamp =
u32::from_str_radix(timestamp_hex, 16).expect("Timestamp part should be valid hex");
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as u32;
let diff = now.abs_diff(timestamp);
assert!(
diff <= 60,
"Timestamp should be recent (within 60 seconds). Got diff: {}",
diff
);
}
#[test]
fn test_delegated_execution_id_pid_valid() {
let exec_id = generate_delegated_execution_id();
let parts: Vec<&str> = exec_id.split('-').collect();
let pid_hex = parts[1];
let pid = u16::from_str_radix(pid_hex, 16).expect("PID part should be valid hex");
let current_pid = std::process::id() as u16;
assert_eq!(pid, current_pid, "PID part should match current process ID");
}
}