use anyhow::{anyhow, Context, Result};
use chrono::{DateTime, Utc};
use regex::Regex;
use serde::{Deserialize, Serialize};
use crate::{Memory, MemoryRow, MemoryType};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct FrontMatter {
pub name: String,
pub description: String,
pub metadata: FrontMatterMetadata,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct FrontMatterMetadata {
#[serde(rename = "type")]
pub kind: MemoryType,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
pub(crate) fn split_front_matter(raw: &str) -> Result<(&str, &str)> {
let rest = raw
.strip_prefix("---\n")
.or_else(|| raw.strip_prefix("---\r\n"))
.ok_or_else(|| anyhow!("memory file is missing opening `---` front-matter fence"))?;
let mut search_start = 0usize;
let close_rel = loop {
let slice = &rest[search_start..];
let idx = slice
.find("\n---")
.ok_or_else(|| anyhow!("memory file is missing closing `---` front-matter fence"))?;
let abs = search_start + idx;
let after = &rest[abs + 4..];
if after.is_empty() || after.starts_with('\n') || after.starts_with("\r\n") {
break abs;
}
search_start = abs + 1;
};
let yaml = &rest[..close_rel];
let after_fence = &rest[close_rel + 1..]; let after_fence = after_fence
.strip_prefix("---\n")
.or_else(|| after_fence.strip_prefix("---\r\n"))
.or_else(|| after_fence.strip_prefix("---"))
.unwrap_or(after_fence);
let body = after_fence
.strip_prefix("\n")
.or_else(|| after_fence.strip_prefix("\r\n"))
.unwrap_or(after_fence);
Ok((yaml, body))
}
pub(crate) fn parse_memory(raw: &str) -> Result<Memory> {
let (yaml, body) = split_front_matter(raw)?;
let fm: FrontMatter =
serde_yaml::from_str(yaml).context("failed to parse memory front-matter as YAML")?;
Ok(Memory {
name: fm.name,
description: fm.description,
kind: fm.metadata.kind,
body: body.to_string(),
created_at: fm.metadata.created_at,
updated_at: fm.metadata.updated_at,
})
}
pub(crate) fn render_memory(m: &Memory) -> Result<String> {
let fm = FrontMatter {
name: m.name.clone(),
description: m.description.clone(),
metadata: FrontMatterMetadata {
kind: m.kind.clone(),
created_at: m.created_at,
updated_at: m.updated_at,
},
};
let yaml = serde_yaml::to_string(&fm).context("failed to serialize memory front-matter")?;
let mut out = String::with_capacity(yaml.len() + m.body.len() + 16);
out.push_str("---\n");
out.push_str(&yaml);
out.push_str("---\n\n");
out.push_str(&m.body);
if !m.body.ends_with('\n') {
out.push('\n');
}
Ok(out)
}
pub(crate) fn index_row_regex() -> &'static Regex {
static RE: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
RE.get_or_init(|| {
Regex::new(r"^\s*-\s*\[(?P<title>[^\]]+)\]\((?P<file>[^)]+)\)\s*(?:—|--|-)\s*(?P<hook>.+?)\s*$")
.expect("index row regex compiles")
})
}
pub(crate) fn parse_index(text: &str) -> Vec<MemoryRow> {
let re = index_row_regex();
text.lines()
.filter_map(|line| {
let caps = re.captures(line)?;
let title = caps.name("title")?.as_str().trim().to_string();
let file = caps.name("file")?.as_str().trim().to_string();
let hook = caps.name("hook")?.as_str().trim().to_string();
let name = file
.strip_suffix(".md")
.map(|s| s.to_string())
.unwrap_or_else(|| file.clone());
Some(MemoryRow { name, title, hook, file })
})
.collect()
}
pub(crate) fn slug_regex() -> &'static Regex {
static RE: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
RE.get_or_init(|| Regex::new(r"^[a-z0-9][a-z0-9-]*$").expect("slug regex compiles"))
}
pub(crate) fn validate_slug(name: &str) -> Result<()> {
if slug_regex().is_match(name) {
Ok(())
} else {
Err(anyhow!(
"invalid memory slug `{}`: must match ^[a-z0-9][a-z0-9-]*$",
name
))
}
}
pub(crate) fn truncate_chars(s: &str, max: usize) -> String {
if s.chars().count() <= max {
return s.to_string();
}
s.chars().take(max).collect()
}
pub(crate) fn index_line_for(name: &str, title: &str, hook: &str) -> String {
let hook = truncate_chars(hook, 120);
format!("- [{}]({}.md) — {}", title, name, hook)
}
pub(crate) fn upsert_index_line(
index_text: &str,
name: &str,
title: &str,
hook: &str,
) -> String {
let re = index_row_regex();
let target_file = format!("{}.md", name);
let mut lines: Vec<String> = index_text.lines().map(|l| l.to_string()).collect();
let new_line = index_line_for(name, title, hook);
let mut replaced = false;
for line in lines.iter_mut() {
if let Some(caps) = re.captures(line) {
if caps.name("file").map(|m| m.as_str().trim()) == Some(target_file.as_str()) {
*line = new_line.clone();
replaced = true;
break;
}
}
}
if !replaced {
lines.push(new_line);
}
let mut out = lines.join("\n");
if !out.ends_with('\n') {
out.push('\n');
}
out
}
pub(crate) fn remove_index_line(index_text: &str, name: &str) -> String {
let re = index_row_regex();
let target_file = format!("{}.md", name);
let kept: Vec<&str> = index_text
.lines()
.filter(|line| {
if let Some(caps) = re.captures(line) {
if caps.name("file").map(|m| m.as_str().trim()) == Some(target_file.as_str()) {
return false;
}
}
true
})
.collect();
let mut out = kept.join("\n");
if !out.ends_with('\n') {
out.push('\n');
}
out
}