Skip to main content

rho_core/
memories.rs

1use std::path::{Path, PathBuf};
2
3#[derive(Debug, Clone)]
4pub struct MemoryMetadata {
5    pub name: String,
6    pub description: String,
7    pub tags: Vec<String>,
8    pub path: PathBuf,
9}
10
11/// Parse YAML frontmatter, extracting `name`, `description`, and `tags`.
12fn parse_frontmatter(content: &str) -> Option<(String, String, Vec<String>)> {
13    let content = content.trim_start();
14    if !content.starts_with("---") {
15        return None;
16    }
17    let after_first = &content[3..];
18    let end = after_first.find("\n---")?;
19    let block = &after_first[..end];
20
21    let mut name = None;
22    let mut description = None;
23    let mut tags = Vec::new();
24
25    for line in block.lines() {
26        if let Some(val) = line.strip_prefix("name:") {
27            name = Some(val.trim().to_string());
28        } else if let Some(val) = line.strip_prefix("description:") {
29            description = Some(val.trim().to_string());
30        } else if let Some(val) = line.strip_prefix("tags:") {
31            // Parse inline YAML list: [tag1, tag2, tag3]
32            let val = val.trim();
33            if val.starts_with('[') && val.ends_with(']') {
34                tags = val[1..val.len() - 1]
35                    .split(',')
36                    .map(|s| s.trim().to_string())
37                    .filter(|s| !s.is_empty())
38                    .collect();
39            }
40        }
41    }
42
43    Some((name?, description?, tags))
44}
45
46/// Discover memories in the given directories.
47///
48/// Each `.md` file in a memory directory is a memory.
49/// The file stem must match the `name` field in frontmatter.
50pub fn discover_memories(dirs: &[PathBuf]) -> Vec<MemoryMetadata> {
51    let mut memories = Vec::new();
52    for dir in dirs {
53        if let Ok(entries) = std::fs::read_dir(dir) {
54            for entry in entries.flatten() {
55                let path = entry.path();
56                if !path.is_file() {
57                    continue;
58                }
59                let ext = path.extension().and_then(|e| e.to_str());
60                if ext != Some("md") {
61                    continue;
62                }
63                let Ok(content) = std::fs::read_to_string(&path) else {
64                    continue;
65                };
66                let Some((name, description, tags)) = parse_frontmatter(&content) else {
67                    continue;
68                };
69                let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
70                if name != stem {
71                    continue;
72                }
73                memories.push(MemoryMetadata {
74                    name,
75                    description,
76                    tags,
77                    path: std::fs::canonicalize(&path).unwrap_or(path),
78                });
79            }
80        }
81    }
82    memories.sort_by(|a, b| a.name.cmp(&b.name));
83    memories
84}
85
86/// Build the default memory discovery directories.
87///
88/// Project-local: `.rho/memories/`
89/// Global: `~/.rho/memories/`
90pub fn default_memory_dirs(cwd: &Path) -> Vec<PathBuf> {
91    let mut dirs = vec![cwd.join(".rho/memories")];
92    if let Some(home) = dirs::home_dir() {
93        dirs.push(home.join(".rho/memories"));
94    }
95    dirs
96}
97
98/// Format discovered memories as an XML block for system prompt injection.
99pub fn format_memories_prompt(memories: &[MemoryMetadata]) -> String {
100    if memories.is_empty() {
101        return String::new();
102    }
103    let mut out = String::from("<available_memories>\n");
104    for mem in memories {
105        out.push_str("  <memory>\n");
106        out.push_str(&format!("    <name>{}</name>\n", mem.name));
107        out.push_str(&format!(
108            "    <description>{}</description>\n",
109            mem.description
110        ));
111        if !mem.tags.is_empty() {
112            out.push_str(&format!("    <tags>{}</tags>\n", mem.tags.join(", ")));
113        }
114        out.push_str("  </memory>\n");
115    }
116    out.push_str("</available_memories>\n");
117    out.push_str(
118        "To invoke a memory, the user types /memory-name. \
119         To create a new memory, write a .md file to .rho/memories/ with frontmatter (name, description, tags) and body content.",
120    );
121    out
122}
123
124/// Load the content body of a memory (strips frontmatter).
125pub fn load_memory_content(memory: &MemoryMetadata) -> Option<String> {
126    let content = std::fs::read_to_string(&memory.path).ok()?;
127    let trimmed = content.trim_start();
128    if !trimmed.starts_with("---") {
129        return Some(content);
130    }
131    let after_first = &trimmed[3..];
132    let end = after_first.find("\n---")?;
133    let body_start = 3 + end + 4; // "---" + frontmatter + "\n---"
134    let body = trimmed[body_start..].trim_start_matches('\n');
135    Some(body.to_string())
136}
137
138/// Search memories by case-insensitive substring match on name, description, and tags.
139pub fn search_memories<'a>(
140    memories: &'a [MemoryMetadata],
141    query: &str,
142) -> Vec<&'a MemoryMetadata> {
143    let query_lower = query.to_lowercase();
144    memories
145        .iter()
146        .filter(|m| {
147            m.name.to_lowercase().contains(&query_lower)
148                || m.description.to_lowercase().contains(&query_lower)
149                || m.tags
150                    .iter()
151                    .any(|t| t.to_lowercase().contains(&query_lower))
152        })
153        .collect()
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use std::fs;
160
161    #[test]
162    fn parse_frontmatter_basic() {
163        let content =
164            "---\nname: rust-testing\ndescription: Testing patterns\ntags: [rust, testing]\n---\nBody.";
165        let (name, desc, tags) = parse_frontmatter(content).unwrap();
166        assert_eq!(name, "rust-testing");
167        assert_eq!(desc, "Testing patterns");
168        assert_eq!(tags, vec!["rust", "testing"]);
169    }
170
171    #[test]
172    fn parse_frontmatter_no_tags() {
173        let content = "---\nname: simple\ndescription: A simple memory\n---\nContent.";
174        let (name, desc, tags) = parse_frontmatter(content).unwrap();
175        assert_eq!(name, "simple");
176        assert_eq!(desc, "A simple memory");
177        assert!(tags.is_empty());
178    }
179
180    #[test]
181    fn parse_frontmatter_missing_fields() {
182        assert!(parse_frontmatter("---\nname: hello\n---\n").is_none());
183        assert!(parse_frontmatter("---\ndescription: x\n---\n").is_none());
184    }
185
186    #[test]
187    fn parse_frontmatter_no_markers() {
188        assert!(parse_frontmatter("name: hello\ndescription: x").is_none());
189    }
190
191    #[test]
192    fn discover_memories_from_dir() {
193        let tmp = tempfile::tempdir().unwrap();
194        fs::write(
195            tmp.path().join("my-memory.md"),
196            "---\nname: my-memory\ndescription: Test memory\ntags: [test]\n---\nContent here.",
197        )
198        .unwrap();
199
200        let memories = discover_memories(&[tmp.path().to_path_buf()]);
201        assert_eq!(memories.len(), 1);
202        assert_eq!(memories[0].name, "my-memory");
203        assert_eq!(memories[0].description, "Test memory");
204        assert_eq!(memories[0].tags, vec!["test"]);
205    }
206
207    #[test]
208    fn discover_memories_name_mismatch() {
209        let tmp = tempfile::tempdir().unwrap();
210        fs::write(
211            tmp.path().join("actual-file.md"),
212            "---\nname: wrong-name\ndescription: Oops\n---\n",
213        )
214        .unwrap();
215
216        let memories = discover_memories(&[tmp.path().to_path_buf()]);
217        assert!(memories.is_empty());
218    }
219
220    #[test]
221    fn discover_memories_missing_dir() {
222        let memories = discover_memories(&[PathBuf::from("/nonexistent/memories")]);
223        assert!(memories.is_empty());
224    }
225
226    #[test]
227    fn discover_memories_skips_non_md() {
228        let tmp = tempfile::tempdir().unwrap();
229        fs::write(
230            tmp.path().join("notes.txt"),
231            "---\nname: notes\ndescription: Notes\n---\nNotes.",
232        )
233        .unwrap();
234
235        let memories = discover_memories(&[tmp.path().to_path_buf()]);
236        assert!(memories.is_empty());
237    }
238
239    #[test]
240    fn format_empty_memories() {
241        assert_eq!(format_memories_prompt(&[]), "");
242    }
243
244    #[test]
245    fn format_memories_with_tags() {
246        let memories = vec![MemoryMetadata {
247            name: "rust-testing".into(),
248            description: "Testing patterns".into(),
249            tags: vec!["rust".into(), "testing".into()],
250            path: PathBuf::from("/tmp/memories/rust-testing.md"),
251        }];
252        let xml = format_memories_prompt(&memories);
253        assert!(xml.contains("<available_memories>"));
254        assert!(xml.contains("<name>rust-testing</name>"));
255        assert!(xml.contains("<tags>rust, testing</tags>"));
256        assert!(xml.contains("</available_memories>"));
257    }
258
259    #[test]
260    fn load_memory_content_strips_frontmatter() {
261        let tmp = tempfile::tempdir().unwrap();
262        let path = tmp.path().join("test.md");
263        fs::write(
264            &path,
265            "---\nname: test\ndescription: Test\ntags: [test]\n---\nActual content here.",
266        )
267        .unwrap();
268
269        let mem = MemoryMetadata {
270            name: "test".into(),
271            description: "Test".into(),
272            tags: vec!["test".into()],
273            path,
274        };
275        let content = load_memory_content(&mem).unwrap();
276        assert_eq!(content, "Actual content here.");
277    }
278
279    #[test]
280    fn search_memories_by_name() {
281        let memories = vec![
282            MemoryMetadata {
283                name: "rust-testing".into(),
284                description: "Testing".into(),
285                tags: vec![],
286                path: PathBuf::new(),
287            },
288            MemoryMetadata {
289                name: "python-debugging".into(),
290                description: "Debugging".into(),
291                tags: vec!["python".into()],
292                path: PathBuf::new(),
293            },
294        ];
295        let results = search_memories(&memories, "rust");
296        assert_eq!(results.len(), 1);
297        assert_eq!(results[0].name, "rust-testing");
298    }
299
300    #[test]
301    fn search_memories_by_tag() {
302        let memories = vec![MemoryMetadata {
303            name: "something".into(),
304            description: "Other".into(),
305            tags: vec!["rust".into(), "async".into()],
306            path: PathBuf::new(),
307        }];
308        let results = search_memories(&memories, "async");
309        assert_eq!(results.len(), 1);
310    }
311
312    #[test]
313    fn search_memories_case_insensitive() {
314        let memories = vec![MemoryMetadata {
315            name: "RustTesting".into(),
316            description: "Testing".into(),
317            tags: vec![],
318            path: PathBuf::new(),
319        }];
320        let results = search_memories(&memories, "rusttesting");
321        assert_eq!(results.len(), 1);
322    }
323}