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 self.ensure_mems_loaded(None);
59 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 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 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 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 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 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 #[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 #[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 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 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 #[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 #[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 #[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 #[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}