spool-memory 0.2.3

Local-first developer memory system — persistent, structured knowledge for AI coding tools
Documentation
use crate::lifecycle_format;
use crate::lifecycle_service::{LifecycleAction, LifecycleWorkbenchSnapshot};
use crate::lifecycle_store::LedgerEntry;
use serde::Serialize;
use serde_json::{Value, json};

#[derive(Debug, Clone, Serialize)]
pub struct LifecycleQueuePayload {
    pub entries: Vec<LedgerEntry>,
    pub summaries: Vec<LifecycleSummary>,
}

#[derive(Debug, Clone, Serialize)]
pub struct LifecycleRecordPayload {
    pub record: LedgerEntry,
    pub summary: LifecycleSummary,
}

#[derive(Debug, Clone, Serialize)]
pub struct LifecycleHistoryPayload {
    pub record_id: String,
    pub history: Vec<LedgerEntry>,
    pub summaries: Vec<LifecycleSummary>,
}

#[derive(Debug, Clone, Serialize)]
pub struct LifecycleSummary {
    pub record_id: String,
    pub state: &'static str,
    pub title: String,
    pub pending_review: bool,
    pub wakeup_ready: bool,
    pub actor: Option<String>,
    pub reason: Option<String>,
    pub evidence_refs: Vec<String>,
}

#[derive(Debug, Clone, Serialize)]
pub struct LifecycleCreateSummary {
    pub kind: String,
    #[serde(flatten)]
    pub summary: LifecycleSummary,
}

#[derive(Debug, Clone, Serialize)]
pub struct LifecycleActionSummary {
    pub action: String,
    #[serde(flatten)]
    pub summary: LifecycleSummary,
}

impl LifecycleSummary {
    pub fn from_entry(entry: &LedgerEntry) -> Self {
        Self {
            record_id: entry.record_id.clone(),
            state: lifecycle_format::state_label(entry),
            title: entry.record.title.clone(),
            pending_review: entry.record.requires_review(),
            wakeup_ready: entry.record.can_be_returned_in_wakeup(),
            actor: entry.metadata.actor.clone(),
            reason: entry.metadata.reason.clone(),
            evidence_refs: entry.metadata.evidence_refs.clone(),
        }
    }

    pub fn to_json(&self) -> Value {
        serde_json::to_value(self).expect("lifecycle summary should serialize")
    }

    pub fn metadata_lines(&self) -> String {
        let mut lines = String::new();
        if let Some(actor) = self.actor.as_deref() {
            lines.push_str(&format!("- actor: {}\n", actor));
        }
        if let Some(reason) = self.reason.as_deref() {
            lines.push_str(&format!("- reason: {}\n", reason));
        }
        if !self.evidence_refs.is_empty() {
            lines.push_str(&format!(
                "- evidence_refs: {}\n",
                self.evidence_refs.join(", ")
            ));
        }
        lines
    }
}

impl LifecycleCreateSummary {
    pub fn new(kind: &str, entry: &LedgerEntry) -> Self {
        Self {
            kind: kind.to_string(),
            summary: LifecycleSummary::from_entry(entry),
        }
    }

    pub fn to_json(&self) -> Value {
        serde_json::to_value(self).expect("lifecycle create summary should serialize")
    }

    pub fn render_markdown(&self) -> String {
        format!(
            "# Lifecycle create\n\n- kind: {}\n- record_id: `{}`\n- state: {}\n- title: {}\n- pending_review: {}\n- wakeup_ready: {}\n{}",
            self.kind,
            self.summary.record_id,
            self.summary.state,
            self.summary.title,
            self.summary.pending_review,
            self.summary.wakeup_ready,
            self.summary.metadata_lines()
        )
    }
}

impl LifecycleActionSummary {
    pub fn new(action: LifecycleAction, entry: &LedgerEntry) -> Self {
        Self {
            action: action.label().to_string(),
            summary: LifecycleSummary::from_entry(entry),
        }
    }

    pub fn to_json(&self) -> Value {
        serde_json::to_value(self).expect("lifecycle action summary should serialize")
    }

