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}