spool-memory 0.1.0

Local-first developer memory system — persistent, structured knowledge for AI coding tools
Documentation
use crate::domain::{
    OutputFormat, TargetTool, WakeupMemoryItem, WakeupPacket, WakeupProfile, WakeupRecommendedNote,
};

pub fn render(packet: &WakeupPacket, format: OutputFormat) -> String {
    match format {
        OutputFormat::Json => render_json(packet),
        OutputFormat::Markdown => render_markdown(packet),
        OutputFormat::Prompt => render_prompt(packet),
    }
}

pub fn render_json(packet: &WakeupPacket) -> String {
    serde_json::to_string_pretty(packet).unwrap_or_else(|_| "{}".to_string())
}

pub fn render_markdown(packet: &WakeupPacket) -> String {
    let mut output = String::new();
    output.push_str("# wakeup packet\n\n");
    output.push_str(&format!("- profile: {}\n", profile_label(packet.profile)));
    output.push_str(&format!("- target: {}\n", target_label(packet.target)));
    output.push_str(&format!("- task: {}\n", packet.query.task));
    output.push_str(&format!("- cwd: {}\n", packet.query.cwd));
    if let Some(project_name) = &packet.identity.project_name {
        output.push_str(&format!("- project: {}\n", project_name));
    }
    if !packet.identity.developer_roots.is_empty() {
        output.push_str(&format!(
            "- developer_roots: {}\n",
            packet.identity.developer_roots.join(", ")
        ));
    }
    output.push('\n');

    if let Some(index) = &packet.knowledge_index {
        output.push_str("## Knowledge index\n\n");
        output.push_str(index);
        if !index.ends_with('\n') {
            output.push('\n');
        }
        output.push('\n');
    }

    render_section(&mut output, "Working style", &packet.working_style.items);
    render_section(&mut output, "Active context", &packet.active_context.items);
    render_section(&mut output, "Constraints", &packet.constraints);
    render_section(&mut output, "Decisions", &packet.decisions);
    render_section(&mut output, "Incidents", &packet.incidents);
    render_notes(&mut output, &packet.recommended_notes);

    if !packet.maintenance_hints.is_empty() {
        output.push_str("## Maintenance hints\n\n");
        for hint in &packet.maintenance_hints {
            output.push_str(&format!("- {}\n", hint));
        }
        output.push('\n');
    }

    output.push_str("## Policy\n\n");
    output.push_str(&format!(
        "- mode: {}\n- max_sensitivity_included: {}\n- redactions_applied: {}\n- suppressed_note_count: {}\n",
        packet.policy.policy_mode,
        packet
            .policy
            .max_sensitivity_included
            .as_deref()
            .unwrap_or("none"),
        packet.policy.redactions_applied,
        packet.policy.suppressed_note_count,
    ));

    output
}

pub fn render_prompt(packet: &WakeupPacket) -> String {
    let mut output = String::new();
    output.push_str(match packet.target {
        TargetTool::Claude => "以下是给 Claude 使用的 wake-up packet。\n\n",
        TargetTool::Codex => "以下是给 Codex 使用的 wake-up packet。\n\n",
        TargetTool::Opencode => "以下是给 OpenCode 使用的 wake-up packet。\n\n",
    });
    output.push_str(&format!("Profile: {}\n", profile_label(packet.profile)));
    if let Some(project_name) = &packet.identity.project_name {
        output.push_str(&format!("Project: {}\n", project_name));
    }
    output.push_str(&format!("Task: {}\n", packet.query.task));

    let total_items = packet.working_style.items.len()
        + packet.active_context.items.len()
        + packet.constraints.len()
        + packet.decisions.len()
        + packet.incidents.len();
    if total_items > 0 {
        output.push_str(&format!("Memories loaded: {total_items}\n"));
    }
    output.push('\n');

    if let Some(index) = &packet.knowledge_index {
        output.push_str("Knowledge index (auto-synthesized):\n");
        output.push_str(index);
        if !index.ends_with('\n') {
            output.push('\n');
        }
        output.push('\n');
    }

    if !packet.working_style.items.is_empty() {
        output.push_str("Working style:\n");
        for item in &packet.working_style.items {
            output.push_str(&format!("- {}\n", item.summary));
        }
        output.push('\n');
    }

    if !packet.active_context.items.is_empty() {
        output.push_str("Active context:\n");
        for item in &packet.active_context.items {
            output.push_str(&format!("- {}\n", item.summary));
        }
        output.push('\n');
    }

    if !packet.recommended_notes.is_empty() {
        output.push_str("Recommended notes:\n");
        for note in &packet.recommended_notes {
            output.push_str(&format!("- {} ({})\n", note.title, note.path));
        }
        output.push('\n');
    }

    if !packet.maintenance_hints.is_empty() {
        output.push_str("Maintenance:\n");
        for hint in &packet.maintenance_hints {
            output.push_str(&format!("- {}\n", hint));
        }
    }

    output
}

