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        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}