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    /// `revoke_cross_link` removed a grant that existing edges were
106    /// relying on. The edges are NOT removed — a policy edit must not
107    /// delete user data — but they are now in a state the default-deny
108    /// write gate would refuse to create, so they are named at the moment
109    /// the operator can act on them cheapest rather than left for a later
110    /// gate run (04/07, criterion 5).
111    ///
112    /// Rides the warning channel deliberately: every revoke surface
113    /// already renders these, so the CLI operator, the HTTP client and the
114    /// web app get the same account without three separate renderings.
115    CrossLinkRevokeOrphanedEdges { edges: Vec<String> },
116}
117
118impl WorkspaceEditWarning {
119    /// Stable UPPER_SNAKE_CASE code surfaced as the CLI's stderr
120    /// notice and as the MCP wrapper's warning envelope.
121    pub fn code(&self) -> &'static str {
122        match self {
123            Self::RuleAlreadyPresent { .. } => "RULE_ALREADY_PRESENT",
124            Self::RuleNotFoundNoop { .. } => "RULE_NOT_FOUND_NOOP",
125            Self::GrantAlreadyPresent { .. } => "GRANT_ALREADY_PRESENT",
126            Self::GrantNotFound { .. } => "GRANT_NOT_FOUND",
127            Self::CrossLinkTargetUnregistered { .. } => "CROSS_LINK_TARGET_UNREGISTERED",
128            Self::CrossLinkSelfGrantNoop { .. } => "CROSS_LINK_SELF_GRANT_NOOP",
129            Self::CrossLinkRevokeOrphanedEdges { .. } => "CROSS_LINK_REVOKE_ORPHANED_EDGES",
130        }
131    }
132}
133
134impl std::fmt::Display for WorkspaceEditWarning {
135    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
136        match self {
137            Self::RuleAlreadyPresent { section, pattern } => write!(
138                f,
139                "`[[{section}]]` already contains an entry for pattern `{pattern}` — file unchanged"
140            ),
141            Self::RuleNotFoundNoop { section, pattern } => write!(
142                f,
143                "`[[{section}]]` has no entry for pattern `{pattern}` — file unchanged"
144            ),
145            Self::GrantAlreadyPresent { from, to } => write!(
146                f,
147                "`[cross_mem_links]` already grants {from} → {to} — file unchanged"
148            ),
149            Self::GrantNotFound { from, to } => write!(
150                f,
151                "`[cross_mem_links]` does not grant {from} → {to} — file unchanged"
152            ),
153            Self::CrossLinkTargetUnregistered { to } => write!(
154                f,
155                "cross-link target `{to}` is not a registered mem — the grant is persisted (forward-reference is allowed) but will validate no relate until `{to}` exists"
156            ),
157            Self::CrossLinkSelfGrantNoop { mem } => write!(
158                f,
159                "self-grant `{mem} → {mem}` is a no-op — intra-mem links never traverse the cross-link gate; the grant is persisted but has no effect"
160            ),
161            Self::CrossLinkRevokeOrphanedEdges { edges } => write!(
162                f,
163                "{} existing edge(s) are now without a grant and are NOT removed: {}. Each refuses `memstead health --include integrity --strict` until it is granted again or removed (`memstead relate ... --remove`, which needs no grant)",
164                edges.len(),
165                edges.join(", ")
166            ),
167        }
168    }
169}
170
171impl std::fmt::Display for WorkspaceEditError {
172    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
173        match self {
174            Self::WorkspaceNotInitialised { path, .. } => write!(
175                f,
176                "no `.memstead/workspace.toml` at {} — run `memstead mem-repo init` or `memstead init` first",
177                path.display()
178            ),
179            Self::InvalidToml { path, message } => {
180                write!(f, "{}: failed to parse TOML — {message}", path.display())
181            }
182            Self::BeforePatternNotFound { section, pattern } => write!(
183                f,
184                "`--before {pattern}` did not match any existing `[[{section}]]` entry"
185            ),
186            Self::CrossLinkConflict { from, message } => write!(
187                f,
188                "`[cross_mem_links]` rejects edit for `{from}`: {message}"
189            ),
190            Self::RuleExistsSchemasDiffer {
191                section,
192                pattern,
193                stored,
194                requested,
195            } => write!(
196                f,
197                "`[[{section}]]` already has a rule for pattern `{pattern}` pinned to schemas [{}], \
198                 which differs from the requested [{}] — refusing to silently change the schema pins. \
199                 To change them, revoke the rule first (`revoke_create {pattern}`) then re-add it with the new schemas",
200                stored.join(", "),
201                requested.join(", "),
202            ),
203            Self::Io { path, source } => write!(f, "{}: {source}", path.display()),
204        }
205    }
206}
207
208impl std::error::Error for WorkspaceEditError {}
209
210impl WorkspaceEditError {
211    /// Stable symbolic code used by the CLI exit envelope.
212    pub fn code(&self) -> &'static str {
213        match self {
214            Self::WorkspaceNotInitialised { .. } => "WORKSPACE_NOT_INITIALISED",
215            Self::InvalidToml { .. } => "INVALID_TOML",
216            Self::BeforePatternNotFound { .. } => "BEFORE_PATTERN_NOT_FOUND",
217            Self::CrossLinkConflict { .. } => "CROSS_LINK_CONFLICT",
218            Self::RuleExistsSchemasDiffer { .. } => "RULE_EXISTS_SCHEMAS_DIFFER",
219            Self::Io { .. } => "IO_ERROR",
220        }
221    }
222}
223
224/// Path of the workspace config file relative to the workspace root.
225pub fn workspace_toml_path(workspace_root: &Path) -> PathBuf {
226    workspace_root
227        .join(memstead_base::WORKSPACE_STORE_DIR)
228        .join("workspace.toml")
229}
230
231fn load(workspace_root: &Path) -> Result<(PathBuf, DocumentMut), WorkspaceEditError> {
232    let path = workspace_toml_path(workspace_root);
233    let text = fs::read_to_string(&path).map_err(|source| {
234        if source.kind() == std::io::ErrorKind::NotFound {
235            WorkspaceEditError::WorkspaceNotInitialised { path: path.clone() }
236        } else {
237            WorkspaceEditError::Io {
238                path: path.clone(),
239                source,
240            }
241        }
242    })?;
243    let doc: DocumentMut =
244        text.parse()
245            .map_err(|e: toml_edit::TomlError| WorkspaceEditError::InvalidToml {
246                path: path.clone(),
247                message: e.to_string(),
248            })?;
249    Ok((path, doc))
250}
251
252fn save(path: &Path, doc: &DocumentMut) -> Result<(), WorkspaceEditError> {
253    fs::write(path, doc.to_string()).map_err(|source| WorkspaceEditError::Io {
254        path: path.to_path_buf(),
255        source,
256    })
257}
258
259/// Either-or shape mirroring `[cross_mem_links]` semantics on disk:
260/// `<from> = "*"` (wildcard) or `<from> = ["a", "b"]` (allowlist). The
261/// CLI exposes both via `--target *` and `--target <name>` on
262/// `grant-cross-link`.
263#[derive(Debug, Clone, PartialEq, Eq)]
264pub enum CrossLinkTarget {
265    /// `*` — every current writable target is permitted.
266    Wildcard,
267    /// One named target. Multiple grants accumulate into the
268    /// underlying `<from> = ["a", "b", ...]` list on disk.
269    Named(String),
270}
271
272impl CrossLinkTarget {
273    /// Parse a CLI-supplied target token. `*` maps to the wildcard
274    /// shape; anything else maps to the named shape verbatim. The
275    /// caller validates name shape elsewhere (the engine's existing
276    /// mem-name validation runs on load).
277    pub fn parse(raw: &str) -> Self {
278        if raw == "*" {
279            Self::Wildcard
280        } else {
281            Self::Named(raw.to_string())
282        }
283    }
284}
285
286/// `memstead workspace allow-create <pattern> --schema <pin>[,…] [--cross-link …]
287/// [--before <pattern>]` — append a `[[mem_management.create]]` rule.
288/// Default ordering is append (lowest priority); `before` flags lift it
289/// above the named pattern.
290pub fn add_create_rule(
291    workspace_root: &Path,
292    pattern: &str,
293    schemas: &[String],
294    default_cross_links: Option<&[CrossLinkTarget]>,
295    before: Option<&str>,
296) -> Result<Vec<WorkspaceEditWarning>, WorkspaceEditError> {
297    let (path, mut doc) = load(workspace_root)?;
298    let section = ensure_array_of_tables(&mut doc, "mem_management", "create");
299
300    if let Some(idx) = find_pattern_index(section, pattern) {
301        // Pattern already present. Compare the stored schema set against
302        // the requested one: identical → clean idempotent no-op; a
303        // *difference* must not silently no-op (and must not echo a
304        // change that did not land), so refuse with an actionable typed
305        // error pointing at the revoke-then-readd recovery.
306        let stored = read_rule_schemas(section, idx);
307        if schema_sets_equal(&stored, schemas) {
308            return Ok(vec![WorkspaceEditWarning::RuleAlreadyPresent {
309                section: "mem_management.create",
310                pattern: pattern.to_string(),
311            }]);
312        }
313        return Err(WorkspaceEditError::RuleExistsSchemasDiffer {
314            section: "mem_management.create",
315            pattern: pattern.to_string(),
316            stored,
317            requested: schemas.to_vec(),
318        });
319    }
320
321    let mut table = Table::new();
322    table["pattern"] = Item::Value(Value::from(pattern));
323    let mut arr = Array::new();
324    for s in schemas {
325        arr.push(s.as_str());
326    }
327    table["schemas"] = Item::Value(Value::Array(arr));
328    if let Some(cross_links) = default_cross_links {
329        table["default_cross_links"] = cross_link_value_item(cross_links);
330    }
331
332    if let Some(before_pattern) = before {
333        let idx = find_pattern_index(section, before_pattern).ok_or_else(|| {
334            WorkspaceEditError::BeforePatternNotFound {
335                section: "mem_management.create",
336                pattern: before_pattern.to_string(),
337            }
338        })?;
339        // `ArrayOfTables` has no `insert(idx, table)`; emulate it by
340        // detaching every entry from `idx` onward, pushing the new
341        // table, then pushing the detached entries back.
342        let mut tail = Vec::with_capacity(section.len() - idx);
343        while section.len() > idx {
344            let last = section.get(section.len() - 1).cloned().unwrap();
345            tail.push(last);
346            section.remove(section.len() - 1);
347        }
348        section.push(table);
349        for entry in tail.into_iter().rev() {
350            section.push(entry);
351        }
352    } else {
353        section.push(table);
354    }
355
356    save(&path, &doc)?;
357    Ok(Vec::new())
358}
359
360/// `memstead workspace revoke-create <pattern>` — remove a
361/// `[[mem_management.create]]` rule by pattern.
362pub fn remove_create_rule(
363    workspace_root: &Path,
364    pattern: &str,
365) -> Result<Vec<WorkspaceEditWarning>, WorkspaceEditError> {
366    let (path, mut doc) = load(workspace_root)?;
367    let section = ensure_array_of_tables(&mut doc, "mem_management", "create");
368    let idx = match find_pattern_index(section, pattern) {
369        Some(i) => i,
370        None => {
371            return Ok(vec![WorkspaceEditWarning::RuleNotFoundNoop {
372                section: "mem_management.create",
373                pattern: pattern.to_string(),
374            }]);
375        }
376    };
377    section.remove(idx);
378    save(&path, &doc)?;
379    Ok(Vec::new())
380}
381
382/// `memstead workspace allow-delete <pattern>` — append a
383/// `[[mem_management.delete]]` rule.
384pub fn add_delete_rule(
385    workspace_root: &Path,
386    pattern: &str,
387) -> Result<Vec<WorkspaceEditWarning>, WorkspaceEditError> {
388    let (path, mut doc) = load(workspace_root)?;
389    let section = ensure_array_of_tables(&mut doc, "mem_management", "delete");
390    if find_pattern_index(section, pattern).is_some() {
391        return Ok(vec![WorkspaceEditWarning::RuleAlreadyPresent {
392            section: "mem_management.delete",
393            pattern: pattern.to_string(),
394        }]);
395    }
396    let mut table = Table::new();
397    table["pattern"] = Item::Value(Value::from(pattern));
398    section.push(table);
399    save(&path, &doc)?;
400    Ok(Vec::new())
401}
402
403/// `memstead workspace revoke-delete <pattern>` — remove a
404/// `[[mem_management.delete]]` rule by pattern.
405pub fn remove_delete_rule(
406    workspace_root: &Path,
407    pattern: &str,
408) -> Result<Vec<WorkspaceEditWarning>, WorkspaceEditError> {
409    let (path, mut doc) = load(workspace_root)?;
410    let section = ensure_array_of_tables(&mut doc, "mem_management", "delete");
411    let idx = match find_pattern_index(section, pattern) {
412        Some(i) => i,
413        None => {
414            return Ok(vec![WorkspaceEditWarning::RuleNotFoundNoop {
415                section: "mem_management.delete",
416                pattern: pattern.to_string(),
417            }]);
418        }
419    };
420    section.remove(idx);
421    save(&path, &doc)?;
422    Ok(Vec::new())
423}
424
425/// `memstead workspace grant-cross-link <from> <to>` — add `to` to the
426/// allowlist for `from` in `[cross_mem_links]`. `to == "*"` sets the
427/// wildcard shape; named targets accumulate into a list.
428pub fn grant_cross_link(
429    workspace_root: &Path,
430    from: &str,
431    to: &CrossLinkTarget,
432    known_mems: &[String],
433) -> Result<Vec<WorkspaceEditWarning>, WorkspaceEditError> {
434    // Diligence (matching the sibling `revoke_cross_link`'s warn-on-
435    // anomaly behaviour): warn — never block — on a self-grant or an
436    // unregistered named target. The grant still persists so the
437    // forward-reference workflow (grant before the target mem exists)
438    // stays open. The `*` wildcard is a legitimate non-mem token and
439    // is not validated against the registered set.
440    let mut warnings: Vec<WorkspaceEditWarning> = Vec::new();
441    if let CrossLinkTarget::Named(name) = to {
442        if name == from {
443            warnings.push(WorkspaceEditWarning::CrossLinkSelfGrantNoop {
444                mem: from.to_string(),
445            });
446        } else if !known_mems.iter().any(|v| v == name) {
447            warnings.push(WorkspaceEditWarning::CrossLinkTargetUnregistered { to: name.clone() });
448        }
449    }
450
451    let (path, mut doc) = load(workspace_root)?;
452    let table = ensure_table(&mut doc, "cross_mem_links");
453    match (table.get(from), to) {
454        (None, CrossLinkTarget::Wildcard) => {
455            table.insert(from, Item::Value(Value::from("*")));
456        }
457        (None, CrossLinkTarget::Named(name)) => {
458            let mut arr = Array::new();
459            arr.push(name.as_str());
460            table.insert(from, Item::Value(Value::Array(arr)));
461        }
462        (Some(Item::Value(Value::String(s))), CrossLinkTarget::Wildcard) if s.value() == "*" => {
463            warnings.push(WorkspaceEditWarning::GrantAlreadyPresent {
464                from: from.to_string(),
465                to: "*".to_string(),
466            });
467            return Ok(warnings);
468        }
469        (Some(Item::Value(Value::String(_))), _) => {
470            return Err(WorkspaceEditError::CrossLinkConflict {
471                from: from.to_string(),
472                message: "wildcard `*` already set — revoke `*` before granting a named target"
473                    .to_string(),
474            });
475        }
476        (Some(Item::Value(Value::Array(_))), CrossLinkTarget::Wildcard) => {
477            return Err(WorkspaceEditError::CrossLinkConflict {
478                from: from.to_string(),
479                message: "specific allowlist already set — revoke every entry before granting `*`"
480                    .to_string(),
481            });
482        }
483        (Some(Item::Value(Value::Array(arr))), CrossLinkTarget::Named(name)) => {
484            if array_contains(arr, name) {
485                warnings.push(WorkspaceEditWarning::GrantAlreadyPresent {
486                    from: from.to_string(),
487                    to: name.clone(),
488                });
489                return Ok(warnings);
490            }
491            let mut arr = arr.clone();
492            arr.push(name.as_str());
493            table.insert(from, Item::Value(Value::Array(arr)));
494        }
495        (Some(_), _) => {
496            return Err(WorkspaceEditError::CrossLinkConflict {
497                from: from.to_string(),
498                message: "existing value is neither a string nor an array — fix by hand"
499                    .to_string(),
500            });
501        }
502    }
503    save(&path, &doc)?;
504    Ok(warnings)
505}
506
507/// `memstead workspace revoke-cross-link <from> <to>` — remove `to` from
508/// the allowlist for `from`. When the underlying list becomes empty,
509/// the `<from>` key is dropped entirely; `*` is matched as a literal.
510pub fn revoke_cross_link(
511    workspace_root: &Path,
512    from: &str,
513    to: &CrossLinkTarget,
514) -> Result<Vec<WorkspaceEditWarning>, WorkspaceEditError> {
515    let (path, mut doc) = load(workspace_root)?;
516    let table = ensure_table(&mut doc, "cross_mem_links");
517    let removed = match (table.get(from), to) {
518        (None, _) => false,
519        (Some(Item::Value(Value::String(s))), CrossLinkTarget::Wildcard) if s.value() == "*" => {
520            table.remove(from);
521            true
522        }
523        (Some(Item::Value(Value::String(_))), CrossLinkTarget::Named(_)) => false,
524        (Some(Item::Value(Value::String(_))), CrossLinkTarget::Wildcard) => false,
525        (Some(Item::Value(Value::Array(_))), CrossLinkTarget::Wildcard) => false,
526        (Some(Item::Value(Value::Array(arr))), CrossLinkTarget::Named(name)) => {
527            let mut arr = arr.clone();
528            let original_len = arr.len();
529            arr.retain(|v| match v {
530                Value::String(s) => s.value() != name,
531                _ => true,
532            });
533            if arr.len() == original_len {
534                false
535            } else if arr.is_empty() {
536                table.remove(from);
537                true
538            } else {
539                table.insert(from, Item::Value(Value::Array(arr)));
540                true
541            }
542        }
543        (Some(_), _) => false,
544    };
545    if !removed {
546        let target = match to {
547            CrossLinkTarget::Wildcard => "*".to_string(),
548            CrossLinkTarget::Named(s) => s.clone(),
549        };
550        return Ok(vec![WorkspaceEditWarning::GrantNotFound {
551            from: from.to_string(),
552            to: target,
553        }]);
554    }
555    save(&path, &doc)?;
556    Ok(Vec::new())
557}
558
559/// `memstead workspace set-mutations --require-notes <bool>` — set the
560/// `[mutations] require_notes` field. Creates the section on demand.
561pub fn set_mutation_require_notes(
562    workspace_root: &Path,
563    value: bool,
564) -> Result<(), WorkspaceEditError> {
565    let (path, mut doc) = load(workspace_root)?;
566    let table = ensure_table(&mut doc, "mutations");
567    table.insert("require_notes", Item::Value(Value::from(value)));
568    save(&path, &doc)
569}
570
571/// Scrub `.memstead/workspace.toml` of the now-dangling `[cross_mem_links]`
572/// grants naming `mem_name` so the workspace no longer references a
573/// mem the engine just destructively deleted. The
574/// `[[mem_management.create]]` / `[[mem_management.delete]]`
575/// allowlist rules are deliberately left intact.
576///
577/// Two passes, both on the in-memory `DocumentMut` before one final
578/// save:
579///   1. `[cross_mem_links]` — drop the key `mem_name` if present
580///      (the deleted mem as `from`).
581///   2. `[cross_mem_links]` — remove `mem_name` from every other
582///      key's allowlist `Array`. When an allowlist becomes empty,
583///      drop the key entirely (mirrors `revoke_cross_link`).
584///
585/// What is NOT scrubbed, and why: the `[[mem_management.create]]` /
586/// `[[mem_management.delete]]` rules are forward-looking *permissions
587/// for a name*, not references to the deleted *instance*. A cross-link
588/// grant `test → other` names the gone instance and genuinely dangles,
589/// so it is scrubbed; a `[[mem_management.create]]` rule for `other`
590/// means "an agent may bring a mem named `other` into existence" and
591/// stays true after the delete. Scrubbing it would silently revoke a
592/// guarded-config permission as a side effect of an instance op, and
593/// force a fresh `allow-create` before the next `mem init other` —
594/// exactly the re-attach friction `unregister` avoids by preserving
595/// policy. So `delete` is idempotent w.r.t. a later re-create.
596///
597/// Silent no-op cases (returns `Ok(())` without touching the file):
598/// - `.memstead/workspace.toml` missing — pre-init workspaces (tests, ad-hoc
599///   consumers) have no policy state to scrub. The caller (delete
600///   orchestrator) already wrote nothing, so there's nothing to undo.
601/// - `.memstead/workspace.toml` unparseable — surfaces as `InvalidToml`.
602///
603/// IO failures surface as `WorkspaceEditError::Io`; the caller's outer
604/// engine-error type wraps that.
605pub fn scrub_policy_for_deleted_mem(
606    workspace_root: &Path,
607    mem_name: &str,
608) -> Result<Vec<ScrubbedEntry>, WorkspaceEditError> {
609    let path = workspace_toml_path(workspace_root);
610    let text = match fs::read_to_string(&path) {
611        Ok(t) => t,
612        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
613            // Pre-init or operator-edited away — nothing to scrub.
614            return Ok(Vec::new());
615        }
616        Err(source) => {
617            return Err(WorkspaceEditError::Io {
618                path: path.clone(),
619                source,
620            });
621        }
622    };
623    let mut doc: DocumentMut =
624        text.parse()
625            .map_err(|e: toml_edit::TomlError| WorkspaceEditError::InvalidToml {
626                path: path.clone(),
627                message: e.to_string(),
628            })?;
629
630    let mut scrubbed: Vec<ScrubbedEntry> = Vec::new();
631
632    if let Some(item) = doc.get_mut("cross_mem_links")
633        && let Some(table) = item.as_table_mut()
634    {
635        // The deleted mem's own key — every grant `mem_name
636        // → <anything>` is dropped wholesale.
637        if let Some(removed) = table.remove(mem_name) {
638            let targets = match removed {
639                Item::Value(Value::Array(arr)) => arr
640                    .iter()
641                    .filter_map(|v| match v {
642                        Value::String(s) => Some(s.value().to_string()),
643                        _ => None,
644                    })
645                    .collect::<Vec<_>>(),
646                Item::Value(Value::String(s)) => vec![s.value().to_string()],
647                _ => Vec::new(),
648            };
649            if targets.is_empty() {
650                scrubbed.push(ScrubbedEntry::CrossLink {
651                    from: mem_name.to_string(),
652                    to: "*".to_string(),
653                });
654            } else {
655                for to in targets {
656                    scrubbed.push(ScrubbedEntry::CrossLink {
657                        from: mem_name.to_string(),
658                        to,
659                    });
660                }
661            }
662        }
663        // Peer entries referencing the deleted mem as a grant
664        // target — drop the entry from the array; collapse empty
665        // arrays.
666        let keys: Vec<String> = table.iter().map(|(k, _)| k.to_string()).collect();
667        for key in keys {
668            let drop_key = match table.get(&key) {
669                Some(Item::Value(Value::Array(arr))) if array_contains(arr, mem_name) => {
670                    let mut arr = arr.clone();
671                    arr.retain(|v| match v {
672                        Value::String(s) => s.value() != mem_name,
673                        _ => true,
674                    });
675                    scrubbed.push(ScrubbedEntry::CrossLink {
676                        from: key.clone(),
677                        to: mem_name.to_string(),
678                    });
679                    if arr.is_empty() {
680                        true
681                    } else {
682                        table.insert(&key, Item::Value(Value::Array(arr)));
683                        false
684                    }
685                }
686                _ => false,
687            };
688            if drop_key {
689                table.remove(&key);
690            }
691        }
692    }
693
694    // The `[[mem_management.create]]` / `[[mem_management.delete]]`
695    // allowlist rules are deliberately NOT scrubbed — see this
696    // function's doc-comment for why deleting an instance must not
697    // revoke the forward-looking permission to (re-)create a mem of
698    // the same name.
699
700    if !scrubbed.is_empty() {
701        save(&path, &doc)?;
702    }
703    Ok(scrubbed)
704}
705
706/// One scrubbed entry returned from [`scrub_policy_for_deleted_mem`].
707/// The `memstead_mem_delete` response surfaces these so an agent sees
708/// every policy side effect in one round-trip. Only dangling
709/// `[cross_mem_links]` grants are scrubbed; the
710/// `[[mem_management.*]]` allowlist rules are preserved, so this enum
711/// carries the one scrubbed shape.
712#[derive(Debug, Clone, PartialEq, Eq)]
713pub enum ScrubbedEntry {
714    /// `[cross_mem_links]` grant naming the deleted mem on
715    /// either side.
716    CrossLink {
717        /// Source mem — the grant's table key.
718        from: String,
719        /// Target mem — array element or wildcard `"*"`.
720        to: String,
721    },
722}
723
724// --- internal helpers ---------------------------------------------------
725
726/// Rename every occurrence of mem `old` in the workspace's
727/// `[cross_mem_links]` table — as a granting key and inside named
728/// allowlist arrays — to `new`. The mem-rename counterpart of the
729/// grant/revoke pair: a pure key/value rewrite with none of their
730/// conflict semantics. A missing `workspace.toml` or absent
731/// `[cross_mem_links]` table is a no-op (nothing names the mem).
732/// Returns whether anything changed on disk.
733pub fn rename_mem_in_cross_links(
734    workspace_root: &Path,
735    old: &str,
736    new: &str,
737) -> Result<bool, WorkspaceEditError> {
738    let (path, mut doc) = match load(workspace_root) {
739        Ok(pair) => pair,
740        Err(WorkspaceEditError::WorkspaceNotInitialised { .. }) => return Ok(false),
741        Err(e) => return Err(e),
742    };
743    let Some(table) = doc.get_mut("cross_mem_links").and_then(Item::as_table_mut) else {
744        return Ok(false);
745    };
746
747    let mut changed = false;
748    // Value rewrite: any named allowlist entry equal to `old`.
749    for (_key, item) in table.iter_mut() {
750        if let Item::Value(Value::Array(arr)) = item {
751            let mut next = Array::new();
752            let mut arr_changed = false;
753            for v in arr.iter() {
754                match v.as_str() {
755                    Some(s) if s == old => {
756                        next.push(new);
757                        arr_changed = true;
758                    }
759                    _ => next.push(v.clone()),
760                }
761            }
762            if arr_changed {
763                *item = Item::Value(Value::Array(next));
764                changed = true;
765            }
766        }
767    }
768    // Key rewrite: `old` as a granting mem. toml_edit has no key
769    // rename; remove + reinsert preserves the value.
770    if let Some(value) = table.remove(old) {
771        table.insert(new, value);
772        changed = true;
773    }
774
775    if changed {
776        save(&path, &doc)?;
777    }
778    Ok(changed)
779}
780
781fn ensure_table<'a>(doc: &'a mut DocumentMut, name: &str) -> &'a mut Table {
782    if !doc.contains_key(name) {
783        let mut t = Table::new();
784        t.set_implicit(false);
785        doc.insert(name, Item::Table(t));
786    }
787    doc.get_mut(name)
788        .unwrap()
789        .as_table_mut()
790        .expect("ensured table shape")
791}
792
793fn ensure_array_of_tables<'a>(
794    doc: &'a mut DocumentMut,
795    outer: &str,
796    inner: &str,
797) -> &'a mut ArrayOfTables {
798    if !doc.contains_key(outer) {
799        let mut t = Table::new();
800        t.set_implicit(true);
801        doc.insert(outer, Item::Table(t));
802    }
803    let outer_table = doc
804        .get_mut(outer)
805        .and_then(|i| i.as_table_mut())
806        .expect("mem_management must be a table");
807    if !outer_table.contains_key(inner) {
808        outer_table.insert(inner, Item::ArrayOfTables(ArrayOfTables::new()));
809    }
810    outer_table
811        .get_mut(inner)
812        .and_then(|i| i.as_array_of_tables_mut())
813        .expect("ensured array-of-tables shape")
814}
815
816fn find_pattern_index(section: &ArrayOfTables, pattern: &str) -> Option<usize> {
817    section
818        .iter()
819        .position(|t| t.get("pattern").and_then(|i| i.as_str()) == Some(pattern))
820}
821
822/// Read the `schemas` string list off the rule at `idx` in `section`.
823/// Missing or malformed `schemas` reads as an empty list.
824fn read_rule_schemas(section: &ArrayOfTables, idx: usize) -> Vec<String> {
825    section
826        .get(idx)
827        .and_then(|t| t.get("schemas"))
828        .and_then(|i| i.as_array())
829        .map(|arr| {
830            arr.iter()
831                .filter_map(|v| v.as_str().map(str::to_string))
832                .collect()
833        })
834        .unwrap_or_default()
835}
836
837/// Set-equality over two schema-pin lists (order- and duplicate-
838/// insensitive). A rule pinned to `[a, b]` re-added as `[b, a]` is the
839/// same allowlist, so it stays a clean no-op rather than a refusal.
840fn schema_sets_equal(a: &[String], b: &[String]) -> bool {
841    let mut a: Vec<&str> = a.iter().map(String::as_str).collect();
842    let mut b: Vec<&str> = b.iter().map(String::as_str).collect();
843    a.sort_unstable();
844    a.dedup();
845    b.sort_unstable();
846    b.dedup();
847    a == b
848}
849
850fn cross_link_value_item(targets: &[CrossLinkTarget]) -> Item {
851    if targets
852        .iter()
853        .any(|t| matches!(t, CrossLinkTarget::Wildcard))
854    {
855        Item::Value(Value::from("*"))
856    } else {
857        let mut arr = Array::new();
858        for t in targets {
859            if let CrossLinkTarget::Named(name) = t {
860                arr.push(name.as_str());
861            }
862        }
863        Item::Value(Value::Array(arr))
864    }
865}
866
867fn array_contains(arr: &Array, needle: &str) -> bool {
868    arr.iter().any(|v| match v {
869        Value::String(s) => s.value() == needle,
870        _ => false,
871    })
872}
873
874#[cfg(test)]
875mod tests {
876    use super::*;
877    use tempfile::TempDir;
878
879    const DEFAULT_BODY: &str =
880        "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n";
881
882    fn seed(body: &str) -> TempDir {
883        let tmp = TempDir::new().unwrap();
884        let memstead = tmp.path().join(".memstead");
885        fs::create_dir_all(&memstead).unwrap();
886        fs::write(memstead.join("workspace.toml"), body).unwrap();
887        tmp
888    }
889
890    fn read(root: &Path) -> String {
891        fs::read_to_string(workspace_toml_path(root)).unwrap()
892    }
893
894    /// Registered-mem set for `grant_cross_link` target validation.
895    /// Covers every named `to` target the grant tests use, so the
896    /// behaviour-focused tests don't trip the `CROSS_LINK_TARGET_
897    /// UNREGISTERED` warning. Target-validation behaviour is exercised
898    /// by its own dedicated tests.
899    fn known() -> Vec<String> {
900        ["engine", "plugin", "macos", "specs", "default"]
901            .iter()
902            .map(|s| s.to_string())
903            .collect()
904    }
905
906    #[test]
907    fn add_create_rule_appends_by_default() {
908        let tmp = seed(DEFAULT_BODY);
909        add_create_rule(
910            tmp.path(),
911            "exec-*",
912            &["default@1.0.0".to_string()],
913            None,
914            None,
915        )
916        .unwrap();
917        let body = read(tmp.path());
918        assert!(body.contains("[[mem_management.create]]"), "got:\n{body}");
919        assert!(body.contains("pattern = \"exec-*\""), "got:\n{body}");
920        assert!(
921            body.contains("schemas = [\"default@1.0.0\"]"),
922            "got:\n{body}"
923        );
924    }
925
926    /// Adding a duplicate rule is idempotent — the call returns
927    /// `Ok(vec![RuleAlreadyPresent])` rather than an error. Scripts and
928    /// agents can retry safely without branching on prior state. The
929    /// original file body is preserved (no spurious save).
930    #[test]
931    fn add_create_rule_duplicate_is_idempotent_with_warning() {
932        let tmp = seed(DEFAULT_BODY);
933        let first = add_create_rule(
934            tmp.path(),
935            "exec-*",
936            &["default@1.0.0".to_string()],
937            None,
938            None,
939        )
940        .unwrap();
941        assert!(first.is_empty(), "first add must return no warnings");
942        let body_after_first = read(tmp.path());
943        let warnings = add_create_rule(
944            tmp.path(),
945            "exec-*",
946            &["default@1.0.0".to_string()],
947            None,
948            None,
949        )
950        .unwrap();
951        assert_eq!(warnings.len(), 1);
952        assert_eq!(warnings[0].code(), "RULE_ALREADY_PRESENT");
953        let body_after_second = read(tmp.path());
954        assert_eq!(
955            body_after_first, body_after_second,
956            "duplicate add must not rewrite the file",
957        );
958    }
959
960    /// MCP F3 / CLI: re-adding an existing pattern with a *different*
961    /// schema set must NOT silently no-op (the deceptive "file unchanged"
962    /// echoing a change that did not land). It refuses with a typed error
963    /// naming the stored vs requested schemas, and the file is unchanged.
964    #[test]
965    fn add_create_rule_differing_schemas_refused_file_unchanged() {
966        let tmp = seed(DEFAULT_BODY);
967        add_create_rule(
968            tmp.path(),
969            "scratch",
970            &["software@0.1.0".to_string()],
971            None,
972            None,
973        )
974        .unwrap();
975        let body_before = read(tmp.path());
976
977        let err = add_create_rule(
978            tmp.path(),
979            "scratch",
980            &["nonexistent@9.9.9".to_string()],
981            None,
982            None,
983        )
984        .expect_err("differing schemas must be refused, not silently no-op'd");
985        assert_eq!(err.code(), "RULE_EXISTS_SCHEMAS_DIFFER");
986        match &err {
987            WorkspaceEditError::RuleExistsSchemasDiffer {
988                stored, requested, ..
989            } => {
990                assert_eq!(stored, &["software@0.1.0".to_string()]);
991                assert_eq!(requested, &["nonexistent@9.9.9".to_string()]);
992            }
993            other => panic!("expected RuleExistsSchemasDiffer, got {other:?}"),
994        }
995        assert_eq!(
996            body_before,
997            read(tmp.path()),
998            "refused schema change must not rewrite the file (stored schemas stay put)",
999        );
1000    }
1001
1002    /// Set-equality: the same schemas in a different order is the same
1003    /// allowlist — a clean idempotent no-op, not a refusal.
1004    #[test]
1005    fn add_create_rule_reordered_schemas_is_idempotent_noop() {
1006        let tmp = seed(DEFAULT_BODY);
1007        add_create_rule(
1008            tmp.path(),
1009            "scratch",
1010            &["a@1.0.0".to_string(), "b@1.0.0".to_string()],
1011            None,
1012            None,
1013        )
1014        .unwrap();
1015        let warnings = add_create_rule(
1016            tmp.path(),
1017            "scratch",
1018            &["b@1.0.0".to_string(), "a@1.0.0".to_string()],
1019            None,
1020            None,
1021        )
1022        .expect("reordered identical schema set must stay a no-op");
1023        assert_eq!(warnings.len(), 1);
1024        assert_eq!(warnings[0].code(), "RULE_ALREADY_PRESENT");
1025    }
1026
1027    /// The documented recovery works: revoke the rule, then re-add with
1028    /// the new schemas — the change lands and the stored pins update.
1029    #[test]
1030    fn revoke_then_readd_applies_the_new_schemas() {
1031        let tmp = seed(DEFAULT_BODY);
1032        add_create_rule(
1033            tmp.path(),
1034            "scratch",
1035            &["software@0.1.0".to_string()],
1036            None,
1037            None,
1038        )
1039        .unwrap();
1040        remove_create_rule(tmp.path(), "scratch").unwrap();
1041        let warnings = add_create_rule(
1042            tmp.path(),
1043            "scratch",
1044            &["planning@0.1.0".to_string()],
1045            None,
1046            None,
1047        )
1048        .expect("re-add after revoke must succeed");
1049        assert!(warnings.is_empty(), "fresh add returns no warnings");
1050        let body = read(tmp.path());
1051        assert!(
1052            body.contains("schemas = [\"planning@0.1.0\"]"),
1053            "new pins stored; got:\n{body}"
1054        );
1055        assert!(
1056            !body.contains("software@0.1.0"),
1057            "old pins gone; got:\n{body}"
1058        );
1059    }
1060
1061    #[test]
1062    fn add_create_rule_before_lifts_priority() {
1063        let tmp = seed(DEFAULT_BODY);
1064        add_create_rule(
1065            tmp.path(),
1066            "z-*",
1067            &["default@1.0.0".to_string()],
1068            None,
1069            None,
1070        )
1071        .unwrap();
1072        add_create_rule(
1073            tmp.path(),
1074            "a-*",
1075            &["default@1.0.0".to_string()],
1076            None,
1077            Some("z-*"),
1078        )
1079        .unwrap();
1080        let body = read(tmp.path());
1081        let a_idx = body.find("pattern = \"a-*\"").expect("a-* must exist");
1082        let z_idx = body.find("pattern = \"z-*\"").expect("z-* must exist");
1083        assert!(
1084            a_idx < z_idx,
1085            "--before must place new rule above target; got:\n{body}"
1086        );
1087    }
1088
1089    #[test]
1090    fn add_create_rule_before_unknown_pattern_errors() {
1091        let tmp = seed(DEFAULT_BODY);
1092        let err = add_create_rule(
1093            tmp.path(),
1094            "exec-*",
1095            &["default@1.0.0".to_string()],
1096            None,
1097            Some("does-not-exist"),
1098        )
1099        .unwrap_err();
1100        assert_eq!(err.code(), "BEFORE_PATTERN_NOT_FOUND");
1101    }
1102
1103    #[test]
1104    fn add_create_rule_with_named_cross_links() {
1105        let tmp = seed(DEFAULT_BODY);
1106        add_create_rule(
1107            tmp.path(),
1108            "exec-*",
1109            &["default@1.0.0".to_string()],
1110            Some(&[CrossLinkTarget::Named("engine".to_string())]),
1111            None,
1112        )
1113        .unwrap();
1114        let body = read(tmp.path());
1115        assert!(
1116            body.contains("default_cross_links = [\"engine\"]"),
1117            "got:\n{body}"
1118        );
1119    }
1120
1121    #[test]
1122    fn add_create_rule_with_wildcard_cross_links() {
1123        let tmp = seed(DEFAULT_BODY);
1124        add_create_rule(
1125            tmp.path(),
1126            "exec-*",
1127            &["default@1.0.0".to_string()],
1128            Some(&[CrossLinkTarget::Wildcard]),
1129            None,
1130        )
1131        .unwrap();
1132        let body = read(tmp.path());
1133        assert!(body.contains("default_cross_links = \"*\""), "got:\n{body}");
1134    }
1135
1136    #[test]
1137    fn remove_create_rule_succeeds() {
1138        let tmp = seed(DEFAULT_BODY);
1139        add_create_rule(
1140            tmp.path(),
1141            "exec-*",
1142            &["default@1.0.0".to_string()],
1143            None,
1144            None,
1145        )
1146        .unwrap();
1147        remove_create_rule(tmp.path(), "exec-*").unwrap();
1148        let body = read(tmp.path());
1149        assert!(!body.contains("pattern = \"exec-*\""), "got:\n{body}");
1150    }
1151
1152    /// Removing a non-existent rule is idempotent.
1153    /// Returns `Ok(vec![RuleNotFoundNoop])` rather than refusing.
1154    #[test]
1155    fn remove_create_rule_unknown_pattern_is_idempotent_with_warning() {
1156        let tmp = seed(DEFAULT_BODY);
1157        let body_before = read(tmp.path());
1158        let warnings = remove_create_rule(tmp.path(), "ghost").unwrap();
1159        assert_eq!(warnings.len(), 1);
1160        assert_eq!(warnings[0].code(), "RULE_NOT_FOUND_NOOP");
1161        let body_after = read(tmp.path());
1162        assert_eq!(
1163            body_before, body_after,
1164            "no-op remove must not touch the file"
1165        );
1166    }
1167
1168    #[test]
1169    fn add_and_remove_delete_rule() {
1170        let tmp = seed(DEFAULT_BODY);
1171        add_delete_rule(tmp.path(), "exec-*").unwrap();
1172        let body = read(tmp.path());
1173        assert!(body.contains("[[mem_management.delete]]"), "got:\n{body}");
1174        assert!(body.contains("pattern = \"exec-*\""), "got:\n{body}");
1175        remove_delete_rule(tmp.path(), "exec-*").unwrap();
1176        let body = read(tmp.path());
1177        assert!(!body.contains("pattern = \"exec-*\""), "got:\n{body}");
1178    }
1179
1180    #[test]
1181    fn grant_cross_link_creates_named_list() {
1182        let tmp = seed(DEFAULT_BODY);
1183        grant_cross_link(
1184            tmp.path(),
1185            "plugin",
1186            &CrossLinkTarget::Named("engine".to_string()),
1187            &known(),
1188        )
1189        .unwrap();
1190        let body = read(tmp.path());
1191        assert!(body.contains("plugin = [\"engine\"]"), "got:\n{body}");
1192    }
1193
1194    #[test]
1195    fn grant_cross_link_appends_named_target() {
1196        let tmp = seed(DEFAULT_BODY);
1197        grant_cross_link(
1198            tmp.path(),
1199            "macos",
1200            &CrossLinkTarget::Named("engine".to_string()),
1201            &known(),
1202        )
1203        .unwrap();
1204        grant_cross_link(
1205            tmp.path(),
1206            "macos",
1207            &CrossLinkTarget::Named("plugin".to_string()),
1208            &known(),
1209        )
1210        .unwrap();
1211        let body = read(tmp.path());
1212        assert!(
1213            body.contains("macos = [\"engine\", \"plugin\"]"),
1214            "got:\n{body}"
1215        );
1216    }
1217
1218    #[test]
1219    fn grant_cross_link_wildcard_sets_string() {
1220        let tmp = seed(DEFAULT_BODY);
1221        grant_cross_link(tmp.path(), "specs", &CrossLinkTarget::Wildcard, &known()).unwrap();
1222        let body = read(tmp.path());
1223        assert!(body.contains("specs = \"*\""), "got:\n{body}");
1224    }
1225
1226    /// Re-granting an existing grant is idempotent.
1227    /// Returns `Ok(vec![GrantAlreadyPresent])` and leaves the file
1228    /// unchanged.
1229    #[test]
1230    fn grant_cross_link_duplicate_named_is_idempotent_with_warning() {
1231        let tmp = seed(DEFAULT_BODY);
1232        grant_cross_link(
1233            tmp.path(),
1234            "plugin",
1235            &CrossLinkTarget::Named("engine".to_string()),
1236            &known(),
1237        )
1238        .unwrap();
1239        let body_before = read(tmp.path());
1240        let warnings = grant_cross_link(
1241            tmp.path(),
1242            "plugin",
1243            &CrossLinkTarget::Named("engine".to_string()),
1244            &known(),
1245        )
1246        .unwrap();
1247        assert_eq!(warnings.len(), 1);
1248        assert_eq!(warnings[0].code(), "GRANT_ALREADY_PRESENT");
1249        let body_after = read(tmp.path());
1250        assert_eq!(
1251            body_before, body_after,
1252            "duplicate grant must not rewrite the file"
1253        );
1254    }
1255
1256    #[test]
1257    fn grant_cross_link_named_over_wildcard_conflicts() {
1258        let tmp = seed(DEFAULT_BODY);
1259        grant_cross_link(tmp.path(), "plugin", &CrossLinkTarget::Wildcard, &known()).unwrap();
1260        let err = grant_cross_link(
1261            tmp.path(),
1262            "plugin",
1263            &CrossLinkTarget::Named("engine".to_string()),
1264            &known(),
1265        )
1266        .unwrap_err();
1267        assert_eq!(err.code(), "CROSS_LINK_CONFLICT");
1268    }
1269
1270    /// A named `to` target that isn't a registered mem warns
1271    /// `CROSS_LINK_TARGET_UNREGISTERED` — but the grant still persists
1272    /// (the forward-reference workflow stays open).
1273    #[test]
1274    fn grant_cross_link_warns_on_unregistered_named_target() {
1275        let tmp = seed(DEFAULT_BODY);
1276        let registered = vec!["plugin".to_string()];
1277        let warnings = grant_cross_link(
1278            tmp.path(),
1279            "plugin",
1280            &CrossLinkTarget::Named("future-mem".to_string()),
1281            &registered,
1282        )
1283        .unwrap();
1284        assert_eq!(warnings.len(), 1);
1285        assert_eq!(warnings[0].code(), "CROSS_LINK_TARGET_UNREGISTERED");
1286        // Grant persisted despite the warning.
1287        assert!(
1288            read(tmp.path()).contains("plugin = [\"future-mem\"]"),
1289            "grant must persist for the forward-reference workflow: {}",
1290            read(tmp.path())
1291        );
1292    }
1293
1294    /// A self-grant (`from == to`) warns `CROSS_LINK_SELF_GRANT_NOOP`
1295    /// and still persists.
1296    #[test]
1297    fn grant_cross_link_warns_on_self_grant() {
1298        let tmp = seed(DEFAULT_BODY);
1299        let warnings = grant_cross_link(
1300            tmp.path(),
1301            "plugin",
1302            &CrossLinkTarget::Named("plugin".to_string()),
1303            &known(),
1304        )
1305        .unwrap();
1306        assert_eq!(warnings.len(), 1);
1307        assert_eq!(warnings[0].code(), "CROSS_LINK_SELF_GRANT_NOOP");
1308        assert!(read(tmp.path()).contains("plugin = [\"plugin\"]"));
1309    }
1310
1311    /// The `*` wildcard is a legitimate non-mem token — it is NOT
1312    /// validated against the registered set, so granting `*` against an
1313    /// empty registry warns nothing.
1314    #[test]
1315    fn grant_cross_link_wildcard_not_target_validated() {
1316        let tmp = seed(DEFAULT_BODY);
1317        let warnings =
1318            grant_cross_link(tmp.path(), "plugin", &CrossLinkTarget::Wildcard, &[]).unwrap();
1319        assert!(
1320            warnings.is_empty(),
1321            "wildcard target must not be validated against the router: {warnings:?}"
1322        );
1323    }
1324
1325    /// A registered named target grants with no warning (the normal
1326    /// path is unchanged).
1327    #[test]
1328    fn grant_cross_link_registered_target_no_warning() {
1329        let tmp = seed(DEFAULT_BODY);
1330        let registered = vec!["engine".to_string()];
1331        let warnings = grant_cross_link(
1332            tmp.path(),
1333            "plugin",
1334            &CrossLinkTarget::Named("engine".to_string()),
1335            &registered,
1336        )
1337        .unwrap();
1338        assert!(
1339            warnings.is_empty(),
1340            "registered target must warn nothing: {warnings:?}"
1341        );
1342    }
1343
1344    #[test]
1345    fn revoke_cross_link_removes_named_target() {
1346        let tmp = seed(DEFAULT_BODY);
1347        grant_cross_link(
1348            tmp.path(),
1349            "macos",
1350            &CrossLinkTarget::Named("engine".to_string()),
1351            &known(),
1352        )
1353        .unwrap();
1354        grant_cross_link(
1355            tmp.path(),
1356            "macos",
1357            &CrossLinkTarget::Named("plugin".to_string()),
1358            &known(),
1359        )
1360        .unwrap();
1361        revoke_cross_link(
1362            tmp.path(),
1363            "macos",
1364            &CrossLinkTarget::Named("engine".to_string()),
1365        )
1366        .unwrap();
1367        let body = read(tmp.path());
1368        // toml_edit preserves the original array's inner whitespace
1369        // (e.g. `[ "plugin"]` if the original was `["engine", "plugin"]`).
1370        // Assert on the key + remaining target + the dropped target.
1371        assert!(body.contains("macos = ["), "got:\n{body}");
1372        assert!(body.contains("\"plugin\""), "got:\n{body}");
1373        assert!(
1374            !body.contains("\"engine\""),
1375            "engine target must be removed, got:\n{body}"
1376        );
1377    }
1378
1379    #[test]
1380    fn revoke_cross_link_empties_key() {
1381        let tmp = seed(DEFAULT_BODY);
1382        grant_cross_link(
1383            tmp.path(),
1384            "macos",
1385            &CrossLinkTarget::Named("engine".to_string()),
1386            &known(),
1387        )
1388        .unwrap();
1389        revoke_cross_link(
1390            tmp.path(),
1391            "macos",
1392            &CrossLinkTarget::Named("engine".to_string()),
1393        )
1394        .unwrap();
1395        let body = read(tmp.path());
1396        assert!(
1397            !body.contains("macos"),
1398            "empty allowlist must drop the key, got:\n{body}"
1399        );
1400    }
1401
1402    #[test]
1403    fn revoke_cross_link_wildcard() {
1404        let tmp = seed(DEFAULT_BODY);
1405        grant_cross_link(tmp.path(), "specs", &CrossLinkTarget::Wildcard, &known()).unwrap();
1406        revoke_cross_link(tmp.path(), "specs", &CrossLinkTarget::Wildcard).unwrap();
1407        let body = read(tmp.path());
1408        assert!(!body.contains("specs"), "got:\n{body}");
1409    }
1410
1411    /// Revoking an absent grant is idempotent.
1412    /// Returns `Ok(vec![GrantNotFound])` and leaves the file
1413    /// unchanged.
1414    #[test]
1415    fn revoke_cross_link_not_granted_is_idempotent_with_warning() {
1416        let tmp = seed(DEFAULT_BODY);
1417        let body_before = read(tmp.path());
1418        let warnings = revoke_cross_link(
1419            tmp.path(),
1420            "macos",
1421            &CrossLinkTarget::Named("engine".to_string()),
1422        )
1423        .unwrap();
1424        assert_eq!(warnings.len(), 1);
1425        assert_eq!(warnings[0].code(), "GRANT_NOT_FOUND");
1426        let body_after = read(tmp.path());
1427        assert_eq!(
1428            body_before, body_after,
1429            "no-op revoke must not touch the file"
1430        );
1431    }
1432
1433    #[test]
1434    fn set_mutation_require_notes_creates_section() {
1435        let tmp = seed(DEFAULT_BODY);
1436        set_mutation_require_notes(tmp.path(), true).unwrap();
1437        let body = read(tmp.path());
1438        assert!(body.contains("[mutations]"), "got:\n{body}");
1439        assert!(body.contains("require_notes = true"), "got:\n{body}");
1440    }
1441
1442    #[test]
1443    fn set_mutation_require_notes_toggles() {
1444        let tmp = seed(DEFAULT_BODY);
1445        set_mutation_require_notes(tmp.path(), true).unwrap();
1446        set_mutation_require_notes(tmp.path(), false).unwrap();
1447        let body = read(tmp.path());
1448        assert!(body.contains("require_notes = false"), "got:\n{body}");
1449    }
1450
1451    #[test]
1452    fn missing_workspace_toml_errors_with_typed_code() {
1453        let tmp = TempDir::new().unwrap();
1454        let err = add_create_rule(tmp.path(), "exec-*", &[], None, None).unwrap_err();
1455        assert_eq!(err.code(), "WORKSPACE_NOT_INITIALISED");
1456    }
1457
1458    #[test]
1459    fn comments_outside_edited_sections_survive() {
1460        // Operator-authored comments encode non-trivial knowledge
1461        // (forward-reference rationale, pattern-grammar examples,
1462        // operator-mode bypass semantics). toml_edit must preserve
1463        // every byte the CLI doesn't intentionally touch.
1464        let body = "# operator comment 1\n\
1465format = \"memstead-git-branch-2\"\n\
1466\n\
1467# operator comment 2\n\
1468[persistence_adapter]\n\
1469name = \"file-two-layer\"\n\
1470\n\
1471# section explanation that must survive\n\
1472[cross_mem_links]\n\
1473plugin = [\"engine\"]  # inline pin\n";
1474        let tmp = seed(body);
1475
1476        add_create_rule(
1477            tmp.path(),
1478            "exec-*",
1479            &["default@1.0.0".to_string()],
1480            None,
1481            None,
1482        )
1483        .unwrap();
1484
1485        let new_body = read(tmp.path());
1486        assert!(new_body.contains("# operator comment 1"));
1487        assert!(new_body.contains("# operator comment 2"));
1488        assert!(new_body.contains("# section explanation that must survive"));
1489        assert!(new_body.contains("# inline pin"));
1490        assert!(new_body.contains("[[mem_management.create]]"));
1491    }
1492
1493    /// A destructive delete scrubs only the dangling `[cross_mem_links]`
1494    /// grants naming the deleted mem — its own key and every peer's
1495    /// allowlist value (with empty-list key drop). The
1496    /// `[[mem_management.create]]` / `[[mem_management.delete]]`
1497    /// allowlist rules survive unconditionally — even the exact-name
1498    /// ones — because they are forward-looking permissions for the name,
1499    /// not references to the gone instance.
1500    #[test]
1501    fn scrub_policy_for_deleted_mem_drops_cross_links_but_keeps_allowlist_rules() {
1502        let body = "format = \"memstead-git-branch-2\"\n\n\
1503            [cross_mem_links]\n\
1504            other = [\"test\"]\n\
1505            test = [\"other\", \"keep\"]\n\
1506            \n\
1507            [[mem_management.create]]\n\
1508            pattern = \"other\"\n\
1509            schemas = [\"default@1.0.0\"]\n\
1510            \n\
1511            [[mem_management.create]]\n\
1512            pattern = \"*\"\n\
1513            schemas = [\"default@1.0.0\"]\n\
1514            \n\
1515            [[mem_management.delete]]\n\
1516            pattern = \"other\"\n\
1517            \n\
1518            [[mem_management.delete]]\n\
1519            pattern = \"team/*\"\n";
1520        let tmp = seed(body);
1521        let scrubbed = scrub_policy_for_deleted_mem(tmp.path(), "other").unwrap();
1522        // Only cross-link grants are reported as scrubbed — never a
1523        // `mem_management.*` rule. Both the deleted mem's own key
1524        // (`other → test`) and the peer value (`test → other`) are
1525        // reported.
1526        assert!(
1527            scrubbed
1528                .iter()
1529                .all(|e| matches!(e, ScrubbedEntry::CrossLink { .. })),
1530            "scrub must report only cross-link grants, got: {scrubbed:?}"
1531        );
1532        assert!(
1533            scrubbed.contains(&ScrubbedEntry::CrossLink {
1534                from: "other".to_string(),
1535                to: "test".to_string(),
1536            }),
1537            "deleted mem's own grant must be reported scrubbed, got: {scrubbed:?}"
1538        );
1539        assert!(
1540            scrubbed.contains(&ScrubbedEntry::CrossLink {
1541                from: "test".to_string(),
1542                to: "other".to_string(),
1543            }),
1544            "peer grant naming the deleted mem must be reported scrubbed, got: {scrubbed:?}"
1545        );
1546        let after = read(tmp.path());
1547        // `other` key removed entirely.
1548        assert!(
1549            !after.contains("\nother = ["),
1550            "`other` key must be scrubbed from cross_mem_links — got:\n{after}"
1551        );
1552        // `other` value removed from `test`'s allowlist; `keep` survives.
1553        assert!(after.contains("\"keep\""), "non-target values must survive");
1554        // The exact-name `pattern = "other"` rules in BOTH
1555        // `[[mem_management.create]]` and `.delete]]` survive — the
1556        // forward-looking permission for the name `other` is preserved.
1557        assert_eq!(
1558            after.matches("pattern = \"other\"").count(),
1559            2,
1560            "exact-name mem_management.{{create,delete}} rules for `other` must survive — got:\n{after}"
1561        );
1562        assert!(
1563            after.contains("pattern = \"*\""),
1564            "wildcard `*` rule must survive"
1565        );
1566        assert!(
1567            after.contains("pattern = \"team/*\""),
1568            "glob `team/*` rule must survive"
1569        );
1570    }
1571
1572    /// Acceptance complement: a refused (or pre-init) workspace.toml
1573    /// shouldn't crash the scrub. The function is best-effort — a
1574    /// missing file is a no-op and surfaces no error.
1575    #[test]
1576    fn scrub_policy_for_deleted_mem_missing_file_is_noop() {
1577        let tmp = TempDir::new().unwrap();
1578        // No `.memstead/workspace.toml` seeded.
1579        let outcome = scrub_policy_for_deleted_mem(tmp.path(), "other");
1580        assert!(outcome.is_ok(), "missing workspace.toml must not error");
1581    }
1582
1583    /// Acceptance complement: a successful delete that doesn't touch
1584    /// any policy entry leaves the file byte-identical (no save).
1585    #[test]
1586    fn scrub_policy_for_deleted_mem_no_match_leaves_file_unchanged() {
1587        let body = "format = \"memstead-git-branch-2\"\n\n\
1588            [cross_mem_links]\n\
1589            test = [\"keep\"]\n\
1590            \n\
1591            [[mem_management.create]]\n\
1592            pattern = \"*\"\n\
1593            schemas = [\"default@1.0.0\"]\n";
1594        let tmp = seed(body);
1595        let before = read(tmp.path());
1596        scrub_policy_for_deleted_mem(tmp.path(), "ghost").unwrap();
1597        let after = read(tmp.path());
1598        assert_eq!(before, after, "unrelated delete must not rewrite the file");
1599    }
1600
1601    /// Acceptance complement: when the only entry in an allowlist
1602    /// names the deleted mem, the underlying key is dropped — same
1603    /// shape as `revoke_cross_link`.
1604    #[test]
1605    fn scrub_policy_for_deleted_mem_drops_emptied_allowlist_key() {
1606        let body = "format = \"memstead-git-branch-2\"\n\n\
1607            [cross_mem_links]\n\
1608            test = [\"other\"]\n";
1609        let tmp = seed(body);
1610        scrub_policy_for_deleted_mem(tmp.path(), "other").unwrap();
1611        let after = read(tmp.path());
1612        assert!(
1613            !after.contains("\ntest = ["),
1614            "key whose allowlist drained to empty must be dropped — got:\n{after}"
1615        );
1616    }
1617}