- Introduced `RepositoryError` and `ServiceError` enums in both IAM and CMS domains for better error management. - Updated domain traits and services to return specific error types instead of `anyhow::Result`. - Enhanced session and OAuth repository implementations to handle errors more explicitly. - Refactored session service methods to return `Result<T, ServiceError>` for improved error handling. - Updated HTTP handlers to utilize the new error types. - Modified password hashing functions to run in a blocking context using `tokio::task::spawn_blocking`. - Added tests for new error handling mechanisms and async password functions.
249 lines
8.9 KiB
Rust
249 lines
8.9 KiB
Rust
//! 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<String, String>`.
|
||
///
|
||
/// Flow: split lines → for each non-empty line, split on first ':' → insert.
|
||
/// Malformed lines (no ':') are silently skipped.
|
||
fn parse_frontmatter(front: &str) -> HashMap<String, String> {
|
||
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<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),
|
||
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<Vec<String>, RepositoryError> {
|
||
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();
|
||
// 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<Memory, RepositoryError> {
|
||
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(())
|
||
}
|
||
}
|