Skip to main content

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}