2026-07-20 09:04:57 +07:00
|
|
|
|
//! Markdown file–backed `MemoryRepository`.
|
2026-07-16 12:32:17 +07:00
|
|
|
|
//! Each memory is stored as a `.md` file with YAML-ish frontmatter.
|
|
|
|
|
|
|
|
|
|
|
|
use std::collections::HashMap;
|
|
|
|
|
|
use std::io::Write;
|
|
|
|
|
|
use std::path::Path;
|
|
|
|
|
|
|
2026-07-20 09:04:57 +07:00
|
|
|
|
use zesdex_domain::cms::{Memory, MemoryRepository, RepositoryError};
|
2026-07-16 12:32:17 +07:00
|
|
|
|
|
2026-07-20 09:04:57 +07:00
|
|
|
|
/// File-based `MemoryRepository` that stores memories as `.md` files with
|
|
|
|
|
|
/// YAML-ish frontmatter.
|
2026-07-16 12:32:17 +07:00
|
|
|
|
#[derive(Debug, Clone, Default)]
|
|
|
|
|
|
pub struct MarkdownMemoryRepository;
|
|
|
|
|
|
|
|
|
|
|
|
impl MarkdownMemoryRepository {
|
|
|
|
|
|
pub fn new() -> Self {
|
|
|
|
|
|
Self
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-20 12:26:08 +07:00
|
|
|
|
/// 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")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-16 12:32:17 +07:00
|
|
|
|
fn build_frontmatter(memory: &Memory) -> String {
|
|
|
|
|
|
let outcome_line = memory
|
|
|
|
|
|
.outcome
|
|
|
|
|
|
.as_ref()
|
2026-07-20 12:26:08 +07:00
|
|
|
|
.map(|o| format!("outcome: {}\n", Self::escape_newlines(o)))
|
2026-07-16 12:32:17 +07:00
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
|
let scope_line = memory
|
|
|
|
|
|
.scope
|
|
|
|
|
|
.as_ref()
|
2026-07-20 12:26:08 +07:00
|
|
|
|
.map(|s| format!("scope: {}\n", Self::escape_newlines(s)))
|
2026-07-16 12:32:17 +07:00
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
|
let before_line = memory
|
|
|
|
|
|
.before_snippet
|
|
|
|
|
|
.as_ref()
|
2026-07-20 12:26:08 +07:00
|
|
|
|
.map(|s| format!("before: {}\n", Self::escape_newlines(s)))
|
2026-07-16 12:32:17 +07:00
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
|
let after_line = memory
|
|
|
|
|
|
.after_snippet
|
|
|
|
|
|
.as_ref()
|
2026-07-20 12:26:08 +07:00
|
|
|
|
.map(|s| format!("after: {}\n", Self::escape_newlines(s)))
|
2026-07-16 12:32:17 +07:00
|
|
|
|
.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<String, String> {
|
|
|
|
|
|
front
|
|
|
|
|
|
.lines()
|
|
|
|
|
|
.filter_map(|l| {
|
|
|
|
|
|
let mut it = l.splitn(2, ':');
|
2026-07-20 09:04:57 +07:00
|
|
|
|
Some((
|
|
|
|
|
|
it.next()?.trim().to_string(),
|
|
|
|
|
|
it.next()?.trim().to_string(),
|
|
|
|
|
|
))
|
2026-07-16 12:32:17 +07:00
|
|
|
|
})
|
|
|
|
|
|
.collect()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn parse(content: &str) -> std::io::Result<Memory> {
|
|
|
|
|
|
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),
|
2026-07-20 12:26:08 +07:00
|
|
|
|
outcome: front
|
|
|
|
|
|
.get("outcome")
|
|
|
|
|
|
.cloned()
|
|
|
|
|
|
.filter(|s| !s.is_empty())
|
|
|
|
|
|
.map(|s| Self::unescape_newlines(&s)),
|
2026-07-16 12:32:17 +07:00
|
|
|
|
lifecycle: front
|
|
|
|
|
|
.get("lifecycle")
|
|
|
|
|
|
.cloned()
|
|
|
|
|
|
.unwrap_or_else(|| "new".to_string()),
|
2026-07-20 12:26:08 +07:00
|
|
|
|
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)),
|
2026-07-16 12:32:17 +07:00
|
|
|
|
provenances: front
|
|
|
|
|
|
.get("provenances")
|
|
|
|
|
|
.cloned()
|
2026-07-20 09:04:57 +07:00
|
|
|
|
.map(|s| s.split(", ").map(String::from).collect())
|
2026-07-16 12:32:17 +07:00
|
|
|
|
.unwrap_or_default(),
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl MemoryRepository for MarkdownMemoryRepository {
|
2026-07-20 06:14:23 +07:00
|
|
|
|
fn list(&self, memory_dir: &Path) -> Result<Vec<String>, RepositoryError> {
|
2026-07-16 12:32:17 +07:00
|
|
|
|
let Ok(entries) = std::fs::read_dir(memory_dir) else {
|
|
|
|
|
|
return Ok(Vec::new());
|
|
|
|
|
|
};
|
|
|
|
|
|
let slugs: Vec<String> = 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;
|
|
|
|
|
|
}
|
2026-07-17 06:44:31 +07:00
|
|
|
|
name.strip_suffix(".md")
|
|
|
|
|
|
.map(std::string::ToString::to_string)
|
2026-07-16 12:32:17 +07:00
|
|
|
|
})
|
|
|
|
|
|
.collect();
|
|
|
|
|
|
Ok(slugs)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-20 06:14:23 +07:00
|
|
|
|
fn load(&self, memory_dir: &Path, name: &str) -> Result<Memory, RepositoryError> {
|
2026-07-16 12:32:17 +07:00
|
|
|
|
let path = Memory::path(memory_dir, name);
|
2026-07-20 06:14:23 +07:00
|
|
|
|
let content = std::fs::read_to_string(&path)?;
|
2026-07-16 12:32:17 +07:00
|
|
|
|
let memory = Self::parse(&content)
|
2026-07-20 06:14:23 +07:00
|
|
|
|
.map_err(|e| RepositoryError::Other(format!("failed to parse memory '{name}': {e}")))?;
|
2026-07-16 12:32:17 +07:00
|
|
|
|
Ok(memory)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-20 06:14:23 +07:00
|
|
|
|
fn save(&self, memory_dir: &Path, memory: &Memory) -> Result<(), RepositoryError> {
|
2026-07-16 12:32:17 +07:00
|
|
|
|
let path = Memory::path(memory_dir, &memory.name);
|
|
|
|
|
|
let parent = path.parent().unwrap();
|
2026-07-20 06:14:23 +07:00
|
|
|
|
std::fs::create_dir_all(parent)?;
|
2026-07-16 12:32:17 +07:00
|
|
|
|
|
|
|
|
|
|
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)
|
2026-07-20 06:14:23 +07:00
|
|
|
|
.open(&tmp)?;
|
2026-07-16 12:32:17 +07:00
|
|
|
|
f.write_all(content.as_bytes())?;
|
|
|
|
|
|
f.sync_all()?;
|
|
|
|
|
|
}
|
2026-07-20 06:14:23 +07:00
|
|
|
|
std::fs::rename(&tmp, &path)?;
|
2026-07-16 12:32:17 +07:00
|
|
|
|
if let Some(p) = path.parent() {
|
|
|
|
|
|
if let Ok(d) = std::fs::File::open(p) {
|
|
|
|
|
|
let _ = d.sync_all();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
Ok(())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-20 06:14:23 +07:00
|
|
|
|
fn delete(&self, memory_dir: &Path, name: &str) -> Result<(), RepositoryError> {
|
2026-07-16 12:32:17 +07:00
|
|
|
|
let path = Memory::path(memory_dir, name);
|
|
|
|
|
|
if path.exists() {
|
2026-07-20 06:14:23 +07:00
|
|
|
|
std::fs::remove_file(&path)?;
|
2026-07-16 12:32:17 +07:00
|
|
|
|
}
|
|
|
|
|
|
Ok(())
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|