memstead_base/engine/mutation/mem_sweep.rs
1//! Mem-rename content sweep — the referrer-rewrite half of
2//! `memstead mem rename`.
3//!
4//! Entity ids are derived from `(mount name, file path)`, so renaming a
5//! mount re-ids every entity in it for free; what the rename must
6//! rewrite by hand is every **textual** `<old>--<slug>` / `<old>:<slug>`
7//! reference across the workspace — cross-mem wiki-links and
8//! `## Relationships` entries in peer mems, full-id self-references in
9//! the renamed mem itself, and the renamed mem's anchors-sidecar keys.
10//!
11//! The sweep is a raw-text pass over each entity file
12//! ([`crate::entity::wikilink_rewrite::rewrite_mem_prefix`], sharing the
13//! parser's code-masking discipline) rather than a parse → mutate →
14//! regenerate round-trip: formatting outside the rewritten links stays
15//! byte-identical, and both link surfaces (bodies and the
16//! `## Relationships` section) are covered by the one pass because both
17//! are wiki-links.
18//!
19//! One commit per affected mem, every commit tagged with a shared
20//! `logical_operation_id`. Peer commits are parent-pinned
21//! (`commit_with_expected_parent`) so a concurrent sibling writer
22//! surfaces as [`EngineError::RenamePartialFailure`] rather than a
23//! silent overwrite. Read-only mounts cannot be rewritten — their stale
24//! references degrade to load-time stubs, which health surfaces.
25//!
26//! The sweep deliberately does NOT maintain the in-memory store — the
27//! orchestrator (`memstead_engine::rename_mem`) reloads the engine
28//! after the storage-identity flip, and everything between the sweep
29//! and that reload happens inside one engine call.
30
31use std::path::Path;
32
33use crate::engine::{Engine, EngineError};
34use crate::vcs::{Actor, CommitContext};
35
36/// Outcome of [`Engine::rewrite_mem_references`].
37#[derive(Debug, Clone)]
38pub struct MemSweepOutcome {
39 /// Mems whose entity files were rewritten and committed, in commit
40 /// order (the renamed mem itself first when it had self-references
41 /// or anchors, then peers sorted by name).
42 pub rewritten_mems: Vec<String>,
43 /// The shared `logical_operation_id` carried by every commit.
44 pub logical_operation_id: String,
45}
46
47impl Engine {
48 /// Rewrite every textual reference to mem `old_mem` so it carries
49 /// `new_mem` instead: cross-mem wiki-links and Relationships
50 /// entries in every writable peer mem, full-id self-references
51 /// inside `old_mem` itself, and `old_mem`'s anchors-sidecar keys
52 /// (`<old>--<slug>` → `<new>--<slug>`). One commit per affected
53 /// mem; unaffected mems get no commit. Idempotent: a second run
54 /// finds nothing left to rewrite and commits nothing — which is
55 /// exactly what makes an interrupted `mem rename` completable by
56 /// re-issuing it.
57 ///
58 /// Read-only mounts are skipped (no write access); their stale
59 /// references surface as load-time stubs on the next boot.
60 pub fn rewrite_mem_references(
61 &mut self,
62 old_mem: &str,
63 new_mem: &str,
64 note: Option<&str>,
65 ) -> Result<MemSweepOutcome, EngineError> {
66 // The sweep's answer scope is the whole workspace — every mem
67 // that references `old_mem` must be rewritten — so its load
68 // scope must match: a reference inside a deferred (lazy,
69 // unloaded) mem would otherwise survive the rename unrewritten
70 // (load-scope/answer-scope rule, flywheel W7/01).
71 self.ensure_mems_loaded(None);
72 let logical_op_id = crate::provenance::mint_logical_operation_id();
73
74 // Plan first, commit after: collect per-mem rewrite lists
75 // before any backend write so a read failure aborts cleanly.
76 // The renamed mem itself sweeps first (self-references +
77 // anchors move with the mem), then peers in name order.
78 let mut mem_order: Vec<usize> = Vec::new();
79 if let Some(own_idx) = self.mounts.iter().position(|m| {
80 m.mount.mem == old_mem && m.mount.capability == crate::workspace::MountCapability::Write
81 }) {
82 mem_order.push(own_idx);
83 }
84 let mut peer_idxs: Vec<usize> = self
85 .mounts
86 .iter()
87 .enumerate()
88 .filter(|(_, m)| {
89 m.mount.mem != old_mem
90 && m.mount.capability == crate::workspace::MountCapability::Write
91 })
92 .map(|(i, _)| i)
93 .collect();
94 peer_idxs.sort_by(|a, b| self.mounts[*a].mount.mem.cmp(&self.mounts[*b].mount.mem));
95 mem_order.extend(peer_idxs);
96
97 let mut rewritten_mems: Vec<String> = Vec::new();
98 for mount_idx in mem_order {
99 let mem_name = self.mounts[mount_idx].mount.mem.clone();
100 let backend = self.mounts[mount_idx].backend.as_ref();
101
102 // Snapshot the head before planning so the commit is
103 // parent-pinned against concurrent sibling writers.
104 let head_snapshot = backend.current_head()?;
105
106 let mut changed: Vec<(std::path::PathBuf, String)> = Vec::new();
107 for rel_path in backend.list_entities()? {
108 let Some(bytes) = backend.read_entity(&rel_path)? else {
109 continue;
110 };
111 let Ok(text) = String::from_utf8(bytes) else {
112 // Undecodable file — boot already warns about it;
113 // the sweep leaves it alone.
114 continue;
115 };
116 let (rewritten, count) =
117 crate::entity::wikilink_rewrite::rewrite_mem_prefix(&text, old_mem, new_mem);
118 if count > 0 {
119 changed.push((rel_path, rewritten));
120 }
121 }
122
123 // The renamed mem's anchors sidecar: re-key every
124 // `<old>--<slug>` row to `<new>--<slug>` in the same
125 // commit as its content rewrites.
126 let mut anchors_changed = false;
127 let mut sidecar_bytes: Option<Vec<u8>> = None;
128 if mem_name == old_mem {
129 let sidecar_raw = backend.read_anchors_sidecar()?;
130 if let Some(raw) = sidecar_raw {
131 let sidecar = crate::anchor::AnchorSidecar::from_bytes(&raw)
132 .map_err(|e| EngineError::Mem(format!("anchors sidecar parse: {e}")))?;
133 let prefix = format!("{old_mem}--");
134 let mut next = crate::anchor::AnchorSidecar::default();
135 for (entity_id, anchors) in &sidecar.entities {
136 let new_key = match entity_id.strip_prefix(&prefix) {
137 Some(rest) => {
138 anchors_changed = true;
139 format!("{new_mem}--{rest}")
140 }
141 None => entity_id.clone(),
142 };
143 next.set(&new_key, anchors.clone());
144 }
145 if anchors_changed {
146 sidecar_bytes = Some(next.to_bytes());
147 }
148 }
149 }
150
151 if changed.is_empty() && !anchors_changed {
152 continue;
153 }
154
155 for (rel_path, text) in &changed {
156 backend.write_entity(Path::new(rel_path), text.as_bytes())?;
157 }
158 if let Some(bytes) = sidecar_bytes {
159 backend.write_anchors_sidecar(&bytes)?;
160 }
161
162 let subject = format!(
163 "memstead: rename mem `{old_mem}` → `{new_mem}` (reference rewrite in `{mem_name}`)"
164 );
165 let ctx = CommitContext {
166 actor: Actor::Agent,
167 client: None,
168 tool: Some("mem rename"),
169 note: note.map(String::from),
170 role: self.current_role,
171 logical_operation_id: Some(logical_op_id.as_str()),
172 entity_ids: None,
173 };
174 let commit_result =
175 backend.commit_with_expected_parent(&subject, &ctx, head_snapshot.as_deref());
176 let commit_sha = match commit_result {
177 Ok(sha) => sha,
178 Err(crate::backend::BackendError::ParentMismatch { .. }) => {
179 return Err(EngineError::RenamePartialFailure {
180 committed_mems: rewritten_mems,
181 failed_mem: mem_name,
182 failure_cause: "drift".to_string(),
183 });
184 }
185 Err(e) => return Err(e.into()),
186 };
187 self.record_self_write(mount_idx, &commit_sha);
188 self.stamp_mutation_versions(mount_idx);
189 rewritten_mems.push(mem_name);
190 }
191
192 Ok(MemSweepOutcome {
193 rewritten_mems,
194 logical_operation_id: logical_op_id,
195 })
196 }
197}