cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
//! Engine-agnostic fact extraction for the workspace graph.
//!
//! `infra/driven/cozo` (CozoScript runtime, user-facing
//! `cartu query`) ingests this view of the workspace.
//!
//! This module walks the repositories once and returns plain
//! tuples the engine can ingest.
//!
//! Output relations:
//! - `issues: (id, title, status)`
//! - `decisions: (id, kind, title, status)`
//! - `links: (source, kind, target)` — typed mono-kind links: issue
//!   verbs (`blocks`, `blocked-by`, `parent-of`, `child-of`) and DR verbs (`supersedes`,
//!   `amends`, `clarifies`, `depends`, `conflicts`, plus the inverse
//!   pointers). The two vocabularies do not overlap; `kind` is the
//!   discriminator.
//! - `relates: (source, target)` — neutral cross-kind pointer from
//!   the `relates:` field of any record (issue or DR) to any other
//!   record. No filtering by prefix.
//! - `assignees: (issue_id, name)` — populated only when the issue
//!   has an assignee.
//! - `tags: (record_id, key, value)` — `key` is empty for flat tags
//!   like `urgent`, populated for structured ones like
//!   `priority:high`.
//! - `events: (record_id, action, ts, ts_unix, from_status, to_status)`
//!   — append-only history; `ts` is ISO-8601, `ts_unix` is seconds
//!   since epoch.
//! - `rollups: (issue_id, queued, active, stalled, resolved, cancelled,
//!   category)` — derived status rollup of a composite issue's direct
//!   children. Only issues with at least one `parent-of` child appear
//!   here.
//! - `tag_rollups: (issue_id, key, value, count)` — derived per-tag
//!   rollup; one row per (composite issue, declared `aggregate` tag)
//!   that produced a value.

use crate::domain::model::decision_record::DecisionRecord;
use crate::domain::model::event::EventAction;
use crate::domain::model::issue::{Issue, IssueRelationship};
use crate::domain::model::tag_descriptor::TagDescriptors;
use crate::domain::usecases::decision_record::DecisionRecordRepository;
use crate::domain::usecases::issue::{
    compute_status_rollup_via_map, compute_tag_rollups, index_issues_by_id, IssueRepository,
};

#[derive(Default)]
pub struct GraphFacts {
    pub issues: Vec<(String, String, String)>,
    pub decisions: Vec<(String, String, String, String)>,
    pub links: Vec<(String, String, String)>,
    pub relates: Vec<(String, String)>,
    pub assignees: Vec<(String, String)>,
    pub tags: Vec<(String, String, String)>,
    pub events: Vec<EventFact>,
    pub rollups: Vec<RollupFact>,
    pub tag_rollups: Vec<TagRollupFact>,
}

/// Emitted row for the `*rollup` relation. One row per composite
/// issue (at least one `parent-of` child).
pub struct RollupFact {
    pub issue_id: String,
    pub queued: u32,
    pub active: u32,
    pub stalled: u32,
    pub resolved: u32,
    pub cancelled: u32,
    pub category: String,
}

/// Emitted row for the `*tag_rollup` relation. One row per
/// (composite issue, declared `aggregate` tag) that yielded a value.
pub struct TagRollupFact {
    pub issue_id: String,
    pub key: String,
    pub value: String,
    pub count: u32,
}

/// Emitted row for the `*event` relation. `from_status` / `to_status`
/// are absent for `created` events.
pub struct EventFact {
    pub record_id: String,
    pub action: String,
    pub ts: String,
    pub ts_unix: i64,
    pub from_status: Option<String>,
    pub to_status: Option<String>,
}

impl GraphFacts {
    /// Walk both issue and decision-record repositories. The
    /// decision repos are passed by-value as a slice of trait
    /// objects so the caller (typically `cartu query`) can hand
    /// over one repo per configured DR kind.
    pub fn from_workspace(
        issue_repo: &dyn IssueRepository,
        dr_repos: &[(String, &dyn DecisionRecordRepository)],
        tag_descriptors: &TagDescriptors,
    ) -> Self {
        let mut facts = Self::default();
        facts.absorb_issues(issue_repo, tag_descriptors);
        for (kind, dr_repo) in dr_repos {
            facts.absorb_decisions(kind, *dr_repo);
        }
        facts
    }

