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(())
}