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    /// Remove a read-only mem from the router. Returns `true` when the
224    /// entry was present. Mirror of [`Self::remove_writable`] for the
225    /// uninstall path: writable entries are not affected, and `visible`
226    /// only drops the name when no writable entry still carries it.
227    pub fn remove_read_only(&mut self, name: &str) -> bool {
228        if self.read_only_archives.remove(name).is_some() {
229            if !self.writable_entries.contains_key(name) {
230                self.visible.remove(name);
231            }
232            true
233        } else {
234            false
235        }
236    }
237
238    /// Check if a mem is writable.
239    pub fn is_writable(&self, mem: &str) -> bool {
240        self.writable.contains(mem)
241    }
242
243    /// Check if a mem is visible (writable or read-only).
244    pub fn is_visible(&self, mem: &str) -> bool {
245        self.visible.contains(mem)
246    }
247
248    /// Check if an entity is visible from the given context.
249    pub fn is_entity_visible(&self, entity_id: &EntityId) -> bool {
250        let mem = entity_id.mem();
251        mem.is_empty() || self.visible.contains(mem)
252    }
253
254    /// Get the directory path for a writable mem.
255    ///
256    /// Returns `None` when the mem is unknown OR when the mem is
257    /// mem-repo-backed (no on-disk directory). Callers that need to
258    /// distinguish "mem not found" from "mem has no dir" use
259    /// `is_writable` first.
260    pub fn dir_for_mem(&self, mem: &str) -> Option<&Path> {
261        self.writable_entries
262            .get(mem)
263            .and_then(|e| e.dir.as_deref())
264    }
265
266    /// Get the `MemOrigin` for a writable mem. Used by the
267    /// `MEM_NAME_COLLISION` envelope renderer and the
268    /// `memstead_health { include_config: true }` per-mem `origin` field.
269    pub fn origin_for_mem(&self, mem: &str) -> Option<&MemOrigin> {
270        self.writable_entries.get(mem).map(|e| &e.origin)
271    }
272
273    /// Get the sealed-archive path for a read-only mem.
274    ///
275    /// Returns `None` for writable mems and unknown names. Keep this
276    /// distinct from `dir_for_mem` — a directory and a zip archive are
277    /// different backing stores, and callers usually care which they get.
278    pub fn archive_path_for_mem(&self, mem: &str) -> Option<&Path> {
279        self.read_only_archives.get(mem).map(|p| p.as_path())
280    }
281
282    /// Get all writable mem names.
283    pub fn writable_mems(&self) -> &HashSet<String> {
284        &self.writable
285    }
286
287    /// Get all visible mem names.
288    pub fn visible_mems(&self) -> &HashSet<String> {
289        &self.visible
290    }
291
292    /// Validate that a mem is writable, returning an error message if not.
293    pub fn validate_writable(&self, mem: &str) -> Result<(), String> {
294        if self.writable.contains(mem) {
295            Ok(())
296        } else {
297            let writable: Vec<_> = self.writable.iter().cloned().collect();
298            Err(format!(
299                "Mem '{}' is read-only. Writable mems: {}",
300                mem,
301                writable.join(", ")
302            ))
303        }
304    }
305}
306
307impl Default for MemRouterSnapshot {
308    fn default() -> Self {
309        Self::new()
310    }
311}
312
313/// Convenience: check if an entity is visible. Returns true if router is None (no filtering).
314pub fn is_visible(entity_id: &EntityId, router: Option<&MemRouterSnapshot>) -> bool {
315    match router {
316        Some(r) => r.is_entity_visible(entity_id),
317        None => true,
318    }
319}
320
321/// Convenience: check if a mem is writable. Returns true if router is None.
322pub fn is_writable(mem: &str, router: Option<&MemRouterSnapshot>) -> bool {
323    match router {
324        Some(r) => r.is_writable(mem),
325        None => true,
326    }
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    #[test]
334    fn empty_router_allows_nothing() {
335        let router = MemRouterSnapshot::new();
336        assert!(!router.is_writable("specs"));
337        assert!(!router.is_visible("specs"));
338    }
339
340    #[test]
341    fn writable_mem_is_visible() {
342        let mut router = MemRouterSnapshot::new();
343        router.add_writable(
344            "specs".to_string(),
345            Some(PathBuf::from("/path/to/specs")),
346            MemOrigin::ExplicitToml,
347        );
348        assert!(router.is_writable("specs"));
349        assert!(router.is_visible("specs"));
350        assert!(!router.is_writable("other"));
351    }
352
353    #[test]
354    fn read_only_mem() {
355        let mut router = MemRouterSnapshot::new();
356        router.add_read_only(
357            "external".to_string(),
358            PathBuf::from("/path/to/external.mem"),
359        );
360        assert!(!router.is_writable("external"));
361        assert!(router.is_visible("external"));
362    }
363
364    #[test]
365    fn entity_visibility() {
366        let mut router = MemRouterSnapshot::new();
367        router.add_writable(
368            "specs".to_string(),
369            Some(PathBuf::from("/specs")),
370            MemOrigin::ExplicitToml,
371        );
372        router.add_read_only(
373            "external".to_string(),
374            PathBuf::from("/path/to/external.mem"),
375        );
376
377        assert!(router.is_entity_visible(&EntityId::new("specs", "entity")));
378        assert!(router.is_entity_visible(&EntityId::new("external", "entity")));
379        assert!(!router.is_entity_visible(&EntityId::new("hidden", "entity")));
380    }
381
382    #[test]
383    fn dir_for_mem() {
384        let mut router = MemRouterSnapshot::new();
385        router.add_writable(
386            "specs".to_string(),
387            Some(PathBuf::from("/path/to/specs")),
388            MemOrigin::ExplicitToml,
389        );
390        assert_eq!(
391            router.dir_for_mem("specs"),
392            Some(Path::new("/path/to/specs"))
393        );
394        assert_eq!(router.dir_for_mem("unknown"), None);
395    }
396
397    #[test]
398    fn validate_writable_ok() {
399        let mut router = MemRouterSnapshot::new();
400        router.add_writable(
401            "specs".to_string(),
402            Some(PathBuf::from("/specs")),
403            MemOrigin::ExplicitToml,
404        );
405        assert!(router.validate_writable("specs").is_ok());
406    }
407
408    #[test]
409    fn validate_writable_err() {
410        let mut router = MemRouterSnapshot::new();
411        router.add_read_only(
412            "external".to_string(),
413            PathBuf::from("/path/to/external.mem"),
414        );
415        assert!(router.validate_writable("external").is_err());
416    }
417
418    #[test]
419    fn convenience_functions_with_none() {
420        let id = EntityId::new("any", "entity");
421        assert!(is_visible(&id, None));
422        assert!(is_writable("any", None));
423    }
424
425    #[test]
426    fn archive_path_for_read_only_mem() {
427        let mut router = MemRouterSnapshot::new();
428        router.add_read_only("external".to_string(), PathBuf::from("/deps/external.mem"));
429        assert_eq!(
430            router.archive_path_for_mem("external"),
431            Some(Path::new("/deps/external.mem"))
432        );
433        // Writable mems do not carry an archive path.
434        router.add_writable(
435            "specs".to_string(),
436            Some(PathBuf::from("/specs")),
437            MemOrigin::ExplicitToml,
438        );
439        assert_eq!(router.archive_path_for_mem("specs"), None);
440        // Unknown mems return None cleanly.
441        assert_eq!(router.archive_path_for_mem("unknown"), None);
442    }
443
444    #[test]
445    fn dir_and_archive_paths_stay_separate() {
446        // Deliberate check that `dir_for_mem` and `archive_path_for_mem`
447        // don't leak into each other's keyspace — a writable mem must
448        // never surface via the archive accessor, and vice versa.
449        let mut router = MemRouterSnapshot::new();
450        router.add_writable(
451            "specs".to_string(),
452            Some(PathBuf::from("/specs")),
453            MemOrigin::ExplicitToml,
454        );
455        router.add_read_only("external".to_string(), PathBuf::from("/deps/external.mem"));
456        assert!(router.dir_for_mem("specs").is_some());
457        assert!(router.dir_for_mem("external").is_none());
458        assert!(router.archive_path_for_mem("specs").is_none());
459        assert!(router.archive_path_for_mem("external").is_some());
460    }
461
462    #[test]
463    fn remove_writable_returns_true_when_present() {
464        let mut router = MemRouterSnapshot::new();
465        router.add_writable(
466            "specs".to_string(),
467            Some(PathBuf::from("/specs")),
468            MemOrigin::ExplicitToml,
469        );
470        assert!(router.remove_writable("specs"));
471        assert!(!router.is_writable("specs"));
472        assert!(!router.is_visible("specs"));
473        assert!(router.dir_for_mem("specs").is_none());
474    }
475
476    #[test]
477    fn remove_writable_returns_false_when_absent() {
478        let mut router = MemRouterSnapshot::new();
479        assert!(!router.remove_writable("nonexistent"));
480    }
481
482    #[test]
483    fn remove_writable_leaves_read_only_visibility_when_same_name_read_only_exists() {
484        // Contrived: a name carried by both a writable entry and a
485        // read-only archive. Not a current product state, but the
486        // router's invariant ("visibility reflects union of registry
487        // kinds") is worth locking in.
488        let mut router = MemRouterSnapshot::new();
489        router.add_writable(
490            "shared".to_string(),
491            Some(PathBuf::from("/specs")),
492            MemOrigin::ExplicitToml,
493        );
494        router.add_read_only("shared".to_string(), PathBuf::from("/deps/shared.mem"));
495        assert!(router.remove_writable("shared"));
496        assert!(!router.is_writable("shared"));
497        assert!(router.is_visible("shared"));
498    }
499
500    #[test]
501    fn mem_origin_render_source_explicit() {
502        let o = MemOrigin::ExplicitToml;
503        // The config file lives at `.memstead/workspace.toml`.
504        assert_eq!(o.render_source(), "explicit from .memstead/workspace.toml");
505        assert_eq!(o.kind(), "explicit");
506    }
507
508    #[test]
509    fn mem_origin_render_source_runtime_created() {
510        // Anchor at a deterministic epoch offset so the rendered form
511        // is stable: 1_700_000_000 = 2023-11-14T22:13:20Z.
512        let ts = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000);
513        let o = MemOrigin::RuntimeCreated {
514            at: ts,
515            by_tool: "memstead_mem_create",
516        };
517        let rendered = o.render_source();
518        assert!(
519            rendered.contains("memstead_mem_create"),
520            "rendered source should name the tool: {rendered}"
521        );
522        assert!(
523            rendered.contains("2023-11-14T22:13:20Z"),
524            "rendered source should carry the RFC-3339 timestamp: {rendered}"
525        );
526        assert_eq!(o.kind(), "runtime_created");
527    }
528
529    #[test]
530    fn snapshot_clone_is_independent() {
531        // Locks the COW-snapshot discipline: a clone taken before a
532        // mutation does not observe the mutation. This is the
533        // invariant `Arc<MemRouterSnapshot>` relies on — readers
534        // holding the pre-swap `Arc` see the pre-swap state.
535        let mut original = MemRouterSnapshot::new();
536        original.add_writable(
537            "a".to_string(),
538            Some(PathBuf::from("/a")),
539            MemOrigin::ExplicitToml,
540        );
541        let pre_clone = original.clone();
542
543        original.add_writable(
544            "b".to_string(),
545            Some(PathBuf::from("/b")),
546            MemOrigin::ExplicitToml,
547        );
548
549        assert!(pre_clone.is_writable("a"));
550        assert!(!pre_clone.is_writable("b"));
551        assert!(original.is_writable("a"));
552        assert!(original.is_writable("b"));
553    }
554
555    /// Hierarchical mem identity lives directly in the router HashMap
556    /// key — `add_writable("team/sub-mem", …)` registers under the full
557    /// path, lookups against `"sub-mem"` (the leaf alone) miss
558    /// cleanly. Locks the "path is the only identifier" invariant.
559    #[test]
560    fn hierarchical_name_is_the_router_key() {
561        let mut router = MemRouterSnapshot::new();
562        router.add_writable("team/sub-mem".to_string(), None, MemOrigin::ExplicitToml);
563        assert!(router.is_writable("team/sub-mem"));
564        // Leaf-only lookup misses — there's no fallback path-lookup.
565        assert!(!router.is_writable("sub-mem"));
566    }
567}