Skip to main content

walrus_memory/fs/
mod.rs

1//! Filesystem-based implementation of the Memory trait.
2//!
3//! Keys are mapped to directory paths using `.` as a separator:
4//! `user.goal.abc` → `{base}/user/goal/abc.md`.
5//! File content is raw Markdown — the value itself, no frontmatter.
6//! Reads are always fresh from disk, so manual edits are immediately visible.
7
8use std::{
9    fs, io,
10    path::{Path, PathBuf},
11    sync::Arc,
12};
13use wcore::Memory;
14
15/// Filesystem memory store backed by Markdown files.
16///
17/// `Clone` is cheap — clones share the same base path.
18#[derive(Debug, Clone)]
19pub struct FsMemory {
20    base: Arc<PathBuf>,
21}
22
23impl FsMemory {
24    /// Create a new store rooted at `base`, creating the directory if needed.
25    pub fn new(base: impl Into<PathBuf>) -> io::Result<Self> {
26        let base = base.into();
27        fs::create_dir_all(&base)?;
28        Ok(Self {
29            base: Arc::new(base),
30        })
31    }
32
33    fn key_to_path(&self, key: &str) -> PathBuf {
34        let mut path = (*self.base).clone();
35        let parts: Vec<&str> = key.split('.').collect();
36        for part in &parts {
37            path.push(part);
38        }
39        path.set_extension("md");
40        path
41    }
42
43    fn path_to_key(base: &Path, path: &Path) -> Option<String> {
44        let rel = path.strip_prefix(base).ok()?;
45        let without_ext = rel.with_extension("");
46        let key = without_ext
47            .components()
48            .map(|c| c.as_os_str().to_string_lossy())
49            .collect::<Vec<_>>()
50            .join(".");
51        if key.is_empty() { None } else { Some(key) }
52    }
53
54    fn collect_entries(dir: &Path, base: &Path, out: &mut Vec<(String, String)>) {
55        let Ok(read) = fs::read_dir(dir) else {
56            return;
57        };
58        for entry in read.flatten() {
59            let path = entry.path();
60            if path.is_dir() {
61                Self::collect_entries(&path, base, out);
62            } else if path.extension().and_then(|e| e.to_str()) == Some("md")
63                && let (Some(key), Ok(value)) =
64                    (Self::path_to_key(base, &path), fs::read_to_string(&path))
65            {
66                out.push((key, value));
67            }
68        }
69    }
70}
71
72impl Memory for FsMemory {
73    fn get(&self, key: &str) -> Option<String> {
74        let path = self.key_to_path(key);
75        fs::read_to_string(path).ok()
76    }
77
78    fn entries(&self) -> Vec<(String, String)> {
79        let mut out = Vec::new();
80        Self::collect_entries(&self.base, &self.base, &mut out);
81        out
82    }
83
84    fn set(&self, key: impl Into<String>, value: impl Into<String>) -> Option<String> {
85        let key = key.into();
86        let value = value.into();
87        let path = self.key_to_path(&key);
88
89        // Capture old value for return.
90        let old = fs::read_to_string(&path).ok();
91
92        // Create parent directories.
93        if let Some(parent) = path.parent() {
94            fs::create_dir_all(parent).ok()?;
95        }
96
97        // Atomic write: write to .tmp then rename.
98        let tmp = path.with_extension("md.tmp");
99        fs::write(&tmp, &value).ok()?;
100        fs::rename(&tmp, &path).ok()?;
101
102        old
103    }
104
105    fn remove(&self, key: &str) -> Option<String> {
106        let path = self.key_to_path(key);
107        let old = fs::read_to_string(&path).ok()?;
108        fs::remove_file(&path).ok()?;
109
110        // Remove empty parent directories up to base.
111        let mut current = path.parent();
112        while let Some(dir) = current {
113            if dir == *self.base {
114                break;
115            }
116            if fs::read_dir(dir)
117                .map(|mut d| d.next().is_none())
118                .unwrap_or(false)
119            {
120                fs::remove_dir(dir).ok();
121            } else {
122                break;
123            }
124            current = dir.parent();
125        }
126
127        Some(old)
128    }
129}