use indexmap::IndexMap;
use crate::entity::EntityId;
use crate::ops::{ParseRecoveryEntry, ParseRecoveryReport, WarningHint};
use crate::vcs::{Actor, ClientId};
use super::super::{Engine, EngineError, UpdateEntityArgs};
impl Engine {
pub fn apply_parse_recovery(
&mut self,
actor: Actor,
client: Option<&ClientId>,
note: Option<&str>,
) -> Result<ParseRecoveryReport, EngineError> {
struct Drop {
entity_id: EntityId,
rel_type: String,
target: EntityId,
origin: String,
}
let drops: Vec<Drop> = self
.load_warnings()
.iter()
.filter_map(|w| match w {
WarningHint::ParsedRelationInvalid {
entity_id,
rel_type,
target,
origin,
..
} => Some(Drop {
entity_id: entity_id.clone(),
rel_type: rel_type.clone(),
target: target.clone(),
origin: origin.clone(),
}),
_ => None,
})
.collect();
let mut writable_by_source: IndexMap<EntityId, Vec<usize>> = IndexMap::new();
let mut readonly_indices: Vec<usize> = Vec::new();
for (idx, drop) in drops.iter().enumerate() {
if drop.origin == "writable" {
writable_by_source
.entry(drop.entity_id.clone())
.or_default()
.push(idx);
} else {
readonly_indices.push(idx);
}
}
let mut entries: Vec<ParseRecoveryEntry> = Vec::with_capacity(drops.len());
let mut result_per_drop: Vec<Option<(String, Option<String>)>> = vec![None; drops.len()];
for idx in &readonly_indices {
result_per_drop[*idx] = Some((
ParseRecoveryEntry::OUTCOME_SKIPPED.to_string(),
Some(ParseRecoveryEntry::REASON_READONLY_MOUNT.to_string()),
));
}
let mut last_commit_sha = String::new();
for (source_id, drop_indices) in writable_by_source {
let outcome = self.rewrite_for_parse_recovery(&source_id, actor, client, note);
match outcome {
Ok(commit_sha) => {
if !commit_sha.is_empty() {
last_commit_sha = commit_sha;
}
for idx in drop_indices {
result_per_drop[idx] =
Some((ParseRecoveryEntry::OUTCOME_REMOVED.to_string(), None));
}
}
Err(err) => {
let code = err.code().to_string();
for idx in drop_indices {
result_per_drop[idx] = Some((
ParseRecoveryEntry::OUTCOME_FAILED.to_string(),
Some(code.clone()),
));
}
}
}
}
for (idx, drop) in drops.into_iter().enumerate() {
let (outcome, reason) = result_per_drop[idx]
.take()
.expect("every drop should have been classified");
entries.push(ParseRecoveryEntry {
entity_id: drop.entity_id,
rel_type: drop.rel_type,
target: drop.target,
outcome,
reason,
});
}
if !entries.is_empty() {
self.reload_each_writable_mem()?;
}
Ok(ParseRecoveryReport {
entries,
commit_sha: last_commit_sha,
})
}
fn rewrite_for_parse_recovery(
&mut self,
source_id: &EntityId,
actor: Actor,
client: Option<&ClientId>,
note: Option<&str>,
) -> Result<String, EngineError> {
let entity = self
.store()
.get(source_id)
.ok_or_else(|| EngineError::NotFound {
id: source_id.to_string(),
})?;
let expected_hash = entity.content_hash.clone();
let mut sections: IndexMap<String, String> = IndexMap::new();
if let Some((key, body)) = entity.sections.iter().next() {
sections.insert(key.clone(), body.clone());
}
let args = UpdateEntityArgs {
anchors: Vec::new(),
id: source_id.clone(),
expected_hash: Some(expected_hash),
sections,
append_sections: IndexMap::new(),
patch_sections: IndexMap::new(),
metadata: IndexMap::new(),
metadata_unset: Vec::new(),
dry_run: false,
declare_relations: Vec::new(),
relations_unset: Vec::new(),
};
let outcome = self.update_entity(args, actor, client, note)?;
Ok(outcome.commit_sha)
}
}
#[cfg(test)]
mod tests {
use tempfile::TempDir;
use crate::backend::MemBackend;
use crate::engine::Engine;
use crate::engine::test_helpers::{
archive_mount, build_archive, cli_actor, folder_mount, write_schema_files_with_default_type,
};
use crate::ops::{ParseRecoveryEntry, WarningHint};
use crate::storage::{ArchiveBackend, FilesystemMemWriter};
use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
use memstead_schema::SchemaRef;
#[test]
fn apply_parse_recovery_clears_writable_drops_in_one_call() {
let tmp = TempDir::new().unwrap();
let mem_dir = tmp.path().to_path_buf();
let target = "---\ntype: spec\n---\n# Target\n\n## Identity\n\nTarget body.\n";
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";
std::fs::write(mem_dir.join("target.md"), target).unwrap();
std::fs::write(mem_dir.join("source.md"), source).unwrap();
let writer = FilesystemMemWriter::new(mem_dir.clone());
let mut engine = Engine::from_mounts(vec![(
folder_mount("specs", mem_dir.clone()),
Box::new(writer) as Box<dyn MemBackend>,
)])
.unwrap();
let pre: Vec<_> = engine
.load_warnings()
.iter()
.filter(|w| matches!(w, WarningHint::ParsedRelationInvalid { .. }))
.collect();
assert_eq!(pre.len(), 2, "expected two parse-time drops, got {pre:?}");
let (actor, client) = cli_actor();
let report = engine
.apply_parse_recovery(actor, Some(&client), Some("recovery"))
.expect("recovery succeeds");
assert_eq!(report.entries.len(), 2);
for entry in &report.entries {
assert_eq!(
entry.outcome,
ParseRecoveryEntry::OUTCOME_REMOVED,
"expected both writable drops removed, got {entry:?}",
);
assert!(entry.reason.is_none());
}
assert!(!report.commit_sha.is_empty(), "recovery must commit");
let post: Vec<_> = engine
.load_warnings()
.iter()
.filter(|w| matches!(w, WarningHint::ParsedRelationInvalid { .. }))
.collect();
assert!(post.is_empty(), "drops must be cleared, got {post:?}");
let cleaned = std::fs::read_to_string(mem_dir.join("source.md")).unwrap();
assert!(
!cleaned.contains("MADE_UP_TYPE_A"),
"cleaned source: {cleaned}"
);
assert!(
!cleaned.contains("MADE_UP_TYPE_B"),
"cleaned source: {cleaned}"
);
}
#[test]
fn apply_parse_recovery_leaves_anchors_bit_intact() {
let tmp = TempDir::new().unwrap();
let mem_dir = tmp.path().to_path_buf();
let target = "---\ntype: spec\n---\n# Target\n\n## Identity\n\nTarget body.\n";
let source = "---\ntype: spec\n---\n# Source\n\n## Identity\n\nSource body.\n\n## Relationships\n\n- **MADE_UP_TYPE**: [[specs--target]]\n";
std::fs::write(mem_dir.join("target.md"), target).unwrap();
std::fs::write(mem_dir.join("source.md"), source).unwrap();
std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
let sidecar_path = mem_dir.join(".memstead").join("anchors.json");
let sidecar = br#"{"version":1,"entities":{"specs--source":[{"artifact":"src/lib.rs","grain":"file","class":"anchored","hash_stability":"stable","hash":"h1"}]}}"#;
std::fs::write(&sidecar_path, sidecar).unwrap();
let before = std::fs::read(&sidecar_path).unwrap();
let writer = FilesystemMemWriter::new(mem_dir.clone());
let mut engine = Engine::from_mounts(vec![(
folder_mount("specs", mem_dir.clone()),
Box::new(writer) as Box<dyn MemBackend>,
)])
.unwrap();
let (actor, client) = cli_actor();
let report = engine
.apply_parse_recovery(actor, Some(&client), Some("recovery"))
.expect("recovery succeeds");
assert_eq!(report.entries.len(), 1);
assert_eq!(
report.entries[0].outcome,
ParseRecoveryEntry::OUTCOME_REMOVED
);
let after = std::fs::read(&sidecar_path).unwrap();
assert_eq!(before, after, "recovery must leave anchors bit-intact");
let anchors = engine.entity_anchors(&crate::EntityId::new("specs", "source"));
assert_eq!(anchors.len(), 1);
assert_eq!(anchors[0].artifact, "src/lib.rs");
}
#[test]
fn apply_parse_recovery_skips_readonly_origin_drops() {
let tmp = TempDir::new().unwrap();
let target = "---\ntype: spec\n---\n# Target\n\n## Identity\n\nTarget.\n";
let source = "---\ntype: spec\n---\n# Source\n\n## Identity\n\nSource.\n\n## Relationships\n\n- **MADE_UP**: [[external--target]]\n";
let archive_path = build_archive(
tmp.path(),
"ext",
&[
("target.md", target.as_bytes()),
("source.md", source.as_bytes()),
],
);
let mut engine = Engine::from_mounts(vec![(
archive_mount("external", archive_path.clone()),
Box::new(ArchiveBackend::new(archive_path)),
)])
.unwrap();
let (actor, client) = cli_actor();
let report = engine
.apply_parse_recovery(actor, Some(&client), None)
.expect("recovery succeeds");
assert_eq!(report.entries.len(), 1);
let entry = &report.entries[0];
assert_eq!(entry.outcome, ParseRecoveryEntry::OUTCOME_SKIPPED);
assert_eq!(
entry.reason.as_deref(),
Some(ParseRecoveryEntry::REASON_READONLY_MOUNT),
);
assert!(
report.commit_sha.is_empty(),
"readonly path commits nothing"
);
let post: Vec<_> = engine
.load_warnings()
.iter()
.filter(|w| matches!(w, WarningHint::ParsedRelationInvalid { .. }))
.collect();
assert_eq!(post.len(), 1, "readonly drop must persist, got {post:?}");
}
#[test]
fn apply_parse_recovery_is_idempotent_after_clean_state() {
let tmp = TempDir::new().unwrap();
let mem_dir = tmp.path().to_path_buf();
let target = "---\ntype: spec\n---\n# Target\n\n## Identity\n\nTarget.\n";
let source = "---\ntype: spec\n---\n# Source\n\n## Identity\n\nSource.\n\n## Relationships\n\n- **MADE_UP_TYPE**: [[specs--target]]\n";
std::fs::write(mem_dir.join("target.md"), target).unwrap();
std::fs::write(mem_dir.join("source.md"), source).unwrap();
let writer = FilesystemMemWriter::new(mem_dir.clone());
let mut engine = Engine::from_mounts(vec![(
folder_mount("specs", mem_dir),
Box::new(writer) as Box<dyn MemBackend>,
)])
.unwrap();
let (actor, client) = cli_actor();
let first = engine
.apply_parse_recovery(actor, Some(&client), None)
.expect("first recovery succeeds");
assert_eq!(first.entries.len(), 1);
assert_eq!(
first.entries[0].outcome,
ParseRecoveryEntry::OUTCOME_REMOVED
);
assert!(!first.commit_sha.is_empty());
let second = engine
.apply_parse_recovery(actor, Some(&client), None)
.expect("second recovery succeeds");
assert!(
second.entries.is_empty(),
"second call must be no-op, got {:?}",
second.entries
);
assert!(second.commit_sha.is_empty());
}
#[test]
fn apply_parse_recovery_reports_per_warning_across_origins() {
let tmp = TempDir::new().unwrap();
let writable_dir = tmp.path().join("writable");
std::fs::create_dir_all(&writable_dir).unwrap();
let w_target = "---\ntype: spec\n---\n# WT\n\n## Identity\n\nwt\n";
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";
std::fs::write(writable_dir.join("target.md"), w_target).unwrap();
std::fs::write(writable_dir.join("source.md"), w_source).unwrap();
let r_target = "---\ntype: spec\n---\n# RT\n\n## Identity\n\nrt\n";
let r_source = "---\ntype: spec\n---\n# RS\n\n## Identity\n\nrs\n\n## Relationships\n\n- **MADE_UP_RO**: [[external--target]]\n";
let archive_path = build_archive(
tmp.path(),
"ext",
&[
("target.md", r_target.as_bytes()),
("source.md", r_source.as_bytes()),
],
);
let writer = FilesystemMemWriter::new(writable_dir.clone());
let mut engine = Engine::from_mounts(vec![
(
folder_mount("specs", writable_dir),
Box::new(writer) as Box<dyn MemBackend>,
),
(
archive_mount("external", archive_path.clone()),
Box::new(ArchiveBackend::new(archive_path)),
),
])
.unwrap();
let (actor, client) = cli_actor();
let report = engine
.apply_parse_recovery(actor, Some(&client), None)
.expect("recovery succeeds");
assert_eq!(report.entries.len(), 3);
let removed: Vec<_> = report
.entries
.iter()
.filter(|e| e.outcome == ParseRecoveryEntry::OUTCOME_REMOVED)
.collect();
let skipped: Vec<_> = report
.entries
.iter()
.filter(|e| e.outcome == ParseRecoveryEntry::OUTCOME_SKIPPED)
.collect();
assert_eq!(removed.len(), 2);
assert_eq!(skipped.len(), 1);
assert_eq!(
skipped[0].reason.as_deref(),
Some(ParseRecoveryEntry::REASON_READONLY_MOUNT),
);
assert!(!report.commit_sha.is_empty());
}
#[test]
fn apply_parse_recovery_reports_failed_for_unbacked_body_link() {
let tmp = TempDir::new().unwrap();
let schemas_dir = tmp.path().join("schemas");
std::fs::create_dir_all(&schemas_dir).unwrap();
let manifest = r#"name: link-test
version: 0.1.0
description: schema for wikilink-blocker test
when_to_use: tests
types:
- doc
relationships:
mode: strict
definitions:
- name: MENTIONS
description: doc references doc
default_weight: 1.0
- name: _default
description: fallback
default_weight: 1.0
community:
resolution: 1.0
seed: 42
"#;
write_schema_files_with_default_type(&schemas_dir, "link-test", manifest, &["doc"]);
let mem_dir = tmp.path().join("mem");
std::fs::create_dir_all(&mem_dir).unwrap();
let target = "---\ntype: doc\n---\n# Target\n\n## Body\n\nbody\n";
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";
std::fs::write(mem_dir.join("target.md"), target).unwrap();
std::fs::write(mem_dir.join("source.md"), source).unwrap();
let writer = FilesystemMemWriter::new(mem_dir.clone());
let pin = SchemaRef::new("link-test", semver::Version::new(0, 1, 0));
let mount = Mount {
mem: "specs".to_string(),
schema: Some(pin),
storage: MountStorage::Folder {
path: mem_dir.clone(),
},
capability: MountCapability::Write,
lifecycle: MountLifecycle::Eager,
cross_linkable: true,
migration_target: None,
};
let mut engine = Engine::from_mounts_with_schemas_dir(
vec![(mount, Box::new(writer) as Box<dyn MemBackend>)],
Some(&schemas_dir),
)
.unwrap();
let (actor, client) = cli_actor();
let report = engine
.apply_parse_recovery(actor, Some(&client), None)
.expect("recovery returns Ok even when entries fail");
assert_eq!(report.entries.len(), 1);
let entry = &report.entries[0];
assert_eq!(entry.outcome, ParseRecoveryEntry::OUTCOME_FAILED);
assert_eq!(
entry.reason.as_deref(),
Some("WIKILINK_WITHOUT_RELATION"),
"expected the strict validator's typed code, got {:?}",
entry.reason,
);
let unchanged = std::fs::read_to_string(mem_dir.join("source.md")).unwrap();
assert!(
unchanged.contains("BADTYPE"),
"source must be unchanged on failure"
);
let post: Vec<_> = engine
.load_warnings()
.iter()
.filter(|w| matches!(w, WarningHint::ParsedRelationInvalid { .. }))
.collect();
assert_eq!(post.len(), 1, "failed drop must persist, got {post:?}");
}
}