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 (yaml_chunk, body) =
354 caliban_common::frontmatter::split(raw).map_err(|e| MemoryError::InvalidTopic {
355 path: path.to_path_buf(),
356 reason: e.reason().into(),
357 })?;
358 let fm: RawFrontmatter =
359 serde_yaml::from_str(yaml_chunk).map_err(|e| MemoryError::InvalidTopic {
360 path: path.to_path_buf(),
361 reason: format!("yaml: {e}"),
362 })?;
363 if fm.name.trim().is_empty() {
364 return Err(MemoryError::InvalidTopic {
365 path: path.to_path_buf(),
366 reason: "name must be non-empty".into(),
367 });
368 }
369 if fm.description.trim().is_empty() {
370 return Err(MemoryError::InvalidTopic {
371 path: path.to_path_buf(),
372 reason: "description must be non-empty".into(),
373 });
374 }
375 Ok((fm, body))
376}
377
378fn render_topic_file(draft: &TopicDraft) -> String {
380 let mut out = String::with_capacity(draft.body.len() + 256);
381 out.push_str("---\n");
382 out.push_str("name: ");
383 out.push_str(&draft.name);
384 out.push('\n');
385 out.push_str("description: \"");
386 out.push_str(&escape_yaml_string(&draft.description));
387 out.push_str("\"\n");
388 out.push_str("metadata:\n");
389 out.push_str(" node_type: memory\n");
390 out.push_str(" type: ");
391 out.push_str(draft.kind.as_str());
392 out.push('\n');
393 out.push_str("---\n\n");
394 out.push_str(&draft.body);
395 if !draft.body.ends_with('\n') {
396 out.push('\n');
397 }
398 out
399}
400
401fn escape_yaml_string(s: &str) -> String {
403 let mut out = String::with_capacity(s.len());
404 for ch in s.chars() {
405 match ch {
406 '"' => out.push_str("\\\""),
407 '\\' => out.push_str("\\\\"),
408 '\n' => out.push_str("\\n"),
409 '\r' => out.push_str("\\r"),
410 c => out.push(c),
411 }
412 }
413 out
414}
415
416fn update_index_line(dir: &Path, draft: &TopicDraft) -> Result<()> {
419 let index_path = dir.join("MEMORY.md");
420 let existing = match std::fs::read_to_string(&index_path) {
421 Ok(s) => s,
422 Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
423 Err(source) => {
424 return Err(MemoryError::Io {
425 path: index_path.clone(),
426 source,
427 });
428 }
429 };
430
431 let new_line = format!(
432 "- [{title}]({slug}.md) — {kind}: {desc}",
433 title = draft.name,
434 slug = draft.name,
435 kind = draft.kind.as_str(),
436 desc = draft.description.lines().next().unwrap_or("").trim(),
437 );
438
439 let new_body = rewrite_with_index_line(&existing, &draft.name, &new_line);
440 caliban_common::fs::write_atomic(&index_path, new_body.as_bytes()).map_err(|source| {
441 MemoryError::Io {
442 path: index_path.clone(),
443 source,
444 }
445 })?;
446 Ok(())
447}
448
449fn remove_index_line(dir: &Path, slug: &str) -> Result<()> {
451 let index_path = dir.join("MEMORY.md");
452 let existing = match std::fs::read_to_string(&index_path) {
453 Ok(s) => s,
454 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
455 Err(source) => {
456 return Err(MemoryError::Io {
457 path: index_path.clone(),
458 source,
459 });
460 }
461 };
462 let needle = format!("]({slug}.md)");
463 let kept: Vec<&str> = existing.lines().filter(|l| !l.contains(&needle)).collect();
464 let mut new_body = kept.join("\n");
465 if existing.ends_with('\n') && !new_body.ends_with('\n') {
466 new_body.push('\n');
467 }
468 caliban_common::fs::write_atomic(&index_path, new_body.as_bytes()).map_err(|source| {
469 MemoryError::Io {
470 path: index_path.clone(),
471 source,
472 }
473 })?;
474 Ok(())
475}
476
477fn rewrite_with_index_line(existing: &str, slug: &str, new_line: &str) -> String {
481 if existing.is_empty() {
482 let mut s = String::from("# Memory index\n\n");
483 s.push_str(new_line);
484 s.push('\n');
485 return s;
486 }
487 let needle = format!("]({slug}.md)");
488 let mut replaced = false;
489 let mut out_lines: Vec<String> = Vec::with_capacity(existing.lines().count() + 1);
490 for line in existing.lines() {
491 if !replaced && line.contains(&needle) {
492 out_lines.push(new_line.to_string());
493 replaced = true;
494 } else {
495 out_lines.push(line.to_string());
496 }
497 }
498 if !replaced {
499 let mut insert_idx = out_lines.len();
501 for (i, line) in out_lines.iter().enumerate().rev() {
503 if line.trim_start().starts_with("- [") {
504 insert_idx = i + 1;
505 break;
506 }
507 }
508 out_lines.insert(insert_idx, new_line.to_string());
509 }
510 let mut s = out_lines.join("\n");
511 if existing.ends_with('\n') || !s.ends_with('\n') {
512 s.push('\n');
513 }
514 s
515}
516
517#[must_use]
521pub fn strip_html_comments(body: &str) -> String {
522 let mut out = String::with_capacity(body.len());
523 let bytes = body.as_bytes();
524 let mut i = 0;
525 while i < bytes.len() {
526 if i + 3 < bytes.len() && &bytes[i..i + 4] == b"<!--" {
527 if let Some(end) = find_subslice(&bytes[i + 4..], b"-->") {
529 i += 4 + end + 3;
530 continue;
531 }
532 break;
533 }
534 out.push(bytes[i] as char);
535 i += 1;
536 }
537 out
538}
539
540fn find_subslice(hay: &[u8], needle: &[u8]) -> Option<usize> {
541 if needle.is_empty() || needle.len() > hay.len() {
542 return None;
543 }
544 for i in 0..=hay.len() - needle.len() {
545 if &hay[i..i + needle.len()] == needle {
546 return Some(i);
547 }
548 }
549 None
550}
551
552#[cfg(test)]
553mod tests {
554 use super::*;
555 use tempfile::TempDir;
556
557 fn topic_md(name: &str, kind: &str, desc: &str, body: &str) -> String {
558 format!(
559 "---\nname: {name}\ndescription: \"{desc}\"\nmetadata:\n node_type: memory\n type: {kind}\n---\n\n{body}\n",
560 )
561 }
562
563 #[test]
564 fn list_enumerates_topic_files_excluding_memory_md() {
565 let tmp = TempDir::new().unwrap();
566 let dir = tmp.path();
567 std::fs::write(
568 dir.join("MEMORY.md"),
569 "# Memory index\n\n- [foo](foo.md) — user: foo\n",
570 )
571 .unwrap();
572 std::fs::write(
573 dir.join("foo.md"),
574 topic_md("foo", "user", "foo desc", "body"),
575 )
576 .unwrap();
577 std::fs::write(
578 dir.join("bar.md"),
579 topic_md("bar", "feedback", "bar desc", "body"),
580 )
581 .unwrap();
582
583 let loader = TopicLoader::new(dir.to_path_buf());
584 let topics = loader.list().unwrap();
585 let names: Vec<_> = topics.iter().map(|t| t.name.as_str()).collect();
586 assert_eq!(names, vec!["bar", "foo"]);
587 assert!(topics.iter().any(|t| matches!(t.kind, TopicKind::User)));
588 assert!(topics.iter().any(|t| matches!(t.kind, TopicKind::Feedback)));
589 }
590
591 #[test]
592 fn read_round_trips_a_topic() {
593 let tmp = TempDir::new().unwrap();
594 let dir = tmp.path();
595 std::fs::write(
596 dir.join("user-role.md"),
597 topic_md(
598 "user-role",
599 "user",
600 "role + context",
601 "# User role\n\nSenior engineer.\n",
602 ),
603 )
604 .unwrap();
605
606 let loader = TopicLoader::new(dir.to_path_buf());
607 let topic = loader.read("user-role").unwrap();
608 assert_eq!(topic.name, "user-role");
609 assert_eq!(topic.kind, TopicKind::User);
610 assert!(topic.body.contains("Senior engineer."));
611 }
612
613 #[test]
614 fn write_creates_topic_and_updates_index() {
615 let tmp = TempDir::new().unwrap();
616 let dir = tmp.path();
617 std::fs::write(dir.join("MEMORY.md"), "# Memory index\n\n").unwrap();
618 let loader = TopicLoader::new(dir.to_path_buf());
619 let path = loader
620 .write(&TopicDraft {
621 name: "personal-email".to_string(),
622 description: "use personal email for ~/dev/personal/**".to_string(),
623 kind: TopicKind::Feedback,
624 body: "Use john.ford2002@gmail.com.\n".to_string(),
625 })
626 .unwrap();
627 assert!(path.exists());
628 assert!(!dir.join("personal-email.md.tmp").exists());
629
630 let written = std::fs::read_to_string(&path).unwrap();
632 assert!(written.contains("name: personal-email"));
633 assert!(written.contains("type: feedback"));
634 assert!(written.contains("john.ford2002@gmail.com"));
635
636 let index = std::fs::read_to_string(dir.join("MEMORY.md")).unwrap();
638 assert!(index.contains("[personal-email](personal-email.md)"));
639 assert!(index.contains("feedback:"));
640 }
641
642 #[test]
643 fn write_updates_existing_index_line_in_place() {
644 let tmp = TempDir::new().unwrap();
645 let dir = tmp.path();
646 std::fs::write(
647 dir.join("MEMORY.md"),
648 "# Memory index\n\n- [foo](foo.md) — user: old desc\n",
649 )
650 .unwrap();
651 let loader = TopicLoader::new(dir.to_path_buf());
652 loader
653 .write(&TopicDraft {
654 name: "foo".to_string(),
655 description: "new desc".to_string(),
656 kind: TopicKind::User,
657 body: "body".to_string(),
658 })
659 .unwrap();
660 let index = std::fs::read_to_string(dir.join("MEMORY.md")).unwrap();
661 assert_eq!(index.matches("[foo](foo.md)").count(), 1);
663 assert!(index.contains("new desc"));
664 assert!(!index.contains("old desc"));
665 }
666
667 #[test]
668 fn read_rejects_invalid_type_in_frontmatter() {
669 let tmp = TempDir::new().unwrap();
670 let dir = tmp.path();
671 std::fs::write(dir.join("bad.md"), topic_md("bad", "junk", "desc", "body")).unwrap();
672 let loader = TopicLoader::new(dir.to_path_buf());
673 let err = loader.read("bad").unwrap_err();
674 assert!(matches!(err, MemoryError::InvalidTopic { .. }));
675 }
676
677 #[test]
678 fn read_rejects_missing_required_frontmatter_fields() {
679 let tmp = TempDir::new().unwrap();
680 let dir = tmp.path();
681 std::fs::write(
682 dir.join("incomplete.md"),
683 "---\ndescription: \"no name\"\nmetadata:\n type: user\n---\n\nbody\n",
684 )
685 .unwrap();
686 let loader = TopicLoader::new(dir.to_path_buf());
687 let err = loader.read("incomplete").unwrap_err();
688 assert!(matches!(err, MemoryError::InvalidTopic { .. }));
689 }
690
691 #[test]
692 fn cross_reference_brackets_preserved_in_body() {
693 let tmp = TempDir::new().unwrap();
694 let dir = tmp.path();
695 let body = "Crosslinks: [[parity-gap-matrix]], [[sprint-mode]].\n".to_string();
696 let loader = TopicLoader::new(dir.to_path_buf());
697 loader
698 .write(&TopicDraft {
699 name: "user-role".to_string(),
700 description: "role".to_string(),
701 kind: TopicKind::User,
702 body: body.clone(),
703 })
704 .unwrap();
705 let topic = loader.read("user-role").unwrap();
706 assert!(topic.body.contains("[[parity-gap-matrix]]"));
707 assert!(topic.body.contains("[[sprint-mode]]"));
708 }
709
710 #[test]
711 fn validate_slug_rejects_path_traversal() {
712 assert!(validate_slug("ok").is_ok());
713 assert!(validate_slug("ok-slug_1").is_ok());
714 assert!(validate_slug("").is_err());
715 assert!(validate_slug("a/b").is_err());
716 assert!(validate_slug("a\\b").is_err());
717 assert!(validate_slug("..").is_err());
718 assert!(validate_slug("a..b").is_err());
719 assert!(validate_slug(".hidden").is_err());
720 }
721
722 #[test]
723 fn strip_html_comments_handles_single_and_multiline() {
724 let single = "hello <!-- inline --> world";
725 assert_eq!(strip_html_comments(single), "hello world");
726
727 let multi = "before\n<!-- line one\nline two\n-->\nafter";
728 let stripped = strip_html_comments(multi);
729 assert!(stripped.contains("before"));
730 assert!(stripped.contains("after"));
731 assert!(!stripped.contains("line one"));
732 assert!(!stripped.contains("line two"));
733 }
734
735 #[test]
736 fn delete_removes_file_and_index_line() {
737 let tmp = TempDir::new().unwrap();
738 let dir = tmp.path();
739 let loader = TopicLoader::new(dir.to_path_buf());
740 loader
741 .write(&TopicDraft {
742 name: "tmp-topic".to_string(),
743 description: "tmp".to_string(),
744 kind: TopicKind::Project,
745 body: "body".to_string(),
746 })
747 .unwrap();
748 loader.delete("tmp-topic").unwrap();
749 assert!(!dir.join("tmp-topic.md").exists());
750 let index = std::fs::read_to_string(dir.join("MEMORY.md")).unwrap();
751 assert!(!index.contains("tmp-topic.md"));
752 }
753
754 #[test]
757 fn topic_kind_as_str_covers_all_variants() {
758 assert_eq!(TopicKind::User.as_str(), "user");
759 assert_eq!(TopicKind::Feedback.as_str(), "feedback");
760 assert_eq!(TopicKind::Project.as_str(), "project");
761 assert_eq!(TopicKind::Reference.as_str(), "reference");
762 }
763
764 #[test]
765 fn topic_kind_parse_is_case_and_whitespace_insensitive() {
766 assert_eq!(TopicKind::parse("USER"), Some(TopicKind::User));
767 assert_eq!(TopicKind::parse(" Feedback "), Some(TopicKind::Feedback));
768 assert_eq!(TopicKind::parse("Project"), Some(TopicKind::Project));
769 assert_eq!(TopicKind::parse("rEfErEnCe"), Some(TopicKind::Reference));
770 }
771
772 #[test]
773 fn topic_kind_parse_rejects_unknown_and_empty() {
774 assert_eq!(TopicKind::parse(""), None);
775 assert_eq!(TopicKind::parse(" "), None);
776 assert_eq!(TopicKind::parse("junk"), None);
777 }
778
779 #[test]
782 fn loader_dir_returns_managed_directory() {
783 let tmp = TempDir::new().unwrap();
784 let loader = TopicLoader::new(tmp.path().to_path_buf());
785 assert_eq!(loader.dir(), tmp.path());
786 }
787
788 #[test]
789 fn list_on_nonexistent_dir_returns_empty() {
790 let tmp = TempDir::new().unwrap();
791 let missing = tmp.path().join("does-not-exist");
792 let loader = TopicLoader::new(missing);
793 assert!(loader.list().unwrap().is_empty());
794 }
795
796 #[test]
797 fn list_skips_non_md_files_and_subdirectories() {
798 let tmp = TempDir::new().unwrap();
799 let dir = tmp.path();
800 std::fs::write(dir.join("notes.txt"), "not markdown").unwrap();
801 std::fs::create_dir(dir.join("subdir")).unwrap();
802 std::fs::create_dir(dir.join("dir.md")).unwrap();
804 std::fs::write(
805 dir.join("ok.md"),
806 topic_md("ok", "project", "ok desc", "body"),
807 )
808 .unwrap();
809
810 let loader = TopicLoader::new(dir.to_path_buf());
811 let topics = loader.list().unwrap();
812 let names: Vec<_> = topics.iter().map(|t| t.name.as_str()).collect();
813 assert_eq!(names, vec!["ok"]);
814 }
815
816 #[test]
817 fn list_skips_malformed_topic_file() {
818 let tmp = TempDir::new().unwrap();
819 let dir = tmp.path();
820 std::fs::write(dir.join("broken.md"), "no frontmatter here\n").unwrap();
822 std::fs::write(
823 dir.join("good.md"),
824 topic_md("good", "reference", "good desc", "body"),
825 )
826 .unwrap();
827
828 let loader = TopicLoader::new(dir.to_path_buf());
829 let topics = loader.list().unwrap();
830 let names: Vec<_> = topics.iter().map(|t| t.name.as_str()).collect();
831 assert_eq!(names, vec!["good"]);
832 assert_eq!(topics[0].kind, TopicKind::Reference);
833 }
834
835 #[test]
836 fn list_uses_filename_when_frontmatter_name_mismatches() {
837 let tmp = TempDir::new().unwrap();
838 let dir = tmp.path();
839 std::fs::write(
841 dir.join("actual-stem.md"),
842 topic_md("wrong", "user", "desc", "body"),
843 )
844 .unwrap();
845
846 let loader = TopicLoader::new(dir.to_path_buf());
847 let topics = loader.list().unwrap();
848 assert_eq!(topics.len(), 1);
849 assert_eq!(topics[0].name, "actual-stem");
850 assert_eq!(topics[0].description, "desc");
851 }
852
853 #[test]
856 fn read_rejects_invalid_slug() {
857 let tmp = TempDir::new().unwrap();
858 let loader = TopicLoader::new(tmp.path().to_path_buf());
859 let err = loader.read("../escape").unwrap_err();
860 assert!(matches!(err, MemoryError::InvalidSlug { .. }));
861 }
862
863 #[test]
864 fn read_missing_file_is_io_error() {
865 let tmp = TempDir::new().unwrap();
866 let loader = TopicLoader::new(tmp.path().to_path_buf());
867 let err = loader.read("nope").unwrap_err();
868 assert!(matches!(err, MemoryError::Io { .. }));
869 }
870
871 #[test]
874 fn parse_frontmatter_strips_bom() {
875 let raw = format!(
876 "\u{feff}{}",
877 topic_md("bom", "user", "with bom", "body line")
878 );
879 let path = Path::new("bom.md");
880 let (fm, body) = parse_frontmatter(&raw, path).unwrap();
881 assert_eq!(fm.name, "bom");
882 assert!(body.contains("body line"));
883 }
884
885 #[test]
886 fn parse_frontmatter_rejects_missing_leading_delimiter() {
887 let raw = "name: x\ndescription: y\n---\nbody\n";
888 let err = parse_frontmatter(raw, Path::new("x.md")).unwrap_err();
889 match err {
890 MemoryError::InvalidTopic { reason, .. } => assert!(reason.contains("leading")),
891 other => panic!("unexpected: {other:?}"),
892 }
893 }
894
895 #[test]
896 fn parse_frontmatter_rejects_missing_closing_delimiter() {
897 let raw = "---\nname: x\ndescription: y\nno closing here\n";
898 let err = parse_frontmatter(raw, Path::new("x.md")).unwrap_err();
899 match err {
900 MemoryError::InvalidTopic { reason, .. } => assert!(reason.contains("closing")),
901 other => panic!("unexpected: {other:?}"),
902 }
903 }
904
905 #[test]
906 fn parse_frontmatter_accepts_closing_delimiter_at_eof_without_body() {
907 let raw = "---\nname: eof\ndescription: d\nmetadata:\n type: user\n---";
909 let (fm, body) = parse_frontmatter(raw, Path::new("eof.md")).unwrap();
910 assert_eq!(fm.name, "eof");
911 assert_eq!(body, "");
912 }
913
914 #[test]
915 fn parse_frontmatter_rejects_empty_name() {
916 let raw = "---\nname: \" \"\ndescription: d\n---\nbody\n";
917 let err = parse_frontmatter(raw, Path::new("x.md")).unwrap_err();
918 match err {
919 MemoryError::InvalidTopic { reason, .. } => assert!(reason.contains("name")),
920 other => panic!("unexpected: {other:?}"),
921 }
922 }
923
924 #[test]
925 fn parse_frontmatter_rejects_empty_description() {
926 let raw = "---\nname: x\ndescription: \" \"\n---\nbody\n";
927 let err = parse_frontmatter(raw, Path::new("x.md")).unwrap_err();
928 match err {
929 MemoryError::InvalidTopic { reason, .. } => assert!(reason.contains("description")),
930 other => panic!("unexpected: {other:?}"),
931 }
932 }
933
934 #[test]
935 fn parse_frontmatter_rejects_invalid_yaml() {
936 let raw = "---\nname: [unbalanced\ndescription: d\n---\nbody\n";
937 let err = parse_frontmatter(raw, Path::new("x.md")).unwrap_err();
938 match err {
939 MemoryError::InvalidTopic { reason, .. } => assert!(reason.contains("yaml")),
940 other => panic!("unexpected: {other:?}"),
941 }
942 }
943
944 #[test]
947 fn render_topic_file_appends_trailing_newline_when_missing() {
948 let draft = TopicDraft {
949 name: "no-nl".to_string(),
950 description: "desc".to_string(),
951 kind: TopicKind::Project,
952 body: "body without newline".to_string(),
953 };
954 let rendered = render_topic_file(&draft);
955 assert!(rendered.ends_with("body without newline\n"));
956 assert!(rendered.contains("type: project"));
957 }
958
959 #[test]
960 fn render_topic_file_preserves_single_trailing_newline() {
961 let draft = TopicDraft {
962 name: "has-nl".to_string(),
963 description: "desc".to_string(),
964 kind: TopicKind::User,
965 body: "body\n".to_string(),
966 };
967 let rendered = render_topic_file(&draft);
968 assert!(rendered.ends_with("body\n"));
970 assert!(!rendered.ends_with("body\n\n"));
971 }
972
973 #[test]
974 fn escape_yaml_string_escapes_special_chars() {
975 assert_eq!(escape_yaml_string("a\"b"), "a\\\"b");
976 assert_eq!(escape_yaml_string("a\\b"), "a\\\\b");
977 assert_eq!(escape_yaml_string("a\nb"), "a\\nb");
978 assert_eq!(escape_yaml_string("a\rb"), "a\\rb");
979 assert_eq!(escape_yaml_string("plain"), "plain");
980 }
981
982 #[test]
983 fn write_then_read_round_trips_description_with_quotes() {
984 let tmp = TempDir::new().unwrap();
985 let dir = tmp.path();
986 let loader = TopicLoader::new(dir.to_path_buf());
987 loader
988 .write(&TopicDraft {
989 name: "quoted".to_string(),
990 description: "use \"smart\" quotes \\ backslash".to_string(),
991 kind: TopicKind::Reference,
992 body: "body".to_string(),
993 })
994 .unwrap();
995 let topic = loader.read("quoted").unwrap();
996 assert_eq!(topic.description, "use \"smart\" quotes \\ backslash");
997 assert_eq!(topic.kind, TopicKind::Reference);
998 }
999
1000 #[test]
1003 fn write_creates_index_with_header_when_none_exists() {
1004 let tmp = TempDir::new().unwrap();
1005 let dir = tmp.path();
1006 let loader = TopicLoader::new(dir.to_path_buf());
1007 loader
1008 .write(&TopicDraft {
1009 name: "first".to_string(),
1010 description: "first desc".to_string(),
1011 kind: TopicKind::User,
1012 body: "body".to_string(),
1013 })
1014 .unwrap();
1015 let index = std::fs::read_to_string(dir.join("MEMORY.md")).unwrap();
1016 assert!(index.starts_with("# Memory index\n\n"));
1017 assert!(index.contains("[first](first.md)"));
1018 }
1019
1020 #[test]
1021 fn write_appends_after_last_bullet_line() {
1022 let tmp = TempDir::new().unwrap();
1023 let dir = tmp.path();
1024 std::fs::write(
1025 dir.join("MEMORY.md"),
1026 "# Memory index\n\n- [aaa](aaa.md) — user: a\n\nTrailing prose paragraph.\n",
1027 )
1028 .unwrap();
1029 let loader = TopicLoader::new(dir.to_path_buf());
1030 loader
1031 .write(&TopicDraft {
1032 name: "bbb".to_string(),
1033 description: "b desc".to_string(),
1034 kind: TopicKind::User,
1035 body: "body".to_string(),
1036 })
1037 .unwrap();
1038 let index = std::fs::read_to_string(dir.join("MEMORY.md")).unwrap();
1039 let lines: Vec<&str> = index.lines().collect();
1040 let aaa_idx = lines.iter().position(|l| l.contains("aaa.md")).unwrap();
1041 let bbb_idx = lines.iter().position(|l| l.contains("bbb.md")).unwrap();
1042 let prose_idx = lines
1043 .iter()
1044 .position(|l| l.contains("Trailing prose"))
1045 .unwrap();
1046 assert_eq!(bbb_idx, aaa_idx + 1);
1048 assert!(bbb_idx < prose_idx);
1049 }
1050
1051 #[test]
1052 fn rewrite_with_index_line_appends_at_eof_when_no_bullets() {
1053 let out = rewrite_with_index_line(
1054 "# Memory index\n\nSome prose.\n",
1055 "x",
1056 "- [x](x.md) — user: d",
1057 );
1058 assert!(out.contains("Some prose."));
1059 assert!(out.trim_end().ends_with("- [x](x.md) — user: d"));
1060 assert!(out.ends_with('\n'));
1061 }
1062
1063 #[test]
1064 fn rewrite_with_index_line_adds_trailing_newline_when_existing_lacks_one() {
1065 let out =
1067 rewrite_with_index_line("- [x](x.md) — user: old", "x", "- [x](x.md) — user: new");
1068 assert!(out.contains("new"));
1069 assert!(!out.contains("old"));
1070 assert!(out.ends_with('\n'));
1071 }
1072
1073 #[test]
1074 fn remove_index_line_on_missing_index_is_ok() {
1075 let tmp = TempDir::new().unwrap();
1076 let dir = tmp.path();
1077 remove_index_line(dir, "ghost").unwrap();
1079 assert!(!dir.join("MEMORY.md").exists());
1080 }
1081
1082 #[test]
1083 fn remove_index_line_preserves_other_entries_and_trailing_newline() {
1084 let tmp = TempDir::new().unwrap();
1085 let dir = tmp.path();
1086 std::fs::write(
1087 dir.join("MEMORY.md"),
1088 "# Memory index\n\n- [keep](keep.md) — user: k\n- [drop](drop.md) — user: d\n",
1089 )
1090 .unwrap();
1091 remove_index_line(dir, "drop").unwrap();
1092 let index = std::fs::read_to_string(dir.join("MEMORY.md")).unwrap();
1093 assert!(index.contains("[keep](keep.md)"));
1094 assert!(!index.contains("drop.md"));
1095 assert!(index.ends_with('\n'));
1096 }
1097
1098 #[test]
1101 fn delete_rejects_invalid_slug() {
1102 let tmp = TempDir::new().unwrap();
1103 let loader = TopicLoader::new(tmp.path().to_path_buf());
1104 let err = loader.delete("a/b").unwrap_err();
1105 assert!(matches!(err, MemoryError::InvalidSlug { .. }));
1106 }
1107
1108 #[test]
1109 fn delete_missing_topic_is_idempotent() {
1110 let tmp = TempDir::new().unwrap();
1111 let loader = TopicLoader::new(tmp.path().to_path_buf());
1112 loader.delete("never-existed").unwrap();
1114 }
1115
1116 #[test]
1119 fn validate_slug_rejects_nul() {
1120 let err = validate_slug("a\0b").unwrap_err();
1121 match err {
1122 MemoryError::InvalidSlug { reason, .. } => assert!(reason.contains("NUL")),
1123 other => panic!("unexpected: {other:?}"),
1124 }
1125 }
1126
1127 #[test]
1130 fn strip_html_comments_drops_unterminated_comment_tail() {
1131 let input = "keep me <!-- never closed";
1132 let out = strip_html_comments(input);
1133 assert_eq!(out, "keep me ");
1134 }
1135
1136 #[test]
1137 fn strip_html_comments_no_comment_is_identity() {
1138 let input = "plain text with < and > but no comment";
1139 assert_eq!(strip_html_comments(input), input);
1140 }
1141}