//! Markdown file–backed `MemoryRepository`. //! Each memory is stored as a `.md` file with YAML-ish frontmatter. use std::collections::HashMap; use std::io::Write; use std::path::Path; use zesdex_domain::cms::{Memory, MemoryRepository, RepositoryError}; /// File-based `MemoryRepository` that stores memories as `.md` files with /// YAML-ish frontmatter. #[derive(Debug, Clone, Default)] pub struct MarkdownMemoryRepository; impl MarkdownMemoryRepository { pub fn new() -> Self { Self } /// Escape newlines in field values so they do not break the /// line-oriented frontmatter parser. fn escape_newlines(s: &str) -> String { s.replace('\n', "\\n") } /// Unescape `\n` back to actual newlines after frontmatter parsing. fn unescape_newlines(s: &str) -> String { s.replace("\\n", "\n") } fn build_frontmatter(memory: &Memory) -> String { let outcome_line = memory .outcome .as_ref() .map(|o| format!("outcome: {}\n", Self::escape_newlines(o))) .unwrap_or_default(); let scope_line = memory .scope .as_ref() .map(|s| format!("scope: {}\n", Self::escape_newlines(s))) .unwrap_or_default(); let before_line = memory .before_snippet .as_ref() .map(|s| format!("before: {}\n", Self::escape_newlines(s))) .unwrap_or_default(); let after_line = memory .after_snippet .as_ref() .map(|s| format!("after: {}\n", Self::escape_newlines(s))) .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, ) } 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() } 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()) .map(|s| Self::unescape_newlines(&s)), lifecycle: front .get("lifecycle") .cloned() .unwrap_or_else(|| "new".to_string()), scope: front .get("scope") .cloned() .filter(|s| !s.is_empty()) .map(|s| Self::unescape_newlines(&s)), before_snippet: front .get("before") .cloned() .filter(|s| !s.is_empty()) .map(|s| Self::unescape_newlines(&s)), after_snippet: front .get("after") .cloned() .filter(|s| !s.is_empty()) .map(|s| Self::unescape_newlines(&s)), provenances: front .get("provenances") .cloned() .map(|s| s.split(", ").map(String::from).collect()) .unwrap_or_default(), }) } } impl MemoryRepository for MarkdownMemoryRepository { 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(); 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)?; 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(); } } 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)?; } Ok(()) } }