use std::path::PathBuf;
use memstead_base::backend::MemBackend;
use memstead_base::storage::FilesystemMemWriter;
use memstead_base::workspace::{
CreateRuleSetting, DeleteRuleSetting, Mount, MountCapability, MountLifecycle, MountStorage,
WorkspaceSettings,
};
use memstead_base::{Engine, EngineError};
use memstead_engine::{FullEngineError, mem_management};
use memstead_schema::SchemaRef;
use tempfile::TempDir;
fn pin(name: &str) -> SchemaRef {
let pin_str = match name {
"default" => "default@1.0.0".to_string(),
other => format!("{other}@0.1.0"),
};
pin_str.parse().expect("static test pin parses")
}
fn folder_mount(mem: &str, path: PathBuf) -> Mount {
Mount {
mem: mem.to_string(),
schema: Some(pin("default")),
storage: MountStorage::Folder { path },
capability: MountCapability::Write,
lifecycle: MountLifecycle::Eager,
cross_linkable: true,
migration_target: None,
}
}
#[test]
fn create_mem_rejects_overlong_note() {
let tmp = TempDir::new().unwrap();
let mem_dir = tmp.path().join("seed");
std::fs::create_dir_all(&mem_dir).unwrap();
let writer = FilesystemMemWriter::new(mem_dir.clone());
let mut engine = Engine::from_mounts(vec![(
folder_mount("seed", mem_dir),
Box::new(writer) as Box<dyn MemBackend>,
)])
.unwrap();
let err = mem_management::create_mem(
&mut engine,
mem_management::MemCreateParams {
write_guidance: Default::default(),
name: "alpha".to_string(),
vcs: None,
location: tmp.path().join("alpha"),
schema_ref: "default@1.0.0".parse().unwrap(),
note: Some("x".repeat(mem_management::NOTE_MAX_LEN + 1)),
operator_mode: false,
recovery: None,
storage: None,
actor: memstead_base::vcs::Actor::Cli,
client: None,
},
)
.unwrap_err();
match err {
FullEngineError::Lean(EngineError::InvalidInput(msg)) => {
assert!(msg.contains("note exceeds"))
}
other => panic!("expected InvalidInput, got {other:?}"),
}
}
#[test]
fn create_mem_rejects_when_no_allowlist_configured() {
let tmp = TempDir::new().unwrap();
let mem_dir = tmp.path().join("seed");
std::fs::create_dir_all(&mem_dir).unwrap();
let writer = FilesystemMemWriter::new(mem_dir.clone());
let mut engine = Engine::from_mounts(vec![(
folder_mount("seed", mem_dir),
Box::new(writer) as Box<dyn MemBackend>,
)])
.unwrap();
let err = mem_management::create_mem(
&mut engine,
mem_management::MemCreateParams {
write_guidance: Default::default(),
name: "alpha".to_string(),
vcs: None,
location: tmp.path().join("alpha"),
schema_ref: "default@1.0.0".parse().unwrap(),
note: None,
operator_mode: false,
recovery: None,
storage: None,
actor: memstead_base::vcs::Actor::Cli,
client: None,
},
)
.unwrap_err();
match err {
FullEngineError::MemPathNotAllowed { reason, .. } => {
assert_eq!(reason, "no_allowlist_configured");
}
other => panic!("expected MemPathNotAllowed, got {other:?}"),
}
}
#[test]
fn create_mem_rejects_name_collision() {
let tmp = TempDir::new().unwrap();
let mem_dir = tmp.path().join("alpha");
std::fs::create_dir_all(&mem_dir).unwrap();
let writer = FilesystemMemWriter::new(mem_dir.clone());
let mut engine = Engine::from_mounts(vec![(
folder_mount("alpha", mem_dir),
Box::new(writer) as Box<dyn MemBackend>,
)])
.unwrap();
engine.set_settings(WorkspaceSettings {
mem_create_rules: vec![CreateRuleSetting {
pattern: "*".to_string(),
schemas: vec!["*".to_string()],
default_cross_links: None,
}],
mem_delete_rules: Vec::new(),
cross_mem_links: Default::default(),
..Default::default()
});
let err = mem_management::create_mem(
&mut engine,
mem_management::MemCreateParams {
write_guidance: Default::default(),
name: "alpha".to_string(),
vcs: None,
location: tmp.path().join("alpha"),
schema_ref: "default@1.0.0".parse().unwrap(),
note: None,
operator_mode: false,
recovery: None,
storage: None,
actor: memstead_base::vcs::Actor::Cli,
client: None,
},
)
.unwrap_err();
match err {
FullEngineError::Lean(EngineError::MemNameCollision { name, .. }) => {
assert_eq!(name, "alpha")
}
other => panic!("expected MemNameCollision, got {other:?}"),
}
}
#[test]
fn create_mem_succeeds_with_wildcard_rule() {
let tmp = TempDir::new().unwrap();
let mem_dir = tmp.path().join("seed");
std::fs::create_dir_all(&mem_dir).unwrap();
let writer = FilesystemMemWriter::new(mem_dir.clone());
let mut engine = Engine::from_mounts(vec![(
folder_mount("seed", mem_dir),
Box::new(writer) as Box<dyn MemBackend>,
)])
.unwrap();
engine.set_settings(WorkspaceSettings {
mem_create_rules: vec![CreateRuleSetting {
pattern: "*".to_string(),
schemas: vec!["*".to_string()],
default_cross_links: None,
}],
mem_delete_rules: Vec::new(),
cross_mem_links: Default::default(),
..Default::default()
});
let new_loc = tmp.path().join("alpha");
let response = mem_management::create_mem(
&mut engine,
mem_management::MemCreateParams {
write_guidance: Default::default(),
name: "alpha".to_string(),
vcs: None,
location: new_loc.clone(),
schema_ref: "default@1.0.0".parse().unwrap(),
note: Some("seed".to_string()),
operator_mode: false,
recovery: None,
storage: None,
actor: memstead_base::vcs::Actor::Cli,
client: None,
},
)
.unwrap();
assert_eq!(response.name, "alpha");
assert!(
!response.seed_commit_sha.is_empty(),
"seed_commit_sha should be a non-empty cursor (synthetic for folder backend, real sha for git-branch)",
);
assert!(engine.mem_router().is_writable("alpha"));
let config_path = new_loc.join(".memstead").join("config.json");
assert!(
config_path.exists(),
"config.json must land at {config_path:?}"
);
let notice = response
.warnings
.iter()
.find(|w| w.code() == "FOLDER_MEM_PROVENANCE")
.expect("folder-mem create must carry FOLDER_MEM_PROVENANCE");
let msg = notice.message();
assert!(msg.contains("changelog"), "names the ledger: {msg}");
assert!(
msg.contains("placeholder"),
"names the placeholder sha: {msg}"
);
assert!(
msg.contains("durable"),
"names the durability condition: {msg}"
);
}
#[test]
fn create_mem_seed_provenance_carries_the_callers_actor_and_client() {
let tmp = TempDir::new().unwrap();
let mem_dir = tmp.path().join("seed");
std::fs::create_dir_all(&mem_dir).unwrap();
let writer = FilesystemMemWriter::new(mem_dir.clone());
let mut engine = Engine::from_mounts(vec![(
folder_mount("seed", mem_dir),
Box::new(writer) as Box<dyn MemBackend>,
)])
.unwrap();
engine.set_settings(WorkspaceSettings {
mem_create_rules: vec![CreateRuleSetting {
pattern: "*".to_string(),
schemas: vec!["*".to_string()],
default_cross_links: None,
}],
mem_delete_rules: Vec::new(),
cross_mem_links: Default::default(),
..Default::default()
});
let new_loc = tmp.path().join("appmade");
mem_management::create_mem(
&mut engine,
mem_management::MemCreateParams {
write_guidance: Default::default(),
name: "appmade".to_string(),
vcs: None,
location: new_loc.clone(),
schema_ref: "default@1.0.0".parse().unwrap(),
note: Some("created from the app".to_string()),
operator_mode: false,
recovery: None,
storage: None,
actor: memstead_base::vcs::Actor::App,
client: Some(memstead_base::vcs::ClientId {
name: "memstead-node-app".to_string(),
version: "0.1.0".to_string(),
}),
},
)
.unwrap();
engine
.create_entity(
memstead_base::CreateEntityArgs {
mem: "appmade".to_string(),
title: "App Born".to_string(),
entity_type: "spec".to_string(),
sections: [
("identity".to_string(), "x".to_string()),
("purpose".to_string(), "y".to_string()),
]
.into_iter()
.collect(),
metadata: Default::default(),
relations: Vec::new(),
anchors: Vec::new(),
dry_run: false,
},
memstead_base::vcs::Actor::App,
Some(&memstead_base::vcs::ClientId {
name: "memstead-node-app".to_string(),
version: "0.1.0".to_string(),
}),
Some("created from the app"),
)
.unwrap();
let changelog = std::fs::read_to_string(memstead_base::filesystem::changelog::changelog_path(
&new_loc,
))
.expect("entity mutation writes the changelog");
let line: serde_json::Value =
serde_json::from_str(changelog.lines().next_back().expect("a changelog line"))
.expect("changelog line is JSON");
assert_eq!(line["actor"], "app");
assert_eq!(line["client"], "memstead-node-app@0.1.0");
assert_eq!(line["note"], "created from the app");
}
#[test]
fn create_mem_persists_write_guidance_into_seed_config() {
let tmp = TempDir::new().unwrap();
let mem_dir = tmp.path().join("seed");
std::fs::create_dir_all(&mem_dir).unwrap();
let writer = FilesystemMemWriter::new(mem_dir.clone());
let mut engine = Engine::from_mounts(vec![(
folder_mount("seed", mem_dir),
Box::new(writer) as Box<dyn MemBackend>,
)])
.unwrap();
engine.set_settings(WorkspaceSettings {
mem_create_rules: vec![CreateRuleSetting {
pattern: "*".to_string(),
schemas: vec!["*".to_string()],
default_cross_links: None,
}],
mem_delete_rules: Vec::new(),
cross_mem_links: Default::default(),
..Default::default()
});
let mut guidance = std::collections::HashMap::new();
guidance.insert(
"phase_context".to_string(),
serde_json::json!("early design"),
);
guidance.insert("stack".to_string(), serde_json::json!("Rust"));
let new_loc = tmp.path().join("alpha");
mem_management::create_mem(
&mut engine,
mem_management::MemCreateParams {
write_guidance: guidance,
name: "alpha".to_string(),
vcs: None,
location: new_loc.clone(),
schema_ref: "default@1.0.0".parse().unwrap(),
note: Some("seed".to_string()),
operator_mode: false,
recovery: None,
storage: None,
actor: memstead_base::vcs::Actor::Cli,
client: None,
},
)
.unwrap();
let config_path = new_loc.join(".memstead").join("config.json");
let bytes = std::fs::read(&config_path).unwrap();
let value: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(
value["writeGuidance"]["phase_context"], "early design",
"seed config must carry the opaque write_guidance verbatim under the camelCase key; got {value}",
);
assert_eq!(value["writeGuidance"]["stack"], "Rust");
}
#[test]
fn create_mem_with_hierarchical_name_matches_path_rule() {
let tmp = TempDir::new().unwrap();
let mem_dir = tmp.path().join("seed");
std::fs::create_dir_all(&mem_dir).unwrap();
let writer = FilesystemMemWriter::new(mem_dir.clone());
let mut engine = Engine::from_mounts(vec![(
folder_mount("seed", mem_dir),
Box::new(writer) as Box<dyn MemBackend>,
)])
.unwrap();
engine.set_settings(WorkspaceSettings {
mem_create_rules: vec![CreateRuleSetting {
pattern: "planning/*".to_string(),
schemas: vec!["*".to_string()],
default_cross_links: None,
}],
mem_delete_rules: Vec::new(),
cross_mem_links: Default::default(),
..Default::default()
});
let new_loc = tmp.path().join("alpha");
let response = mem_management::create_mem(
&mut engine,
mem_management::MemCreateParams {
write_guidance: Default::default(),
name: "planning/alpha".to_string(),
vcs: None,
location: new_loc.clone(),
schema_ref: "default@1.0.0".parse().unwrap(),
note: None,
operator_mode: false,
recovery: None,
storage: None,
actor: memstead_base::vcs::Actor::Cli,
client: None,
},
)
.unwrap();
assert_eq!(response.name, "planning/alpha");
assert!(!response.seed_commit_sha.is_empty());
assert!(engine.mem_router().is_writable("planning/alpha"));
assert!(!engine.mem_router().is_writable("alpha"));
}
#[test]
fn create_mem_rejects_double_underscore_segment() {
let tmp = TempDir::new().unwrap();
let mem_dir = tmp.path().join("seed");
std::fs::create_dir_all(&mem_dir).unwrap();
let writer = FilesystemMemWriter::new(mem_dir.clone());
let mut engine = Engine::from_mounts(vec![(
folder_mount("seed", mem_dir),
Box::new(writer) as Box<dyn MemBackend>,
)])
.unwrap();
engine.set_settings(WorkspaceSettings {
mem_create_rules: vec![CreateRuleSetting {
pattern: "**".to_string(),
schemas: vec!["*".to_string()],
default_cross_links: None,
}],
mem_delete_rules: Vec::new(),
cross_mem_links: Default::default(),
..Default::default()
});
let err = mem_management::create_mem(
&mut engine,
mem_management::MemCreateParams {
write_guidance: Default::default(),
name: "__reserved/alpha".to_string(),
vcs: None,
location: tmp.path().join("alpha"),
schema_ref: "default@1.0.0".parse().unwrap(),
note: None,
operator_mode: false,
recovery: None,
storage: None,
actor: memstead_base::vcs::Actor::Cli,
client: None,
},
)
.unwrap_err();
match err {
FullEngineError::InvalidMemName { name, reason } => {
assert_eq!(name, "__reserved/alpha");
assert_eq!(reason, "reserved_prefix");
}
other => panic!("expected InvalidMemName, got {other:?}"),
}
}
#[test]
fn create_mem_structural_invalid_name_matrix() {
let tmp = TempDir::new().unwrap();
let mem_dir = tmp.path().join("seed");
std::fs::create_dir_all(&mem_dir).unwrap();
let writer = FilesystemMemWriter::new(mem_dir.clone());
let mut engine = Engine::from_mounts(vec![(
folder_mount("seed", mem_dir),
Box::new(writer) as Box<dyn MemBackend>,
)])
.unwrap();
engine.set_settings(WorkspaceSettings {
mem_create_rules: vec![CreateRuleSetting {
pattern: "**".to_string(),
schemas: vec!["*".to_string()],
default_cross_links: None,
}],
mem_delete_rules: Vec::new(),
cross_mem_links: Default::default(),
..Default::default()
});
let cases: &[(&str, &str)] = &[
("", "empty"),
("other with spaces", "whitespace"),
("__reserved", "reserved_prefix"),
("bad\u{0007}name", "invalid_char"),
];
for (name, expected_reason) in cases {
let err = mem_management::create_mem(
&mut engine,
mem_management::MemCreateParams {
write_guidance: Default::default(),
name: (*name).to_string(),
vcs: None,
location: tmp.path().join("any"),
schema_ref: "default@1.0.0".parse().unwrap(),
note: None,
operator_mode: false,
recovery: None,
storage: None,
actor: memstead_base::vcs::Actor::Cli,
client: None,
},
)
.unwrap_err();
match err {
FullEngineError::InvalidMemName {
name: got_name,
reason,
} => {
assert_eq!(got_name, *name, "name echoes the offending input");
assert_eq!(
reason, *expected_reason,
"reason discriminator drifted for input {name:?}"
);
}
other => panic!(
"input {name:?} expected InvalidMemName (reason={expected_reason}), got {other:?}"
),
}
}
}
#[test]
fn create_mem_rejects_basename_mismatch() {
let tmp = TempDir::new().unwrap();
let mem_dir = tmp.path().join("seed");
std::fs::create_dir_all(&mem_dir).unwrap();
let writer = FilesystemMemWriter::new(mem_dir.clone());
let mut engine = Engine::from_mounts(vec![(
folder_mount("seed", mem_dir),
Box::new(writer) as Box<dyn MemBackend>,
)])
.unwrap();
engine.set_settings(WorkspaceSettings {
mem_create_rules: vec![CreateRuleSetting {
pattern: "*".to_string(),
schemas: vec!["*".to_string()],
default_cross_links: None,
}],
mem_delete_rules: Vec::new(),
cross_mem_links: Default::default(),
..Default::default()
});
let err = mem_management::create_mem(
&mut engine,
mem_management::MemCreateParams {
write_guidance: Default::default(),
name: "alpha".to_string(),
vcs: None,
location: tmp.path().join("beta"),
schema_ref: "default@1.0.0".parse().unwrap(),
note: None,
operator_mode: false,
recovery: None,
storage: None,
actor: memstead_base::vcs::Actor::Cli,
client: None,
},
)
.unwrap_err();
match err {
FullEngineError::Lean(EngineError::InvalidInput(msg)) => {
assert!(msg.contains("does not match the basename"));
}
other => panic!("expected InvalidInput, got {other:?}"),
}
}
#[test]
fn create_mem_rejects_explicit_git_branch_without_mem_repo() {
let tmp = TempDir::new().unwrap();
let mem_dir = tmp.path().join("seed");
std::fs::create_dir_all(&mem_dir).unwrap();
let writer = FilesystemMemWriter::new(mem_dir.clone());
let mut engine = Engine::from_mounts(vec![(
folder_mount("seed", mem_dir),
Box::new(writer) as Box<dyn MemBackend>,
)])
.unwrap();
let err = mem_management::create_mem(
&mut engine,
mem_management::MemCreateParams {
write_guidance: Default::default(),
name: "alpha".to_string(),
vcs: None,
location: tmp.path().join("alpha"),
schema_ref: "default@1.0.0".parse().unwrap(),
note: None,
operator_mode: true,
recovery: None,
storage: Some(mem_management::StorageKind::GitBranch),
actor: memstead_base::vcs::Actor::Cli,
client: None,
},
)
.unwrap_err();
match err {
FullEngineError::Lean(EngineError::InvalidInput(msg)) => {
assert!(
msg.contains("mem-repo/.git"),
"refusal must name the missing mem-repo/.git, got: {msg}"
);
}
other => panic!("expected InvalidInput, got {other:?}"),
}
}
#[test]
fn delete_mem_rejects_when_no_allowlist_configured() {
let tmp = TempDir::new().unwrap();
let mem_dir = tmp.path().join("specs");
std::fs::create_dir_all(&mem_dir).unwrap();
let writer = FilesystemMemWriter::new(mem_dir.clone());
let mut engine = Engine::from_mounts(vec![(
folder_mount("specs", mem_dir),
Box::new(writer) as Box<dyn MemBackend>,
)])
.unwrap();
let err = mem_management::delete_mem(
&mut engine,
mem_management::MemDeleteParams {
name: "specs".to_string(),
delete_files: false,
note: None,
detach_incoming: false,
operator_mode: false,
},
)
.unwrap_err();
match err {
FullEngineError::MemPathNotAllowed {
reason, candidate, ..
} => {
assert_eq!(reason, "no_allowlist_configured");
assert_eq!(candidate, "specs");
}
other => panic!("expected MemPathNotAllowed, got {other:?}"),
}
assert!(engine.mem_router().is_writable("specs"));
}
#[test]
fn delete_mem_rejects_unknown_name() {
let tmp = TempDir::new().unwrap();
let mem_dir = tmp.path().join("specs");
std::fs::create_dir_all(&mem_dir).unwrap();
let writer = FilesystemMemWriter::new(mem_dir.clone());
let mut engine = Engine::from_mounts(vec![(
folder_mount("specs", mem_dir),
Box::new(writer) as Box<dyn MemBackend>,
)])
.unwrap();
let err = mem_management::delete_mem(
&mut engine,
mem_management::MemDeleteParams {
name: "missing".to_string(),
delete_files: false,
note: None,
detach_incoming: false,
operator_mode: false,
},
)
.unwrap_err();
assert!(matches!(
err,
FullEngineError::Lean(EngineError::UnknownMem(v)) if v == "missing"
));
}
#[test]
fn delete_mem_rejects_overlong_note() {
let tmp = TempDir::new().unwrap();
let mem_dir = tmp.path().join("specs");
std::fs::create_dir_all(&mem_dir).unwrap();
let writer = FilesystemMemWriter::new(mem_dir.clone());
let mut engine = Engine::from_mounts(vec![(
folder_mount("specs", mem_dir),
Box::new(writer) as Box<dyn MemBackend>,
)])
.unwrap();
let long_note = "x".repeat(mem_management::NOTE_MAX_LEN + 1);
let err = mem_management::delete_mem(
&mut engine,
mem_management::MemDeleteParams {
name: "specs".to_string(),
delete_files: false,
note: Some(long_note),
detach_incoming: false,
operator_mode: false,
},
)
.unwrap_err();
match err {
FullEngineError::Lean(EngineError::InvalidInput(msg)) => {
assert!(msg.contains("note exceeds"))
}
other => panic!("expected InvalidInput, got {other:?}"),
}
}
#[test]
fn delete_mem_unregisters_when_allowlist_matches() {
let tmp = TempDir::new().unwrap();
let mem_dir = tmp.path().join("specs");
std::fs::create_dir_all(&mem_dir).unwrap();
let writer = FilesystemMemWriter::new(mem_dir.clone());
let mut engine = Engine::from_mounts(vec![(
folder_mount("specs", mem_dir.clone()),
Box::new(writer) as Box<dyn MemBackend>,
)])
.unwrap();
engine.set_settings(WorkspaceSettings {
mem_create_rules: Vec::new(),
mem_delete_rules: vec![DeleteRuleSetting {
pattern: "specs".to_string(),
}],
cross_mem_links: Default::default(),
..Default::default()
});
let response = mem_management::delete_mem(
&mut engine,
mem_management::MemDeleteParams {
name: "specs".to_string(),
delete_files: false,
note: None,
detach_incoming: false,
operator_mode: false,
},
)
.unwrap();
assert_eq!(response.name, "specs");
assert!(response.deleted_from_router);
assert!(!response.files_deleted);
assert!(!engine.mem_router().is_writable("specs"));
assert!(mem_dir.exists(), "delete_files=false leaves directory");
}
#[test]
fn create_delete_round_trip_flat_namespace() {
let tmp = TempDir::new().unwrap();
let mem_dir = tmp.path().join("seed");
std::fs::create_dir_all(&mem_dir).unwrap();
let writer = FilesystemMemWriter::new(mem_dir.clone());
let mut engine = Engine::from_mounts(vec![(
folder_mount("seed", mem_dir),
Box::new(writer) as Box<dyn MemBackend>,
)])
.unwrap();
engine.set_settings(WorkspaceSettings {
mem_create_rules: vec![CreateRuleSetting {
pattern: "exec-*".to_string(),
schemas: vec!["default@1.0.0".to_string()],
default_cross_links: None,
}],
mem_delete_rules: vec![DeleteRuleSetting {
pattern: "exec-*".to_string(),
}],
cross_mem_links: Default::default(),
..Default::default()
});
let new_loc = tmp.path().join("exec-foo");
mem_management::create_mem(
&mut engine,
mem_management::MemCreateParams {
write_guidance: Default::default(),
name: "exec-foo".to_string(),
vcs: None,
location: new_loc.clone(),
schema_ref: "default@1.0.0".parse().unwrap(),
note: None,
operator_mode: false,
recovery: None,
storage: None,
actor: memstead_base::vcs::Actor::Cli,
client: None,
},
)
.unwrap();
assert!(engine.mem_router().is_writable("exec-foo"));
let response = mem_management::delete_mem(
&mut engine,
mem_management::MemDeleteParams {
name: "exec-foo".to_string(),
delete_files: false,
note: None,
detach_incoming: false,
operator_mode: false,
},
)
.unwrap();
assert!(response.deleted_from_router);
assert!(!engine.mem_router().is_writable("exec-foo"));
}
#[test]
fn create_delete_round_trip_hierarchical_namespace() {
let tmp = TempDir::new().unwrap();
let mem_dir = tmp.path().join("seed");
std::fs::create_dir_all(&mem_dir).unwrap();
let writer = FilesystemMemWriter::new(mem_dir.clone());
let mut engine = Engine::from_mounts(vec![(
folder_mount("seed", mem_dir),
Box::new(writer) as Box<dyn MemBackend>,
)])
.unwrap();
engine.set_settings(WorkspaceSettings {
mem_create_rules: vec![CreateRuleSetting {
pattern: "planning/plan-*".to_string(),
schemas: vec!["default@1.0.0".to_string()],
default_cross_links: None,
}],
mem_delete_rules: vec![DeleteRuleSetting {
pattern: "planning/plan-*".to_string(),
}],
cross_mem_links: Default::default(),
..Default::default()
});
let new_loc = tmp.path().join("plan-foo");
mem_management::create_mem(
&mut engine,
mem_management::MemCreateParams {
write_guidance: Default::default(),
name: "planning/plan-foo".to_string(),
vcs: None,
location: new_loc.clone(),
schema_ref: "default@1.0.0".parse().unwrap(),
note: None,
operator_mode: false,
recovery: None,
storage: None,
actor: memstead_base::vcs::Actor::Cli,
client: None,
},
)
.unwrap();
assert!(engine.mem_router().is_writable("planning/plan-foo"));
assert!(!engine.mem_router().is_writable("plan-foo"));
let response = mem_management::delete_mem(
&mut engine,
mem_management::MemDeleteParams {
name: "planning/plan-foo".to_string(),
delete_files: false,
note: None,
detach_incoming: false,
operator_mode: false,
},
)
.unwrap();
assert!(response.deleted_from_router);
assert!(!engine.mem_router().is_writable("planning/plan-foo"));
}
#[test]
fn delete_mem_hierarchical_name_in_path_not_allowed_envelope() {
let tmp = TempDir::new().unwrap();
let mem_dir = tmp.path().join("seed");
std::fs::create_dir_all(&mem_dir).unwrap();
let writer = FilesystemMemWriter::new(mem_dir.clone());
let mut engine = Engine::from_mounts(vec![(
folder_mount("seed", mem_dir),
Box::new(writer) as Box<dyn MemBackend>,
)])
.unwrap();
engine.set_settings(WorkspaceSettings {
mem_create_rules: vec![CreateRuleSetting {
pattern: "planning/plan-*".to_string(),
schemas: vec!["default@1.0.0".to_string()],
default_cross_links: None,
}],
mem_delete_rules: vec![DeleteRuleSetting {
pattern: "plan-*".to_string(),
}],
cross_mem_links: Default::default(),
..Default::default()
});
mem_management::create_mem(
&mut engine,
mem_management::MemCreateParams {
write_guidance: Default::default(),
name: "planning/plan-foo".to_string(),
vcs: None,
location: tmp.path().join("plan-foo"),
schema_ref: "default@1.0.0".parse().unwrap(),
note: None,
operator_mode: false,
recovery: None,
storage: None,
actor: memstead_base::vcs::Actor::Cli,
client: None,
},
)
.unwrap();
let err = mem_management::delete_mem(
&mut engine,
mem_management::MemDeleteParams {
name: "planning/plan-foo".to_string(),
delete_files: false,
note: None,
detach_incoming: false,
operator_mode: false,
},
)
.unwrap_err();
match err {
FullEngineError::MemPathNotAllowed {
reason, candidate, ..
} => {
assert_eq!(reason, "no_match");
assert_eq!(candidate, "planning/plan-foo");
}
other => panic!("expected MemPathNotAllowed, got {other:?}"),
}
assert!(engine.mem_router().is_writable("planning/plan-foo"));
}
#[test]
fn delete_mem_with_delete_files_removes_directory() {
let tmp = TempDir::new().unwrap();
let mem_dir = tmp.path().join("specs");
std::fs::create_dir_all(&mem_dir).unwrap();
let writer = FilesystemMemWriter::new(mem_dir.clone());
let mut engine = Engine::from_mounts(vec![(
folder_mount("specs", mem_dir.clone()),
Box::new(writer) as Box<dyn MemBackend>,
)])
.unwrap();
engine.set_settings(WorkspaceSettings {
mem_create_rules: Vec::new(),
mem_delete_rules: vec![DeleteRuleSetting {
pattern: "*".to_string(),
}],
cross_mem_links: Default::default(),
..Default::default()
});
let response = mem_management::delete_mem(
&mut engine,
mem_management::MemDeleteParams {
name: "specs".to_string(),
delete_files: true,
note: None,
detach_incoming: false,
operator_mode: false,
},
)
.unwrap();
assert!(response.deleted_from_router);
assert!(response.files_deleted);
assert!(!mem_dir.exists(), "delete_files=true removes directory");
}
#[test]
fn create_mem_operator_mode_bypasses_empty_allowlist() {
let tmp = TempDir::new().unwrap();
let mem_dir = tmp.path().join("seed");
std::fs::create_dir_all(&mem_dir).unwrap();
let writer = FilesystemMemWriter::new(mem_dir.clone());
let mut engine = Engine::from_mounts(vec![(
folder_mount("seed", mem_dir),
Box::new(writer) as Box<dyn MemBackend>,
)])
.unwrap();
engine.set_settings(WorkspaceSettings::default());
let agent_err = mem_management::create_mem(
&mut engine,
mem_management::MemCreateParams {
write_guidance: Default::default(),
name: "alpha".to_string(),
vcs: None,
location: tmp.path().join("alpha"),
schema_ref: "default@1.0.0".parse().unwrap(),
note: None,
operator_mode: false,
recovery: None,
storage: None,
actor: memstead_base::vcs::Actor::Cli,
client: None,
},
)
.unwrap_err();
match agent_err {
FullEngineError::MemPathNotAllowed { reason, .. } => {
assert_eq!(reason, "no_allowlist_configured");
}
other => panic!("expected MemPathNotAllowed, got {other:?}"),
}
let response = mem_management::create_mem(
&mut engine,
mem_management::MemCreateParams {
write_guidance: Default::default(),
name: "alpha".to_string(),
vcs: None,
location: tmp.path().join("alpha"),
schema_ref: "default@1.0.0".parse().unwrap(),
note: None,
operator_mode: true,
recovery: None,
storage: None,
actor: memstead_base::vcs::Actor::Cli,
client: None,
},
)
.unwrap();
assert_eq!(response.name, "alpha");
assert!(engine.mem_router().is_writable("alpha"));
}
#[test]
fn create_mem_operator_mode_still_enforces_input_validation() {
let tmp = TempDir::new().unwrap();
let mem_dir = tmp.path().join("seed");
std::fs::create_dir_all(&mem_dir).unwrap();
let writer = FilesystemMemWriter::new(mem_dir.clone());
let mut engine = Engine::from_mounts(vec![(
folder_mount("seed", mem_dir),
Box::new(writer) as Box<dyn MemBackend>,
)])
.unwrap();
engine.set_settings(WorkspaceSettings::default());
let err = mem_management::create_mem(
&mut engine,
mem_management::MemCreateParams {
write_guidance: Default::default(),
name: "alpha".to_string(),
vcs: None,
location: tmp.path().join("alpha"),
schema_ref: "default@1.0.0".parse().unwrap(),
note: Some("x".repeat(mem_management::NOTE_MAX_LEN + 1)),
operator_mode: true,
recovery: None,
storage: None,
actor: memstead_base::vcs::Actor::Cli,
client: None,
},
)
.unwrap_err();
match err {
FullEngineError::Lean(EngineError::InvalidInput(msg)) => {
assert!(msg.contains("note exceeds"));
}
other => panic!("expected InvalidInput from operator-mode, got {other:?}"),
}
}
#[test]
fn delete_mem_operator_mode_bypasses_empty_allowlist() {
let tmp = TempDir::new().unwrap();
let mem_dir = tmp.path().join("specs");
std::fs::create_dir_all(&mem_dir).unwrap();
let writer = FilesystemMemWriter::new(mem_dir.clone());
let mut engine = Engine::from_mounts(vec![(
folder_mount("specs", mem_dir.clone()),
Box::new(writer) as Box<dyn MemBackend>,
)])
.unwrap();
engine.set_settings(WorkspaceSettings::default());
let agent_err = mem_management::delete_mem(
&mut engine,
mem_management::MemDeleteParams {
name: "specs".to_string(),
delete_files: false,
note: None,
detach_incoming: false,
operator_mode: false,
},
)
.unwrap_err();
match agent_err {
FullEngineError::MemPathNotAllowed { reason, .. } => {
assert_eq!(reason, "no_allowlist_configured");
}
other => panic!("expected MemPathNotAllowed, got {other:?}"),
}
let response = mem_management::delete_mem(
&mut engine,
mem_management::MemDeleteParams {
name: "specs".to_string(),
delete_files: false,
note: None,
detach_incoming: false,
operator_mode: true,
},
)
.unwrap();
assert!(response.deleted_from_router);
assert!(!engine.mem_router().is_writable("specs"));
}
#[test]
fn delete_mem_policy_check_gates_on_delete_files() {
use memstead_schema::workspace_config::CrossLinkValue;
let tmp = TempDir::new().unwrap();
let target_dir = tmp.path().join("target");
let referrer_dir = tmp.path().join("referrer");
std::fs::create_dir_all(&target_dir).unwrap();
std::fs::create_dir_all(&referrer_dir).unwrap();
let target_writer = FilesystemMemWriter::new(target_dir.clone());
let referrer_writer = FilesystemMemWriter::new(referrer_dir.clone());
let mut engine = Engine::from_mounts(vec![
(
folder_mount("target", target_dir.clone()),
Box::new(target_writer) as Box<dyn MemBackend>,
),
(
folder_mount("referrer", referrer_dir),
Box::new(referrer_writer) as Box<dyn MemBackend>,
),
])
.unwrap();
let mut cross_links: std::collections::BTreeMap<String, CrossLinkValue> = Default::default();
cross_links.insert(
"referrer".to_string(),
CrossLinkValue::List(vec!["target".to_string()]),
);
engine.set_settings(WorkspaceSettings {
mem_create_rules: Vec::new(),
mem_delete_rules: vec![DeleteRuleSetting {
pattern: "*".to_string(),
}],
cross_mem_links: cross_links,
..Default::default()
});
let agent_destroy_err = mem_management::delete_mem(
&mut engine,
mem_management::MemDeleteParams {
name: "target".to_string(),
delete_files: true,
note: None,
detach_incoming: false,
operator_mode: false,
},
)
.unwrap_err();
match agent_destroy_err {
FullEngineError::MemReferencedByPolicy {
name,
referring_mems,
} => {
assert_eq!(name, "target");
assert_eq!(referring_mems, vec!["referrer".to_string()]);
}
other => {
panic!("expected MemReferencedByPolicy for agent-mode delete_files=true, got {other:?}")
}
}
let operator_destroy_err = mem_management::delete_mem(
&mut engine,
mem_management::MemDeleteParams {
name: "target".to_string(),
delete_files: true,
note: None,
detach_incoming: false,
operator_mode: true,
},
)
.unwrap_err();
match operator_destroy_err {
FullEngineError::MemReferencedByPolicy { name, .. } => {
assert_eq!(name, "target");
}
other => panic!(
"expected MemReferencedByPolicy for operator-mode delete_files=true, got {other:?}"
),
}
let agent_unregister = mem_management::delete_mem(
&mut engine,
mem_management::MemDeleteParams {
name: "target".to_string(),
delete_files: false,
note: None,
detach_incoming: false,
operator_mode: false,
},
)
.unwrap();
assert!(agent_unregister.deleted_from_router);
assert!(!engine.mem_router().is_writable("target"));
}
#[test]
fn delete_mem_refuses_when_cross_mem_incoming_edges_remain() {
use memstead_base::ops::RelateArg;
use memstead_base::vcs::Actor;
use memstead_base::{CreateEntityArgs, RelateEntityArgs};
use memstead_schema::workspace_config::CrossLinkValue;
let tmp = TempDir::new().unwrap();
let a_dir = tmp.path().join("mem-a");
let b_dir = tmp.path().join("mem-b");
std::fs::create_dir_all(&a_dir).unwrap();
std::fs::create_dir_all(&b_dir).unwrap();
let a_writer = FilesystemMemWriter::new(a_dir.clone());
let b_writer = FilesystemMemWriter::new(b_dir.clone());
let mut engine = Engine::from_mounts(vec![
(
folder_mount("a", a_dir.clone()),
Box::new(a_writer) as Box<dyn MemBackend>,
),
(
folder_mount("b", b_dir.clone()),
Box::new(b_writer) as Box<dyn MemBackend>,
),
])
.unwrap();
let mut cross_links: std::collections::BTreeMap<String, CrossLinkValue> = Default::default();
cross_links.insert("a".to_string(), CrossLinkValue::List(vec!["b".to_string()]));
engine.set_settings(WorkspaceSettings {
mem_create_rules: Vec::new(),
mem_delete_rules: vec![DeleteRuleSetting {
pattern: "b".to_string(),
}],
cross_mem_links: cross_links,
..Default::default()
});
let seed_sections = || {
let mut sample = CreateEntityArgs {
anchors: Vec::new(),
mem: String::new(),
title: String::new(),
entity_type: String::new(),
sections: Default::default(),
metadata: Default::default(),
relations: Vec::new(),
dry_run: false,
};
sample
.sections
.insert("identity".to_string(), "seed identity".to_string());
sample
.sections
.insert("purpose".to_string(), "seed purpose".to_string());
sample.sections
};
engine
.create_entity(
CreateEntityArgs {
anchors: Vec::new(),
mem: "b".to_string(),
title: "Target".to_string(),
entity_type: "spec".to_string(),
sections: seed_sections(),
metadata: Default::default(),
relations: Vec::new(),
dry_run: false,
},
Actor::Cli,
None,
None,
)
.expect("seed b--target");
engine
.create_entity(
CreateEntityArgs {
anchors: Vec::new(),
mem: "a".to_string(),
title: "Source".to_string(),
entity_type: "spec".to_string(),
sections: seed_sections(),
metadata: Default::default(),
relations: vec![RelateArg {
to: memstead_base::EntityId::canonical("b--target"),
rel_type: "DEPENDS_ON".to_string(),
description: None,
}],
dry_run: false,
},
Actor::Cli,
None,
None,
)
.expect("seed a--source with DEPENDS_ON edge");
engine.set_settings(WorkspaceSettings {
mem_create_rules: Vec::new(),
mem_delete_rules: vec![DeleteRuleSetting {
pattern: "b".to_string(),
}],
cross_mem_links: Default::default(),
..Default::default()
});
let err = mem_management::delete_mem(
&mut engine,
mem_management::MemDeleteParams {
name: "b".to_string(),
delete_files: true,
note: None,
detach_incoming: false,
operator_mode: false,
},
)
.unwrap_err();
match err {
FullEngineError::Lean(EngineError::MemHasIncomingRefs { mem, referrers }) => {
assert_eq!(mem, "b");
assert_eq!(referrers.len(), 1, "exactly one Write-Mem referrer");
assert_eq!(referrers[0].from_id, "a--source");
assert_eq!(referrers[0].mem, "a");
assert_eq!(referrers[0].rel_types, vec!["DEPENDS_ON".to_string()]);
}
other => panic!("expected MemHasIncomingRefs, got {other:?}"),
}
assert!(engine.mem_router().is_writable("b"));
let mut cross_links: std::collections::BTreeMap<String, CrossLinkValue> = Default::default();
cross_links.insert("a".to_string(), CrossLinkValue::List(vec!["b".to_string()]));
engine.set_settings(WorkspaceSettings {
mem_create_rules: Vec::new(),
mem_delete_rules: vec![DeleteRuleSetting {
pattern: "b".to_string(),
}],
cross_mem_links: cross_links,
..Default::default()
});
engine
.relate_entity(
RelateEntityArgs {
source: memstead_base::EntityId::canonical("a--source"),
target: memstead_base::EntityId::canonical("b--target"),
rel_type: "DEPENDS_ON".to_string(),
description: None,
expected_hash: None,
remove: true,
dry_run: false,
},
Actor::Cli,
None,
None,
)
.expect("relate --remove");
engine.set_settings(WorkspaceSettings {
mem_create_rules: Vec::new(),
mem_delete_rules: vec![DeleteRuleSetting {
pattern: "b".to_string(),
}],
cross_mem_links: Default::default(),
..Default::default()
});
let response = mem_management::delete_mem(
&mut engine,
mem_management::MemDeleteParams {
name: "b".to_string(),
delete_files: true,
note: None,
detach_incoming: false,
operator_mode: false,
},
)
.expect("delete succeeds after edge removed");
assert!(response.deleted_from_router);
assert!(!engine.mem_router().is_writable("b"));
let dangling = memstead_base::ops::health::collect_dangling_links(engine.store(), None);
assert!(
dangling.is_empty(),
"post-delete state must be coherent; got dangling = {dangling:?}"
);
}
#[test]
fn delete_mem_router_only_refuses_when_cross_mem_incoming_edges_remain() {
use memstead_base::CreateEntityArgs;
use memstead_base::ops::RelateArg;
use memstead_base::vcs::Actor;
use memstead_schema::workspace_config::CrossLinkValue;
let tmp = TempDir::new().unwrap();
let a_dir = tmp.path().join("mem-a");
let b_dir = tmp.path().join("mem-b");
std::fs::create_dir_all(&a_dir).unwrap();
std::fs::create_dir_all(&b_dir).unwrap();
let a_writer = FilesystemMemWriter::new(a_dir.clone());
let b_writer = FilesystemMemWriter::new(b_dir.clone());
let mut engine = Engine::from_mounts(vec![
(
folder_mount("a", a_dir.clone()),
Box::new(a_writer) as Box<dyn MemBackend>,
),
(
folder_mount("b", b_dir.clone()),
Box::new(b_writer) as Box<dyn MemBackend>,
),
])
.unwrap();
let mut cross_links: std::collections::BTreeMap<String, CrossLinkValue> = Default::default();
cross_links.insert("a".to_string(), CrossLinkValue::List(vec!["b".to_string()]));
engine.set_settings(WorkspaceSettings {
mem_create_rules: Vec::new(),
mem_delete_rules: vec![DeleteRuleSetting {
pattern: "b".to_string(),
}],
cross_mem_links: cross_links,
..Default::default()
});
let seed_sections = || {
let mut sample = CreateEntityArgs {
anchors: Vec::new(),
mem: String::new(),
title: String::new(),
entity_type: String::new(),
sections: Default::default(),
metadata: Default::default(),
relations: Vec::new(),
dry_run: false,
};
sample
.sections
.insert("identity".to_string(), "seed identity".to_string());
sample
.sections
.insert("purpose".to_string(), "seed purpose".to_string());
sample.sections
};
engine
.create_entity(
CreateEntityArgs {
anchors: Vec::new(),
mem: "b".to_string(),
title: "Target".to_string(),
entity_type: "spec".to_string(),
sections: seed_sections(),
metadata: Default::default(),
relations: Vec::new(),
dry_run: false,
},
Actor::Cli,
None,
None,
)
.expect("seed b--target");
engine
.create_entity(
CreateEntityArgs {
anchors: Vec::new(),
mem: "a".to_string(),
title: "Source".to_string(),
entity_type: "spec".to_string(),
sections: seed_sections(),
metadata: Default::default(),
relations: vec![RelateArg {
to: memstead_base::EntityId::canonical("b--target"),
rel_type: "DEPENDS_ON".to_string(),
description: None,
}],
dry_run: false,
},
Actor::Cli,
None,
None,
)
.expect("seed a--source with DEPENDS_ON edge");
let err = mem_management::delete_mem(
&mut engine,
mem_management::MemDeleteParams {
name: "b".to_string(),
delete_files: false,
note: None,
detach_incoming: false,
operator_mode: false,
},
)
.unwrap_err();
match err {
FullEngineError::Lean(EngineError::MemHasIncomingRefs { mem, referrers }) => {
assert_eq!(mem, "b");
assert_eq!(referrers.len(), 1);
assert_eq!(referrers[0].from_id, "a--source");
assert_eq!(referrers[0].mem, "a");
assert_eq!(referrers[0].rel_types, vec!["DEPENDS_ON".to_string()]);
}
other => panic!("expected MemHasIncomingRefs, got {other:?}"),
}
assert!(engine.mem_router().is_writable("b"));
}
#[test]
fn destructive_delete_scrubs_cross_links_but_keeps_allowlist_rules() {
use memstead_base::CreateEntityArgs;
use memstead_base::vcs::Actor;
let tmp = TempDir::new().unwrap();
let workspace = tmp.path().join("ws");
let memstead = workspace.join(".memstead");
std::fs::create_dir_all(&memstead).unwrap();
std::fs::write(
memstead.join("workspace.toml"),
"format = \"memstead-git-branch-2\"\n\n\
[cross_mem_links]\n\
other = [\"test\"]\n\
keep = [\"other\", \"survive\"]\n\
\n\
[[mem_management.create]]\n\
pattern = \"other\"\n\
schemas = [\"default@1.0.0\"]\n\
\n\
[[mem_management.create]]\n\
pattern = \"*\"\n\
schemas = [\"default@1.0.0\"]\n\
\n\
[[mem_management.delete]]\n\
pattern = \"other\"\n",
)
.unwrap();
let other_dir = workspace.join("other");
std::fs::create_dir_all(&other_dir).unwrap();
let mut engine = Engine::from_mounts(vec![(
folder_mount("other", other_dir.clone()),
Box::new(FilesystemMemWriter::new(other_dir)) as Box<dyn MemBackend>,
)])
.unwrap();
engine.set_workspace_root(workspace.clone());
engine.set_settings(WorkspaceSettings {
mem_create_rules: Vec::new(),
mem_delete_rules: vec![DeleteRuleSetting {
pattern: "other".to_string(),
}],
cross_mem_links: Default::default(),
..Default::default()
});
let mut sample = CreateEntityArgs {
anchors: Vec::new(),
mem: String::new(),
title: String::new(),
entity_type: String::new(),
sections: Default::default(),
metadata: Default::default(),
relations: Vec::new(),
dry_run: false,
};
sample
.sections
.insert("identity".to_string(), "seed identity".to_string());
sample
.sections
.insert("purpose".to_string(), "seed purpose".to_string());
engine
.create_entity(
CreateEntityArgs {
anchors: Vec::new(),
mem: "other".to_string(),
title: "Solo".to_string(),
entity_type: "spec".to_string(),
sections: sample.sections,
metadata: Default::default(),
relations: Vec::new(),
dry_run: false,
},
Actor::Cli,
None,
None,
)
.expect("seed other--solo");
let response = mem_management::delete_mem(
&mut engine,
mem_management::MemDeleteParams {
name: "other".to_string(),
delete_files: true,
note: None,
detach_incoming: false,
operator_mode: false,
},
)
.expect("destructive delete succeeds");
assert!(response.deleted_from_router);
let after = std::fs::read_to_string(memstead.join("workspace.toml")).unwrap();
assert!(
!after.contains("\nother = ["),
"`other` key must be scrubbed from cross_mem_links — got:\n{after}"
);
assert!(
after.contains("\"survive\""),
"non-target cross-link values must survive — got:\n{after}"
);
assert_eq!(
after.matches("pattern = \"other\"").count(),
2,
"exact-name mem_management.{{create,delete}} rules for `other` must be preserved — got:\n{after}"
);
assert!(
after.contains("pattern = \"*\""),
"wildcard rule must survive — got:\n{after}"
);
assert!(
!engine.settings().cross_mem_links.contains_key("other"),
"post-scrub in-memory settings still surface the dropped key"
);
assert!(
!engine
.settings()
.cross_mem_links
.get("keep")
.map(|v| matches!(v,
memstead_schema::workspace_config::CrossLinkValue::List(l) if l.iter().any(|s| s == "other")
))
.unwrap_or(false),
"post-scrub in-memory settings still surface the dropped value"
);
}
#[test]
fn router_only_unregister_leaves_policy_intact() {
let tmp = TempDir::new().unwrap();
let workspace = tmp.path().join("ws");
let memstead = workspace.join(".memstead");
std::fs::create_dir_all(&memstead).unwrap();
let toml_body = "format = \"memstead-git-branch-2\"\n\n\
[cross_mem_links]\n\
other = [\"test\"]\n";
std::fs::write(memstead.join("workspace.toml"), toml_body).unwrap();
let other_dir = workspace.join("other");
std::fs::create_dir_all(&other_dir).unwrap();
let mut engine = Engine::from_mounts(vec![(
folder_mount("other", other_dir.clone()),
Box::new(FilesystemMemWriter::new(other_dir)) as Box<dyn MemBackend>,
)])
.unwrap();
engine.set_workspace_root(workspace.clone());
engine.set_settings(WorkspaceSettings {
mem_create_rules: Vec::new(),
mem_delete_rules: vec![DeleteRuleSetting {
pattern: "other".to_string(),
}],
..Default::default()
});
let _ = mem_management::delete_mem(
&mut engine,
mem_management::MemDeleteParams {
name: "other".to_string(),
delete_files: false,
note: None,
detach_incoming: false,
operator_mode: false,
},
)
.expect("router-only unregister succeeds");
let after = std::fs::read_to_string(memstead.join("workspace.toml")).unwrap();
assert_eq!(
after, toml_body,
"unregister-only must leave the policy file byte-identical"
);
}
#[test]
fn policy_mutation_refreshes_engine_settings() {
let tmp = TempDir::new().unwrap();
let workspace = tmp.path().join("ws");
let memstead = workspace.join(".memstead");
std::fs::create_dir_all(&memstead).unwrap();
std::fs::write(
memstead.join("workspace.toml"),
r#"
format = "memstead-git-branch-2"
[persistence_adapter]
name = "file-two-layer"
[cross_mem_links]
a = ["b"]
"#,
)
.unwrap();
let mem_dir = workspace.join("mem");
std::fs::create_dir_all(&mem_dir).unwrap();
let writer = FilesystemMemWriter::new(mem_dir.clone());
let mut engine = Engine::from_mounts(vec![(
folder_mount("v", mem_dir),
Box::new(writer) as Box<dyn MemBackend>,
)])
.unwrap();
let initial = memstead_base::workspace_store::parse_workspace_settings(&workspace).unwrap();
engine.set_settings(initial);
assert!(
engine.settings().cross_mem_links.contains_key("a"),
"engine must surface the seed grant; got {:?}",
engine.settings().cross_mem_links
);
std::fs::write(
memstead.join("workspace.toml"),
r#"
format = "memstead-git-branch-2"
[persistence_adapter]
name = "file-two-layer"
[cross_mem_links]
"#,
)
.unwrap();
assert!(
engine.settings().cross_mem_links.contains_key("a"),
"pre-refresh cache must still show the old state — proves the bug existed",
);
let refreshed = memstead_base::workspace_store::parse_workspace_settings(&workspace).unwrap();
engine.set_settings(refreshed);
assert!(
engine.settings().cross_mem_links.is_empty(),
"post-refresh cache must reflect the revoke; got {:?}",
engine.settings().cross_mem_links
);
}
#[test]
fn delete_mem_succeeds_when_no_cross_mem_incoming_edges() {
let tmp = TempDir::new().unwrap();
let a_dir = tmp.path().join("mem-a");
let b_dir = tmp.path().join("mem-b");
std::fs::create_dir_all(&a_dir).unwrap();
std::fs::create_dir_all(&b_dir).unwrap();
let a_writer = FilesystemMemWriter::new(a_dir.clone());
let b_writer = FilesystemMemWriter::new(b_dir.clone());
let mut engine = Engine::from_mounts(vec![
(
folder_mount("a", a_dir.clone()),
Box::new(a_writer) as Box<dyn MemBackend>,
),
(
folder_mount("b", b_dir.clone()),
Box::new(b_writer) as Box<dyn MemBackend>,
),
])
.unwrap();
engine.set_settings(WorkspaceSettings {
mem_create_rules: Vec::new(),
mem_delete_rules: vec![DeleteRuleSetting {
pattern: "b".to_string(),
}],
cross_mem_links: Default::default(),
..Default::default()
});
let response = mem_management::delete_mem(
&mut engine,
mem_management::MemDeleteParams {
name: "b".to_string(),
delete_files: true,
note: None,
detach_incoming: false,
operator_mode: false,
},
)
.expect("delete with no edges should succeed");
assert!(response.deleted_from_router);
assert!(!engine.mem_router().is_writable("b"));
}
#[test]
fn rule_derived_cross_link_grant_is_enforced_and_surfaced_in_overview() {
use memstead_schema::workspace_config::CrossLinkValue;
let tmp = TempDir::new().unwrap();
let mem_dir = tmp.path().join("seed");
std::fs::create_dir_all(&mem_dir).unwrap();
let writer = FilesystemMemWriter::new(mem_dir.clone());
let mut engine = Engine::from_mounts(vec![(
folder_mount("seed", mem_dir),
Box::new(writer) as Box<dyn MemBackend>,
)])
.unwrap();
engine.set_settings(WorkspaceSettings {
mem_create_rules: vec![CreateRuleSetting {
pattern: "scratch".to_string(),
schemas: vec!["default".to_string()],
default_cross_links: Some(CrossLinkValue::List(vec!["seed".to_string()])),
}],
mem_delete_rules: Vec::new(),
cross_mem_links: Default::default(),
..Default::default()
});
assert!(engine.cross_mem_link_allowed("scratch", "seed"));
assert!(!engine.cross_mem_link_allowed("seed", "scratch"));
assert!(!engine.cross_mem_link_allowed("scratch", "elsewhere"));
assert!(engine.settings().cross_mem_links.is_empty());
let entries = memstead_engine::overview::build_workspace_policy_entries(&engine);
assert!(
entries
.iter()
.any(|(k, v)| *k == "cross_mem_links_from_rules" && v == "named"),
"rule-derived grant must surface in the policy projection: {entries:?}",
);
assert!(
!entries.iter().any(|(k, _)| *k == "cross_mem_links"),
"explicit cross_mem_links must stay absent — nothing materialized: {entries:?}",
);
let out = memstead_engine::overview::compose_overview(
&mut engine,
memstead_engine::overview::OverviewArgs {
include: &[],
mem: None,
rebuild: false,
token_budget: 8000,
operator_mode: false,
suppress_lifecycle: false,
},
memstead_engine::overview::Surface::Mcp,
)
.unwrap();
assert!(
out.markdown.contains("Cross-mem links (rule-derived)") && out.markdown.contains("seed"),
"overview must name the rule-derived cross-link target under the pattern:\n{}",
out.markdown,
);
assert!(
out.policy_flow
.as_deref()
.is_some_and(|f| f.contains("cross_mem_links_from_rules")),
"policy flow must carry the rule-derived posture: {:?}",
out.policy_flow,
);
}
#[test]
fn explicit_and_rule_derived_cross_links_project_as_distinct_entries() {
use memstead_schema::workspace_config::CrossLinkValue;
let tmp = TempDir::new().unwrap();
let mem_dir = tmp.path().join("seed");
std::fs::create_dir_all(&mem_dir).unwrap();
let writer = FilesystemMemWriter::new(mem_dir.clone());
let mut engine = Engine::from_mounts(vec![(
folder_mount("seed", mem_dir),
Box::new(writer) as Box<dyn MemBackend>,
)])
.unwrap();
let mut cross_links = std::collections::BTreeMap::new();
cross_links.insert("seed".to_string(), CrossLinkValue::Wildcard);
engine.set_settings(WorkspaceSettings {
mem_create_rules: vec![CreateRuleSetting {
pattern: "scratch".to_string(),
schemas: vec!["default".to_string()],
default_cross_links: Some(CrossLinkValue::List(vec!["seed".to_string()])),
}],
mem_delete_rules: Vec::new(),
cross_mem_links: cross_links,
..Default::default()
});
let entries = memstead_engine::overview::build_workspace_policy_entries(&engine);
assert!(
entries
.iter()
.any(|(k, v)| *k == "cross_mem_links" && v == "wildcard"),
"explicit grant posture must project unchanged: {entries:?}",
);
assert!(
entries
.iter()
.any(|(k, v)| *k == "cross_mem_links_from_rules" && v == "named"),
"rule-derived grant must project as its own entry: {entries:?}",
);
}