fn render_section(output: &mut String, heading: &str, items: &[WakeupMemoryItem]) {
    output.push_str(&format!("## {}\n\n", heading));
    if items.is_empty() {
        output.push_str("- none\n\n");
        return;
    }
    for item in items {
        output.push_str(&format!("- {}{}\n", item.title, item.summary));
    }
    output.push('\n');
}

fn render_notes(output: &mut String, notes: &[WakeupRecommendedNote]) {
    output.push_str("## Recommended notes\n\n");
    if notes.is_empty() {
        output.push_str("- none\n\n");
        return;
    }
    for note in notes {
        output.push_str(&format!(
            "- {} [{}] — {}\n",
            note.title, note.path, note.why_relevant
        ));
    }
    output.push('\n');
}

fn profile_label(profile: WakeupProfile) -> &'static str {
    match profile {
        WakeupProfile::Developer => "developer",
        WakeupProfile::Project => "project",
    }
}

fn target_label(target: TargetTool) -> &'static str {
    match target {
        TargetTool::Claude => "claude",
        TargetTool::Codex => "codex",
        TargetTool::Opencode => "opencode",
    }
}

#[cfg(test)]
mod tests {
    use super::{render_markdown, render_prompt};
    use crate::domain::{
        ConfidenceTier, TargetTool, WakeupIdentity, WakeupPacket, WakeupPolicy, WakeupProfile,
        WakeupProvenance, WakeupQuery, WakeupRecommendedNote, WakeupSection,
    };

    fn make_packet(target: TargetTool) -> WakeupPacket {
        WakeupPacket {
            version: "wakeup.v1".to_string(),
            generated_at: "unix:1".to_string(),
            target,
            profile: WakeupProfile::Project,
            query: WakeupQuery {
                task: "demo task".to_string(),
                cwd: "/tmp/repo".to_string(),
                files: vec!["src/app.rs".to_string()],
            },
            identity: WakeupIdentity {
                project_id: Some("spool".to_string()),
                project_name: Some("spool".to_string()),
                repo_paths: vec!["/tmp/repo".to_string()],
                modules: Vec::new(),
                scenes: Vec::new(),
                active_profile: "project".to_string(),
                developer_roots: Vec::new(),
            },
            knowledge_index: None,
            working_style: WakeupSection::default(),
            active_context: WakeupSection::default(),
            priorities: Vec::new(),
            constraints: Vec::new(),
            decisions: Vec::new(),
            incidents: Vec::new(),
            recommended_notes: vec![WakeupRecommendedNote {
                path: "10-Projects/demo.md".to_string(),
                title: "Demo".to_string(),
                memory_type: Some("project".to_string()),
                why_relevant: "matched project token".to_string(),
                score: 10,
                confidence: ConfidenceTier::Medium,
            }],
            maintenance_hints: Vec::new(),
            provenance: WakeupProvenance::default(),
            policy: WakeupPolicy {
                max_sensitivity_included: Some("internal".to_string()),
                redactions_applied: false,
                suppressed_note_count: 0,
                policy_mode: "conservative_default".to_string(),
            },
        }
    }

    #[test]
    fn prompt_renderer_should_include_target_specific_intro() {
        let rendered = render_prompt(&make_packet(TargetTool::Codex));
        assert!(rendered.contains("给 Codex 使用的 wake-up packet"));
    }

    #[test]
    fn markdown_renderer_should_include_policy_block() {
        let rendered = render_markdown(&make_packet(TargetTool::Claude));
        assert!(rendered.contains("## Policy"));
        assert!(rendered.contains("max_sensitivity_included: internal"));
    }
}