Skip to main content

codelens_engine/memory/
paths.rs

1use std::path::{Path, PathBuf};
2
3use anyhow::{Result, bail};
4use serde::{Deserialize, Serialize};
5
6use super::policy::POLICY_FILENAME;
7
8/// Memory tier determines the storage root.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
10pub enum MemoryTier {
11    /// Project-scoped: `<project>/.codelens/memories/`
12    Project,
13    /// User-wide: `$HOME/.codelens/memories/`
14    Global,
15}
16
17impl MemoryTier {
18    pub fn as_str(&self) -> &'static str {
19        match self {
20            Self::Project => "project",
21            Self::Global => "global",
22        }
23    }
24}
25
26/// A resolved memory location that carries its tier and absolute path.
27#[derive(Debug, Clone)]
28pub struct MemoryLocation {
29    pub tier: MemoryTier,
30    pub dir: PathBuf,
31    pub path: PathBuf,
32}
33
34/// Resolve which tier a memory name lives in.
35pub fn resolve_memory_tier(
36    name: &str,
37    project_dir: &Path,
38    global_dir: Option<&Path>,
39) -> MemoryLocation {
40    if let Some(stripped) = name.strip_prefix("global/") {
41        let stripped = stripped.trim_start_matches('/');
42        if let Some(gdir) = global_dir {
43            let path = resolve_memory_path(gdir, stripped)
44                .unwrap_or_else(|_| gdir.join(format!("{stripped}.md")));
45            return MemoryLocation {
46                tier: MemoryTier::Global,
47                dir: gdir.to_path_buf(),
48                path,
49            };
50        }
51    }
52
53    let project_memories = project_dir.join(".codelens").join("memories");
54    let project_path = resolve_memory_path(&project_memories, name);
55    if let Ok(path) = &project_path
56        && path.is_file()
57    {
58        return MemoryLocation {
59            tier: MemoryTier::Project,
60            dir: project_memories,
61            path: path.clone(),
62        };
63    }
64
65    if let Some(gdir) = global_dir {
66        let global_path = resolve_memory_path(gdir, name);
67        if let Ok(path) = &global_path
68            && path.is_file()
69        {
70            return MemoryLocation {
71                tier: MemoryTier::Global,
72                dir: gdir.to_path_buf(),
73                path: path.clone(),
74            };
75        }
76    }
77
78    let fallback_dir = project_dir.join(".codelens").join("memories");
79    MemoryLocation {
80        tier: MemoryTier::Project,
81        dir: fallback_dir.clone(),
82        path: project_path.unwrap_or_else(|_| fallback_dir.join(format!("{name}.md"))),
83    }
84}
85
86/// Return the global memory directory path: `$HOME/.codelens/memories`.
87pub fn global_memory_dir() -> Option<PathBuf> {
88    std::env::var_os("HOME")
89        .map(PathBuf::from)
90        .map(|home| home.join(".codelens").join("memories"))
91}
92
93/// Resolve a memory name to a filesystem path, with validation.
94pub fn resolve_memory_path(memories_dir: &Path, name: &str) -> Result<PathBuf> {
95    let normalized = name
96        .trim()
97        .replace('\\', "/")
98        .trim_matches('/')
99        .trim_end_matches(".md")
100        .to_string();
101    if normalized.is_empty() {
102        bail!("memory name must not be empty");
103    }
104    if normalized.contains("..") {
105        bail!("memory path must not contain '..': {name}");
106    }
107    Ok(memories_dir.join(format!("{normalized}.md")))
108}
109
110pub(crate) fn collect_memory_files(base: &Path, dir: &Path, names: &mut Vec<String>) {
111    let entries = match std::fs::read_dir(dir) {
112        Ok(e) => e,
113        Err(_) => return,
114    };
115    for entry in entries.flatten() {
116        let path = entry.path();
117        if path.is_dir() {
118            if let Some(fname) = path.file_name().and_then(|n| n.to_str())
119                && fname.starts_with('.')
120            {
121                continue;
122            }
123            collect_memory_files(base, &path, names);
124        } else if path.extension().and_then(|e| e.to_str()) == Some("md")
125            && let Ok(rel) = path.strip_prefix(base)
126        {
127            let name = rel
128                .to_string_lossy()
129                .replace('\\', "/")
130                .trim_end_matches(".md")
131                .to_string();
132            if name != POLICY_FILENAME {
133                names.push(name);
134            }
135        }
136    }
137}