1use std::path::{Path, PathBuf};
7
8use serde::Deserialize;
9
10use crate::error::{MemoryError, Result};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum TopicKind {
15 User,
17 Feedback,
19 Project,
21 Reference,
23}
24
25impl TopicKind {
26 #[must_use]
28 pub const fn as_str(self) -> &'static str {
29 match self {
30 Self::User => "user",
31 Self::Feedback => "feedback",
32 Self::Project => "project",
33 Self::Reference => "reference",
34 }
35 }
36
37 #[must_use]
40 pub fn parse(s: &str) -> Option<Self> {
41 match s.trim().to_ascii_lowercase().as_str() {
42 "user" => Some(Self::User),
43 "feedback" => Some(Self::Feedback),
44 "project" => Some(Self::Project),
45 "reference" => Some(Self::Reference),
46 _ => None,
47 }
48 }
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct TopicSummary {
54 pub name: String,
56 pub description: String,
58 pub kind: TopicKind,
60 pub path: PathBuf,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct TopicFile {
67 pub name: String,
69 pub description: String,
71 pub kind: TopicKind,
73 pub body: String,
75 pub path: PathBuf,
77}
78
79#[derive(Debug, Clone)]
82pub struct TopicDraft {
83 pub name: String,
85 pub description: String,
87 pub kind: TopicKind,
89 pub body: String,
91}
92
93#[derive(Debug, Deserialize)]
95struct RawFrontmatter {
96 name: String,
97 description: String,
98 #[serde(default)]
99 metadata: RawMetadata,
100}
101
102#[derive(Debug, Default, Deserialize)]
103struct RawMetadata {
104 #[serde(rename = "type")]
105 kind: Option<String>,
106}
107
108#[derive(Debug, Clone)]
111pub struct TopicLoader {
112 dir: PathBuf,
113}
114
115impl TopicLoader {
116 #[must_use]
120 pub fn new(dir: impl Into<PathBuf>) -> Self {
121 Self { dir: dir.into() }
122 }
123
124 #[must_use]
126 pub fn dir(&self) -> &Path {
127 &self.dir
128 }
129
130 pub fn list(&self) -> Result<Vec<TopicSummary>> {
139 let mut out = Vec::new();
140 if !self.dir.exists() {
141 return Ok(out);
142 }
143 let entries = std::fs::read_dir(&self.dir).map_err(|source| MemoryError::Io {
144 path: self.dir.clone(),
145 source,
146 })?;
147 for entry in entries.flatten() {
148 let path = entry.path();
149 if !path.is_file() {
150 continue;
151 }
152 let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
153 continue;
154 };
155 if path.extension().and_then(|s| s.to_str()) != Some("md") {
156 continue;
157 }
158 if path.file_name().and_then(|s| s.to_str()) == Some("MEMORY.md") {
160 continue;
161 }
162 match Self::read_summary(&path) {
163 Ok(mut summary) => {
164 if summary.name != stem {
165 tracing::warn!(
166 target: caliban_common::tracing_targets::TARGET_MEMORY_AUTO,
167 path = %path.display(),
168 frontmatter_name = %summary.name,
169 file_stem = %stem,
170 "topic frontmatter name does not match filename; using filename",
171 );
172 summary.name = stem.to_string();
173 }
174 out.push(summary);
175 }
176 Err(e) => {
177 tracing::warn!(
178 target: caliban_common::tracing_targets::TARGET_MEMORY_AUTO,
179 path = %path.display(),
180 error = %e,
181 "skipping malformed topic file",
182 );
183 }
184 }
185 }
186 out.sort_by(|a, b| a.name.cmp(&b.name));
187 Ok(out)
188 }
189
190 pub fn read(&self, name: &str) -> Result<TopicFile> {
199 validate_slug(name)?;
200 let path = self.dir.join(format!("{name}.md"));
201 let raw = std::fs::read_to_string(&path).map_err(|source| MemoryError::Io {
202 path: path.clone(),
203 source,
204 })?;
205 let (fm, body) = parse_frontmatter(&raw, &path)?;
206 let kind =
207 TopicKind::parse(fm.metadata.kind.as_deref().unwrap_or("")).ok_or_else(|| {
208 MemoryError::InvalidTopic {
209 path: path.clone(),
210 reason: format!(
211 "metadata.type must be one of user|feedback|project|reference (got {:?})",
212 fm.metadata.kind
213 ),
214 }
215 })?;
216 Ok(TopicFile {
217 name: fm.name,
218 description: fm.description,
219 kind,
220 body: body.to_string(),
221 path,
222 })
223 }
224
225 pub fn write(&self, draft: &TopicDraft) -> Result<PathBuf> {
242 validate_slug(&draft.name)?;
243 std::fs::create_dir_all(&self.dir).map_err(|source| MemoryError::Io {
244 path: self.dir.clone(),
245 source,
246 })?;
247
248 let path = self.dir.join(format!("{}.md", draft.name));
249 let serialized = render_topic_file(draft);
250 caliban_common::fs::write_atomic(&path, serialized.as_bytes()).map_err(|source| {
251 MemoryError::Io {
252 path: path.clone(),
253 source,
254 }
255 })?;
256
257 update_index_line(&self.dir, draft)?;
258 Ok(path)
259 }
260
261 pub fn delete(&self, name: &str) -> Result<()> {
269 validate_slug(name)?;
270 let path = self.dir.join(format!("{name}.md"));
271 match std::fs::remove_file(&path) {
272 Ok(()) | Err(_) if !path.exists() => {}
273 Err(e) => {
274 return Err(MemoryError::Io {
275 path: path.clone(),
276 source: e,
277 });
278 }
279 Ok(()) => {}
280 }
281 remove_index_line(&self.dir, name)?;
282 Ok(())
283 }
284
285 fn read_summary(path: &Path) -> Result<TopicSummary> {
286 let raw = std::fs::read_to_string(path).map_err(|source| MemoryError::Io {
287 path: path.to_path_buf(),
288 source,
289 })?;
290 let (fm, _) = parse_frontmatter(&raw, path)?;
291 let kind =
292 TopicKind::parse(fm.metadata.kind.as_deref().unwrap_or("")).ok_or_else(|| {
293 MemoryError::InvalidTopic {
294 path: path.to_path_buf(),
295 reason: format!(
296 "metadata.type must be one of user|feedback|project|reference (got {:?})",
297 fm.metadata.kind
298 ),
299 }
300 })?;
301 Ok(TopicSummary {
302 name: fm.name,
303 description: fm.description,
304 kind,
305 path: path.to_path_buf(),
306 })
307 }
308}
309
310pub fn validate_slug(slug: &str) -> Result<()> {
317 if slug.is_empty() {
318 return Err(MemoryError::InvalidSlug {
319 slug: slug.to_string(),
320 reason: "slug must be non-empty".into(),
321 });
322 }
323 if slug.contains('/') || slug.contains('\\') {
324 return Err(MemoryError::InvalidSlug {
325 slug: slug.to_string(),
326 reason: "slug must not contain path separators".into(),
327 });
328 }
329 if slug.contains("..") {
330 return Err(MemoryError::InvalidSlug {
331 slug: slug.to_string(),
332 reason: "slug must not contain '..'".into(),
333 });
334 }
335 if slug.starts_with('.') {
336 return Err(MemoryError::InvalidSlug {
337 slug: slug.to_string(),
338 reason: "slug must not start with '.'".into(),
339 });
340 }
341 if slug.contains('\0') {
342 return Err(MemoryError::InvalidSlug {
343 slug: slug.to_string(),
344 reason: "slug must not contain NUL".into(),
345 });
346 }
347 Ok(())
348}
349
350fn parse_frontmatter<'a>(raw: &'a str, path: &Path) -> Result<(RawFrontmatter, &'a str)> {
353 let trimmed = raw.trim_start_matches('\u{feff}');
354 let body_start = "---\n";
355 if !trimmed.starts_with(body_start) {
356 return Err(MemoryError::InvalidTopic {
357 path: path.to_path_buf(),
358 reason: "missing leading `---` frontmatter delimiter".into(),
359 });
360 }
361 let after_start = &trimmed[body_start.len()..];
362 let Some(end_idx) = after_start.find("\n---\n").or_else(|| {
363 after_start
364 .find("\n---")
365 .filter(|i| after_start[*i..].starts_with("\n---"))
366 }) else {
367 return Err(MemoryError::InvalidTopic {
368 path: path.to_path_buf(),
369 reason: "missing closing `---` frontmatter delimiter".into(),
370 });
371 };
372 let yaml_chunk = &after_start[..end_idx];
373 let body_start_offset = end_idx + "\n---\n".len();
374 let body = if body_start_offset >= after_start.len() {
375 ""
376 } else {
377 &after_start[body_start_offset..]
378 };
379 let fm: RawFrontmatter =
380 serde_yaml::from_str(yaml_chunk).map_err(|e| MemoryError::InvalidTopic {
381 path: path.to_path_buf(),
382 reason: format!("yaml: {e}"),
383 })?;
384 if fm.name.trim().is_empty() {
385 return Err(MemoryError::InvalidTopic {
386 path: path.to_path_buf(),
387 reason: "name must be non-empty".into(),
388 });
389 }
390 if fm.description.trim().is_empty() {
391 return Err(MemoryError::InvalidTopic {
392 path: path.to_path_buf(),
393 reason: "description must be non-empty".into(),
394 });
395 }
396 Ok((fm, body))
397}
398
399fn render_topic_file(draft: &TopicDraft) -> String {
401 let mut out = String::with_capacity(draft.body.len() + 256);
402 out.push_str("---\n");
403 out.push_str("name: ");
404 out.push_str(&draft.name);
405 out.push('\n');
406 out.push_str("description: \"");
407 out.push_str(&escape_yaml_string(&draft.description));
408 out.push_str("\"\n");
409 out.push_str("metadata:\n");
410 out.push_str(" node_type: memory\n");
411 out.push_str(" type: ");
412 out.push_str(draft.kind.as_str());
413 out.push('\n');
414 out.push_str("---\n\n");
415 out.push_str(&draft.body);
416 if !draft.body.ends_with('\n') {
417 out.push('\n');
418 }
419 out
420}
421
422fn escape_yaml_string(s: &str) -> String {
424 let mut out = String::with_capacity(s.len());
425 for ch in s.chars() {
426 match ch {
427 '"' => out.push_str("\\\""),
428 '\\' => out.push_str("\\\\"),
429 '\n' => out.push_str("\\n"),
430 '\r' => out.push_str("\\r"),
431 c => out.push(c),
432 }
433 }
434 out
435}
436
437fn update_index_line(dir: &Path, draft: &TopicDraft) -> Result<()> {
440 let index_path = dir.join("MEMORY.md");
441 let existing = match std::fs::read_to_string(&index_path) {
442 Ok(s) => s,
443 Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
444 Err(source) => {
445 return Err(MemoryError::Io {
446 path: index_path.clone(),
447 source,
448 });
449 }
450 };
451
452 let new_line = format!(
453 "- [{title}]({slug}.md) — {kind}: {desc}",
454 title = draft.name,
455 slug = draft.name,
456 kind = draft.kind.as_str(),
457 desc = draft.description.lines().next().unwrap_or("").trim(),
458 );
459
460 let new_body = rewrite_with_index_line(&existing, &draft.name, &new_line);
461 caliban_common::fs::write_atomic(&index_path, new_body.as_bytes()).map_err(|source| {
462 MemoryError::Io {
463 path: index_path.clone(),
464 source,
465 }
466 })?;
467 Ok(())
468}
469
470fn remove_index_line(dir: &Path, slug: &str) -> Result<()> {
472 let index_path = dir.join("MEMORY.md");
473 let existing = match std::fs::read_to_string(&index_path) {
474 Ok(s) => s,
475 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
476 Err(source) => {
477 return Err(MemoryError::Io {
478 path: index_path.clone(),
479 source,
480 });
481 }
482 };
483 let needle = format!("]({slug}.md)");
484 let kept: Vec<&str> = existing.lines().filter(|l| !l.contains(&needle)).collect();
485 let mut new_body = kept.join("\n");
486 if existing.ends_with('\n') && !new_body.ends_with('\n') {
487 new_body.push('\n');
488 }
489 caliban_common::fs::write_atomic(&index_path, new_body.as_bytes()).map_err(|source| {
490 MemoryError::Io {
491 path: index_path.clone(),
492 source,
493 }
494 })?;
495 Ok(())
496}
497
498fn rewrite_with_index_line(existing: &str, slug: &str, new_line: &str) -> String {
502 if existing.is_empty() {
503 let mut s = String::from("# Memory index\n\n");
504 s.push_str(new_line);
505 s.push('\n');
506 return s;
507 }
508 let needle = format!("]({slug}.md)");
509 let mut replaced = false;
510 let mut out_lines: Vec<String> = Vec::with_capacity(existing.lines().count() + 1);
511 for line in existing.lines() {
512 if !replaced && line.contains(&needle) {
513 out_lines.push(new_line.to_string());
514 replaced = true;
515 } else {
516 out_lines.push(line.to_string());
517 }
518 }
519 if !replaced {
520 let mut insert_idx = out_lines.len();
522 for (i, line) in out_lines.iter().enumerate().rev() {
524 if line.trim_start().starts_with("- [") {
525 insert_idx = i + 1;
526 break;
527 }
528 }
529 out_lines.insert(insert_idx, new_line.to_string());
530 }
531 let mut s = out_lines.join("\n");
532 if existing.ends_with('\n') || !s.ends_with('\n') {
533 s.push('\n');
534 }
535 s
536}
537
538#[must_use]
542pub fn strip_html_comments(body: &str) -> String {
543 let mut out = String::with_capacity(body.len());
544 let bytes = body.as_bytes();
545 let mut i = 0;
546 while i < bytes.len() {
547 if i + 3 < bytes.len() && &bytes[i..i + 4] == b"<!--" {
548 if let Some(end) = find_subslice(&bytes[i + 4..], b"-->") {
550 i += 4 + end + 3;
551 continue;
552 }
553 break;
554 }
555 out.push(bytes[i] as char);
556 i += 1;
557 }
558 out
559}
560
561fn find_subslice(hay: &[u8], needle: &[u8]) -> Option<usize> {
562 if needle.is_empty() || needle.len() > hay.len() {
563 return None;
564 }
565 for i in 0..=hay.len() - needle.len() {
566 if &hay[i..i + needle.len()] == needle {
567 return Some(i);
568 }
569 }
570 None
571}
572
573#[cfg(test)]
574mod tests {
575 use super::*;
576 use tempfile::TempDir;
577
578 fn topic_md(name: &str, kind: &str, desc: &str, body: &str) -> String {
579 format!(
580 "---\nname: {name}\ndescription: \"{desc}\"\nmetadata:\n node_type: memory\n type: {kind}\n---\n\n{body}\n",
581 )
582 }
583
584 #[test]
585 fn list_enumerates_topic_files_excluding_memory_md() {
586 let tmp = TempDir::new().unwrap();
587 let dir = tmp.path();
588 std::fs::write(
589 dir.join("MEMORY.md"),
590 "# Memory index\n\n- [foo](foo.md) — user: foo\n",
591 )
592 .unwrap();
593 std::fs::write(
594 dir.join("foo.md"),
595 topic_md("foo", "user", "foo desc", "body"),
596 )
597 .unwrap();
598 std::fs::write(
599 dir.join("bar.md"),
600 topic_md("bar", "feedback", "bar desc", "body"),
601 )
602 .unwrap();
603
604 let loader = TopicLoader::new(dir.to_path_buf());
605 let topics = loader.list().unwrap();
606 let names: Vec<_> = topics.iter().map(|t| t.name.as_str()).collect();
607 assert_eq!(names, vec!["bar", "foo"]);
608 assert!(topics.iter().any(|t| matches!(t.kind, TopicKind::User)));
609 assert!(topics.iter().any(|t| matches!(t.kind, TopicKind::Feedback)));
610 }
611
612 #[test]
613 fn read_round_trips_a_topic() {
614 let tmp = TempDir::new().unwrap();
615 let dir = tmp.path();
616 std::fs::write(
617 dir.join("user-role.md"),
618 topic_md(
619 "user-role",
620 "user",
621 "role + context",
622 "# User role\n\nSenior engineer.\n",
623 ),
624 )
625 .unwrap();
626
627 let loader = TopicLoader::new(dir.to_path_buf());
628 let topic = loader.read("user-role").unwrap();
629 assert_eq!(topic.name, "user-role");
630 assert_eq!(topic.kind, TopicKind::User);
631 assert!(topic.body.contains("Senior engineer."));
632 }
633
634 #[test]
635 fn write_creates_topic_and_updates_index() {
636 let tmp = TempDir::new().unwrap();
637 let dir = tmp.path();
638 std::fs::write(dir.join("MEMORY.md"), "# Memory index\n\n").unwrap();
639 let loader = TopicLoader::new(dir.to_path_buf());
640 let path = loader
641 .write(&TopicDraft {
642 name: "personal-email".to_string(),
643 description: "use personal email for ~/dev/personal/**".to_string(),
644 kind: TopicKind::Feedback,
645 body: "Use john.ford2002@gmail.com.\n".to_string(),
646 })
647 .unwrap();
648 assert!(path.exists());
649 assert!(!dir.join("personal-email.md.tmp").exists());
650
651 let written = std::fs::read_to_string(&path).unwrap();
653 assert!(written.contains("name: personal-email"));
654 assert!(written.contains("type: feedback"));
655 assert!(written.contains("john.ford2002@gmail.com"));
656
657 let index = std::fs::read_to_string(dir.join("MEMORY.md")).unwrap();
659 assert!(index.contains("[personal-email](personal-email.md)"));
660 assert!(index.contains("feedback:"));
661 }
662
663 #[test]
664 fn write_updates_existing_index_line_in_place() {
665 let tmp = TempDir::new().unwrap();
666 let dir = tmp.path();
667 std::fs::write(
668 dir.join("MEMORY.md"),
669 "# Memory index\n\n- [foo](foo.md) — user: old desc\n",
670 )
671 .unwrap();
672 let loader = TopicLoader::new(dir.to_path_buf());
673 loader
674 .write(&TopicDraft {
675 name: "foo".to_string(),
676 description: "new desc".to_string(),
677 kind: TopicKind::User,
678 body: "body".to_string(),
679 })
680 .unwrap();
681 let index = std::fs::read_to_string(dir.join("MEMORY.md")).unwrap();
682 assert_eq!(index.matches("[foo](foo.md)").count(), 1);
684 assert!(index.contains("new desc"));
685 assert!(!index.contains("old desc"));
686 }
687
688 #[test]
689 fn read_rejects_invalid_type_in_frontmatter() {
690 let tmp = TempDir::new().unwrap();
691 let dir = tmp.path();
692 std::fs::write(dir.join("bad.md"), topic_md("bad", "junk", "desc", "body")).unwrap();
693 let loader = TopicLoader::new(dir.to_path_buf());
694 let err = loader.read("bad").unwrap_err();
695 assert!(matches!(err, MemoryError::InvalidTopic { .. }));
696 }
697
698 #[test]
699 fn read_rejects_missing_required_frontmatter_fields() {
700 let tmp = TempDir::new().unwrap();
701 let dir = tmp.path();
702 std::fs::write(
703 dir.join("incomplete.md"),
704 "---\ndescription: \"no name\"\nmetadata:\n type: user\n---\n\nbody\n",
705 )
706 .unwrap();
707 let loader = TopicLoader::new(dir.to_path_buf());
708 let err = loader.read("incomplete").unwrap_err();
709 assert!(matches!(err, MemoryError::InvalidTopic { .. }));
710 }
711
712 #[test]
713 fn cross_reference_brackets_preserved_in_body() {
714 let tmp = TempDir::new().unwrap();
715 let dir = tmp.path();
716 let body = "Crosslinks: [[parity-gap-matrix]], [[sprint-mode]].\n".to_string();
717 let loader = TopicLoader::new(dir.to_path_buf());
718 loader
719 .write(&TopicDraft {
720 name: "user-role".to_string(),
721 description: "role".to_string(),
722 kind: TopicKind::User,
723 body: body.clone(),
724 })
725 .unwrap();
726 let topic = loader.read("user-role").unwrap();
727 assert!(topic.body.contains("[[parity-gap-matrix]]"));
728 assert!(topic.body.contains("[[sprint-mode]]"));
729 }
730
731 #[test]
732 fn validate_slug_rejects_path_traversal() {
733 assert!(validate_slug("ok").is_ok());
734 assert!(validate_slug("ok-slug_1").is_ok());
735 assert!(validate_slug("").is_err());
736 assert!(validate_slug("a/b").is_err());
737 assert!(validate_slug("a\\b").is_err());
738 assert!(validate_slug("..").is_err());
739 assert!(validate_slug("a..b").is_err());
740 assert!(validate_slug(".hidden").is_err());
741 }
742
743 #[test]
744 fn strip_html_comments_handles_single_and_multiline() {
745 let single = "hello <!-- inline --> world";
746 assert_eq!(strip_html_comments(single), "hello world");
747
748 let multi = "before\n<!-- line one\nline two\n-->\nafter";
749 let stripped = strip_html_comments(multi);
750 assert!(stripped.contains("before"));
751 assert!(stripped.contains("after"));
752 assert!(!stripped.contains("line one"));
753 assert!(!stripped.contains("line two"));
754 }
755
756 #[test]
757 fn delete_removes_file_and_index_line() {
758 let tmp = TempDir::new().unwrap();
759 let dir = tmp.path();
760 let loader = TopicLoader::new(dir.to_path_buf());
761 loader
762 .write(&TopicDraft {
763 name: "tmp-topic".to_string(),
764 description: "tmp".to_string(),
765 kind: TopicKind::Project,
766 body: "body".to_string(),
767 })
768 .unwrap();
769 loader.delete("tmp-topic").unwrap();
770 assert!(!dir.join("tmp-topic.md").exists());
771 let index = std::fs::read_to_string(dir.join("MEMORY.md")).unwrap();
772 assert!(!index.contains("tmp-topic.md"));
773 }
774
775 #[test]
778 fn topic_kind_as_str_covers_all_variants() {
779 assert_eq!(TopicKind::User.as_str(), "user");
780 assert_eq!(TopicKind::Feedback.as_str(), "feedback");
781 assert_eq!(TopicKind::Project.as_str(), "project");
782 assert_eq!(TopicKind::Reference.as_str(), "reference");
783 }
784
785 #[test]
786 fn topic_kind_parse_is_case_and_whitespace_insensitive() {
787 assert_eq!(TopicKind::parse("USER"), Some(TopicKind::User));
788 assert_eq!(TopicKind::parse(" Feedback "), Some(TopicKind::Feedback));
789 assert_eq!(TopicKind::parse("Project"), Some(TopicKind::Project));
790 assert_eq!(TopicKind::parse("rEfErEnCe"), Some(TopicKind::Reference));
791 }
792
793 #[test]
794 fn topic_kind_parse_rejects_unknown_and_empty() {
795 assert_eq!(TopicKind::parse(""), None);
796 assert_eq!(TopicKind::parse(" "), None);
797 assert_eq!(TopicKind::parse("junk"), None);
798 }
799
800 #[test]
803 fn loader_dir_returns_managed_directory() {
804 let tmp = TempDir::new().unwrap();
805 let loader = TopicLoader::new(tmp.path().to_path_buf());
806 assert_eq!(loader.dir(), tmp.path());
807 }
808
809 #[test]
810 fn list_on_nonexistent_dir_returns_empty() {
811 let tmp = TempDir::new().unwrap();
812 let missing = tmp.path().join("does-not-exist");
813 let loader = TopicLoader::new(missing);
814 assert!(loader.list().unwrap().is_empty());
815 }
816
817 #[test]
818 fn list_skips_non_md_files_and_subdirectories() {
819 let tmp = TempDir::new().unwrap();
820 let dir = tmp.path();
821 std::fs::write(dir.join("notes.txt"), "not markdown").unwrap();
822 std::fs::create_dir(dir.join("subdir")).unwrap();
823 std::fs::create_dir(dir.join("dir.md")).unwrap();
825 std::fs::write(
826 dir.join("ok.md"),
827 topic_md("ok", "project", "ok desc", "body"),
828 )
829 .unwrap();
830
831 let loader = TopicLoader::new(dir.to_path_buf());
832 let topics = loader.list().unwrap();
833 let names: Vec<_> = topics.iter().map(|t| t.name.as_str()).collect();
834 assert_eq!(names, vec!["ok"]);
835 }
836
837 #[test]
838 fn list_skips_malformed_topic_file() {
839 let tmp = TempDir::new().unwrap();
840 let dir = tmp.path();
841 std::fs::write(dir.join("broken.md"), "no frontmatter here\n").unwrap();
843 std::fs::write(
844 dir.join("good.md"),
845 topic_md("good", "reference", "good desc", "body"),
846 )
847 .unwrap();
848
849 let loader = TopicLoader::new(dir.to_path_buf());
850 let topics = loader.list().unwrap();
851 let names: Vec<_> = topics.iter().map(|t| t.name.as_str()).collect();
852 assert_eq!(names, vec!["good"]);
853 assert_eq!(topics[0].kind, TopicKind::Reference);
854 }
855
856 #[test]
857 fn list_uses_filename_when_frontmatter_name_mismatches() {
858 let tmp = TempDir::new().unwrap();
859 let dir = tmp.path();
860 std::fs::write(
862 dir.join("actual-stem.md"),
863 topic_md("wrong", "user", "desc", "body"),
864 )
865 .unwrap();
866
867 let loader = TopicLoader::new(dir.to_path_buf());
868 let topics = loader.list().unwrap();
869 assert_eq!(topics.len(), 1);
870 assert_eq!(topics[0].name, "actual-stem");
871 assert_eq!(topics[0].description, "desc");
872 }
873
874 #[test]
877 fn read_rejects_invalid_slug() {
878 let tmp = TempDir::new().unwrap();
879 let loader = TopicLoader::new(tmp.path().to_path_buf());
880 let err = loader.read("../escape").unwrap_err();
881 assert!(matches!(err, MemoryError::InvalidSlug { .. }));
882 }
883
884 #[test]
885 fn read_missing_file_is_io_error() {
886 let tmp = TempDir::new().unwrap();
887 let loader = TopicLoader::new(tmp.path().to_path_buf());
888 let err = loader.read("nope").unwrap_err();
889 assert!(matches!(err, MemoryError::Io { .. }));
890 }
891
892 #[test]
895 fn parse_frontmatter_strips_bom() {
896 let raw = format!(
897 "\u{feff}{}",
898 topic_md("bom", "user", "with bom", "body line")
899 );
900 let path = Path::new("bom.md");
901 let (fm, body) = parse_frontmatter(&raw, path).unwrap();
902 assert_eq!(fm.name, "bom");
903 assert!(body.contains("body line"));
904 }
905
906 #[test]
907 fn parse_frontmatter_rejects_missing_leading_delimiter() {
908 let raw = "name: x\ndescription: y\n---\nbody\n";
909 let err = parse_frontmatter(raw, Path::new("x.md")).unwrap_err();
910 match err {
911 MemoryError::InvalidTopic { reason, .. } => assert!(reason.contains("leading")),
912 other => panic!("unexpected: {other:?}"),
913 }
914 }
915
916 #[test]
917 fn parse_frontmatter_rejects_missing_closing_delimiter() {
918 let raw = "---\nname: x\ndescription: y\nno closing here\n";
919 let err = parse_frontmatter(raw, Path::new("x.md")).unwrap_err();
920 match err {
921 MemoryError::InvalidTopic { reason, .. } => assert!(reason.contains("closing")),
922 other => panic!("unexpected: {other:?}"),
923 }
924 }
925
926 #[test]
927 fn parse_frontmatter_accepts_closing_delimiter_at_eof_without_body() {
928 let raw = "---\nname: eof\ndescription: d\nmetadata:\n type: user\n---";
930 let (fm, body) = parse_frontmatter(raw, Path::new("eof.md")).unwrap();
931 assert_eq!(fm.name, "eof");
932 assert_eq!(body, "");
933 }
934
935 #[test]
936 fn parse_frontmatter_rejects_empty_name() {
937 let raw = "---\nname: \" \"\ndescription: d\n---\nbody\n";
938 let err = parse_frontmatter(raw, Path::new("x.md")).unwrap_err();
939 match err {
940 MemoryError::InvalidTopic { reason, .. } => assert!(reason.contains("name")),
941 other => panic!("unexpected: {other:?}"),
942 }
943 }
944
945 #[test]
946 fn parse_frontmatter_rejects_empty_description() {
947 let raw = "---\nname: x\ndescription: \" \"\n---\nbody\n";
948 let err = parse_frontmatter(raw, Path::new("x.md")).unwrap_err();
949 match err {
950 MemoryError::InvalidTopic { reason, .. } => assert!(reason.contains("description")),
951 other => panic!("unexpected: {other:?}"),
952 }
953 }
954
955 #[test]
956 fn parse_frontmatter_rejects_invalid_yaml() {
957 let raw = "---\nname: [unbalanced\ndescription: d\n---\nbody\n";
958 let err = parse_frontmatter(raw, Path::new("x.md")).unwrap_err();
959 match err {
960 MemoryError::InvalidTopic { reason, .. } => assert!(reason.contains("yaml")),
961 other => panic!("unexpected: {other:?}"),
962 }
963 }
964
965 #[test]
968 fn render_topic_file_appends_trailing_newline_when_missing() {
969 let draft = TopicDraft {
970 name: "no-nl".to_string(),
971 description: "desc".to_string(),
972 kind: TopicKind::Project,
973 body: "body without newline".to_string(),
974 };
975 let rendered = render_topic_file(&draft);
976 assert!(rendered.ends_with("body without newline\n"));
977 assert!(rendered.contains("type: project"));
978 }
979
980 #[test]
981 fn render_topic_file_preserves_single_trailing_newline() {
982 let draft = TopicDraft {
983 name: "has-nl".to_string(),
984 description: "desc".to_string(),
985 kind: TopicKind::User,
986 body: "body\n".to_string(),
987 };
988 let rendered = render_topic_file(&draft);
989 assert!(rendered.ends_with("body\n"));
991 assert!(!rendered.ends_with("body\n\n"));
992 }
993
994 #[test]
995 fn escape_yaml_string_escapes_special_chars() {
996 assert_eq!(escape_yaml_string("a\"b"), "a\\\"b");
997 assert_eq!(escape_yaml_string("a\\b"), "a\\\\b");
998 assert_eq!(escape_yaml_string("a\nb"), "a\\nb");
999 assert_eq!(escape_yaml_string("a\rb"), "a\\rb");
1000 assert_eq!(escape_yaml_string("plain"), "plain");
1001 }
1002
1003 #[test]
1004 fn write_then_read_round_trips_description_with_quotes() {
1005 let tmp = TempDir::new().unwrap();
1006 let dir = tmp.path();
1007 let loader = TopicLoader::new(dir.to_path_buf());
1008 loader
1009 .write(&TopicDraft {
1010 name: "quoted".to_string(),
1011 description: "use \"smart\" quotes \\ backslash".to_string(),
1012 kind: TopicKind::Reference,
1013 body: "body".to_string(),
1014 })
1015 .unwrap();
1016 let topic = loader.read("quoted").unwrap();
1017 assert_eq!(topic.description, "use \"smart\" quotes \\ backslash");
1018 assert_eq!(topic.kind, TopicKind::Reference);
1019 }
1020
1021 #[test]
1024 fn write_creates_index_with_header_when_none_exists() {
1025 let tmp = TempDir::new().unwrap();
1026 let dir = tmp.path();
1027 let loader = TopicLoader::new(dir.to_path_buf());
1028 loader
1029 .write(&TopicDraft {
1030 name: "first".to_string(),
1031 description: "first desc".to_string(),
1032 kind: TopicKind::User,
1033 body: "body".to_string(),
1034 })
1035 .unwrap();
1036 let index = std::fs::read_to_string(dir.join("MEMORY.md")).unwrap();
1037 assert!(index.starts_with("# Memory index\n\n"));
1038 assert!(index.contains("[first](first.md)"));
1039 }
1040
1041 #[test]
1042 fn write_appends_after_last_bullet_line() {
1043 let tmp = TempDir::new().unwrap();
1044 let dir = tmp.path();
1045 std::fs::write(
1046 dir.join("MEMORY.md"),
1047 "# Memory index\n\n- [aaa](aaa.md) — user: a\n\nTrailing prose paragraph.\n",
1048 )
1049 .unwrap();
1050 let loader = TopicLoader::new(dir.to_path_buf());
1051 loader
1052 .write(&TopicDraft {
1053 name: "bbb".to_string(),
1054 description: "b desc".to_string(),
1055 kind: TopicKind::User,
1056 body: "body".to_string(),
1057 })
1058 .unwrap();
1059 let index = std::fs::read_to_string(dir.join("MEMORY.md")).unwrap();
1060 let lines: Vec<&str> = index.lines().collect();
1061 let aaa_idx = lines.iter().position(|l| l.contains("aaa.md")).unwrap();
1062 let bbb_idx = lines.iter().position(|l| l.contains("bbb.md")).unwrap();
1063 let prose_idx = lines
1064 .iter()
1065 .position(|l| l.contains("Trailing prose"))
1066 .unwrap();
1067 assert_eq!(bbb_idx, aaa_idx + 1);
1069 assert!(bbb_idx < prose_idx);
1070 }
1071
1072 #[test]
1073 fn rewrite_with_index_line_appends_at_eof_when_no_bullets() {
1074 let out = rewrite_with_index_line(
1075 "# Memory index\n\nSome prose.\n",
1076 "x",
1077 "- [x](x.md) — user: d",
1078 );
1079 assert!(out.contains("Some prose."));
1080 assert!(out.trim_end().ends_with("- [x](x.md) — user: d"));
1081 assert!(out.ends_with('\n'));
1082 }
1083
1084 #[test]
1085 fn rewrite_with_index_line_adds_trailing_newline_when_existing_lacks_one() {
1086 let out =
1088 rewrite_with_index_line("- [x](x.md) — user: old", "x", "- [x](x.md) — user: new");
1089 assert!(out.contains("new"));
1090 assert!(!out.contains("old"));
1091 assert!(out.ends_with('\n'));
1092 }
1093
1094 #[test]
1095 fn remove_index_line_on_missing_index_is_ok() {
1096 let tmp = TempDir::new().unwrap();
1097 let dir = tmp.path();
1098 remove_index_line(dir, "ghost").unwrap();
1100 assert!(!dir.join("MEMORY.md").exists());
1101 }
1102
1103 #[test]
1104 fn remove_index_line_preserves_other_entries_and_trailing_newline() {
1105 let tmp = TempDir::new().unwrap();
1106 let dir = tmp.path();
1107 std::fs::write(
1108 dir.join("MEMORY.md"),
1109 "# Memory index\n\n- [keep](keep.md) — user: k\n- [drop](drop.md) — user: d\n",
1110 )
1111 .unwrap();
1112 remove_index_line(dir, "drop").unwrap();
1113 let index = std::fs::read_to_string(dir.join("MEMORY.md")).unwrap();
1114 assert!(index.contains("[keep](keep.md)"));
1115 assert!(!index.contains("drop.md"));
1116 assert!(index.ends_with('\n'));
1117 }
1118
1119 #[test]
1122 fn delete_rejects_invalid_slug() {
1123 let tmp = TempDir::new().unwrap();
1124 let loader = TopicLoader::new(tmp.path().to_path_buf());
1125 let err = loader.delete("a/b").unwrap_err();
1126 assert!(matches!(err, MemoryError::InvalidSlug { .. }));
1127 }
1128
1129 #[test]
1130 fn delete_missing_topic_is_idempotent() {
1131 let tmp = TempDir::new().unwrap();
1132 let loader = TopicLoader::new(tmp.path().to_path_buf());
1133 loader.delete("never-existed").unwrap();
1135 }
1136
1137 #[test]
1140 fn validate_slug_rejects_nul() {
1141 let err = validate_slug("a\0b").unwrap_err();
1142 match err {
1143 MemoryError::InvalidSlug { reason, .. } => assert!(reason.contains("NUL")),
1144 other => panic!("unexpected: {other:?}"),
1145 }
1146 }
1147
1148 #[test]
1151 fn strip_html_comments_drops_unterminated_comment_tail() {
1152 let input = "keep me <!-- never closed";
1153 let out = strip_html_comments(input);
1154 assert_eq!(out, "keep me ");
1155 }
1156
1157 #[test]
1158 fn strip_html_comments_no_comment_is_identity() {
1159 let input = "plain text with < and > but no comment";
1160 assert_eq!(strip_html_comments(input), input);
1161 }
1162}