Skip to main content

codelens_engine/memory/
store.rs

1use std::path::Path;
2
3use anyhow::{Context, Result, bail};
4
5use super::audit::{AuditRecorder, MemoryAuditEvent};
6use super::frontmatter::{MemoryMetadata, parse_frontmatter};
7use super::paths::{MemoryTier, collect_memory_files, resolve_memory_path, resolve_memory_tier};
8use super::policy::{MemoryPolicy, POLICY_FILE_BASENAME, POLICY_FILENAME};
9
10pub fn list_memory_names(memories_dir: &Path, topic: Option<&str>) -> Vec<String> {
11    let policy = MemoryPolicy::load(memories_dir);
12    list_memory_names_with_policy(memories_dir, topic, &policy)
13}
14
15/// List memory names, filtering out entries matching the ignored policy.
16pub fn list_memory_names_with_policy(
17    memories_dir: &Path,
18    topic: Option<&str>,
19    policy: &MemoryPolicy,
20) -> Vec<String> {
21    let mut names = Vec::new();
22    if !memories_dir.is_dir() {
23        return names;
24    }
25    collect_memory_files(memories_dir, memories_dir, &mut names);
26    names.sort();
27    names.retain(|n| {
28        // Policy file and archive dir are hidden
29        !n.starts_with(POLICY_FILENAME) && !policy.is_ignored(n)
30    });
31    if let Some(t) = topic {
32        let t = t.trim().trim_matches('/');
33        if !t.is_empty() {
34            names.retain(|n| n == t || n.starts_with(&format!("{t}/")));
35        }
36    }
37    names
38}
39
40/// List memory names from all tiers, returning (name, tier) pairs.
41/// Entries from the global tier are prefixed with `global/`.
42pub fn list_all_memory_names(
43    project_dir: &Path,
44    global_dir: Option<&Path>,
45    topic: Option<&str>,
46) -> Vec<(String, MemoryTier)> {
47    let project_memories = project_dir.join(".codelens").join("memories");
48    let project_names = list_memory_names(&project_memories, topic);
49    let mut result: Vec<(String, MemoryTier)> = project_names
50        .into_iter()
51        .map(|n| (n, MemoryTier::Project))
52        .collect();
53
54    if let Some(gdir) = global_dir {
55        let global_names = list_memory_names(gdir, topic);
56        for name in global_names {
57            // Deduplicate: project tier takes precedence
58            let prefixed = format!("global/{}", name);
59            if !result.iter().any(|(n, _)| n == &name) {
60                result.push((prefixed, MemoryTier::Global));
61            }
62        }
63    }
64    result
65}
66
67/// Read a memory file's content from the appropriate tier.
68pub fn read_memory(memories_dir: &Path, name: &str) -> Result<String> {
69    let path = resolve_memory_path(memories_dir, name)?;
70    std::fs::read_to_string(&path).with_context(|| format!("memory not found: {name}"))
71}
72
73/// Read a memory file from a specific tier's directory.
74pub fn read_memory_from_tier(
75    project_dir: &Path,
76    global_dir: Option<&Path>,
77    name: &str,
78) -> Result<(String, MemoryTier)> {
79    let loc = resolve_memory_tier(name, project_dir, global_dir);
80    let content = std::fs::read_to_string(&loc.path)
81        .with_context(|| format!("memory not found: {}", name.trim_start_matches("global/")))?;
82    Ok((content, loc.tier))
83}
84
85/// Read a memory file with full metadata: frontmatter links, stale detection,
86/// and tier information.  This is the rich-read counterpart of
87/// `read_memory_from_tier` for MCP responses that include metadata.
88pub fn read_memory_with_metadata(
89    project_dir: &Path,
90    global_dir: Option<&Path>,
91    name: &str,
92) -> Result<(String, MemoryMetadata)> {
93    let loc = resolve_memory_tier(name, project_dir, global_dir);
94    let effective_name = name.trim_start_matches("global/");
95    let content = std::fs::read_to_string(&loc.path)
96        .with_context(|| format!("memory not found: {effective_name}"))?;
97    let policy = MemoryPolicy::load(&loc.dir);
98    let modified_secs = std::fs::metadata(&loc.path)
99        .ok()
100        .and_then(|m| m.modified().ok())
101        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
102        .map(|d| d.as_secs());
103    let stale = modified_secs
104        .map(|m| policy.is_stale(effective_name, m))
105        .unwrap_or(false);
106    let fm = parse_frontmatter(&content);
107    Ok((
108        content,
109        MemoryMetadata {
110            tier: loc.tier,
111            stale,
112            last_modified_secs: modified_secs,
113            linked_symbols: fm
114                .as_ref()
115                .map(|f| f.linked_symbols.clone())
116                .unwrap_or_default(),
117            linked_files: fm
118                .as_ref()
119                .map(|f| f.linked_files.clone())
120                .unwrap_or_default(),
121            linked_analyses: fm
122                .as_ref()
123                .map(|f| f.linked_analyses.clone())
124                .unwrap_or_default(),
125        },
126    ))
127}
128
129/// Write content to a memory file in the project tier.
130/// Creates directories if needed.  Rejects writes to read-only entries.
131pub fn write_memory(memories_dir: &Path, name: &str, content: &str) -> Result<()> {
132    // Policy file is always writable
133    if name == POLICY_FILENAME {
134        return write_policy(memories_dir, content);
135    }
136    let policy = MemoryPolicy::load(memories_dir);
137    if policy.is_read_only(name) {
138        bail!("memory '{name}' is read-only (matches policy pattern)");
139    }
140    let path = resolve_memory_path(memories_dir, name)?;
141    if let Some(parent) = path.parent() {
142        std::fs::create_dir_all(parent)?;
143    }
144    std::fs::write(&path, content)?;
145    Ok(())
146}
147
148/// Write content to a memory file, resolving the tier automatically.
149/// Records an audit event via `recorder` when provided.
150pub fn write_memory_tiered(
151    project_dir: &Path,
152    global_dir: Option<&Path>,
153    name: &str,
154    content: &str,
155) -> Result<MemoryTier> {
156    write_memory_tiered_rec(project_dir, global_dir, name, content, None)
157}
158
159/// Write content to a memory file with optional audit recording.
160pub fn write_memory_tiered_rec(
161    project_dir: &Path,
162    global_dir: Option<&Path>,
163    name: &str,
164    content: &str,
165    recorder: Option<&dyn AuditRecorder>,
166) -> Result<MemoryTier> {
167    // Explicit global prefix
168    let (effective_name, force_tier) = if let Some(stripped) = name.strip_prefix("global/") {
169        (stripped.trim_start_matches('/'), Some(MemoryTier::Global))
170    } else {
171        (name, None)
172    };
173
174    if effective_name == POLICY_FILENAME {
175        let dir = match force_tier {
176            Some(MemoryTier::Global) => global_dir
177                .ok_or_else(|| anyhow::anyhow!("global memory directory not available"))?,
178            _ => &project_dir.join(".codelens").join("memories"),
179        };
180        write_policy(dir, content)?;
181        return Ok(force_tier.unwrap_or(MemoryTier::Project));
182    }
183
184    let loc = resolve_memory_tier(name, project_dir, global_dir);
185    let tier_dir = &loc.dir;
186    let policy = MemoryPolicy::load(tier_dir);
187    if policy.is_read_only(effective_name) {
188        bail!("memory '{name}' is read-only (matches policy pattern)");
189    }
190    let path = resolve_memory_path(tier_dir, effective_name)?;
191    if let Some(parent) = path.parent() {
192        std::fs::create_dir_all(parent)?;
193    }
194    let is_new = !path.exists();
195    std::fs::write(&path, content)?;
196    if let Some(rec) = recorder {
197        let event = if is_new {
198            MemoryAuditEvent::Created {
199                tier: loc.tier,
200                path: path.to_string_lossy().to_string(),
201            }
202        } else {
203            MemoryAuditEvent::Updated {
204                tier: loc.tier,
205                path: path.to_string_lossy().to_string(),
206            }
207        };
208        rec.record(&event);
209    }
210    Ok(loc.tier)
211}
212
213/// Delete a memory file.  Rejects deletes on read-only entries.
214pub fn delete_memory(memories_dir: &Path, name: &str) -> Result<()> {
215    if name == POLICY_FILENAME {
216        bail!("cannot delete the policy file; write an empty policy instead");
217    }
218    let policy = MemoryPolicy::load(memories_dir);
219    if policy.is_read_only(name) {
220        bail!("memory '{name}' is read-only and cannot be deleted");
221    }
222    let path = resolve_memory_path(memories_dir, name)?;
223    if !path.is_file() {
224        bail!("memory not found: {name}");
225    }
226    std::fs::remove_file(&path)?;
227    Ok(())
228}
229
230/// Delete a memory file from the appropriate tier.
231/// Records an audit event via `recorder` when provided.
232pub fn delete_memory_tiered(
233    project_dir: &Path,
234    global_dir: Option<&Path>,
235    name: &str,
236) -> Result<MemoryTier> {
237    delete_memory_tiered_rec(project_dir, global_dir, name, None)
238}
239
240/// Delete a memory file from the appropriate tier with optional audit recording.
241pub fn delete_memory_tiered_rec(
242    project_dir: &Path,
243    global_dir: Option<&Path>,
244    name: &str,
245    recorder: Option<&dyn AuditRecorder>,
246) -> Result<MemoryTier> {
247    let effective_name = name.trim_start_matches("global/");
248    let loc = resolve_memory_tier(name, project_dir, global_dir);
249    if effective_name == POLICY_FILENAME {
250        bail!("cannot delete the policy file; write an empty policy instead");
251    }
252    let policy = MemoryPolicy::load(&loc.dir);
253    if policy.is_read_only(effective_name) {
254        bail!("memory '{name}' is read-only and cannot be deleted");
255    }
256    if !loc.path.is_file() {
257        bail!("memory not found: {}", effective_name);
258    }
259    let path_str = loc.path.to_string_lossy().to_string();
260    std::fs::remove_file(&loc.path)?;
261    if let Some(rec) = recorder {
262        rec.record(&MemoryAuditEvent::Deleted {
263            tier: loc.tier,
264            path: path_str,
265        });
266    }
267    Ok(loc.tier)
268}
269
270/// Rename a memory file.  Rejects if source is read-only or target exists.
271pub fn rename_memory(memories_dir: &Path, old_name: &str, new_name: &str) -> Result<()> {
272    let policy = MemoryPolicy::load(memories_dir);
273    if policy.is_read_only(old_name) {
274        bail!("memory '{old_name}' is read-only and cannot be renamed");
275    }
276    if policy.is_read_only(new_name) {
277        bail!("target name '{new_name}' is read-only and cannot be overwritten");
278    }
279    let old_path = resolve_memory_path(memories_dir, old_name)?;
280    let new_path = resolve_memory_path(memories_dir, new_name)?;
281    if !old_path.is_file() {
282        bail!("memory not found: {old_name}");
283    }
284    if new_path.exists() {
285        bail!("target already exists: {new_name}");
286    }
287    if let Some(parent) = new_path.parent() {
288        std::fs::create_dir_all(parent)?;
289    }
290    std::fs::rename(&old_path, &new_path)?;
291    Ok(())
292}
293
294fn write_policy(memories_dir: &Path, content: &str) -> Result<()> {
295    if let Some(parent) = memories_dir.parent() {
296        std::fs::create_dir_all(parent)?;
297    }
298    std::fs::create_dir_all(memories_dir)?;
299    std::fs::write(memories_dir.join(POLICY_FILE_BASENAME), content)?;
300    Ok(())
301}
302
303/// Read the current policy content for a memories directory.
304pub fn read_policy(memories_dir: &Path) -> Result<String> {
305    let path = memories_dir.join(POLICY_FILE_BASENAME);
306    if path.is_file() {
307        std::fs::read_to_string(&path).with_context(|| "failed to read memory policy")
308    } else {
309        Ok(String::new())
310    }
311}