Skip to main content

crabtalk_runtime/memory/
entry.rs

1//! Memory entry — frontmatter-based file format for individual memories.
2
3use crate::memory::storage::Storage;
4use anyhow::{Result, bail};
5use std::path::{Path, PathBuf};
6
7/// A single memory entry.
8pub struct MemoryEntry {
9    pub name: String,
10    pub description: String,
11    pub content: String,
12    pub path: PathBuf,
13}
14
15impl MemoryEntry {
16    /// Create a new entry with a computed path under `entries_dir`.
17    pub fn new(name: String, description: String, content: String, entries_dir: &Path) -> Self {
18        let slug = slugify(&name);
19        let path = entries_dir.join(format!("{slug}.md"));
20        Self {
21            name,
22            description,
23            content,
24            path,
25        }
26    }
27
28    /// Parse an entry from its file content and path.
29    pub fn parse(path: PathBuf, raw: &str) -> Result<Self> {
30        let raw = raw.replace("\r\n", "\n");
31        let raw = raw.trim();
32        if !raw.starts_with("---") {
33            bail!("missing frontmatter opening ---");
34        }
35
36        let after_open = &raw[3..];
37        let Some(close_pos) = after_open.find("\n---") else {
38            bail!("missing frontmatter closing ---");
39        };
40
41        let frontmatter = &after_open[..close_pos];
42        let content = after_open[close_pos + 4..].trim().to_owned();
43
44        let mut name = None;
45        let mut description = None;
46
47        for line in frontmatter.lines() {
48            let line = line.trim();
49            if let Some(val) = line.strip_prefix("name:") {
50                name = Some(val.trim().to_owned());
51            } else if let Some(val) = line.strip_prefix("description:") {
52                description = Some(val.trim().to_owned());
53            }
54        }
55
56        let Some(name) = name else {
57            bail!("missing 'name' in frontmatter");
58        };
59        let description = description.unwrap_or_default();
60
61        Ok(Self {
62            name,
63            description,
64            content,
65            path,
66        })
67    }
68
69    /// Serialize to the frontmatter file format.
70    pub fn serialize(&self) -> String {
71        let mut out = String::new();
72        out.push_str("---\n");
73        out.push_str(&format!("name: {}\n", self.name));
74        out.push_str(&format!("description: {}\n", self.description));
75        out.push_str("---\n\n");
76        out.push_str(&self.content);
77        out.push('\n');
78        out
79    }
80
81    /// Write this entry to storage.
82    pub fn save(&self, storage: &dyn Storage) -> Result<()> {
83        storage.write(&self.path, &self.serialize())
84    }
85
86    /// Delete this entry from storage.
87    pub fn delete(&self, storage: &dyn Storage) -> Result<()> {
88        storage.delete(&self.path)
89    }
90
91    /// Text for BM25 scoring — description + content concatenated.
92    pub fn search_text(&self) -> String {
93        format!("{} {}", self.description, self.content)
94    }
95}
96
97/// Convert a name to a filesystem-safe slug.
98pub fn slugify(name: &str) -> String {
99    let mut slug = String::with_capacity(name.len());
100    let mut prev_dash = true;
101
102    for ch in name.chars() {
103        if ch.is_alphanumeric() {
104            for lc in ch.to_lowercase() {
105                slug.push(lc);
106            }
107            prev_dash = false;
108        } else if !prev_dash {
109            slug.push('-');
110            prev_dash = true;
111        }
112    }
113
114    if slug.ends_with('-') {
115        slug.pop();
116    }
117
118    if slug.is_empty() {
119        slug.push_str("entry");
120    }
121
122    slug
123}