Skip to main content

walrus_daemon/hook/system/memory/
entry.rs

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