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