//! Markdown file–backed `MemoryRepository`. //! //! Each memory is stored as a `.md` file with YAML-ish frontmatter. //! Filenames are derived from the memory's `name` via slugification. //! //! Frontmatter fields parsed from `---\n...\n---\n` header: //! name, description, kind, created_at, updated_at, lifecycle, //! outcome, scope, before, after, provenances #![allow( clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap )] use std::collections::HashMap; use std::io::Write; use std::path::Path; use anyhow::{Context, Result}; use crate::domain::memory::Memory; use crate::domain::repository::MemoryRepository; /// Persists `Memory` as markdown files with YAML-ish frontmatter. #[derive(Debug, Clone, Default)] pub struct MarkdownMemoryRepository; impl MarkdownMemoryRepository { /// Create a new repository instance. pub fn new() -> Self { Self } /// Build the frontmatter lines for a memory. 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`. 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 contents (frontmatter + body) into a `Memory`. 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 { fn list(&self, memory_dir: &Path) -> Result> { 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) } fn load(&self, memory_dir: &Path, name: &str) -> Result { let path = Memory::path(memory_dir, name); let content = std::fs::read_to_string(&path) .with_context(|| format!("failed to read memory '{name}' at '{}'", path.display()))?; let memory = Self::parse(&content) .map_err(|e| anyhow::anyhow!("failed to parse memory '{name}': {e}"))?; Ok(memory) } fn save(&self, memory_dir: &Path, memory: &Memory) -> Result<()> { let path = Memory::path(memory_dir, &memory.name); let parent = path.parent().unwrap(); std::fs::create_dir_all(parent) .with_context(|| format!("failed to create memory dir '{}'", parent.display()))?; 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) .with_context(|| format!("failed to write temp file '{}'", tmp.display()))?; f.write_all(content.as_bytes())?; f.sync_all()?; } std::fs::rename(&tmp, &path).with_context(|| { format!( "failed to rename '{}' -> '{}'", tmp.display(), path.display() ) })?; 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<()> { let path = Memory::path(memory_dir, name); if path.exists() { std::fs::remove_file(&path).with_context(|| { format!("failed to delete memory '{name}' at '{}'", path.display()) })?; tracing::debug!("memory deleted: '{}'", path.display()); } else { tracing::warn!( "memory '{name}' not found at '{}', skipping delete", path.display() ); } Ok(()) } }