1use std::collections::BTreeMap;
61use std::fs;
62use std::path::{Path, PathBuf};
63
64use serde::Serialize;
65
66use crate::artifacts::{frontmatter_entries, split_frontmatter};
67use crate::error::{Error, Result};
68
69#[derive(Debug, Clone)]
74pub struct MemoryRoot {
75 path: PathBuf,
76}
77
78impl MemoryRoot {
79 pub fn home() -> Result<Self> {
82 let home = home_dir().ok_or_else(|| Error::Artifacts {
83 message: "could not determine user home directory".to_string(),
84 })?;
85 Ok(Self {
86 path: home.join(".claude").join("projects"),
87 })
88 }
89
90 pub fn at(path: impl Into<PathBuf>) -> Self {
93 Self { path: path.into() }
94 }
95
96 pub fn path(&self) -> &Path {
98 &self.path
99 }
100
101 pub fn list_projects_with_memory(&self) -> Result<Vec<ProjectMemorySummary>> {
105 let entries = match fs::read_dir(&self.path) {
106 Ok(it) => it,
107 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
108 Err(e) => return Err(e.into()),
109 };
110 let mut out = Vec::new();
111 for entry in entries.flatten() {
112 let project_dir = entry.path();
113 if !project_dir.is_dir() {
114 continue;
115 }
116 let Some(slug) = project_dir.file_name().and_then(|s| s.to_str()) else {
117 continue;
118 };
119 let memory_dir = project_dir.join("memory");
120 if !memory_dir.is_dir() {
121 continue;
122 }
123 let entry_count = memory_files(&memory_dir).len();
124 let has_index = memory_dir.join("MEMORY.md").is_file();
125 out.push(ProjectMemorySummary {
126 slug: slug.to_string(),
127 memory_dir,
128 entry_count,
129 has_index,
130 });
131 }
132 out.sort_by(|a, b| a.slug.cmp(&b.slug));
133 Ok(out)
134 }
135
136 pub fn list(&self, slug: &str) -> Result<Vec<MemorySummary>> {
142 let memory_dir = self.path.join(slug).join("memory");
143 let mut out = Vec::new();
144 for path in memory_files(&memory_dir) {
145 match parse_memory_file(&path) {
146 Ok(memory) => out.push(MemorySummary::from_memory(&memory)),
147 Err(e) => tracing::warn!(?path, "skipping memory file: {e}"),
148 }
149 }
150 out.sort_by(|a, b| a.file_stem.cmp(&b.file_stem));
151 Ok(out)
152 }
153
154 pub fn get(&self, slug: &str, file_stem: &str) -> Result<Memory> {
158 let path = self
159 .path
160 .join(slug)
161 .join("memory")
162 .join(format!("{file_stem}.md"));
163 if !path.is_file() {
164 return Err(Error::Artifacts {
165 message: format!("no memory at {}", path.display()),
166 });
167 }
168 parse_memory_file(&path)
169 }
170
171 pub fn index(&self, slug: &str) -> Result<Option<String>> {
174 let path = self.path.join(slug).join("memory").join("MEMORY.md");
175 if !path.is_file() {
176 return Ok(None);
177 }
178 Ok(Some(fs::read_to_string(&path)?))
179 }
180}
181
182#[derive(Debug, Clone, Serialize)]
185pub struct ProjectMemorySummary {
186 pub slug: String,
188 pub memory_dir: PathBuf,
190 pub entry_count: usize,
192 pub has_index: bool,
194}
195
196#[derive(Debug, Clone, Serialize)]
199pub struct MemorySummary {
200 pub file_stem: String,
203 pub name: String,
205 pub description: Option<String>,
207 pub memory_type: Option<String>,
211 pub file_path: PathBuf,
213 pub size_bytes: u64,
215}
216
217impl MemorySummary {
218 fn from_memory(m: &Memory) -> Self {
219 let size_bytes = fs::metadata(&m.file_path)
220 .map(|meta| meta.len())
221 .unwrap_or_default();
222 Self {
223 file_stem: m.file_stem.clone(),
224 name: m.name.clone(),
225 description: m.description.clone(),
226 memory_type: m.memory_type.clone(),
227 file_path: m.file_path.clone(),
228 size_bytes,
229 }
230 }
231}
232
233#[derive(Debug, Clone, Serialize)]
235pub struct Memory {
236 pub file_stem: String,
239 pub name: String,
241 pub description: Option<String>,
243 pub memory_type: Option<String>,
245 pub file_path: PathBuf,
247 pub body: String,
251 pub extra: BTreeMap<String, String>,
255}
256
257fn memory_files(dir: &Path) -> Vec<PathBuf> {
261 let mut out = Vec::new();
262 if let Ok(entries) = fs::read_dir(dir) {
263 for entry in entries.flatten() {
264 let path = entry.path();
265 if !path.is_file() {
266 continue;
267 }
268 if path.extension().and_then(|s| s.to_str()) != Some("md") {
269 continue;
270 }
271 if path.file_name().and_then(|s| s.to_str()) == Some("MEMORY.md") {
272 continue;
273 }
274 out.push(path);
275 }
276 }
277 out
278}
279
280fn parse_memory_file(file_path: &Path) -> Result<Memory> {
281 let file_stem = file_path
282 .file_stem()
283 .and_then(|s| s.to_str())
284 .unwrap_or_default()
285 .to_string();
286 let raw = fs::read_to_string(file_path)?;
287 let (frontmatter, body) = split_frontmatter(&raw);
288
289 let mut name = file_stem.clone();
290 let mut description = None;
291 let mut memory_type = None;
292 let mut extra = BTreeMap::new();
293
294 if let Some(fm) = frontmatter {
295 for (key, value) in frontmatter_entries(fm) {
296 let value = unquote(&value).to_string();
297 match key.as_str() {
298 "name" if !value.is_empty() => name = value,
299 "description" if !value.is_empty() => description = Some(value),
300 "type" if !value.is_empty() => memory_type = Some(value),
304 _ if !value.is_empty() => {
305 extra.insert(key, value);
306 }
307 _ => {}
308 }
309 }
310 }
311
312 Ok(Memory {
313 file_stem,
314 name,
315 description,
316 memory_type,
317 file_path: file_path.to_path_buf(),
318 body: body.trim().to_string(),
319 extra,
320 })
321}
322
323fn unquote(value: &str) -> &str {
327 value
328 .strip_prefix('"')
329 .and_then(|v| v.strip_suffix('"'))
330 .unwrap_or(value)
331}
332
333fn home_dir() -> Option<PathBuf> {
334 if let Ok(h) = std::env::var("HOME")
335 && !h.is_empty()
336 {
337 return Some(PathBuf::from(h));
338 }
339 if let Ok(h) = std::env::var("USERPROFILE")
340 && !h.is_empty()
341 {
342 return Some(PathBuf::from(h));
343 }
344 None
345}
346
347#[cfg(test)]
348mod tests {
349 use super::*;
350 use std::io::Write;
351
352 fn write_memory(root: &Path, slug: &str, stem: &str, contents: &str) -> PathBuf {
353 let dir = root.join(slug).join("memory");
354 fs::create_dir_all(&dir).expect("create memory dir");
355 let path = dir.join(format!("{stem}.md"));
356 let mut f = fs::File::create(&path).expect("create memory file");
357 f.write_all(contents.as_bytes()).expect("write memory file");
358 path
359 }
360
361 fn fixture_root() -> tempfile::TempDir {
362 let tmp = tempfile::tempdir().expect("tempdir");
363 write_memory(
364 tmp.path(),
365 "-Users-me-Code-projA",
366 "user-name",
367 "---\nname: user-name\ndescription: \"preferred name - quoted\"\nmetadata:\n type: user\n---\n\nThe user goes by Zed. See [[other-memory]].\n",
368 );
369 write_memory(
370 tmp.path(),
371 "-Users-me-Code-projA",
372 "no-frontmatter",
373 "Just a body.\n",
374 );
375 fs::write(
376 tmp.path()
377 .join("-Users-me-Code-projA")
378 .join("memory")
379 .join("MEMORY.md"),
380 "# Memory index\n\n- [User name](user-name.md)\n",
381 )
382 .unwrap();
383 fs::create_dir_all(tmp.path().join("-Users-me-Code-projB")).unwrap();
385 tmp
386 }
387
388 #[test]
389 fn list_projects_with_memory_omits_projects_without() {
390 let tmp = fixture_root();
391 let root = MemoryRoot::at(tmp.path());
392 let projects = root.list_projects_with_memory().expect("list");
393 assert_eq!(projects.len(), 1);
394 assert_eq!(projects[0].slug, "-Users-me-Code-projA");
395 assert_eq!(projects[0].entry_count, 2);
396 assert!(projects[0].has_index);
397 }
398
399 #[test]
400 fn list_projects_missing_root_returns_empty() {
401 let tmp = tempfile::tempdir().unwrap();
402 let root = MemoryRoot::at(tmp.path().join("does-not-exist"));
403 assert!(root.list_projects_with_memory().expect("ok").is_empty());
404 }
405
406 #[test]
407 fn list_excludes_index_and_parses_metadata() {
408 let tmp = fixture_root();
409 let root = MemoryRoot::at(tmp.path());
410 let memories = root.list("-Users-me-Code-projA").expect("list");
411 let stems: Vec<&str> = memories.iter().map(|m| m.file_stem.as_str()).collect();
412 assert_eq!(stems, ["no-frontmatter", "user-name"]);
413 let m = memories
414 .iter()
415 .find(|m| m.file_stem == "user-name")
416 .unwrap();
417 assert_eq!(m.name, "user-name");
418 assert_eq!(m.description.as_deref(), Some("preferred name - quoted"));
419 assert_eq!(m.memory_type.as_deref(), Some("user"));
420 assert!(m.size_bytes > 0);
421 }
422
423 #[test]
424 fn list_unknown_slug_returns_empty() {
425 let tmp = fixture_root();
426 let root = MemoryRoot::at(tmp.path());
427 assert!(root.list("nope").expect("ok").is_empty());
428 assert!(root.list("-Users-me-Code-projB").expect("ok").is_empty());
429 }
430
431 #[test]
432 fn get_returns_body_and_falls_back_to_stem() {
433 let tmp = fixture_root();
434 let root = MemoryRoot::at(tmp.path());
435 let m = root.get("-Users-me-Code-projA", "user-name").expect("get");
436 assert!(m.body.contains("[[other-memory]]"));
437 let nf = root
438 .get("-Users-me-Code-projA", "no-frontmatter")
439 .expect("get");
440 assert_eq!(nf.name, "no-frontmatter");
441 assert_eq!(nf.memory_type, None);
442 assert_eq!(nf.body, "Just a body.");
443 }
444
445 #[test]
446 fn get_unknown_stem_errors() {
447 let tmp = fixture_root();
448 let root = MemoryRoot::at(tmp.path());
449 let err = root.get("-Users-me-Code-projA", "nope").unwrap_err();
450 assert!(err.to_string().contains("no memory at"));
451 }
452
453 #[test]
454 fn index_reads_memory_md_or_none() {
455 let tmp = fixture_root();
456 let root = MemoryRoot::at(tmp.path());
457 let idx = root.index("-Users-me-Code-projA").expect("ok");
458 assert!(idx.expect("present").contains("# Memory index"));
459 assert!(root.index("-Users-me-Code-projB").expect("ok").is_none());
460 assert!(root.index("nope").expect("ok").is_none());
461 }
462
463 #[test]
464 fn unknown_frontmatter_keys_land_in_extra() {
465 let tmp = tempfile::tempdir().unwrap();
466 write_memory(
467 tmp.path(),
468 "-slug",
469 "weird",
470 "---\nname: weird\nmetadata:\n type: reference\n originSessionId: abc\ncustom: kept\n---\nbody\n",
471 );
472 let root = MemoryRoot::at(tmp.path());
473 let m = root.get("-slug", "weird").expect("get");
474 assert_eq!(m.memory_type.as_deref(), Some("reference"));
475 assert_eq!(
476 m.extra.get("originSessionId").map(String::as_str),
477 Some("abc")
478 );
479 assert_eq!(m.extra.get("custom").map(String::as_str), Some("kept"));
480 assert!(!m.extra.contains_key("metadata"));
482 }
483
484 #[test]
485 fn folded_description_with_colons_is_one_value() {
486 let tmp = tempfile::tempdir().unwrap();
487 write_memory(
488 tmp.path(),
489 "-slug",
490 "folded",
491 concat!(
492 "---\n",
493 "name: folded\n",
494 "description: >-\n",
495 " Restarting as its own repo: MCP server plus CLI over one router,\n",
496 " SQLite persistence.\n",
497 "metadata:\n",
498 " type: project\n",
499 "---\n\nBody.\n",
500 ),
501 );
502 let root = MemoryRoot::at(tmp.path());
503 let m = root.get("-slug", "folded").expect("get");
504 assert_eq!(
505 m.description.as_deref(),
506 Some(
507 "Restarting as its own repo: MCP server plus CLI over one router, \
508 SQLite persistence."
509 )
510 );
511 assert_eq!(m.memory_type.as_deref(), Some("project"));
513 assert!(m.extra.is_empty(), "extra: {:?}", m.extra);
514 assert_eq!(m.body, "Body.");
515 }
516}