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 CrossLinkRevokeOrphanedEdges { edges: Vec<String> },
116}
117
118impl WorkspaceEditWarning {
119 pub fn code(&self) -> &'static str {
122 match self {
123 Self::RuleAlreadyPresent { .. } => "RULE_ALREADY_PRESENT",
124 Self::RuleNotFoundNoop { .. } => "RULE_NOT_FOUND_NOOP",
125 Self::GrantAlreadyPresent { .. } => "GRANT_ALREADY_PRESENT",
126 Self::GrantNotFound { .. } => "GRANT_NOT_FOUND",
127 Self::CrossLinkTargetUnregistered { .. } => "CROSS_LINK_TARGET_UNREGISTERED",
128 Self::CrossLinkSelfGrantNoop { .. } => "CROSS_LINK_SELF_GRANT_NOOP",
129 Self::CrossLinkRevokeOrphanedEdges { .. } => "CROSS_LINK_REVOKE_ORPHANED_EDGES",
130 }
131 }
132}
133
134impl std::fmt::Display for WorkspaceEditWarning {
135 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
136 match self {
137 Self::RuleAlreadyPresent { section, pattern } => write!(
138 f,
139 "`[[{section}]]` already contains an entry for pattern `{pattern}` — file unchanged"
140 ),
141 Self::RuleNotFoundNoop { section, pattern } => write!(
142 f,
143 "`[[{section}]]` has no entry for pattern `{pattern}` — file unchanged"
144 ),
145 Self::GrantAlreadyPresent { from, to } => write!(
146 f,
147 "`[cross_mem_links]` already grants {from} → {to} — file unchanged"
148 ),
149 Self::GrantNotFound { from, to } => write!(
150 f,
151 "`[cross_mem_links]` does not grant {from} → {to} — file unchanged"
152 ),
153 Self::CrossLinkTargetUnregistered { to } => write!(
154 f,
155 "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"
156 ),
157 Self::CrossLinkSelfGrantNoop { mem } => write!(
158 f,
159 "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"
160 ),
161 Self::CrossLinkRevokeOrphanedEdges { edges } => write!(
162 f,
163 "{} existing edge(s) are now without a grant and are NOT removed: {}. Each refuses `memstead health --include integrity --strict` until it is granted again or removed (`memstead relate ... --remove`, which needs no grant)",
164 edges.len(),
165 edges.join(", ")
166 ),
167 }
168 }
169}
170
171impl std::fmt::Display for WorkspaceEditError {
172 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
173 match self {
174 Self::WorkspaceNotInitialised { path, .. } => write!(
175 f,
176 "no `.memstead/workspace.toml` at {} — run `memstead mem-repo init` or `memstead init` first",
177 path.display()
178 ),
179 Self::InvalidToml { path, message } => {
180 write!(f, "{}: failed to parse TOML — {message}", path.display())
181 }
182 Self::BeforePatternNotFound { section, pattern } => write!(
183 f,
184 "`--before {pattern}` did not match any existing `[[{section}]]` entry"
185 ),
186 Self::CrossLinkConflict { from, message } => write!(
187 f,
188 "`[cross_mem_links]` rejects edit for `{from}`: {message}"
189 ),
190 Self::RuleExistsSchemasDiffer {
191 section,
192 pattern,
193 stored,
194 requested,
195 } => write!(
196 f,
197 "`[[{section}]]` already has a rule for pattern `{pattern}` pinned to schemas [{}], \
198 which differs from the requested [{}] — refusing to silently change the schema pins. \
199 To change them, revoke the rule first (`revoke_create {pattern}`) then re-add it with the new schemas",
200 stored.join(", "),
201 requested.join(", "),
202 ),
203 Self::Io { path, source } => write!(f, "{}: {source}", path.display()),
204 }
205 }
206}
207
208impl std::error::Error for WorkspaceEditError {}
209
210impl WorkspaceEditError {
211 pub fn code(&self) -> &'static str {
213 match self {
214 Self::WorkspaceNotInitialised { .. } => "WORKSPACE_NOT_INITIALISED",
215 Self::InvalidToml { .. } => "INVALID_TOML",
216 Self::BeforePatternNotFound { .. } => "BEFORE_PATTERN_NOT_FOUND",
217 Self::CrossLinkConflict { .. } => "CROSS_LINK_CONFLICT",
218 Self::RuleExistsSchemasDiffer { .. } => "RULE_EXISTS_SCHEMAS_DIFFER",
219 Self::Io { .. } => "IO_ERROR",
220 }
221 }
222}
223
224pub fn workspace_toml_path(workspace_root: &Path) -> PathBuf {
226 workspace_root
227 .join(memstead_base::WORKSPACE_STORE_DIR)
228 .join("workspace.toml")
229}
230
231fn load(workspace_root: &Path) -> Result<(PathBuf, DocumentMut), WorkspaceEditError> {
232 let path = workspace_toml_path(workspace_root);
233 let text = fs::read_to_string(&path).map_err(|source| {
234 if source.kind() == std::io::ErrorKind::NotFound {
235 WorkspaceEditError::WorkspaceNotInitialised { path: path.clone() }
236 } else {
237 WorkspaceEditError::Io {
238 path: path.clone(),
239 source,
240 }
241 }
242 })?;
243 let doc: DocumentMut =
244 text.parse()
245 .map_err(|e: toml_edit::TomlError| WorkspaceEditError::InvalidToml {
246 path: path.clone(),
247 message: e.to_string(),
248 })?;
249 Ok((path, doc))
250}
251
252fn save(path: &Path, doc: &DocumentMut) -> Result<(), WorkspaceEditError> {
253 fs::write(path, doc.to_string()).map_err(|source| WorkspaceEditError::Io {
254 path: path.to_path_buf(),
255 source,
256 })
257}
258
259#[derive(Debug, Clone, PartialEq, Eq)]
264pub enum CrossLinkTarget {
265 Wildcard,
267 Named(String),
270}
271
272impl CrossLinkTarget {
273 pub fn parse(raw: &str) -> Self {
278 if raw == "*" {
279 Self::Wildcard
280 } else {
281 Self::Named(raw.to_string())
282 }
283 }
284}
285
286pub fn add_create_rule(
291 workspace_root: &Path,
292 pattern: &str,
293 schemas: &[String],
294 default_cross_links: Option<&[CrossLinkTarget]>,
295 before: Option<&str>,
296) -> Result<Vec<WorkspaceEditWarning>, WorkspaceEditError> {
297 let (path, mut doc) = load(workspace_root)?;
298 let section = ensure_array_of_tables(&mut doc, "mem_management", "create");
299
300 if let Some(idx) = find_pattern_index(section, pattern) {
301 let stored = read_rule_schemas(section, idx);
307 if schema_sets_equal(&stored, schemas) {
308 return Ok(vec![WorkspaceEditWarning::RuleAlreadyPresent {
309 section: "mem_management.create",
310 pattern: pattern.to_string(),
311 }]);
312 }
313 return Err(WorkspaceEditError::RuleExistsSchemasDiffer {
314 section: "mem_management.create",
315 pattern: pattern.to_string(),
316 stored,
317 requested: schemas.to_vec(),
318 });
319 }
320
321 let mut table = Table::new();
322 table["pattern"] = Item::Value(Value::from(pattern));
323 let mut arr = Array::new();
324 for s in schemas {
325 arr.push(s.as_str());
326 }
327 table["schemas"] = Item::Value(Value::Array(arr));
328 if let Some(cross_links) = default_cross_links {
329 table["default_cross_links"] = cross_link_value_item(cross_links);
330 }
331
332 if let Some(before_pattern) = before {
333 let idx = find_pattern_index(section, before_pattern).ok_or_else(|| {
334 WorkspaceEditError::BeforePatternNotFound {
335 section: "mem_management.create",
336 pattern: before_pattern.to_string(),
337 }
338 })?;
339 let mut tail = Vec::with_capacity(section.len() - idx);
343 while section.len() > idx {
344 let last = section.get(section.len() - 1).cloned().unwrap();
345 tail.push(last);
346 section.remove(section.len() - 1);
347 }
348 section.push(table);
349 for entry in tail.into_iter().rev() {
350 section.push(entry);
351 }
352 } else {
353 section.push(table);
354 }
355
356 save(&path, &doc)?;
357 Ok(Vec::new())
358}
359
360pub fn remove_create_rule(
363 workspace_root: &Path,
364 pattern: &str,
365) -> Result<Vec<WorkspaceEditWarning>, WorkspaceEditError> {
366 let (path, mut doc) = load(workspace_root)?;
367 let section = ensure_array_of_tables(&mut doc, "mem_management", "create");
368 let idx = match find_pattern_index(section, pattern) {
369 Some(i) => i,
370 None => {
371 return Ok(vec![WorkspaceEditWarning::RuleNotFoundNoop {
372 section: "mem_management.create",
373 pattern: pattern.to_string(),
374 }]);
375 }
376 };
377 section.remove(idx);
378 save(&path, &doc)?;
379 Ok(Vec::new())
380}
381
382pub fn add_delete_rule(
385 workspace_root: &Path,
386 pattern: &str,
387) -> Result<Vec<WorkspaceEditWarning>, WorkspaceEditError> {
388 let (path, mut doc) = load(workspace_root)?;
389 let section = ensure_array_of_tables(&mut doc, "mem_management", "delete");
390 if find_pattern_index(section, pattern).is_some() {
391 return Ok(vec![WorkspaceEditWarning::RuleAlreadyPresent {
392 section: "mem_management.delete",
393 pattern: pattern.to_string(),
394 }]);
395 }
396 let mut table = Table::new();
397 table["pattern"] = Item::Value(Value::from(pattern));
398 section.push(table);
399 save(&path, &doc)?;
400 Ok(Vec::new())
401}
402
403pub fn remove_delete_rule(
406 workspace_root: &Path,
407 pattern: &str,
408) -> Result<Vec<WorkspaceEditWarning>, WorkspaceEditError> {
409 let (path, mut doc) = load(workspace_root)?;
410 let section = ensure_array_of_tables(&mut doc, "mem_management", "delete");
411 let idx = match find_pattern_index(section, pattern) {
412 Some(i) => i,
413 None => {
414 return Ok(vec![WorkspaceEditWarning::RuleNotFoundNoop {
415 section: "mem_management.delete",
416 pattern: pattern.to_string(),
417 }]);
418 }
419 };
420 section.remove(idx);
421 save(&path, &doc)?;
422 Ok(Vec::new())
423}
424
425pub fn grant_cross_link(
429 workspace_root: &Path,
430 from: &str,
431 to: &CrossLinkTarget,
432 known_mems: &[String],
433) -> Result<Vec<WorkspaceEditWarning>, WorkspaceEditError> {
434 let mut warnings: Vec<WorkspaceEditWarning> = Vec::new();
441 if let CrossLinkTarget::Named(name) = to {
442 if name == from {
443 warnings.push(WorkspaceEditWarning::CrossLinkSelfGrantNoop {
444 mem: from.to_string(),
445 });
446 } else if !known_mems.iter().any(|v| v == name) {
447 warnings.push(WorkspaceEditWarning::CrossLinkTargetUnregistered { to: name.clone() });
448 }
449 }
450
451 let (path, mut doc) = load(workspace_root)?;
452 let table = ensure_table(&mut doc, "cross_mem_links");
453 match (table.get(from), to) {
454 (None, CrossLinkTarget::Wildcard) => {
455 table.insert(from, Item::Value(Value::from("*")));
456 }
457 (None, CrossLinkTarget::Named(name)) => {
458 let mut arr = Array::new();
459 arr.push(name.as_str());
460 table.insert(from, Item::Value(Value::Array(arr)));
461 }
462 (Some(Item::Value(Value::String(s))), CrossLinkTarget::Wildcard) if s.value() == "*" => {
463 warnings.push(WorkspaceEditWarning::GrantAlreadyPresent {
464 from: from.to_string(),
465 to: "*".to_string(),
466 });
467 return Ok(warnings);
468 }
469 (Some(Item::Value(Value::String(_))), _) => {
470 return Err(WorkspaceEditError::CrossLinkConflict {
471 from: from.to_string(),
472 message: "wildcard `*` already set — revoke `*` before granting a named target"
473 .to_string(),
474 });
475 }
476 (Some(Item::Value(Value::Array(_))), CrossLinkTarget::Wildcard) => {
477 return Err(WorkspaceEditError::CrossLinkConflict {
478 from: from.to_string(),
479 message: "specific allowlist already set — revoke every entry before granting `*`"
480 .to_string(),
481 });
482 }
483 (Some(Item::Value(Value::Array(arr))), CrossLinkTarget::Named(name)) => {
484 if array_contains(arr, name) {
485 warnings.push(WorkspaceEditWarning::GrantAlreadyPresent {
486 from: from.to_string(),
487 to: name.clone(),
488 });
489 return Ok(warnings);
490 }
491 let mut arr = arr.clone();
492 arr.push(name.as_str());
493 table.insert(from, Item::Value(Value::Array(arr)));
494 }
495 (Some(_), _) => {
496 return Err(WorkspaceEditError::CrossLinkConflict {
497 from: from.to_string(),
498 message: "existing value is neither a string nor an array — fix by hand"
499 .to_string(),
500 });
501 }
502 }
503 save(&path, &doc)?;
504 Ok(warnings)
505}
506
507pub fn revoke_cross_link(
511 workspace_root: &Path,
512 from: &str,
513 to: &CrossLinkTarget,
514) -> Result<Vec<WorkspaceEditWarning>, WorkspaceEditError> {
515 let (path, mut doc) = load(workspace_root)?;
516 let table = ensure_table(&mut doc, "cross_mem_links");
517 let removed = match (table.get(from), to) {
518 (None, _) => false,
519 (Some(Item::Value(Value::String(s))), CrossLinkTarget::Wildcard) if s.value() == "*" => {
520 table.remove(from);
521 true
522 }
523 (Some(Item::Value(Value::String(_))), CrossLinkTarget::Named(_)) => false,
524 (Some(Item::Value(Value::String(_))), CrossLinkTarget::Wildcard) => false,
525 (Some(Item::Value(Value::Array(_))), CrossLinkTarget::Wildcard) => false,
526 (Some(Item::Value(Value::Array(arr))), CrossLinkTarget::Named(name)) => {
527 let mut arr = arr.clone();
528 let original_len = arr.len();
529 arr.retain(|v| match v {
530 Value::String(s) => s.value() != name,
531 _ => true,
532 });
533 if arr.len() == original_len {
534 false
535 } else if arr.is_empty() {
536 table.remove(from);
537 true
538 } else {
539 table.insert(from, Item::Value(Value::Array(arr)));
540 true
541 }
542 }
543 (Some(_), _) => false,
544 };
545 if !removed {
546 let target = match to {
547 CrossLinkTarget::Wildcard => "*".to_string(),
548 CrossLinkTarget::Named(s) => s.clone(),
549 };
550 return Ok(vec![WorkspaceEditWarning::GrantNotFound {
551 from: from.to_string(),
552 to: target,
553 }]);
554 }
555 save(&path, &doc)?;
556 Ok(Vec::new())
557}
558
559pub fn set_mutation_require_notes(
562 workspace_root: &Path,
563 value: bool,
564) -> Result<(), WorkspaceEditError> {
565 let (path, mut doc) = load(workspace_root)?;
566 let table = ensure_table(&mut doc, "mutations");
567 table.insert("require_notes", Item::Value(Value::from(value)));
568 save(&path, &doc)
569}
570
571pub fn scrub_policy_for_deleted_mem(
606 workspace_root: &Path,
607 mem_name: &str,
608) -> Result<Vec<ScrubbedEntry>, WorkspaceEditError> {
609 let path = workspace_toml_path(workspace_root);
610 let text = match fs::read_to_string(&path) {
611 Ok(t) => t,
612 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
613 return Ok(Vec::new());
615 }
616 Err(source) => {
617 return Err(WorkspaceEditError::Io {
618 path: path.clone(),
619 source,
620 });
621 }
622 };
623 let mut doc: DocumentMut =
624 text.parse()
625 .map_err(|e: toml_edit::TomlError| WorkspaceEditError::InvalidToml {
626 path: path.clone(),
627 message: e.to_string(),
628 })?;
629
630 let mut scrubbed: Vec<ScrubbedEntry> = Vec::new();
631
632 if let Some(item) = doc.get_mut("cross_mem_links")
633 && let Some(table) = item.as_table_mut()
634 {
635 if let Some(removed) = table.remove(mem_name) {
638 let targets = match removed {
639 Item::Value(Value::Array(arr)) => arr
640 .iter()
641 .filter_map(|v| match v {
642 Value::String(s) => Some(s.value().to_string()),
643 _ => None,
644 })
645 .collect::<Vec<_>>(),
646 Item::Value(Value::String(s)) => vec![s.value().to_string()],
647 _ => Vec::new(),
648 };
649 if targets.is_empty() {
650 scrubbed.push(ScrubbedEntry::CrossLink {
651 from: mem_name.to_string(),
652 to: "*".to_string(),
653 });
654 } else {
655 for to in targets {
656 scrubbed.push(ScrubbedEntry::CrossLink {
657 from: mem_name.to_string(),
658 to,
659 });
660 }
661 }
662 }
663 let keys: Vec<String> = table.iter().map(|(k, _)| k.to_string()).collect();
667 for key in keys {
668 let drop_key = match table.get(&key) {
669 Some(Item::Value(Value::Array(arr))) if array_contains(arr, mem_name) => {
670 let mut arr = arr.clone();
671 arr.retain(|v| match v {
672 Value::String(s) => s.value() != mem_name,
673 _ => true,
674 });
675 scrubbed.push(ScrubbedEntry::CrossLink {
676 from: key.clone(),
677 to: mem_name.to_string(),
678 });
679 if arr.is_empty() {
680 true
681 } else {
682 table.insert(&key, Item::Value(Value::Array(arr)));
683 false
684 }
685 }
686 _ => false,
687 };
688 if drop_key {
689 table.remove(&key);
690 }
691 }
692 }
693
694 if !scrubbed.is_empty() {
701 save(&path, &doc)?;
702 }
703 Ok(scrubbed)
704}
705
706#[derive(Debug, Clone, PartialEq, Eq)]
713pub enum ScrubbedEntry {
714 CrossLink {
717 from: String,
719 to: String,
721 },
722}
723
724pub fn rename_mem_in_cross_links(
734 workspace_root: &Path,
735 old: &str,
736 new: &str,
737) -> Result<bool, WorkspaceEditError> {
738 let (path, mut doc) = match load(workspace_root) {
739 Ok(pair) => pair,
740 Err(WorkspaceEditError::WorkspaceNotInitialised { .. }) => return Ok(false),
741 Err(e) => return Err(e),
742 };
743 let Some(table) = doc.get_mut("cross_mem_links").and_then(Item::as_table_mut) else {
744 return Ok(false);
745 };
746
747 let mut changed = false;
748 for (_key, item) in table.iter_mut() {
750 if let Item::Value(Value::Array(arr)) = item {
751 let mut next = Array::new();
752 let mut arr_changed = false;
753 for v in arr.iter() {
754 match v.as_str() {
755 Some(s) if s == old => {
756 next.push(new);
757 arr_changed = true;
758 }
759 _ => next.push(v.clone()),
760 }
761 }
762 if arr_changed {
763 *item = Item::Value(Value::Array(next));
764 changed = true;
765 }
766 }
767 }
768 if let Some(value) = table.remove(old) {
771 table.insert(new, value);
772 changed = true;
773 }
774
775 if changed {
776 save(&path, &doc)?;
777 }
778 Ok(changed)
779}
780
781fn ensure_table<'a>(doc: &'a mut DocumentMut, name: &str) -> &'a mut Table {
782 if !doc.contains_key(name) {
783 let mut t = Table::new();
784 t.set_implicit(false);
785 doc.insert(name, Item::Table(t));
786 }
787 doc.get_mut(name)
788 .unwrap()
789 .as_table_mut()
790 .expect("ensured table shape")
791}
792
793fn ensure_array_of_tables<'a>(
794 doc: &'a mut DocumentMut,
795 outer: &str,
796 inner: &str,
797) -> &'a mut ArrayOfTables {
798 if !doc.contains_key(outer) {
799 let mut t = Table::new();
800 t.set_implicit(true);
801 doc.insert(outer, Item::Table(t));
802 }
803 let outer_table = doc
804 .get_mut(outer)
805 .and_then(|i| i.as_table_mut())
806 .expect("mem_management must be a table");
807 if !outer_table.contains_key(inner) {
808 outer_table.insert(inner, Item::ArrayOfTables(ArrayOfTables::new()));
809 }
810 outer_table
811 .get_mut(inner)
812 .and_then(|i| i.as_array_of_tables_mut())
813 .expect("ensured array-of-tables shape")
814}
815
816fn find_pattern_index(section: &ArrayOfTables, pattern: &str) -> Option<usize> {
817 section
818 .iter()
819 .position(|t| t.get("pattern").and_then(|i| i.as_str()) == Some(pattern))
820}
821
822fn read_rule_schemas(section: &ArrayOfTables, idx: usize) -> Vec<String> {
825 section
826 .get(idx)
827 .and_then(|t| t.get("schemas"))
828 .and_then(|i| i.as_array())
829 .map(|arr| {
830 arr.iter()
831 .filter_map(|v| v.as_str().map(str::to_string))
832 .collect()
833 })
834 .unwrap_or_default()
835}
836
837fn schema_sets_equal(a: &[String], b: &[String]) -> bool {
841 let mut a: Vec<&str> = a.iter().map(String::as_str).collect();
842 let mut b: Vec<&str> = b.iter().map(String::as_str).collect();
843 a.sort_unstable();
844 a.dedup();
845 b.sort_unstable();
846 b.dedup();
847 a == b
848}
849
850fn cross_link_value_item(targets: &[CrossLinkTarget]) -> Item {
851 if targets
852 .iter()
853 .any(|t| matches!(t, CrossLinkTarget::Wildcard))
854 {
855 Item::Value(Value::from("*"))
856 } else {
857 let mut arr = Array::new();
858 for t in targets {
859 if let CrossLinkTarget::Named(name) = t {
860 arr.push(name.as_str());
861 }
862 }
863 Item::Value(Value::Array(arr))
864 }
865}
866
867fn array_contains(arr: &Array, needle: &str) -> bool {
868 arr.iter().any(|v| match v {
869 Value::String(s) => s.value() == needle,
870 _ => false,
871 })
872}
873
874#[cfg(test)]
875mod tests {
876 use super::*;
877 use tempfile::TempDir;
878
879 const DEFAULT_BODY: &str =
880 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n";
881
882 fn seed(body: &str) -> TempDir {
883 let tmp = TempDir::new().unwrap();
884 let memstead = tmp.path().join(".memstead");
885 fs::create_dir_all(&memstead).unwrap();
886 fs::write(memstead.join("workspace.toml"), body).unwrap();
887 tmp
888 }
889
890 fn read(root: &Path) -> String {
891 fs::read_to_string(workspace_toml_path(root)).unwrap()
892 }
893
894 fn known() -> Vec<String> {
900 ["engine", "plugin", "macos", "specs", "default"]
901 .iter()
902 .map(|s| s.to_string())
903 .collect()
904 }
905
906 #[test]
907 fn add_create_rule_appends_by_default() {
908 let tmp = seed(DEFAULT_BODY);
909 add_create_rule(
910 tmp.path(),
911 "exec-*",
912 &["default@1.0.0".to_string()],
913 None,
914 None,
915 )
916 .unwrap();
917 let body = read(tmp.path());
918 assert!(body.contains("[[mem_management.create]]"), "got:\n{body}");
919 assert!(body.contains("pattern = \"exec-*\""), "got:\n{body}");
920 assert!(
921 body.contains("schemas = [\"default@1.0.0\"]"),
922 "got:\n{body}"
923 );
924 }
925
926 #[test]
931 fn add_create_rule_duplicate_is_idempotent_with_warning() {
932 let tmp = seed(DEFAULT_BODY);
933 let first = add_create_rule(
934 tmp.path(),
935 "exec-*",
936 &["default@1.0.0".to_string()],
937 None,
938 None,
939 )
940 .unwrap();
941 assert!(first.is_empty(), "first add must return no warnings");
942 let body_after_first = read(tmp.path());
943 let warnings = add_create_rule(
944 tmp.path(),
945 "exec-*",
946 &["default@1.0.0".to_string()],
947 None,
948 None,
949 )
950 .unwrap();
951 assert_eq!(warnings.len(), 1);
952 assert_eq!(warnings[0].code(), "RULE_ALREADY_PRESENT");
953 let body_after_second = read(tmp.path());
954 assert_eq!(
955 body_after_first, body_after_second,
956 "duplicate add must not rewrite the file",
957 );
958 }
959
960 #[test]
965 fn add_create_rule_differing_schemas_refused_file_unchanged() {
966 let tmp = seed(DEFAULT_BODY);
967 add_create_rule(
968 tmp.path(),
969 "scratch",
970 &["software@0.1.0".to_string()],
971 None,
972 None,
973 )
974 .unwrap();
975 let body_before = read(tmp.path());
976
977 let err = add_create_rule(
978 tmp.path(),
979 "scratch",
980 &["nonexistent@9.9.9".to_string()],
981 None,
982 None,
983 )
984 .expect_err("differing schemas must be refused, not silently no-op'd");
985 assert_eq!(err.code(), "RULE_EXISTS_SCHEMAS_DIFFER");
986 match &err {
987 WorkspaceEditError::RuleExistsSchemasDiffer {
988 stored, requested, ..
989 } => {
990 assert_eq!(stored, &["software@0.1.0".to_string()]);
991 assert_eq!(requested, &["nonexistent@9.9.9".to_string()]);
992 }
993 other => panic!("expected RuleExistsSchemasDiffer, got {other:?}"),
994 }
995 assert_eq!(
996 body_before,
997 read(tmp.path()),
998 "refused schema change must not rewrite the file (stored schemas stay put)",
999 );
1000 }
1001
1002 #[test]
1005 fn add_create_rule_reordered_schemas_is_idempotent_noop() {
1006 let tmp = seed(DEFAULT_BODY);
1007 add_create_rule(
1008 tmp.path(),
1009 "scratch",
1010 &["a@1.0.0".to_string(), "b@1.0.0".to_string()],
1011 None,
1012 None,
1013 )
1014 .unwrap();
1015 let warnings = add_create_rule(
1016 tmp.path(),
1017 "scratch",
1018 &["b@1.0.0".to_string(), "a@1.0.0".to_string()],
1019 None,
1020 None,
1021 )
1022 .expect("reordered identical schema set must stay a no-op");
1023 assert_eq!(warnings.len(), 1);
1024 assert_eq!(warnings[0].code(), "RULE_ALREADY_PRESENT");
1025 }
1026
1027 #[test]
1030 fn revoke_then_readd_applies_the_new_schemas() {
1031 let tmp = seed(DEFAULT_BODY);
1032 add_create_rule(
1033 tmp.path(),
1034 "scratch",
1035 &["software@0.1.0".to_string()],
1036 None,
1037 None,
1038 )
1039 .unwrap();
1040 remove_create_rule(tmp.path(), "scratch").unwrap();
1041 let warnings = add_create_rule(
1042 tmp.path(),
1043 "scratch",
1044 &["planning@0.1.0".to_string()],
1045 None,
1046 None,
1047 )
1048 .expect("re-add after revoke must succeed");
1049 assert!(warnings.is_empty(), "fresh add returns no warnings");
1050 let body = read(tmp.path());
1051 assert!(
1052 body.contains("schemas = [\"planning@0.1.0\"]"),
1053 "new pins stored; got:\n{body}"
1054 );
1055 assert!(
1056 !body.contains("software@0.1.0"),
1057 "old pins gone; got:\n{body}"
1058 );
1059 }
1060
1061 #[test]
1062 fn add_create_rule_before_lifts_priority() {
1063 let tmp = seed(DEFAULT_BODY);
1064 add_create_rule(
1065 tmp.path(),
1066 "z-*",
1067 &["default@1.0.0".to_string()],
1068 None,
1069 None,
1070 )
1071 .unwrap();
1072 add_create_rule(
1073 tmp.path(),
1074 "a-*",
1075 &["default@1.0.0".to_string()],
1076 None,
1077 Some("z-*"),
1078 )
1079 .unwrap();
1080 let body = read(tmp.path());
1081 let a_idx = body.find("pattern = \"a-*\"").expect("a-* must exist");
1082 let z_idx = body.find("pattern = \"z-*\"").expect("z-* must exist");
1083 assert!(
1084 a_idx < z_idx,
1085 "--before must place new rule above target; got:\n{body}"
1086 );
1087 }
1088
1089 #[test]
1090 fn add_create_rule_before_unknown_pattern_errors() {
1091 let tmp = seed(DEFAULT_BODY);
1092 let err = add_create_rule(
1093 tmp.path(),
1094 "exec-*",
1095 &["default@1.0.0".to_string()],
1096 None,
1097 Some("does-not-exist"),
1098 )
1099 .unwrap_err();
1100 assert_eq!(err.code(), "BEFORE_PATTERN_NOT_FOUND");
1101 }
1102
1103 #[test]
1104 fn add_create_rule_with_named_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::Named("engine".to_string())]),
1111 None,
1112 )
1113 .unwrap();
1114 let body = read(tmp.path());
1115 assert!(
1116 body.contains("default_cross_links = [\"engine\"]"),
1117 "got:\n{body}"
1118 );
1119 }
1120
1121 #[test]
1122 fn add_create_rule_with_wildcard_cross_links() {
1123 let tmp = seed(DEFAULT_BODY);
1124 add_create_rule(
1125 tmp.path(),
1126 "exec-*",
1127 &["default@1.0.0".to_string()],
1128 Some(&[CrossLinkTarget::Wildcard]),
1129 None,
1130 )
1131 .unwrap();
1132 let body = read(tmp.path());
1133 assert!(body.contains("default_cross_links = \"*\""), "got:\n{body}");
1134 }
1135
1136 #[test]
1137 fn remove_create_rule_succeeds() {
1138 let tmp = seed(DEFAULT_BODY);
1139 add_create_rule(
1140 tmp.path(),
1141 "exec-*",
1142 &["default@1.0.0".to_string()],
1143 None,
1144 None,
1145 )
1146 .unwrap();
1147 remove_create_rule(tmp.path(), "exec-*").unwrap();
1148 let body = read(tmp.path());
1149 assert!(!body.contains("pattern = \"exec-*\""), "got:\n{body}");
1150 }
1151
1152 #[test]
1155 fn remove_create_rule_unknown_pattern_is_idempotent_with_warning() {
1156 let tmp = seed(DEFAULT_BODY);
1157 let body_before = read(tmp.path());
1158 let warnings = remove_create_rule(tmp.path(), "ghost").unwrap();
1159 assert_eq!(warnings.len(), 1);
1160 assert_eq!(warnings[0].code(), "RULE_NOT_FOUND_NOOP");
1161 let body_after = read(tmp.path());
1162 assert_eq!(
1163 body_before, body_after,
1164 "no-op remove must not touch the file"
1165 );
1166 }
1167
1168 #[test]
1169 fn add_and_remove_delete_rule() {
1170 let tmp = seed(DEFAULT_BODY);
1171 add_delete_rule(tmp.path(), "exec-*").unwrap();
1172 let body = read(tmp.path());
1173 assert!(body.contains("[[mem_management.delete]]"), "got:\n{body}");
1174 assert!(body.contains("pattern = \"exec-*\""), "got:\n{body}");
1175 remove_delete_rule(tmp.path(), "exec-*").unwrap();
1176 let body = read(tmp.path());
1177 assert!(!body.contains("pattern = \"exec-*\""), "got:\n{body}");
1178 }
1179
1180 #[test]
1181 fn grant_cross_link_creates_named_list() {
1182 let tmp = seed(DEFAULT_BODY);
1183 grant_cross_link(
1184 tmp.path(),
1185 "plugin",
1186 &CrossLinkTarget::Named("engine".to_string()),
1187 &known(),
1188 )
1189 .unwrap();
1190 let body = read(tmp.path());
1191 assert!(body.contains("plugin = [\"engine\"]"), "got:\n{body}");
1192 }
1193
1194 #[test]
1195 fn grant_cross_link_appends_named_target() {
1196 let tmp = seed(DEFAULT_BODY);
1197 grant_cross_link(
1198 tmp.path(),
1199 "macos",
1200 &CrossLinkTarget::Named("engine".to_string()),
1201 &known(),
1202 )
1203 .unwrap();
1204 grant_cross_link(
1205 tmp.path(),
1206 "macos",
1207 &CrossLinkTarget::Named("plugin".to_string()),
1208 &known(),
1209 )
1210 .unwrap();
1211 let body = read(tmp.path());
1212 assert!(
1213 body.contains("macos = [\"engine\", \"plugin\"]"),
1214 "got:\n{body}"
1215 );
1216 }
1217
1218 #[test]
1219 fn grant_cross_link_wildcard_sets_string() {
1220 let tmp = seed(DEFAULT_BODY);
1221 grant_cross_link(tmp.path(), "specs", &CrossLinkTarget::Wildcard, &known()).unwrap();
1222 let body = read(tmp.path());
1223 assert!(body.contains("specs = \"*\""), "got:\n{body}");
1224 }
1225
1226 #[test]
1230 fn grant_cross_link_duplicate_named_is_idempotent_with_warning() {
1231 let tmp = seed(DEFAULT_BODY);
1232 grant_cross_link(
1233 tmp.path(),
1234 "plugin",
1235 &CrossLinkTarget::Named("engine".to_string()),
1236 &known(),
1237 )
1238 .unwrap();
1239 let body_before = read(tmp.path());
1240 let warnings = grant_cross_link(
1241 tmp.path(),
1242 "plugin",
1243 &CrossLinkTarget::Named("engine".to_string()),
1244 &known(),
1245 )
1246 .unwrap();
1247 assert_eq!(warnings.len(), 1);
1248 assert_eq!(warnings[0].code(), "GRANT_ALREADY_PRESENT");
1249 let body_after = read(tmp.path());
1250 assert_eq!(
1251 body_before, body_after,
1252 "duplicate grant must not rewrite the file"
1253 );
1254 }
1255
1256 #[test]
1257 fn grant_cross_link_named_over_wildcard_conflicts() {
1258 let tmp = seed(DEFAULT_BODY);
1259 grant_cross_link(tmp.path(), "plugin", &CrossLinkTarget::Wildcard, &known()).unwrap();
1260 let err = grant_cross_link(
1261 tmp.path(),
1262 "plugin",
1263 &CrossLinkTarget::Named("engine".to_string()),
1264 &known(),
1265 )
1266 .unwrap_err();
1267 assert_eq!(err.code(), "CROSS_LINK_CONFLICT");
1268 }
1269
1270 #[test]
1274 fn grant_cross_link_warns_on_unregistered_named_target() {
1275 let tmp = seed(DEFAULT_BODY);
1276 let registered = vec!["plugin".to_string()];
1277 let warnings = grant_cross_link(
1278 tmp.path(),
1279 "plugin",
1280 &CrossLinkTarget::Named("future-mem".to_string()),
1281 ®istered,
1282 )
1283 .unwrap();
1284 assert_eq!(warnings.len(), 1);
1285 assert_eq!(warnings[0].code(), "CROSS_LINK_TARGET_UNREGISTERED");
1286 assert!(
1288 read(tmp.path()).contains("plugin = [\"future-mem\"]"),
1289 "grant must persist for the forward-reference workflow: {}",
1290 read(tmp.path())
1291 );
1292 }
1293
1294 #[test]
1297 fn grant_cross_link_warns_on_self_grant() {
1298 let tmp = seed(DEFAULT_BODY);
1299 let warnings = grant_cross_link(
1300 tmp.path(),
1301 "plugin",
1302 &CrossLinkTarget::Named("plugin".to_string()),
1303 &known(),
1304 )
1305 .unwrap();
1306 assert_eq!(warnings.len(), 1);
1307 assert_eq!(warnings[0].code(), "CROSS_LINK_SELF_GRANT_NOOP");
1308 assert!(read(tmp.path()).contains("plugin = [\"plugin\"]"));
1309 }
1310
1311 #[test]
1315 fn grant_cross_link_wildcard_not_target_validated() {
1316 let tmp = seed(DEFAULT_BODY);
1317 let warnings =
1318 grant_cross_link(tmp.path(), "plugin", &CrossLinkTarget::Wildcard, &[]).unwrap();
1319 assert!(
1320 warnings.is_empty(),
1321 "wildcard target must not be validated against the router: {warnings:?}"
1322 );
1323 }
1324
1325 #[test]
1328 fn grant_cross_link_registered_target_no_warning() {
1329 let tmp = seed(DEFAULT_BODY);
1330 let registered = vec!["engine".to_string()];
1331 let warnings = grant_cross_link(
1332 tmp.path(),
1333 "plugin",
1334 &CrossLinkTarget::Named("engine".to_string()),
1335 ®istered,
1336 )
1337 .unwrap();
1338 assert!(
1339 warnings.is_empty(),
1340 "registered target must warn nothing: {warnings:?}"
1341 );
1342 }
1343
1344 #[test]
1345 fn revoke_cross_link_removes_named_target() {
1346 let tmp = seed(DEFAULT_BODY);
1347 grant_cross_link(
1348 tmp.path(),
1349 "macos",
1350 &CrossLinkTarget::Named("engine".to_string()),
1351 &known(),
1352 )
1353 .unwrap();
1354 grant_cross_link(
1355 tmp.path(),
1356 "macos",
1357 &CrossLinkTarget::Named("plugin".to_string()),
1358 &known(),
1359 )
1360 .unwrap();
1361 revoke_cross_link(
1362 tmp.path(),
1363 "macos",
1364 &CrossLinkTarget::Named("engine".to_string()),
1365 )
1366 .unwrap();
1367 let body = read(tmp.path());
1368 assert!(body.contains("macos = ["), "got:\n{body}");
1372 assert!(body.contains("\"plugin\""), "got:\n{body}");
1373 assert!(
1374 !body.contains("\"engine\""),
1375 "engine target must be removed, got:\n{body}"
1376 );
1377 }
1378
1379 #[test]
1380 fn revoke_cross_link_empties_key() {
1381 let tmp = seed(DEFAULT_BODY);
1382 grant_cross_link(
1383 tmp.path(),
1384 "macos",
1385 &CrossLinkTarget::Named("engine".to_string()),
1386 &known(),
1387 )
1388 .unwrap();
1389 revoke_cross_link(
1390 tmp.path(),
1391 "macos",
1392 &CrossLinkTarget::Named("engine".to_string()),
1393 )
1394 .unwrap();
1395 let body = read(tmp.path());
1396 assert!(
1397 !body.contains("macos"),
1398 "empty allowlist must drop the key, got:\n{body}"
1399 );
1400 }
1401
1402 #[test]
1403 fn revoke_cross_link_wildcard() {
1404 let tmp = seed(DEFAULT_BODY);
1405 grant_cross_link(tmp.path(), "specs", &CrossLinkTarget::Wildcard, &known()).unwrap();
1406 revoke_cross_link(tmp.path(), "specs", &CrossLinkTarget::Wildcard).unwrap();
1407 let body = read(tmp.path());
1408 assert!(!body.contains("specs"), "got:\n{body}");
1409 }
1410
1411 #[test]
1415 fn revoke_cross_link_not_granted_is_idempotent_with_warning() {
1416 let tmp = seed(DEFAULT_BODY);
1417 let body_before = read(tmp.path());
1418 let warnings = revoke_cross_link(
1419 tmp.path(),
1420 "macos",
1421 &CrossLinkTarget::Named("engine".to_string()),
1422 )
1423 .unwrap();
1424 assert_eq!(warnings.len(), 1);
1425 assert_eq!(warnings[0].code(), "GRANT_NOT_FOUND");
1426 let body_after = read(tmp.path());
1427 assert_eq!(
1428 body_before, body_after,
1429 "no-op revoke must not touch the file"
1430 );
1431 }
1432
1433 #[test]
1434 fn set_mutation_require_notes_creates_section() {
1435 let tmp = seed(DEFAULT_BODY);
1436 set_mutation_require_notes(tmp.path(), true).unwrap();
1437 let body = read(tmp.path());
1438 assert!(body.contains("[mutations]"), "got:\n{body}");
1439 assert!(body.contains("require_notes = true"), "got:\n{body}");
1440 }
1441
1442 #[test]
1443 fn set_mutation_require_notes_toggles() {
1444 let tmp = seed(DEFAULT_BODY);
1445 set_mutation_require_notes(tmp.path(), true).unwrap();
1446 set_mutation_require_notes(tmp.path(), false).unwrap();
1447 let body = read(tmp.path());
1448 assert!(body.contains("require_notes = false"), "got:\n{body}");
1449 }
1450
1451 #[test]
1452 fn missing_workspace_toml_errors_with_typed_code() {
1453 let tmp = TempDir::new().unwrap();
1454 let err = add_create_rule(tmp.path(), "exec-*", &[], None, None).unwrap_err();
1455 assert_eq!(err.code(), "WORKSPACE_NOT_INITIALISED");
1456 }
1457
1458 #[test]
1459 fn comments_outside_edited_sections_survive() {
1460 let body = "# operator comment 1\n\
1465format = \"memstead-git-branch-2\"\n\
1466\n\
1467# operator comment 2\n\
1468[persistence_adapter]\n\
1469name = \"file-two-layer\"\n\
1470\n\
1471# section explanation that must survive\n\
1472[cross_mem_links]\n\
1473plugin = [\"engine\"] # inline pin\n";
1474 let tmp = seed(body);
1475
1476 add_create_rule(
1477 tmp.path(),
1478 "exec-*",
1479 &["default@1.0.0".to_string()],
1480 None,
1481 None,
1482 )
1483 .unwrap();
1484
1485 let new_body = read(tmp.path());
1486 assert!(new_body.contains("# operator comment 1"));
1487 assert!(new_body.contains("# operator comment 2"));
1488 assert!(new_body.contains("# section explanation that must survive"));
1489 assert!(new_body.contains("# inline pin"));
1490 assert!(new_body.contains("[[mem_management.create]]"));
1491 }
1492
1493 #[test]
1501 fn scrub_policy_for_deleted_mem_drops_cross_links_but_keeps_allowlist_rules() {
1502 let body = "format = \"memstead-git-branch-2\"\n\n\
1503 [cross_mem_links]\n\
1504 other = [\"test\"]\n\
1505 test = [\"other\", \"keep\"]\n\
1506 \n\
1507 [[mem_management.create]]\n\
1508 pattern = \"other\"\n\
1509 schemas = [\"default@1.0.0\"]\n\
1510 \n\
1511 [[mem_management.create]]\n\
1512 pattern = \"*\"\n\
1513 schemas = [\"default@1.0.0\"]\n\
1514 \n\
1515 [[mem_management.delete]]\n\
1516 pattern = \"other\"\n\
1517 \n\
1518 [[mem_management.delete]]\n\
1519 pattern = \"team/*\"\n";
1520 let tmp = seed(body);
1521 let scrubbed = scrub_policy_for_deleted_mem(tmp.path(), "other").unwrap();
1522 assert!(
1527 scrubbed
1528 .iter()
1529 .all(|e| matches!(e, ScrubbedEntry::CrossLink { .. })),
1530 "scrub must report only cross-link grants, got: {scrubbed:?}"
1531 );
1532 assert!(
1533 scrubbed.contains(&ScrubbedEntry::CrossLink {
1534 from: "other".to_string(),
1535 to: "test".to_string(),
1536 }),
1537 "deleted mem's own grant must be reported scrubbed, got: {scrubbed:?}"
1538 );
1539 assert!(
1540 scrubbed.contains(&ScrubbedEntry::CrossLink {
1541 from: "test".to_string(),
1542 to: "other".to_string(),
1543 }),
1544 "peer grant naming the deleted mem must be reported scrubbed, got: {scrubbed:?}"
1545 );
1546 let after = read(tmp.path());
1547 assert!(
1549 !after.contains("\nother = ["),
1550 "`other` key must be scrubbed from cross_mem_links — got:\n{after}"
1551 );
1552 assert!(after.contains("\"keep\""), "non-target values must survive");
1554 assert_eq!(
1558 after.matches("pattern = \"other\"").count(),
1559 2,
1560 "exact-name mem_management.{{create,delete}} rules for `other` must survive — got:\n{after}"
1561 );
1562 assert!(
1563 after.contains("pattern = \"*\""),
1564 "wildcard `*` rule must survive"
1565 );
1566 assert!(
1567 after.contains("pattern = \"team/*\""),
1568 "glob `team/*` rule must survive"
1569 );
1570 }
1571
1572 #[test]
1576 fn scrub_policy_for_deleted_mem_missing_file_is_noop() {
1577 let tmp = TempDir::new().unwrap();
1578 let outcome = scrub_policy_for_deleted_mem(tmp.path(), "other");
1580 assert!(outcome.is_ok(), "missing workspace.toml must not error");
1581 }
1582
1583 #[test]
1586 fn scrub_policy_for_deleted_mem_no_match_leaves_file_unchanged() {
1587 let body = "format = \"memstead-git-branch-2\"\n\n\
1588 [cross_mem_links]\n\
1589 test = [\"keep\"]\n\
1590 \n\
1591 [[mem_management.create]]\n\
1592 pattern = \"*\"\n\
1593 schemas = [\"default@1.0.0\"]\n";
1594 let tmp = seed(body);
1595 let before = read(tmp.path());
1596 scrub_policy_for_deleted_mem(tmp.path(), "ghost").unwrap();
1597 let after = read(tmp.path());
1598 assert_eq!(before, after, "unrelated delete must not rewrite the file");
1599 }
1600
1601 #[test]
1605 fn scrub_policy_for_deleted_mem_drops_emptied_allowlist_key() {
1606 let body = "format = \"memstead-git-branch-2\"\n\n\
1607 [cross_mem_links]\n\
1608 test = [\"other\"]\n";
1609 let tmp = seed(body);
1610 scrub_policy_for_deleted_mem(tmp.path(), "other").unwrap();
1611 let after = read(tmp.path());
1612 assert!(
1613 !after.contains("\ntest = ["),
1614 "key whose allowlist drained to empty must be dropped — got:\n{after}"
1615 );
1616 }
1617}