use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use serde::Serialize;
use crate::artifacts::{frontmatter_entries, split_frontmatter};
use crate::error::{Error, Result};
#[derive(Debug, Clone)]
pub struct MemoryRoot {
path: PathBuf,
}
impl MemoryRoot {
pub fn home() -> Result<Self> {
let home = home_dir().ok_or_else(|| Error::Artifacts {
message: "could not determine user home directory".to_string(),
})?;
Ok(Self {
path: home.join(".claude").join("projects"),
})
}
pub fn at(path: impl Into<PathBuf>) -> Self {
Self { path: path.into() }
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn list_projects_with_memory(&self) -> Result<Vec<ProjectMemorySummary>> {
let entries = match fs::read_dir(&self.path) {
Ok(it) => it,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(e.into()),
};
let mut out = Vec::new();
for entry in entries.flatten() {
let project_dir = entry.path();
if !project_dir.is_dir() {
continue;
}
let Some(slug) = project_dir.file_name().and_then(|s| s.to_str()) else {
continue;
};
let memory_dir = project_dir.join("memory");
if !memory_dir.is_dir() {
continue;
}
let entry_count = memory_files(&memory_dir).len();
let has_index = memory_dir.join("MEMORY.md").is_file();
out.push(ProjectMemorySummary {
slug: slug.to_string(),
memory_dir,
entry_count,
has_index,
});
}
out.sort_by(|a, b| a.slug.cmp(&b.slug));
Ok(out)
}
pub fn list(&self, slug: &str) -> Result<Vec<MemorySummary>> {
let memory_dir = self.path.join(slug).join("memory");
let mut out = Vec::new();
for path in memory_files(&memory_dir) {
match parse_memory_file(&path) {
Ok(memory) => out.push(MemorySummary::from_memory(&memory)),
Err(e) => tracing::warn!(?path, "skipping memory file: {e}"),
}
}
out.sort_by(|a, b| a.file_stem.cmp(&b.file_stem));
Ok(out)
}
pub fn get(&self, slug: &str, file_stem: &str) -> Result<Memory> {
let path = self
.path
.join(slug)
.join("memory")
.join(format!("{file_stem}.md"));
if !path.is_file() {
return Err(Error::Artifacts {
message: format!("no memory at {}", path.display()),
});
}
parse_memory_file(&path)
}
pub fn index(&self, slug: &str) -> Result<Option<String>> {
let path = self.path.join(slug).join("memory").join("MEMORY.md");
if !path.is_file() {
return Ok(None);
}
Ok(Some(fs::read_to_string(&path)?))
}
}
#[derive(Debug, Clone, Serialize)]
pub struct ProjectMemorySummary {
pub slug: String,
pub memory_dir: PathBuf,
pub entry_count: usize,
pub has_index: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct MemorySummary {
pub file_stem: String,
pub name: String,
pub description: Option<String>,
pub memory_type: Option<String>,
pub file_path: PathBuf,
pub size_bytes: u64,
}
impl MemorySummary {
fn from_memory(m: &Memory) -> Self {
let size_bytes = fs::metadata(&m.file_path)
.map(|meta| meta.len())
.unwrap_or_default();
Self {
file_stem: m.file_stem.clone(),
name: m.name.clone(),
description: m.description.clone(),
memory_type: m.memory_type.clone(),
file_path: m.file_path.clone(),
size_bytes,
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct Memory {
pub file_stem: String,
pub name: String,
pub description: Option<String>,
pub memory_type: Option<String>,
pub file_path: PathBuf,
pub body: String,
pub extra: BTreeMap<String, String>,
}
fn memory_files(dir: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
if let Ok(entries) = fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
if path.extension().and_then(|s| s.to_str()) != Some("md") {
continue;
}
if path.file_name().and_then(|s| s.to_str()) == Some("MEMORY.md") {
continue;
}
out.push(path);
}
}
out
}
fn parse_memory_file(file_path: &Path) -> Result<Memory> {
let file_stem = file_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or_default()
.to_string();
let raw = fs::read_to_string(file_path)?;
let (frontmatter, body) = split_frontmatter(&raw);
let mut name = file_stem.clone();
let mut description = None;
let mut memory_type = None;
let mut extra = BTreeMap::new();
if let Some(fm) = frontmatter {
for (key, value) in frontmatter_entries(fm) {
let value = unquote(&value).to_string();
match key.as_str() {
"name" if !value.is_empty() => name = value,
"description" if !value.is_empty() => description = Some(value),
"type" if !value.is_empty() => memory_type = Some(value),
_ if !value.is_empty() => {
extra.insert(key, value);
}
_ => {}
}
}
}
Ok(Memory {
file_stem,
name,
description,
memory_type,
file_path: file_path.to_path_buf(),
body: body.trim().to_string(),
extra,
})
}
fn unquote(value: &str) -> &str {
value
.strip_prefix('"')
.and_then(|v| v.strip_suffix('"'))
.unwrap_or(value)
}
fn home_dir() -> Option<PathBuf> {
if let Ok(h) = std::env::var("HOME")
&& !h.is_empty()
{
return Some(PathBuf::from(h));
}
if let Ok(h) = std::env::var("USERPROFILE")
&& !h.is_empty()
{
return Some(PathBuf::from(h));
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn write_memory(root: &Path, slug: &str, stem: &str, contents: &str) -> PathBuf {
let dir = root.join(slug).join("memory");
fs::create_dir_all(&dir).expect("create memory dir");
let path = dir.join(format!("{stem}.md"));
let mut f = fs::File::create(&path).expect("create memory file");
f.write_all(contents.as_bytes()).expect("write memory file");
path
}
fn fixture_root() -> tempfile::TempDir {
let tmp = tempfile::tempdir().expect("tempdir");
write_memory(
tmp.path(),
"-Users-me-Code-projA",
"user-name",
"---\nname: user-name\ndescription: \"preferred name - quoted\"\nmetadata:\n type: user\n---\n\nThe user goes by Zed. See [[other-memory]].\n",
);
write_memory(
tmp.path(),
"-Users-me-Code-projA",
"no-frontmatter",
"Just a body.\n",
);
fs::write(
tmp.path()
.join("-Users-me-Code-projA")
.join("memory")
.join("MEMORY.md"),
"# Memory index\n\n- [User name](user-name.md)\n",
)
.unwrap();
fs::create_dir_all(tmp.path().join("-Users-me-Code-projB")).unwrap();
tmp
}
#[test]
fn list_projects_with_memory_omits_projects_without() {
let tmp = fixture_root();
let root = MemoryRoot::at(tmp.path());
let projects = root.list_projects_with_memory().expect("list");
assert_eq!(projects.len(), 1);
assert_eq!(projects[0].slug, "-Users-me-Code-projA");
assert_eq!(projects[0].entry_count, 2);
assert!(projects[0].has_index);
}
#[test]
fn list_projects_missing_root_returns_empty() {
let tmp = tempfile::tempdir().unwrap();
let root = MemoryRoot::at(tmp.path().join("does-not-exist"));
assert!(root.list_projects_with_memory().expect("ok").is_empty());
}
#[test]
fn list_excludes_index_and_parses_metadata() {
let tmp = fixture_root();
let root = MemoryRoot::at(tmp.path());
let memories = root.list("-Users-me-Code-projA").expect("list");
let stems: Vec<&str> = memories.iter().map(|m| m.file_stem.as_str()).collect();
assert_eq!(stems, ["no-frontmatter", "user-name"]);
let m = memories
.iter()
.find(|m| m.file_stem == "user-name")
.unwrap();
assert_eq!(m.name, "user-name");
assert_eq!(m.description.as_deref(), Some("preferred name - quoted"));
assert_eq!(m.memory_type.as_deref(), Some("user"));
assert!(m.size_bytes > 0);
}
#[test]
fn list_unknown_slug_returns_empty() {
let tmp = fixture_root();
let root = MemoryRoot::at(tmp.path());
assert!(root.list("nope").expect("ok").is_empty());
assert!(root.list("-Users-me-Code-projB").expect("ok").is_empty());
}
#[test]
fn get_returns_body_and_falls_back_to_stem() {
let tmp = fixture_root();
let root = MemoryRoot::at(tmp.path());
let m = root.get("-Users-me-Code-projA", "user-name").expect("get");
assert!(m.body.contains("[[other-memory]]"));
let nf = root
.get("-Users-me-Code-projA", "no-frontmatter")
.expect("get");
assert_eq!(nf.name, "no-frontmatter");
assert_eq!(nf.memory_type, None);
assert_eq!(nf.body, "Just a body.");
}
#[test]
fn get_unknown_stem_errors() {
let tmp = fixture_root();
let root = MemoryRoot::at(tmp.path());
let err = root.get("-Users-me-Code-projA", "nope").unwrap_err();
assert!(err.to_string().contains("no memory at"));
}
#[test]
fn index_reads_memory_md_or_none() {
let tmp = fixture_root();
let root = MemoryRoot::at(tmp.path());
let idx = root.index("-Users-me-Code-projA").expect("ok");
assert!(idx.expect("present").contains("# Memory index"));
assert!(root.index("-Users-me-Code-projB").expect("ok").is_none());
assert!(root.index("nope").expect("ok").is_none());
}
#[test]
fn unknown_frontmatter_keys_land_in_extra() {
let tmp = tempfile::tempdir().unwrap();
write_memory(
tmp.path(),
"-slug",
"weird",
"---\nname: weird\nmetadata:\n type: reference\n originSessionId: abc\ncustom: kept\n---\nbody\n",
);
let root = MemoryRoot::at(tmp.path());
let m = root.get("-slug", "weird").expect("get");
assert_eq!(m.memory_type.as_deref(), Some("reference"));
assert_eq!(
m.extra.get("originSessionId").map(String::as_str),
Some("abc")
);
assert_eq!(m.extra.get("custom").map(String::as_str), Some("kept"));
assert!(!m.extra.contains_key("metadata"));
}
#[test]
fn folded_description_with_colons_is_one_value() {
let tmp = tempfile::tempdir().unwrap();
write_memory(
tmp.path(),
"-slug",
"folded",
concat!(
"---\n",
"name: folded\n",
"description: >-\n",
" Restarting as its own repo: MCP server plus CLI over one router,\n",
" SQLite persistence.\n",
"metadata:\n",
" type: project\n",
"---\n\nBody.\n",
),
);
let root = MemoryRoot::at(tmp.path());
let m = root.get("-slug", "folded").expect("get");
assert_eq!(
m.description.as_deref(),
Some(
"Restarting as its own repo: MCP server plus CLI over one router, \
SQLite persistence."
)
);
assert_eq!(m.memory_type.as_deref(), Some("project"));
assert!(m.extra.is_empty(), "extra: {:?}", m.extra);
assert_eq!(m.body, "Body.");
}
}