cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
//! Cozo adapter: load workspace facts into an in-memory Cozo DB
//! and expose `run` for ad-hoc CozoScript queries.
//!
//! Facts come from the sibling `graph_facts` module. Schema additions
//! land in `GraphFacts` and propagate here automatically.

use std::collections::BTreeMap;

use cozo::{DataValue, DbInstance, NamedRows, ScriptMutability};

use super::graph_facts::GraphFacts;
use crate::domain::model::tag_descriptor::TagDescriptors;
use crate::domain::usecases::decision_record::DecisionRecordRepository;
use crate::domain::usecases::issue::IssueRepository;

pub struct CozoAdapter {
    db: DbInstance,
}

impl CozoAdapter {
    pub fn from_workspace(
        issue_repo: &dyn IssueRepository,
        dr_repos: &[(String, &dyn DecisionRecordRepository)],
        tag_descriptors: &TagDescriptors,
    ) -> anyhow::Result<Self> {
        Self::from_facts(GraphFacts::from_workspace(
            issue_repo,
            dr_repos,
            tag_descriptors,
        ))
    }

    pub fn from_facts(facts: GraphFacts) -> anyhow::Result<Self> {
        let db = DbInstance::new("mem", "", Default::default())
            .map_err(|e| anyhow::anyhow!("cozo: {e}"))?;

        // Schema: stored relations created at startup.
        let create_schema = r#"
            :create issue {id: String => title: String, status: String}
            :create decision {id: String => kind: String, title: String, status: String}
            :create link {source: String, kind: String, target: String}
            :create relates {source: String, target: String}
            :create assignee {issue_id: String, name: String}
            :create tag {record_id: String, key: String, value: String}
            :create event {record_id: String, action: String, ts: String, ts_unix: Int, from_status: String?, to_status: String?}
            :create rollup {issue_id: String => queued: Int, active: Int, stalled: Int, resolved: Int, cancelled: Int, category: String}
            :create tag_rollup {issue_id: String, key: String => value: String, count: Int}
        "#;
        for stmt in create_schema
            .split(":create")
            .filter(|s| !s.trim().is_empty())
        {
            let stmt = format!(":create {stmt}");
            db.run_script(&stmt, BTreeMap::new(), ScriptMutability::Mutable)
                .map_err(|e| anyhow::anyhow!("cozo schema: {e}"))?;
        }

        insert_rows(
            &db,
            "?[id, title, status]",
            ":put issue {id => title, status}",
            &["id", "title", "status"],
            facts
                .issues
                .iter()
                .map(|(a, b, c)| vec![string(a), string(b), string(c)])
                .collect(),
        )?;

        insert_rows(
            &db,
            "?[id, kind, title, status]",
            ":put decision {id => kind, title, status}",
            &["id", "kind", "title", "status"],
            facts
                .decisions
                .iter()
                .map(|(a, b, c, d)| vec![string(a), string(b), string(c), string(d)])
                .collect(),
        )?;

        insert_rows(
            &db,
            "?[source, kind, target]",
            ":put link {source, kind, target}",
            &["source", "kind", "target"],
            facts
                .links
                .iter()
                .map(|(a, b, c)| vec![string(a), string(b), string(c)])
                .collect(),
        )?;

        insert_rows(
            &db,
            "?[source, target]",
            ":put relates {source, target}",
            &["source", "target"],
            facts
                .relates
                .iter()
                .map(|(a, b)| vec![string(a), string(b)])
                .collect(),
        )?;

        insert_rows(
            &db,
            "?[issue_id, name]",
            ":put assignee {issue_id, name}",
            &["issue_id", "name"],
            facts
                .assignees
                .iter()
                .map(|(a, b)| vec![string(a), string(b)])
                .collect(),
        )?;

        insert_rows(
            &db,
            "?[record_id, key, value]",
            ":put tag {record_id, key, value}",
            &["record_id", "key", "value"],
            facts
                .tags
                .iter()
                .map(|(a, b, c)| vec![string(a), string(b), string(c)])
                .collect(),
        )?;

        insert_rows(
            &db,
            "?[record_id, action, ts, ts_unix, from_status, to_status]",
            ":put event {record_id, action, ts, ts_unix, from_status, to_status}",
            &[
                "record_id",
                "action",
                "ts",
                "ts_unix",
                "from_status",
                "to_status",
            ],
            facts
                .events
                .iter()
                .map(|e| {
                    vec![
                        string(&e.record_id),
                        string(&e.action),
                        string(&e.ts),
                        DataValue::from(e.ts_unix),
                        opt_string(e.from_status.as_deref()),
                        opt_string(e.to_status.as_deref()),
                    ]
                })
                .collect(),
        )?;

        insert_rows(
            &db,
            "?[issue_id, queued, active, stalled, resolved, cancelled, category]",
            ":put rollup {issue_id => queued, active, stalled, resolved, cancelled, category}",
            &[
                "issue_id",
                "queued",
                "active",
                "stalled",
                "resolved",
                "cancelled",
                "category",
            ],
            facts
                .rollups
                .iter()
                .map(|r| {
                    vec![
                        string(&r.issue_id),
                        DataValue::from(r.queued as i64),
                        DataValue::from(r.active as i64),
                        DataValue::from(r.stalled as i64),
                        DataValue::from(r.resolved as i64),
                        DataValue::from(r.cancelled as i64),
                        string(&r.category),
                    ]
                })
                .collect(),
        )?;

        insert_rows(
            &db,
            "?[issue_id, key, value, count]",
            ":put tag_rollup {issue_id, key => value, count}",
            &["issue_id", "key", "value", "count"],
            facts
                .tag_rollups
                .iter()
                .map(|r| {
                    vec![
                        string(&r.issue_id),
                        string(&r.key),
                        string(&r.value),
                        DataValue::from(r.count as i64),
                    ]
                })
                .collect(),
        )?;

        Ok(Self { db })
    }

