1use std::collections::BTreeMap;
54use std::fs;
55use std::path::{Path, PathBuf};
56
57use serde::Serialize;
58
59use crate::artifacts::split_frontmatter;
60use crate::error::{Error, Result};
61
62#[derive(Debug, Clone)]
67pub struct MemoryRoot {
68 path: PathBuf,
69}
70
71impl MemoryRoot {
72 pub fn home() -> Result<Self> {
75 let home = home_dir().ok_or_else(|| Error::Artifacts {
76 message: "could not determine user home directory".to_string(),
77 })?;
78 Ok(Self {
79 path: home.join(".claude").join("projects"),
80 })
81 }
82
83 pub fn at(path: impl Into<PathBuf>) -> Self {
86 Self { path: path.into() }
87 }
88
89 pub fn path(&self) -> &Path {
91 &self.path
92 }
93
94 pub fn list_projects_with_memory(&self) -> Result<Vec<ProjectMemorySummary>> {
98 let entries = match fs::read_dir(&self.path) {
99 Ok(it) => it,
100 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
101 Err(e) => return Err(e.into()),
102 };
103 let mut out = Vec::new();
104 for entry in entries.flatten() {
105 let project_dir = entry.path();
106 if !project_dir.is_dir() {
107 continue;
108 }
109 let Some(slug) = project_dir.file_name().and_then(|s| s.to_str()) else {
110 continue;
111 };
112 let memory_dir = project_dir.join("memory");
113 if !memory_dir.is_dir() {
114 continue;
115 }
116 let entry_count = memory_files(&memory_dir).len();
117 let has_index = memory_dir.join("MEMORY.md").is_file();
118 out.push(ProjectMemorySummary {
119 slug: slug.to_string(),
120 memory_dir,
121 entry_count,
122 has_index,
123 });
124 }
125 out.sort_by(|a, b| a.slug.cmp(&b.slug));
126 Ok(out)
127 }
128
129 pub fn list(&self, slug: &str) -> Result<Vec<MemorySummary>> {
135 let memory_dir = self.path.join(slug).join("memory");
136 let mut out = Vec::new();
137 for path in memory_files(&memory_dir) {
138 match parse_memory_file(&path) {
139 Ok(memory) => out.push(MemorySummary::from_memory(&memory)),
140 Err(e) => tracing::warn!(?path, "skipping memory file: {e}"),
141 }
142 }
143 out.sort_by(|a, b| a.file_stem.cmp(&b.file_stem));
144 Ok(out)
145 }
146
147 pub fn get(&self, slug: &str, file_stem: &str) -> Result<Memory> {
151 let path = self
152 .path
153 .join(slug)
154 .join("memory")
155 .join(format!("{file_stem}.md"));
156 if !path.is_file() {
157 return Err(Error::Artifacts {
158 message: format!("no memory at {}", path.display()),
159 });
160 }
161 parse_memory_file(&path)
162 }
163
164 pub fn index(&self, slug: &str) -> Result<Option<String>> {
167 let path = self.path.join(slug).join("memory").join("MEMORY.md");
168 if !path.is_file() {
169 return Ok(None);
170 }
171 Ok(Some(fs::read_to_string(&path)?))
172 }
173}
174
175#[derive(Debug, Clone, Serialize)]
178pub struct ProjectMemorySummary {
179 pub slug: String,
181 pub memory_dir: PathBuf,
183 pub entry_count: usize,
185 pub has_index: bool,
187}
188
189#[derive(Debug, Clone, Serialize)]
192pub struct MemorySummary {
193 pub file_stem: String,
196 pub name: String,
198 pub description: Option<String>,
200 pub memory_type: Option<String>,
204 pub file_path: PathBuf,
206 pub size_bytes: u64,
208}
209
210impl MemorySummary {
211 fn from_memory(m: &Memory) -> Self {
212 let size_bytes = fs::metadata(&m.file_path)
213 .map(|meta| meta.len())
214 .unwrap_or_default();
215 Self {
216 file_stem: m.file_stem.clone(),
217 name: m.name.clone(),
218 description: m.description.clone(),
219 memory_type: m.memory_type.clone(),
220 file_path: m.file_path.clone(),
221 size_bytes,
222 }
223 }
224}
225
226#[derive(Debug, Clone, Serialize)]
228pub struct Memory {
229 pub file_stem: String,
232 pub name: String,
234 pub description: Option<String>,
236 pub memory_type: Option<String>,
238 pub file_path: PathBuf,
240 pub body: String,
244 pub extra: BTreeMap<String, String>,
248}
249
250fn memory_files(dir: &Path) -> Vec<PathBuf> {
254 let mut out = Vec::new();
255 if let Ok(entries) = fs::read_dir(dir) {
256 for entry in entries.flatten() {
257 let path = entry.path();
258 if !path.is_file() {
259 continue;
260 }
261 if path.extension().and_then(|s| s.to_str()) != Some("md") {
262 continue;
263 }
264 if path.file_name().and_then(|s| s.to_str()) == Some("MEMORY.md") {
265 continue;
266 }
267 out.push(path);
268 }
269 }
270 out
271}
272
273fn parse_memory_file(file_path: &Path) -> Result<Memory> {
274 let file_stem = file_path
275 .file_stem()
276 .and_then(|s| s.to_str())
277 .unwrap_or_default()
278 .to_string();
279 let raw = fs::read_to_string(file_path)?;
280 let (frontmatter, body) = split_frontmatter(&raw);
281
282 let mut name = file_stem.clone();
283 let mut description = None;
284 let mut memory_type = None;
285 let mut extra = BTreeMap::new();
286
287 if let Some(fm) = frontmatter {
288 for line in fm.lines() {
289 let trimmed = line.trim();
290 if trimmed.is_empty() {
291 continue;
292 }
293 let Some((k, v)) = trimmed.split_once(':') else {
294 continue;
295 };
296 let key = k.trim();
297 let value = unquote(v.trim()).to_string();
298 match key {
299 "name" if !value.is_empty() => name = value,
300 "description" if !value.is_empty() => description = Some(value),
301 "type" if !value.is_empty() => memory_type = Some(value),
305 _ if !key.is_empty() && !value.is_empty() => {
306 extra.insert(key.to_string(), value);
307 }
308 _ => {}
309 }
310 }
311 }
312
313 Ok(Memory {
314 file_stem,
315 name,
316 description,
317 memory_type,
318 file_path: file_path.to_path_buf(),
319 body: body.trim().to_string(),
320 extra,
321 })
322}
323
324fn unquote(value: &str) -> &str {
328 value
329 .strip_prefix('"')
330 .and_then(|v| v.strip_suffix('"'))
331 .unwrap_or(value)
332}
333
334fn home_dir() -> Option<PathBuf> {
335 if let Ok(h) = std::env::var("HOME")
336 && !h.is_empty()
337 {
338 return Some(PathBuf::from(h));
339 }
340 if let Ok(h) = std::env::var("USERPROFILE")
341 && !h.is_empty()
342 {
343 return Some(PathBuf::from(h));
344 }
345 None
346}
347
348#[cfg(test)]
349mod tests {
350 use super::*;
351 use std::io::Write;
352
353 fn write_memory(root: &Path, slug: &str, stem: &str, contents: &str) -> PathBuf {
354 let dir = root.join(slug).join("memory");
355 fs::create_dir_all(&dir).expect("create memory dir");
356 let path = dir.join(format!("{stem}.md"));
357 let mut f = fs::File::create(&path).expect("create memory file");
358 f.write_all(contents.as_bytes()).expect("write memory file");
359 path
360 }
361
362 fn fixture_root() -> tempfile::TempDir {
363 let tmp = tempfile::tempdir().expect("tempdir");
364 write_memory(
365 tmp.path(),
366 "-Users-me-Code-projA",
367 "user-name",
368 "---\nname: user-name\ndescription: \"preferred name - quoted\"\nmetadata:\n type: user\n---\n\nThe user goes by Zed. See [[other-memory]].\n",
369 );
370 write_memory(
371 tmp.path(),
372 "-Users-me-Code-projA",
373 "no-frontmatter",
374 "Just a body.\n",
375 );
376 fs::write(
377 tmp.path()
378 .join("-Users-me-Code-projA")
379 .join("memory")
380 .join("MEMORY.md"),
381 "# Memory index\n\n- [User name](user-name.md)\n",
382 )
383 .unwrap();
384 fs::create_dir_all(tmp.path().join("-Users-me-Code-projB")).unwrap();
386 tmp
387 }
388
389 #[test]
390 fn list_projects_with_memory_omits_projects_without() {
391 let tmp = fixture_root();
392 let root = MemoryRoot::at(tmp.path());
393 let projects = root.list_projects_with_memory().expect("list");
394 assert_eq!(projects.len(), 1);
395 assert_eq!(projects[0].slug, "-Users-me-Code-projA");
396 assert_eq!(projects[0].entry_count, 2);
397 assert!(projects[0].has_index);
398 }
399
400 #[test]
401 fn list_projects_missing_root_returns_empty() {
402 let tmp = tempfile::tempdir().unwrap();
403 let root = MemoryRoot::at(tmp.path().join("does-not-exist"));
404 assert!(root.list_projects_with_memory().expect("ok").is_empty());
405 }
406
407 #[test]
408 fn list_excludes_index_and_parses_metadata() {
409 let tmp = fixture_root();
410 let root = MemoryRoot::at(tmp.path());
411 let memories = root.list("-Users-me-Code-projA").expect("list");
412 let stems: Vec<&str> = memories.iter().map(|m| m.file_stem.as_str()).collect();
413 assert_eq!(stems, ["no-frontmatter", "user-name"]);
414 let m = memories
415 .iter()
416 .find(|m| m.file_stem == "user-name")
417 .unwrap();
418 assert_eq!(m.name, "user-name");
419 assert_eq!(m.description.as_deref(), Some("preferred name - quoted"));
420 assert_eq!(m.memory_type.as_deref(), Some("user"));
421 assert!(m.size_bytes > 0);
422 }
423
424 #[test]
425 fn list_unknown_slug_returns_empty() {
426 let tmp = fixture_root();
427 let root = MemoryRoot::at(tmp.path());
428 assert!(root.list("nope").expect("ok").is_empty());
429 assert!(root.list("-Users-me-Code-projB").expect("ok").is_empty());
430 }
431
432 #[test]
433 fn get_returns_body_and_falls_back_to_stem() {
434 let tmp = fixture_root();
435 let root = MemoryRoot::at(tmp.path());
436 let m = root.get("-Users-me-Code-projA", "user-name").expect("get");
437 assert!(m.body.contains("[[other-memory]]"));
438 let nf = root
439 .get("-Users-me-Code-projA", "no-frontmatter")
440 .expect("get");
441 assert_eq!(nf.name, "no-frontmatter");
442 assert_eq!(nf.memory_type, None);
443 assert_eq!(nf.body, "Just a body.");
444 }
445
446 #[test]
447 fn get_unknown_stem_errors() {
448 let tmp = fixture_root();
449 let root = MemoryRoot::at(tmp.path());
450 let err = root.get("-Users-me-Code-projA", "nope").unwrap_err();
451 assert!(err.to_string().contains("no memory at"));
452 }
453
454 #[test]
455 fn index_reads_memory_md_or_none() {
456 let tmp = fixture_root();
457 let root = MemoryRoot::at(tmp.path());
458 let idx = root.index("-Users-me-Code-projA").expect("ok");
459 assert!(idx.expect("present").contains("# Memory index"));
460 assert!(root.index("-Users-me-Code-projB").expect("ok").is_none());
461 assert!(root.index("nope").expect("ok").is_none());
462 }
463
464 #[test]
465 fn unknown_frontmatter_keys_land_in_extra() {
466 let tmp = tempfile::tempdir().unwrap();
467 write_memory(
468 tmp.path(),
469 "-slug",
470 "weird",
471 "---\nname: weird\nmetadata:\n type: reference\n originSessionId: abc\ncustom: kept\n---\nbody\n",
472 );
473 let root = MemoryRoot::at(tmp.path());
474 let m = root.get("-slug", "weird").expect("get");
475 assert_eq!(m.memory_type.as_deref(), Some("reference"));
476 assert_eq!(
477 m.extra.get("originSessionId").map(String::as_str),
478 Some("abc")
479 );
480 assert_eq!(m.extra.get("custom").map(String::as_str), Some("kept"));
481 assert!(!m.extra.contains_key("metadata"));
483 }
484}