Skip to main content

memstead_base/engine/mutation/
parse_recovery.rs

1//! `Engine::apply_parse_recovery` — bulk-fix path that consumes the
2//! `ParsedRelationRecovery` payload on every writable-origin
3//! `PARSED_RELATION_INVALID` warning and applies the recovery action
4//! in a single operator-initiated call.
5//!
6//! The recovery action `remove_explicit_relation` means: drop the
7//! parse-time-dropped row from the source entity's markdown. The
8//! drop is already reflected in the in-memory `entity.relationships`
9//! (the parse-time validator strips the bad row at boot / reload /
10//! attach), so re-rendering and re-writing the source entity is
11//! enough — the renderer emits `## Relationships` from
12//! `entity.relationships`, so the stale row disappears.
13//!
14//! Multiple drops from the same source entity collapse to one
15//! re-render: `entity.relationships` already excludes every dropped
16//! row, so a single re-write fixes them all. The report still lists
17//! one entry per warning so consumers see exactly which drops were
18//! recovered.
19
20use indexmap::IndexMap;
21
22use crate::entity::EntityId;
23use crate::ops::{ParseRecoveryEntry, ParseRecoveryReport, WarningHint};
24use crate::vcs::{Actor, ClientId};
25
26use super::super::{Engine, EngineError, UpdateEntityArgs};
27
28impl Engine {
29    /// Walk `load_warnings`, dispatch the `remove_explicit_relation`
30    /// recovery for every writable-origin `PARSED_RELATION_INVALID`,
31    /// and report each entry on the response. Read-only-origin
32    /// warnings cannot be acted on (the engine has no write access
33    /// to their source markdown) and surface as
34    /// `outcome: "skipped"` with `reason: "readonly_mount"`.
35    ///
36    /// Failure model: per-entry failures land on the response as
37    /// `outcome: "failed"` with the underlying engine error code in
38    /// `reason`. The bulk-fix continues past per-entry failures so a
39    /// single bad source doesn't strand the rest of the batch. Only
40    /// engine-level errors (reload failure, broken workspace state)
41    /// abort the call and propagate via `Err`.
42    ///
43    /// Idempotency: after the per-source re-renders land, the method
44    /// runs `reload_each_writable_mem` so subsequent calls to
45    /// `health` / `load_warnings` reflect the post-recovery state.
46    /// Re-running on an already-clean workspace returns an empty
47    /// `entries` list with no commits.
48    pub fn apply_parse_recovery(
49        &mut self,
50        actor: Actor,
51        client: Option<&ClientId>,
52        note: Option<&str>,
53    ) -> Result<ParseRecoveryReport, EngineError> {
54        // Recovery's answer scope is every mem's parse drops, and a
55        // deferred (lazy, unloaded) mem records no load warnings until
56        // it loads — full load first, or its drops are invisible to
57        // this pass (load-scope/answer-scope rule, flywheel W7/01).
58        self.ensure_mems_loaded(None);
59        // Snapshot every `PARSED_RELATION_INVALID` warning. We
60        // iterate the snapshot, not `self.load_warnings`, so the
61        // mid-loop `update_entity` calls (which do not touch
62        // `load_warnings`) can't introduce ordering surprises.
63        struct Drop {
64            entity_id: EntityId,
65            rel_type: String,
66            target: EntityId,
67            origin: String,
68        }
69        let drops: Vec<Drop> = self
70            .load_warnings()
71            .iter()
72            .filter_map(|w| match w {
73                WarningHint::ParsedRelationInvalid {
74                    entity_id,
75                    rel_type,
76                    target,
77                    origin,
78                    ..
79                } => Some(Drop {
80                    entity_id: entity_id.clone(),
81                    rel_type: rel_type.clone(),
82                    target: target.clone(),
83                    origin: origin.clone(),
84                }),
85                _ => None,
86            })
87            .collect();
88
89        // Group writable drops by source-entity id so each source is
90        // re-rendered at most once. Iteration over the IndexMap
91        // preserves the order the warnings appeared in.
92        let mut writable_by_source: IndexMap<EntityId, Vec<usize>> = IndexMap::new();
93        let mut readonly_indices: Vec<usize> = Vec::new();
94        for (idx, drop) in drops.iter().enumerate() {
95            if drop.origin == "writable" {
96                writable_by_source
97                    .entry(drop.entity_id.clone())
98                    .or_default()
99                    .push(idx);
100            } else {
101                readonly_indices.push(idx);
102            }
103        }
104
105        let mut entries: Vec<ParseRecoveryEntry> = Vec::with_capacity(drops.len());
106        // Per-drop result slot — populated as the per-source attempts
107        // land. Keyed by the snapshot index so the final `entries`
108        // vec preserves the warning order.
109        let mut result_per_drop: Vec<Option<(String, Option<String>)>> = vec![None; drops.len()];
110
111        for idx in &readonly_indices {
112            result_per_drop[*idx] = Some((
113                ParseRecoveryEntry::OUTCOME_SKIPPED.to_string(),
114                Some(ParseRecoveryEntry::REASON_READONLY_MOUNT.to_string()),
115            ));
116        }
117
118        let mut last_write_id = String::new();
119        for (source_id, drop_indices) in writable_by_source {
120            let outcome = self.rewrite_for_parse_recovery(&source_id, actor, client, note);
121            match outcome {
122                Ok(write_id) => {
123                    if !write_id.is_empty() {
124                        last_write_id = write_id;
125                    }
126                    for idx in drop_indices {
127                        result_per_drop[idx] =
128                            Some((ParseRecoveryEntry::OUTCOME_REMOVED.to_string(), None));
129                    }
130                }
131                Err(err) => {
132                    let code = err.code().to_string();
133                    for idx in drop_indices {
134                        result_per_drop[idx] = Some((
135                            ParseRecoveryEntry::OUTCOME_FAILED.to_string(),
136                            Some(code.clone()),
137                        ));
138                    }
139                }
140            }
141        }
142
143        // Materialise entries in the original warning order.
144        for (idx, drop) in drops.into_iter().enumerate() {
145            let (outcome, reason) = result_per_drop[idx]
146                .take()
147                .expect("every drop should have been classified");
148            entries.push(ParseRecoveryEntry {
149                entity_id: drop.entity_id,
150                rel_type: drop.rel_type,
151                target: drop.target,
152                outcome,
153                reason,
154            });
155        }
156
157        // Reload writable mems so `load_warnings` reflects the
158        // post-recovery state — drops that re-rendered cleanly drop
159        // out; drops that failed (or read-only ones that were always
160        // out of scope) survive.
161        if !entries.is_empty() {
162            self.reload_each_writable_mem()?;
163        }
164
165        Ok(ParseRecoveryReport {
166            entries,
167            write_id: last_write_id,
168        })
169    }
170
171    /// Re-render the source entity and write it back to disk. Calls
172    /// `update_entity` seeding the first section's current body as a
173    /// rewrite anchor — section content stays identical, but the
174    /// full re-render flushes the parse-time relation drops out of
175    /// the auto-managed `## Relationships` section. Returns the
176    /// resulting `write_id` on success.
177    ///
178    /// Section-anchor seeding (instead of an empty payload) keeps
179    /// this internal rewrite path on the public `update_entity`
180    /// surface — the agent-boundary `EMPTY_UPDATE` guard refuses
181    /// empty payloads, and re-using the same guard avoids splitting the engine's
182    /// validation surface for one internal caller. Picking the
183    /// first section is safe because real entities always carry at
184    /// least one schema-required section (a stub would have tripped
185    /// the `StubNotUpdatable` guard before reaching here).
186    fn rewrite_for_parse_recovery(
187        &mut self,
188        source_id: &EntityId,
189        actor: Actor,
190        client: Option<&ClientId>,
191        note: Option<&str>,
192    ) -> Result<String, EngineError> {
193        let entity = self
194            .store()
195            .get(source_id)
196            .ok_or_else(|| EngineError::NotFound {
197                id: source_id.to_string(),
198            })?;
199        let expected_hash = entity.content_hash.clone();
200        let mut sections: IndexMap<String, String> = IndexMap::new();
201        if let Some((key, body)) = entity.sections.iter().next() {
202            sections.insert(key.clone(), body.clone());
203        }
204        let args = UpdateEntityArgs {
205            anchors: Vec::new(),
206            id: source_id.clone(),
207            expected_hash: Some(expected_hash),
208            sections,
209            append_sections: IndexMap::new(),
210            patch_sections: IndexMap::new(),
211            sections_unset: Vec::new(),
212            metadata: IndexMap::new(),
213            metadata_unset: Vec::new(),
214            dry_run: false,
215            declare_relations: Vec::new(),
216            relations_unset: Vec::new(),
217            anchors_unset: Vec::new(),
218        };
219        let outcome = self.update_entity(args, actor, client, note)?;
220        Ok(outcome.write_id)
221    }
222}
223
224#[cfg(test)]
225mod tests {
226    use tempfile::TempDir;
227
228    use crate::backend::MemBackend;
229    use crate::engine::Engine;
230    use crate::engine::test_helpers::{
231        archive_mount, build_archive, cli_actor, folder_mount, write_schema_files_with_default_type,
232    };
233    use crate::ops::{ParseRecoveryEntry, WarningHint};
234    use crate::storage::{ArchiveBackend, FilesystemMemWriter};
235    use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
236
237    use memstead_schema::SchemaRef;
238
239    /// Two writable parse-time drops on the same source collapse to
240    /// one re-render. Both entries land on the response as
241    /// `removed`; the on-disk markdown no longer carries the bad
242    /// rows; `load_warnings` is empty after the call.
243    #[test]
244    fn apply_parse_recovery_clears_writable_drops_in_one_call() {
245        let tmp = TempDir::new().unwrap();
246        let mem_dir = tmp.path().to_path_buf();
247        let target = "---\ntype: spec\n---\n# Target\n\n## Identity\n\nTarget body.\n";
248        let source = "---\ntype: spec\n---\n# Source\n\n## Identity\n\nSource body.\n\n## Relationships\n\n- **MADE_UP_TYPE_A**: [[specs--target]]\n- **MADE_UP_TYPE_B**: [[specs--target]]\n";
249        std::fs::write(mem_dir.join("target.md"), target).unwrap();
250        std::fs::write(mem_dir.join("source.md"), source).unwrap();
251
252        let writer = FilesystemMemWriter::new(mem_dir.clone());
253        let mut engine = Engine::from_mounts(vec![(
254            folder_mount("specs", mem_dir.clone()),
255            Box::new(writer) as Box<dyn MemBackend>,
256        )])
257        .unwrap();
258
259        let pre: Vec<_> = engine
260            .load_warnings()
261            .iter()
262            .filter(|w| matches!(w, WarningHint::ParsedRelationInvalid { .. }))
263            .collect();
264        assert_eq!(pre.len(), 2, "expected two parse-time drops, got {pre:?}");
265
266        let (actor, client) = cli_actor();
267        let report = engine
268            .apply_parse_recovery(actor, Some(&client), Some("recovery"))
269            .expect("recovery succeeds");
270
271        assert_eq!(report.entries.len(), 2);
272        for entry in &report.entries {
273            assert_eq!(
274                entry.outcome,
275                ParseRecoveryEntry::OUTCOME_REMOVED,
276                "expected both writable drops removed, got {entry:?}",
277            );
278            assert!(entry.reason.is_none());
279        }
280        assert!(!report.write_id.is_empty(), "recovery must commit");
281
282        let post: Vec<_> = engine
283            .load_warnings()
284            .iter()
285            .filter(|w| matches!(w, WarningHint::ParsedRelationInvalid { .. }))
286            .collect();
287        assert!(post.is_empty(), "drops must be cleared, got {post:?}");
288
289        let cleaned = std::fs::read_to_string(mem_dir.join("source.md")).unwrap();
290        assert!(
291            !cleaned.contains("MADE_UP_TYPE_A"),
292            "cleaned source: {cleaned}"
293        );
294        assert!(
295            !cleaned.contains("MADE_UP_TYPE_B"),
296            "cleaned source: {cleaned}"
297        );
298    }
299
300    /// Recover leg (criterion 5): `apply_parse_recovery` re-renders the
301    /// source entity but leaves its anchors sidecar bit-intact — the
302    /// re-render is an anchorless update, which never stages the sidecar.
303    #[test]
304    fn apply_parse_recovery_leaves_anchors_bit_intact() {
305        let tmp = TempDir::new().unwrap();
306        let mem_dir = tmp.path().to_path_buf();
307        let target = "---\ntype: spec\n---\n# Target\n\n## Identity\n\nTarget body.\n";
308        let source = "---\ntype: spec\n---\n# Source\n\n## Identity\n\nSource body.\n\n## Relationships\n\n- **MADE_UP_TYPE**: [[specs--target]]\n";
309        std::fs::write(mem_dir.join("target.md"), target).unwrap();
310        std::fs::write(mem_dir.join("source.md"), source).unwrap();
311
312        // Seed an anchors sidecar for the source entity directly on disk (the
313        // recovery path must not touch it).
314        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
315        let sidecar_path = mem_dir.join(".memstead").join("anchors.json");
316        let sidecar = br#"{"version":1,"entities":{"specs--source":[{"artifact":"src/lib.rs","grain":"file","class":"anchored","hash_stability":"stable","hash":"h1"}]}}"#;
317        std::fs::write(&sidecar_path, sidecar).unwrap();
318        let before = std::fs::read(&sidecar_path).unwrap();
319
320        let writer = FilesystemMemWriter::new(mem_dir.clone());
321        let mut engine = Engine::from_mounts(vec![(
322            folder_mount("specs", mem_dir.clone()),
323            Box::new(writer) as Box<dyn MemBackend>,
324        )])
325        .unwrap();
326
327        let (actor, client) = cli_actor();
328        let report = engine
329            .apply_parse_recovery(actor, Some(&client), Some("recovery"))
330            .expect("recovery succeeds");
331        assert_eq!(report.entries.len(), 1);
332        assert_eq!(
333            report.entries[0].outcome,
334            ParseRecoveryEntry::OUTCOME_REMOVED
335        );
336
337        // The sidecar file is byte-identical, and the anchor still resolves.
338        let after = std::fs::read(&sidecar_path).unwrap();
339        assert_eq!(before, after, "recovery must leave anchors bit-intact");
340        let anchors = engine.entity_anchors(&crate::EntityId::new("specs", "source"));
341        assert_eq!(anchors.len(), 1);
342        assert_eq!(anchors[0].artifact, "src/lib.rs");
343    }
344
345    /// Read-only-origin drops are reported as `skipped` with
346    /// `reason: "readonly_mount"`. The engine cannot rewrite an
347    /// archive, so the warning survives the call.
348    #[test]
349    fn apply_parse_recovery_skips_readonly_origin_drops() {
350        let tmp = TempDir::new().unwrap();
351        let target = "---\ntype: spec\n---\n# Target\n\n## Identity\n\nTarget.\n";
352        let source = "---\ntype: spec\n---\n# Source\n\n## Identity\n\nSource.\n\n## Relationships\n\n- **MADE_UP**: [[external--target]]\n";
353        let archive_path = build_archive(
354            tmp.path(),
355            "ext",
356            &[
357                ("target.md", target.as_bytes()),
358                ("source.md", source.as_bytes()),
359            ],
360        );
361
362        let mut engine = Engine::from_mounts(vec![(
363            archive_mount("external", archive_path.clone()),
364            Box::new(ArchiveBackend::new(archive_path)),
365        )])
366        .unwrap();
367
368        let (actor, client) = cli_actor();
369        let report = engine
370            .apply_parse_recovery(actor, Some(&client), None)
371            .expect("recovery succeeds");
372
373        assert_eq!(report.entries.len(), 1);
374        let entry = &report.entries[0];
375        assert_eq!(entry.outcome, ParseRecoveryEntry::OUTCOME_SKIPPED);
376        assert_eq!(
377            entry.reason.as_deref(),
378            Some(ParseRecoveryEntry::REASON_READONLY_MOUNT),
379        );
380        assert!(report.write_id.is_empty(), "readonly path commits nothing");
381
382        let post: Vec<_> = engine
383            .load_warnings()
384            .iter()
385            .filter(|w| matches!(w, WarningHint::ParsedRelationInvalid { .. }))
386            .collect();
387        assert_eq!(post.len(), 1, "readonly drop must persist, got {post:?}");
388    }
389
390    /// Re-running on an already-clean workspace returns an empty
391    /// entries list and produces no commits.
392    #[test]
393    fn apply_parse_recovery_is_idempotent_after_clean_state() {
394        let tmp = TempDir::new().unwrap();
395        let mem_dir = tmp.path().to_path_buf();
396        let target = "---\ntype: spec\n---\n# Target\n\n## Identity\n\nTarget.\n";
397        let source = "---\ntype: spec\n---\n# Source\n\n## Identity\n\nSource.\n\n## Relationships\n\n- **MADE_UP_TYPE**: [[specs--target]]\n";
398        std::fs::write(mem_dir.join("target.md"), target).unwrap();
399        std::fs::write(mem_dir.join("source.md"), source).unwrap();
400
401        let writer = FilesystemMemWriter::new(mem_dir.clone());
402        let mut engine = Engine::from_mounts(vec![(
403            folder_mount("specs", mem_dir),
404            Box::new(writer) as Box<dyn MemBackend>,
405        )])
406        .unwrap();
407
408        let (actor, client) = cli_actor();
409        let first = engine
410            .apply_parse_recovery(actor, Some(&client), None)
411            .expect("first recovery succeeds");
412        assert_eq!(first.entries.len(), 1);
413        assert_eq!(
414            first.entries[0].outcome,
415            ParseRecoveryEntry::OUTCOME_REMOVED
416        );
417        assert!(!first.write_id.is_empty());
418
419        let second = engine
420            .apply_parse_recovery(actor, Some(&client), None)
421            .expect("second recovery succeeds");
422        assert!(
423            second.entries.is_empty(),
424            "second call must be no-op, got {:?}",
425            second.entries
426        );
427        assert!(second.write_id.is_empty());
428    }
429
430    /// Mixed writable + readonly drops land in a single report.
431    #[test]
432    fn apply_parse_recovery_reports_per_warning_across_origins() {
433        let tmp = TempDir::new().unwrap();
434
435        let writable_dir = tmp.path().join("writable");
436        std::fs::create_dir_all(&writable_dir).unwrap();
437        let w_target = "---\ntype: spec\n---\n# WT\n\n## Identity\n\nwt\n";
438        let w_source = "---\ntype: spec\n---\n# WS\n\n## Identity\n\nws\n\n## Relationships\n\n- **MADE_UP_A**: [[specs--target]]\n- **MADE_UP_B**: [[specs--target]]\n";
439        std::fs::write(writable_dir.join("target.md"), w_target).unwrap();
440        std::fs::write(writable_dir.join("source.md"), w_source).unwrap();
441
442        let r_target = "---\ntype: spec\n---\n# RT\n\n## Identity\n\nrt\n";
443        let r_source = "---\ntype: spec\n---\n# RS\n\n## Identity\n\nrs\n\n## Relationships\n\n- **MADE_UP_RO**: [[external--target]]\n";
444        let archive_path = build_archive(
445            tmp.path(),
446            "ext",
447            &[
448                ("target.md", r_target.as_bytes()),
449                ("source.md", r_source.as_bytes()),
450            ],
451        );
452
453        let writer = FilesystemMemWriter::new(writable_dir.clone());
454        let mut engine = Engine::from_mounts(vec![
455            (
456                folder_mount("specs", writable_dir),
457                Box::new(writer) as Box<dyn MemBackend>,
458            ),
459            (
460                archive_mount("external", archive_path.clone()),
461                Box::new(ArchiveBackend::new(archive_path)),
462            ),
463        ])
464        .unwrap();
465
466        let (actor, client) = cli_actor();
467        let report = engine
468            .apply_parse_recovery(actor, Some(&client), None)
469            .expect("recovery succeeds");
470
471        assert_eq!(report.entries.len(), 3);
472        let removed: Vec<_> = report
473            .entries
474            .iter()
475            .filter(|e| e.outcome == ParseRecoveryEntry::OUTCOME_REMOVED)
476            .collect();
477        let skipped: Vec<_> = report
478            .entries
479            .iter()
480            .filter(|e| e.outcome == ParseRecoveryEntry::OUTCOME_SKIPPED)
481            .collect();
482        assert_eq!(removed.len(), 2);
483        assert_eq!(skipped.len(), 1);
484        assert_eq!(
485            skipped[0].reason.as_deref(),
486            Some(ParseRecoveryEntry::REASON_READONLY_MOUNT),
487        );
488        assert!(!report.write_id.is_empty());
489    }
490
491    /// A drop whose source still has an unresolved body wiki-link to
492    /// the dropped target lands as `failed` with
493    /// `WIKILINK_WITHOUT_RELATION`. The strict validator refuses to
494    /// leave a body wiki-link unbacked by any relation; the
495    /// operator's recovery is to also remove the body reference.
496    #[test]
497    fn apply_parse_recovery_reports_failed_for_unbacked_body_link() {
498        let tmp = TempDir::new().unwrap();
499        let schemas_dir = tmp.path().join("schemas");
500        std::fs::create_dir_all(&schemas_dir).unwrap();
501        let manifest = r#"name: link-test
502version: 0.1.0
503description: schema for wikilink-blocker test
504when_to_use: tests
505types:
506  - doc
507relationships:
508  mode: strict
509  definitions:
510    - name: MENTIONS
511      description: doc references doc
512      default_weight: 1.0
513    - name: _default
514      description: fallback
515      default_weight: 1.0
516community:
517  resolution: 1.0
518  seed: 42
519"#;
520        write_schema_files_with_default_type(&schemas_dir, "link-test", manifest, &["doc"]);
521
522        let mem_dir = tmp.path().join("mem");
523        std::fs::create_dir_all(&mem_dir).unwrap();
524        let target = "---\ntype: doc\n---\n# Target\n\n## Body\n\nbody\n";
525        let source = "---\ntype: doc\n---\n# Source\n\n## Body\n\nrefer to [[specs--target]] here\n\n## Relationships\n\n- **BADTYPE**: [[specs--target]]\n";
526        std::fs::write(mem_dir.join("target.md"), target).unwrap();
527        std::fs::write(mem_dir.join("source.md"), source).unwrap();
528
529        let writer = FilesystemMemWriter::new(mem_dir.clone());
530        let pin = SchemaRef::new("link-test", semver::Version::new(0, 1, 0));
531        let mount = Mount {
532            mem: "specs".to_string(),
533            schema: Some(pin),
534            storage: MountStorage::Folder {
535                path: mem_dir.clone(),
536            },
537            capability: MountCapability::Write,
538            lifecycle: MountLifecycle::Eager,
539            cross_linkable: true,
540            migration_target: None,
541        };
542        let mut engine = Engine::from_mounts_with_schemas_dir(
543            vec![(mount, Box::new(writer) as Box<dyn MemBackend>)],
544            Some(&schemas_dir),
545        )
546        .unwrap();
547
548        let (actor, client) = cli_actor();
549        let report = engine
550            .apply_parse_recovery(actor, Some(&client), None)
551            .expect("recovery returns Ok even when entries fail");
552
553        assert_eq!(report.entries.len(), 1);
554        let entry = &report.entries[0];
555        assert_eq!(entry.outcome, ParseRecoveryEntry::OUTCOME_FAILED);
556        assert_eq!(
557            entry.reason.as_deref(),
558            Some("WIKILINK_WITHOUT_RELATION"),
559            "expected the strict validator's typed code, got {:?}",
560            entry.reason,
561        );
562        let unchanged = std::fs::read_to_string(mem_dir.join("source.md")).unwrap();
563        assert!(
564            unchanged.contains("BADTYPE"),
565            "source must be unchanged on failure"
566        );
567
568        let post: Vec<_> = engine
569            .load_warnings()
570            .iter()
571            .filter(|w| matches!(w, WarningHint::ParsedRelationInvalid { .. }))
572            .collect();
573        assert_eq!(post.len(), 1, "failed drop must persist, got {post:?}");
574    }
575}