spool-memory 0.2.3

Local-first developer memory system — persistent, structured knowledge for AI coding tools
Documentation
use super::dto::{
    DesktopDaemonRequest, DesktopLifecycleActionDto, DesktopMemoryActionRequest,
    DesktopMemoryDraftRequest, DesktopMetadataDto, DesktopRecordLookupRequest, DesktopRouteRequest,
    DesktopSessionItem, DesktopWakeupRequest, DesktopWorkbenchRequest,
};
use super::errors::DesktopErrorKind;
use super::helpers::{
    build_continue_command, delete_session_file, parse_csv_items, parse_file_list,
};
use super::service::DesktopService;
use crate::domain::{MemoryLifecycleState, MemoryScope, OutputFormat, TargetTool, WakeupProfile};
use std::fs;
use tempfile::tempdir;

fn setup_workspace() -> (tempfile::TempDir, std::path::PathBuf, std::path::PathBuf) {
    let temp = tempdir().unwrap();
    let vault_root = temp.path().join("vault");
    let repo_root = temp.path().join("repo");
    fs::create_dir_all(vault_root.join("10-Projects")).unwrap();
    fs::create_dir_all(&repo_root).unwrap();
    fs::write(
        vault_root.join("10-Projects/spool.md"),
        "---\ntitle: spool\nmemory_type: project\n---\n\nDesktop facade test note.\n",
    )
    .unwrap();
    let config_path = temp.path().join("spool.toml");
    fs::write(
        &config_path,
        format!(
            "[vault]\nroot = \"{}\"\n\n[output]\ndefault_format = \"markdown\"\nmax_chars = 4000\nmax_notes = 8\n\n[[projects]]\nid = \"spool\"\nname = \"spool\"\nrepo_paths = [\"{}\"]\nnote_roots = [\"10-Projects\"]\n",
            vault_root.display(),
            repo_root.display(),
        ),
    )
    .unwrap();
    (temp, config_path, repo_root)
}

#[test]
fn parse_helpers_should_split_csv_and_multiline_inputs() {
    assert_eq!(parse_csv_items("a, b, ,c"), vec!["a", "b", "c"]);
    assert_eq!(
        parse_file_list("a.rs, b.rs\n c.rs"),
        vec!["a.rs", "b.rs", "c.rs"]
    );
}

#[test]
fn service_should_run_context_workflow() {
    let (_temp, config_path, repo_root) = setup_workspace();
    let response = DesktopService::new()
        .run_context(DesktopRouteRequest {
            config_path,
            vault_root_override: None,
            cwd: repo_root,
            task: "summarize current project context".to_string(),
            files: vec!["src/lib.rs".to_string()],
            target: TargetTool::Claude,
            format: OutputFormat::Markdown,
        })
        .unwrap();

    assert_eq!(response.used_format, OutputFormat::Markdown);
    assert_eq!(response.bundle.route.debug.note_count, 1);
    assert!(response.rendered.contains("spool") || !response.rendered.is_empty());
}

#[test]
fn service_should_build_wakeup_workflow() {
    let (_temp, config_path, repo_root) = setup_workspace();
    let response = DesktopService::new()
        .build_wakeup(DesktopWakeupRequest {
            config_path,
            vault_root_override: None,
            cwd: repo_root,
            task: "prepare restart packet".to_string(),
            files: vec![],
            target: TargetTool::Claude,
            profile: WakeupProfile::Project,
        })
        .unwrap();

    assert_eq!(response.packet.profile, WakeupProfile::Project);
    assert_eq!(response.bundle.route.debug.note_count, 1);
}

#[test]
fn service_should_wrap_validation_errors_in_input_envelope() {
    let error = DesktopService::new()
        .run_context(DesktopRouteRequest {
            config_path: std::path::PathBuf::from("/missing/spool.toml"),
            vault_root_override: None,
            cwd: std::env::temp_dir(),
            task: String::new(),
            files: vec![],
            target: TargetTool::Claude,
            format: OutputFormat::Markdown,
        })
        .unwrap_err();

    assert_eq!(error.kind, DesktopErrorKind::Input);
    assert!(error.message.contains("Config 不存在") || error.message.contains("Task"));
}

