1use std::path::{Path, PathBuf};
6use std::time::{SystemTime, UNIX_EPOCH};
7
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "lowercase")]
15pub enum MemoryType {
16 User,
17 Feedback,
18 Project,
19 Reference,
20}
21
22impl MemoryType {
23 pub fn from_str(s: &str) -> Option<Self> {
24 match s.to_lowercase().as_str() {
25 "user" => Some(Self::User),
26 "feedback" => Some(Self::Feedback),
27 "project" => Some(Self::Project),
28 "reference" => Some(Self::Reference),
29 _ => None,
30 }
31 }
32}
33
34#[derive(Debug, Clone)]
36pub struct MemoryFileMeta {
37 pub filename: String,
38 pub path: PathBuf,
39 pub name: Option<String>,
40 pub description: Option<String>,
41 pub memory_type: Option<MemoryType>,
42 pub modified_secs: u64,
43}
44
45#[derive(Debug, Clone)]
47pub struct MemoryFile {
48 pub meta: MemoryFileMeta,
49 pub content: String,
50}
51
52pub struct MemoryIndex {
54 pub content: String,
55 pub truncated: bool,
56 pub total_lines: usize,
57}
58
59const MAX_MEMORY_FILES: usize = 200;
62const MAX_INDEX_LINES: usize = 200;
63const MAX_INDEX_BYTES: usize = 25_000;
64
65pub fn sanitize_path_component(path: &str) -> String {
70 path.chars()
71 .map(|c| {
72 if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
73 c
74 } else {
75 '_'
76 }
77 })
78 .collect()
79}
80
81pub fn auto_memory_path(project_root: &Path) -> PathBuf {
84 if let Ok(override_path) = std::env::var("CERSEI_MEMORY_PATH_OVERRIDE") {
86 return PathBuf::from(override_path);
87 }
88
89 let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
90 let sanitized = sanitize_path_component(&project_root.display().to_string());
91 home.join(".claude")
92 .join("projects")
93 .join(sanitized)
94 .join("memory")
95}
96
97pub fn ensure_memory_dir_exists(dir: &Path) {
99 if let Err(e) = std::fs::create_dir_all(dir) {
100 tracing::debug!("Failed to create memory dir {}: {}", dir.display(), e);
101 }
102}
103
104fn parse_frontmatter_quick(content: &str) -> (Option<String>, Option<String>, Option<MemoryType>) {
109 let mut name = None;
110 let mut description = None;
111 let mut memory_type = None;
112
113 if !content.starts_with("---") {
114 return (name, description, memory_type);
115 }
116
117 let mut in_frontmatter = false;
118 for (i, line) in content.lines().enumerate() {
119 if i > 30 {
120 break;
121 }
122 if i == 0 && line.trim() == "---" {
123 in_frontmatter = true;
124 continue;
125 }
126 if in_frontmatter && line.trim() == "---" {
127 break;
128 }
129 if !in_frontmatter {
130 continue;
131 }
132
133 if let Some(colon) = line.find(':') {
134 let key = line[..colon].trim().to_lowercase();
135 let value = line[colon + 1..].trim().to_string();
136 match key.as_str() {
137 "name" => name = Some(value),
138 "description" => description = Some(value),
139 "type" => memory_type = MemoryType::from_str(&value),
140 _ => {}
141 }
142 }
143 }
144
145 (name, description, memory_type)
146}
147
148pub fn scan_memory_dir(dir: &Path) -> Vec<MemoryFileMeta> {
152 let mut results: Vec<MemoryFileMeta> = Vec::new();
153
154 let _walker = match std::fs::read_dir(dir) {
155 Ok(w) => w,
156 Err(_) => return results,
157 };
158
159 scan_dir_recursive(dir, dir, &mut results);
161
162 results.sort_by(|a, b| b.modified_secs.cmp(&a.modified_secs));
164
165 results.truncate(MAX_MEMORY_FILES);
167 results
168}
169
170fn scan_dir_recursive(base: &Path, dir: &Path, results: &mut Vec<MemoryFileMeta>) {
171 let entries = match std::fs::read_dir(dir) {
172 Ok(e) => e,
173 Err(_) => return,
174 };
175
176 for entry in entries.flatten() {
177 let path = entry.path();
178
179 if path.is_dir() {
180 scan_dir_recursive(base, &path, results);
181 continue;
182 }
183
184 if path.extension().and_then(|e| e.to_str()) != Some("md") {
185 continue;
186 }
187
188 if path.file_name().and_then(|n| n.to_str()) == Some("MEMORY.md") {
190 continue;
191 }
192
193 let filename = path
194 .strip_prefix(base)
195 .unwrap_or(&path)
196 .display()
197 .to_string();
198
199 let modified_secs = std::fs::metadata(&path)
200 .and_then(|m| m.modified())
201 .ok()
202 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
203 .map(|d| d.as_secs())
204 .unwrap_or(0);
205
206 let content = std::fs::read_to_string(&path).unwrap_or_default();
208 let (name, description, memory_type) = parse_frontmatter_quick(&content);
209
210 results.push(MemoryFileMeta {
211 filename,
212 path,
213 name,
214 description,
215 memory_type,
216 modified_secs,
217 });
218 }
219}
220
221pub fn load_memory_index(memory_dir: &Path) -> Option<MemoryIndex> {
226 let index_path = memory_dir.join("MEMORY.md");
227 let content = std::fs::read_to_string(&index_path).ok()?;
228
229 if content.trim().is_empty() {
230 return None;
231 }
232
233 let lines: Vec<&str> = content.lines().collect();
234 let total_lines = lines.len();
235 let truncated = total_lines > MAX_INDEX_LINES || content.len() > MAX_INDEX_BYTES;
236
237 let output = if truncated {
238 let mut result: String = lines[..MAX_INDEX_LINES.min(total_lines)].join("\n");
239 if result.len() > MAX_INDEX_BYTES {
240 result.truncate(MAX_INDEX_BYTES);
241 }
242 result.push_str(&format!(
243 "\n\n<!-- MEMORY.md truncated: {} total lines, showing {} -->",
244 total_lines,
245 MAX_INDEX_LINES.min(total_lines)
246 ));
247 result
248 } else {
249 content
250 };
251
252 Some(MemoryIndex {
253 content: output,
254 truncated,
255 total_lines,
256 })
257}
258
259pub fn build_memory_prompt_content(memory_dir: &Path) -> String {
261 let index = load_memory_index(memory_dir);
262 match index {
263 Some(idx) => idx.content,
264 None => String::new(),
265 }
266}
267
268pub fn memory_age_days(modified_secs: u64) -> u64 {
272 let now = SystemTime::now()
273 .duration_since(UNIX_EPOCH)
274 .map(|d| d.as_secs())
275 .unwrap_or(0);
276 if now > modified_secs {
277 (now - modified_secs) / 86400
278 } else {
279 0
280 }
281}
282
283pub fn memory_age_text(modified_secs: u64) -> String {
285 let days = memory_age_days(modified_secs);
286 match days {
287 0 => "today".to_string(),
288 1 => "yesterday".to_string(),
289 d => format!("{} days ago", d),
290 }
291}
292
293pub fn memory_freshness_text(modified_secs: u64) -> Option<String> {
295 let days = memory_age_days(modified_secs);
296 if days > 1 {
297 Some(format!(
298 "This memory was last updated {} — verify it's still current before acting on it.",
299 memory_age_text(modified_secs)
300 ))
301 } else {
302 None
303 }
304}
305
306pub fn load_memory_file(path: &Path) -> Option<MemoryFile> {
310 let content = std::fs::read_to_string(path).ok()?;
311 let (name, description, memory_type) = parse_frontmatter_quick(&content);
312
313 let modified_secs = std::fs::metadata(path)
314 .and_then(|m| m.modified())
315 .ok()
316 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
317 .map(|d| d.as_secs())
318 .unwrap_or(0);
319
320 let body = crate::strip_frontmatter(&content);
322
323 Some(MemoryFile {
324 meta: MemoryFileMeta {
325 filename: path.file_name()?.to_str()?.to_string(),
326 path: path.to_path_buf(),
327 name,
328 description,
329 memory_type,
330 modified_secs,
331 },
332 content: body,
333 })
334}
335
336#[cfg(test)]
339mod tests {
340 use super::*;
341
342 fn create_memory_file(dir: &Path, name: &str, content: &str) {
343 std::fs::write(dir.join(name), content).unwrap();
344 }
345
346 #[test]
347 fn test_scan_memory_dir() {
348 let tmp = tempfile::tempdir().unwrap();
349 let mem_dir = tmp.path();
350
351 create_memory_file(mem_dir, "user_role.md", "---\nname: User Role\ndescription: Developer preferences\ntype: user\n---\n\nI prefer Rust.");
352 create_memory_file(
353 mem_dir,
354 "project_arch.md",
355 "---\nname: Architecture\ntype: project\n---\n\nMicroservices.",
356 );
357 create_memory_file(
358 mem_dir,
359 "feedback_style.md",
360 "---\ntype: feedback\n---\n\nBe concise.",
361 );
362 create_memory_file(
363 mem_dir,
364 "MEMORY.md",
365 "- [User Role](user_role.md)\n- [Architecture](project_arch.md)",
366 );
367 create_memory_file(mem_dir, "no_frontmatter.md", "Just plain content.");
368
369 let metas = scan_memory_dir(mem_dir);
370 assert_eq!(metas.len(), 4); assert!(metas.iter().all(|m| m.filename != "MEMORY.md"));
372
373 let user = metas.iter().find(|m| m.filename == "user_role.md").unwrap();
375 assert_eq!(user.name.as_deref(), Some("User Role"));
376 assert_eq!(user.description.as_deref(), Some("Developer preferences"));
377 assert_eq!(user.memory_type, Some(MemoryType::User));
378
379 let plain = metas
381 .iter()
382 .find(|m| m.filename == "no_frontmatter.md")
383 .unwrap();
384 assert!(plain.name.is_none());
385 }
386
387 #[test]
388 fn test_load_memory_index() {
389 let tmp = tempfile::tempdir().unwrap();
390 std::fs::write(
391 tmp.path().join("MEMORY.md"),
392 "- [Test](test.md) — hook\n".repeat(10),
393 )
394 .unwrap();
395
396 let index = load_memory_index(tmp.path());
397 assert!(index.is_some());
398 let index = index.unwrap();
399 assert!(!index.truncated);
400 assert_eq!(index.total_lines, 10);
401 }
402
403 #[test]
404 fn test_load_memory_index_truncation() {
405 let tmp = tempfile::tempdir().unwrap();
406 let content = "- line\n".repeat(300);
407 std::fs::write(tmp.path().join("MEMORY.md"), content).unwrap();
408
409 let index = load_memory_index(tmp.path());
410 assert!(index.is_some());
411 let index = index.unwrap();
412 assert!(index.truncated);
413 assert!(index.content.contains("truncated"));
414 }
415
416 #[test]
417 fn test_load_memory_index_missing() {
418 let tmp = tempfile::tempdir().unwrap();
419 assert!(load_memory_index(tmp.path()).is_none());
420 }
421
422 #[test]
423 fn test_sanitize_path_component() {
424 assert_eq!(
425 sanitize_path_component("/Users/foo/project"),
426 "_Users_foo_project"
427 );
428 assert_eq!(sanitize_path_component("simple-name"), "simple-name");
429 assert_eq!(sanitize_path_component("a/b:c"), "a_b_c");
430 }
431
432 #[test]
433 fn test_memory_age() {
434 let now = SystemTime::now()
435 .duration_since(UNIX_EPOCH)
436 .unwrap()
437 .as_secs();
438 assert_eq!(memory_age_days(now), 0);
439 assert_eq!(memory_age_text(now), "today");
440 assert_eq!(memory_age_text(now - 86400), "yesterday");
441 assert_eq!(memory_age_text(now - 86400 * 5), "5 days ago");
442 }
443
444 #[test]
445 fn test_freshness_warning() {
446 let now = SystemTime::now()
447 .duration_since(UNIX_EPOCH)
448 .unwrap()
449 .as_secs();
450 assert!(memory_freshness_text(now).is_none()); assert!(memory_freshness_text(now - 86400 * 3).is_some()); }
453
454 #[test]
455 fn test_auto_memory_path() {
456 let path = auto_memory_path(Path::new("/Users/test/myproject"));
457 assert!(path.to_str().unwrap().contains("memory"));
458 assert!(path.to_str().unwrap().contains(".claude"));
459 }
460
461 #[test]
462 fn test_load_memory_file() {
463 let tmp = tempfile::tempdir().unwrap();
464 create_memory_file(
465 tmp.path(),
466 "test.md",
467 "---\nname: Test\ntype: user\n---\n\nContent here.",
468 );
469
470 let file = load_memory_file(&tmp.path().join("test.md"));
471 assert!(file.is_some());
472 let file = file.unwrap();
473 assert_eq!(file.meta.name.as_deref(), Some("Test"));
474 assert!(file.content.contains("Content here"));
475 assert!(!file.content.contains("name: Test")); }
477
478 #[test]
479 fn test_build_memory_prompt() {
480 let tmp = tempfile::tempdir().unwrap();
481 std::fs::write(
482 tmp.path().join("MEMORY.md"),
483 "- [Role](role.md) — my role\n- [Project](proj.md) — the project",
484 )
485 .unwrap();
486
487 let content = build_memory_prompt_content(tmp.path());
488 assert!(content.contains("Role"));
489 assert!(content.contains("Project"));
490 }
491}