bamboo_memory/memory_store/
paths.rs1use 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#[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 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}