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
11fn 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 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
46pub 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
86pub 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
98pub 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
124pub 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; let body = trimmed[body_start..].trim_start_matches('\n');
135 Some(body.to_string())
136}
137
138pub 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}