Skip to main content

caliban_memory/
auto.rs

1//! Per-project auto-memory: topic file enumerator, reader, and writer.
2//!
3//! See `docs/superpowers/specs/2026-05-24-auto-memory-design.md` and
4//! `docs/adr/0035-auto-memory.md`.
5
6use std::path::{Path, PathBuf};
7
8use serde::Deserialize;
9
10use crate::error::{MemoryError, Result};
11
12/// The four memory-type categories the model classifies a topic file under.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum TopicKind {
15    /// Durable facts about the user.
16    User,
17    /// User-issued corrections / preferences for future interactions.
18    Feedback,
19    /// Durable project facts not already in the repo.
20    Project,
21    /// Stable external context (account IDs, URLs, API quotas).
22    Reference,
23}
24
25impl TopicKind {
26    /// Lower-case wire form.
27    #[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    /// Parse from a string, accepting case-insensitively. Returns `None` for
38    /// any input that is not one of the four valid types.
39    #[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/// Lightweight summary of a topic file (frontmatter only).
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct TopicSummary {
54    /// The slug (kebab-case, must match filename stem).
55    pub name: String,
56    /// One-line description (≤ 120 chars by convention).
57    pub description: String,
58    /// Memory type classification.
59    pub kind: TopicKind,
60    /// Absolute path to the topic file.
61    pub path: PathBuf,
62}
63
64/// A fully-loaded topic file.
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct TopicFile {
67    /// The slug (kebab-case).
68    pub name: String,
69    /// One-line description.
70    pub description: String,
71    /// Memory type classification.
72    pub kind: TopicKind,
73    /// Markdown body (everything after the closing frontmatter `---`).
74    pub body: String,
75    /// Absolute path.
76    pub path: PathBuf,
77}
78
79/// Draft passed to [`TopicLoader::write`]. The loader fills in path / on-disk
80/// frontmatter from these fields.
81#[derive(Debug, Clone)]
82pub struct TopicDraft {
83    /// The slug (kebab-case). Must pass [`validate_slug`].
84    pub name: String,
85    /// One-line description for the `MEMORY.md` index entry + frontmatter.
86    pub description: String,
87    /// Memory type classification.
88    pub kind: TopicKind,
89    /// Raw markdown body (no frontmatter — the loader emits it).
90    pub body: String,
91}
92
93/// Frontmatter shape used by [`TopicLoader::read`] / [`TopicLoader::list`].
94#[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/// Enumerator + reader/writer for topic `.md` files under a single memory
109/// directory.
110#[derive(Debug, Clone)]
111pub struct TopicLoader {
112    dir: PathBuf,
113}
114
115impl TopicLoader {
116    /// Construct a loader over the given memory directory. The directory does
117    /// not have to exist yet — `list` returns an empty vec, and `write` will
118    /// create it on demand.
119    #[must_use]
120    pub fn new(dir: impl Into<PathBuf>) -> Self {
121        Self { dir: dir.into() }
122    }
123
124    /// The directory this loader manages.
125    #[must_use]
126    pub fn dir(&self) -> &Path {
127        &self.dir
128    }
129
130    /// Enumerate every `.md` sibling of `MEMORY.md`, parsing frontmatter for
131    /// each. Files with malformed frontmatter are silently skipped with a
132    /// `warn!` log entry (rationale: a single corrupted topic file should not
133    /// brick the whole memory tier).
134    ///
135    /// # Errors
136    ///
137    /// Returns [`MemoryError::Io`] if the directory exists but cannot be read.
138    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            // Skip the index file itself.
159            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    /// Read a topic by slug. The slug must pass [`validate_slug`] — no path
191    /// separators, no `..`, no leading `.`.
192    ///
193    /// # Errors
194    ///
195    /// Returns [`MemoryError::InvalidSlug`] for traversal / illegal slugs,
196    /// [`MemoryError::Io`] if the file does not exist or cannot be read, and
197    /// [`MemoryError::InvalidTopic`] if the frontmatter is malformed.
198    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    /// Atomically write a topic file (`<slug>.md`) and update the `MEMORY.md`
226    /// index line for it. Returns the topic's absolute path on success.
227    ///
228    /// Write semantics:
229    /// 1. Write the topic body + frontmatter to `<slug>.md.tmp`.
230    /// 2. Rename to `<slug>.md` (atomic on the same filesystem).
231    /// 3. Rewrite `MEMORY.md` with an updated index line for the slug
232    ///    (`MEMORY.md` is rewritten via the same tmp+rename dance).
233    ///
234    /// A crash between (2) and (3) leaves an orphan topic file that
235    /// `rebuild-index` can re-detect.
236    ///
237    /// # Errors
238    ///
239    /// Returns [`MemoryError::InvalidSlug`] for bad slugs, [`MemoryError::Io`]
240    /// on any IO failure.
241    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    /// Delete a topic file by slug and remove its `MEMORY.md` index line.
262    /// Missing files are treated as success (idempotent delete).
263    ///
264    /// # Errors
265    ///
266    /// Returns [`MemoryError::InvalidSlug`] for bad slugs or [`MemoryError::Io`]
267    /// on IO failure.
268    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
310/// Validate a topic slug. Rules: non-empty, no path separators (`/`, `\\`),
311/// no `..`, no leading dot.
312///
313/// # Errors
314///
315/// Returns [`MemoryError::InvalidSlug`] if the slug fails any rule.
316pub 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
350/// Split a raw file into frontmatter struct + body. Frontmatter delimiters are
351/// `---\n` opening and `\n---\n` (or `\n---` at EOF) closing.
352fn 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
378/// Render a [`TopicDraft`] to on-disk markdown (frontmatter + body).
379fn 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
401/// Escape `"` and `\` for a double-quoted YAML scalar.
402fn 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
416/// Update (or insert) the index line for `draft.name` inside `MEMORY.md`.
417/// Atomic via tmp + rename.
418fn 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
449/// Remove a topic's index line, if present.
450fn 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
477/// Insert-or-replace the index line for `slug`. We match on the
478/// `](<slug>.md)` substring, which is robust against operators tweaking the
479/// title or one-line summary in place.
480fn 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        // Append after the last existing index-style line, otherwise at EOF.
500        let mut insert_idx = out_lines.len();
501        // Insert after the last `- [` bullet line if any exist.
502        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/// Strip every `<!-- … -->` HTML comment (greedy, multi-line) from `body`.
518/// Used by the memory loader before splicing into the system prompt — the
519/// on-disk file is untouched.
520#[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            // find closing -->; if not found, drop the rest.
528            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        // Topic file contains frontmatter + body.
631        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        // Index updated.
637        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        // exactly one entry for foo
662        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    // --- TopicKind ----------------------------------------------------------
755
756    #[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    // --- TopicLoader accessors / list edge cases ----------------------------
780
781    #[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        // A `.md` directory entry must also be ignored (not a file).
803        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        // Malformed: no frontmatter delimiters at all.
821        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        // Frontmatter name "wrong" but file stem is "actual-stem".
840        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    // --- read error paths ---------------------------------------------------
854
855    #[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    // --- parse_frontmatter edge cases ---------------------------------------
872
873    #[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        // Closing `\n---` with no trailing newline and no body.
908        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    // --- render_topic_file / escape_yaml_string -----------------------------
945
946    #[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        // Exactly one trailing newline (no double newline appended).
969        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    // --- index handling -----------------------------------------------------
1001
1002    #[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        // New bullet inserted right after the last existing bullet, before prose.
1047        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        // existing does not end with '\n' and is non-empty.
1066        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        // No MEMORY.md exists; remove_index_line must succeed silently.
1078        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    // --- delete edge cases --------------------------------------------------
1099
1100    #[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        // Deleting a topic that was never written succeeds.
1113        loader.delete("never-existed").unwrap();
1114    }
1115
1116    // --- validate_slug NUL --------------------------------------------------
1117
1118    #[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    // --- strip_html_comments edge cases -------------------------------------
1128
1129    #[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}