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//! `adrs/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 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
399/// Render a [`TopicDraft`] to on-disk markdown (frontmatter + body).
400fn 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
422/// Escape `"` and `\` for a double-quoted YAML scalar.
423fn 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
437/// Update (or insert) the index line for `draft.name` inside `MEMORY.md`.
438/// Atomic via tmp + rename.
439fn 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
470/// Remove a topic's index line, if present.
471fn 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
498/// Insert-or-replace the index line for `slug`. We match on the
499/// `](<slug>.md)` substring, which is robust against operators tweaking the
500/// title or one-line summary in place.
501fn 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        // Append after the last existing index-style line, otherwise at EOF.
521        let mut insert_idx = out_lines.len();
522        // Insert after the last `- [` bullet line if any exist.
523        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/// Strip every `<!-- … -->` HTML comment (greedy, multi-line) from `body`.
539/// Used by the memory loader before splicing into the system prompt — the
540/// on-disk file is untouched.
541#[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            // find closing -->; if not found, drop the rest.
549            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        // Topic file contains frontmatter + body.
652        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        // Index updated.
658        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        // exactly one entry for foo
683        assert_eq!(index.matches("[foo](foo.md)").count(), 1);
684        assert!(index.contains("new desc"));
685        assert!(!index.contains("old desc"));
686    }
687
688    #[test]
689    fn read_rejects_invalid_type_in_frontmatter() {
690        let tmp = TempDir::new().unwrap();
691        let dir = tmp.path();
692        std::fs::write(dir.join("bad.md"), topic_md("bad", "junk", "desc", "body")).unwrap();
693        let loader = TopicLoader::new(dir.to_path_buf());
694        let err = loader.read("bad").unwrap_err();
695        assert!(matches!(err, MemoryError::InvalidTopic { .. }));
696    }
697
698    #[test]
699    fn read_rejects_missing_required_frontmatter_fields() {
700        let tmp = TempDir::new().unwrap();
701        let dir = tmp.path();
702        std::fs::write(
703            dir.join("incomplete.md"),
704            "---\ndescription: \"no name\"\nmetadata:\n  type: user\n---\n\nbody\n",
705        )
706        .unwrap();
707        let loader = TopicLoader::new(dir.to_path_buf());
708        let err = loader.read("incomplete").unwrap_err();
709        assert!(matches!(err, MemoryError::InvalidTopic { .. }));
710    }
711
712    #[test]
713    fn cross_reference_brackets_preserved_in_body() {
714        let tmp = TempDir::new().unwrap();
715        let dir = tmp.path();
716        let body = "Crosslinks: [[parity-gap-matrix]], [[sprint-mode]].\n".to_string();
717        let loader = TopicLoader::new(dir.to_path_buf());
718        loader
719            .write(&TopicDraft {
720                name: "user-role".to_string(),
721                description: "role".to_string(),
722                kind: TopicKind::User,
723                body: body.clone(),
724            })
725            .unwrap();
726        let topic = loader.read("user-role").unwrap();
727        assert!(topic.body.contains("[[parity-gap-matrix]]"));
728        assert!(topic.body.contains("[[sprint-mode]]"));
729    }
730
731    #[test]
732    fn validate_slug_rejects_path_traversal() {
733        assert!(validate_slug("ok").is_ok());
734        assert!(validate_slug("ok-slug_1").is_ok());
735        assert!(validate_slug("").is_err());
736        assert!(validate_slug("a/b").is_err());
737        assert!(validate_slug("a\\b").is_err());
738        assert!(validate_slug("..").is_err());
739        assert!(validate_slug("a..b").is_err());
740        assert!(validate_slug(".hidden").is_err());
741    }
742
743    #[test]
744    fn strip_html_comments_handles_single_and_multiline() {
745        let single = "hello <!-- inline --> world";
746        assert_eq!(strip_html_comments(single), "hello  world");
747
748        let multi = "before\n<!-- line one\nline two\n-->\nafter";
749        let stripped = strip_html_comments(multi);
750        assert!(stripped.contains("before"));
751        assert!(stripped.contains("after"));
752        assert!(!stripped.contains("line one"));
753        assert!(!stripped.contains("line two"));
754    }
755
756    #[test]
757    fn delete_removes_file_and_index_line() {
758        let tmp = TempDir::new().unwrap();
759        let dir = tmp.path();
760        let loader = TopicLoader::new(dir.to_path_buf());
761        loader
762            .write(&TopicDraft {
763                name: "tmp-topic".to_string(),
764                description: "tmp".to_string(),
765                kind: TopicKind::Project,
766                body: "body".to_string(),
767            })
768            .unwrap();
769        loader.delete("tmp-topic").unwrap();
770        assert!(!dir.join("tmp-topic.md").exists());
771        let index = std::fs::read_to_string(dir.join("MEMORY.md")).unwrap();
772        assert!(!index.contains("tmp-topic.md"));
773    }
774
775    // --- TopicKind ----------------------------------------------------------
776
777    #[test]
778    fn topic_kind_as_str_covers_all_variants() {
779        assert_eq!(TopicKind::User.as_str(), "user");
780        assert_eq!(TopicKind::Feedback.as_str(), "feedback");
781        assert_eq!(TopicKind::Project.as_str(), "project");
782        assert_eq!(TopicKind::Reference.as_str(), "reference");
783    }
784
785    #[test]
786    fn topic_kind_parse_is_case_and_whitespace_insensitive() {
787        assert_eq!(TopicKind::parse("USER"), Some(TopicKind::User));
788        assert_eq!(TopicKind::parse("  Feedback  "), Some(TopicKind::Feedback));
789        assert_eq!(TopicKind::parse("Project"), Some(TopicKind::Project));
790        assert_eq!(TopicKind::parse("rEfErEnCe"), Some(TopicKind::Reference));
791    }
792
793    #[test]
794    fn topic_kind_parse_rejects_unknown_and_empty() {
795        assert_eq!(TopicKind::parse(""), None);
796        assert_eq!(TopicKind::parse("   "), None);
797        assert_eq!(TopicKind::parse("junk"), None);
798    }
799
800    // --- TopicLoader accessors / list edge cases ----------------------------
801
802    #[test]
803    fn loader_dir_returns_managed_directory() {
804        let tmp = TempDir::new().unwrap();
805        let loader = TopicLoader::new(tmp.path().to_path_buf());
806        assert_eq!(loader.dir(), tmp.path());
807    }
808
809    #[test]
810    fn list_on_nonexistent_dir_returns_empty() {
811        let tmp = TempDir::new().unwrap();
812        let missing = tmp.path().join("does-not-exist");
813        let loader = TopicLoader::new(missing);
814        assert!(loader.list().unwrap().is_empty());
815    }
816
817    #[test]
818    fn list_skips_non_md_files_and_subdirectories() {
819        let tmp = TempDir::new().unwrap();
820        let dir = tmp.path();
821        std::fs::write(dir.join("notes.txt"), "not markdown").unwrap();
822        std::fs::create_dir(dir.join("subdir")).unwrap();
823        // A `.md` directory entry must also be ignored (not a file).
824        std::fs::create_dir(dir.join("dir.md")).unwrap();
825        std::fs::write(
826            dir.join("ok.md"),
827            topic_md("ok", "project", "ok desc", "body"),
828        )
829        .unwrap();
830
831        let loader = TopicLoader::new(dir.to_path_buf());
832        let topics = loader.list().unwrap();
833        let names: Vec<_> = topics.iter().map(|t| t.name.as_str()).collect();
834        assert_eq!(names, vec!["ok"]);
835    }
836
837    #[test]
838    fn list_skips_malformed_topic_file() {
839        let tmp = TempDir::new().unwrap();
840        let dir = tmp.path();
841        // Malformed: no frontmatter delimiters at all.
842        std::fs::write(dir.join("broken.md"), "no frontmatter here\n").unwrap();
843        std::fs::write(
844            dir.join("good.md"),
845            topic_md("good", "reference", "good desc", "body"),
846        )
847        .unwrap();
848
849        let loader = TopicLoader::new(dir.to_path_buf());
850        let topics = loader.list().unwrap();
851        let names: Vec<_> = topics.iter().map(|t| t.name.as_str()).collect();
852        assert_eq!(names, vec!["good"]);
853        assert_eq!(topics[0].kind, TopicKind::Reference);
854    }
855
856    #[test]
857    fn list_uses_filename_when_frontmatter_name_mismatches() {
858        let tmp = TempDir::new().unwrap();
859        let dir = tmp.path();
860        // Frontmatter name "wrong" but file stem is "actual-stem".
861        std::fs::write(
862            dir.join("actual-stem.md"),
863            topic_md("wrong", "user", "desc", "body"),
864        )
865        .unwrap();
866
867        let loader = TopicLoader::new(dir.to_path_buf());
868        let topics = loader.list().unwrap();
869        assert_eq!(topics.len(), 1);
870        assert_eq!(topics[0].name, "actual-stem");
871        assert_eq!(topics[0].description, "desc");
872    }
873
874    // --- read error paths ---------------------------------------------------
875
876    #[test]
877    fn read_rejects_invalid_slug() {
878        let tmp = TempDir::new().unwrap();
879        let loader = TopicLoader::new(tmp.path().to_path_buf());
880        let err = loader.read("../escape").unwrap_err();
881        assert!(matches!(err, MemoryError::InvalidSlug { .. }));
882    }
883
884    #[test]
885    fn read_missing_file_is_io_error() {
886        let tmp = TempDir::new().unwrap();
887        let loader = TopicLoader::new(tmp.path().to_path_buf());
888        let err = loader.read("nope").unwrap_err();
889        assert!(matches!(err, MemoryError::Io { .. }));
890    }
891
892    // --- parse_frontmatter edge cases ---------------------------------------
893
894    #[test]
895    fn parse_frontmatter_strips_bom() {
896        let raw = format!(
897            "\u{feff}{}",
898            topic_md("bom", "user", "with bom", "body line")
899        );
900        let path = Path::new("bom.md");
901        let (fm, body) = parse_frontmatter(&raw, path).unwrap();
902        assert_eq!(fm.name, "bom");
903        assert!(body.contains("body line"));
904    }
905
906    #[test]
907    fn parse_frontmatter_rejects_missing_leading_delimiter() {
908        let raw = "name: x\ndescription: y\n---\nbody\n";
909        let err = parse_frontmatter(raw, Path::new("x.md")).unwrap_err();
910        match err {
911            MemoryError::InvalidTopic { reason, .. } => assert!(reason.contains("leading")),
912            other => panic!("unexpected: {other:?}"),
913        }
914    }
915
916    #[test]
917    fn parse_frontmatter_rejects_missing_closing_delimiter() {
918        let raw = "---\nname: x\ndescription: y\nno closing here\n";
919        let err = parse_frontmatter(raw, Path::new("x.md")).unwrap_err();
920        match err {
921            MemoryError::InvalidTopic { reason, .. } => assert!(reason.contains("closing")),
922            other => panic!("unexpected: {other:?}"),
923        }
924    }
925
926    #[test]
927    fn parse_frontmatter_accepts_closing_delimiter_at_eof_without_body() {
928        // Closing `\n---` with no trailing newline and no body.
929        let raw = "---\nname: eof\ndescription: d\nmetadata:\n  type: user\n---";
930        let (fm, body) = parse_frontmatter(raw, Path::new("eof.md")).unwrap();
931        assert_eq!(fm.name, "eof");
932        assert_eq!(body, "");
933    }
934
935    #[test]
936    fn parse_frontmatter_rejects_empty_name() {
937        let raw = "---\nname: \"  \"\ndescription: d\n---\nbody\n";
938        let err = parse_frontmatter(raw, Path::new("x.md")).unwrap_err();
939        match err {
940            MemoryError::InvalidTopic { reason, .. } => assert!(reason.contains("name")),
941            other => panic!("unexpected: {other:?}"),
942        }
943    }
944
945    #[test]
946    fn parse_frontmatter_rejects_empty_description() {
947        let raw = "---\nname: x\ndescription: \"  \"\n---\nbody\n";
948        let err = parse_frontmatter(raw, Path::new("x.md")).unwrap_err();
949        match err {
950            MemoryError::InvalidTopic { reason, .. } => assert!(reason.contains("description")),
951            other => panic!("unexpected: {other:?}"),
952        }
953    }
954
955    #[test]
956    fn parse_frontmatter_rejects_invalid_yaml() {
957        let raw = "---\nname: [unbalanced\ndescription: d\n---\nbody\n";
958        let err = parse_frontmatter(raw, Path::new("x.md")).unwrap_err();
959        match err {
960            MemoryError::InvalidTopic { reason, .. } => assert!(reason.contains("yaml")),
961            other => panic!("unexpected: {other:?}"),
962        }
963    }
964
965    // --- render_topic_file / escape_yaml_string -----------------------------
966
967    #[test]
968    fn render_topic_file_appends_trailing_newline_when_missing() {
969        let draft = TopicDraft {
970            name: "no-nl".to_string(),
971            description: "desc".to_string(),
972            kind: TopicKind::Project,
973            body: "body without newline".to_string(),
974        };
975        let rendered = render_topic_file(&draft);
976        assert!(rendered.ends_with("body without newline\n"));
977        assert!(rendered.contains("type: project"));
978    }
979
980    #[test]
981    fn render_topic_file_preserves_single_trailing_newline() {
982        let draft = TopicDraft {
983            name: "has-nl".to_string(),
984            description: "desc".to_string(),
985            kind: TopicKind::User,
986            body: "body\n".to_string(),
987        };
988        let rendered = render_topic_file(&draft);
989        // Exactly one trailing newline (no double newline appended).
990        assert!(rendered.ends_with("body\n"));
991        assert!(!rendered.ends_with("body\n\n"));
992    }
993
994    #[test]
995    fn escape_yaml_string_escapes_special_chars() {
996        assert_eq!(escape_yaml_string("a\"b"), "a\\\"b");
997        assert_eq!(escape_yaml_string("a\\b"), "a\\\\b");
998        assert_eq!(escape_yaml_string("a\nb"), "a\\nb");
999        assert_eq!(escape_yaml_string("a\rb"), "a\\rb");
1000        assert_eq!(escape_yaml_string("plain"), "plain");
1001    }
1002
1003    #[test]
1004    fn write_then_read_round_trips_description_with_quotes() {
1005        let tmp = TempDir::new().unwrap();
1006        let dir = tmp.path();
1007        let loader = TopicLoader::new(dir.to_path_buf());
1008        loader
1009            .write(&TopicDraft {
1010                name: "quoted".to_string(),
1011                description: "use \"smart\" quotes \\ backslash".to_string(),
1012                kind: TopicKind::Reference,
1013                body: "body".to_string(),
1014            })
1015            .unwrap();
1016        let topic = loader.read("quoted").unwrap();
1017        assert_eq!(topic.description, "use \"smart\" quotes \\ backslash");
1018        assert_eq!(topic.kind, TopicKind::Reference);
1019    }
1020
1021    // --- index handling -----------------------------------------------------
1022
1023    #[test]
1024    fn write_creates_index_with_header_when_none_exists() {
1025        let tmp = TempDir::new().unwrap();
1026        let dir = tmp.path();
1027        let loader = TopicLoader::new(dir.to_path_buf());
1028        loader
1029            .write(&TopicDraft {
1030                name: "first".to_string(),
1031                description: "first desc".to_string(),
1032                kind: TopicKind::User,
1033                body: "body".to_string(),
1034            })
1035            .unwrap();
1036        let index = std::fs::read_to_string(dir.join("MEMORY.md")).unwrap();
1037        assert!(index.starts_with("# Memory index\n\n"));
1038        assert!(index.contains("[first](first.md)"));
1039    }
1040
1041    #[test]
1042    fn write_appends_after_last_bullet_line() {
1043        let tmp = TempDir::new().unwrap();
1044        let dir = tmp.path();
1045        std::fs::write(
1046            dir.join("MEMORY.md"),
1047            "# Memory index\n\n- [aaa](aaa.md) — user: a\n\nTrailing prose paragraph.\n",
1048        )
1049        .unwrap();
1050        let loader = TopicLoader::new(dir.to_path_buf());
1051        loader
1052            .write(&TopicDraft {
1053                name: "bbb".to_string(),
1054                description: "b desc".to_string(),
1055                kind: TopicKind::User,
1056                body: "body".to_string(),
1057            })
1058            .unwrap();
1059        let index = std::fs::read_to_string(dir.join("MEMORY.md")).unwrap();
1060        let lines: Vec<&str> = index.lines().collect();
1061        let aaa_idx = lines.iter().position(|l| l.contains("aaa.md")).unwrap();
1062        let bbb_idx = lines.iter().position(|l| l.contains("bbb.md")).unwrap();
1063        let prose_idx = lines
1064            .iter()
1065            .position(|l| l.contains("Trailing prose"))
1066            .unwrap();
1067        // New bullet inserted right after the last existing bullet, before prose.
1068        assert_eq!(bbb_idx, aaa_idx + 1);
1069        assert!(bbb_idx < prose_idx);
1070    }
1071
1072    #[test]
1073    fn rewrite_with_index_line_appends_at_eof_when_no_bullets() {
1074        let out = rewrite_with_index_line(
1075            "# Memory index\n\nSome prose.\n",
1076            "x",
1077            "- [x](x.md) — user: d",
1078        );
1079        assert!(out.contains("Some prose."));
1080        assert!(out.trim_end().ends_with("- [x](x.md) — user: d"));
1081        assert!(out.ends_with('\n'));
1082    }
1083
1084    #[test]
1085    fn rewrite_with_index_line_adds_trailing_newline_when_existing_lacks_one() {
1086        // existing does not end with '\n' and is non-empty.
1087        let out =
1088            rewrite_with_index_line("- [x](x.md) — user: old", "x", "- [x](x.md) — user: new");
1089        assert!(out.contains("new"));
1090        assert!(!out.contains("old"));
1091        assert!(out.ends_with('\n'));
1092    }
1093
1094    #[test]
1095    fn remove_index_line_on_missing_index_is_ok() {
1096        let tmp = TempDir::new().unwrap();
1097        let dir = tmp.path();
1098        // No MEMORY.md exists; remove_index_line must succeed silently.
1099        remove_index_line(dir, "ghost").unwrap();
1100        assert!(!dir.join("MEMORY.md").exists());
1101    }
1102
1103    #[test]
1104    fn remove_index_line_preserves_other_entries_and_trailing_newline() {
1105        let tmp = TempDir::new().unwrap();
1106        let dir = tmp.path();
1107        std::fs::write(
1108            dir.join("MEMORY.md"),
1109            "# Memory index\n\n- [keep](keep.md) — user: k\n- [drop](drop.md) — user: d\n",
1110        )
1111        .unwrap();
1112        remove_index_line(dir, "drop").unwrap();
1113        let index = std::fs::read_to_string(dir.join("MEMORY.md")).unwrap();
1114        assert!(index.contains("[keep](keep.md)"));
1115        assert!(!index.contains("drop.md"));
1116        assert!(index.ends_with('\n'));
1117    }
1118
1119    // --- delete edge cases --------------------------------------------------
1120
1121    #[test]
1122    fn delete_rejects_invalid_slug() {
1123        let tmp = TempDir::new().unwrap();
1124        let loader = TopicLoader::new(tmp.path().to_path_buf());
1125        let err = loader.delete("a/b").unwrap_err();
1126        assert!(matches!(err, MemoryError::InvalidSlug { .. }));
1127    }
1128
1129    #[test]
1130    fn delete_missing_topic_is_idempotent() {
1131        let tmp = TempDir::new().unwrap();
1132        let loader = TopicLoader::new(tmp.path().to_path_buf());
1133        // Deleting a topic that was never written succeeds.
1134        loader.delete("never-existed").unwrap();
1135    }
1136
1137    // --- validate_slug NUL --------------------------------------------------
1138
1139    #[test]
1140    fn validate_slug_rejects_nul() {
1141        let err = validate_slug("a\0b").unwrap_err();
1142        match err {
1143            MemoryError::InvalidSlug { reason, .. } => assert!(reason.contains("NUL")),
1144            other => panic!("unexpected: {other:?}"),
1145        }
1146    }
1147
1148    // --- strip_html_comments edge cases -------------------------------------
1149
1150    #[test]
1151    fn strip_html_comments_drops_unterminated_comment_tail() {
1152        let input = "keep me <!-- never closed";
1153        let out = strip_html_comments(input);
1154        assert_eq!(out, "keep me ");
1155    }
1156
1157    #[test]
1158    fn strip_html_comments_no_comment_is_identity() {
1159        let input = "plain text with < and > but no comment";
1160        assert_eq!(strip_html_comments(input), input);
1161    }
1162}