    pub fn render_markdown(&self) -> String {
        format!(
            "# Lifecycle action\n\n- action: {}\n- record_id: `{}`\n- state: {}\n- title: {}\n- pending_review: {}\n- wakeup_ready: {}\n{}",
            self.action,
            self.summary.record_id,
            self.summary.state,
            self.summary.title,
            self.summary.pending_review,
            self.summary.wakeup_ready,
            self.summary.metadata_lines()
        )
    }
}

impl LifecycleQueuePayload {
    pub fn new(entries: &[LedgerEntry]) -> Self {
        Self {
            entries: entries.to_vec(),
            summaries: entries.iter().map(LifecycleSummary::from_entry).collect(),
        }
    }

    pub fn to_json_with_field_name(&self, field_name: &str) -> Value {
        json!({
            field_name: self.entries,
            "summaries": self.summaries,
        })
    }
}

impl LifecycleRecordPayload {
    pub fn new(entry: &LedgerEntry) -> Self {
        Self {
            record: entry.clone(),
            summary: LifecycleSummary::from_entry(entry),
        }
    }

    pub fn to_json(&self) -> Value {
        serde_json::to_value(self).expect("lifecycle record payload should serialize")
    }

    pub fn render_markdown(&self, quote_record_id: bool, include_summary_section: bool) -> String {
        lifecycle_format::render_detail(&self.record, quote_record_id, include_summary_section)
    }
}

impl LifecycleHistoryPayload {
    pub fn new(record_id: &str, history: &[LedgerEntry]) -> Self {
        Self {
            record_id: record_id.to_string(),
            history: history.to_vec(),
            summaries: history.iter().map(LifecycleSummary::from_entry).collect(),
        }
    }

    pub fn to_json(&self) -> Value {
        serde_json::to_value(self).expect("lifecycle history payload should serialize")
    }

    pub fn render_markdown(&self, quote_record_id: bool) -> String {
        lifecycle_format::render_history(&self.record_id, &self.history, quote_record_id)
    }
}

pub fn create_payload(
    kind: &str,
    entry: &LedgerEntry,
    snapshot: &LifecycleWorkbenchSnapshot,
) -> Value {
    json!({
        "entry": entry,
        "summary": LifecycleCreateSummary::new(kind, entry).to_json(),
        "snapshot": {
            "pending_review": snapshot.pending_review,
            "wakeup_ready": snapshot.wakeup_ready
        }
    })
}

pub fn action_payload(
    entry: &LedgerEntry,
    snapshot: &LifecycleWorkbenchSnapshot,
    action: LifecycleAction,
) -> Value {
    json!({
        "action": action.label(),
        "entry": entry,
        "summary": LifecycleActionSummary::new(action, entry).to_json(),
        "snapshot": {
            "pending_review": snapshot.pending_review,
            "wakeup_ready": snapshot.wakeup_ready
        }
    })
}

pub fn queue_payload(entries: &[LedgerEntry], field_name: &str) -> Value {
    LifecycleQueuePayload::new(entries).to_json_with_field_name(field_name)
}

pub fn record_payload(entry: &LedgerEntry) -> Value {
    LifecycleRecordPayload::new(entry).to_json()
}

pub fn history_payload(record_id: &str, history: &[LedgerEntry]) -> Value {
    LifecycleHistoryPayload::new(record_id, history).to_json()
}

pub fn render_record_text(
    entry: &LedgerEntry,
    quote_record_id: bool,
    include_summary_section: bool,
) -> String {
    LifecycleRecordPayload::new(entry).render_markdown(quote_record_id, include_summary_section)
}

pub fn render_history_text(
    record_id: &str,
    history: &[LedgerEntry],
    quote_record_id: bool,
) -> String {
    LifecycleHistoryPayload::new(record_id, history).render_markdown(quote_record_id)
}

pub fn render_queue_text(
    title: &str,
    entries: &[LedgerEntry],
    include_record_id: bool,
    markdown_heading: bool,
) -> String {
    lifecycle_format::render_list(title, entries, include_record_id, markdown_heading)
}

pub fn not_found_payload(record_id: &str) -> Value {
    json!({
        "record_id": record_id,
        "summary": Value::Null,
    })
}

pub fn render_create_text(kind: &str, entry: &LedgerEntry) -> String {
    LifecycleCreateSummary::new(kind, entry).render_markdown()
}

pub fn render_action_text(action: LifecycleAction, entry: &LedgerEntry) -> String {
    LifecycleActionSummary::new(action, entry).render_markdown()
}