    fn absorb_issues(&mut self, repo: &dyn IssueRepository, descriptors: &TagDescriptors) {
        // Collect issues into memory once so the rollup pass can
        // resolve `parent-of` children through an id index without a
        // second repo scan.
        let issues: Vec<Issue> = repo.list().unwrap_or_default().into_vec();
        let by_id = index_issues_by_id(&issues);
        for issue in &issues {
            self.absorb_issue(issue);
            if let Some(h) = compute_status_rollup_via_map(issue, &by_id) {
                self.rollups.push(RollupFact {
                    issue_id: issue.id.to_string(),
                    queued: h.queued,
                    active: h.active,
                    stalled: h.stalled,
                    resolved: h.resolved,
                    cancelled: h.cancelled,
                    category: h.category().as_str().to_owned(),
                });
            }
            if !descriptors.is_empty() {
                let child_refs: Vec<&Issue> = issue
                    .links
                    .iter()
                    .filter(|l| l.relationship == IssueRelationship::ParentOf)
                    .filter_map(|l| by_id.get(&l.target).copied())
                    .collect();
                for (key, value) in compute_tag_rollups(&child_refs, descriptors) {
                    self.tag_rollups.push(TagRollupFact {
                        issue_id: issue.id.to_string(),
                        key,
                        value: value.value,
                        count: value.count,
                    });
                }
            }
        }
    }

    fn absorb_issue(&mut self, issue: &Issue) {
        let id = issue.id.to_string();
        self.issues.push((
            id.clone(),
            issue.title.as_str().to_owned(),
            issue.status.as_str().to_owned(),
        ));
        for link in issue.links.iter() {
            self.links.push((
                id.clone(),
                link.relationship.as_str().to_owned(),
                link.target.to_string(),
            ));
        }
        for entity_ref in issue.relates().iter() {
            self.relates
                .push((id.clone(), entity_ref.as_str().to_owned()));
        }
        if let Some(assignee) = &issue.assignee {
            self.assignees
                .push((id.clone(), assignee.as_str().to_owned()));
        }
        absorb_tags(&mut self.tags, &id, issue.tags.iter());
        absorb_event_log(&mut self.events, &id, issue.events.iter());
    }

    fn absorb_decisions(&mut self, kind: &str, repo: &dyn DecisionRecordRepository) {
        for record in repo.list().unwrap_or_default() {
            self.absorb_decision(kind, &record);
        }
    }

    fn absorb_decision(&mut self, kind: &str, record: &DecisionRecord) {
        let id = record.id.to_string();
        self.decisions.push((
            id.clone(),
            kind.to_owned(),
            record.title.as_str().to_owned(),
            record.status.as_str().to_owned(),
        ));
        for link in record.links.iter() {
            self.links.push((
                id.clone(),
                link.relationship.as_str().to_owned(),
                link.target.as_str().to_owned(),
            ));
        }
        for entity_ref in record.relates().iter() {
            self.relates
                .push((id.clone(), entity_ref.as_str().to_owned()));
        }
        absorb_tags(&mut self.tags, &id, record.tags.iter());
        absorb_event_log(&mut self.events, &id, record.events.iter());
    }
}

fn absorb_tags<'a, I>(out: &mut Vec<(String, String, String)>, record_id: &str, tags: I)
where
    I: IntoIterator<Item = &'a crate::domain::model::tag::Tag>,
{
    for tag in tags {
        let (key, value) = match tag.as_kv() {
            Some((k, v)) => (k.to_owned(), v.to_owned()),
            None => (String::new(), tag.as_str().to_owned()),
        };
        out.push((record_id.to_owned(), key, value));
    }
}

fn absorb_event_log<'a, I>(out: &mut Vec<EventFact>, record_id: &str, events: I)
where
    I: IntoIterator<Item = &'a crate::domain::model::event::Event>,
{
    for event in events {
        let ts = event.timestamp.as_str().to_owned();
        let ts_unix = event.timestamp.unix_seconds();
        let (action, from_status, to_status) = match &event.action {
            EventAction::Created { state } => ("created", None, Some(state.as_str().to_owned())),
            EventAction::StatusChanged { from, to } => (
                "status_changed",
                Some(from.as_str().to_owned()),
                Some(to.as_str().to_owned()),
            ),
        };
        out.push(EventFact {
            record_id: record_id.to_owned(),
            action: action.to_owned(),
            ts,
            ts_unix,
            from_status,
            to_status,
        });
    }
}