1use 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 pub fn apply_parse_recovery(
49 &mut self,
50 actor: Actor,
51 client: Option<&ClientId>,
52 note: Option<&str>,
53 ) -> Result<ParseRecoveryReport, EngineError> {
54 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 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 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 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 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 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 #[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 #[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 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 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 #[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 #[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 #[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 #[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}