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
706pub fn rename_mem_in_cross_links(
716 workspace_root: &Path,
717 old: &str,
718 new: &str,
719) -> Result<bool, WorkspaceEditError> {
720 let (path, mut doc) = match load(workspace_root) {
721 Ok(pair) => pair,
722 Err(WorkspaceEditError::WorkspaceNotInitialised { .. }) => return Ok(false),
723 Err(e) => return Err(e),
724 };
725 let Some(table) = doc.get_mut("cross_mem_links").and_then(Item::as_table_mut) else {
726 return Ok(false);
727 };
728
729 let mut changed = false;
730 for (_key, item) in table.iter_mut() {
732 if let Item::Value(Value::Array(arr)) = item {
733 let mut next = Array::new();
734 let mut arr_changed = false;
735 for v in arr.iter() {
736 match v.as_str() {
737 Some(s) if s == old => {
738 next.push(new);
739 arr_changed = true;
740 }
741 _ => next.push(v.clone()),
742 }
743 }
744 if arr_changed {
745 *item = Item::Value(Value::Array(next));
746 changed = true;
747 }
748 }
749 }
750 if let Some(value) = table.remove(old) {
753 table.insert(new, value);
754 changed = true;
755 }
756
757 if changed {
758 save(&path, &doc)?;
759 }
760 Ok(changed)
761}
762
763fn ensure_table<'a>(doc: &'a mut DocumentMut, name: &str) -> &'a mut Table {
764 if !doc.contains_key(name) {
765 let mut t = Table::new();
766 t.set_implicit(false);
767 doc.insert(name, Item::Table(t));
768 }
769 doc.get_mut(name)
770 .unwrap()
771 .as_table_mut()
772 .expect("ensured table shape")
773}
774
775fn ensure_array_of_tables<'a>(
776 doc: &'a mut DocumentMut,
777 outer: &str,
778 inner: &str,
779) -> &'a mut ArrayOfTables {
780 if !doc.contains_key(outer) {
781 let mut t = Table::new();
782 t.set_implicit(true);
783 doc.insert(outer, Item::Table(t));
784 }
785 let outer_table = doc
786 .get_mut(outer)
787 .and_then(|i| i.as_table_mut())
788 .expect("mem_management must be a table");
789 if !outer_table.contains_key(inner) {
790 outer_table.insert(inner, Item::ArrayOfTables(ArrayOfTables::new()));
791 }
792 outer_table
793 .get_mut(inner)
794 .and_then(|i| i.as_array_of_tables_mut())
795 .expect("ensured array-of-tables shape")
796}
797
798fn find_pattern_index(section: &ArrayOfTables, pattern: &str) -> Option<usize> {
799 section
800 .iter()
801 .position(|t| t.get("pattern").and_then(|i| i.as_str()) == Some(pattern))
802}
803
804fn read_rule_schemas(section: &ArrayOfTables, idx: usize) -> Vec<String> {
807 section
808 .get(idx)
809 .and_then(|t| t.get("schemas"))
810 .and_then(|i| i.as_array())
811 .map(|arr| {
812 arr.iter()
813 .filter_map(|v| v.as_str().map(str::to_string))
814 .collect()
815 })
816 .unwrap_or_default()
817}
818
819fn schema_sets_equal(a: &[String], b: &[String]) -> bool {
823 let mut a: Vec<&str> = a.iter().map(String::as_str).collect();
824 let mut b: Vec<&str> = b.iter().map(String::as_str).collect();
825 a.sort_unstable();
826 a.dedup();
827 b.sort_unstable();
828 b.dedup();
829 a == b
830}
831
832fn cross_link_value_item(targets: &[CrossLinkTarget]) -> Item {
833 if targets
834 .iter()
835 .any(|t| matches!(t, CrossLinkTarget::Wildcard))
836 {
837 Item::Value(Value::from("*"))
838 } else {
839 let mut arr = Array::new();
840 for t in targets {
841 if let CrossLinkTarget::Named(name) = t {
842 arr.push(name.as_str());
843 }
844 }
845 Item::Value(Value::Array(arr))
846 }
847}
848
849fn array_contains(arr: &Array, needle: &str) -> bool {
850 arr.iter().any(|v| match v {
851 Value::String(s) => s.value() == needle,
852 _ => false,
853 })
854}
855
856#[cfg(test)]
857mod tests {
858 use super::*;
859 use tempfile::TempDir;
860
861 const DEFAULT_BODY: &str =
862 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n";
863
864 fn seed(body: &str) -> TempDir {
865 let tmp = TempDir::new().unwrap();
866 let memstead = tmp.path().join(".memstead");
867 fs::create_dir_all(&memstead).unwrap();
868 fs::write(memstead.join("workspace.toml"), body).unwrap();
869 tmp
870 }
871
872 fn read(root: &Path) -> String {
873 fs::read_to_string(workspace_toml_path(root)).unwrap()
874 }
875
876 fn known() -> Vec<String> {
882 ["engine", "plugin", "macos", "specs", "default"]
883 .iter()
884 .map(|s| s.to_string())
885 .collect()
886 }
887
888 #[test]
889 fn add_create_rule_appends_by_default() {
890 let tmp = seed(DEFAULT_BODY);
891 add_create_rule(
892 tmp.path(),
893 "exec-*",
894 &["default@1.0.0".to_string()],
895 None,
896 None,
897 )
898 .unwrap();
899 let body = read(tmp.path());
900 assert!(body.contains("[[mem_management.create]]"), "got:\n{body}");
901 assert!(body.contains("pattern = \"exec-*\""), "got:\n{body}");
902 assert!(
903 body.contains("schemas = [\"default@1.0.0\"]"),
904 "got:\n{body}"
905 );
906 }
907
908 #[test]
913 fn add_create_rule_duplicate_is_idempotent_with_warning() {
914 let tmp = seed(DEFAULT_BODY);
915 let first = add_create_rule(
916 tmp.path(),
917 "exec-*",
918 &["default@1.0.0".to_string()],
919 None,
920 None,
921 )
922 .unwrap();
923 assert!(first.is_empty(), "first add must return no warnings");
924 let body_after_first = read(tmp.path());
925 let warnings = add_create_rule(
926 tmp.path(),
927 "exec-*",
928 &["default@1.0.0".to_string()],
929 None,
930 None,
931 )
932 .unwrap();
933 assert_eq!(warnings.len(), 1);
934 assert_eq!(warnings[0].code(), "RULE_ALREADY_PRESENT");
935 let body_after_second = read(tmp.path());
936 assert_eq!(
937 body_after_first, body_after_second,
938 "duplicate add must not rewrite the file",
939 );
940 }
941
942 #[test]
947 fn add_create_rule_differing_schemas_refused_file_unchanged() {
948 let tmp = seed(DEFAULT_BODY);
949 add_create_rule(
950 tmp.path(),
951 "scratch",
952 &["software@0.1.0".to_string()],
953 None,
954 None,
955 )
956 .unwrap();
957 let body_before = read(tmp.path());
958
959 let err = add_create_rule(
960 tmp.path(),
961 "scratch",
962 &["nonexistent@9.9.9".to_string()],
963 None,
964 None,
965 )
966 .expect_err("differing schemas must be refused, not silently no-op'd");
967 assert_eq!(err.code(), "RULE_EXISTS_SCHEMAS_DIFFER");
968 match &err {
969 WorkspaceEditError::RuleExistsSchemasDiffer {
970 stored, requested, ..
971 } => {
972 assert_eq!(stored, &["software@0.1.0".to_string()]);
973 assert_eq!(requested, &["nonexistent@9.9.9".to_string()]);
974 }
975 other => panic!("expected RuleExistsSchemasDiffer, got {other:?}"),
976 }
977 assert_eq!(
978 body_before,
979 read(tmp.path()),
980 "refused schema change must not rewrite the file (stored schemas stay put)",
981 );
982 }
983
984 #[test]
987 fn add_create_rule_reordered_schemas_is_idempotent_noop() {
988 let tmp = seed(DEFAULT_BODY);
989 add_create_rule(
990 tmp.path(),
991 "scratch",
992 &["a@1.0.0".to_string(), "b@1.0.0".to_string()],
993 None,
994 None,
995 )
996 .unwrap();
997 let warnings = add_create_rule(
998 tmp.path(),
999 "scratch",
1000 &["b@1.0.0".to_string(), "a@1.0.0".to_string()],
1001 None,
1002 None,
1003 )
1004 .expect("reordered identical schema set must stay a no-op");
1005 assert_eq!(warnings.len(), 1);
1006 assert_eq!(warnings[0].code(), "RULE_ALREADY_PRESENT");
1007 }
1008
1009 #[test]
1012 fn revoke_then_readd_applies_the_new_schemas() {
1013 let tmp = seed(DEFAULT_BODY);
1014 add_create_rule(
1015 tmp.path(),
1016 "scratch",
1017 &["software@0.1.0".to_string()],
1018 None,
1019 None,
1020 )
1021 .unwrap();
1022 remove_create_rule(tmp.path(), "scratch").unwrap();
1023 let warnings = add_create_rule(
1024 tmp.path(),
1025 "scratch",
1026 &["planning@0.1.0".to_string()],
1027 None,
1028 None,
1029 )
1030 .expect("re-add after revoke must succeed");
1031 assert!(warnings.is_empty(), "fresh add returns no warnings");
1032 let body = read(tmp.path());
1033 assert!(
1034 body.contains("schemas = [\"planning@0.1.0\"]"),
1035 "new pins stored; got:\n{body}"
1036 );
1037 assert!(
1038 !body.contains("software@0.1.0"),
1039 "old pins gone; got:\n{body}"
1040 );
1041 }
1042
1043 #[test]
1044 fn add_create_rule_before_lifts_priority() {
1045 let tmp = seed(DEFAULT_BODY);
1046 add_create_rule(
1047 tmp.path(),
1048 "z-*",
1049 &["default@1.0.0".to_string()],
1050 None,
1051 None,
1052 )
1053 .unwrap();
1054 add_create_rule(
1055 tmp.path(),
1056 "a-*",
1057 &["default@1.0.0".to_string()],
1058 None,
1059 Some("z-*"),
1060 )
1061 .unwrap();
1062 let body = read(tmp.path());
1063 let a_idx = body.find("pattern = \"a-*\"").expect("a-* must exist");
1064 let z_idx = body.find("pattern = \"z-*\"").expect("z-* must exist");
1065 assert!(
1066 a_idx < z_idx,
1067 "--before must place new rule above target; got:\n{body}"
1068 );
1069 }
1070
1071 #[test]
1072 fn add_create_rule_before_unknown_pattern_errors() {
1073 let tmp = seed(DEFAULT_BODY);
1074 let err = add_create_rule(
1075 tmp.path(),
1076 "exec-*",
1077 &["default@1.0.0".to_string()],
1078 None,
1079 Some("does-not-exist"),
1080 )
1081 .unwrap_err();
1082 assert_eq!(err.code(), "BEFORE_PATTERN_NOT_FOUND");
1083 }
1084
1085 #[test]
1086 fn add_create_rule_with_named_cross_links() {
1087 let tmp = seed(DEFAULT_BODY);
1088 add_create_rule(
1089 tmp.path(),
1090 "exec-*",
1091 &["default@1.0.0".to_string()],
1092 Some(&[CrossLinkTarget::Named("engine".to_string())]),
1093 None,
1094 )
1095 .unwrap();
1096 let body = read(tmp.path());
1097 assert!(
1098 body.contains("default_cross_links = [\"engine\"]"),
1099 "got:\n{body}"
1100 );
1101 }
1102
1103 #[test]
1104 fn add_create_rule_with_wildcard_cross_links() {
1105 let tmp = seed(DEFAULT_BODY);
1106 add_create_rule(
1107 tmp.path(),
1108 "exec-*",
1109 &["default@1.0.0".to_string()],
1110 Some(&[CrossLinkTarget::Wildcard]),
1111 None,
1112 )
1113 .unwrap();
1114 let body = read(tmp.path());
1115 assert!(body.contains("default_cross_links = \"*\""), "got:\n{body}");
1116 }
1117
1118 #[test]
1119 fn remove_create_rule_succeeds() {
1120 let tmp = seed(DEFAULT_BODY);
1121 add_create_rule(
1122 tmp.path(),
1123 "exec-*",
1124 &["default@1.0.0".to_string()],
1125 None,
1126 None,
1127 )
1128 .unwrap();
1129 remove_create_rule(tmp.path(), "exec-*").unwrap();
1130 let body = read(tmp.path());
1131 assert!(!body.contains("pattern = \"exec-*\""), "got:\n{body}");
1132 }
1133
1134 #[test]
1137 fn remove_create_rule_unknown_pattern_is_idempotent_with_warning() {
1138 let tmp = seed(DEFAULT_BODY);
1139 let body_before = read(tmp.path());
1140 let warnings = remove_create_rule(tmp.path(), "ghost").unwrap();
1141 assert_eq!(warnings.len(), 1);
1142 assert_eq!(warnings[0].code(), "RULE_NOT_FOUND_NOOP");
1143 let body_after = read(tmp.path());
1144 assert_eq!(
1145 body_before, body_after,
1146 "no-op remove must not touch the file"
1147 );
1148 }
1149
1150 #[test]
1151 fn add_and_remove_delete_rule() {
1152 let tmp = seed(DEFAULT_BODY);
1153 add_delete_rule(tmp.path(), "exec-*").unwrap();
1154 let body = read(tmp.path());
1155 assert!(body.contains("[[mem_management.delete]]"), "got:\n{body}");
1156 assert!(body.contains("pattern = \"exec-*\""), "got:\n{body}");
1157 remove_delete_rule(tmp.path(), "exec-*").unwrap();
1158 let body = read(tmp.path());
1159 assert!(!body.contains("pattern = \"exec-*\""), "got:\n{body}");
1160 }
1161
1162 #[test]
1163 fn grant_cross_link_creates_named_list() {
1164 let tmp = seed(DEFAULT_BODY);
1165 grant_cross_link(
1166 tmp.path(),
1167 "plugin",
1168 &CrossLinkTarget::Named("engine".to_string()),
1169 &known(),
1170 )
1171 .unwrap();
1172 let body = read(tmp.path());
1173 assert!(body.contains("plugin = [\"engine\"]"), "got:\n{body}");
1174 }
1175
1176 #[test]
1177 fn grant_cross_link_appends_named_target() {
1178 let tmp = seed(DEFAULT_BODY);
1179 grant_cross_link(
1180 tmp.path(),
1181 "macos",
1182 &CrossLinkTarget::Named("engine".to_string()),
1183 &known(),
1184 )
1185 .unwrap();
1186 grant_cross_link(
1187 tmp.path(),
1188 "macos",
1189 &CrossLinkTarget::Named("plugin".to_string()),
1190 &known(),
1191 )
1192 .unwrap();
1193 let body = read(tmp.path());
1194 assert!(
1195 body.contains("macos = [\"engine\", \"plugin\"]"),
1196 "got:\n{body}"
1197 );
1198 }
1199
1200 #[test]
1201 fn grant_cross_link_wildcard_sets_string() {
1202 let tmp = seed(DEFAULT_BODY);
1203 grant_cross_link(tmp.path(), "specs", &CrossLinkTarget::Wildcard, &known()).unwrap();
1204 let body = read(tmp.path());
1205 assert!(body.contains("specs = \"*\""), "got:\n{body}");
1206 }
1207
1208 #[test]
1212 fn grant_cross_link_duplicate_named_is_idempotent_with_warning() {
1213 let tmp = seed(DEFAULT_BODY);
1214 grant_cross_link(
1215 tmp.path(),
1216 "plugin",
1217 &CrossLinkTarget::Named("engine".to_string()),
1218 &known(),
1219 )
1220 .unwrap();
1221 let body_before = read(tmp.path());
1222 let warnings = grant_cross_link(
1223 tmp.path(),
1224 "plugin",
1225 &CrossLinkTarget::Named("engine".to_string()),
1226 &known(),
1227 )
1228 .unwrap();
1229 assert_eq!(warnings.len(), 1);
1230 assert_eq!(warnings[0].code(), "GRANT_ALREADY_PRESENT");
1231 let body_after = read(tmp.path());
1232 assert_eq!(
1233 body_before, body_after,
1234 "duplicate grant must not rewrite the file"
1235 );
1236 }
1237
1238 #[test]
1239 fn grant_cross_link_named_over_wildcard_conflicts() {
1240 let tmp = seed(DEFAULT_BODY);
1241 grant_cross_link(tmp.path(), "plugin", &CrossLinkTarget::Wildcard, &known()).unwrap();
1242 let err = grant_cross_link(
1243 tmp.path(),
1244 "plugin",
1245 &CrossLinkTarget::Named("engine".to_string()),
1246 &known(),
1247 )
1248 .unwrap_err();
1249 assert_eq!(err.code(), "CROSS_LINK_CONFLICT");
1250 }
1251
1252 #[test]
1256 fn grant_cross_link_warns_on_unregistered_named_target() {
1257 let tmp = seed(DEFAULT_BODY);
1258 let registered = vec!["plugin".to_string()];
1259 let warnings = grant_cross_link(
1260 tmp.path(),
1261 "plugin",
1262 &CrossLinkTarget::Named("future-mem".to_string()),
1263 ®istered,
1264 )
1265 .unwrap();
1266 assert_eq!(warnings.len(), 1);
1267 assert_eq!(warnings[0].code(), "CROSS_LINK_TARGET_UNREGISTERED");
1268 assert!(
1270 read(tmp.path()).contains("plugin = [\"future-mem\"]"),
1271 "grant must persist for the forward-reference workflow: {}",
1272 read(tmp.path())
1273 );
1274 }
1275
1276 #[test]
1279 fn grant_cross_link_warns_on_self_grant() {
1280 let tmp = seed(DEFAULT_BODY);
1281 let warnings = grant_cross_link(
1282 tmp.path(),
1283 "plugin",
1284 &CrossLinkTarget::Named("plugin".to_string()),
1285 &known(),
1286 )
1287 .unwrap();
1288 assert_eq!(warnings.len(), 1);
1289 assert_eq!(warnings[0].code(), "CROSS_LINK_SELF_GRANT_NOOP");
1290 assert!(read(tmp.path()).contains("plugin = [\"plugin\"]"));
1291 }
1292
1293 #[test]
1297 fn grant_cross_link_wildcard_not_target_validated() {
1298 let tmp = seed(DEFAULT_BODY);
1299 let warnings =
1300 grant_cross_link(tmp.path(), "plugin", &CrossLinkTarget::Wildcard, &[]).unwrap();
1301 assert!(
1302 warnings.is_empty(),
1303 "wildcard target must not be validated against the router: {warnings:?}"
1304 );
1305 }
1306
1307 #[test]
1310 fn grant_cross_link_registered_target_no_warning() {
1311 let tmp = seed(DEFAULT_BODY);
1312 let registered = vec!["engine".to_string()];
1313 let warnings = grant_cross_link(
1314 tmp.path(),
1315 "plugin",
1316 &CrossLinkTarget::Named("engine".to_string()),
1317 ®istered,
1318 )
1319 .unwrap();
1320 assert!(
1321 warnings.is_empty(),
1322 "registered target must warn nothing: {warnings:?}"
1323 );
1324 }
1325
1326 #[test]
1327 fn revoke_cross_link_removes_named_target() {
1328 let tmp = seed(DEFAULT_BODY);
1329 grant_cross_link(
1330 tmp.path(),
1331 "macos",
1332 &CrossLinkTarget::Named("engine".to_string()),
1333 &known(),
1334 )
1335 .unwrap();
1336 grant_cross_link(
1337 tmp.path(),
1338 "macos",
1339 &CrossLinkTarget::Named("plugin".to_string()),
1340 &known(),
1341 )
1342 .unwrap();
1343 revoke_cross_link(
1344 tmp.path(),
1345 "macos",
1346 &CrossLinkTarget::Named("engine".to_string()),
1347 )
1348 .unwrap();
1349 let body = read(tmp.path());
1350 assert!(body.contains("macos = ["), "got:\n{body}");
1354 assert!(body.contains("\"plugin\""), "got:\n{body}");
1355 assert!(
1356 !body.contains("\"engine\""),
1357 "engine target must be removed, got:\n{body}"
1358 );
1359 }
1360
1361 #[test]
1362 fn revoke_cross_link_empties_key() {
1363 let tmp = seed(DEFAULT_BODY);
1364 grant_cross_link(
1365 tmp.path(),
1366 "macos",
1367 &CrossLinkTarget::Named("engine".to_string()),
1368 &known(),
1369 )
1370 .unwrap();
1371 revoke_cross_link(
1372 tmp.path(),
1373 "macos",
1374 &CrossLinkTarget::Named("engine".to_string()),
1375 )
1376 .unwrap();
1377 let body = read(tmp.path());
1378 assert!(
1379 !body.contains("macos"),
1380 "empty allowlist must drop the key, got:\n{body}"
1381 );
1382 }
1383
1384 #[test]
1385 fn revoke_cross_link_wildcard() {
1386 let tmp = seed(DEFAULT_BODY);
1387 grant_cross_link(tmp.path(), "specs", &CrossLinkTarget::Wildcard, &known()).unwrap();
1388 revoke_cross_link(tmp.path(), "specs", &CrossLinkTarget::Wildcard).unwrap();
1389 let body = read(tmp.path());
1390 assert!(!body.contains("specs"), "got:\n{body}");
1391 }
1392
1393 #[test]
1397 fn revoke_cross_link_not_granted_is_idempotent_with_warning() {
1398 let tmp = seed(DEFAULT_BODY);
1399 let body_before = read(tmp.path());
1400 let warnings = revoke_cross_link(
1401 tmp.path(),
1402 "macos",
1403 &CrossLinkTarget::Named("engine".to_string()),
1404 )
1405 .unwrap();
1406 assert_eq!(warnings.len(), 1);
1407 assert_eq!(warnings[0].code(), "GRANT_NOT_FOUND");
1408 let body_after = read(tmp.path());
1409 assert_eq!(
1410 body_before, body_after,
1411 "no-op revoke must not touch the file"
1412 );
1413 }
1414
1415 #[test]
1416 fn set_mutation_require_notes_creates_section() {
1417 let tmp = seed(DEFAULT_BODY);
1418 set_mutation_require_notes(tmp.path(), true).unwrap();
1419 let body = read(tmp.path());
1420 assert!(body.contains("[mutations]"), "got:\n{body}");
1421 assert!(body.contains("require_notes = true"), "got:\n{body}");
1422 }
1423
1424 #[test]
1425 fn set_mutation_require_notes_toggles() {
1426 let tmp = seed(DEFAULT_BODY);
1427 set_mutation_require_notes(tmp.path(), true).unwrap();
1428 set_mutation_require_notes(tmp.path(), false).unwrap();
1429 let body = read(tmp.path());
1430 assert!(body.contains("require_notes = false"), "got:\n{body}");
1431 }
1432
1433 #[test]
1434 fn missing_workspace_toml_errors_with_typed_code() {
1435 let tmp = TempDir::new().unwrap();
1436 let err = add_create_rule(tmp.path(), "exec-*", &[], None, None).unwrap_err();
1437 assert_eq!(err.code(), "WORKSPACE_NOT_INITIALISED");
1438 }
1439
1440 #[test]
1441 fn comments_outside_edited_sections_survive() {
1442 let body = "# operator comment 1\n\
1447format = \"memstead-git-branch-2\"\n\
1448\n\
1449# operator comment 2\n\
1450[persistence_adapter]\n\
1451name = \"file-two-layer\"\n\
1452\n\
1453# section explanation that must survive\n\
1454[cross_mem_links]\n\
1455plugin = [\"engine\"] # inline pin\n";
1456 let tmp = seed(body);
1457
1458 add_create_rule(
1459 tmp.path(),
1460 "exec-*",
1461 &["default@1.0.0".to_string()],
1462 None,
1463 None,
1464 )
1465 .unwrap();
1466
1467 let new_body = read(tmp.path());
1468 assert!(new_body.contains("# operator comment 1"));
1469 assert!(new_body.contains("# operator comment 2"));
1470 assert!(new_body.contains("# section explanation that must survive"));
1471 assert!(new_body.contains("# inline pin"));
1472 assert!(new_body.contains("[[mem_management.create]]"));
1473 }
1474
1475 #[test]
1483 fn scrub_policy_for_deleted_mem_drops_cross_links_but_keeps_allowlist_rules() {
1484 let body = "format = \"memstead-git-branch-2\"\n\n\
1485 [cross_mem_links]\n\
1486 other = [\"test\"]\n\
1487 test = [\"other\", \"keep\"]\n\
1488 \n\
1489 [[mem_management.create]]\n\
1490 pattern = \"other\"\n\
1491 schemas = [\"default@1.0.0\"]\n\
1492 \n\
1493 [[mem_management.create]]\n\
1494 pattern = \"*\"\n\
1495 schemas = [\"default@1.0.0\"]\n\
1496 \n\
1497 [[mem_management.delete]]\n\
1498 pattern = \"other\"\n\
1499 \n\
1500 [[mem_management.delete]]\n\
1501 pattern = \"team/*\"\n";
1502 let tmp = seed(body);
1503 let scrubbed = scrub_policy_for_deleted_mem(tmp.path(), "other").unwrap();
1504 assert!(
1509 scrubbed
1510 .iter()
1511 .all(|e| matches!(e, ScrubbedEntry::CrossLink { .. })),
1512 "scrub must report only cross-link grants, got: {scrubbed:?}"
1513 );
1514 assert!(
1515 scrubbed.contains(&ScrubbedEntry::CrossLink {
1516 from: "other".to_string(),
1517 to: "test".to_string(),
1518 }),
1519 "deleted mem's own grant must be reported scrubbed, got: {scrubbed:?}"
1520 );
1521 assert!(
1522 scrubbed.contains(&ScrubbedEntry::CrossLink {
1523 from: "test".to_string(),
1524 to: "other".to_string(),
1525 }),
1526 "peer grant naming the deleted mem must be reported scrubbed, got: {scrubbed:?}"
1527 );
1528 let after = read(tmp.path());
1529 assert!(
1531 !after.contains("\nother = ["),
1532 "`other` key must be scrubbed from cross_mem_links — got:\n{after}"
1533 );
1534 assert!(after.contains("\"keep\""), "non-target values must survive");
1536 assert_eq!(
1540 after.matches("pattern = \"other\"").count(),
1541 2,
1542 "exact-name mem_management.{{create,delete}} rules for `other` must survive — got:\n{after}"
1543 );
1544 assert!(
1545 after.contains("pattern = \"*\""),
1546 "wildcard `*` rule must survive"
1547 );
1548 assert!(
1549 after.contains("pattern = \"team/*\""),
1550 "glob `team/*` rule must survive"
1551 );
1552 }
1553
1554 #[test]
1558 fn scrub_policy_for_deleted_mem_missing_file_is_noop() {
1559 let tmp = TempDir::new().unwrap();
1560 let outcome = scrub_policy_for_deleted_mem(tmp.path(), "other");
1562 assert!(outcome.is_ok(), "missing workspace.toml must not error");
1563 }
1564
1565 #[test]
1568 fn scrub_policy_for_deleted_mem_no_match_leaves_file_unchanged() {
1569 let body = "format = \"memstead-git-branch-2\"\n\n\
1570 [cross_mem_links]\n\
1571 test = [\"keep\"]\n\
1572 \n\
1573 [[mem_management.create]]\n\
1574 pattern = \"*\"\n\
1575 schemas = [\"default@1.0.0\"]\n";
1576 let tmp = seed(body);
1577 let before = read(tmp.path());
1578 scrub_policy_for_deleted_mem(tmp.path(), "ghost").unwrap();
1579 let after = read(tmp.path());
1580 assert_eq!(before, after, "unrelated delete must not rewrite the file");
1581 }
1582
1583 #[test]
1587 fn scrub_policy_for_deleted_mem_drops_emptied_allowlist_key() {
1588 let body = "format = \"memstead-git-branch-2\"\n\n\
1589 [cross_mem_links]\n\
1590 test = [\"other\"]\n";
1591 let tmp = seed(body);
1592 scrub_policy_for_deleted_mem(tmp.path(), "other").unwrap();
1593 let after = read(tmp.path());
1594 assert!(
1595 !after.contains("\ntest = ["),
1596 "key whose allowlist drained to empty must be dropped — got:\n{after}"
1597 );
1598 }
1599}