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 let logical_op_id = crate::provenance::mint_logical_operation_id();
67
68 // Plan first, commit after: collect per-mem rewrite lists
69 // before any backend write so a read failure aborts cleanly.
70 // The renamed mem itself sweeps first (self-references +
71 // anchors move with the mem), then peers in name order.
72 let mut mem_order: Vec<usize> = Vec::new();
73 if let Some(own_idx) = self.mounts.iter().position(|m| {
74 m.mount.mem == old_mem && m.mount.capability == crate::workspace::MountCapability::Write
75 }) {
76 mem_order.push(own_idx);
77 }
78 let mut peer_idxs: Vec<usize> = self
79 .mounts
80 .iter()
81 .enumerate()
82 .filter(|(_, m)| {
83 m.mount.mem != old_mem
84 && m.mount.capability == crate::workspace::MountCapability::Write
85 })
86 .map(|(i, _)| i)
87 .collect();
88 peer_idxs.sort_by(|a, b| self.mounts[*a].mount.mem.cmp(&self.mounts[*b].mount.mem));
89 mem_order.extend(peer_idxs);
90
91 let mut rewritten_mems: Vec<String> = Vec::new();
92 for mount_idx in mem_order {
93 let mem_name = self.mounts[mount_idx].mount.mem.clone();
94 let backend = self.mounts[mount_idx].backend.as_ref();
95
96 // Snapshot the head before planning so the commit is
97 // parent-pinned against concurrent sibling writers.
98 let head_snapshot = backend.current_head()?;
99
100 let mut changed: Vec<(std::path::PathBuf, String)> = Vec::new();
101 for rel_path in backend.list_entities()? {
102 let Some(bytes) = backend.read_entity(&rel_path)? else {
103 continue;
104 };
105 let Ok(text) = String::from_utf8(bytes) else {
106 // Undecodable file — boot already warns about it;
107 // the sweep leaves it alone.
108 continue;
109 };
110 let (rewritten, count) =
111 crate::entity::wikilink_rewrite::rewrite_mem_prefix(&text, old_mem, new_mem);
112 if count > 0 {
113 changed.push((rel_path, rewritten));
114 }
115 }
116
117 // The renamed mem's anchors sidecar: re-key every
118 // `<old>--<slug>` row to `<new>--<slug>` in the same
119 // commit as its content rewrites.
120 let mut anchors_changed = false;
121 let mut sidecar_bytes: Option<Vec<u8>> = None;
122 if mem_name == old_mem {
123 let sidecar_raw = backend.read_anchors_sidecar()?;
124 if let Some(raw) = sidecar_raw {
125 let sidecar = crate::anchor::AnchorSidecar::from_bytes(&raw)
126 .map_err(|e| EngineError::Mem(format!("anchors sidecar parse: {e}")))?;
127 let prefix = format!("{old_mem}--");
128 let mut next = crate::anchor::AnchorSidecar::default();
129 for (entity_id, anchors) in &sidecar.entities {
130 let new_key = match entity_id.strip_prefix(&prefix) {
131 Some(rest) => {
132 anchors_changed = true;
133 format!("{new_mem}--{rest}")
134 }
135 None => entity_id.clone(),
136 };
137 next.set(&new_key, anchors.clone());
138 }
139 if anchors_changed {
140 sidecar_bytes = Some(next.to_bytes());
141 }
142 }
143 }
144
145 if changed.is_empty() && !anchors_changed {
146 continue;
147 }
148
149 for (rel_path, text) in &changed {
150 backend.write_entity(Path::new(rel_path), text.as_bytes())?;
151 }
152 if let Some(bytes) = sidecar_bytes {
153 backend.write_anchors_sidecar(&bytes)?;
154 }
155
156 let subject = format!(
157 "memstead: rename mem `{old_mem}` → `{new_mem}` (reference rewrite in `{mem_name}`)"
158 );
159 let ctx = CommitContext {
160 actor: Actor::Agent,
161 client: None,
162 tool: Some("mem rename"),
163 note: note.map(String::from),
164 role: self.current_role,
165 logical_operation_id: Some(logical_op_id.as_str()),
166 entity_ids: None,
167 };
168 let commit_result =
169 backend.commit_with_expected_parent(&subject, &ctx, head_snapshot.as_deref());
170 let commit_sha = match commit_result {
171 Ok(sha) => sha,
172 Err(crate::backend::BackendError::ParentMismatch { .. }) => {
173 return Err(EngineError::RenamePartialFailure {
174 committed_mems: rewritten_mems,
175 failed_mem: mem_name,
176 failure_cause: "drift".to_string(),
177 });
178 }
179 Err(e) => return Err(e.into()),
180 };
181 self.record_self_write(mount_idx, &commit_sha);
182 self.stamp_mutation_versions(mount_idx);
183 rewritten_mems.push(mem_name);
184 }
185
186 Ok(MemSweepOutcome {
187 rewritten_mems,
188 logical_operation_id: logical_op_id,
189 })
190 }
191}