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