#[test]
fn service_should_expose_lifecycle_workbench_record_history_and_actions() {
    let (_temp, config_path, _repo_root) = setup_workspace();
    let draft = DesktopMemoryDraftRequest {
        config_path: config_path.clone(),
        title: "测试偏好".to_string(),
        summary: "先 smoke 再收口".to_string(),
        memory_type: "workflow".to_string(),
        scope: MemoryScope::User,
        source_ref: "session:1".to_string(),
        project_id: None,
        user_id: Some("long".to_string()),
        sensitivity: None,
        metadata: DesktopMetadataDto {
            actor: Some("desktop".to_string()),
            reason: Some("captured from desktop workflow".to_string()),
            evidence_refs: vec!["session:1".to_string()],
        },
        entities: Vec::new(),
        tags: Vec::new(),
        triggers: Vec::new(),
        related_files: Vec::new(),
        related_records: Vec::new(),
        supersedes: None,
        applies_to: Vec::new(),
        valid_until: None,
    };

    let created = DesktopService::new().propose_memory(draft).unwrap();
    assert_eq!(created.entry.record.state, MemoryLifecycleState::Candidate);
    assert_eq!(created.payload["summary"]["kind"], "propose");

    let workbench = DesktopService::new()
        .load_workbench(DesktopWorkbenchRequest {
            config_path: config_path.clone(),
            daemon: Some(DesktopDaemonRequest {
                enabled: false,
                daemon_bin: None,
            }),
        })
        .unwrap();
    assert_eq!(workbench.snapshot.pending_review.len(), 1);

    let record = DesktopService::new()
        .get_record(DesktopRecordLookupRequest {
            config_path: config_path.clone(),
            record_id: created.entry.record_id.clone(),
            daemon: None,
        })
        .unwrap()
        .unwrap();
    assert_eq!(record.record.record.title, "测试偏好");

    let action = DesktopService::new()
        .apply_memory_action(DesktopMemoryActionRequest {
            config_path: config_path.clone(),
            record_id: created.entry.record_id.clone(),
            action: DesktopLifecycleActionDto::Accept,
            metadata: DesktopMetadataDto::default(),
        })
        .unwrap();
    assert_eq!(action.entry.record.state, MemoryLifecycleState::Accepted);
    assert_eq!(action.payload["action"], "accept");

    let history = DesktopService::new()
        .get_history(DesktopRecordLookupRequest {
            config_path,
            record_id: created.entry.record_id,
            daemon: None,
        })
        .unwrap();
    assert_eq!(history.history.len(), 2);
    assert_eq!(history.payload["record_id"], history.record_id);
}

#[test]
fn session_action_helpers_should_build_resume_commands_and_delete_files() {
    let claude = DesktopSessionItem {
        provider: "claude".to_string(),
        session_id: "claude:demo-1".to_string(),
        title: "Claude".to_string(),
        summary: None,
        prompt_preview: None,
        cwd: Some("/tmp/project".to_string()),
        source_path: None,
        project_path: None,
        updated_at: "2026-04-16T12:00:00Z".to_string(),
        record_count: 0,
        pending_review_count: 0,
        wakeup_ready_count: 0,
        titles: Vec::new(),
        memory_types: Vec::new(),
    };
    assert_eq!(
        build_continue_command(&claude).as_deref(),
        Some("cd '/tmp/project' && claude -r \"demo-1\"")
    );

    let codex = DesktopSessionItem {
        provider: "codex".to_string(),
        session_id: "codex:demo-2".to_string(),
        title: "Codex".to_string(),
        summary: None,
        prompt_preview: None,
        cwd: None,
        source_path: None,
        project_path: None,
        updated_at: "2026-04-16T12:00:00Z".to_string(),
        record_count: 0,
        pending_review_count: 0,
        wakeup_ready_count: 0,
        titles: Vec::new(),
        memory_types: Vec::new(),
    };
    assert_eq!(
        build_continue_command(&codex).as_deref(),
        Some("codex resume \"demo-2\"")
    );

    let unsupported = DesktopSessionItem {
        provider: "spool".to_string(),
        session_id: "spool:session:demo".to_string(),
        title: "spool".to_string(),
        summary: None,
        prompt_preview: None,
        cwd: None,
        source_path: None,
        project_path: None,
        updated_at: "2026-04-16T12:00:00Z".to_string(),
        record_count: 0,
        pending_review_count: 0,
        wakeup_ready_count: 0,
        titles: Vec::new(),
        memory_types: Vec::new(),
    };
    assert!(build_continue_command(&unsupported).is_none());

    let temp = tempdir().unwrap();
    let session_file = temp.path().join("session.jsonl");
    fs::write(&session_file, "demo").unwrap();
    let deletable = DesktopSessionItem {
        provider: "claude".to_string(),
        session_id: "claude:delete-demo".to_string(),
        title: "delete".to_string(),
        summary: None,
        prompt_preview: None,
        cwd: None,
        source_path: Some(session_file.display().to_string()),
        project_path: None,
        updated_at: "2026-04-16T12:00:00Z".to_string(),
        record_count: 0,
        pending_review_count: 0,
        wakeup_ready_count: 0,
        titles: Vec::new(),
        memory_types: Vec::new(),
    };
    delete_session_file(&deletable).unwrap();
    assert!(!session_file.exists());
}