Skip to main content

walrus_memory/fs/
mod.rs

1//! Filesystem-based implementation of the Memory trait using TOML files.
2//!
3//! The key scheme splits on `.`: the first segment is the TOML filename, and
4//! the remaining segments form a nested TOML key path. For example:
5//!
6//! - `user.name`         → `{base}/user.toml`, key `name`
7//! - `user.goal.coding`  → `{base}/user.toml`, nested `[goal]` → `coding`
8//! - `soul.relationship` → `{base}/soul.toml`, key `relationship`
9//!
10//! Files are format-preserving via `toml_edit`. Reads are always fresh from
11//! disk, so manual edits are immediately visible.
12
13use std::{
14    fs, io,
15    path::{Path, PathBuf},
16    sync::Arc,
17};
18use toml_edit::{DocumentMut, Item, Table, value};
19use wcore::Memory;
20
21/// Filesystem memory store backed by per-namespace TOML files.
22///
23/// `Clone` is cheap — clones share the same base path.
24#[derive(Debug, Clone)]
25pub struct FsMemory {
26    base: Arc<PathBuf>,
27}
28
29impl FsMemory {
30    /// Create a new store rooted at `base`, creating the directory if needed.
31    pub fn new(base: impl Into<PathBuf>) -> io::Result<Self> {
32        let base = base.into();
33        fs::create_dir_all(&base)?;
34        Ok(Self {
35            base: Arc::new(base),
36        })
37    }
38
39    /// Path to `{base}/{ns}.toml`.
40    fn file_path(&self, ns: &str) -> PathBuf {
41        self.base.join(format!("{ns}.toml"))
42    }
43
44    /// Split `key` into `(namespace, key_segments)`.
45    ///
46    /// Returns `None` if the key has fewer than 2 segments.
47    fn split_key(key: &str) -> Option<(&str, Vec<&str>)> {
48        let (ns, rest) = key.split_once('.')?;
49        let segs: Vec<&str> = rest.split('.').collect();
50        if ns.is_empty() || segs.iter().any(|s| s.is_empty()) {
51            return None;
52        }
53        Some((ns, segs))
54    }
55
56    /// Load a TOML file, returning an empty document if the file does not exist.
57    fn load(path: &Path) -> DocumentMut {
58        fs::read_to_string(path)
59            .ok()
60            .and_then(|s| s.parse::<DocumentMut>().ok())
61            .unwrap_or_default()
62    }
63
64    /// Atomically write a TOML document to `path` via a `.tmp` file + rename.
65    fn save(path: &Path, doc: &DocumentMut) {
66        let tmp = path.with_extension("toml.tmp");
67        if fs::write(&tmp, doc.to_string()).is_ok() {
68            let _ = fs::rename(&tmp, path);
69        }
70    }
71
72    /// Walk `item` through `segs` and return the string value at the leaf.
73    fn get_nested<'a>(item: &'a Item, segs: &[&str]) -> Option<&'a str> {
74        if segs.is_empty() {
75            return item.as_str();
76        }
77        Self::get_nested(&item[segs[0]], &segs[1..])
78    }
79
80    /// Recursively collect all leaf string values from a TOML table, building
81    /// full key paths by prepending `prefix`.
82    fn collect_leaves(table: &Table, prefix: &str, out: &mut Vec<(String, String)>) {
83        for (k, v) in table {
84            let full_key = if prefix.is_empty() {
85                k.to_owned()
86            } else {
87                format!("{prefix}.{k}")
88            };
89            match v {
90                Item::Value(toml_edit::Value::String(s)) => {
91                    out.push((full_key, s.value().clone()));
92                }
93                Item::Table(t) => {
94                    Self::collect_leaves(t, &full_key, out);
95                }
96                _ => {}
97            }
98        }
99    }
100
101    /// Ensure all intermediate tables along `segs[..segs.len()-1]` exist in `doc`,
102    /// then insert `value` at the leaf `segs[last]`. Returns the previous string
103    /// value at that path if one existed.
104    fn insert_nested(doc: &mut DocumentMut, segs: &[&str], val: &str) -> Option<String> {
105        // Walk/create intermediate tables.
106        let mut table: *mut Table = doc.as_table_mut();
107        for &seg in &segs[..segs.len() - 1] {
108            // Safety: we hold &mut doc for the duration of this function.
109            let t = unsafe { &mut *table };
110            if !t.contains_key(seg) {
111                t.insert(seg, Item::Table(Table::new()));
112            }
113            table = match unsafe { &mut *table }.get_mut(seg) {
114                Some(Item::Table(t)) => t as *mut Table,
115                _ => return None,
116            };
117        }
118        let leaf = segs[segs.len() - 1];
119        let t = unsafe { &mut *table };
120        let old = t.get(leaf).and_then(|i| i.as_str()).map(ToOwned::to_owned);
121        t.insert(leaf, value(val));
122        old
123    }
124
125    /// Remove the leaf at `segs` from `doc`. Returns the previous value if any.
126    /// Prunes empty parent tables bottom-up.
127    fn remove_nested(doc: &mut DocumentMut, segs: &[&str]) -> Option<String> {
128        if segs.len() == 1 {
129            return doc
130                .remove(segs[0])
131                .and_then(|i| i.into_value().ok())
132                .and_then(|v| {
133                    if let toml_edit::Value::String(s) = v {
134                        Some(s.into_value())
135                    } else {
136                        None
137                    }
138                });
139        }
140        // For nested paths, rebuild the path and prune after removal.
141        // Walk to the parent table, remove leaf, then prune empty tables.
142        Self::remove_nested_recursive(doc.as_table_mut(), segs)
143    }
144
145    fn remove_nested_recursive(table: &mut Table, segs: &[&str]) -> Option<String> {
146        if segs.len() == 1 {
147            return table
148                .remove(segs[0])
149                .and_then(|i| i.into_value().ok())
150                .and_then(|v| {
151                    if let toml_edit::Value::String(s) = v {
152                        Some(s.into_value())
153                    } else {
154                        None
155                    }
156                });
157        }
158        let child = table.get_mut(segs[0])?;
159        let child_table = child.as_table_mut()?;
160        let old = Self::remove_nested_recursive(child_table, &segs[1..]);
161        if old.is_some() && child_table.is_empty() {
162            table.remove(segs[0]);
163        }
164        old
165    }
166}
167
168impl Memory for FsMemory {
169    fn get(&self, key: &str) -> Option<String> {
170        let (ns, segs) = Self::split_key(key)?;
171        let doc = Self::load(&self.file_path(ns));
172        Self::get_nested(doc.as_item(), &segs).map(ToOwned::to_owned)
173    }
174
175    fn entries(&self) -> Vec<(String, String)> {
176        let Ok(read_dir) = fs::read_dir(&*self.base) else {
177            return Vec::new();
178        };
179        let mut out = Vec::new();
180        for entry in read_dir.flatten() {
181            let path = entry.path();
182            if path.extension().and_then(|e| e.to_str()) != Some("toml") {
183                continue;
184            }
185            let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
186                continue;
187            };
188            let doc = Self::load(&path);
189            Self::collect_leaves(doc.as_table(), stem, &mut out);
190        }
191        out
192    }
193
194    fn set(&self, key: impl Into<String>, value: impl Into<String>) -> Option<String> {
195        let key = key.into();
196        let value = value.into();
197        let (ns, segs) = Self::split_key(&key)?;
198        let path = self.file_path(ns);
199        let mut doc = Self::load(&path);
200        let old = Self::insert_nested(&mut doc, &segs, &value);
201        Self::save(&path, &doc);
202        old
203    }
204
205    fn remove(&self, key: &str) -> Option<String> {
206        let (ns, segs) = Self::split_key(key)?;
207        let path = self.file_path(ns);
208        let mut doc = Self::load(&path);
209        let old = Self::remove_nested(&mut doc, &segs);
210        if old.is_some() {
211            if doc.as_table().is_empty() {
212                let _ = fs::remove_file(&path);
213            } else {
214                Self::save(&path, &doc);
215            }
216        }
217        old
218    }
219}