Skip to main content

memstead_base/
mem.rs

1//! Multi-mem routing, visibility filtering, mem config.
2//!
3//! Writable/visible mem tracking. The engine loads multiple mems;
4//! some may be read-only (from --read-mem paths or JSON imports).
5//!
6//! The router is structured as a **snapshot** (`MemRouterSnapshot`) —
7//! a `Clone`-able value that the engine holds behind an `Arc`. Lifecycle
8//! operations mutate by cloning the snapshot, editing the clone, and
9//! swapping the `Arc` pointer on `Engine`. Readers that hold an `Arc`
10//! before the swap observe the pre-swap snapshot for their lifetime; no
11//! torn reads are possible. The MCP-level `memstead_mem_create` /
12//! `memstead_mem_delete` tools flip state at runtime.
13
14use std::collections::HashMap;
15use std::collections::HashSet;
16use std::path::{Path, PathBuf};
17use std::time::SystemTime;
18
19use crate::entity::EntityId;
20
21/// The per-mem engine-internal directory under a folder mem's
22/// root (`<mem_root>/.memstead/` — `config.json`, `changes.jsonl`).
23/// Defined in `memstead-schema` (mem-config loading lives there);
24/// re-exported here as the mem-level home for the concept.
25pub use memstead_schema::MEM_META_DIR;
26
27/// Provenance record attached to every writable-mem registration.
28///
29/// `MEM_NAME_COLLISION` reads the colliding registration's
30/// `MemOrigin` and renders it into `details.source` so agents can
31/// distinguish "collision with an explicit workspace mem" from
32/// "collision with a previously-runtime-created mem" without a
33/// follow-up round trip. The enum is kept internal to `memstead-git-branch` with a
34/// `render_source` rendering helper that produces the agent-facing
35/// string — no public `serde` derivation yet; the render path is the
36/// only supported serialization surface until a caller needs structured
37/// consumption.
38#[derive(Debug, Clone)]
39pub enum MemOrigin {
40    /// Loaded from the workspace's `.memstead/workspace.toml` `mems = [...]`
41    /// entry.
42    ExplicitToml,
43    /// Registered after `Engine::init` via
44    /// `Engine::register_mem_runtime` — the path lifecycle tools
45    /// take. `at` captures when the runtime registration
46    /// happened (rendered as an RFC-3339 timestamp on the error
47    /// surface); `by_tool` names the MCP tool that produced the
48    /// registration (today always `"memstead_mem_create"`, but kept
49    /// extensible).
50    RuntimeCreated {
51        at: SystemTime,
52        by_tool: &'static str,
53    },
54}
55
56impl MemOrigin {
57    /// Agent-facing string rendering consumed by
58    /// `MEM_NAME_COLLISION.details.source`. The string is short,
59    /// declarative, and identifies the registration site so agents can
60    /// correlate the collision with something they can observe
61    /// (`.memstead/workspace.toml` entry, timestamp).
62    ///
63    /// The workspace config file lives at `.memstead/workspace.toml`.
64    /// The error message points at the current path so an agent
65    /// following the hint finds it.
66    pub fn render_source(&self) -> String {
67        match self {
68            MemOrigin::ExplicitToml => "explicit from .memstead/workspace.toml".to_string(),
69            MemOrigin::RuntimeCreated { at, by_tool } => {
70                format!("runtime-created at {} by {}", render_rfc3339(*at), by_tool)
71            }
72        }
73    }
74
75    /// Short discriminator consumed by `memstead_health { include_config:
76    /// true }` to tag each writable-mem entry with an agent-readable
77    /// origin string. Variants map to stable kebab-case tokens so
78    /// downstream filters key on them.
79    pub fn kind(&self) -> &'static str {
80        match self {
81            MemOrigin::ExplicitToml => "explicit",
82            MemOrigin::RuntimeCreated { .. } => "runtime_created",
83        }
84    }
85}
86
87/// Render a `SystemTime` as an RFC-3339 UTC timestamp with second
88/// precision. Kept deliberately local to this module because the only
89/// consumer is `MemOrigin::render_source` — lifting it into a shared
90/// utility is premature until a second caller appears.
91///
92/// Pre-epoch times fall back to the epoch itself; the error surface is
93/// an agent-facing string and "crashing on a nonsensical timestamp" is
94/// worse than the (vanishingly unlikely) pre-1970 fallback.
95fn render_rfc3339(ts: SystemTime) -> String {
96    let secs = ts
97        .duration_since(std::time::UNIX_EPOCH)
98        .map(|d| d.as_secs())
99        .unwrap_or(0);
100    let days = secs / 86400;
101    let remainder = secs % 86400;
102    let hour = remainder / 3600;
103    let minute = (remainder % 3600) / 60;
104    let second = remainder % 60;
105    let (year, month, day) = days_to_ymd(days);
106    format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
107}
108
109/// Convert days since epoch (1970-01-01) to (year, month, day).
110/// Algorithm from http://howardhinnant.github.io/date_algorithms.html —
111/// duplicated from `entity::generator::days_to_ymd` deliberately: this
112/// module's rendering semantics (UTC-anchored, time-inclusive) are a
113/// different shape than the generator's pure-date helper, and a shared
114/// utility would couple two independent code paths.
115fn days_to_ymd(days: u64) -> (u64, u64, u64) {
116    let z = days + 719468;
117    let era = z / 146097;
118    let doe = z - era * 146097;
119    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
120    let y = yoe + era * 400;
121    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
122    let mp = (5 * doy + 2) / 153;
123    let d = doy - (153 * mp + 2) / 5 + 1;
124    let m = if mp < 10 { mp + 3 } else { mp - 9 };
125    let y = if m <= 2 { y + 1 } else { y };
126    (y, m, d)
127}
128
129/// Per-writable-mem origin + directory payload held inside
130/// `MemRouterSnapshot`. Cloned along with the snapshot on every
131/// lifecycle mutation; clone cost is O(1).
132///
133/// Hierarchical mem identity lives directly on the router HashMap
134/// key (and on `Mount::mem`) — there's exactly one identifier (the
135/// full path, e.g. `team/sub-mem`). The delete-side lifecycle
136/// composer reads the mem name as-is, no path-composition step
137/// needed.
138#[derive(Debug, Clone)]
139pub struct WritableEntry {
140    pub dir: Option<PathBuf>,
141    pub origin: MemOrigin,
142}
143
144/// Mem configuration for the engine runtime — cloneable snapshot.
145///
146/// Tracks which mems are writable, which are visible, and their
147/// directories. Held on `Engine` behind an `Arc<MemRouterSnapshot>`;
148/// lifecycle mutations clone the snapshot, edit the clone, and swap the
149/// `Arc` pointer atomically inside the engine mutex.
150#[derive(Debug, Clone)]
151pub struct MemRouterSnapshot {
152    /// Writable mem names.
153    writable: HashSet<String>,
154    /// All visible mem names (writable + read-only).
155    visible: HashSet<String>,
156    /// Mem name → (directory path, registration origin). Writable
157    /// mems only — read-only entries live in `read_only_archives`.
158    /// `dir` is `Some(path)` for disk-backed mems and `None` for
159    /// mem-repo-backed mems whose content lives only as a branch in
160    /// `mem-repo-git` (no working tree).
161    writable_entries: HashMap<String, WritableEntry>,
162    /// Mem name → sealed-archive path (read-only mems only).
163    /// Stored so reload can re-open the same archive when its mtime
164    /// changes without needing to re-parse the project config.
165    read_only_archives: HashMap<String, PathBuf>,
166}
167
168impl MemRouterSnapshot {
169    pub fn new() -> Self {
170        Self {
171            writable: HashSet::new(),
172            visible: HashSet::new(),
173            writable_entries: HashMap::new(),
174            read_only_archives: HashMap::new(),
175        }
176    }
177
178    /// Register a writable mem with its directory path and origin.
179    ///
180    /// The mem's hierarchical organisational path is part of `name`
181    /// itself (e.g. `"team/sub-mem"`). The router HashMap key, the
182    /// `Mount::mem` field, and the lifecycle-allowlist candidate
183    /// all converge on the same string — no separate composition
184    /// step.
185    pub fn add_writable(&mut self, name: String, dir: Option<PathBuf>, origin: MemOrigin) {
186        self.visible.insert(name.clone());
187        self.writable.insert(name.clone());
188        self.writable_entries
189            .insert(name, WritableEntry { dir, origin });
190    }
191
192    /// Remove a writable mem from the router. Returns `true` when
193    /// the entry was present.
194    ///
195    /// Internal — called by `Engine::unregister_mem_runtime`.
196    /// Read-only entries are not affected; unregistering a name that
197    /// names a read-only mem is a caller-level misuse and returns
198    /// `false`.
199    pub fn remove_writable(&mut self, name: &str) -> bool {
200        if self.writable_entries.remove(name).is_some() {
201            self.writable.remove(name);
202            // `visible` tracks both writable + read-only; only drop
203            // from `visible` when no read-only entry still carries it.
204            if !self.read_only_archives.contains_key(name) {
205                self.visible.remove(name);
206            }
207            true
208        } else {
209            false
210        }
211    }
212
213    /// Register a read-only mem backed by a sealed `.mem` archive.
214    ///
215    /// `archive_path` is the on-disk location of the archive. The
216    /// router retains it so reload can re-open the same archive without
217    /// re-parsing the project config.
218    pub fn add_read_only(&mut self, name: String, archive_path: PathBuf) {
219        self.visible.insert(name.clone());
220        self.read_only_archives.insert(name, archive_path);
221    }
222
223    /// Check if a mem is writable.
224    pub fn is_writable(&self, mem: &str) -> bool {
225        self.writable.contains(mem)
226    }
227
228    /// Check if a mem is visible (writable or read-only).
229    pub fn is_visible(&self, mem: &str) -> bool {
230        self.visible.contains(mem)
231    }
232
233    /// Check if an entity is visible from the given context.
234    pub fn is_entity_visible(&self, entity_id: &EntityId) -> bool {
235        let mem = entity_id.mem();
236        mem.is_empty() || self.visible.contains(mem)
237    }
238
239    /// Get the directory path for a writable mem.
240    ///
241    /// Returns `None` when the mem is unknown OR when the mem is
242    /// mem-repo-backed (no on-disk directory). Callers that need to
243    /// distinguish "mem not found" from "mem has no dir" use
244    /// `is_writable` first.
245    pub fn dir_for_mem(&self, mem: &str) -> Option<&Path> {
246        self.writable_entries
247            .get(mem)
248            .and_then(|e| e.dir.as_deref())
249    }
250
251    /// Get the `MemOrigin` for a writable mem. Used by the
252    /// `MEM_NAME_COLLISION` envelope renderer and the
253    /// `memstead_health { include_config: true }` per-mem `origin` field.
254    pub fn origin_for_mem(&self, mem: &str) -> Option<&MemOrigin> {
255        self.writable_entries.get(mem).map(|e| &e.origin)
256    }
257
258    /// Get the sealed-archive path for a read-only mem.
259    ///
260    /// Returns `None` for writable mems and unknown names. Keep this
261    /// distinct from `dir_for_mem` — a directory and a zip archive are
262    /// different backing stores, and callers usually care which they get.
263    pub fn archive_path_for_mem(&self, mem: &str) -> Option<&Path> {
264        self.read_only_archives.get(mem).map(|p| p.as_path())
265    }
266
267    /// Get all writable mem names.
268    pub fn writable_mems(&self) -> &HashSet<String> {
269        &self.writable
270    }
271
272    /// Get all visible mem names.
273    pub fn visible_mems(&self) -> &HashSet<String> {
274        &self.visible
275    }
276
277    /// Validate that a mem is writable, returning an error message if not.
278    pub fn validate_writable(&self, mem: &str) -> Result<(), String> {
279        if self.writable.contains(mem) {
280            Ok(())
281        } else {
282            let writable: Vec<_> = self.writable.iter().cloned().collect();
283            Err(format!(
284                "Mem '{}' is read-only. Writable mems: {}",
285                mem,
286                writable.join(", ")
287            ))
288        }
289    }
290}
291
292impl Default for MemRouterSnapshot {
293    fn default() -> Self {
294        Self::new()
295    }
296}
297
298/// Convenience: check if an entity is visible. Returns true if router is None (no filtering).
299pub fn is_visible(entity_id: &EntityId, router: Option<&MemRouterSnapshot>) -> bool {
300    match router {
301        Some(r) => r.is_entity_visible(entity_id),
302        None => true,
303    }
304}
305
306/// Convenience: check if a mem is writable. Returns true if router is None.
307pub fn is_writable(mem: &str, router: Option<&MemRouterSnapshot>) -> bool {
308    match router {
309        Some(r) => r.is_writable(mem),
310        None => true,
311    }
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317
318    #[test]
319    fn empty_router_allows_nothing() {
320        let router = MemRouterSnapshot::new();
321        assert!(!router.is_writable("specs"));
322        assert!(!router.is_visible("specs"));
323    }
324
325    #[test]
326    fn writable_mem_is_visible() {
327        let mut router = MemRouterSnapshot::new();
328        router.add_writable(
329            "specs".to_string(),
330            Some(PathBuf::from("/path/to/specs")),
331            MemOrigin::ExplicitToml,
332        );
333        assert!(router.is_writable("specs"));
334        assert!(router.is_visible("specs"));
335        assert!(!router.is_writable("other"));
336    }
337
338    #[test]
339    fn read_only_mem() {
340        let mut router = MemRouterSnapshot::new();
341        router.add_read_only(
342            "external".to_string(),
343            PathBuf::from("/path/to/external.mem"),
344        );
345        assert!(!router.is_writable("external"));
346        assert!(router.is_visible("external"));
347    }
348
349    #[test]
350    fn entity_visibility() {
351        let mut router = MemRouterSnapshot::new();
352        router.add_writable(
353            "specs".to_string(),
354            Some(PathBuf::from("/specs")),
355            MemOrigin::ExplicitToml,
356        );
357        router.add_read_only(
358            "external".to_string(),
359            PathBuf::from("/path/to/external.mem"),
360        );
361
362        assert!(router.is_entity_visible(&EntityId::new("specs", "entity")));
363        assert!(router.is_entity_visible(&EntityId::new("external", "entity")));
364        assert!(!router.is_entity_visible(&EntityId::new("hidden", "entity")));
365    }
366
367    #[test]
368    fn dir_for_mem() {
369        let mut router = MemRouterSnapshot::new();
370        router.add_writable(
371            "specs".to_string(),
372            Some(PathBuf::from("/path/to/specs")),
373            MemOrigin::ExplicitToml,
374        );
375        assert_eq!(
376            router.dir_for_mem("specs"),
377            Some(Path::new("/path/to/specs"))
378        );
379        assert_eq!(router.dir_for_mem("unknown"), None);
380    }
381
382    #[test]
383    fn validate_writable_ok() {
384        let mut router = MemRouterSnapshot::new();
385        router.add_writable(
386            "specs".to_string(),
387            Some(PathBuf::from("/specs")),
388            MemOrigin::ExplicitToml,
389        );
390        assert!(router.validate_writable("specs").is_ok());
391    }
392
393    #[test]
394    fn validate_writable_err() {
395        let mut router = MemRouterSnapshot::new();
396        router.add_read_only(
397            "external".to_string(),
398            PathBuf::from("/path/to/external.mem"),
399        );
400        assert!(router.validate_writable("external").is_err());
401    }
402
403    #[test]
404    fn convenience_functions_with_none() {
405        let id = EntityId::new("any", "entity");
406        assert!(is_visible(&id, None));
407        assert!(is_writable("any", None));
408    }
409
410    #[test]
411    fn archive_path_for_read_only_mem() {
412        let mut router = MemRouterSnapshot::new();
413        router.add_read_only("external".to_string(), PathBuf::from("/deps/external.mem"));
414        assert_eq!(
415            router.archive_path_for_mem("external"),
416            Some(Path::new("/deps/external.mem"))
417        );
418        // Writable mems do not carry an archive path.
419        router.add_writable(
420            "specs".to_string(),
421            Some(PathBuf::from("/specs")),
422            MemOrigin::ExplicitToml,
423        );
424        assert_eq!(router.archive_path_for_mem("specs"), None);
425        // Unknown mems return None cleanly.
426        assert_eq!(router.archive_path_for_mem("unknown"), None);
427    }
428
429    #[test]
430    fn dir_and_archive_paths_stay_separate() {
431        // Deliberate check that `dir_for_mem` and `archive_path_for_mem`
432        // don't leak into each other's keyspace — a writable mem must
433        // never surface via the archive accessor, and vice versa.
434        let mut router = MemRouterSnapshot::new();
435        router.add_writable(
436            "specs".to_string(),
437            Some(PathBuf::from("/specs")),
438            MemOrigin::ExplicitToml,
439        );
440        router.add_read_only("external".to_string(), PathBuf::from("/deps/external.mem"));
441        assert!(router.dir_for_mem("specs").is_some());
442        assert!(router.dir_for_mem("external").is_none());
443        assert!(router.archive_path_for_mem("specs").is_none());
444        assert!(router.archive_path_for_mem("external").is_some());
445    }
446
447    #[test]
448    fn remove_writable_returns_true_when_present() {
449        let mut router = MemRouterSnapshot::new();
450        router.add_writable(
451            "specs".to_string(),
452            Some(PathBuf::from("/specs")),
453            MemOrigin::ExplicitToml,
454        );
455        assert!(router.remove_writable("specs"));
456        assert!(!router.is_writable("specs"));
457        assert!(!router.is_visible("specs"));
458        assert!(router.dir_for_mem("specs").is_none());
459    }
460
461    #[test]
462    fn remove_writable_returns_false_when_absent() {
463        let mut router = MemRouterSnapshot::new();
464        assert!(!router.remove_writable("nonexistent"));
465    }
466
467    #[test]
468    fn remove_writable_leaves_read_only_visibility_when_same_name_read_only_exists() {
469        // Contrived: a name carried by both a writable entry and a
470        // read-only archive. Not a current product state, but the
471        // router's invariant ("visibility reflects union of registry
472        // kinds") is worth locking in.
473        let mut router = MemRouterSnapshot::new();
474        router.add_writable(
475            "shared".to_string(),
476            Some(PathBuf::from("/specs")),
477            MemOrigin::ExplicitToml,
478        );
479        router.add_read_only("shared".to_string(), PathBuf::from("/deps/shared.mem"));
480        assert!(router.remove_writable("shared"));
481        assert!(!router.is_writable("shared"));
482        assert!(router.is_visible("shared"));
483    }
484
485    #[test]
486    fn mem_origin_render_source_explicit() {
487        let o = MemOrigin::ExplicitToml;
488        // The config file lives at `.memstead/workspace.toml`.
489        assert_eq!(o.render_source(), "explicit from .memstead/workspace.toml");
490        assert_eq!(o.kind(), "explicit");
491    }
492
493    #[test]
494    fn mem_origin_render_source_runtime_created() {
495        // Anchor at a deterministic epoch offset so the rendered form
496        // is stable: 1_700_000_000 = 2023-11-14T22:13:20Z.
497        let ts = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000);
498        let o = MemOrigin::RuntimeCreated {
499            at: ts,
500            by_tool: "memstead_mem_create",
501        };
502        let rendered = o.render_source();
503        assert!(
504            rendered.contains("memstead_mem_create"),
505            "rendered source should name the tool: {rendered}"
506        );
507        assert!(
508            rendered.contains("2023-11-14T22:13:20Z"),
509            "rendered source should carry the RFC-3339 timestamp: {rendered}"
510        );
511        assert_eq!(o.kind(), "runtime_created");
512    }
513
514    #[test]
515    fn snapshot_clone_is_independent() {
516        // Locks the COW-snapshot discipline: a clone taken before a
517        // mutation does not observe the mutation. This is the
518        // invariant `Arc<MemRouterSnapshot>` relies on — readers
519        // holding the pre-swap `Arc` see the pre-swap state.
520        let mut original = MemRouterSnapshot::new();
521        original.add_writable(
522            "a".to_string(),
523            Some(PathBuf::from("/a")),
524            MemOrigin::ExplicitToml,
525        );
526        let pre_clone = original.clone();
527
528        original.add_writable(
529            "b".to_string(),
530            Some(PathBuf::from("/b")),
531            MemOrigin::ExplicitToml,
532        );
533
534        assert!(pre_clone.is_writable("a"));
535        assert!(!pre_clone.is_writable("b"));
536        assert!(original.is_writable("a"));
537        assert!(original.is_writable("b"));
538    }
539
540    /// Hierarchical mem identity lives directly in the router HashMap
541    /// key — `add_writable("team/sub-mem", …)` registers under the full
542    /// path, lookups against `"sub-mem"` (the leaf alone) miss
543    /// cleanly. Locks the "path is the only identifier" invariant.
544    #[test]
545    fn hierarchical_name_is_the_router_key() {
546        let mut router = MemRouterSnapshot::new();
547        router.add_writable("team/sub-mem".to_string(), None, MemOrigin::ExplicitToml);
548        assert!(router.is_writable("team/sub-mem"));
549        // Leaf-only lookup misses — there's no fallback path-lookup.
550        assert!(!router.is_writable("sub-mem"));
551    }
552}