use std::collections::HashMap;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use crate::entity::EntityId;
pub use memstead_schema::MEM_META_DIR;
#[derive(Debug, Clone)]
pub enum MemOrigin {
ExplicitToml,
RuntimeCreated {
at: SystemTime,
by_tool: &'static str,
},
}
impl MemOrigin {
pub fn render_source(&self) -> String {
match self {
MemOrigin::ExplicitToml => "explicit from .memstead/workspace.toml".to_string(),
MemOrigin::RuntimeCreated { at, by_tool } => {
format!("runtime-created at {} by {}", render_rfc3339(*at), by_tool)
}
}
}
pub fn kind(&self) -> &'static str {
match self {
MemOrigin::ExplicitToml => "explicit",
MemOrigin::RuntimeCreated { .. } => "runtime_created",
}
}
}
fn render_rfc3339(ts: SystemTime) -> String {
let secs = ts
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let days = secs / 86400;
let remainder = secs % 86400;
let hour = remainder / 3600;
let minute = (remainder % 3600) / 60;
let second = remainder % 60;
let (year, month, day) = days_to_ymd(days);
format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
}
fn days_to_ymd(days: u64) -> (u64, u64, u64) {
let z = days + 719468;
let era = z / 146097;
let doe = z - era * 146097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
(y, m, d)
}
#[derive(Debug, Clone)]
pub struct WritableEntry {
pub dir: Option<PathBuf>,
pub origin: MemOrigin,
}
#[derive(Debug, Clone)]
pub struct MemRouterSnapshot {
writable: HashSet<String>,
visible: HashSet<String>,
writable_entries: HashMap<String, WritableEntry>,
read_only_archives: HashMap<String, PathBuf>,
}
impl MemRouterSnapshot {
pub fn new() -> Self {
Self {
writable: HashSet::new(),
visible: HashSet::new(),
writable_entries: HashMap::new(),
read_only_archives: HashMap::new(),
}
}
pub fn add_writable(&mut self, name: String, dir: Option<PathBuf>, origin: MemOrigin) {
self.visible.insert(name.clone());
self.writable.insert(name.clone());
self.writable_entries
.insert(name, WritableEntry { dir, origin });
}
pub fn remove_writable(&mut self, name: &str) -> bool {
if self.writable_entries.remove(name).is_some() {
self.writable.remove(name);
if !self.read_only_archives.contains_key(name) {
self.visible.remove(name);
}
true
} else {
false
}
}
pub fn add_read_only(&mut self, name: String, archive_path: PathBuf) {
self.visible.insert(name.clone());
self.read_only_archives.insert(name, archive_path);
}
pub fn remove_read_only(&mut self, name: &str) -> bool {
if self.read_only_archives.remove(name).is_some() {
if !self.writable_entries.contains_key(name) {
self.visible.remove(name);
}
true
} else {
false
}
}
pub fn is_writable(&self, mem: &str) -> bool {
self.writable.contains(mem)
}
pub fn is_visible(&self, mem: &str) -> bool {
self.visible.contains(mem)
}
pub fn is_entity_visible(&self, entity_id: &EntityId) -> bool {
let mem = entity_id.mem();
mem.is_empty() || self.visible.contains(mem)
}
pub fn dir_for_mem(&self, mem: &str) -> Option<&Path> {
self.writable_entries
.get(mem)
.and_then(|e| e.dir.as_deref())
}
pub fn origin_for_mem(&self, mem: &str) -> Option<&MemOrigin> {
self.writable_entries.get(mem).map(|e| &e.origin)
}
pub fn archive_path_for_mem(&self, mem: &str) -> Option<&Path> {
self.read_only_archives.get(mem).map(|p| p.as_path())
}
pub fn writable_mems(&self) -> &HashSet<String> {
&self.writable
}
pub fn visible_mems(&self) -> &HashSet<String> {
&self.visible
}
pub fn validate_writable(&self, mem: &str) -> Result<(), String> {
if self.writable.contains(mem) {
Ok(())
} else {
let writable: Vec<_> = self.writable.iter().cloned().collect();
Err(format!(
"Mem '{}' is read-only. Writable mems: {}",
mem,
writable.join(", ")
))
}
}
}
impl Default for MemRouterSnapshot {
fn default() -> Self {
Self::new()
}
}
pub fn is_visible(entity_id: &EntityId, router: Option<&MemRouterSnapshot>) -> bool {
match router {
Some(r) => r.is_entity_visible(entity_id),
None => true,
}
}
pub fn is_writable(mem: &str, router: Option<&MemRouterSnapshot>) -> bool {
match router {
Some(r) => r.is_writable(mem),
None => true,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_router_allows_nothing() {
let router = MemRouterSnapshot::new();
assert!(!router.is_writable("specs"));
assert!(!router.is_visible("specs"));
}
#[test]
fn writable_mem_is_visible() {
let mut router = MemRouterSnapshot::new();
router.add_writable(
"specs".to_string(),
Some(PathBuf::from("/path/to/specs")),
MemOrigin::ExplicitToml,
);
assert!(router.is_writable("specs"));
assert!(router.is_visible("specs"));
assert!(!router.is_writable("other"));
}
#[test]
fn read_only_mem() {
let mut router = MemRouterSnapshot::new();
router.add_read_only(
"external".to_string(),
PathBuf::from("/path/to/external.mem"),
);
assert!(!router.is_writable("external"));
assert!(router.is_visible("external"));
}
#[test]
fn entity_visibility() {
let mut router = MemRouterSnapshot::new();
router.add_writable(
"specs".to_string(),
Some(PathBuf::from("/specs")),
MemOrigin::ExplicitToml,
);
router.add_read_only(
"external".to_string(),
PathBuf::from("/path/to/external.mem"),
);
assert!(router.is_entity_visible(&EntityId::new("specs", "entity")));
assert!(router.is_entity_visible(&EntityId::new("external", "entity")));
assert!(!router.is_entity_visible(&EntityId::new("hidden", "entity")));
}
#[test]
fn dir_for_mem() {
let mut router = MemRouterSnapshot::new();
router.add_writable(
"specs".to_string(),
Some(PathBuf::from("/path/to/specs")),
MemOrigin::ExplicitToml,
);
assert_eq!(
router.dir_for_mem("specs"),
Some(Path::new("/path/to/specs"))
);
assert_eq!(router.dir_for_mem("unknown"), None);
}
#[test]
fn validate_writable_ok() {
let mut router = MemRouterSnapshot::new();
router.add_writable(
"specs".to_string(),
Some(PathBuf::from("/specs")),
MemOrigin::ExplicitToml,
);
assert!(router.validate_writable("specs").is_ok());
}
#[test]
fn validate_writable_err() {
let mut router = MemRouterSnapshot::new();
router.add_read_only(
"external".to_string(),
PathBuf::from("/path/to/external.mem"),
);
assert!(router.validate_writable("external").is_err());
}
#[test]
fn convenience_functions_with_none() {
let id = EntityId::new("any", "entity");
assert!(is_visible(&id, None));
assert!(is_writable("any", None));
}
#[test]
fn archive_path_for_read_only_mem() {
let mut router = MemRouterSnapshot::new();
router.add_read_only("external".to_string(), PathBuf::from("/deps/external.mem"));
assert_eq!(
router.archive_path_for_mem("external"),
Some(Path::new("/deps/external.mem"))
);
router.add_writable(
"specs".to_string(),
Some(PathBuf::from("/specs")),
MemOrigin::ExplicitToml,
);
assert_eq!(router.archive_path_for_mem("specs"), None);
assert_eq!(router.archive_path_for_mem("unknown"), None);
}
#[test]
fn dir_and_archive_paths_stay_separate() {
let mut router = MemRouterSnapshot::new();
router.add_writable(
"specs".to_string(),
Some(PathBuf::from("/specs")),
MemOrigin::ExplicitToml,
);
router.add_read_only("external".to_string(), PathBuf::from("/deps/external.mem"));
assert!(router.dir_for_mem("specs").is_some());
assert!(router.dir_for_mem("external").is_none());
assert!(router.archive_path_for_mem("specs").is_none());
assert!(router.archive_path_for_mem("external").is_some());
}
#[test]
fn remove_writable_returns_true_when_present() {
let mut router = MemRouterSnapshot::new();
router.add_writable(
"specs".to_string(),
Some(PathBuf::from("/specs")),
MemOrigin::ExplicitToml,
);
assert!(router.remove_writable("specs"));
assert!(!router.is_writable("specs"));
assert!(!router.is_visible("specs"));
assert!(router.dir_for_mem("specs").is_none());
}
#[test]
fn remove_writable_returns_false_when_absent() {
let mut router = MemRouterSnapshot::new();
assert!(!router.remove_writable("nonexistent"));
}
#[test]
fn remove_writable_leaves_read_only_visibility_when_same_name_read_only_exists() {
let mut router = MemRouterSnapshot::new();
router.add_writable(
"shared".to_string(),
Some(PathBuf::from("/specs")),
MemOrigin::ExplicitToml,
);
router.add_read_only("shared".to_string(), PathBuf::from("/deps/shared.mem"));
assert!(router.remove_writable("shared"));
assert!(!router.is_writable("shared"));
assert!(router.is_visible("shared"));
}
#[test]
fn mem_origin_render_source_explicit() {
let o = MemOrigin::ExplicitToml;
assert_eq!(o.render_source(), "explicit from .memstead/workspace.toml");
assert_eq!(o.kind(), "explicit");
}
#[test]
fn mem_origin_render_source_runtime_created() {
let ts = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000);
let o = MemOrigin::RuntimeCreated {
at: ts,
by_tool: "memstead_mem_create",
};
let rendered = o.render_source();
assert!(
rendered.contains("memstead_mem_create"),
"rendered source should name the tool: {rendered}"
);
assert!(
rendered.contains("2023-11-14T22:13:20Z"),
"rendered source should carry the RFC-3339 timestamp: {rendered}"
);
assert_eq!(o.kind(), "runtime_created");
}
#[test]
fn snapshot_clone_is_independent() {
let mut original = MemRouterSnapshot::new();
original.add_writable(
"a".to_string(),
Some(PathBuf::from("/a")),
MemOrigin::ExplicitToml,
);
let pre_clone = original.clone();
original.add_writable(
"b".to_string(),
Some(PathBuf::from("/b")),
MemOrigin::ExplicitToml,
);
assert!(pre_clone.is_writable("a"));
assert!(!pre_clone.is_writable("b"));
assert!(original.is_writable("a"));
assert!(original.is_writable("b"));
}
#[test]
fn hierarchical_name_is_the_router_key() {
let mut router = MemRouterSnapshot::new();
router.add_writable("team/sub-mem".to_string(), None, MemOrigin::ExplicitToml);
assert!(router.is_writable("team/sub-mem"));
assert!(!router.is_writable("sub-mem"));
}
}