Skip to main content

bamboo_memory/memory_store/
paths.rs

1use std::path::{Path, PathBuf};
2
3use bamboo_config::paths::bamboo_dir;
4use bamboo_domain::ProjectId;
5
6use super::types::MemoryScope;
7
8pub const MEMORY_ROOT_DIR: &str = "memory";
9pub const MEMORY_VERSION_DIR: &str = "v1";
10pub const SESSIONS_DIR: &str = "sessions";
11pub const SCOPES_DIR: &str = "scopes";
12pub const GLOBAL_DIR: &str = "global";
13pub const PROJECTS_DIR: &str = "projects";
14pub const NOTE_DIR: &str = "note";
15pub const STATE_DIR: &str = "state";
16pub const INDEXES_DIR: &str = "indexes";
17pub const VIEWS_DIR: &str = "views";
18pub const LOGS_DIR: &str = "logs";
19pub const TOPICS_DIR: &str = "topics";
20
21#[derive(Debug, Clone)]
22pub struct MemoryPathResolver {
23    data_dir: PathBuf,
24    root: PathBuf,
25    project_id: Option<ProjectId>,
26    legacy_project_read_roots: Vec<LegacyProjectMemoryReadRoot>,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct LegacyProjectMemoryReadRoot {
31    pub project_key: String,
32    pub root: PathBuf,
33}
34
35/// Explicit first-class Project memory layout.
36///
37/// This resolver is intentionally separate from the legacy path-hash
38/// `MemoryPathResolver::project_root(&str)`: a safe legacy key and a ProjectId
39/// can have the same character shape, so guessing by string format would move
40/// old scopes silently. Assigned-session writes must use this typed resolver;
41/// legacy roots remain read-only migration aliases.
42#[derive(Debug, Clone)]
43pub struct ProjectMemoryPathResolver {
44    data_dir: PathBuf,
45}
46
47impl ProjectMemoryPathResolver {
48    pub fn from_data_dir(data_dir: impl Into<PathBuf>) -> Self {
49        Self {
50            data_dir: data_dir.into(),
51        }
52    }
53
54    pub fn project_home(&self, project_id: &ProjectId) -> PathBuf {
55        self.data_dir.join(PROJECTS_DIR).join(project_id.as_str())
56    }
57
58    pub fn memory_root(&self, project_id: &ProjectId) -> PathBuf {
59        self.project_home(project_id)
60            .join(MEMORY_ROOT_DIR)
61            .join(MEMORY_VERSION_DIR)
62    }
63
64    pub fn legacy_read_root(&self, legacy_project_key: &str) -> std::io::Result<PathBuf> {
65        let legacy_project_key = legacy_project_key.trim();
66        if legacy_project_key.is_empty()
67            || legacy_project_key.contains('/')
68            || legacy_project_key.contains('\\')
69            || legacy_project_key == "."
70            || legacy_project_key == ".."
71        {
72            return Err(std::io::Error::new(
73                std::io::ErrorKind::InvalidInput,
74                "legacy project key is not a safe path component",
75            ));
76        }
77        Ok(self
78            .data_dir
79            .join(MEMORY_ROOT_DIR)
80            .join(MEMORY_VERSION_DIR)
81            .join(SCOPES_DIR)
82            .join(PROJECTS_DIR)
83            .join(legacy_project_key))
84    }
85}
86
87impl Default for MemoryPathResolver {
88    fn default() -> Self {
89        Self::from_data_dir(bamboo_dir())
90    }
91}
92
93impl MemoryPathResolver {
94    pub fn new(root: impl Into<PathBuf>) -> Self {
95        let root = root.into();
96        let data_dir = infer_data_dir_from_root(&root);
97        Self {
98            data_dir,
99            root,
100            project_id: None,
101            legacy_project_read_roots: Vec::new(),
102        }
103    }
104
105    pub fn from_data_dir(data_dir: impl Into<PathBuf>) -> Self {
106        let data_dir = data_dir.into();
107        let root = data_dir.join(MEMORY_ROOT_DIR).join(MEMORY_VERSION_DIR);
108        Self {
109            data_dir,
110            root,
111            project_id: None,
112            legacy_project_read_roots: Vec::new(),
113        }
114    }
115
116    /// Bind Project-scope paths to one validated first-class Project id.
117    ///
118    /// This is explicit rather than inferred from an arbitrary string key, so
119    /// legacy path-hash keys keep their original layout.
120    pub fn for_project(&self, project_id: &ProjectId) -> Self {
121        Self {
122            data_dir: self.data_dir.clone(),
123            root: self.root.clone(),
124            project_id: Some(project_id.clone()),
125            legacy_project_read_roots: Vec::new(),
126        }
127    }
128
129    pub fn for_project_with_legacy_read_roots(
130        &self,
131        project_id: &ProjectId,
132        legacy_project_read_roots: Vec<LegacyProjectMemoryReadRoot>,
133    ) -> Self {
134        Self {
135            data_dir: self.data_dir.clone(),
136            root: self.root.clone(),
137            project_id: Some(project_id.clone()),
138            legacy_project_read_roots,
139        }
140    }
141
142    pub fn project_id(&self) -> Option<&ProjectId> {
143        self.project_id.as_ref()
144    }
145
146    pub fn has_legacy_project_read_roots(&self) -> bool {
147        !self.legacy_project_read_roots.is_empty()
148    }
149
150    pub fn project_read_roots(&self, project_key: &str) -> Vec<PathBuf> {
151        if self
152            .project_id
153            .as_ref()
154            .is_some_and(|project_id| project_id.as_str() == project_key)
155        {
156            let mut roots = vec![self.project_root(project_key)];
157            roots.extend(
158                self.legacy_project_read_roots
159                    .iter()
160                    .map(|legacy| legacy.root.clone()),
161            );
162            return roots;
163        }
164        vec![self.project_root(project_key)]
165    }
166
167    pub fn is_legacy_project_read_path(&self, path: &Path) -> bool {
168        self.legacy_project_read_roots
169            .iter()
170            .any(|legacy| path.starts_with(&legacy.root))
171    }
172
173    pub fn scope_read_roots(&self, scope: MemoryScope, project_key: Option<&str>) -> Vec<PathBuf> {
174        match scope {
175            MemoryScope::Project => self.project_read_roots(project_key.unwrap_or("unknown")),
176            _ => vec![self.scope_root(scope, project_key)],
177        }
178    }
179
180    pub fn data_dir(&self) -> PathBuf {
181        self.data_dir.clone()
182    }
183
184    pub fn root(&self) -> PathBuf {
185        self.root.clone()
186    }
187
188    pub fn sessions_root(&self) -> PathBuf {
189        self.root.join(SESSIONS_DIR)
190    }
191
192    pub fn session_root(&self, session_id: &str) -> PathBuf {
193        self.sessions_root().join(session_id)
194    }
195
196    pub fn session_note_dir(&self, session_id: &str) -> PathBuf {
197        self.session_root(session_id).join(NOTE_DIR)
198    }
199
200    pub fn session_topic_path(&self, session_id: &str, topic: &str) -> PathBuf {
201        self.session_note_dir(session_id)
202            .join(format!("{}.md", topic))
203    }
204
205    pub fn session_state_path(&self, session_id: &str) -> PathBuf {
206        self.session_root(session_id).join("state.json")
207    }
208
209    pub fn scopes_root(&self) -> PathBuf {
210        self.root.join(SCOPES_DIR)
211    }
212
213    pub fn global_root(&self) -> PathBuf {
214        self.scopes_root().join(GLOBAL_DIR)
215    }
216
217    pub fn project_root(&self, project_key: &str) -> PathBuf {
218        if self
219            .project_id
220            .as_ref()
221            .is_some_and(|project_id| project_id.as_str() == project_key)
222        {
223            return ProjectMemoryPathResolver::from_data_dir(self.data_dir.clone())
224                .memory_root(self.project_id.as_ref().expect("checked Project id"));
225        }
226        self.scopes_root().join(PROJECTS_DIR).join(project_key)
227    }
228
229    pub fn scope_root(&self, scope: MemoryScope, project_key: Option<&str>) -> PathBuf {
230        match scope {
231            MemoryScope::Global => self.global_root(),
232            MemoryScope::Project => self.project_root(project_key.unwrap_or("unknown")),
233            MemoryScope::Session => self.sessions_root(),
234        }
235    }
236
237    pub fn topic_dir(&self, scope: MemoryScope, project_key: Option<&str>) -> PathBuf {
238        match scope {
239            MemoryScope::Global => self.global_root().join(TOPICS_DIR),
240            MemoryScope::Project => self
241                .project_root(project_key.unwrap_or("unknown"))
242                .join(TOPICS_DIR),
243            MemoryScope::Session => self.sessions_root(),
244        }
245    }
246
247    pub fn topic_path(
248        &self,
249        scope: MemoryScope,
250        project_key: Option<&str>,
251        memory_id: &str,
252    ) -> PathBuf {
253        self.topic_dir(scope, project_key)
254            .join(format!("{}.md", memory_id))
255    }
256
257    pub fn indexes_dir(&self, scope: MemoryScope, project_key: Option<&str>) -> PathBuf {
258        match scope {
259            MemoryScope::Global => self.global_root().join(INDEXES_DIR),
260            MemoryScope::Project => self
261                .project_root(project_key.unwrap_or("unknown"))
262                .join(INDEXES_DIR),
263            MemoryScope::Session => self.sessions_root(),
264        }
265    }
266
267    pub fn views_dir(&self, scope: MemoryScope, project_key: Option<&str>) -> PathBuf {
268        match scope {
269            MemoryScope::Global => self.global_root().join(VIEWS_DIR),
270            MemoryScope::Project => self
271                .project_root(project_key.unwrap_or("unknown"))
272                .join(VIEWS_DIR),
273            MemoryScope::Session => self.sessions_root(),
274        }
275    }
276
277    pub fn logs_dir(&self, scope: MemoryScope, project_key: Option<&str>) -> PathBuf {
278        match scope {
279            MemoryScope::Global => self.global_root().join(LOGS_DIR),
280            MemoryScope::Project => self
281                .project_root(project_key.unwrap_or("unknown"))
282                .join(LOGS_DIR),
283            MemoryScope::Session => self.sessions_root(),
284        }
285    }
286
287    pub fn state_dir(&self, scope: MemoryScope, project_key: Option<&str>) -> PathBuf {
288        match scope {
289            MemoryScope::Global => self.global_root().join(STATE_DIR),
290            MemoryScope::Project => self
291                .project_root(project_key.unwrap_or("unknown"))
292                .join(STATE_DIR),
293            MemoryScope::Session => self.sessions_root(),
294        }
295    }
296
297    pub fn legacy_notes_root(&self) -> PathBuf {
298        self.data_dir.join("notes")
299    }
300
301    pub fn legacy_session_file(&self, session_id: &str) -> PathBuf {
302        self.legacy_notes_root().join(format!("{}.md", session_id))
303    }
304
305    pub fn legacy_session_topic_dir(&self, session_id: &str) -> PathBuf {
306        self.legacy_notes_root().join(session_id)
307    }
308}
309
310pub fn memory_root() -> PathBuf {
311    MemoryPathResolver::from_data_dir(bamboo_dir()).root()
312}
313
314fn infer_data_dir_from_root(root: &Path) -> PathBuf {
315    if root
316        .file_name()
317        .and_then(|value| value.to_str())
318        .is_some_and(|value| value == MEMORY_VERSION_DIR)
319    {
320        if let Some(memory_dir) = root.parent() {
321            if memory_dir
322                .file_name()
323                .and_then(|value| value.to_str())
324                .is_some_and(|value| value == MEMORY_ROOT_DIR)
325            {
326                if let Some(data_dir) = memory_dir.parent() {
327                    return data_dir.to_path_buf();
328                }
329            }
330        }
331    }
332    root.to_path_buf()
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338
339    #[test]
340    fn paths_follow_v1_layout() {
341        let resolver = MemoryPathResolver::new("/tmp/memory/v1");
342        assert_eq!(
343            resolver.session_topic_path("session-1", "default"),
344            PathBuf::from("/tmp/memory/v1/sessions/session-1/note/default.md")
345        );
346        assert_eq!(
347            resolver.topic_path(MemoryScope::Project, Some("proj-1"), "mem_1"),
348            PathBuf::from("/tmp/memory/v1/scopes/projects/proj-1/topics/mem_1.md")
349        );
350    }
351
352    #[test]
353    fn typed_project_memory_uses_project_home_without_guessing_legacy_keys() {
354        let resolver = ProjectMemoryPathResolver::from_data_dir("/tmp/bamboo");
355        let project_id = ProjectId::parse("01JABCDEF0123456789ABCDEFG").expect("id");
356        assert_eq!(
357            resolver.memory_root(&project_id),
358            PathBuf::from("/tmp/bamboo/projects/01JABCDEF0123456789ABCDEFG/memory/v1")
359        );
360        let scoped = MemoryPathResolver::from_data_dir("/tmp/bamboo").for_project(&project_id);
361        assert_eq!(
362            scoped.topic_dir(MemoryScope::Project, Some(project_id.as_str())),
363            PathBuf::from("/tmp/bamboo/projects/01JABCDEF0123456789ABCDEFG/memory/v1/topics")
364        );
365        assert_eq!(
366            resolver
367                .legacy_read_root("zenith-deadbeef")
368                .expect("legacy"),
369            PathBuf::from("/tmp/bamboo/memory/v1/scopes/projects/zenith-deadbeef")
370        );
371        assert!(resolver.legacy_read_root("../escape").is_err());
372    }
373}