#[cfg(test)]
mod tests {
    use super::{
        LifecycleActionSummary, LifecycleCreateSummary, LifecycleHistoryPayload,
        LifecycleQueuePayload, LifecycleRecordPayload, LifecycleSummary, render_action_text,
        render_create_text, render_history_text, render_queue_text, render_record_text,
    };
    use crate::domain::{MemoryLedgerAction, MemoryRecord, MemoryScope, MemorySourceKind};
    use crate::lifecycle_service::LifecycleAction;
    use crate::lifecycle_store::{LedgerEntry, TransitionMetadata};

    fn sample_entry() -> LedgerEntry {
        LedgerEntry {
            schema_version: "memory-ledger.v1".to_string(),
            record_id: "record-1".to_string(),
            action: MemoryLedgerAction::Accept,
            recorded_at: "2026-04-13T00:00:00Z".to_string(),
            source_kind: MemorySourceKind::AiProposal,
            scope_key: "user:long".to_string(),
            metadata: TransitionMetadata {
                actor: Some("long".to_string()),
                reason: Some("approved after review".to_string()),
                evidence_refs: vec!["session:1".to_string()],
            },
            record: MemoryRecord::new_ai_proposal(
                "测试偏好",
                "先 smoke 再收口",
                "workflow",
                MemoryScope::User,
                "session:1",
            )
            .apply(crate::domain::MemoryPromotionAction::Accept)
            .with_user_id("long"),
        }
    }

    #[test]
    fn summary_should_capture_cli_and_mcp_shared_fields() {
        let summary = LifecycleSummary::from_entry(&sample_entry());
        assert_eq!(summary.record_id, "record-1");
        assert_eq!(summary.state, "accepted");
        assert_eq!(summary.title, "测试偏好");
        assert!(!summary.pending_review);
        assert!(summary.wakeup_ready);
        assert_eq!(summary.actor.as_deref(), Some("long"));
        assert_eq!(summary.reason.as_deref(), Some("approved after review"));
        assert_eq!(summary.evidence_refs, vec!["session:1"]);
    }

    #[test]
    fn structured_summaries_should_include_create_and_action_specific_fields() {
        let entry = sample_entry();
        let create = LifecycleCreateSummary::new("propose", &entry).to_json();
        let action = LifecycleActionSummary::new(LifecycleAction::Accept, &entry).to_json();
        assert_eq!(create["kind"], "propose");
        assert_eq!(create["record_id"], "record-1");
        assert_eq!(action["action"], "accept");
        assert_eq!(action["reason"], "approved after review");
    }

    #[test]
    fn text_renderers_should_use_summary_fields() {
        let entry = sample_entry();
        let create = render_create_text("propose", &entry);
        let action = render_action_text(LifecycleAction::Accept, &entry);
        assert!(create.contains("- state: accepted"));
        assert!(create.contains("- actor: long"));
        assert!(action.contains("- action: accept"));
        assert!(action.contains("- reason: approved after review"));
    }

    #[test]
    fn read_side_payloads_should_share_summary_generation() {
        let entry = sample_entry();
        let queue = LifecycleQueuePayload::new(std::slice::from_ref(&entry));
        let record = LifecycleRecordPayload::new(&entry);
        let history = LifecycleHistoryPayload::new("record-1", std::slice::from_ref(&entry));
        assert_eq!(queue.entries.len(), 1);
        assert_eq!(queue.summaries[0].record_id, "record-1");
        assert_eq!(record.summary.state, "accepted");
        assert_eq!(history.record_id, "record-1");
        assert_eq!(history.summaries[0].title, "测试偏好");
    }

    #[test]
    fn read_side_text_renderers_should_match_existing_markdown_contracts() {
        let entry = sample_entry();
        let list = render_queue_text("Pending review", std::slice::from_ref(&entry), true, true);
        let detail = render_record_text(&entry, true, true);
        let history = render_history_text("record-1", std::slice::from_ref(&entry), true);
        assert!(list.contains("# Pending review"));
        assert!(list.contains("record-1"));
        assert!(detail.contains("# Memory record"));
        assert!(detail.contains("## Summary"));
        assert!(history.contains("# Memory history"));
        assert!(history.contains("events: 1"));
    }
}