use std::fs;
use std::path::{Path, PathBuf};
use toml_edit::{Array, ArrayOfTables, DocumentMut, Item, Table, Value};
#[derive(Debug)]
pub enum WorkspaceEditError {
WorkspaceNotInitialised { path: PathBuf },
InvalidToml { path: PathBuf, message: String },
BeforePatternNotFound {
section: &'static str,
pattern: String,
},
CrossLinkConflict { from: String, message: String },
RuleExistsSchemasDiffer {
section: &'static str,
pattern: String,
stored: Vec<String>,
requested: Vec<String>,
},
Io {
path: PathBuf,
source: std::io::Error,
},
}
#[derive(Debug, Clone)]
pub enum WorkspaceEditWarning {
RuleAlreadyPresent {
section: &'static str,
pattern: String,
},
RuleNotFoundNoop {
section: &'static str,
pattern: String,
},
GrantAlreadyPresent { from: String, to: String },
GrantNotFound { from: String, to: String },
CrossLinkTargetUnregistered { to: String },
CrossLinkSelfGrantNoop { mem: String },
}
impl WorkspaceEditWarning {
pub fn code(&self) -> &'static str {
match self {
Self::RuleAlreadyPresent { .. } => "RULE_ALREADY_PRESENT",
Self::RuleNotFoundNoop { .. } => "RULE_NOT_FOUND_NOOP",
Self::GrantAlreadyPresent { .. } => "GRANT_ALREADY_PRESENT",
Self::GrantNotFound { .. } => "GRANT_NOT_FOUND",
Self::CrossLinkTargetUnregistered { .. } => "CROSS_LINK_TARGET_UNREGISTERED",
Self::CrossLinkSelfGrantNoop { .. } => "CROSS_LINK_SELF_GRANT_NOOP",
}
}
}
impl std::fmt::Display for WorkspaceEditWarning {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::RuleAlreadyPresent { section, pattern } => write!(
f,
"`[[{section}]]` already contains an entry for pattern `{pattern}` — file unchanged"
),
Self::RuleNotFoundNoop { section, pattern } => write!(
f,
"`[[{section}]]` has no entry for pattern `{pattern}` — file unchanged"
),
Self::GrantAlreadyPresent { from, to } => write!(
f,
"`[cross_mem_links]` already grants {from} → {to} — file unchanged"
),
Self::GrantNotFound { from, to } => write!(
f,
"`[cross_mem_links]` does not grant {from} → {to} — file unchanged"
),
Self::CrossLinkTargetUnregistered { to } => write!(
f,
"cross-link target `{to}` is not a registered mem — the grant is persisted (forward-reference is allowed) but will validate no relate until `{to}` exists"
),
Self::CrossLinkSelfGrantNoop { mem } => write!(
f,
"self-grant `{mem} → {mem}` is a no-op — intra-mem links never traverse the cross-link gate; the grant is persisted but has no effect"
),
}
}
}
impl std::fmt::Display for WorkspaceEditError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::WorkspaceNotInitialised { path, .. } => write!(
f,
"no `.memstead/workspace.toml` at {} — run `memstead mem-repo init` or `memstead init` first",
path.display()
),
Self::InvalidToml { path, message } => {
write!(f, "{}: failed to parse TOML — {message}", path.display())
}
Self::BeforePatternNotFound { section, pattern } => write!(
f,
"`--before {pattern}` did not match any existing `[[{section}]]` entry"
),
Self::CrossLinkConflict { from, message } => write!(
f,
"`[cross_mem_links]` rejects edit for `{from}`: {message}"
),
Self::RuleExistsSchemasDiffer {
section,
pattern,
stored,
requested,
} => write!(
f,
"`[[{section}]]` already has a rule for pattern `{pattern}` pinned to schemas [{}], \
which differs from the requested [{}] — refusing to silently change the schema pins. \
To change them, revoke the rule first (`revoke_create {pattern}`) then re-add it with the new schemas",
stored.join(", "),
requested.join(", "),
),
Self::Io { path, source } => write!(f, "{}: {source}", path.display()),
}
}
}
impl std::error::Error for WorkspaceEditError {}
impl WorkspaceEditError {
pub fn code(&self) -> &'static str {
match self {
Self::WorkspaceNotInitialised { .. } => "WORKSPACE_NOT_INITIALISED",
Self::InvalidToml { .. } => "INVALID_TOML",
Self::BeforePatternNotFound { .. } => "BEFORE_PATTERN_NOT_FOUND",
Self::CrossLinkConflict { .. } => "CROSS_LINK_CONFLICT",
Self::RuleExistsSchemasDiffer { .. } => "RULE_EXISTS_SCHEMAS_DIFFER",
Self::Io { .. } => "IO_ERROR",
}
}
}
pub fn workspace_toml_path(workspace_root: &Path) -> PathBuf {
workspace_root
.join(memstead_base::WORKSPACE_STORE_DIR)
.join("workspace.toml")
}
fn load(workspace_root: &Path) -> Result<(PathBuf, DocumentMut), WorkspaceEditError> {
let path = workspace_toml_path(workspace_root);
let text = fs::read_to_string(&path).map_err(|source| {
if source.kind() == std::io::ErrorKind::NotFound {
WorkspaceEditError::WorkspaceNotInitialised { path: path.clone() }
} else {
WorkspaceEditError::Io {
path: path.clone(),
source,
}
}
})?;
let doc: DocumentMut =
text.parse()
.map_err(|e: toml_edit::TomlError| WorkspaceEditError::InvalidToml {
path: path.clone(),
message: e.to_string(),
})?;
Ok((path, doc))
}
fn save(path: &Path, doc: &DocumentMut) -> Result<(), WorkspaceEditError> {
fs::write(path, doc.to_string()).map_err(|source| WorkspaceEditError::Io {
path: path.to_path_buf(),
source,
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CrossLinkTarget {
Wildcard,
Named(String),
}
impl CrossLinkTarget {
pub fn parse(raw: &str) -> Self {
if raw == "*" {
Self::Wildcard
} else {
Self::Named(raw.to_string())
}
}
}
pub fn add_create_rule(
workspace_root: &Path,
pattern: &str,
schemas: &[String],
default_cross_links: Option<&[CrossLinkTarget]>,
before: Option<&str>,
) -> Result<Vec<WorkspaceEditWarning>, WorkspaceEditError> {
let (path, mut doc) = load(workspace_root)?;
let section = ensure_array_of_tables(&mut doc, "mem_management", "create");
if let Some(idx) = find_pattern_index(section, pattern) {
let stored = read_rule_schemas(section, idx);
if schema_sets_equal(&stored, schemas) {
return Ok(vec![WorkspaceEditWarning::RuleAlreadyPresent {
section: "mem_management.create",
pattern: pattern.to_string(),
}]);
}
return Err(WorkspaceEditError::RuleExistsSchemasDiffer {
section: "mem_management.create",
pattern: pattern.to_string(),
stored,
requested: schemas.to_vec(),
});
}
let mut table = Table::new();
table["pattern"] = Item::Value(Value::from(pattern));
let mut arr = Array::new();
for s in schemas {
arr.push(s.as_str());
}
table["schemas"] = Item::Value(Value::Array(arr));
if let Some(cross_links) = default_cross_links {
table["default_cross_links"] = cross_link_value_item(cross_links);
}
if let Some(before_pattern) = before {
let idx = find_pattern_index(section, before_pattern).ok_or_else(|| {
WorkspaceEditError::BeforePatternNotFound {
section: "mem_management.create",
pattern: before_pattern.to_string(),
}
})?;
let mut tail = Vec::with_capacity(section.len() - idx);
while section.len() > idx {
let last = section.get(section.len() - 1).cloned().unwrap();
tail.push(last);
section.remove(section.len() - 1);
}
section.push(table);
for entry in tail.into_iter().rev() {
section.push(entry);
}
} else {
section.push(table);
}
save(&path, &doc)?;
Ok(Vec::new())
}
pub fn remove_create_rule(
workspace_root: &Path,
pattern: &str,
) -> Result<Vec<WorkspaceEditWarning>, WorkspaceEditError> {
let (path, mut doc) = load(workspace_root)?;
let section = ensure_array_of_tables(&mut doc, "mem_management", "create");
let idx = match find_pattern_index(section, pattern) {
Some(i) => i,
None => {
return Ok(vec![WorkspaceEditWarning::RuleNotFoundNoop {
section: "mem_management.create",
pattern: pattern.to_string(),
}]);
}
};
section.remove(idx);
save(&path, &doc)?;
Ok(Vec::new())
}
pub fn add_delete_rule(
workspace_root: &Path,
pattern: &str,
) -> Result<Vec<WorkspaceEditWarning>, WorkspaceEditError> {
let (path, mut doc) = load(workspace_root)?;
let section = ensure_array_of_tables(&mut doc, "mem_management", "delete");
if find_pattern_index(section, pattern).is_some() {
return Ok(vec![WorkspaceEditWarning::RuleAlreadyPresent {
section: "mem_management.delete",
pattern: pattern.to_string(),
}]);
}
let mut table = Table::new();
table["pattern"] = Item::Value(Value::from(pattern));
section.push(table);
save(&path, &doc)?;
Ok(Vec::new())
}
pub fn remove_delete_rule(
workspace_root: &Path,
pattern: &str,
) -> Result<Vec<WorkspaceEditWarning>, WorkspaceEditError> {
let (path, mut doc) = load(workspace_root)?;
let section = ensure_array_of_tables(&mut doc, "mem_management", "delete");
let idx = match find_pattern_index(section, pattern) {
Some(i) => i,
None => {
return Ok(vec![WorkspaceEditWarning::RuleNotFoundNoop {
section: "mem_management.delete",
pattern: pattern.to_string(),
}]);
}
};
section.remove(idx);
save(&path, &doc)?;
Ok(Vec::new())
}
pub fn grant_cross_link(
workspace_root: &Path,
from: &str,
to: &CrossLinkTarget,
known_mems: &[String],
) -> Result<Vec<WorkspaceEditWarning>, WorkspaceEditError> {
let mut warnings: Vec<WorkspaceEditWarning> = Vec::new();
if let CrossLinkTarget::Named(name) = to {
if name == from {
warnings.push(WorkspaceEditWarning::CrossLinkSelfGrantNoop {
mem: from.to_string(),
});
} else if !known_mems.iter().any(|v| v == name) {
warnings.push(WorkspaceEditWarning::CrossLinkTargetUnregistered { to: name.clone() });
}
}
let (path, mut doc) = load(workspace_root)?;
let table = ensure_table(&mut doc, "cross_mem_links");
match (table.get(from), to) {
(None, CrossLinkTarget::Wildcard) => {
table.insert(from, Item::Value(Value::from("*")));
}
(None, CrossLinkTarget::Named(name)) => {
let mut arr = Array::new();
arr.push(name.as_str());
table.insert(from, Item::Value(Value::Array(arr)));
}
(Some(Item::Value(Value::String(s))), CrossLinkTarget::Wildcard) if s.value() == "*" => {
warnings.push(WorkspaceEditWarning::GrantAlreadyPresent {
from: from.to_string(),
to: "*".to_string(),
});
return Ok(warnings);
}
(Some(Item::Value(Value::String(_))), _) => {
return Err(WorkspaceEditError::CrossLinkConflict {
from: from.to_string(),
message: "wildcard `*` already set — revoke `*` before granting a named target"
.to_string(),
});
}
(Some(Item::Value(Value::Array(_))), CrossLinkTarget::Wildcard) => {
return Err(WorkspaceEditError::CrossLinkConflict {
from: from.to_string(),
message: "specific allowlist already set — revoke every entry before granting `*`"
.to_string(),
});
}
(Some(Item::Value(Value::Array(arr))), CrossLinkTarget::Named(name)) => {
if array_contains(arr, name) {
warnings.push(WorkspaceEditWarning::GrantAlreadyPresent {
from: from.to_string(),
to: name.clone(),
});
return Ok(warnings);
}
let mut arr = arr.clone();
arr.push(name.as_str());
table.insert(from, Item::Value(Value::Array(arr)));
}
(Some(_), _) => {
return Err(WorkspaceEditError::CrossLinkConflict {
from: from.to_string(),
message: "existing value is neither a string nor an array — fix by hand"
.to_string(),
});
}
}
save(&path, &doc)?;
Ok(warnings)
}
pub fn revoke_cross_link(
workspace_root: &Path,
from: &str,
to: &CrossLinkTarget,
) -> Result<Vec<WorkspaceEditWarning>, WorkspaceEditError> {
let (path, mut doc) = load(workspace_root)?;
let table = ensure_table(&mut doc, "cross_mem_links");
let removed = match (table.get(from), to) {
(None, _) => false,
(Some(Item::Value(Value::String(s))), CrossLinkTarget::Wildcard) if s.value() == "*" => {
table.remove(from);
true
}
(Some(Item::Value(Value::String(_))), CrossLinkTarget::Named(_)) => false,
(Some(Item::Value(Value::String(_))), CrossLinkTarget::Wildcard) => false,
(Some(Item::Value(Value::Array(_))), CrossLinkTarget::Wildcard) => false,
(Some(Item::Value(Value::Array(arr))), CrossLinkTarget::Named(name)) => {
let mut arr = arr.clone();
let original_len = arr.len();
arr.retain(|v| match v {
Value::String(s) => s.value() != name,
_ => true,
});
if arr.len() == original_len {
false
} else if arr.is_empty() {
table.remove(from);
true
} else {
table.insert(from, Item::Value(Value::Array(arr)));
true
}
}
(Some(_), _) => false,
};
if !removed {
let target = match to {
CrossLinkTarget::Wildcard => "*".to_string(),
CrossLinkTarget::Named(s) => s.clone(),
};
return Ok(vec![WorkspaceEditWarning::GrantNotFound {
from: from.to_string(),
to: target,
}]);
}
save(&path, &doc)?;
Ok(Vec::new())
}
pub fn set_mutation_require_notes(
workspace_root: &Path,
value: bool,
) -> Result<(), WorkspaceEditError> {
let (path, mut doc) = load(workspace_root)?;
let table = ensure_table(&mut doc, "mutations");
table.insert("require_notes", Item::Value(Value::from(value)));
save(&path, &doc)
}
pub fn scrub_policy_for_deleted_mem(
workspace_root: &Path,
mem_name: &str,
) -> Result<Vec<ScrubbedEntry>, WorkspaceEditError> {
let path = workspace_toml_path(workspace_root);
let text = match fs::read_to_string(&path) {
Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Ok(Vec::new());
}
Err(source) => {
return Err(WorkspaceEditError::Io {
path: path.clone(),
source,
});
}
};
let mut doc: DocumentMut =
text.parse()
.map_err(|e: toml_edit::TomlError| WorkspaceEditError::InvalidToml {
path: path.clone(),
message: e.to_string(),
})?;
let mut scrubbed: Vec<ScrubbedEntry> = Vec::new();
if let Some(item) = doc.get_mut("cross_mem_links")
&& let Some(table) = item.as_table_mut()
{
if let Some(removed) = table.remove(mem_name) {
let targets = match removed {
Item::Value(Value::Array(arr)) => arr
.iter()
.filter_map(|v| match v {
Value::String(s) => Some(s.value().to_string()),
_ => None,
})
.collect::<Vec<_>>(),
Item::Value(Value::String(s)) => vec![s.value().to_string()],
_ => Vec::new(),
};
if targets.is_empty() {
scrubbed.push(ScrubbedEntry::CrossLink {
from: mem_name.to_string(),
to: "*".to_string(),
});
} else {
for to in targets {
scrubbed.push(ScrubbedEntry::CrossLink {
from: mem_name.to_string(),
to,
});
}
}
}
let keys: Vec<String> = table.iter().map(|(k, _)| k.to_string()).collect();
for key in keys {
let drop_key = match table.get(&key) {
Some(Item::Value(Value::Array(arr))) if array_contains(arr, mem_name) => {
let mut arr = arr.clone();
arr.retain(|v| match v {
Value::String(s) => s.value() != mem_name,
_ => true,
});
scrubbed.push(ScrubbedEntry::CrossLink {
from: key.clone(),
to: mem_name.to_string(),
});
if arr.is_empty() {
true
} else {
table.insert(&key, Item::Value(Value::Array(arr)));
false
}
}
_ => false,
};
if drop_key {
table.remove(&key);
}
}
}
if !scrubbed.is_empty() {
save(&path, &doc)?;
}
Ok(scrubbed)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ScrubbedEntry {
CrossLink {
from: String,
to: String,
},
}
pub fn rename_mem_in_cross_links(
workspace_root: &Path,
old: &str,
new: &str,
) -> Result<bool, WorkspaceEditError> {
let (path, mut doc) = match load(workspace_root) {
Ok(pair) => pair,
Err(WorkspaceEditError::WorkspaceNotInitialised { .. }) => return Ok(false),
Err(e) => return Err(e),
};
let Some(table) = doc.get_mut("cross_mem_links").and_then(Item::as_table_mut) else {
return Ok(false);
};
let mut changed = false;
for (_key, item) in table.iter_mut() {
if let Item::Value(Value::Array(arr)) = item {
let mut next = Array::new();
let mut arr_changed = false;
for v in arr.iter() {
match v.as_str() {
Some(s) if s == old => {
next.push(new);
arr_changed = true;
}
_ => next.push(v.clone()),
}
}
if arr_changed {
*item = Item::Value(Value::Array(next));
changed = true;
}
}
}
if let Some(value) = table.remove(old) {
table.insert(new, value);
changed = true;
}
if changed {
save(&path, &doc)?;
}
Ok(changed)
}
fn ensure_table<'a>(doc: &'a mut DocumentMut, name: &str) -> &'a mut Table {
if !doc.contains_key(name) {
let mut t = Table::new();
t.set_implicit(false);
doc.insert(name, Item::Table(t));
}
doc.get_mut(name)
.unwrap()
.as_table_mut()
.expect("ensured table shape")
}
fn ensure_array_of_tables<'a>(
doc: &'a mut DocumentMut,
outer: &str,
inner: &str,
) -> &'a mut ArrayOfTables {
if !doc.contains_key(outer) {
let mut t = Table::new();
t.set_implicit(true);
doc.insert(outer, Item::Table(t));
}
let outer_table = doc
.get_mut(outer)
.and_then(|i| i.as_table_mut())
.expect("mem_management must be a table");
if !outer_table.contains_key(inner) {
outer_table.insert(inner, Item::ArrayOfTables(ArrayOfTables::new()));
}
outer_table
.get_mut(inner)
.and_then(|i| i.as_array_of_tables_mut())
.expect("ensured array-of-tables shape")
}
fn find_pattern_index(section: &ArrayOfTables, pattern: &str) -> Option<usize> {
section
.iter()
.position(|t| t.get("pattern").and_then(|i| i.as_str()) == Some(pattern))
}
fn read_rule_schemas(section: &ArrayOfTables, idx: usize) -> Vec<String> {
section
.get(idx)
.and_then(|t| t.get("schemas"))
.and_then(|i| i.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default()
}
fn schema_sets_equal(a: &[String], b: &[String]) -> bool {
let mut a: Vec<&str> = a.iter().map(String::as_str).collect();
let mut b: Vec<&str> = b.iter().map(String::as_str).collect();
a.sort_unstable();
a.dedup();
b.sort_unstable();
b.dedup();
a == b
}
fn cross_link_value_item(targets: &[CrossLinkTarget]) -> Item {
if targets
.iter()
.any(|t| matches!(t, CrossLinkTarget::Wildcard))
{
Item::Value(Value::from("*"))
} else {
let mut arr = Array::new();
for t in targets {
if let CrossLinkTarget::Named(name) = t {
arr.push(name.as_str());
}
}
Item::Value(Value::Array(arr))
}
}
fn array_contains(arr: &Array, needle: &str) -> bool {
arr.iter().any(|v| match v {
Value::String(s) => s.value() == needle,
_ => false,
})
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
const DEFAULT_BODY: &str =
"format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n";
fn seed(body: &str) -> TempDir {
let tmp = TempDir::new().unwrap();
let memstead = tmp.path().join(".memstead");
fs::create_dir_all(&memstead).unwrap();
fs::write(memstead.join("workspace.toml"), body).unwrap();
tmp
}
fn read(root: &Path) -> String {
fs::read_to_string(workspace_toml_path(root)).unwrap()
}
fn known() -> Vec<String> {
["engine", "plugin", "macos", "specs", "default"]
.iter()
.map(|s| s.to_string())
.collect()
}
#[test]
fn add_create_rule_appends_by_default() {
let tmp = seed(DEFAULT_BODY);
add_create_rule(
tmp.path(),
"exec-*",
&["default@1.0.0".to_string()],
None,
None,
)
.unwrap();
let body = read(tmp.path());
assert!(body.contains("[[mem_management.create]]"), "got:\n{body}");
assert!(body.contains("pattern = \"exec-*\""), "got:\n{body}");
assert!(
body.contains("schemas = [\"default@1.0.0\"]"),
"got:\n{body}"
);
}
#[test]
fn add_create_rule_duplicate_is_idempotent_with_warning() {
let tmp = seed(DEFAULT_BODY);
let first = add_create_rule(
tmp.path(),
"exec-*",
&["default@1.0.0".to_string()],
None,
None,
)
.unwrap();
assert!(first.is_empty(), "first add must return no warnings");
let body_after_first = read(tmp.path());
let warnings = add_create_rule(
tmp.path(),
"exec-*",
&["default@1.0.0".to_string()],
None,
None,
)
.unwrap();
assert_eq!(warnings.len(), 1);
assert_eq!(warnings[0].code(), "RULE_ALREADY_PRESENT");
let body_after_second = read(tmp.path());
assert_eq!(
body_after_first, body_after_second,
"duplicate add must not rewrite the file",
);
}
#[test]
fn add_create_rule_differing_schemas_refused_file_unchanged() {
let tmp = seed(DEFAULT_BODY);
add_create_rule(
tmp.path(),
"scratch",
&["software@0.1.0".to_string()],
None,
None,
)
.unwrap();
let body_before = read(tmp.path());
let err = add_create_rule(
tmp.path(),
"scratch",
&["nonexistent@9.9.9".to_string()],
None,
None,
)
.expect_err("differing schemas must be refused, not silently no-op'd");
assert_eq!(err.code(), "RULE_EXISTS_SCHEMAS_DIFFER");
match &err {
WorkspaceEditError::RuleExistsSchemasDiffer {
stored, requested, ..
} => {
assert_eq!(stored, &["software@0.1.0".to_string()]);
assert_eq!(requested, &["nonexistent@9.9.9".to_string()]);
}
other => panic!("expected RuleExistsSchemasDiffer, got {other:?}"),
}
assert_eq!(
body_before,
read(tmp.path()),
"refused schema change must not rewrite the file (stored schemas stay put)",
);
}
#[test]
fn add_create_rule_reordered_schemas_is_idempotent_noop() {
let tmp = seed(DEFAULT_BODY);
add_create_rule(
tmp.path(),
"scratch",
&["a@1.0.0".to_string(), "b@1.0.0".to_string()],
None,
None,
)
.unwrap();
let warnings = add_create_rule(
tmp.path(),
"scratch",
&["b@1.0.0".to_string(), "a@1.0.0".to_string()],
None,
None,
)
.expect("reordered identical schema set must stay a no-op");
assert_eq!(warnings.len(), 1);
assert_eq!(warnings[0].code(), "RULE_ALREADY_PRESENT");
}
#[test]
fn revoke_then_readd_applies_the_new_schemas() {
let tmp = seed(DEFAULT_BODY);
add_create_rule(
tmp.path(),
"scratch",
&["software@0.1.0".to_string()],
None,
None,
)
.unwrap();
remove_create_rule(tmp.path(), "scratch").unwrap();
let warnings = add_create_rule(
tmp.path(),
"scratch",
&["planning@0.1.0".to_string()],
None,
None,
)
.expect("re-add after revoke must succeed");
assert!(warnings.is_empty(), "fresh add returns no warnings");
let body = read(tmp.path());
assert!(
body.contains("schemas = [\"planning@0.1.0\"]"),
"new pins stored; got:\n{body}"
);
assert!(
!body.contains("software@0.1.0"),
"old pins gone; got:\n{body}"
);
}
#[test]
fn add_create_rule_before_lifts_priority() {
let tmp = seed(DEFAULT_BODY);
add_create_rule(
tmp.path(),
"z-*",
&["default@1.0.0".to_string()],
None,
None,
)
.unwrap();
add_create_rule(
tmp.path(),
"a-*",
&["default@1.0.0".to_string()],
None,
Some("z-*"),
)
.unwrap();
let body = read(tmp.path());
let a_idx = body.find("pattern = \"a-*\"").expect("a-* must exist");
let z_idx = body.find("pattern = \"z-*\"").expect("z-* must exist");
assert!(
a_idx < z_idx,
"--before must place new rule above target; got:\n{body}"
);
}
#[test]
fn add_create_rule_before_unknown_pattern_errors() {
let tmp = seed(DEFAULT_BODY);
let err = add_create_rule(
tmp.path(),
"exec-*",
&["default@1.0.0".to_string()],
None,
Some("does-not-exist"),
)
.unwrap_err();
assert_eq!(err.code(), "BEFORE_PATTERN_NOT_FOUND");
}
#[test]
fn add_create_rule_with_named_cross_links() {
let tmp = seed(DEFAULT_BODY);
add_create_rule(
tmp.path(),
"exec-*",
&["default@1.0.0".to_string()],
Some(&[CrossLinkTarget::Named("engine".to_string())]),
None,
)
.unwrap();
let body = read(tmp.path());
assert!(
body.contains("default_cross_links = [\"engine\"]"),
"got:\n{body}"
);
}
#[test]
fn add_create_rule_with_wildcard_cross_links() {
let tmp = seed(DEFAULT_BODY);
add_create_rule(
tmp.path(),
"exec-*",
&["default@1.0.0".to_string()],
Some(&[CrossLinkTarget::Wildcard]),
None,
)
.unwrap();
let body = read(tmp.path());
assert!(body.contains("default_cross_links = \"*\""), "got:\n{body}");
}
#[test]
fn remove_create_rule_succeeds() {
let tmp = seed(DEFAULT_BODY);
add_create_rule(
tmp.path(),
"exec-*",
&["default@1.0.0".to_string()],
None,
None,
)
.unwrap();
remove_create_rule(tmp.path(), "exec-*").unwrap();
let body = read(tmp.path());
assert!(!body.contains("pattern = \"exec-*\""), "got:\n{body}");
}
#[test]
fn remove_create_rule_unknown_pattern_is_idempotent_with_warning() {
let tmp = seed(DEFAULT_BODY);
let body_before = read(tmp.path());
let warnings = remove_create_rule(tmp.path(), "ghost").unwrap();
assert_eq!(warnings.len(), 1);
assert_eq!(warnings[0].code(), "RULE_NOT_FOUND_NOOP");
let body_after = read(tmp.path());
assert_eq!(
body_before, body_after,
"no-op remove must not touch the file"
);
}
#[test]
fn add_and_remove_delete_rule() {
let tmp = seed(DEFAULT_BODY);
add_delete_rule(tmp.path(), "exec-*").unwrap();
let body = read(tmp.path());
assert!(body.contains("[[mem_management.delete]]"), "got:\n{body}");
assert!(body.contains("pattern = \"exec-*\""), "got:\n{body}");
remove_delete_rule(tmp.path(), "exec-*").unwrap();
let body = read(tmp.path());
assert!(!body.contains("pattern = \"exec-*\""), "got:\n{body}");
}
#[test]
fn grant_cross_link_creates_named_list() {
let tmp = seed(DEFAULT_BODY);
grant_cross_link(
tmp.path(),
"plugin",
&CrossLinkTarget::Named("engine".to_string()),
&known(),
)
.unwrap();
let body = read(tmp.path());
assert!(body.contains("plugin = [\"engine\"]"), "got:\n{body}");
}
#[test]
fn grant_cross_link_appends_named_target() {
let tmp = seed(DEFAULT_BODY);
grant_cross_link(
tmp.path(),
"macos",
&CrossLinkTarget::Named("engine".to_string()),
&known(),
)
.unwrap();
grant_cross_link(
tmp.path(),
"macos",
&CrossLinkTarget::Named("plugin".to_string()),
&known(),
)
.unwrap();
let body = read(tmp.path());
assert!(
body.contains("macos = [\"engine\", \"plugin\"]"),
"got:\n{body}"
);
}
#[test]
fn grant_cross_link_wildcard_sets_string() {
let tmp = seed(DEFAULT_BODY);
grant_cross_link(tmp.path(), "specs", &CrossLinkTarget::Wildcard, &known()).unwrap();
let body = read(tmp.path());
assert!(body.contains("specs = \"*\""), "got:\n{body}");
}
#[test]
fn grant_cross_link_duplicate_named_is_idempotent_with_warning() {
let tmp = seed(DEFAULT_BODY);
grant_cross_link(
tmp.path(),
"plugin",
&CrossLinkTarget::Named("engine".to_string()),
&known(),
)
.unwrap();
let body_before = read(tmp.path());
let warnings = grant_cross_link(
tmp.path(),
"plugin",
&CrossLinkTarget::Named("engine".to_string()),
&known(),
)
.unwrap();
assert_eq!(warnings.len(), 1);
assert_eq!(warnings[0].code(), "GRANT_ALREADY_PRESENT");
let body_after = read(tmp.path());
assert_eq!(
body_before, body_after,
"duplicate grant must not rewrite the file"
);
}
#[test]
fn grant_cross_link_named_over_wildcard_conflicts() {
let tmp = seed(DEFAULT_BODY);
grant_cross_link(tmp.path(), "plugin", &CrossLinkTarget::Wildcard, &known()).unwrap();
let err = grant_cross_link(
tmp.path(),
"plugin",
&CrossLinkTarget::Named("engine".to_string()),
&known(),
)
.unwrap_err();
assert_eq!(err.code(), "CROSS_LINK_CONFLICT");
}
#[test]
fn grant_cross_link_warns_on_unregistered_named_target() {
let tmp = seed(DEFAULT_BODY);
let registered = vec!["plugin".to_string()];
let warnings = grant_cross_link(
tmp.path(),
"plugin",
&CrossLinkTarget::Named("future-mem".to_string()),
®istered,
)
.unwrap();
assert_eq!(warnings.len(), 1);
assert_eq!(warnings[0].code(), "CROSS_LINK_TARGET_UNREGISTERED");
assert!(
read(tmp.path()).contains("plugin = [\"future-mem\"]"),
"grant must persist for the forward-reference workflow: {}",
read(tmp.path())
);
}
#[test]
fn grant_cross_link_warns_on_self_grant() {
let tmp = seed(DEFAULT_BODY);
let warnings = grant_cross_link(
tmp.path(),
"plugin",
&CrossLinkTarget::Named("plugin".to_string()),
&known(),
)
.unwrap();
assert_eq!(warnings.len(), 1);
assert_eq!(warnings[0].code(), "CROSS_LINK_SELF_GRANT_NOOP");
assert!(read(tmp.path()).contains("plugin = [\"plugin\"]"));
}
#[test]
fn grant_cross_link_wildcard_not_target_validated() {
let tmp = seed(DEFAULT_BODY);
let warnings =
grant_cross_link(tmp.path(), "plugin", &CrossLinkTarget::Wildcard, &[]).unwrap();
assert!(
warnings.is_empty(),
"wildcard target must not be validated against the router: {warnings:?}"
);
}
#[test]
fn grant_cross_link_registered_target_no_warning() {
let tmp = seed(DEFAULT_BODY);
let registered = vec!["engine".to_string()];
let warnings = grant_cross_link(
tmp.path(),
"plugin",
&CrossLinkTarget::Named("engine".to_string()),
®istered,
)
.unwrap();
assert!(
warnings.is_empty(),
"registered target must warn nothing: {warnings:?}"
);
}
#[test]
fn revoke_cross_link_removes_named_target() {
let tmp = seed(DEFAULT_BODY);
grant_cross_link(
tmp.path(),
"macos",
&CrossLinkTarget::Named("engine".to_string()),
&known(),
)
.unwrap();
grant_cross_link(
tmp.path(),
"macos",
&CrossLinkTarget::Named("plugin".to_string()),
&known(),
)
.unwrap();
revoke_cross_link(
tmp.path(),
"macos",
&CrossLinkTarget::Named("engine".to_string()),
)
.unwrap();
let body = read(tmp.path());
assert!(body.contains("macos = ["), "got:\n{body}");
assert!(body.contains("\"plugin\""), "got:\n{body}");
assert!(
!body.contains("\"engine\""),
"engine target must be removed, got:\n{body}"
);
}
#[test]
fn revoke_cross_link_empties_key() {
let tmp = seed(DEFAULT_BODY);
grant_cross_link(
tmp.path(),
"macos",
&CrossLinkTarget::Named("engine".to_string()),
&known(),
)
.unwrap();
revoke_cross_link(
tmp.path(),
"macos",
&CrossLinkTarget::Named("engine".to_string()),
)
.unwrap();
let body = read(tmp.path());
assert!(
!body.contains("macos"),
"empty allowlist must drop the key, got:\n{body}"
);
}
#[test]
fn revoke_cross_link_wildcard() {
let tmp = seed(DEFAULT_BODY);
grant_cross_link(tmp.path(), "specs", &CrossLinkTarget::Wildcard, &known()).unwrap();
revoke_cross_link(tmp.path(), "specs", &CrossLinkTarget::Wildcard).unwrap();
let body = read(tmp.path());
assert!(!body.contains("specs"), "got:\n{body}");
}
#[test]
fn revoke_cross_link_not_granted_is_idempotent_with_warning() {
let tmp = seed(DEFAULT_BODY);
let body_before = read(tmp.path());
let warnings = revoke_cross_link(
tmp.path(),
"macos",
&CrossLinkTarget::Named("engine".to_string()),
)
.unwrap();
assert_eq!(warnings.len(), 1);
assert_eq!(warnings[0].code(), "GRANT_NOT_FOUND");
let body_after = read(tmp.path());
assert_eq!(
body_before, body_after,
"no-op revoke must not touch the file"
);
}
#[test]
fn set_mutation_require_notes_creates_section() {
let tmp = seed(DEFAULT_BODY);
set_mutation_require_notes(tmp.path(), true).unwrap();
let body = read(tmp.path());
assert!(body.contains("[mutations]"), "got:\n{body}");
assert!(body.contains("require_notes = true"), "got:\n{body}");
}
#[test]
fn set_mutation_require_notes_toggles() {
let tmp = seed(DEFAULT_BODY);
set_mutation_require_notes(tmp.path(), true).unwrap();
set_mutation_require_notes(tmp.path(), false).unwrap();
let body = read(tmp.path());
assert!(body.contains("require_notes = false"), "got:\n{body}");
}
#[test]
fn missing_workspace_toml_errors_with_typed_code() {
let tmp = TempDir::new().unwrap();
let err = add_create_rule(tmp.path(), "exec-*", &[], None, None).unwrap_err();
assert_eq!(err.code(), "WORKSPACE_NOT_INITIALISED");
}
#[test]
fn comments_outside_edited_sections_survive() {
let body = "# operator comment 1\n\
format = \"memstead-git-branch-2\"\n\
\n\
# operator comment 2\n\
[persistence_adapter]\n\
name = \"file-two-layer\"\n\
\n\
# section explanation that must survive\n\
[cross_mem_links]\n\
plugin = [\"engine\"] # inline pin\n";
let tmp = seed(body);
add_create_rule(
tmp.path(),
"exec-*",
&["default@1.0.0".to_string()],
None,
None,
)
.unwrap();
let new_body = read(tmp.path());
assert!(new_body.contains("# operator comment 1"));
assert!(new_body.contains("# operator comment 2"));
assert!(new_body.contains("# section explanation that must survive"));
assert!(new_body.contains("# inline pin"));
assert!(new_body.contains("[[mem_management.create]]"));
}
#[test]
fn scrub_policy_for_deleted_mem_drops_cross_links_but_keeps_allowlist_rules() {
let body = "format = \"memstead-git-branch-2\"\n\n\
[cross_mem_links]\n\
other = [\"test\"]\n\
test = [\"other\", \"keep\"]\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\
\n\
[[mem_management.delete]]\n\
pattern = \"team/*\"\n";
let tmp = seed(body);
let scrubbed = scrub_policy_for_deleted_mem(tmp.path(), "other").unwrap();
assert!(
scrubbed
.iter()
.all(|e| matches!(e, ScrubbedEntry::CrossLink { .. })),
"scrub must report only cross-link grants, got: {scrubbed:?}"
);
assert!(
scrubbed.contains(&ScrubbedEntry::CrossLink {
from: "other".to_string(),
to: "test".to_string(),
}),
"deleted mem's own grant must be reported scrubbed, got: {scrubbed:?}"
);
assert!(
scrubbed.contains(&ScrubbedEntry::CrossLink {
from: "test".to_string(),
to: "other".to_string(),
}),
"peer grant naming the deleted mem must be reported scrubbed, got: {scrubbed:?}"
);
let after = read(tmp.path());
assert!(
!after.contains("\nother = ["),
"`other` key must be scrubbed from cross_mem_links — got:\n{after}"
);
assert!(after.contains("\"keep\""), "non-target values must survive");
assert_eq!(
after.matches("pattern = \"other\"").count(),
2,
"exact-name mem_management.{{create,delete}} rules for `other` must survive — got:\n{after}"
);
assert!(
after.contains("pattern = \"*\""),
"wildcard `*` rule must survive"
);
assert!(
after.contains("pattern = \"team/*\""),
"glob `team/*` rule must survive"
);
}
#[test]
fn scrub_policy_for_deleted_mem_missing_file_is_noop() {
let tmp = TempDir::new().unwrap();
let outcome = scrub_policy_for_deleted_mem(tmp.path(), "other");
assert!(outcome.is_ok(), "missing workspace.toml must not error");
}
#[test]
fn scrub_policy_for_deleted_mem_no_match_leaves_file_unchanged() {
let body = "format = \"memstead-git-branch-2\"\n\n\
[cross_mem_links]\n\
test = [\"keep\"]\n\
\n\
[[mem_management.create]]\n\
pattern = \"*\"\n\
schemas = [\"default@1.0.0\"]\n";
let tmp = seed(body);
let before = read(tmp.path());
scrub_policy_for_deleted_mem(tmp.path(), "ghost").unwrap();
let after = read(tmp.path());
assert_eq!(before, after, "unrelated delete must not rewrite the file");
}
#[test]
fn scrub_policy_for_deleted_mem_drops_emptied_allowlist_key() {
let body = "format = \"memstead-git-branch-2\"\n\n\
[cross_mem_links]\n\
test = [\"other\"]\n";
let tmp = seed(body);
scrub_policy_for_deleted_mem(tmp.path(), "other").unwrap();
let after = read(tmp.path());
assert!(
!after.contains("\ntest = ["),
"key whose allowlist drained to empty must be dropped — got:\n{after}"
);
}
}