use std::path::{Path, PathBuf};
use serde::Deserialize;
use crate::error::{MemoryError, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TopicKind {
User,
Feedback,
Project,
Reference,
}
impl TopicKind {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::User => "user",
Self::Feedback => "feedback",
Self::Project => "project",
Self::Reference => "reference",
}
}
#[must_use]
pub fn parse(s: &str) -> Option<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"user" => Some(Self::User),
"feedback" => Some(Self::Feedback),
"project" => Some(Self::Project),
"reference" => Some(Self::Reference),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TopicSummary {
pub name: String,
pub description: String,
pub kind: TopicKind,
pub path: PathBuf,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TopicFile {
pub name: String,
pub description: String,
pub kind: TopicKind,
pub body: String,
pub path: PathBuf,
}
#[derive(Debug, Clone)]
pub struct TopicDraft {
pub name: String,
pub description: String,
pub kind: TopicKind,
pub body: String,
}
#[derive(Debug, Deserialize)]
struct RawFrontmatter {
name: String,
description: String,
#[serde(default)]
metadata: RawMetadata,
}
#[derive(Debug, Default, Deserialize)]
struct RawMetadata {
#[serde(rename = "type")]
kind: Option<String>,
}
#[derive(Debug, Clone)]
pub struct TopicLoader {
dir: PathBuf,
}
impl TopicLoader {
#[must_use]
pub fn new(dir: impl Into<PathBuf>) -> Self {
Self { dir: dir.into() }
}
#[must_use]
pub fn dir(&self) -> &Path {
&self.dir
}
pub fn list(&self) -> Result<Vec<TopicSummary>> {
let mut out = Vec::new();
if !self.dir.exists() {
return Ok(out);
}
let entries = std::fs::read_dir(&self.dir).map_err(|source| MemoryError::Io {
path: self.dir.clone(),
source,
})?;
for entry in entries.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
if path.extension().and_then(|s| s.to_str()) != Some("md") {
continue;
}
if path.file_name().and_then(|s| s.to_str()) == Some("MEMORY.md") {
continue;
}
match Self::read_summary(&path) {
Ok(mut summary) => {
if summary.name != stem {
tracing::warn!(
target: caliban_common::tracing_targets::TARGET_MEMORY_AUTO,
path = %path.display(),
frontmatter_name = %summary.name,
file_stem = %stem,
"topic frontmatter name does not match filename; using filename",
);
summary.name = stem.to_string();
}
out.push(summary);
}
Err(e) => {
tracing::warn!(
target: caliban_common::tracing_targets::TARGET_MEMORY_AUTO,
path = %path.display(),
error = %e,
"skipping malformed topic file",
);
}
}
}
out.sort_by(|a, b| a.name.cmp(&b.name));
Ok(out)
}
pub fn read(&self, name: &str) -> Result<TopicFile> {
validate_slug(name)?;
let path = self.dir.join(format!("{name}.md"));
let raw = std::fs::read_to_string(&path).map_err(|source| MemoryError::Io {
path: path.clone(),
source,
})?;
let (fm, body) = parse_frontmatter(&raw, &path)?;
let kind =
TopicKind::parse(fm.metadata.kind.as_deref().unwrap_or("")).ok_or_else(|| {
MemoryError::InvalidTopic {
path: path.clone(),
reason: format!(
"metadata.type must be one of user|feedback|project|reference (got {:?})",
fm.metadata.kind
),
}
})?;
Ok(TopicFile {
name: fm.name,
description: fm.description,
kind,
body: body.to_string(),
path,
})
}
pub fn write(&self, draft: &TopicDraft) -> Result<PathBuf> {
validate_slug(&draft.name)?;
std::fs::create_dir_all(&self.dir).map_err(|source| MemoryError::Io {
path: self.dir.clone(),
source,
})?;
let path = self.dir.join(format!("{}.md", draft.name));
let serialized = render_topic_file(draft);
caliban_common::fs::write_atomic(&path, serialized.as_bytes()).map_err(|source| {
MemoryError::Io {
path: path.clone(),
source,
}
})?;
update_index_line(&self.dir, draft)?;
Ok(path)
}
pub fn delete(&self, name: &str) -> Result<()> {
validate_slug(name)?;
let path = self.dir.join(format!("{name}.md"));
match std::fs::remove_file(&path) {
Ok(()) | Err(_) if !path.exists() => {}
Err(e) => {
return Err(MemoryError::Io {
path: path.clone(),
source: e,
});
}
Ok(()) => {}
}
remove_index_line(&self.dir, name)?;
Ok(())
}
fn read_summary(path: &Path) -> Result<TopicSummary> {
let raw = std::fs::read_to_string(path).map_err(|source| MemoryError::Io {
path: path.to_path_buf(),
source,
})?;
let (fm, _) = parse_frontmatter(&raw, path)?;
let kind =
TopicKind::parse(fm.metadata.kind.as_deref().unwrap_or("")).ok_or_else(|| {
MemoryError::InvalidTopic {
path: path.to_path_buf(),
reason: format!(
"metadata.type must be one of user|feedback|project|reference (got {:?})",
fm.metadata.kind
),
}
})?;
Ok(TopicSummary {
name: fm.name,
description: fm.description,
kind,
path: path.to_path_buf(),
})
}
}
pub fn validate_slug(slug: &str) -> Result<()> {
if slug.is_empty() {
return Err(MemoryError::InvalidSlug {
slug: slug.to_string(),
reason: "slug must be non-empty".into(),
});
}
if slug.contains('/') || slug.contains('\\') {
return Err(MemoryError::InvalidSlug {
slug: slug.to_string(),
reason: "slug must not contain path separators".into(),
});
}
if slug.contains("..") {
return Err(MemoryError::InvalidSlug {
slug: slug.to_string(),
reason: "slug must not contain '..'".into(),
});
}
if slug.starts_with('.') {
return Err(MemoryError::InvalidSlug {
slug: slug.to_string(),
reason: "slug must not start with '.'".into(),
});
}
if slug.contains('\0') {
return Err(MemoryError::InvalidSlug {
slug: slug.to_string(),
reason: "slug must not contain NUL".into(),
});
}
Ok(())
}
fn parse_frontmatter<'a>(raw: &'a str, path: &Path) -> Result<(RawFrontmatter, &'a str)> {
let trimmed = raw.trim_start_matches('\u{feff}');
let body_start = "---\n";
if !trimmed.starts_with(body_start) {
return Err(MemoryError::InvalidTopic {
path: path.to_path_buf(),
reason: "missing leading `---` frontmatter delimiter".into(),
});
}
let after_start = &trimmed[body_start.len()..];
let Some(end_idx) = after_start.find("\n---\n").or_else(|| {
after_start
.find("\n---")
.filter(|i| after_start[*i..].starts_with("\n---"))
}) else {
return Err(MemoryError::InvalidTopic {
path: path.to_path_buf(),
reason: "missing closing `---` frontmatter delimiter".into(),
});
};
let yaml_chunk = &after_start[..end_idx];
let body_start_offset = end_idx + "\n---\n".len();
let body = if body_start_offset >= after_start.len() {
""
} else {
&after_start[body_start_offset..]
};
let fm: RawFrontmatter =
serde_yaml::from_str(yaml_chunk).map_err(|e| MemoryError::InvalidTopic {
path: path.to_path_buf(),
reason: format!("yaml: {e}"),
})?;
if fm.name.trim().is_empty() {
return Err(MemoryError::InvalidTopic {
path: path.to_path_buf(),
reason: "name must be non-empty".into(),
});
}
if fm.description.trim().is_empty() {
return Err(MemoryError::InvalidTopic {
path: path.to_path_buf(),
reason: "description must be non-empty".into(),
});
}
Ok((fm, body))
}
fn render_topic_file(draft: &TopicDraft) -> String {
let mut out = String::with_capacity(draft.body.len() + 256);
out.push_str("---\n");
out.push_str("name: ");
out.push_str(&draft.name);
out.push('\n');
out.push_str("description: \"");
out.push_str(&escape_yaml_string(&draft.description));
out.push_str("\"\n");
out.push_str("metadata:\n");
out.push_str(" node_type: memory\n");
out.push_str(" type: ");
out.push_str(draft.kind.as_str());
out.push('\n');
out.push_str("---\n\n");
out.push_str(&draft.body);
if !draft.body.ends_with('\n') {
out.push('\n');
}
out
}
fn escape_yaml_string(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
c => out.push(c),
}
}
out
}
fn update_index_line(dir: &Path, draft: &TopicDraft) -> Result<()> {
let index_path = dir.join("MEMORY.md");
let existing = match std::fs::read_to_string(&index_path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(source) => {
return Err(MemoryError::Io {
path: index_path.clone(),
source,
});
}
};
let new_line = format!(
"- [{title}]({slug}.md) — {kind}: {desc}",
title = draft.name,
slug = draft.name,
kind = draft.kind.as_str(),
desc = draft.description.lines().next().unwrap_or("").trim(),
);
let new_body = rewrite_with_index_line(&existing, &draft.name, &new_line);
caliban_common::fs::write_atomic(&index_path, new_body.as_bytes()).map_err(|source| {
MemoryError::Io {
path: index_path.clone(),
source,
}
})?;
Ok(())
}
fn remove_index_line(dir: &Path, slug: &str) -> Result<()> {
let index_path = dir.join("MEMORY.md");
let existing = match std::fs::read_to_string(&index_path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(source) => {
return Err(MemoryError::Io {
path: index_path.clone(),
source,
});
}
};
let needle = format!("]({slug}.md)");
let kept: Vec<&str> = existing.lines().filter(|l| !l.contains(&needle)).collect();
let mut new_body = kept.join("\n");
if existing.ends_with('\n') && !new_body.ends_with('\n') {
new_body.push('\n');
}
caliban_common::fs::write_atomic(&index_path, new_body.as_bytes()).map_err(|source| {
MemoryError::Io {
path: index_path.clone(),
source,
}
})?;
Ok(())
}
fn rewrite_with_index_line(existing: &str, slug: &str, new_line: &str) -> String {
if existing.is_empty() {
let mut s = String::from("# Memory index\n\n");
s.push_str(new_line);
s.push('\n');
return s;
}
let needle = format!("]({slug}.md)");
let mut replaced = false;
let mut out_lines: Vec<String> = Vec::with_capacity(existing.lines().count() + 1);
for line in existing.lines() {
if !replaced && line.contains(&needle) {
out_lines.push(new_line.to_string());
replaced = true;
} else {
out_lines.push(line.to_string());
}
}
if !replaced {
let mut insert_idx = out_lines.len();
for (i, line) in out_lines.iter().enumerate().rev() {
if line.trim_start().starts_with("- [") {
insert_idx = i + 1;
break;
}
}
out_lines.insert(insert_idx, new_line.to_string());
}
let mut s = out_lines.join("\n");
if existing.ends_with('\n') || !s.ends_with('\n') {
s.push('\n');
}
s
}
#[must_use]
pub fn strip_html_comments(body: &str) -> String {
let mut out = String::with_capacity(body.len());
let bytes = body.as_bytes();
let mut i = 0;
while i < bytes.len() {
if i + 3 < bytes.len() && &bytes[i..i + 4] == b"<!--" {
if let Some(end) = find_subslice(&bytes[i + 4..], b"-->") {
i += 4 + end + 3;
continue;
}
break;
}
out.push(bytes[i] as char);
i += 1;
}
out
}
fn find_subslice(hay: &[u8], needle: &[u8]) -> Option<usize> {
if needle.is_empty() || needle.len() > hay.len() {
return None;
}
for i in 0..=hay.len() - needle.len() {
if &hay[i..i + needle.len()] == needle {
return Some(i);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn topic_md(name: &str, kind: &str, desc: &str, body: &str) -> String {
format!(
"---\nname: {name}\ndescription: \"{desc}\"\nmetadata:\n node_type: memory\n type: {kind}\n---\n\n{body}\n",
)
}
#[test]
fn list_enumerates_topic_files_excluding_memory_md() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
std::fs::write(
dir.join("MEMORY.md"),
"# Memory index\n\n- [foo](foo.md) — user: foo\n",
)
.unwrap();
std::fs::write(
dir.join("foo.md"),
topic_md("foo", "user", "foo desc", "body"),
)
.unwrap();
std::fs::write(
dir.join("bar.md"),
topic_md("bar", "feedback", "bar desc", "body"),
)
.unwrap();
let loader = TopicLoader::new(dir.to_path_buf());
let topics = loader.list().unwrap();
let names: Vec<_> = topics.iter().map(|t| t.name.as_str()).collect();
assert_eq!(names, vec!["bar", "foo"]);
assert!(topics.iter().any(|t| matches!(t.kind, TopicKind::User)));
assert!(topics.iter().any(|t| matches!(t.kind, TopicKind::Feedback)));
}
#[test]
fn read_round_trips_a_topic() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
std::fs::write(
dir.join("user-role.md"),
topic_md(
"user-role",
"user",
"role + context",
"# User role\n\nSenior engineer.\n",
),
)
.unwrap();
let loader = TopicLoader::new(dir.to_path_buf());
let topic = loader.read("user-role").unwrap();
assert_eq!(topic.name, "user-role");
assert_eq!(topic.kind, TopicKind::User);
assert!(topic.body.contains("Senior engineer."));
}
#[test]
fn write_creates_topic_and_updates_index() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
std::fs::write(dir.join("MEMORY.md"), "# Memory index\n\n").unwrap();
let loader = TopicLoader::new(dir.to_path_buf());
let path = loader
.write(&TopicDraft {
name: "personal-email".to_string(),
description: "use personal email for ~/dev/personal/**".to_string(),
kind: TopicKind::Feedback,
body: "Use john.ford2002@gmail.com.\n".to_string(),
})
.unwrap();
assert!(path.exists());
assert!(!dir.join("personal-email.md.tmp").exists());
let written = std::fs::read_to_string(&path).unwrap();
assert!(written.contains("name: personal-email"));
assert!(written.contains("type: feedback"));
assert!(written.contains("john.ford2002@gmail.com"));
let index = std::fs::read_to_string(dir.join("MEMORY.md")).unwrap();
assert!(index.contains("[personal-email](personal-email.md)"));
assert!(index.contains("feedback:"));
}
#[test]
fn write_updates_existing_index_line_in_place() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
std::fs::write(
dir.join("MEMORY.md"),
"# Memory index\n\n- [foo](foo.md) — user: old desc\n",
)
.unwrap();
let loader = TopicLoader::new(dir.to_path_buf());
loader
.write(&TopicDraft {
name: "foo".to_string(),
description: "new desc".to_string(),
kind: TopicKind::User,
body: "body".to_string(),
})
.unwrap();
let index = std::fs::read_to_string(dir.join("MEMORY.md")).unwrap();
assert_eq!(index.matches("[foo](foo.md)").count(), 1);
assert!(index.contains("new desc"));
assert!(!index.contains("old desc"));
}
#[test]
fn read_rejects_invalid_type_in_frontmatter() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
std::fs::write(dir.join("bad.md"), topic_md("bad", "junk", "desc", "body")).unwrap();
let loader = TopicLoader::new(dir.to_path_buf());
let err = loader.read("bad").unwrap_err();
assert!(matches!(err, MemoryError::InvalidTopic { .. }));
}
#[test]
fn read_rejects_missing_required_frontmatter_fields() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
std::fs::write(
dir.join("incomplete.md"),
"---\ndescription: \"no name\"\nmetadata:\n type: user\n---\n\nbody\n",
)
.unwrap();
let loader = TopicLoader::new(dir.to_path_buf());
let err = loader.read("incomplete").unwrap_err();
assert!(matches!(err, MemoryError::InvalidTopic { .. }));
}
#[test]
fn cross_reference_brackets_preserved_in_body() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
let body = "Crosslinks: [[parity-gap-matrix]], [[sprint-mode]].\n".to_string();
let loader = TopicLoader::new(dir.to_path_buf());
loader
.write(&TopicDraft {
name: "user-role".to_string(),
description: "role".to_string(),
kind: TopicKind::User,
body: body.clone(),
})
.unwrap();
let topic = loader.read("user-role").unwrap();
assert!(topic.body.contains("[[parity-gap-matrix]]"));
assert!(topic.body.contains("[[sprint-mode]]"));
}
#[test]
fn validate_slug_rejects_path_traversal() {
assert!(validate_slug("ok").is_ok());
assert!(validate_slug("ok-slug_1").is_ok());
assert!(validate_slug("").is_err());
assert!(validate_slug("a/b").is_err());
assert!(validate_slug("a\\b").is_err());
assert!(validate_slug("..").is_err());
assert!(validate_slug("a..b").is_err());
assert!(validate_slug(".hidden").is_err());
}
#[test]
fn strip_html_comments_handles_single_and_multiline() {
let single = "hello <!-- inline --> world";
assert_eq!(strip_html_comments(single), "hello world");
let multi = "before\n<!-- line one\nline two\n-->\nafter";
let stripped = strip_html_comments(multi);
assert!(stripped.contains("before"));
assert!(stripped.contains("after"));
assert!(!stripped.contains("line one"));
assert!(!stripped.contains("line two"));
}
#[test]
fn delete_removes_file_and_index_line() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
let loader = TopicLoader::new(dir.to_path_buf());
loader
.write(&TopicDraft {
name: "tmp-topic".to_string(),
description: "tmp".to_string(),
kind: TopicKind::Project,
body: "body".to_string(),
})
.unwrap();
loader.delete("tmp-topic").unwrap();
assert!(!dir.join("tmp-topic.md").exists());
let index = std::fs::read_to_string(dir.join("MEMORY.md")).unwrap();
assert!(!index.contains("tmp-topic.md"));
}
#[test]
fn topic_kind_as_str_covers_all_variants() {
assert_eq!(TopicKind::User.as_str(), "user");
assert_eq!(TopicKind::Feedback.as_str(), "feedback");
assert_eq!(TopicKind::Project.as_str(), "project");
assert_eq!(TopicKind::Reference.as_str(), "reference");
}
#[test]
fn topic_kind_parse_is_case_and_whitespace_insensitive() {
assert_eq!(TopicKind::parse("USER"), Some(TopicKind::User));
assert_eq!(TopicKind::parse(" Feedback "), Some(TopicKind::Feedback));
assert_eq!(TopicKind::parse("Project"), Some(TopicKind::Project));
assert_eq!(TopicKind::parse("rEfErEnCe"), Some(TopicKind::Reference));
}
#[test]
fn topic_kind_parse_rejects_unknown_and_empty() {
assert_eq!(TopicKind::parse(""), None);
assert_eq!(TopicKind::parse(" "), None);
assert_eq!(TopicKind::parse("junk"), None);
}
#[test]
fn loader_dir_returns_managed_directory() {
let tmp = TempDir::new().unwrap();
let loader = TopicLoader::new(tmp.path().to_path_buf());
assert_eq!(loader.dir(), tmp.path());
}
#[test]
fn list_on_nonexistent_dir_returns_empty() {
let tmp = TempDir::new().unwrap();
let missing = tmp.path().join("does-not-exist");
let loader = TopicLoader::new(missing);
assert!(loader.list().unwrap().is_empty());
}
#[test]
fn list_skips_non_md_files_and_subdirectories() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
std::fs::write(dir.join("notes.txt"), "not markdown").unwrap();
std::fs::create_dir(dir.join("subdir")).unwrap();
std::fs::create_dir(dir.join("dir.md")).unwrap();
std::fs::write(
dir.join("ok.md"),
topic_md("ok", "project", "ok desc", "body"),
)
.unwrap();
let loader = TopicLoader::new(dir.to_path_buf());
let topics = loader.list().unwrap();
let names: Vec<_> = topics.iter().map(|t| t.name.as_str()).collect();
assert_eq!(names, vec!["ok"]);
}
#[test]
fn list_skips_malformed_topic_file() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
std::fs::write(dir.join("broken.md"), "no frontmatter here\n").unwrap();
std::fs::write(
dir.join("good.md"),
topic_md("good", "reference", "good desc", "body"),
)
.unwrap();
let loader = TopicLoader::new(dir.to_path_buf());
let topics = loader.list().unwrap();
let names: Vec<_> = topics.iter().map(|t| t.name.as_str()).collect();
assert_eq!(names, vec!["good"]);
assert_eq!(topics[0].kind, TopicKind::Reference);
}
#[test]
fn list_uses_filename_when_frontmatter_name_mismatches() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
std::fs::write(
dir.join("actual-stem.md"),
topic_md("wrong", "user", "desc", "body"),
)
.unwrap();
let loader = TopicLoader::new(dir.to_path_buf());
let topics = loader.list().unwrap();
assert_eq!(topics.len(), 1);
assert_eq!(topics[0].name, "actual-stem");
assert_eq!(topics[0].description, "desc");
}
#[test]
fn read_rejects_invalid_slug() {
let tmp = TempDir::new().unwrap();
let loader = TopicLoader::new(tmp.path().to_path_buf());
let err = loader.read("../escape").unwrap_err();
assert!(matches!(err, MemoryError::InvalidSlug { .. }));
}
#[test]
fn read_missing_file_is_io_error() {
let tmp = TempDir::new().unwrap();
let loader = TopicLoader::new(tmp.path().to_path_buf());
let err = loader.read("nope").unwrap_err();
assert!(matches!(err, MemoryError::Io { .. }));
}
#[test]
fn parse_frontmatter_strips_bom() {
let raw = format!(
"\u{feff}{}",
topic_md("bom", "user", "with bom", "body line")
);
let path = Path::new("bom.md");
let (fm, body) = parse_frontmatter(&raw, path).unwrap();
assert_eq!(fm.name, "bom");
assert!(body.contains("body line"));
}
#[test]
fn parse_frontmatter_rejects_missing_leading_delimiter() {
let raw = "name: x\ndescription: y\n---\nbody\n";
let err = parse_frontmatter(raw, Path::new("x.md")).unwrap_err();
match err {
MemoryError::InvalidTopic { reason, .. } => assert!(reason.contains("leading")),
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn parse_frontmatter_rejects_missing_closing_delimiter() {
let raw = "---\nname: x\ndescription: y\nno closing here\n";
let err = parse_frontmatter(raw, Path::new("x.md")).unwrap_err();
match err {
MemoryError::InvalidTopic { reason, .. } => assert!(reason.contains("closing")),
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn parse_frontmatter_accepts_closing_delimiter_at_eof_without_body() {
let raw = "---\nname: eof\ndescription: d\nmetadata:\n type: user\n---";
let (fm, body) = parse_frontmatter(raw, Path::new("eof.md")).unwrap();
assert_eq!(fm.name, "eof");
assert_eq!(body, "");
}
#[test]
fn parse_frontmatter_rejects_empty_name() {
let raw = "---\nname: \" \"\ndescription: d\n---\nbody\n";
let err = parse_frontmatter(raw, Path::new("x.md")).unwrap_err();
match err {
MemoryError::InvalidTopic { reason, .. } => assert!(reason.contains("name")),
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn parse_frontmatter_rejects_empty_description() {
let raw = "---\nname: x\ndescription: \" \"\n---\nbody\n";
let err = parse_frontmatter(raw, Path::new("x.md")).unwrap_err();
match err {
MemoryError::InvalidTopic { reason, .. } => assert!(reason.contains("description")),
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn parse_frontmatter_rejects_invalid_yaml() {
let raw = "---\nname: [unbalanced\ndescription: d\n---\nbody\n";
let err = parse_frontmatter(raw, Path::new("x.md")).unwrap_err();
match err {
MemoryError::InvalidTopic { reason, .. } => assert!(reason.contains("yaml")),
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn render_topic_file_appends_trailing_newline_when_missing() {
let draft = TopicDraft {
name: "no-nl".to_string(),
description: "desc".to_string(),
kind: TopicKind::Project,
body: "body without newline".to_string(),
};
let rendered = render_topic_file(&draft);
assert!(rendered.ends_with("body without newline\n"));
assert!(rendered.contains("type: project"));
}
#[test]
fn render_topic_file_preserves_single_trailing_newline() {
let draft = TopicDraft {
name: "has-nl".to_string(),
description: "desc".to_string(),
kind: TopicKind::User,
body: "body\n".to_string(),
};
let rendered = render_topic_file(&draft);
assert!(rendered.ends_with("body\n"));
assert!(!rendered.ends_with("body\n\n"));
}
#[test]
fn escape_yaml_string_escapes_special_chars() {
assert_eq!(escape_yaml_string("a\"b"), "a\\\"b");
assert_eq!(escape_yaml_string("a\\b"), "a\\\\b");
assert_eq!(escape_yaml_string("a\nb"), "a\\nb");
assert_eq!(escape_yaml_string("a\rb"), "a\\rb");
assert_eq!(escape_yaml_string("plain"), "plain");
}
#[test]
fn write_then_read_round_trips_description_with_quotes() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
let loader = TopicLoader::new(dir.to_path_buf());
loader
.write(&TopicDraft {
name: "quoted".to_string(),
description: "use \"smart\" quotes \\ backslash".to_string(),
kind: TopicKind::Reference,
body: "body".to_string(),
})
.unwrap();
let topic = loader.read("quoted").unwrap();
assert_eq!(topic.description, "use \"smart\" quotes \\ backslash");
assert_eq!(topic.kind, TopicKind::Reference);
}
#[test]
fn write_creates_index_with_header_when_none_exists() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
let loader = TopicLoader::new(dir.to_path_buf());
loader
.write(&TopicDraft {
name: "first".to_string(),
description: "first desc".to_string(),
kind: TopicKind::User,
body: "body".to_string(),
})
.unwrap();
let index = std::fs::read_to_string(dir.join("MEMORY.md")).unwrap();
assert!(index.starts_with("# Memory index\n\n"));
assert!(index.contains("[first](first.md)"));
}
#[test]
fn write_appends_after_last_bullet_line() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
std::fs::write(
dir.join("MEMORY.md"),
"# Memory index\n\n- [aaa](aaa.md) — user: a\n\nTrailing prose paragraph.\n",
)
.unwrap();
let loader = TopicLoader::new(dir.to_path_buf());
loader
.write(&TopicDraft {
name: "bbb".to_string(),
description: "b desc".to_string(),
kind: TopicKind::User,
body: "body".to_string(),
})
.unwrap();
let index = std::fs::read_to_string(dir.join("MEMORY.md")).unwrap();
let lines: Vec<&str> = index.lines().collect();
let aaa_idx = lines.iter().position(|l| l.contains("aaa.md")).unwrap();
let bbb_idx = lines.iter().position(|l| l.contains("bbb.md")).unwrap();
let prose_idx = lines
.iter()
.position(|l| l.contains("Trailing prose"))
.unwrap();
assert_eq!(bbb_idx, aaa_idx + 1);
assert!(bbb_idx < prose_idx);
}
#[test]
fn rewrite_with_index_line_appends_at_eof_when_no_bullets() {
let out = rewrite_with_index_line(
"# Memory index\n\nSome prose.\n",
"x",
"- [x](x.md) — user: d",
);
assert!(out.contains("Some prose."));
assert!(out.trim_end().ends_with("- [x](x.md) — user: d"));
assert!(out.ends_with('\n'));
}
#[test]
fn rewrite_with_index_line_adds_trailing_newline_when_existing_lacks_one() {
let out =
rewrite_with_index_line("- [x](x.md) — user: old", "x", "- [x](x.md) — user: new");
assert!(out.contains("new"));
assert!(!out.contains("old"));
assert!(out.ends_with('\n'));
}
#[test]
fn remove_index_line_on_missing_index_is_ok() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
remove_index_line(dir, "ghost").unwrap();
assert!(!dir.join("MEMORY.md").exists());
}
#[test]
fn remove_index_line_preserves_other_entries_and_trailing_newline() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
std::fs::write(
dir.join("MEMORY.md"),
"# Memory index\n\n- [keep](keep.md) — user: k\n- [drop](drop.md) — user: d\n",
)
.unwrap();
remove_index_line(dir, "drop").unwrap();
let index = std::fs::read_to_string(dir.join("MEMORY.md")).unwrap();
assert!(index.contains("[keep](keep.md)"));
assert!(!index.contains("drop.md"));
assert!(index.ends_with('\n'));
}
#[test]
fn delete_rejects_invalid_slug() {
let tmp = TempDir::new().unwrap();
let loader = TopicLoader::new(tmp.path().to_path_buf());
let err = loader.delete("a/b").unwrap_err();
assert!(matches!(err, MemoryError::InvalidSlug { .. }));
}
#[test]
fn delete_missing_topic_is_idempotent() {
let tmp = TempDir::new().unwrap();
let loader = TopicLoader::new(tmp.path().to_path_buf());
loader.delete("never-existed").unwrap();
}
#[test]
fn validate_slug_rejects_nul() {
let err = validate_slug("a\0b").unwrap_err();
match err {
MemoryError::InvalidSlug { reason, .. } => assert!(reason.contains("NUL")),
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn strip_html_comments_drops_unterminated_comment_tail() {
let input = "keep me <!-- never closed";
let out = strip_html_comments(input);
assert_eq!(out, "keep me ");
}
#[test]
fn strip_html_comments_no_comment_is_identity() {
let input = "plain text with < and > but no comment";
assert_eq!(strip_html_comments(input), input);
}
}