Skip to main content

memstead_engine/
workspace_config_edit.rs

1//! `toml_edit`-backed writer for `.memstead/workspace.toml`.
2//!
3//! Backs the `memstead workspace allow-create / revoke-create / allow-delete /
4//! revoke-delete / grant-cross-link / revoke-cross-link / set-mutations`
5//! subcommand family. Every operation is a load → mutate → write triple;
6//! `toml_edit` preserves operator-authored comments and formatting on
7//! sections the CLI doesn't touch (cross-mem forward-reference
8//! rationale, ingest-namespace pairings, operator-mode bypass semantics,
9//! pattern-grammar examples).
10//!
11//! Errors carry symbolic codes (`WORKSPACE_NOT_INITIALISED`,
12//! `RULE_ALREADY_EXISTS`, `RULE_NOT_FOUND`, `BEFORE_PATTERN_NOT_FOUND`,
13//! `CROSS_LINK_ALREADY_GRANTED`, `CROSS_LINK_NOT_GRANTED`,
14//! `CROSS_LINK_CONFLICT`, `INVALID_TOML`) so the CLI's typed exit envelope
15//! lifts them as `code` in the `--json` payload. The CLI layer maps the
16//! enum variants onto `CliError` / `ExitKind`.
17
18use std::fs;
19use std::path::{Path, PathBuf};
20
21use toml_edit::{Array, ArrayOfTables, DocumentMut, Item, Table, Value};
22
23/// Errors returned by the writer.
24///
25/// The idempotency cases (`RuleAlreadyExists`, `RuleNotFound`,
26/// `CrossLinkAlreadyGranted`, `CrossLinkNotGranted`) live on
27/// [`WorkspaceEditWarning`] rather than here — re-grant / re-revoke /
28/// re-add / re-remove return success-with-warning rather than refusing,
29/// letting CLI scripts and MCP agents retry safely without
30/// branching on prior state. `CrossLinkConflict` stays an error
31/// because it's a real semantic conflict (wildcard vs. specific
32/// list), not an idempotency case.
33#[derive(Debug)]
34pub enum WorkspaceEditError {
35    /// `.memstead/workspace.toml` missing or unreadable. The workspace
36    /// must be initialised before the CLI can edit its config.
37    WorkspaceNotInitialised { path: PathBuf },
38    /// Existing file failed to parse as TOML.
39    InvalidToml { path: PathBuf, message: String },
40    /// `add_create_rule` with `--before <p>` where `<p>` isn't an
41    /// existing pattern in the section.
42    BeforePatternNotFound {
43        section: &'static str,
44        pattern: String,
45    },
46    /// `grant_cross_link` with `*` against an existing specific list,
47    /// or with a specific target against an existing `*`. Operators
48    /// pick a single shape per `from`-mem.
49    CrossLinkConflict { from: String, message: String },
50    /// `add_create_rule` called for a pattern that already exists but
51    /// with a **different** schema set. Refused rather than silently
52    /// no-op'd: changing a pattern's schema pins is a security-relevant
53    /// policy change, so it must be explicit (revoke the rule, then
54    /// re-add with the new schemas) — never a silent success echoing a
55    /// change that did not land. The genuine no-op (identical schemas)
56    /// stays [`WorkspaceEditWarning::RuleAlreadyPresent`].
57    RuleExistsSchemasDiffer {
58        section: &'static str,
59        pattern: String,
60        stored: Vec<String>,
61        requested: Vec<String>,
62    },
63    /// IO failure writing the file back.
64    Io {
65        path: PathBuf,
66        source: std::io::Error,
67    },
68}
69
70/// Idempotency notices emitted by the writer when a call lands on
71/// a state the caller intended (re-grant of an existing grant,
72/// re-revoke of an absent grant, etc.). Surfacing these as warnings
73/// rather than errors lets agents and scripts retry without branching
74/// on prior state.
75#[derive(Debug, Clone)]
76pub enum WorkspaceEditWarning {
77    /// `add_create_rule` / `add_delete_rule` called with a pattern
78    /// that already has a matching entry. File unchanged.
79    RuleAlreadyPresent {
80        section: &'static str,
81        pattern: String,
82    },
83    /// `remove_create_rule` / `remove_delete_rule` called with a
84    /// pattern that has no matching entry. File unchanged.
85    RuleNotFoundNoop {
86        section: &'static str,
87        pattern: String,
88    },
89    /// `grant_cross_link` called with a `(from, to)` pair already
90    /// permitted (target already in the allowlist, or `*` already
91    /// set). File unchanged.
92    GrantAlreadyPresent { from: String, to: String },
93    /// `revoke_cross_link` called with a `(from, to)` pair that
94    /// isn't currently permitted. File unchanged.
95    GrantNotFound { from: String, to: String },
96    /// `grant_cross_link` named a `to` target that isn't a registered
97    /// mem (and isn't the `*` wildcard). The grant still persists —
98    /// the forward-reference workflow (grant before the target mem
99    /// exists) is legitimate — but a likely typo is surfaced.
100    CrossLinkTargetUnregistered { to: String },
101    /// `grant_cross_link` named `to == from` — a self-grant. Intra-mem
102    /// links never traverse the cross-link gate, so the grant is a
103    /// no-op. It still persists; the meaninglessness is surfaced.
104    CrossLinkSelfGrantNoop { mem: String },
105}
106
107impl WorkspaceEditWarning {
108    /// Stable UPPER_SNAKE_CASE code surfaced as the CLI's stderr
109    /// notice and as the MCP wrapper's warning envelope.
110    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    /// Stable symbolic code used by the CLI exit envelope.
194    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
206/// Path of the workspace config file relative to the workspace root.
207pub 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/// Either-or shape mirroring `[cross_mem_links]` semantics on disk:
242/// `<from> = "*"` (wildcard) or `<from> = ["a", "b"]` (allowlist). The
243/// CLI exposes both via `--target *` and `--target <name>` on
244/// `grant-cross-link`.
245#[derive(Debug, Clone, PartialEq, Eq)]
246pub enum CrossLinkTarget {
247    /// `*` — every current writable target is permitted.
248    Wildcard,
249    /// One named target. Multiple grants accumulate into the
250    /// underlying `<from> = ["a", "b", ...]` list on disk.
251    Named(String),
252}
253
254impl CrossLinkTarget {
255    /// Parse a CLI-supplied target token. `*` maps to the wildcard
256    /// shape; anything else maps to the named shape verbatim. The
257    /// caller validates name shape elsewhere (the engine's existing
258    /// mem-name validation runs on load).
259    pub fn parse(raw: &str) -> Self {
260        if raw == "*" {
261            Self::Wildcard
262        } else {
263            Self::Named(raw.to_string())
264        }
265    }
266}
267
268/// `memstead workspace allow-create <pattern> --schema <pin>[,…] [--cross-link …]
269/// [--before <pattern>]` — append a `[[mem_management.create]]` rule.
270/// Default ordering is append (lowest priority); `before` flags lift it
271/// above the named pattern.
272pub 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        // Pattern already present. Compare the stored schema set against
284        // the requested one: identical → clean idempotent no-op; a
285        // *difference* must not silently no-op (and must not echo a
286        // change that did not land), so refuse with an actionable typed
287        // error pointing at the revoke-then-readd recovery.
288        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        // `ArrayOfTables` has no `insert(idx, table)`; emulate it by
322        // detaching every entry from `idx` onward, pushing the new
323        // table, then pushing the detached entries back.
324        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
342/// `memstead workspace revoke-create <pattern>` — remove a
343/// `[[mem_management.create]]` rule by pattern.
344pub 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
364/// `memstead workspace allow-delete <pattern>` — append a
365/// `[[mem_management.delete]]` rule.
366pub 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
385/// `memstead workspace revoke-delete <pattern>` — remove a
386/// `[[mem_management.delete]]` rule by pattern.
387pub 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
407/// `memstead workspace grant-cross-link <from> <to>` — add `to` to the
408/// allowlist for `from` in `[cross_mem_links]`. `to == "*"` sets the
409/// wildcard shape; named targets accumulate into a list.
410pub fn grant_cross_link(
411    workspace_root: &Path,
412    from: &str,
413    to: &CrossLinkTarget,
414    known_mems: &[String],
415) -> Result<Vec<WorkspaceEditWarning>, WorkspaceEditError> {
416    // Diligence (matching the sibling `revoke_cross_link`'s warn-on-
417    // anomaly behaviour): warn — never block — on a self-grant or an
418    // unregistered named target. The grant still persists so the
419    // forward-reference workflow (grant before the target mem exists)
420    // stays open. The `*` wildcard is a legitimate non-mem token and
421    // is not validated against the registered set.
422    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
489/// `memstead workspace revoke-cross-link <from> <to>` — remove `to` from
490/// the allowlist for `from`. When the underlying list becomes empty,
491/// the `<from>` key is dropped entirely; `*` is matched as a literal.
492pub 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
541/// `memstead workspace set-mutations --require-notes <bool>` — set the
542/// `[mutations] require_notes` field. Creates the section on demand.
543pub 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
553/// Scrub `.memstead/workspace.toml` of the now-dangling `[cross_mem_links]`
554/// grants naming `mem_name` so the workspace no longer references a
555/// mem the engine just destructively deleted. The
556/// `[[mem_management.create]]` / `[[mem_management.delete]]`
557/// allowlist rules are deliberately left intact.
558///
559/// Two passes, both on the in-memory `DocumentMut` before one final
560/// save:
561///   1. `[cross_mem_links]` — drop the key `mem_name` if present
562///      (the deleted mem as `from`).
563///   2. `[cross_mem_links]` — remove `mem_name` from every other
564///      key's allowlist `Array`. When an allowlist becomes empty,
565///      drop the key entirely (mirrors `revoke_cross_link`).
566///
567/// What is NOT scrubbed, and why: the `[[mem_management.create]]` /
568/// `[[mem_management.delete]]` rules are forward-looking *permissions
569/// for a name*, not references to the deleted *instance*. A cross-link
570/// grant `test → other` names the gone instance and genuinely dangles,
571/// so it is scrubbed; a `[[mem_management.create]]` rule for `other`
572/// means "an agent may bring a mem named `other` into existence" and
573/// stays true after the delete. Scrubbing it would silently revoke a
574/// guarded-config permission as a side effect of an instance op, and
575/// force a fresh `allow-create` before the next `mem init other` —
576/// exactly the re-attach friction `unregister` avoids by preserving
577/// policy. So `delete` is idempotent w.r.t. a later re-create.
578///
579/// Silent no-op cases (returns `Ok(())` without touching the file):
580/// - `.memstead/workspace.toml` missing — pre-init workspaces (tests, ad-hoc
581///   consumers) have no policy state to scrub. The caller (delete
582///   orchestrator) already wrote nothing, so there's nothing to undo.
583/// - `.memstead/workspace.toml` unparseable — surfaces as `InvalidToml`.
584///
585/// IO failures surface as `WorkspaceEditError::Io`; the caller's outer
586/// engine-error type wraps that.
587pub 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            // Pre-init or operator-edited away — nothing to scrub.
596            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        // The deleted mem's own key — every grant `mem_name
618        // → <anything>` is dropped wholesale.
619        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        // Peer entries referencing the deleted mem as a grant
646        // target — drop the entry from the array; collapse empty
647        // arrays.
648        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    // The `[[mem_management.create]]` / `[[mem_management.delete]]`
677    // allowlist rules are deliberately NOT scrubbed — see this
678    // function's doc-comment for why deleting an instance must not
679    // revoke the forward-looking permission to (re-)create a mem of
680    // the same name.
681
682    if !scrubbed.is_empty() {
683        save(&path, &doc)?;
684    }
685    Ok(scrubbed)
686}
687
688/// One scrubbed entry returned from [`scrub_policy_for_deleted_mem`].
689/// The `memstead_mem_delete` response surfaces these so an agent sees
690/// every policy side effect in one round-trip. Only dangling
691/// `[cross_mem_links]` grants are scrubbed; the
692/// `[[mem_management.*]]` allowlist rules are preserved, so this enum
693/// carries the one scrubbed shape.
694#[derive(Debug, Clone, PartialEq, Eq)]
695pub enum ScrubbedEntry {
696    /// `[cross_mem_links]` grant naming the deleted mem on
697    /// either side.
698    CrossLink {
699        /// Source mem — the grant's table key.
700        from: String,
701        /// Target mem — array element or wildcard `"*"`.
702        to: String,
703    },
704}
705
706// --- internal helpers ---------------------------------------------------
707
708fn 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
749/// Read the `schemas` string list off the rule at `idx` in `section`.
750/// Missing or malformed `schemas` reads as an empty list.
751fn 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
764/// Set-equality over two schema-pin lists (order- and duplicate-
765/// insensitive). A rule pinned to `[a, b]` re-added as `[b, a]` is the
766/// same allowlist, so it stays a clean no-op rather than a refusal.
767fn 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    /// Registered-mem set for `grant_cross_link` target validation.
822    /// Covers every named `to` target the grant tests use, so the
823    /// behaviour-focused tests don't trip the `CROSS_LINK_TARGET_
824    /// UNREGISTERED` warning. Target-validation behaviour is exercised
825    /// by its own dedicated tests.
826    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    /// Adding a duplicate rule is idempotent — the call returns
854    /// `Ok(vec![RuleAlreadyPresent])` rather than an error. Scripts and
855    /// agents can retry safely without branching on prior state. The
856    /// original file body is preserved (no spurious save).
857    #[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    /// MCP F3 / CLI: re-adding an existing pattern with a *different*
888    /// schema set must NOT silently no-op (the deceptive "file unchanged"
889    /// echoing a change that did not land). It refuses with a typed error
890    /// naming the stored vs requested schemas, and the file is unchanged.
891    #[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    /// Set-equality: the same schemas in a different order is the same
930    /// allowlist — a clean idempotent no-op, not a refusal.
931    #[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    /// The documented recovery works: revoke the rule, then re-add with
955    /// the new schemas — the change lands and the stored pins update.
956    #[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    /// Removing a non-existent rule is idempotent.
1080    /// Returns `Ok(vec![RuleNotFoundNoop])` rather than refusing.
1081    #[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    /// Re-granting an existing grant is idempotent.
1154    /// Returns `Ok(vec![GrantAlreadyPresent])` and leaves the file
1155    /// unchanged.
1156    #[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    /// A named `to` target that isn't a registered mem warns
1198    /// `CROSS_LINK_TARGET_UNREGISTERED` — but the grant still persists
1199    /// (the forward-reference workflow stays open).
1200    #[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            &registered,
1209        )
1210        .unwrap();
1211        assert_eq!(warnings.len(), 1);
1212        assert_eq!(warnings[0].code(), "CROSS_LINK_TARGET_UNREGISTERED");
1213        // Grant persisted despite the warning.
1214        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    /// A self-grant (`from == to`) warns `CROSS_LINK_SELF_GRANT_NOOP`
1222    /// and still persists.
1223    #[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    /// The `*` wildcard is a legitimate non-mem token — it is NOT
1239    /// validated against the registered set, so granting `*` against an
1240    /// empty registry warns nothing.
1241    #[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    /// A registered named target grants with no warning (the normal
1253    /// path is unchanged).
1254    #[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            &registered,
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        // toml_edit preserves the original array's inner whitespace
1296        // (e.g. `[ "plugin"]` if the original was `["engine", "plugin"]`).
1297        // Assert on the key + remaining target + the dropped target.
1298        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    /// Revoking an absent grant is idempotent.
1339    /// Returns `Ok(vec![GrantNotFound])` and leaves the file
1340    /// unchanged.
1341    #[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        // Operator-authored comments encode non-trivial knowledge
1388        // (forward-reference rationale, pattern-grammar examples,
1389        // operator-mode bypass semantics). toml_edit must preserve
1390        // every byte the CLI doesn't intentionally touch.
1391        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    /// A destructive delete scrubs only the dangling `[cross_mem_links]`
1421    /// grants naming the deleted mem — its own key and every peer's
1422    /// allowlist value (with empty-list key drop). The
1423    /// `[[mem_management.create]]` / `[[mem_management.delete]]`
1424    /// allowlist rules survive unconditionally — even the exact-name
1425    /// ones — because they are forward-looking permissions for the name,
1426    /// not references to the gone instance.
1427    #[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        // Only cross-link grants are reported as scrubbed — never a
1450        // `mem_management.*` rule. Both the deleted mem's own key
1451        // (`other → test`) and the peer value (`test → other`) are
1452        // reported.
1453        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        // `other` key removed entirely.
1475        assert!(
1476            !after.contains("\nother = ["),
1477            "`other` key must be scrubbed from cross_mem_links — got:\n{after}"
1478        );
1479        // `other` value removed from `test`'s allowlist; `keep` survives.
1480        assert!(after.contains("\"keep\""), "non-target values must survive");
1481        // The exact-name `pattern = "other"` rules in BOTH
1482        // `[[mem_management.create]]` and `.delete]]` survive — the
1483        // forward-looking permission for the name `other` is preserved.
1484        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    /// Acceptance complement: a refused (or pre-init) workspace.toml
1500    /// shouldn't crash the scrub. The function is best-effort — a
1501    /// missing file is a no-op and surfaces no error.
1502    #[test]
1503    fn scrub_policy_for_deleted_mem_missing_file_is_noop() {
1504        let tmp = TempDir::new().unwrap();
1505        // No `.memstead/workspace.toml` seeded.
1506        let outcome = scrub_policy_for_deleted_mem(tmp.path(), "other");
1507        assert!(outcome.is_ok(), "missing workspace.toml must not error");
1508    }
1509
1510    /// Acceptance complement: a successful delete that doesn't touch
1511    /// any policy entry leaves the file byte-identical (no save).
1512    #[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    /// Acceptance complement: when the only entry in an allowlist
1529    /// names the deleted mem, the underlying key is dropped — same
1530    /// shape as `revoke_cross_link`.
1531    #[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}