roboticus-db 0.11.1

SQLite persistence layer with 28 tables, FTS5 search, WAL mode, and migration system
Documentation
use crate::{Database, DbResultExt};
use roboticus_core::Result;

#[derive(Debug, Clone)]
pub struct PolicyRecord {
    pub id: String,
    pub turn_id: Option<String>,
    pub tool_name: String,
    pub decision: String,
    pub rule_name: Option<String>,
    pub reason: Option<String>,
    pub context_json: Option<String>,
    pub created_at: String,
}

pub fn record_policy_decision(
    db: &Database,
    turn_id: Option<&str>,
    tool_name: &str,
    decision: &str,
    rule_name: Option<&str>,
    reason: Option<&str>,
) -> Result<String> {
    let conn = db.conn();
    let id = uuid::Uuid::new_v4().to_string();
    conn.execute(
        "INSERT INTO policy_decisions (id, turn_id, tool_name, decision, rule_name, reason) \
         VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
        rusqlite::params![id, turn_id, tool_name, decision, rule_name, reason],
    )
    .db_err()?;
    Ok(id)
}

pub fn get_decisions_for_turn(db: &Database, turn_id: &str) -> Result<Vec<PolicyRecord>> {
    let conn = db.conn();
    let mut stmt = conn
        .prepare(
            "SELECT id, turn_id, tool_name, decision, rule_name, reason, context_json, created_at \
             FROM policy_decisions WHERE turn_id = ?1 ORDER BY created_at ASC",
        )
        .db_err()?;

    let rows = stmt
        .query_map([turn_id], |row| {
            Ok(PolicyRecord {
                id: row.get(0)?,
                turn_id: row.get(1)?,
                tool_name: row.get(2)?,
                decision: row.get(3)?,
                rule_name: row.get(4)?,
                reason: row.get(5)?,
                context_json: row.get(6)?,
                created_at: row.get(7)?,
            })
        })
        .db_err()?;

    rows.collect::<std::result::Result<Vec<_>, _>>().db_err()
}

#[cfg(test)]
mod tests {
    use super::*;

    fn test_db() -> Database {
        Database::new(":memory:").unwrap()
    }

    #[test]
    fn record_and_retrieve_decision() {
        let db = test_db();
        // policy_decisions.turn_id is nullable — no FK seed needed
        let id = record_policy_decision(
            &db,
            Some("turn-1"),
            "bash",
            "deny",
            Some("no_rm_rf"),
            Some("destructive command"),
        )
        .unwrap();
        assert!(!id.is_empty());

        let decisions = get_decisions_for_turn(&db, "turn-1").unwrap();
        assert_eq!(decisions.len(), 1);
        assert_eq!(decisions[0].decision, "deny");
        assert_eq!(decisions[0].rule_name.as_deref(), Some("no_rm_rf"));
    }

    #[test]
    fn empty_turn_returns_empty_vec() {
        let db = test_db();
        let decisions = get_decisions_for_turn(&db, "no-such-turn").unwrap();
        assert!(decisions.is_empty());
    }

    #[test]
    fn multiple_decisions_per_turn() {
        let db = test_db();
        record_policy_decision(&db, Some("t1"), "bash", "allow", None, None).unwrap();
        record_policy_decision(
            &db,
            Some("t1"),
            "write_file",
            "deny",
            Some("readonly"),
            Some("read-only mode"),
        )
        .unwrap();

        let decisions = get_decisions_for_turn(&db, "t1").unwrap();
        assert_eq!(decisions.len(), 2);
    }

    #[test]
    fn record_with_no_turn_id() {
        let db = test_db();
        let id = record_policy_decision(&db, None, "search", "allow", None, None).unwrap();
        assert!(!id.is_empty());
    }

    #[test]
    fn record_all_optional_none() {
        let db = test_db();
        let id = record_policy_decision(&db, None, "tool", "allow", None, None).unwrap();
        assert!(!id.is_empty());
    }

    #[test]
    fn decision_fields_populated() {
        let db = test_db();
        record_policy_decision(
            &db,
            Some("t2"),
            "exec",
            "deny",
            Some("human_review"),
            Some("needs approval"),
        )
        .unwrap();
        let decisions = get_decisions_for_turn(&db, "t2").unwrap();
        assert_eq!(decisions[0].tool_name, "exec");
        assert_eq!(decisions[0].decision, "deny");
        assert_eq!(decisions[0].rule_name.as_deref(), Some("human_review"));
        assert_eq!(decisions[0].reason.as_deref(), Some("needs approval"));
        assert!(!decisions[0].id.is_empty());
        assert!(!decisions[0].created_at.is_empty());
    }
}