spool-memory 0.1.0

Local-first developer memory system — persistent, structured knowledge for AI coding tools
Documentation
//! Private helpers: ID/key/label/token 生成、timestamp、目录准备、写入/状态转移。
//! 这些只在 `lifecycle_store` 内部复用,不对外暴露。

use super::{
    LEDGER_SCHEMA_VERSION, LedgerEntry, LifecycleStore, TransitionMetadata,
    projection::read_projection,
};
use crate::domain::{
    MemoryLedgerAction, MemoryRecord, MemoryScope, MemorySourceKind, ledger_action_for_source,
};
use std::fs;
use std::path::Path;

pub(super) fn write_record(
    store: &LifecycleStore,
    record: MemoryRecord,
    metadata: TransitionMetadata,
) -> anyhow::Result<LedgerEntry> {
    let entry = build_entry(record, metadata);
    store.append(&entry)?;
    Ok(entry)
}

pub(super) fn transition_memory(
    store: &LifecycleStore,
    record_id: &str,
    action: MemoryLedgerAction,
    metadata: TransitionMetadata,
) -> anyhow::Result<LedgerEntry> {
    let current = read_projection(store)?
        .latest_by_record_id(record_id)
        .cloned()
        .ok_or_else(|| anyhow::anyhow!("memory record not found: {record_id}"))?;
    let next_record = current.record.clone().apply_ledger_action(action);
    if next_record.state == current.record.state {
        anyhow::bail!("invalid lifecycle transition for record_id: {record_id}");
    }

    let entry = LedgerEntry {
        schema_version: LEDGER_SCHEMA_VERSION.to_string(),
        recorded_at: timestamp_string(),
        record_id: current.record_id,
        scope_key: current.scope_key,
        action,
        source_kind: current.source_kind,
        metadata,
        record: next_record,
    };
    store.append(&entry)?;
    Ok(entry)
}

fn build_entry(record: MemoryRecord, metadata: TransitionMetadata) -> LedgerEntry {
    let source_kind = record.origin.source_kind;
    LedgerEntry {
        schema_version: LEDGER_SCHEMA_VERSION.to_string(),
        recorded_at: timestamp_string(),
        record_id: build_record_id(&record),
        scope_key: build_scope_key(&record),
        action: ledger_action_for_source(source_kind),
        source_kind,
        metadata,
        record,
    }
}

fn build_record_id(record: &MemoryRecord) -> String {
    format!(
        "{}:{}:{}",
        stable_record_key(record),
        timestamp_id_fragment(),
        normalize_token(&record.origin.source_ref)
    )
}

fn stable_record_key(record: &MemoryRecord) -> String {
    format!(
        "{}:{}:{}:{}",
        scope_label(record.scope),
        build_scope_key(record),
        source_label(record.origin.source_kind),
        normalize_token(&record.title)
    )
}

fn build_scope_key(record: &MemoryRecord) -> String {
    match record.scope {
        MemoryScope::User => record
            .user_id
            .clone()
            .unwrap_or_else(|| "global-user".to_string()),
        MemoryScope::Project => record
            .project_id
            .clone()
            .unwrap_or_else(|| "global-project".to_string()),
        MemoryScope::Workspace => "workspace".to_string(),
        MemoryScope::Team => "team".to_string(),
        MemoryScope::Agent => "agent".to_string(),
    }
}

fn scope_label(scope: MemoryScope) -> &'static str {
    match scope {
        MemoryScope::User => "user",
        MemoryScope::Project => "project",
        MemoryScope::Workspace => "workspace",
        MemoryScope::Team => "team",
        MemoryScope::Agent => "agent",
    }
}

fn source_label(source_kind: MemorySourceKind) -> &'static str {
    match source_kind {
        MemorySourceKind::Manual => "manual",
        MemorySourceKind::AiProposal => "ai_proposal",
        MemorySourceKind::SessionCapture => "session_capture",
        MemorySourceKind::Distilled => "distilled",
        MemorySourceKind::Imported => "imported",
    }
}

fn normalize_token(value: &str) -> String {
    let mut token = value
        .chars()
        .map(|ch| match ch {
            'a'..='z' | 'A'..='Z' | '0'..='9' => ch.to_ascii_lowercase(),
            _ => '-',
        })
        .collect::<String>();
    while token.contains("--") {
        token = token.replace("--", "-");
    }
    token.trim_matches('-').to_string()
}

pub(super) fn timestamp_string() -> String {
    use std::time::{SystemTime, UNIX_EPOCH};

    let seconds = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|duration| duration.as_secs())
        .unwrap_or_default();
    format!("unix:{seconds}")
}

fn timestamp_id_fragment() -> String {
    use std::time::{SystemTime, UNIX_EPOCH};

    let nanos = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|duration| duration.as_nanos())
        .unwrap_or_default();
    nanos.to_string()
}

pub(super) fn ensure_parent_dir(path: &Path) -> anyhow::Result<()> {
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)?;
    }
    Ok(())
}