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}