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}