corcept-memory 0.6.0-pre

Evidence-backed memory candidate and promotion lifecycle for Corcept.
Documentation
use anyhow::{bail, Context, Result};
use chrono::{Duration, SecondsFormat, Utc};
use corcept_types::{AcceptedMemory, MemoryCandidate, MemoryScope};
use std::fs;
use std::path::{Path, PathBuf};
use uuid::Uuid;

pub fn memory_dir(root: impl AsRef<Path>) -> PathBuf {
    root.as_ref().join(".corcept").join("memory")
}

pub fn candidates_dir(root: impl AsRef<Path>) -> PathBuf {
    memory_dir(root).join("candidates")
}

pub fn accepted_dir(root: impl AsRef<Path>) -> PathBuf {
    memory_dir(root).join("accepted")
}

pub fn rejected_dir(root: impl AsRef<Path>) -> PathBuf {
    memory_dir(root).join("rejected")
}

pub fn ensure_dirs(root: impl AsRef<Path>) -> Result<()> {
    fs::create_dir_all(candidates_dir(&root))?;
    fs::create_dir_all(accepted_dir(&root))?;
    fs::create_dir_all(rejected_dir(root))?;
    Ok(())
}

pub fn new_candidate(
    title: impl Into<String>,
    claim: impl Into<String>,
    evidence: Vec<String>,
    proposed_by: impl Into<String>,
) -> MemoryCandidate {
    MemoryCandidate {
        id: format!("mem_{}", Uuid::new_v4().simple()),
        title: title.into(),
        claim: claim.into(),
        scope: MemoryScope::default(),
        evidence,
        confidence: "medium".to_string(),
        expiry: None,
        risk_if_wrong: None,
        proposed_by: proposed_by.into(),
        status: "candidate".to_string(),
        created_at: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true),
    }
}

pub fn validate_candidate(candidate: &MemoryCandidate) -> Result<()> {
    if candidate.title.trim().is_empty() {
        bail!("memory candidate title is required");
    }
    if candidate.claim.trim().is_empty() {
        bail!("memory candidate claim is required");
    }
    if candidate.evidence.is_empty() {
        bail!("memory candidate requires at least one evidence reference");
    }
    if candidate.status != "candidate" {
        bail!("memory candidate status must be `candidate`");
    }
    Ok(())
}

pub fn write_candidate(root: impl AsRef<Path>, candidate: &MemoryCandidate) -> Result<PathBuf> {
    validate_candidate(candidate)?;
    ensure_dirs(&root)?;
    let path = candidates_dir(root).join(format!("{}.yaml", candidate.id));
    fs::write(&path, serde_yaml::to_string(candidate)?)
        .with_context(|| format!("writing memory candidate {}", path.display()))?;
    Ok(path)
}

pub fn read_candidate(root: impl AsRef<Path>, id: &str) -> Result<MemoryCandidate> {
    let path = candidates_dir(root).join(format!("{id}.yaml"));
    let raw = fs::read_to_string(&path)
        .with_context(|| format!("reading memory candidate {}", path.display()))?;
    let candidate = serde_yaml::from_str(&raw)?;
    Ok(candidate)
}

pub fn list_candidates(root: impl AsRef<Path>, limit: usize) -> Result<Vec<MemoryCandidate>> {
    let candidates_dir = candidates_dir(root);
    if !candidates_dir.exists() {
        return Ok(Vec::new());
    }
    let mut entries = fs::read_dir(&candidates_dir)?.collect::<std::result::Result<Vec<_>, _>>()?;
    entries.sort_by_key(|entry| entry.path());

    let mut candidates = Vec::new();
    for entry in entries {
        let path = entry.path();
        let is_candidate_file = path
            .extension()
            .and_then(|ext| ext.to_str())
            .is_some_and(|ext| ext.eq_ignore_ascii_case("yaml") || ext.eq_ignore_ascii_case("yml"));
        if entry.file_type()?.is_file() && is_candidate_file {
            let raw = fs::read_to_string(entry.path())
                .with_context(|| format!("reading memory candidate {}", entry.path().display()))?;
            let candidate = serde_yaml::from_str(&raw)?;
            candidates.push(candidate);
            if candidates.len() >= limit {
                break;
            }
        }
    }
    Ok(candidates)
}

pub fn promote_candidate(
    root: impl AsRef<Path>,
    id: &str,
    approved_by: impl Into<String>,
) -> Result<AcceptedMemory> {
    let root_ref = root.as_ref();
    let candidate = read_candidate(root_ref, id)?;
    validate_candidate(&candidate)?;
    let accepted = AcceptedMemory {
        id: format!("accepted_{}", candidate.id),
        title: candidate.title,
        claim: candidate.claim,
        authority: "accepted_memory".to_string(),
        scope: candidate.scope,
        evidence: vec![candidate.id],
        approved_by: approved_by.into(),
        approved_at: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true),
        review_after: Some(
            (Utc::now() + Duration::days(90))
                .format("%Y-%m-%d")
                .to_string(),
        ),
    };
    ensure_dirs(root_ref)?;
    let path = accepted_dir(root_ref).join(format!("{}.yaml", accepted.id));
    fs::write(&path, serde_yaml::to_string(&accepted)?)
        .with_context(|| format!("writing accepted memory {}", path.display()))?;
    Ok(accepted)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn candidate_requires_evidence() {
        let candidate = new_candidate("title", "claim", vec![], "test");
        assert!(validate_candidate(&candidate).is_err());
    }

    #[test]
    fn writes_and_promotes_candidate() {
        let dir = tempfile::tempdir().unwrap();
        let candidate = new_candidate(
            "Convention",
            "Use explicit errors",
            vec!["src/lib.rs:10".to_string()],
            "test",
        );
        write_candidate(dir.path(), &candidate).unwrap();
        let accepted = promote_candidate(dir.path(), &candidate.id, "user").unwrap();
        assert!(accepted.id.starts_with("accepted_mem_"));
    }

    #[test]
    fn lists_candidates_in_path_order() {
        let dir = tempfile::tempdir().unwrap();
        let first = new_candidate("A", "Claim A", vec!["a".to_string()], "test");
        let second = new_candidate("B", "Claim B", vec!["b".to_string()], "test");
        write_candidate(dir.path(), &first).unwrap();
        write_candidate(dir.path(), &second).unwrap();

        let listed = list_candidates(dir.path(), 10).unwrap();
        assert_eq!(listed.len(), 2);
    }

    #[test]
    fn list_is_read_only_when_memory_dirs_are_missing() {
        let dir = tempfile::tempdir().unwrap();
        let listed = list_candidates(dir.path(), 10).unwrap();
        assert!(listed.is_empty());
        assert!(!memory_dir(dir.path()).exists());
    }
}