    pub fn run(&self, script: &str) -> anyhow::Result<NamedRows> {
        self.db
            .run_script(script, BTreeMap::new(), ScriptMutability::Immutable)
            .map_err(|e| anyhow::anyhow!("cozo: {e}"))
    }
}

/// Insert rows into a stored relation via a `?[…] <- [[...], [...]]
/// :put relation {…}` script with named parameters. Each row is
/// `Vec<DataValue>` matching `columns` by index — caller controls
/// arity.
fn insert_rows(
    db: &DbInstance,
    head: &str,
    put_clause: &str,
    columns: &[&str],
    rows: Vec<Vec<DataValue>>,
) -> anyhow::Result<()> {
    if rows.is_empty() {
        return Ok(());
    }

    let mut script = format!("{head} <- [");
    for (i, _) in rows.iter().enumerate() {
        if i > 0 {
            script.push(',');
        }
        script.push('[');
        for (j, col) in columns.iter().enumerate() {
            if j > 0 {
                script.push(',');
            }
            script.push_str(&format!("${col}{i}"));
        }
        script.push(']');
    }
    script.push_str(&format!("] {put_clause}"));

    let mut params: BTreeMap<String, DataValue> = BTreeMap::new();
    for (i, row) in rows.into_iter().enumerate() {
        for (col, val) in columns.iter().zip(row) {
            params.insert(format!("{col}{i}"), val);
        }
    }
    db.run_script(&script, params, ScriptMutability::Mutable)
        .map_err(|e| anyhow::anyhow!("cozo insert: {e}"))?;
    Ok(())
}

fn string(s: &str) -> DataValue {
    DataValue::Str(s.to_owned().into())
}

fn opt_string(s: Option<&str>) -> DataValue {
    match s {
        Some(v) => DataValue::Str(v.to_owned().into()),
        None => DataValue::Null,
    }
}