1use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11
12use async_trait::async_trait;
13use roma_core::safe_resolve;
14use tokio::fs;
15use tracing::{debug, warn};
16
17pub use roma_core::{MemoryError, MemoryLevel, MemoryStore, NullMemoryStore, PatchError};
18
19pub trait MemoryValidator: Send + Sync {
25 fn validate(&self, content: &str) -> Result<(), MemoryError>;
26}
27
28pub struct L1MaxLines {
30 pub max: usize,
31}
32
33impl MemoryValidator for L1MaxLines {
34 fn validate(&self, content: &str) -> Result<(), MemoryError> {
35 let lines = content.lines().count();
36 if lines > self.max {
37 return Err(MemoryError::ValidationFailed {
38 level: MemoryLevel::L1,
39 reason: format!("{lines} lines exceed maximum of {}", self.max),
40 });
41 }
42 Ok(())
43 }
44}
45
46pub struct L2SectionGuard {
48 pub required: Vec<String>,
49}
50
51impl MemoryValidator for L2SectionGuard {
52 fn validate(&self, content: &str) -> Result<(), MemoryError> {
53 for section in &self.required {
54 let header = format!("# {section}");
55 let header2 = format!("## {section}");
56 if !content.contains(&header) && !content.contains(&header2) {
57 return Err(MemoryError::ValidationFailed {
58 level: MemoryLevel::L2,
59 reason: format!("missing required section: {section}"),
60 });
61 }
62 }
63 Ok(())
64 }
65}
66
67pub struct FileMemoryStore {
84 base_dir: PathBuf,
85 validators: HashMap<MemoryLevel, Box<dyn MemoryValidator>>,
86}
87
88impl FileMemoryStore {
89 pub fn new(base_dir: impl Into<PathBuf>) -> Self {
92 Self {
93 base_dir: base_dir.into(),
94 validators: HashMap::new(),
95 }
96 }
97
98 pub fn with_validator(
100 mut self,
101 level: MemoryLevel,
102 validator: Box<dyn MemoryValidator>,
103 ) -> Self {
104 self.validators.insert(level, validator);
105 self
106 }
107
108 fn parse_path(&self, path: &str) -> Result<(MemoryLevel, PathBuf), MemoryError> {
114 let (level, rest) = path
115 .split_once('/')
116 .ok_or_else(|| MemoryError::NotFound(format!("invalid memory path: {path}")))?;
117 let level = match level {
118 "L0" => MemoryLevel::L0,
119 "L1" => MemoryLevel::L1,
120 "L2" => MemoryLevel::L2,
121 "L3" => MemoryLevel::L3,
122 "L4" => MemoryLevel::L4,
123 _ => return Err(MemoryError::NotFound(format!("unknown level: {level}"))),
124 };
125 let relative = PathBuf::from(rest);
126 for comp in relative.components() {
129 match comp {
130 std::path::Component::ParentDir => {
131 return Err(MemoryError::PathDenied(format!(
132 "path traversal rejected: {path}"
133 )));
134 }
135 std::path::Component::RootDir | std::path::Component::Prefix(_) => {
136 return Err(MemoryError::PathDenied(format!(
137 "absolute sub-path rejected: {path}"
138 )));
139 }
140 _ => {}
141 }
142 }
143 Ok((level, relative))
144 }
145
146 async fn safe_fs_path(
150 &self,
151 level: MemoryLevel,
152 relative: &Path,
153 ) -> Result<PathBuf, MemoryError> {
154 let level_dir = self.base_dir.join(level.dir_name());
155 if !level_dir.exists() {
156 fs::create_dir_all(&level_dir).await?;
157 }
158 let resolved = safe_resolve(&level_dir, relative)?;
159 Ok(resolved)
160 }
161}
162
163#[async_trait]
164impl MemoryStore for FileMemoryStore {
165 async fn read(&self, path: &str) -> Result<String, MemoryError> {
166 let (level, relative) = self.parse_path(path)?;
167 let level_dir = self.base_dir.join(level.dir_name());
168 if !level_dir.exists() {
171 return Err(MemoryError::NotFound(path.to_string()));
172 }
173 let full = self.safe_fs_path(level, &relative).await?;
174 fs::read_to_string(&full).await.map_err(|e| {
175 if e.kind() == std::io::ErrorKind::NotFound {
176 MemoryError::NotFound(path.to_string())
177 } else {
178 MemoryError::Io(e)
179 }
180 })
181 }
182
183 async fn write(&self, path: &str, content: &str) -> Result<(), MemoryError> {
184 let (level, relative) = self.parse_path(path)?;
185 self.validate(level, content)?;
186 let full = self.safe_fs_path(level, &relative).await?;
187 if let Some(parent) = full.parent() {
188 fs::create_dir_all(parent).await?;
189 }
190 debug!(path = %full.display(), "writing memory file");
191 fs::write(&full, content).await?;
192 Ok(())
193 }
194
195 async fn patch(&self, path: &str, old: &str, new: &str) -> Result<(), MemoryError> {
196 let content = self.read(path).await?;
197 let count = content.matches(old).count();
198 if count == 0 {
199 return Err(MemoryError::Patch(PatchError::NotFound));
200 }
201 if count > 1 {
202 return Err(MemoryError::Patch(PatchError::NotUnique { count }));
203 }
204 let patched = content.replacen(old, new, 1);
205 self.write(path, &patched).await
206 }
207
208 async fn delete(&self, path: &str) -> Result<(), MemoryError> {
209 let (level, relative) = self.parse_path(path)?;
210 let level_dir = self.base_dir.join(level.dir_name());
211 if !level_dir.exists() {
212 return Ok(());
214 }
215 let full = self.safe_fs_path(level, &relative).await?;
216 match fs::remove_file(&full).await {
217 Ok(()) => {
218 debug!(path = %full.display(), "deleted memory file");
219 Ok(())
220 }
221 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
222 warn!(path = %full.display(), "delete called on non-existent file");
223 Ok(())
224 }
225 Err(e) => Err(MemoryError::Io(e)),
226 }
227 }
228
229 async fn list(&self, level: MemoryLevel) -> Result<Vec<String>, MemoryError> {
230 let dir = self.base_dir.join(level.dir_name());
231 let mut result = Vec::new();
232 let mut stack = vec![(dir, String::new())];
234 while let Some((current_dir, prefix)) = stack.pop() {
235 let mut entries = match fs::read_dir(¤t_dir).await {
236 Ok(rd) => rd,
237 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
238 Err(e) => return Err(MemoryError::Io(e)),
239 };
240 while let Ok(Some(entry)) = entries.next_entry().await {
241 let name = entry.file_name();
242 let name_str = match name.to_str() {
243 Some(s) => s,
244 None => continue,
245 };
246 let path = entry.path();
247 if path.is_dir() {
248 let sub_prefix = if prefix.is_empty() {
249 format!("{}/", name_str)
250 } else {
251 format!("{prefix}{name_str}/")
252 };
253 stack.push((path, sub_prefix));
254 } else {
255 let logical = if prefix.is_empty() {
256 format!("{}/{}", level.dir_name(), name_str)
257 } else {
258 format!("{}/{}{}", level.dir_name(), prefix, name_str)
259 };
260 result.push(logical);
261 }
262 }
263 }
264 result.sort();
265 Ok(result)
266 }
267
268 fn validate(&self, level: MemoryLevel, content: &str) -> Result<(), MemoryError> {
269 if let Some(v) = self.validators.get(&level) {
270 v.validate(content)?;
271 }
272 Ok(())
273 }
274
275 async fn read_root(&self, name: &str) -> Result<String, MemoryError> {
276 if name.contains("..") || name.contains('/') || name.contains('\\') {
278 return Err(MemoryError::PathDenied(format!(
279 "root file name must be plain: {name}"
280 )));
281 }
282 let resolved = safe_resolve(&self.base_dir, Path::new(name))?;
284 fs::read_to_string(&resolved).await.map_err(|e| {
285 if e.kind() == std::io::ErrorKind::NotFound {
286 MemoryError::NotFound(name.to_string())
287 } else {
288 MemoryError::Io(e)
289 }
290 })
291 }
292}
293
294#[async_trait]
304pub trait Forgetter: Send + Sync {
305 async fn maybe_consolidate(
308 &self,
309 store: &Arc<dyn MemoryStore>,
310 threshold: usize,
311 ) -> Result<usize, MemoryError>;
312}
313
314pub struct NullForgetter;
316
317#[async_trait]
318impl Forgetter for NullForgetter {
319 async fn maybe_consolidate(
320 &self,
321 _store: &Arc<dyn MemoryStore>,
322 _threshold: usize,
323 ) -> Result<usize, MemoryError> {
324 Ok(0)
325 }
326}
327
328pub struct SimpleForgetter;
332
333#[async_trait]
334impl Forgetter for SimpleForgetter {
335 async fn maybe_consolidate(
336 &self,
337 store: &Arc<dyn MemoryStore>,
338 threshold: usize,
339 ) -> Result<usize, MemoryError> {
340 let files = store.list(MemoryLevel::L3).await?;
341 if files.len() <= threshold {
342 return Ok(0);
343 }
344
345 let excess = files.len() - threshold;
346 let to_merge = &files[..excess];
347 let mut merged = String::from("# Consolidated Memory\n\n");
348 let mut read_ok: Vec<&str> = Vec::new();
349 for path in to_merge {
350 match store.read(path).await {
351 Ok(content) => {
352 let name = path.rsplit('/').next().unwrap_or(path);
353 merged.push_str(&format!("## {name}\n\n{content}\n\n"));
354 read_ok.push(path);
355 }
356 Err(e) => {
357 warn!(path, error = %e, "failed to read file during consolidation, skipping");
358 }
359 }
360 }
361
362 let n = std::time::SystemTime::now()
363 .duration_since(std::time::UNIX_EPOCH)
364 .unwrap_or_default();
365 let consolidated_path = format!("L3/_consolidated_{}{}.md", n.as_secs(), n.subsec_millis());
366 store.write(&consolidated_path, &merged).await?;
367
368 let mut removed = 0;
369 for path in read_ok {
370 match store.delete(path).await {
371 Ok(()) | Err(MemoryError::NotFound(_)) => removed += 1,
372 Err(e) => {
373 warn!(path, error = %e, "failed to delete consolidated file");
374 }
375 }
376 }
377
378 debug!(
379 consolidated = consolidated_path,
380 removed, "L3 consolidation complete"
381 );
382 Ok(removed)
383 }
384}
385
386#[cfg(test)]
387#[allow(clippy::expect_used, clippy::unwrap_used)]
388mod tests {
389 use super::*;
390 use tempfile::TempDir;
391
392 fn make_store(dir: &TempDir) -> FileMemoryStore {
393 FileMemoryStore::new(dir.path())
394 .with_validator(MemoryLevel::L1, Box::new(L1MaxLines { max: 30 }))
395 .with_validator(
396 MemoryLevel::L2,
397 Box::new(L2SectionGuard {
398 required: vec!["Findings".into()],
399 }),
400 )
401 }
402
403 #[tokio::test]
404 async fn write_and_read_roundtrip() {
405 let dir = TempDir::new().unwrap();
406 let store = make_store(&dir);
407 store.write("L0/rules.md", "be helpful").await.unwrap();
408 let content = store.read("L0/rules.md").await.unwrap();
409 assert_eq!(content, "be helpful");
410 }
411
412 #[tokio::test]
413 async fn read_missing_returns_not_found() {
414 let dir = TempDir::new().unwrap();
415 let store = make_store(&dir);
416 let err = store.read("L0/absent.md").await.unwrap_err();
417 assert!(matches!(err, MemoryError::NotFound(_)));
418 }
419
420 #[tokio::test]
421 async fn patch_unique_replaces_content() {
422 let dir = TempDir::new().unwrap();
423 let store = make_store(&dir);
424 store.write("L0/rules.md", "old value here").await.unwrap();
425 store.patch("L0/rules.md", "old", "new").await.unwrap();
426 assert_eq!(store.read("L0/rules.md").await.unwrap(), "new value here");
427 }
428
429 #[tokio::test]
430 async fn patch_not_unique_returns_error() {
431 let dir = TempDir::new().unwrap();
432 let store = make_store(&dir);
433 store
434 .write("L0/rules.md", "foo and foo again")
435 .await
436 .unwrap();
437 let err = store.patch("L0/rules.md", "foo", "bar").await.unwrap_err();
438 assert!(matches!(
439 err,
440 MemoryError::Patch(PatchError::NotUnique { count: 2 })
441 ));
442 }
443
444 #[tokio::test]
445 async fn patch_not_found_returns_error() {
446 let dir = TempDir::new().unwrap();
447 let store = make_store(&dir);
448 store.write("L0/rules.md", "hello").await.unwrap();
449 let err = store
450 .patch("L0/rules.md", "absent", "new")
451 .await
452 .unwrap_err();
453 assert!(matches!(err, MemoryError::Patch(PatchError::NotFound)));
454 }
455
456 #[tokio::test]
457 async fn delete_removes_file() {
458 let dir = TempDir::new().unwrap();
459 let store = make_store(&dir);
460 store.write("L0/rules.md", "temp").await.unwrap();
461 store.delete("L0/rules.md").await.unwrap();
462 assert!(matches!(
463 store.read("L0/rules.md").await.unwrap_err(),
464 MemoryError::NotFound(_)
465 ));
466 }
467
468 #[tokio::test]
469 async fn delete_nonexistent_is_ok() {
470 let dir = TempDir::new().unwrap();
471 let store = make_store(&dir);
472 store.delete("L0/ghost.md").await.unwrap();
473 }
474
475 #[tokio::test]
476 async fn list_returns_files_in_level() {
477 let dir = TempDir::new().unwrap();
478 let store = make_store(&dir);
479 store.write("L0/rules.md", "r").await.unwrap();
480 store.write("L0/sop.md", "s").await.unwrap();
481 store.write("L1/key.md", "k").await.unwrap();
482 let l0 = store.list(MemoryLevel::L0).await.unwrap();
483 assert_eq!(l0, vec!["L0/rules.md", "L0/sop.md"]);
484 let l1 = store.list(MemoryLevel::L1).await.unwrap();
485 assert_eq!(l1, vec!["L1/key.md"]);
486 let l2 = store.list(MemoryLevel::L2).await.unwrap();
487 assert!(l2.is_empty());
488 }
489
490 #[tokio::test]
491 async fn l1_validator_rejects_excess_lines() {
492 let dir = TempDir::new().unwrap();
493 let store = make_store(&dir);
494 let long = (0..31)
495 .map(|i| format!("line {i}"))
496 .collect::<Vec<_>>()
497 .join("\n");
498 let err = store.write("L1/key.md", &long).await.unwrap_err();
499 assert!(matches!(
500 err,
501 MemoryError::ValidationFailed {
502 level: MemoryLevel::L1,
503 ..
504 }
505 ));
506 }
507
508 #[tokio::test]
509 async fn l1_validator_accepts_at_limit() {
510 let dir = TempDir::new().unwrap();
511 let store = make_store(&dir);
512 let exact = (0..30)
513 .map(|i| format!("line {i}"))
514 .collect::<Vec<_>>()
515 .join("\n");
516 store.write("L1/key.md", &exact).await.unwrap();
517 }
518
519 #[tokio::test]
520 async fn l2_validator_rejects_missing_section() {
521 let dir = TempDir::new().unwrap();
522 let store = make_store(&dir);
523 let err = store
524 .write("L2/session.md", "# No findings here")
525 .await
526 .unwrap_err();
527 assert!(matches!(
528 err,
529 MemoryError::ValidationFailed {
530 level: MemoryLevel::L2,
531 ..
532 }
533 ));
534 }
535
536 #[tokio::test]
537 async fn l2_validator_accepts_with_section() {
538 let dir = TempDir::new().unwrap();
539 let store = make_store(&dir);
540 store
541 .write("L2/session.md", "# Findings\nsome insight")
542 .await
543 .unwrap();
544 }
545
546 #[test]
547 fn l1_max_lines_validator_direct() {
548 let v = L1MaxLines { max: 2 };
549 assert!(v.validate("a\nb").is_ok());
550 assert!(v.validate("a\nb\nc").is_err());
551 }
552
553 #[test]
554 fn l2_section_guard_validator_direct() {
555 let v = L2SectionGuard {
556 required: vec!["Summary".into()],
557 };
558 assert!(v.validate("# Summary\nhello").is_ok());
559 assert!(v.validate("## Summary\nhello").is_ok());
560 assert!(v.validate("# No summary here").is_err());
561 }
562
563 #[tokio::test]
564 async fn path_traversal_is_rejected() {
565 let dir = TempDir::new().unwrap();
566 let store = make_store(&dir);
567 let err = store.read("L1/../../etc/passwd").await.unwrap_err();
568 assert!(matches!(err, MemoryError::PathDenied(_)));
569 let err = store.write("L0/../secret", "data").await.unwrap_err();
570 assert!(matches!(err, MemoryError::PathDenied(_)));
571 }
572
573 #[tokio::test]
574 async fn absolute_subpath_is_rejected() {
575 let dir = TempDir::new().unwrap();
576 let store = make_store(&dir);
577 let err = store.read("L0//etc/passwd").await.unwrap_err();
578 assert!(matches!(err, MemoryError::PathDenied(_)));
580 let err = store.write("L0//tmp/evil", "x").await.unwrap_err();
581 assert!(matches!(err, MemoryError::PathDenied(_)));
582 }
583
584 #[cfg(unix)]
585 #[tokio::test]
586 async fn symlink_escape_is_rejected() {
587 let outside = TempDir::new().unwrap();
591 std::fs::write(outside.path().join("target.md"), "secret").unwrap();
592
593 let dir = TempDir::new().unwrap();
594 let l0 = dir.path().join("L0");
595 std::fs::create_dir_all(&l0).unwrap();
596 std::os::unix::fs::symlink(outside.path().join("target.md"), l0.join("link.md")).unwrap();
597
598 let store = make_store(&dir);
599 let err = store.read("L0/link.md").await.unwrap_err();
600 assert!(
601 matches!(err, MemoryError::PathDenied(_)),
602 "expected PathDenied, got {err:?}"
603 );
604 }
605
606 #[tokio::test]
607 async fn l3_crud_roundtrip() {
608 let dir = TempDir::new().unwrap();
609 let store = FileMemoryStore::new(dir.path());
610
611 store
612 .write("L3/debug_rust_build.md", "# SOP\n1. cargo check")
613 .await
614 .unwrap();
615 let content = store.read("L3/debug_rust_build.md").await.unwrap();
616 assert!(content.contains("cargo check"));
617
618 store
619 .patch("L3/debug_rust_build.md", "cargo check", "cargo clippy")
620 .await
621 .unwrap();
622 let patched = store.read("L3/debug_rust_build.md").await.unwrap();
623 assert!(patched.contains("cargo clippy"));
624
625 store.delete("L3/debug_rust_build.md").await.unwrap();
626 assert!(matches!(
627 store.read("L3/debug_rust_build.md").await.unwrap_err(),
628 MemoryError::NotFound(_)
629 ));
630 }
631
632 #[tokio::test]
633 async fn list_isolation_between_levels() {
634 let dir = TempDir::new().unwrap();
635 let store = FileMemoryStore::new(dir.path());
636
637 store.write("L0/rules.md", "r").await.unwrap();
638 store.write("L3/sop1.md", "s1").await.unwrap();
639 store.write("L3/sop2.md", "s2").await.unwrap();
640
641 let l0 = store.list(MemoryLevel::L0).await.unwrap();
642 let l3 = store.list(MemoryLevel::L3).await.unwrap();
643
644 assert_eq!(l0, vec!["L0/rules.md"]);
645 assert_eq!(l3, vec!["L3/sop1.md", "L3/sop2.md"]);
646 }
647
648 #[tokio::test]
649 async fn write_creates_subdirectories() {
650 let dir = TempDir::new().unwrap();
651 let store = FileMemoryStore::new(dir.path());
652
653 store
654 .write("L3/debugging/rust_build.md", "steps")
655 .await
656 .unwrap();
657
658 let files = store.list(MemoryLevel::L3).await.unwrap();
659 assert_eq!(files, vec!["L3/debugging/rust_build.md"]);
660
661 let content = store.read("L3/debugging/rust_build.md").await.unwrap();
662 assert_eq!(content, "steps");
663 }
664
665 #[tokio::test]
666 async fn read_root_rejects_traversal() {
667 let dir = TempDir::new().unwrap();
668 let store = FileMemoryStore::new(dir.path());
669
670 let err = store.read_root("../etc/passwd").await.unwrap_err();
671 assert!(matches!(err, MemoryError::PathDenied(_)));
672
673 let err = store.read_root("sub/file").await.unwrap_err();
674 assert!(matches!(err, MemoryError::PathDenied(_)));
675 }
676
677 #[tokio::test]
678 async fn read_root_roundtrip() {
679 let dir = TempDir::new().unwrap();
680 std::fs::write(dir.path().join("README.md"), "project info").unwrap();
681
682 let store = FileMemoryStore::new(dir.path());
683 let content = store.read_root("README.md").await.unwrap();
684 assert_eq!(content, "project info");
685 }
686}