//! Markdown file–backed `MemoryRepository` implementation. //! //! Each memory is stored as a `.md` file with YAML-ish frontmatter. //! Filenames are derived from the memory's `name` via slugification //! (see `Memory::slugify`). //! //! ## File Format //! ```text //! --- //! name: my-memory //! description: A useful lesson //! kind: lesson //! created_at: 1700000000 //! updated_at: 1700000000 //! lifecycle: active //! outcome: success //! scope: global //! before: old content //! after: new content //! provenances: tool1, tool2 //! --- //! Free-form markdown content body... //! ``` //! //! ## Data Flow //! - `list()`: scan `*.md` files (excluding `MEMORY.md`), return slugs //! - `load()`: read file → strip `---\n...\n---\n` frontmatter → parse fields //! - `save()`: build frontmatter → write to temp file → rename atomically //! - `delete()`: remove file from disk //! //! ## Atomicity //! Writes use temp-file + rename + parent-directory fsync for crash safety. use std::collections::HashMap; use std::io::Write; use std::path::Path; use crate::domain::error::RepositoryError; use crate::domain::memory::Memory; use crate::domain::repository::MemoryRepository; /// File-based `MemoryRepository` that stores memories as `.md` files with frontmatter. /// /// Each file has a YAML-ish `---\n...\n---\n` header followed by free-form /// markdown content. Filenames are derived from `Memory.name` via slugification. #[derive(Debug, Clone, Default)] pub struct MarkdownMemoryRepository; impl MarkdownMemoryRepository { /// Create a new repository instance. pub fn new() -> Self { Self } /// Build the YAML-ish frontmatter string for a memory. /// /// Only non-empty optional fields are included in the output. fn build_frontmatter(memory: &Memory) -> String { let outcome_line = memory .outcome .as_ref() .map(|o| format!("outcome: {o}\n")) .unwrap_or_default(); let scope_line = memory .scope .as_ref() .map(|s| format!("scope: {s}\n")) .unwrap_or_default(); let before_line = memory .before_snippet .as_ref() .map(|s| format!("before: {s}\n")) .unwrap_or_default(); let after_line = memory .after_snippet .as_ref() .map(|s| format!("after: {s}\n")) .unwrap_or_default(); let prov_line = if memory.provenances.is_empty() { String::new() } else { format!("provenances: {}\n", memory.provenances.join(", ")) }; format!( "name: {name}\ndescription: {desc}\nkind: {kind}\n\ created_at: {created}\nupdated_at: {updated}\nlifecycle: {lifecycle}\n\ {outcome}{scope}{before}{after}{prov}", name = memory.name, desc = memory.description, kind = memory.kind, created = memory.created_at, updated = memory.updated_at, lifecycle = memory.lifecycle, outcome = outcome_line, scope = scope_line, before = before_line, after = after_line, prov = prov_line, ) } /// Parse frontmatter lines into a `HashMap`. /// /// Flow: split lines → for each non-empty line, split on first ':' → insert. /// Malformed lines (no ':') are silently skipped. fn parse_frontmatter(front: &str) -> HashMap { front .lines() .filter_map(|l| { let mut it = l.splitn(2, ':'); Some((it.next()?.trim().to_string(), it.next()?.trim().to_string())) }) .collect() } /// Parse a memory file's full contents (frontmatter + body) into a `Memory`. /// /// Flow: strip `---\n` prefix → split on `\n---\n` → parse front half with /// `parse_frontmatter()` → use back half as content body → build Memory. /// /// Returns `InvalidData` error if the frontmatter delimiter is missing. fn parse(content: &str) -> std::io::Result { let content = content.strip_prefix("---\n").unwrap_or(content); let parts: Vec<&str> = content.splitn(2, "\n---\n").collect(); if parts.len() < 2 { return Err(std::io::Error::new( std::io::ErrorKind::InvalidData, "missing frontmatter", )); } let front = Self::parse_frontmatter(parts[0]); let body = parts.get(1).unwrap_or(&"").trim().to_string(); Ok(Memory { name: front.get("name").cloned().unwrap_or_default(), description: front.get("description").cloned().unwrap_or_default(), content: body, kind: front .get("kind") .cloned() .unwrap_or_else(|| "reference".to_string()), created_at: front .get("created_at") .and_then(|v| v.parse().ok()) .unwrap_or(0), updated_at: front .get("updated_at") .and_then(|v| v.parse().ok()) .unwrap_or(0), outcome: front.get("outcome").cloned().filter(|s| !s.is_empty()), lifecycle: front .get("lifecycle") .cloned() .unwrap_or_else(|| "new".to_string()), scope: front.get("scope").cloned().filter(|s| !s.is_empty()), before_snippet: front.get("before").cloned().filter(|s| !s.is_empty()), after_snippet: front.get("after").cloned().filter(|s| !s.is_empty()), provenances: front .get("provenances") .cloned() .map(|s| { s.split(", ") .map(std::string::ToString::to_string) .collect() }) .unwrap_or_default(), }) } } impl MemoryRepository for MarkdownMemoryRepository { /// List all memory slugs in `memory_dir` by scanning `*.md` files. /// /// Flow: read directory entries → filter `*.md` → strip extension → exclude MEMORY.md. /// Returns empty Vec if the directory doesn't exist. fn list(&self, memory_dir: &Path) -> Result, RepositoryError> { let Ok(entries) = std::fs::read_dir(memory_dir) else { return Ok(Vec::new()); }; let slugs: Vec = entries .filter_map(std::result::Result::ok) .filter(|e| e.path().extension().is_some_and(|x| x == "md")) .filter_map(|e| { let name = e.file_name().to_string_lossy().to_string(); // Skip special summary file if name == "MEMORY.md" { return None; } name.strip_suffix(".md") .map(std::string::ToString::to_string) }) .collect(); Ok(slugs) } /// Load a single `Memory` by name from `memory_dir`. /// /// Flow: resolve file path → read file → parse frontmatter + body → return Memory. fn load(&self, memory_dir: &Path, name: &str) -> Result { tracing::debug!("loading memory '{name}'"); let path = Memory::path(memory_dir, name); let content = std::fs::read_to_string(&path)?; let memory = Self::parse(&content) .map_err(|e| RepositoryError::Other(format!("failed to parse memory '{name}': {e}")))?; Ok(memory) } fn save(&self, memory_dir: &Path, memory: &Memory) -> Result<(), RepositoryError> { let path = Memory::path(memory_dir, &memory.name); let parent = path.parent().unwrap(); std::fs::create_dir_all(parent)?; let frontmatter = Self::build_frontmatter(memory); let content = format!("---\n{frontmatter}---\n\n{}", memory.content); let tmp = parent.join(format!(".{}.tmp", uuid::Uuid::new_v4())); { let mut f = std::fs::OpenOptions::new() .create(true) .truncate(true) .write(true) .open(&tmp)?; f.write_all(content.as_bytes())?; f.sync_all()?; } std::fs::rename(&tmp, &path)?; if let Some(p) = path.parent() { if let Ok(d) = std::fs::File::open(p) { let _ = d.sync_all(); } } tracing::debug!("memory saved to '{}'", path.display()); Ok(()) } fn delete(&self, memory_dir: &Path, name: &str) -> Result<(), RepositoryError> { let path = Memory::path(memory_dir, name); if path.exists() { std::fs::remove_file(&path)?; tracing::debug!("memory deleted: '{}'", path.display()); } else { tracing::warn!( "memory '{name}' not found at '{}', skipping delete", path.display() ); } Ok(()) } }