spool-memory 0.1.1

Local-first developer memory system — persistent, structured knowledge for AI coding tools
Documentation
use crate::lifecycle_service::LifecycleAction;
use crate::lifecycle_store::LedgerEntry;
use crate::lifecycle_summary;

pub fn state_label(entry: &LedgerEntry) -> &'static str {
    match entry.record.state {
        crate::domain::MemoryLifecycleState::Draft => "draft",
        crate::domain::MemoryLifecycleState::Candidate => "candidate",
        crate::domain::MemoryLifecycleState::Accepted => "accepted",
        crate::domain::MemoryLifecycleState::Canonical => "canonical",
        crate::domain::MemoryLifecycleState::Archived => "archived",
    }
}

pub fn action_label(entry: &LedgerEntry) -> &'static str {
    match entry.action {
        crate::domain::MemoryLedgerAction::RecordManual => "record_manual",
        crate::domain::MemoryLedgerAction::ProposeAi => "propose_ai",
        crate::domain::MemoryLedgerAction::SubmitProposal => "submit_proposal",
        crate::domain::MemoryLedgerAction::Accept => "accept",
        crate::domain::MemoryLedgerAction::PromoteToCanonical => "promote_to_canonical",
        crate::domain::MemoryLedgerAction::Archive => "archive",
    }
}

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

pub fn action_button_label(action: LifecycleAction) -> &'static str {
    match action {
        LifecycleAction::Accept => "Accept",
        LifecycleAction::PromoteToCanonical => "Promote",
        LifecycleAction::Archive => "Archive",
    }
}

pub fn render_list_item(entry: &LedgerEntry, include_record_id: bool) -> String {
    if include_record_id {
        format!(
            "- `{}` [{}] {} ({})",
            entry.record_id,
            state_label(entry),
            entry.record.title,
            entry.record.memory_type
        )
    } else {
        format!(
            "- [{}] {} ({})",
            state_label(entry),
            entry.record.title,
            entry.record.memory_type
        )
    }
}

pub fn render_action_result(action: LifecycleAction, entry: &LedgerEntry) -> String {
    lifecycle_summary::render_action_text(action, entry)
}

pub fn render_create_result(kind: &str, entry: &LedgerEntry) -> String {
    lifecycle_summary::render_create_text(kind, entry)
}

pub fn render_gui_list_label(entry: &LedgerEntry) -> String {
    format!(
        "[{}] {} ({})",
        state_label(entry),
        entry.record.title,
        entry.record.memory_type
    )
}

pub fn render_detail(
    entry: &LedgerEntry,
    quote_record_id: bool,
    include_summary_section: bool,
) -> String {
    let record_id = if quote_record_id {
        format!("`{}`", entry.record_id)
    } else {
        entry.record_id.clone()
    };

    let mut lines = vec![
        "# Memory record".to_string(),
        String::new(),
        format!("- record_id: {}", record_id),
        format!("- state: {}", state_label(entry)),
        format!("- action: {}", action_label(entry)),
        format!("- title: {}", entry.record.title),
        format!("- memory_type: {}", entry.record.memory_type),
        format!("- scope: {:?}", entry.record.scope),
        format!("- source_kind: {:?}", entry.source_kind),
        format!("- scope_key: {}", entry.scope_key),
    ];
    if let Some(project_id) = entry.record.project_id.as_deref() {
        lines.push(format!("- project_id: {}", project_id));
    }
    if let Some(user_id) = entry.record.user_id.as_deref() {
        lines.push(format!("- user_id: {}", user_id));
    }
    if let Some(sensitivity) = entry.record.sensitivity.as_deref() {
        lines.push(format!("- sensitivity: {}", sensitivity));
    }
    if !entry.record.entities.is_empty() {
        lines.push(format!("- entities: {}", entry.record.entities.join(", ")));
    }
    if !entry.record.tags.is_empty() {
        lines.push(format!("- tags: {}", entry.record.tags.join(", ")));
    }
    if !entry.record.triggers.is_empty() {
        lines.push(format!("- triggers: {}", entry.record.triggers.join(", ")));
    }
    if !entry.record.related_files.is_empty() {
        lines.push(format!(
            "- related_files: {}",
            entry.record.related_files.join(", ")
        ));
    }
    if !entry.record.related_records.is_empty() {
        lines.push(format!(
            "- related_records: {}",
            entry.record.related_records.join(", ")
        ));
    }
    if !entry.record.applies_to.is_empty() {
        lines.push(format!(
            "- applies_to: {}",
            entry.record.applies_to.join(", ")
        ));
    }
    if let Some(ref supersedes) = entry.record.supersedes {
        lines.push(format!("- supersedes: {}", supersedes));
    }
    if let Some(ref valid_until) = entry.record.valid_until {
        lines.push(format!("- valid_until: {}", valid_until));
    }
    if include_summary_section {
        lines.push(String::new());
        lines.push("## Summary".to_string());
        lines.push(String::new());
        lines.push(entry.record.summary.clone());
    } else {
        lines.push(format!("- summary: {}", entry.record.summary));
    }
    let metadata = metadata_lines(entry);
    if !metadata.is_empty() {
        let insert_at = lines
            .len()
            .saturating_sub(if include_summary_section { 3 } else { 1 });
        let metadata_lines: Vec<String> = metadata
            .trim_end()
            .lines()
            .map(ToString::to_string)
            .collect();
        lines.splice(insert_at..insert_at, metadata_lines);
    }
    lines.join("\n")
}

pub fn render_history(record_id: &str, entries: &[LedgerEntry], quote_record_id: bool) -> String {
    let record_id = if quote_record_id {
        format!("`{record_id}`")
    } else {
        record_id.to_string()
    };
    if entries.is_empty() {
        return format!("# Memory history\n\n- record_id: {record_id}\n- none");
    }

    let mut output = format!(
        "# Memory history\n\n- record_id: {}\n- events: {}\n\n",
        record_id,
        entries.len()
    );
    for (index, entry) in entries.iter().enumerate() {
        output.push_str(&format!(
            "## {}. {}\n- recorded_at: {}\n- action: {}\n- state: {}\n- title: {}\n{}\n",
            index + 1,
            entry.record_id,
            entry.recorded_at,
            action_label(entry),
            state_label(entry),
            entry.record.title,
            metadata_lines(entry)
        ));
    }
    output.trim_end().to_string()
}

pub fn render_list(
    title: &str,
    entries: &[LedgerEntry],
    include_record_id: bool,
    markdown_heading: bool,
) -> String {
    if entries.is_empty() {
        return if markdown_heading {
            format!("# {title}\n\n- none")
        } else {
            format!("{title}\n\n- none")
        };
    }

    let mut output = String::new();
    if markdown_heading {
        output.push_str(&format!("# {title}\n\n"));
    } else {
        output.push_str(title);
        output.push_str("\n\n");
    }
    for entry in entries {
        output.push_str(&render_list_item(entry, include_record_id));
        output.push('\n');
    }
    output
}