1use std::fs;
19use std::path::{Path, PathBuf};
20
21use toml_edit::{Array, ArrayOfTables, DocumentMut, Item, Table, Value};
22
23#[derive(Debug)]
34pub enum WorkspaceEditError {
35 WorkspaceNotInitialised { path: PathBuf },
38 InvalidToml { path: PathBuf, message: String },
40 BeforePatternNotFound {
43 section: &'static str,
44 pattern: String,
45 },
46 CrossLinkConflict { from: String, message: String },
50 RuleExistsSchemasDiffer {
58 section: &'static str,
59 pattern: String,
60 stored: Vec<String>,
61 requested: Vec<String>,
62 },
63 Io {
65 path: PathBuf,
66 source: std::io::Error,
67 },
68}
69
70#[derive(Debug, Clone)]
76pub enum WorkspaceEditWarning {
77 RuleAlreadyPresent {
80 section: &'static str,
81 pattern: String,
82 },
83 RuleNotFoundNoop {
86 section: &'static str,
87 pattern: String,
88 },
89 GrantAlreadyPresent { from: String, to: String },
93 GrantNotFound { from: String, to: String },
96 CrossLinkTargetUnregistered { to: String },
101 CrossLinkSelfGrantNoop { mem: String },
105}
106
107impl WorkspaceEditWarning {
108 pub fn code(&self) -> &'static str {
111 match self {
112 Self::RuleAlreadyPresent { .. } => "RULE_ALREADY_PRESENT",
113 Self::RuleNotFoundNoop { .. } => "RULE_NOT_FOUND_NOOP",
114 Self::GrantAlreadyPresent { .. } => "GRANT_ALREADY_PRESENT",
115 Self::GrantNotFound { .. } => "GRANT_NOT_FOUND",
116 Self::CrossLinkTargetUnregistered { .. } => "CROSS_LINK_TARGET_UNREGISTERED",
117 Self::CrossLinkSelfGrantNoop { .. } => "CROSS_LINK_SELF_GRANT_NOOP",
118 }
119 }
120}
121
122impl std::fmt::Display for WorkspaceEditWarning {
123 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124 match self {
125 Self::RuleAlreadyPresent { section, pattern } => write!(
126 f,
127 "`[[{section}]]` already contains an entry for pattern `{pattern}` — file unchanged"
128 ),
129 Self::RuleNotFoundNoop { section, pattern } => write!(
130 f,
131 "`[[{section}]]` has no entry for pattern `{pattern}` — file unchanged"
132 ),
133 Self::GrantAlreadyPresent { from, to } => write!(
134 f,
135 "`[cross_mem_links]` already grants {from} → {to} — file unchanged"
136 ),
137 Self::GrantNotFound { from, to } => write!(
138 f,
139 "`[cross_mem_links]` does not grant {from} → {to} — file unchanged"
140 ),
141 Self::CrossLinkTargetUnregistered { to } => write!(
142 f,
143 "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"
144 ),
145 Self::CrossLinkSelfGrantNoop { mem } => write!(
146 f,
147 "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"
148 ),
149 }
150 }
151}
152
153impl std::fmt::Display for WorkspaceEditError {
154 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
155 match self {
156 Self::WorkspaceNotInitialised { path, .. } => write!(
157 f,
158 "no `.memstead/workspace.toml` at {} — run `memstead mem-repo init` or `memstead init` first",
159 path.display()
160 ),
161 Self::InvalidToml { path, message } => {
162 write!(f, "{}: failed to parse TOML — {message}", path.display())
163 }
164 Self::BeforePatternNotFound { section, pattern } => write!(
165 f,
166 "`--before {pattern}` did not match any existing `[[{section}]]` entry"
167 ),
168 Self::CrossLinkConflict { from, message } => write!(
169 f,
170 "`[cross_mem_links]` rejects edit for `{from}`: {message}"
171 ),
172 Self::RuleExistsSchemasDiffer {
173 section,
174 pattern,
175 stored,
176 requested,
177 } => write!(
178 f,
179 "`[[{section}]]` already has a rule for pattern `{pattern}` pinned to schemas [{}], \
180 which differs from the requested [{}] — refusing to silently change the schema pins. \
181 To change them, revoke the rule first (`revoke_create {pattern}`) then re-add it with the new schemas",
182 stored.join(", "),
183 requested.join(", "),
184 ),
185 Self::Io { path, source } => write!(f, "{}: {source}", path.display()),
186 }
187 }
188}
189
190impl std::error::Error for WorkspaceEditError {}
191
192impl WorkspaceEditError {
193 pub fn code(&self) -> &'static str {
195 match self {
196 Self::WorkspaceNotInitialised { .. } => "WORKSPACE_NOT_INITIALISED",
197 Self::InvalidToml { .. } => "INVALID_TOML",
198 Self::BeforePatternNotFound { .. } => "BEFORE_PATTERN_NOT_FOUND",
199 Self::CrossLinkConflict { .. } => "CROSS_LINK_CONFLICT",
200 Self::RuleExistsSchemasDiffer { .. } => "RULE_EXISTS_SCHEMAS_DIFFER",
201 Self::Io { .. } => "IO_ERROR",
202 }
203 }
204}
205
206pub fn workspace_toml_path(workspace_root: &Path) -> PathBuf {
208 workspace_root
209 .join(memstead_base::WORKSPACE_STORE_DIR)
210 .join("workspace.toml")
211}
212
213fn load(workspace_root: &Path) -> Result<(PathBuf, DocumentMut), WorkspaceEditError> {
214 let path = workspace_toml_path(workspace_root);
215 let text = fs::read_to_string(&path).map_err(|source| {
216 if source.kind() == std::io::ErrorKind::NotFound {
217 WorkspaceEditError::WorkspaceNotInitialised { path: path.clone() }
218 } else {
219 WorkspaceEditError::Io {
220 path: path.clone(),
221 source,
222 }
223 }
224 })?;
225 let doc: DocumentMut =
226 text.parse()
227 .map_err(|e: toml_edit::TomlError| WorkspaceEditError::InvalidToml {
228 path: path.clone(),
229 message: e.to_string(),
230 })?;
231 Ok((path, doc))
232}
233
234fn save(path: &Path, doc: &DocumentMut) -> Result<(), WorkspaceEditError> {
235 fs::write(path, doc.to_string()).map_err(|source| WorkspaceEditError::Io {
236 path: path.to_path_buf(),
237 source,
238 })
239}
240
241#[derive(Debug, Clone, PartialEq, Eq)]
246pub enum CrossLinkTarget {
247 Wildcard,
249 Named(String),
252}
253
254impl CrossLinkTarget {
255 pub fn parse(raw: &str) -> Self {
260 if raw == "*" {
261 Self::Wildcard
262 } else {
263 Self::Named(raw.to_string())
264 }
265 }
266}
267
268pub fn add_create_rule(
273 workspace_root: &Path,
274 pattern: &str,
275 schemas: &[String],
276 default_cross_links: Option<&[CrossLinkTarget]>,
277 before: Option<&str>,
278) -> Result<Vec<WorkspaceEditWarning>, WorkspaceEditError> {
279 let (path, mut doc) = load(workspace_root)?;
280 let section = ensure_array_of_tables(&mut doc, "mem_management", "create");
281
282 if let Some(idx) = find_pattern_index(section, pattern) {
283 let stored = read_rule_schemas(section, idx);
289 if schema_sets_equal(&stored, schemas) {
290 return Ok(vec![WorkspaceEditWarning::RuleAlreadyPresent {
291 section: "mem_management.create",
292 pattern: pattern.to_string(),
293 }]);
294 }
295 return Err(WorkspaceEditError::RuleExistsSchemasDiffer {
296 section: "mem_management.create",
297 pattern: pattern.to_string(),
298 stored,
299 requested: schemas.to_vec(),
300 });
301 }
302
303 let mut table = Table::new();
304 table["pattern"] = Item::Value(Value::from(pattern));
305 let mut arr = Array::new();
306 for s in schemas {
307 arr.push(s.as_str());
308 }
309 table["schemas"] = Item::Value(Value::Array(arr));
310 if let Some(cross_links) = default_cross_links {
311 table["default_cross_links"] = cross_link_value_item(cross_links);
312 }
313
314 if let Some(before_pattern) = before {
315 let idx = find_pattern_index(section, before_pattern).ok_or_else(|| {
316 WorkspaceEditError::BeforePatternNotFound {
317 section: "mem_management.create",
318 pattern: before_pattern.to_string(),
319 }
320 })?;
321 let mut tail = Vec::with_capacity(section.len() - idx);
325 while section.len() > idx {
326 let last = section.get(section.len() - 1).cloned().unwrap();
327 tail.push(last);
328 section.remove(section.len() - 1);
329 }
330 section.push(table);
331 for entry in tail.into_iter().rev() {
332 section.push(entry);
333 }
334 } else {
335 section.push(table);
336 }
337
338 save(&path, &doc)?;
339 Ok(Vec::new())
340}
341
342pub fn remove_create_rule(
345 workspace_root: &Path,
346 pattern: &str,
347) -> Result<Vec<WorkspaceEditWarning>, WorkspaceEditError> {
348 let (path, mut doc) = load(workspace_root)?;
349 let section = ensure_array_of_tables(&mut doc, "mem_management", "create");
350 let idx = match find_pattern_index(section, pattern) {
351 Some(i) => i,
352 None => {
353 return Ok(vec![WorkspaceEditWarning::RuleNotFoundNoop {
354 section: "mem_management.create",
355 pattern: pattern.to_string(),
356 }]);
357 }
358 };
359 section.remove(idx);
360 save(&path, &doc)?;
361 Ok(Vec::new())
362}
363
364pub fn add_delete_rule(
367 workspace_root: &Path,
368 pattern: &str,
369) -> Result<Vec<WorkspaceEditWarning>, WorkspaceEditError> {
370 let (path, mut doc) = load(workspace_root)?;
371 let section = ensure_array_of_tables(&mut doc, "mem_management", "delete");
372 if find_pattern_index(section, pattern).is_some() {
373 return Ok(vec![WorkspaceEditWarning::RuleAlreadyPresent {
374 section: "mem_management.delete",
375 pattern: pattern.to_string(),
376 }]);
377 }
378 let mut table = Table::new();
379 table["pattern"] = Item::Value(Value::from(pattern));
380 section.push(table);
381 save(&path, &doc)?;
382 Ok(Vec::new())
383}
384
385pub fn remove_delete_rule(
388 workspace_root: &Path,
389 pattern: &str,
390) -> Result<Vec<WorkspaceEditWarning>, WorkspaceEditError> {
391 let (path, mut doc) = load(workspace_root)?;
392 let section = ensure_array_of_tables(&mut doc, "mem_management", "delete");
393 let idx = match find_pattern_index(section, pattern) {
394 Some(i) => i,
395 None => {
396 return Ok(vec![WorkspaceEditWarning::RuleNotFoundNoop {
397 section: "mem_management.delete",
398 pattern: pattern.to_string(),
399 }]);
400 }
401 };
402 section.remove(idx);
403 save(&path, &doc)?;
404 Ok(Vec::new())
405}
406
407pub fn grant_cross_link(
411 workspace_root: &Path,
412 from: &str,
413 to: &CrossLinkTarget,
414 known_mems: &[String],
415) -> Result<Vec<WorkspaceEditWarning>, WorkspaceEditError> {
416 let mut warnings: Vec<WorkspaceEditWarning> = Vec::new();
423 if let CrossLinkTarget::Named(name) = to {
424 if name == from {
425 warnings.push(WorkspaceEditWarning::CrossLinkSelfGrantNoop {
426 mem: from.to_string(),
427 });
428 } else if !known_mems.iter().any(|v| v == name) {
429 warnings.push(WorkspaceEditWarning::CrossLinkTargetUnregistered { to: name.clone() });
430 }
431 }
432
433 let (path, mut doc) = load(workspace_root)?;
434 let table = ensure_table(&mut doc, "cross_mem_links");
435 match (table.get(from), to) {
436 (None, CrossLinkTarget::Wildcard) => {
437 table.insert(from, Item::Value(Value::from("*")));
438 }
439 (None, CrossLinkTarget::Named(name)) => {
440 let mut arr = Array::new();
441 arr.push(name.as_str());
442 table.insert(from, Item::Value(Value::Array(arr)));
443 }
444 (Some(Item::Value(Value::String(s))), CrossLinkTarget::Wildcard) if s.value() == "*" => {
445 warnings.push(WorkspaceEditWarning::GrantAlreadyPresent {
446 from: from.to_string(),
447 to: "*".to_string(),
448 });
449 return Ok(warnings);
450 }
451 (Some(Item::Value(Value::String(_))), _) => {
452 return Err(WorkspaceEditError::CrossLinkConflict {
453 from: from.to_string(),
454 message: "wildcard `*` already set — revoke `*` before granting a named target"
455 .to_string(),
456 });
457 }
458 (Some(Item::Value(Value::Array(_))), CrossLinkTarget::Wildcard) => {
459 return Err(WorkspaceEditError::CrossLinkConflict {
460 from: from.to_string(),
461 message: "specific allowlist already set — revoke every entry before granting `*`"
462 .to_string(),
463 });
464 }
465 (Some(Item::Value(Value::Array(arr))), CrossLinkTarget::Named(name)) => {
466 if array_contains(arr, name) {
467 warnings.push(WorkspaceEditWarning::GrantAlreadyPresent {
468 from: from.to_string(),
469 to: name.clone(),
470 });
471 return Ok(warnings);
472 }
473 let mut arr = arr.clone();
474 arr.push(name.as_str());
475 table.insert(from, Item::Value(Value::Array(arr)));
476 }
477 (Some(_), _) => {
478 return Err(WorkspaceEditError::CrossLinkConflict {
479 from: from.to_string(),
480 message: "existing value is neither a string nor an array — fix by hand"
481 .to_string(),
482 });
483 }
484 }
485 save(&path, &doc)?;
486 Ok(warnings)
487}
488
489pub fn revoke_cross_link(
493 workspace_root: &Path,
494 from: &str,
495 to: &CrossLinkTarget,
496) -> Result<Vec<WorkspaceEditWarning>, WorkspaceEditError> {
497 let (path, mut doc) = load(workspace_root)?;
498 let table = ensure_table(&mut doc, "cross_mem_links");
499 let removed = match (table.get(from), to) {
500 (None, _) => false,
501 (Some(Item::Value(Value::String(s))), CrossLinkTarget::Wildcard) if s.value() == "*" => {
502 table.remove(from);
503 true
504 }
505 (Some(Item::Value(Value::String(_))), CrossLinkTarget::Named(_)) => false,
506 (Some(Item::Value(Value::String(_))), CrossLinkTarget::Wildcard) => false,
507 (Some(Item::Value(Value::Array(_))), CrossLinkTarget::Wildcard) => false,
508 (Some(Item::Value(Value::Array(arr))), CrossLinkTarget::Named(name)) => {
509 let mut arr = arr.clone();
510 let original_len = arr.len();
511 arr.retain(|v| match v {
512 Value::String(s) => s.value() != name,
513 _ => true,
514 });
515 if arr.len() == original_len {
516 false
517 } else if arr.is_empty() {
518 table.remove(from);
519 true
520 } else {
521 table.insert(from, Item::Value(Value::Array(arr)));
522 true
523 }
524 }
525 (Some(_), _) => false,
526 };
527 if !removed {
528 let target = match to {
529 CrossLinkTarget::Wildcard => "*".to_string(),
530 CrossLinkTarget::Named(s) => s.clone(),
531 };
532 return Ok(vec![WorkspaceEditWarning::GrantNotFound {
533 from: from.to_string(),
534 to: target,
535 }]);
536 }
537 save(&path, &doc)?;
538 Ok(Vec::new())
539}
540
541pub fn set_mutation_require_notes(
544 workspace_root: &Path,
545 value: bool,
546) -> Result<(), WorkspaceEditError> {
547 let (path, mut doc) = load(workspace_root)?;
548 let table = ensure_table(&mut doc, "mutations");
549 table.insert("require_notes", Item::Value(Value::from(value)));
550 save(&path, &doc)
551}
552
553pub fn scrub_policy_for_deleted_mem(
588 workspace_root: &Path,
589 mem_name: &str,
590) -> Result<Vec<ScrubbedEntry>, WorkspaceEditError> {
591 let path = workspace_toml_path(workspace_root);
592 let text = match fs::read_to_string(&path) {
593 Ok(t) => t,
594 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
595 return Ok(Vec::new());
597 }
598 Err(source) => {
599 return Err(WorkspaceEditError::Io {
600 path: path.clone(),
601 source,
602 });
603 }
604 };
605 let mut doc: DocumentMut =
606 text.parse()
607 .map_err(|e: toml_edit::TomlError| WorkspaceEditError::InvalidToml {
608 path: path.clone(),
609 message: e.to_string(),
610 })?;
611
612 let mut scrubbed: Vec<ScrubbedEntry> = Vec::new();
613
614 if let Some(item) = doc.get_mut("cross_mem_links")
615 && let Some(table) = item.as_table_mut()
616 {
617 if let Some(removed) = table.remove(mem_name) {
620 let targets = match removed {
621 Item::Value(Value::Array(arr)) => arr
622 .iter()
623 .filter_map(|v| match v {
624 Value::String(s) => Some(s.value().to_string()),
625 _ => None,
626 })
627 .collect::<Vec<_>>(),
628 Item::Value(Value::String(s)) => vec![s.value().to_string()],
629 _ => Vec::new(),
630 };
631 if targets.is_empty() {
632 scrubbed.push(ScrubbedEntry::CrossLink {
633 from: mem_name.to_string(),
634 to: "*".to_string(),
635 });
636 } else {
637 for to in targets {
638 scrubbed.push(ScrubbedEntry::CrossLink {
639 from: mem_name.to_string(),
640 to,
641 });
642 }
643 }
644 }
645 let keys: Vec<String> = table.iter().map(|(k, _)| k.to_string()).collect();
649 for key in keys {
650 let drop_key = match table.get(&key) {
651 Some(Item::Value(Value::Array(arr))) if array_contains(arr, mem_name) => {
652 let mut arr = arr.clone();
653 arr.retain(|v| match v {
654 Value::String(s) => s.value() != mem_name,
655 _ => true,
656 });
657 scrubbed.push(ScrubbedEntry::CrossLink {
658 from: key.clone(),
659 to: mem_name.to_string(),
660 });
661 if arr.is_empty() {
662 true
663 } else {
664 table.insert(&key, Item::Value(Value::Array(arr)));
665 false
666 }
667 }
668 _ => false,
669 };
670 if drop_key {
671 table.remove(&key);
672 }
673 }
674 }
675
676 if !scrubbed.is_empty() {
683 save(&path, &doc)?;
684 }
685 Ok(scrubbed)
686}
687
688#[derive(Debug, Clone, PartialEq, Eq)]
695pub enum ScrubbedEntry {
696 CrossLink {
699 from: String,
701 to: String,
703 },
704}
705
706fn ensure_table<'a>(doc: &'a mut DocumentMut, name: &str) -> &'a mut Table {
709 if !doc.contains_key(name) {
710 let mut t = Table::new();
711 t.set_implicit(false);
712 doc.insert(name, Item::Table(t));
713 }
714 doc.get_mut(name)
715 .unwrap()
716 .as_table_mut()
717 .expect("ensured table shape")
718}
719
720fn ensure_array_of_tables<'a>(
721 doc: &'a mut DocumentMut,
722 outer: &str,
723 inner: &str,
724) -> &'a mut ArrayOfTables {
725 if !doc.contains_key(outer) {
726 let mut t = Table::new();
727 t.set_implicit(true);
728 doc.insert(outer, Item::Table(t));
729 }
730 let outer_table = doc
731 .get_mut(outer)
732 .and_then(|i| i.as_table_mut())
733 .expect("mem_management must be a table");
734 if !outer_table.contains_key(inner) {
735 outer_table.insert(inner, Item::ArrayOfTables(ArrayOfTables::new()));
736 }
737 outer_table
738 .get_mut(inner)
739 .and_then(|i| i.as_array_of_tables_mut())
740 .expect("ensured array-of-tables shape")
741}
742
743fn find_pattern_index(section: &ArrayOfTables, pattern: &str) -> Option<usize> {
744 section
745 .iter()
746 .position(|t| t.get("pattern").and_then(|i| i.as_str()) == Some(pattern))
747}
748
749fn read_rule_schemas(section: &ArrayOfTables, idx: usize) -> Vec<String> {
752 section
753 .get(idx)
754 .and_then(|t| t.get("schemas"))
755 .and_then(|i| i.as_array())
756 .map(|arr| {
757 arr.iter()
758 .filter_map(|v| v.as_str().map(str::to_string))
759 .collect()
760 })
761 .unwrap_or_default()
762}
763
764fn schema_sets_equal(a: &[String], b: &[String]) -> bool {
768 let mut a: Vec<&str> = a.iter().map(String::as_str).collect();
769 let mut b: Vec<&str> = b.iter().map(String::as_str).collect();
770 a.sort_unstable();
771 a.dedup();
772 b.sort_unstable();
773 b.dedup();
774 a == b
775}
776
777fn cross_link_value_item(targets: &[CrossLinkTarget]) -> Item {
778 if targets
779 .iter()
780 .any(|t| matches!(t, CrossLinkTarget::Wildcard))
781 {
782 Item::Value(Value::from("*"))
783 } else {
784 let mut arr = Array::new();
785 for t in targets {
786 if let CrossLinkTarget::Named(name) = t {
787 arr.push(name.as_str());
788 }
789 }
790 Item::Value(Value::Array(arr))
791 }
792}
793
794fn array_contains(arr: &Array, needle: &str) -> bool {
795 arr.iter().any(|v| match v {
796 Value::String(s) => s.value() == needle,
797 _ => false,
798 })
799}
800
801#[cfg(test)]
802mod tests {
803 use super::*;
804 use tempfile::TempDir;
805
806 const DEFAULT_BODY: &str =
807 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n";
808
809 fn seed(body: &str) -> TempDir {
810 let tmp = TempDir::new().unwrap();
811 let memstead = tmp.path().join(".memstead");
812 fs::create_dir_all(&memstead).unwrap();
813 fs::write(memstead.join("workspace.toml"), body).unwrap();
814 tmp
815 }
816
817 fn read(root: &Path) -> String {
818 fs::read_to_string(workspace_toml_path(root)).unwrap()
819 }
820
821 fn known() -> Vec<String> {
827 ["engine", "plugin", "macos", "specs", "default"]
828 .iter()
829 .map(|s| s.to_string())
830 .collect()
831 }
832
833 #[test]
834 fn add_create_rule_appends_by_default() {
835 let tmp = seed(DEFAULT_BODY);
836 add_create_rule(
837 tmp.path(),
838 "exec-*",
839 &["default@1.0.0".to_string()],
840 None,
841 None,
842 )
843 .unwrap();
844 let body = read(tmp.path());
845 assert!(body.contains("[[mem_management.create]]"), "got:\n{body}");
846 assert!(body.contains("pattern = \"exec-*\""), "got:\n{body}");
847 assert!(
848 body.contains("schemas = [\"default@1.0.0\"]"),
849 "got:\n{body}"
850 );
851 }
852
853 #[test]
858 fn add_create_rule_duplicate_is_idempotent_with_warning() {
859 let tmp = seed(DEFAULT_BODY);
860 let first = add_create_rule(
861 tmp.path(),
862 "exec-*",
863 &["default@1.0.0".to_string()],
864 None,
865 None,
866 )
867 .unwrap();
868 assert!(first.is_empty(), "first add must return no warnings");
869 let body_after_first = read(tmp.path());
870 let warnings = add_create_rule(
871 tmp.path(),
872 "exec-*",
873 &["default@1.0.0".to_string()],
874 None,
875 None,
876 )
877 .unwrap();
878 assert_eq!(warnings.len(), 1);
879 assert_eq!(warnings[0].code(), "RULE_ALREADY_PRESENT");
880 let body_after_second = read(tmp.path());
881 assert_eq!(
882 body_after_first, body_after_second,
883 "duplicate add must not rewrite the file",
884 );
885 }
886
887 #[test]
892 fn add_create_rule_differing_schemas_refused_file_unchanged() {
893 let tmp = seed(DEFAULT_BODY);
894 add_create_rule(
895 tmp.path(),
896 "scratch",
897 &["software@0.1.0".to_string()],
898 None,
899 None,
900 )
901 .unwrap();
902 let body_before = read(tmp.path());
903
904 let err = add_create_rule(
905 tmp.path(),
906 "scratch",
907 &["nonexistent@9.9.9".to_string()],
908 None,
909 None,
910 )
911 .expect_err("differing schemas must be refused, not silently no-op'd");
912 assert_eq!(err.code(), "RULE_EXISTS_SCHEMAS_DIFFER");
913 match &err {
914 WorkspaceEditError::RuleExistsSchemasDiffer {
915 stored, requested, ..
916 } => {
917 assert_eq!(stored, &["software@0.1.0".to_string()]);
918 assert_eq!(requested, &["nonexistent@9.9.9".to_string()]);
919 }
920 other => panic!("expected RuleExistsSchemasDiffer, got {other:?}"),
921 }
922 assert_eq!(
923 body_before,
924 read(tmp.path()),
925 "refused schema change must not rewrite the file (stored schemas stay put)",
926 );
927 }
928
929 #[test]
932 fn add_create_rule_reordered_schemas_is_idempotent_noop() {
933 let tmp = seed(DEFAULT_BODY);
934 add_create_rule(
935 tmp.path(),
936 "scratch",
937 &["a@1.0.0".to_string(), "b@1.0.0".to_string()],
938 None,
939 None,
940 )
941 .unwrap();
942 let warnings = add_create_rule(
943 tmp.path(),
944 "scratch",
945 &["b@1.0.0".to_string(), "a@1.0.0".to_string()],
946 None,
947 None,
948 )
949 .expect("reordered identical schema set must stay a no-op");
950 assert_eq!(warnings.len(), 1);
951 assert_eq!(warnings[0].code(), "RULE_ALREADY_PRESENT");
952 }
953
954 #[test]
957 fn revoke_then_readd_applies_the_new_schemas() {
958 let tmp = seed(DEFAULT_BODY);
959 add_create_rule(
960 tmp.path(),
961 "scratch",
962 &["software@0.1.0".to_string()],
963 None,
964 None,
965 )
966 .unwrap();
967 remove_create_rule(tmp.path(), "scratch").unwrap();
968 let warnings = add_create_rule(
969 tmp.path(),
970 "scratch",
971 &["planning@0.1.0".to_string()],
972 None,
973 None,
974 )
975 .expect("re-add after revoke must succeed");
976 assert!(warnings.is_empty(), "fresh add returns no warnings");
977 let body = read(tmp.path());
978 assert!(
979 body.contains("schemas = [\"planning@0.1.0\"]"),
980 "new pins stored; got:\n{body}"
981 );
982 assert!(
983 !body.contains("software@0.1.0"),
984 "old pins gone; got:\n{body}"
985 );
986 }
987
988 #[test]
989 fn add_create_rule_before_lifts_priority() {
990 let tmp = seed(DEFAULT_BODY);
991 add_create_rule(
992 tmp.path(),
993 "z-*",
994 &["default@1.0.0".to_string()],
995 None,
996 None,
997 )
998 .unwrap();
999 add_create_rule(
1000 tmp.path(),
1001 "a-*",
1002 &["default@1.0.0".to_string()],
1003 None,
1004 Some("z-*"),
1005 )
1006 .unwrap();
1007 let body = read(tmp.path());
1008 let a_idx = body.find("pattern = \"a-*\"").expect("a-* must exist");
1009 let z_idx = body.find("pattern = \"z-*\"").expect("z-* must exist");
1010 assert!(
1011 a_idx < z_idx,
1012 "--before must place new rule above target; got:\n{body}"
1013 );
1014 }
1015
1016 #[test]
1017 fn add_create_rule_before_unknown_pattern_errors() {
1018 let tmp = seed(DEFAULT_BODY);
1019 let err = add_create_rule(
1020 tmp.path(),
1021 "exec-*",
1022 &["default@1.0.0".to_string()],
1023 None,
1024 Some("does-not-exist"),
1025 )
1026 .unwrap_err();
1027 assert_eq!(err.code(), "BEFORE_PATTERN_NOT_FOUND");
1028 }
1029
1030 #[test]
1031 fn add_create_rule_with_named_cross_links() {
1032 let tmp = seed(DEFAULT_BODY);
1033 add_create_rule(
1034 tmp.path(),
1035 "exec-*",
1036 &["default@1.0.0".to_string()],
1037 Some(&[CrossLinkTarget::Named("engine".to_string())]),
1038 None,
1039 )
1040 .unwrap();
1041 let body = read(tmp.path());
1042 assert!(
1043 body.contains("default_cross_links = [\"engine\"]"),
1044 "got:\n{body}"
1045 );
1046 }
1047
1048 #[test]
1049 fn add_create_rule_with_wildcard_cross_links() {
1050 let tmp = seed(DEFAULT_BODY);
1051 add_create_rule(
1052 tmp.path(),
1053 "exec-*",
1054 &["default@1.0.0".to_string()],
1055 Some(&[CrossLinkTarget::Wildcard]),
1056 None,
1057 )
1058 .unwrap();
1059 let body = read(tmp.path());
1060 assert!(body.contains("default_cross_links = \"*\""), "got:\n{body}");
1061 }
1062
1063 #[test]
1064 fn remove_create_rule_succeeds() {
1065 let tmp = seed(DEFAULT_BODY);
1066 add_create_rule(
1067 tmp.path(),
1068 "exec-*",
1069 &["default@1.0.0".to_string()],
1070 None,
1071 None,
1072 )
1073 .unwrap();
1074 remove_create_rule(tmp.path(), "exec-*").unwrap();
1075 let body = read(tmp.path());
1076 assert!(!body.contains("pattern = \"exec-*\""), "got:\n{body}");
1077 }
1078
1079 #[test]
1082 fn remove_create_rule_unknown_pattern_is_idempotent_with_warning() {
1083 let tmp = seed(DEFAULT_BODY);
1084 let body_before = read(tmp.path());
1085 let warnings = remove_create_rule(tmp.path(), "ghost").unwrap();
1086 assert_eq!(warnings.len(), 1);
1087 assert_eq!(warnings[0].code(), "RULE_NOT_FOUND_NOOP");
1088 let body_after = read(tmp.path());
1089 assert_eq!(
1090 body_before, body_after,
1091 "no-op remove must not touch the file"
1092 );
1093 }
1094
1095 #[test]
1096 fn add_and_remove_delete_rule() {
1097 let tmp = seed(DEFAULT_BODY);
1098 add_delete_rule(tmp.path(), "exec-*").unwrap();
1099 let body = read(tmp.path());
1100 assert!(body.contains("[[mem_management.delete]]"), "got:\n{body}");
1101 assert!(body.contains("pattern = \"exec-*\""), "got:\n{body}");
1102 remove_delete_rule(tmp.path(), "exec-*").unwrap();
1103 let body = read(tmp.path());
1104 assert!(!body.contains("pattern = \"exec-*\""), "got:\n{body}");
1105 }
1106
1107 #[test]
1108 fn grant_cross_link_creates_named_list() {
1109 let tmp = seed(DEFAULT_BODY);
1110 grant_cross_link(
1111 tmp.path(),
1112 "plugin",
1113 &CrossLinkTarget::Named("engine".to_string()),
1114 &known(),
1115 )
1116 .unwrap();
1117 let body = read(tmp.path());
1118 assert!(body.contains("plugin = [\"engine\"]"), "got:\n{body}");
1119 }
1120
1121 #[test]
1122 fn grant_cross_link_appends_named_target() {
1123 let tmp = seed(DEFAULT_BODY);
1124 grant_cross_link(
1125 tmp.path(),
1126 "macos",
1127 &CrossLinkTarget::Named("engine".to_string()),
1128 &known(),
1129 )
1130 .unwrap();
1131 grant_cross_link(
1132 tmp.path(),
1133 "macos",
1134 &CrossLinkTarget::Named("plugin".to_string()),
1135 &known(),
1136 )
1137 .unwrap();
1138 let body = read(tmp.path());
1139 assert!(
1140 body.contains("macos = [\"engine\", \"plugin\"]"),
1141 "got:\n{body}"
1142 );
1143 }
1144
1145 #[test]
1146 fn grant_cross_link_wildcard_sets_string() {
1147 let tmp = seed(DEFAULT_BODY);
1148 grant_cross_link(tmp.path(), "specs", &CrossLinkTarget::Wildcard, &known()).unwrap();
1149 let body = read(tmp.path());
1150 assert!(body.contains("specs = \"*\""), "got:\n{body}");
1151 }
1152
1153 #[test]
1157 fn grant_cross_link_duplicate_named_is_idempotent_with_warning() {
1158 let tmp = seed(DEFAULT_BODY);
1159 grant_cross_link(
1160 tmp.path(),
1161 "plugin",
1162 &CrossLinkTarget::Named("engine".to_string()),
1163 &known(),
1164 )
1165 .unwrap();
1166 let body_before = read(tmp.path());
1167 let warnings = grant_cross_link(
1168 tmp.path(),
1169 "plugin",
1170 &CrossLinkTarget::Named("engine".to_string()),
1171 &known(),
1172 )
1173 .unwrap();
1174 assert_eq!(warnings.len(), 1);
1175 assert_eq!(warnings[0].code(), "GRANT_ALREADY_PRESENT");
1176 let body_after = read(tmp.path());
1177 assert_eq!(
1178 body_before, body_after,
1179 "duplicate grant must not rewrite the file"
1180 );
1181 }
1182
1183 #[test]
1184 fn grant_cross_link_named_over_wildcard_conflicts() {
1185 let tmp = seed(DEFAULT_BODY);
1186 grant_cross_link(tmp.path(), "plugin", &CrossLinkTarget::Wildcard, &known()).unwrap();
1187 let err = grant_cross_link(
1188 tmp.path(),
1189 "plugin",
1190 &CrossLinkTarget::Named("engine".to_string()),
1191 &known(),
1192 )
1193 .unwrap_err();
1194 assert_eq!(err.code(), "CROSS_LINK_CONFLICT");
1195 }
1196
1197 #[test]
1201 fn grant_cross_link_warns_on_unregistered_named_target() {
1202 let tmp = seed(DEFAULT_BODY);
1203 let registered = vec!["plugin".to_string()];
1204 let warnings = grant_cross_link(
1205 tmp.path(),
1206 "plugin",
1207 &CrossLinkTarget::Named("future-mem".to_string()),
1208 ®istered,
1209 )
1210 .unwrap();
1211 assert_eq!(warnings.len(), 1);
1212 assert_eq!(warnings[0].code(), "CROSS_LINK_TARGET_UNREGISTERED");
1213 assert!(
1215 read(tmp.path()).contains("plugin = [\"future-mem\"]"),
1216 "grant must persist for the forward-reference workflow: {}",
1217 read(tmp.path())
1218 );
1219 }
1220
1221 #[test]
1224 fn grant_cross_link_warns_on_self_grant() {
1225 let tmp = seed(DEFAULT_BODY);
1226 let warnings = grant_cross_link(
1227 tmp.path(),
1228 "plugin",
1229 &CrossLinkTarget::Named("plugin".to_string()),
1230 &known(),
1231 )
1232 .unwrap();
1233 assert_eq!(warnings.len(), 1);
1234 assert_eq!(warnings[0].code(), "CROSS_LINK_SELF_GRANT_NOOP");
1235 assert!(read(tmp.path()).contains("plugin = [\"plugin\"]"));
1236 }
1237
1238 #[test]
1242 fn grant_cross_link_wildcard_not_target_validated() {
1243 let tmp = seed(DEFAULT_BODY);
1244 let warnings =
1245 grant_cross_link(tmp.path(), "plugin", &CrossLinkTarget::Wildcard, &[]).unwrap();
1246 assert!(
1247 warnings.is_empty(),
1248 "wildcard target must not be validated against the router: {warnings:?}"
1249 );
1250 }
1251
1252 #[test]
1255 fn grant_cross_link_registered_target_no_warning() {
1256 let tmp = seed(DEFAULT_BODY);
1257 let registered = vec!["engine".to_string()];
1258 let warnings = grant_cross_link(
1259 tmp.path(),
1260 "plugin",
1261 &CrossLinkTarget::Named("engine".to_string()),
1262 ®istered,
1263 )
1264 .unwrap();
1265 assert!(
1266 warnings.is_empty(),
1267 "registered target must warn nothing: {warnings:?}"
1268 );
1269 }
1270
1271 #[test]
1272 fn revoke_cross_link_removes_named_target() {
1273 let tmp = seed(DEFAULT_BODY);
1274 grant_cross_link(
1275 tmp.path(),
1276 "macos",
1277 &CrossLinkTarget::Named("engine".to_string()),
1278 &known(),
1279 )
1280 .unwrap();
1281 grant_cross_link(
1282 tmp.path(),
1283 "macos",
1284 &CrossLinkTarget::Named("plugin".to_string()),
1285 &known(),
1286 )
1287 .unwrap();
1288 revoke_cross_link(
1289 tmp.path(),
1290 "macos",
1291 &CrossLinkTarget::Named("engine".to_string()),
1292 )
1293 .unwrap();
1294 let body = read(tmp.path());
1295 assert!(body.contains("macos = ["), "got:\n{body}");
1299 assert!(body.contains("\"plugin\""), "got:\n{body}");
1300 assert!(
1301 !body.contains("\"engine\""),
1302 "engine target must be removed, got:\n{body}"
1303 );
1304 }
1305
1306 #[test]
1307 fn revoke_cross_link_empties_key() {
1308 let tmp = seed(DEFAULT_BODY);
1309 grant_cross_link(
1310 tmp.path(),
1311 "macos",
1312 &CrossLinkTarget::Named("engine".to_string()),
1313 &known(),
1314 )
1315 .unwrap();
1316 revoke_cross_link(
1317 tmp.path(),
1318 "macos",
1319 &CrossLinkTarget::Named("engine".to_string()),
1320 )
1321 .unwrap();
1322 let body = read(tmp.path());
1323 assert!(
1324 !body.contains("macos"),
1325 "empty allowlist must drop the key, got:\n{body}"
1326 );
1327 }
1328
1329 #[test]
1330 fn revoke_cross_link_wildcard() {
1331 let tmp = seed(DEFAULT_BODY);
1332 grant_cross_link(tmp.path(), "specs", &CrossLinkTarget::Wildcard, &known()).unwrap();
1333 revoke_cross_link(tmp.path(), "specs", &CrossLinkTarget::Wildcard).unwrap();
1334 let body = read(tmp.path());
1335 assert!(!body.contains("specs"), "got:\n{body}");
1336 }
1337
1338 #[test]
1342 fn revoke_cross_link_not_granted_is_idempotent_with_warning() {
1343 let tmp = seed(DEFAULT_BODY);
1344 let body_before = read(tmp.path());
1345 let warnings = revoke_cross_link(
1346 tmp.path(),
1347 "macos",
1348 &CrossLinkTarget::Named("engine".to_string()),
1349 )
1350 .unwrap();
1351 assert_eq!(warnings.len(), 1);
1352 assert_eq!(warnings[0].code(), "GRANT_NOT_FOUND");
1353 let body_after = read(tmp.path());
1354 assert_eq!(
1355 body_before, body_after,
1356 "no-op revoke must not touch the file"
1357 );
1358 }
1359
1360 #[test]
1361 fn set_mutation_require_notes_creates_section() {
1362 let tmp = seed(DEFAULT_BODY);
1363 set_mutation_require_notes(tmp.path(), true).unwrap();
1364 let body = read(tmp.path());
1365 assert!(body.contains("[mutations]"), "got:\n{body}");
1366 assert!(body.contains("require_notes = true"), "got:\n{body}");
1367 }
1368
1369 #[test]
1370 fn set_mutation_require_notes_toggles() {
1371 let tmp = seed(DEFAULT_BODY);
1372 set_mutation_require_notes(tmp.path(), true).unwrap();
1373 set_mutation_require_notes(tmp.path(), false).unwrap();
1374 let body = read(tmp.path());
1375 assert!(body.contains("require_notes = false"), "got:\n{body}");
1376 }
1377
1378 #[test]
1379 fn missing_workspace_toml_errors_with_typed_code() {
1380 let tmp = TempDir::new().unwrap();
1381 let err = add_create_rule(tmp.path(), "exec-*", &[], None, None).unwrap_err();
1382 assert_eq!(err.code(), "WORKSPACE_NOT_INITIALISED");
1383 }
1384
1385 #[test]
1386 fn comments_outside_edited_sections_survive() {
1387 let body = "# operator comment 1\n\
1392format = \"memstead-git-branch-2\"\n\
1393\n\
1394# operator comment 2\n\
1395[persistence_adapter]\n\
1396name = \"file-two-layer\"\n\
1397\n\
1398# section explanation that must survive\n\
1399[cross_mem_links]\n\
1400plugin = [\"engine\"] # inline pin\n";
1401 let tmp = seed(body);
1402
1403 add_create_rule(
1404 tmp.path(),
1405 "exec-*",
1406 &["default@1.0.0".to_string()],
1407 None,
1408 None,
1409 )
1410 .unwrap();
1411
1412 let new_body = read(tmp.path());
1413 assert!(new_body.contains("# operator comment 1"));
1414 assert!(new_body.contains("# operator comment 2"));
1415 assert!(new_body.contains("# section explanation that must survive"));
1416 assert!(new_body.contains("# inline pin"));
1417 assert!(new_body.contains("[[mem_management.create]]"));
1418 }
1419
1420 #[test]
1428 fn scrub_policy_for_deleted_mem_drops_cross_links_but_keeps_allowlist_rules() {
1429 let body = "format = \"memstead-git-branch-2\"\n\n\
1430 [cross_mem_links]\n\
1431 other = [\"test\"]\n\
1432 test = [\"other\", \"keep\"]\n\
1433 \n\
1434 [[mem_management.create]]\n\
1435 pattern = \"other\"\n\
1436 schemas = [\"default@1.0.0\"]\n\
1437 \n\
1438 [[mem_management.create]]\n\
1439 pattern = \"*\"\n\
1440 schemas = [\"default@1.0.0\"]\n\
1441 \n\
1442 [[mem_management.delete]]\n\
1443 pattern = \"other\"\n\
1444 \n\
1445 [[mem_management.delete]]\n\
1446 pattern = \"team/*\"\n";
1447 let tmp = seed(body);
1448 let scrubbed = scrub_policy_for_deleted_mem(tmp.path(), "other").unwrap();
1449 assert!(
1454 scrubbed
1455 .iter()
1456 .all(|e| matches!(e, ScrubbedEntry::CrossLink { .. })),
1457 "scrub must report only cross-link grants, got: {scrubbed:?}"
1458 );
1459 assert!(
1460 scrubbed.contains(&ScrubbedEntry::CrossLink {
1461 from: "other".to_string(),
1462 to: "test".to_string(),
1463 }),
1464 "deleted mem's own grant must be reported scrubbed, got: {scrubbed:?}"
1465 );
1466 assert!(
1467 scrubbed.contains(&ScrubbedEntry::CrossLink {
1468 from: "test".to_string(),
1469 to: "other".to_string(),
1470 }),
1471 "peer grant naming the deleted mem must be reported scrubbed, got: {scrubbed:?}"
1472 );
1473 let after = read(tmp.path());
1474 assert!(
1476 !after.contains("\nother = ["),
1477 "`other` key must be scrubbed from cross_mem_links — got:\n{after}"
1478 );
1479 assert!(after.contains("\"keep\""), "non-target values must survive");
1481 assert_eq!(
1485 after.matches("pattern = \"other\"").count(),
1486 2,
1487 "exact-name mem_management.{{create,delete}} rules for `other` must survive — got:\n{after}"
1488 );
1489 assert!(
1490 after.contains("pattern = \"*\""),
1491 "wildcard `*` rule must survive"
1492 );
1493 assert!(
1494 after.contains("pattern = \"team/*\""),
1495 "glob `team/*` rule must survive"
1496 );
1497 }
1498
1499 #[test]
1503 fn scrub_policy_for_deleted_mem_missing_file_is_noop() {
1504 let tmp = TempDir::new().unwrap();
1505 let outcome = scrub_policy_for_deleted_mem(tmp.path(), "other");
1507 assert!(outcome.is_ok(), "missing workspace.toml must not error");
1508 }
1509
1510 #[test]
1513 fn scrub_policy_for_deleted_mem_no_match_leaves_file_unchanged() {
1514 let body = "format = \"memstead-git-branch-2\"\n\n\
1515 [cross_mem_links]\n\
1516 test = [\"keep\"]\n\
1517 \n\
1518 [[mem_management.create]]\n\
1519 pattern = \"*\"\n\
1520 schemas = [\"default@1.0.0\"]\n";
1521 let tmp = seed(body);
1522 let before = read(tmp.path());
1523 scrub_policy_for_deleted_mem(tmp.path(), "ghost").unwrap();
1524 let after = read(tmp.path());
1525 assert_eq!(before, after, "unrelated delete must not rewrite the file");
1526 }
1527
1528 #[test]
1532 fn scrub_policy_for_deleted_mem_drops_emptied_allowlist_key() {
1533 let body = "format = \"memstead-git-branch-2\"\n\n\
1534 [cross_mem_links]\n\
1535 test = [\"other\"]\n";
1536 let tmp = seed(body);
1537 scrub_policy_for_deleted_mem(tmp.path(), "other").unwrap();
1538 let after = read(tmp.path());
1539 assert!(
1540 !after.contains("\ntest = ["),
1541 "key whose allowlist drained to empty must be dropped — got:\n{after}"
1542 );
1543 }
1544}