Skip to main content

corcept_memory/
lib.rs

1use anyhow::{bail, Context, Result};
2use chrono::{Duration, SecondsFormat, Utc};
3use corcept_types::{AcceptedMemory, MemoryCandidate, MemoryScope};
4use std::fs;
5use std::path::{Path, PathBuf};
6use uuid::Uuid;
7
8pub fn memory_dir(root: impl AsRef<Path>) -> PathBuf {
9    root.as_ref().join(".corcept").join("memory")
10}
11
12pub fn candidates_dir(root: impl AsRef<Path>) -> PathBuf {
13    memory_dir(root).join("candidates")
14}
15
16pub fn accepted_dir(root: impl AsRef<Path>) -> PathBuf {
17    memory_dir(root).join("accepted")
18}
19
20pub fn rejected_dir(root: impl AsRef<Path>) -> PathBuf {
21    memory_dir(root).join("rejected")
22}
23
24pub fn ensure_dirs(root: impl AsRef<Path>) -> Result<()> {
25    fs::create_dir_all(candidates_dir(&root))?;
26    fs::create_dir_all(accepted_dir(&root))?;
27    fs::create_dir_all(rejected_dir(root))?;
28    Ok(())
29}
30
31pub fn new_candidate(
32    title: impl Into<String>,
33    claim: impl Into<String>,
34    evidence: Vec<String>,
35    proposed_by: impl Into<String>,
36) -> MemoryCandidate {
37    MemoryCandidate {
38        id: format!("mem_{}", Uuid::new_v4().simple()),
39        title: title.into(),
40        claim: claim.into(),
41        scope: MemoryScope::default(),
42        evidence,
43        confidence: "medium".to_string(),
44        expiry: None,
45        risk_if_wrong: None,
46        proposed_by: proposed_by.into(),
47        status: "candidate".to_string(),
48        created_at: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true),
49    }
50}
51
52pub fn validate_candidate(candidate: &MemoryCandidate) -> Result<()> {
53    if candidate.title.trim().is_empty() {
54        bail!("memory candidate title is required");
55    }
56    if candidate.claim.trim().is_empty() {
57        bail!("memory candidate claim is required");
58    }
59    if candidate.evidence.is_empty() {
60        bail!("memory candidate requires at least one evidence reference");
61    }
62    if candidate.status != "candidate" {
63        bail!("memory candidate status must be `candidate`");
64    }
65    Ok(())
66}
67
68pub fn write_candidate(root: impl AsRef<Path>, candidate: &MemoryCandidate) -> Result<PathBuf> {
69    validate_candidate(candidate)?;
70    ensure_dirs(&root)?;
71    let path = candidates_dir(root).join(format!("{}.yaml", candidate.id));
72    fs::write(&path, serde_yaml::to_string(candidate)?)
73        .with_context(|| format!("writing memory candidate {}", path.display()))?;
74    Ok(path)
75}
76
77pub fn read_candidate(root: impl AsRef<Path>, id: &str) -> Result<MemoryCandidate> {
78    let path = candidates_dir(root).join(format!("{id}.yaml"));
79    let raw = fs::read_to_string(&path)
80        .with_context(|| format!("reading memory candidate {}", path.display()))?;
81    let candidate = serde_yaml::from_str(&raw)?;
82    Ok(candidate)
83}
84
85pub fn list_candidates(root: impl AsRef<Path>, limit: usize) -> Result<Vec<MemoryCandidate>> {
86    let candidates_dir = candidates_dir(root);
87    if !candidates_dir.exists() {
88        return Ok(Vec::new());
89    }
90    let mut entries = fs::read_dir(&candidates_dir)?.collect::<std::result::Result<Vec<_>, _>>()?;
91    entries.sort_by_key(|entry| entry.path());
92
93    let mut candidates = Vec::new();
94    for entry in entries {
95        let path = entry.path();
96        let is_candidate_file = path
97            .extension()
98            .and_then(|ext| ext.to_str())
99            .is_some_and(|ext| ext.eq_ignore_ascii_case("yaml") || ext.eq_ignore_ascii_case("yml"));
100        if entry.file_type()?.is_file() && is_candidate_file {
101            let raw = fs::read_to_string(entry.path())
102                .with_context(|| format!("reading memory candidate {}", entry.path().display()))?;
103            let candidate = serde_yaml::from_str(&raw)?;
104            candidates.push(candidate);
105            if candidates.len() >= limit {
106                break;
107            }
108        }
109    }
110    Ok(candidates)
111}
112
113pub fn promote_candidate(
114    root: impl AsRef<Path>,
115    id: &str,
116    approved_by: impl Into<String>,
117) -> Result<AcceptedMemory> {
118    let root_ref = root.as_ref();
119    let candidate = read_candidate(root_ref, id)?;
120    validate_candidate(&candidate)?;
121    let accepted = AcceptedMemory {
122        id: format!("accepted_{}", candidate.id),
123        title: candidate.title,
124        claim: candidate.claim,
125        authority: "accepted_memory".to_string(),
126        scope: candidate.scope,
127        evidence: vec![candidate.id],
128        approved_by: approved_by.into(),
129        approved_at: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true),
130        review_after: Some(
131            (Utc::now() + Duration::days(90))
132                .format("%Y-%m-%d")
133                .to_string(),
134        ),
135    };
136    ensure_dirs(root_ref)?;
137    let path = accepted_dir(root_ref).join(format!("{}.yaml", accepted.id));
138    fs::write(&path, serde_yaml::to_string(&accepted)?)
139        .with_context(|| format!("writing accepted memory {}", path.display()))?;
140    Ok(accepted)
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn candidate_requires_evidence() {
149        let candidate = new_candidate("title", "claim", vec![], "test");
150        assert!(validate_candidate(&candidate).is_err());
151    }
152
153    #[test]
154    fn writes_and_promotes_candidate() {
155        let dir = tempfile::tempdir().unwrap();
156        let candidate = new_candidate(
157            "Convention",
158            "Use explicit errors",
159            vec!["src/lib.rs:10".to_string()],
160            "test",
161        );
162        write_candidate(dir.path(), &candidate).unwrap();
163        let accepted = promote_candidate(dir.path(), &candidate.id, "user").unwrap();
164        assert!(accepted.id.starts_with("accepted_mem_"));
165    }
166
167    #[test]
168    fn lists_candidates_in_path_order() {
169        let dir = tempfile::tempdir().unwrap();
170        let first = new_candidate("A", "Claim A", vec!["a".to_string()], "test");
171        let second = new_candidate("B", "Claim B", vec!["b".to_string()], "test");
172        write_candidate(dir.path(), &first).unwrap();
173        write_candidate(dir.path(), &second).unwrap();
174
175        let listed = list_candidates(dir.path(), 10).unwrap();
176        assert_eq!(listed.len(), 2);
177    }
178
179    #[test]
180    fn list_is_read_only_when_memory_dirs_are_missing() {
181        let dir = tempfile::tempdir().unwrap();
182        let listed = list_candidates(dir.path(), 10).unwrap();
183        assert!(listed.is_empty());
184        assert!(!memory_dir(dir.path()).exists());
185    }
186}