1use std::collections::BTreeMap;
30use std::path::Path;
31
32use indexmap::IndexMap;
33
34use crate::engine_fallback_type;
35use crate::entity::EntityId;
36use crate::entity::parser::parse_markdown;
37use crate::entity::store_builder::push_entities_into_store;
38use crate::ops::WarningHint;
39use crate::provenance::{Provenance, ProvenanceKind};
40use crate::runtime_validator::{
41 CatchAllContext, CrossMemRelCheck, READ_ONLY_METADATA_KEYS, ValidationError,
42 missing_required_fields, missing_required_sections, parse_metadata_value,
43 validate_cross_mem_edge, validate_rel_shape, validate_section_content, validate_section_keys,
44};
45use crate::vcs::{Actor, ClientId, CommitContext};
46use crate::workspace::MountCapability;
47
48use super::super::{Engine, EngineError, RetypeEntityArgs, RetypeEntityOutcome};
49use super::unknown_type_error;
50use crate::engine::outcomes::{RetypeEdge, RetypeEdgeDirection, RetypeProblem};
51
52impl Engine {
53 pub fn retype_entity(
59 &mut self,
60 args: RetypeEntityArgs,
61 actor: Actor,
62 client: Option<&ClientId>,
63 note: Option<&str>,
64 ) -> Result<RetypeEntityOutcome, EngineError> {
65 let mut args = args;
67 let (resolved, short_hint) = self.resolve_entity_id(&args.id)?;
68 args.id = resolved;
69 let id = &args.id;
70 let mem = id.mem().to_string();
71
72 let mount_idx = self
73 .mounts
74 .iter()
75 .position(|m| m.mount.mem == mem)
76 .ok_or_else(|| self.unknown_mem_error(&mem))?;
77 if self.mounts[mount_idx].mount.capability != MountCapability::Write {
78 return Err(EngineError::ReadOnlyMount(mem));
79 }
80
81 let mut drift_warnings: Vec<WarningHint> = short_hint.into_iter().collect();
84 drift_warnings.extend(self.reload_if_stale(Some(&mem)));
85
86 let entity = self
87 .store
88 .get(id)
89 .ok_or_else(|| EngineError::NotFound { id: id.to_string() })?
90 .clone();
91 if entity.stub {
92 return Err(EngineError::StubNotUpdatable { id: id.to_string() });
93 }
94 if !args.dry_run
95 && let Some(expected) = args.expected_hash.as_deref()
96 && entity.content_hash != expected
97 {
98 return Err(EngineError::HashMismatch {
99 id: id.to_string(),
100 current: entity.content_hash.clone(),
101 is_stub: false,
102 });
103 }
104
105 let schema = self
106 .schemas
107 .get(&mem)
108 .expect("schema present for every registered mount")
109 .clone();
110 let target_def = schema
111 .get_type(&args.target_type)
112 .ok_or_else(|| unknown_type_error(&schema, &args.target_type))?;
113 if entity.entity_type == args.target_type {
114 return Err(EngineError::RetypeNoOp {
115 id: id.to_string(),
116 entity_type: entity.entity_type.clone(),
117 });
118 }
119
120 let mut problems: Vec<RetypeProblem> = Vec::new();
121 let mut warnings: Vec<WarningHint> = Vec::new();
122
123 let mut next = entity.clone();
125 next.entity_type = args.target_type.clone();
126 let mut sections_renamed: Vec<(String, String)> = Vec::new();
127 {
128 let mut mapped: IndexMap<String, String> = IndexMap::new();
129 let mut taken: BTreeMap<String, String> = BTreeMap::new(); for (key, body) in &entity.sections {
131 let new_key = args
132 .section_map
133 .get(key.as_str())
134 .cloned()
135 .unwrap_or_else(|| key.clone());
136 if let Some(prev) = taken.get(&new_key) {
137 problems.push(RetypeProblem::SectionMapCollision {
138 from: key.clone(),
139 to: new_key.clone(),
140 also_from: prev.clone(),
141 });
142 continue;
143 }
144 taken.insert(new_key.clone(), key.clone());
145 if new_key != *key {
146 sections_renamed.push((key.clone(), new_key.clone()));
147 }
148 mapped.insert(new_key, body.clone());
149 }
150 for from in args.section_map.keys() {
151 if !entity.sections.contains_key(from.as_str()) {
152 problems.push(RetypeProblem::SectionMapSourceMissing {
153 key: from.clone(),
154 present: entity.sections.keys().cloned().collect(),
155 });
156 }
157 }
158 next.sections = mapped;
159 }
160 for key in next.sections.keys() {
164 if let Err(ValidationError::UnknownSection {
165 key,
166 declared,
167 suggestion,
168 ..
169 }) = validate_section_keys(std::iter::once(key.as_str()), target_def.as_ref())
170 {
171 problems.push(RetypeProblem::UnknownSection {
172 key,
173 declared,
174 suggestion,
175 });
176 }
177 }
178 {
180 let declared_headings: Vec<&str> = target_def
181 .sections
182 .iter()
183 .map(|s| s.heading.as_str())
184 .collect();
185 let catch_all = target_def.catch_all_section().map(|s| CatchAllContext {
186 key: s.key.as_str(),
187 entity_type: target_def.name.as_str(),
188 declared_headings: &declared_headings,
189 });
190 if let Err(e) = validate_section_content(
191 next.sections.iter().map(|(k, v)| (k.as_str(), v.as_str())),
192 catch_all,
193 ) {
194 problems.push(RetypeProblem::Validation {
195 code: e.code(),
196 message: e.to_string(),
197 details: e.details(),
198 });
199 }
200 }
201 for missing in missing_required_sections(target_def.as_ref(), &next.sections) {
202 problems.push(RetypeProblem::MissingRequiredSection {
203 key: missing.key,
204 heading: missing.heading,
205 write_rules: missing.write_rules,
206 });
207 }
208
209 {
211 let mut parsed: IndexMap<String, crate::entity::MetadataValue> = IndexMap::new();
212 let mut supplied: IndexMap<String, String> = IndexMap::new();
213 for (key, value) in &entity.metadata {
214 if args.drop_metadata.iter().any(|k| k == key) {
215 continue;
216 }
217 if READ_ONLY_METADATA_KEYS.contains(&key.as_str()) {
218 let carried = if key == "type" {
222 crate::entity::MetadataValue::String(args.target_type.clone())
223 } else {
224 value.clone()
225 };
226 parsed.insert(key.clone(), carried);
227 continue;
228 }
229 let raw = value.to_frontmatter_string();
230 match parse_metadata_value(key, &raw, target_def.as_ref()) {
231 Ok(v) => {
232 parsed.insert(key.clone(), v);
233 supplied.insert(key.clone(), raw);
234 }
235 Err(e) => {
236 let message = if e.code() == "UNKNOWN_METADATA_FIELD" {
239 format!("{e}; drop it explicitly with drop_metadata [{key}]")
240 } else {
241 e.to_string()
242 };
243 problems.push(RetypeProblem::Validation {
244 code: e.code(),
245 message,
246 details: e.details(),
247 })
248 }
249 }
250 }
251 for field in &target_def.metadata_fields {
254 if parsed.contains_key(field.key.as_str()) || supplied.contains_key(&field.key) {
255 continue;
256 }
257 if let Some(default) = &field.default_value
258 && let Ok(v) = parse_metadata_value(&field.key, default, target_def.as_ref())
259 {
260 parsed.insert(field.key.clone(), v);
261 supplied.insert(field.key.clone(), default.clone());
262 }
263 }
264 for missing in missing_required_fields(target_def.as_ref(), &supplied) {
265 problems.push(RetypeProblem::MissingRequiredField {
266 key: missing.key,
267 description: missing.description,
268 enum_values: missing.enum_values,
269 });
270 }
271 next.metadata = parsed;
272 }
273
274 let mut edges_rechecked = 0usize;
276 for rel in &next.relationships {
278 edges_rechecked += 1;
279 let target_type = self
280 .store
281 .get(&rel.target)
282 .filter(|e| !e.stub)
283 .map(|e| e.entity_type.clone());
284 let target_mem = rel.target.mem();
285 let violation = self.edge_shape_violation(
286 &schema,
287 &mem,
288 target_mem,
289 &rel.rel_type,
290 &args.target_type,
291 target_type.as_deref(),
292 );
293 if let Some(e) = violation {
294 problems.push(RetypeProblem::EdgeShape(RetypeEdge {
295 direction: RetypeEdgeDirection::Outgoing,
296 from: id.to_string(),
297 to: rel.target.to_string(),
298 rel_type: rel.rel_type.clone(),
299 cross_mem: target_mem != mem,
300 detail: e.details(),
301 }));
302 }
303 }
304 let incoming: Vec<(EntityId, String)> = self
306 .store
307 .incoming(id)
308 .iter()
309 .map(|e| (e.from.clone(), e.rel_type.clone()))
310 .collect();
311 for (from, rel_type) in incoming {
312 let Some(referrer) = self.store.get(&from) else {
313 continue;
314 };
315 edges_rechecked += 1;
316 let from_mem = from.mem().to_string();
317 let referrer_type = referrer.entity_type.clone();
318 let violation = match self.schemas.get(&from_mem).cloned() {
322 Some(referrer_schema) => self.edge_shape_violation(
323 &referrer_schema,
324 &from_mem,
325 &mem,
326 &rel_type,
327 &referrer_type,
328 Some(&args.target_type),
329 ),
330 None => None,
331 };
332 if let Some(e) = violation {
333 problems.push(RetypeProblem::EdgeShape(RetypeEdge {
334 direction: RetypeEdgeDirection::Incoming,
335 from: from.to_string(),
336 to: id.to_string(),
337 rel_type,
338 cross_mem: from_mem != mem,
339 detail: e.details(),
340 }));
341 }
342 }
343 edges_rechecked += self.recheck_deferred_referrers(id, &args.target_type, &mut problems)?;
347
348 let unsatisfied =
350 crate::ops::health::unsatisfied_required_outgoing(&next, target_def.as_ref());
351 if !unsatisfied.is_empty() {
352 let blocked: Vec<_> = unsatisfied
353 .iter()
354 .filter(|b| b.severity == memstead_schema::ConstraintSeverity::Block)
355 .cloned()
356 .collect();
357 if !blocked.is_empty() {
358 problems.push(RetypeProblem::RequiredOutgoingUnsatisfied(blocked));
359 }
360 warnings.push(WarningHint::MissingRequiredOutgoing {
361 entity_type: args.target_type.clone(),
362 entity_id: id.clone(),
363 missing: unsatisfied,
364 });
365 }
366 {
367 let check_provider = self.check_standing_provider();
368 let violated = crate::ops::health::unsatisfied_constraints(
369 &self.store,
370 &next,
371 target_def.as_ref(),
372 Some(id),
373 Some(&check_provider),
374 );
375 if !violated.is_empty() {
376 let blocked: Vec<_> = violated
377 .iter()
378 .filter(|v| v.severity() == memstead_schema::ConstraintSeverity::Block)
379 .cloned()
380 .collect();
381 if !blocked.is_empty() {
382 problems.push(RetypeProblem::ConstraintUnsatisfied(blocked));
383 }
384 warnings.push(WarningHint::ConstraintUnsatisfied {
385 entity_type: args.target_type.clone(),
386 entity_id: id.clone(),
387 violations: violated,
388 });
389 }
390 }
391
392 if !problems.is_empty() {
393 let mut declared: Vec<String> =
394 target_def.sections.iter().map(|s| s.key.clone()).collect();
395 declared.sort();
396 let catch_all = target_def.catch_all_section().map(|s| s.key.clone());
397 let mut proposed: BTreeMap<String, String> = BTreeMap::new();
400 for key in entity.sections.keys() {
401 if key == "relationships" || target_def.section(key).is_some() {
402 continue;
403 }
404 if let Some(to) = target_def
405 .suggest_section(key)
406 .or_else(|| catch_all.clone())
407 {
408 proposed.insert(key.clone(), to);
409 }
410 }
411 return Err(EngineError::RetypeRefused {
412 id: id.to_string(),
413 from_type: entity.entity_type.clone(),
414 to_type: args.target_type.clone(),
415 problems,
416 target_sections: declared,
417 target_catch_all: catch_all,
418 proposed_section_map: proposed,
419 });
420 }
421
422 let today = self.now_iso();
424 super::auto_stamp_timestamps(&mut next, target_def.as_ref(), &today);
425 let markdown = super::render_for_write(&next, target_def.as_ref())?;
426 let staleness_note = |from: &str, to: &str| {
427 format!(
428 "check records and derivation baselines on {id} are stale: its content hash \
429 moved from {from} to {to} with the type change; re-check and re-baseline \
430 deliberately"
431 )
432 };
433 if args.dry_run {
434 let parsed = parse_markdown(&markdown, &entity.file_path, target_def.as_ref(), &mem)
435 .map_err(|e| EngineError::ParseAfterWrite(e.to_string()))?;
436 let prospective = parsed.entity.content_hash.clone();
437 return Ok(RetypeEntityOutcome {
438 id: id.clone(),
439 file_path: entity.file_path.clone(),
440 old_type: entity.entity_type.clone(),
441 new_type: args.target_type.clone(),
442 content_hash: entity.content_hash.clone(),
443 prospective_hash: Some(prospective.clone()),
444 write_id: String::new(),
445 sections_renamed,
446 edges_rechecked,
447 checks_stale: true,
448 staleness_note: staleness_note(&entity.content_hash, &prospective),
449 warnings,
450 });
451 }
452
453 let backend = self.mounts[mount_idx].backend.as_ref();
454 backend.write_entity(Path::new(&entity.file_path), markdown.as_bytes())?;
455 let commit_subject = format!("memstead: retype {id}");
459 let ctx = CommitContext {
460 actor,
461 client: client.cloned(),
462 tool: Some("retype_entity"),
463 note: note.map(String::from),
464 role: self.current_role,
465 identity: self.current_identity.clone(),
466 logical_operation_id: None,
467 entity_ids: None,
468 };
469 let write_id = backend.commit(&commit_subject, &ctx)?;
470 backend.append_provenance(
471 &Provenance::new(
472 std::time::SystemTime::now(),
473 ProvenanceKind::Retype,
474 Some(id.to_string()),
475 actor,
476 client.cloned(),
477 note.map(String::from),
478 )
479 .with_role(self.current_role)
480 .with_identity(self.current_identity.clone()),
481 )?;
482 self.record_self_write(mount_idx, &write_id);
483 let stamp_warnings = self.stamp_mutation_versions(mount_idx);
484
485 let parse_result = parse_markdown(&markdown, &entity.file_path, target_def.as_ref(), &mem)
486 .map_err(|e| EngineError::ParseAfterWrite(e.to_string()))?;
487 let content_hash = parse_result.entity.content_hash.clone();
488 let fallback = engine_fallback_type();
489 push_entities_into_store(&mut self.store, vec![parse_result], fallback.as_ref(), None);
490 crate::entity::store_builder::remap_alias_target_edge_sources(
491 &mut self.store,
492 &self.schemas,
493 );
494 self.invalidate_communities();
495 self.maintain_search_indexes(std::slice::from_ref(id));
496
497 let mut outcome_warnings = Vec::new();
498 outcome_warnings.append(&mut drift_warnings);
499 outcome_warnings.extend(stamp_warnings);
500 outcome_warnings.extend(warnings);
501 if let Some(w) = self.note_missing_warning("retype_entity", note) {
502 outcome_warnings.push(w);
503 }
504
505 Ok(RetypeEntityOutcome {
506 id: id.clone(),
507 file_path: entity.file_path.clone(),
508 old_type: entity.entity_type.clone(),
509 new_type: args.target_type.clone(),
510 staleness_note: staleness_note(&entity.content_hash, &content_hash),
511 content_hash,
512 prospective_hash: None,
513 write_id,
514 sections_renamed,
515 edges_rechecked,
516 checks_stale: true,
517 warnings: outcome_warnings,
518 })
519 }
520
521 fn edge_shape_violation(
534 &self,
535 source_schema: &memstead_schema::Schema,
536 source_mem: &str,
537 target_mem: &str,
538 rel_type: &str,
539 from_type: &str,
540 to_type: Option<&str>,
541 ) -> Option<ValidationError> {
542 let target_ref: Option<memstead_schema::SchemaRef> = if source_mem == target_mem {
543 None
544 } else {
545 super::target_schema_ref_for_routing(self, target_mem)
546 };
547 let cross_mem_different = match (&target_ref, source_schema.id()) {
548 (Some(target), (src_name, _)) => target.name != src_name,
549 (None, _) => false,
550 };
551 if cross_mem_different {
552 let target_ref = target_ref.expect("Some when cross_mem_different");
553 match validate_cross_mem_edge(rel_type, from_type, to_type, source_schema, &target_ref)
554 {
555 CrossMemRelCheck::Ok | CrossMemRelCheck::EdgeNotDeclared => None,
556 CrossMemRelCheck::Invalid(e) => Some(e),
557 }
558 } else {
559 validate_rel_shape(rel_type, from_type, to_type, source_schema).err()
560 }
561 }
562
563 fn recheck_deferred_referrers(
571 &self,
572 id: &EntityId,
573 target_type: &str,
574 problems: &mut Vec<RetypeProblem>,
575 ) -> Result<usize, EngineError> {
576 let mut examined = 0usize;
577 let id_str = id.to_string();
578 let fallback = engine_fallback_type();
579 for mounted in self.mounts.iter().filter(|m| m.deferred) {
580 let referrer_mem = mounted.mount.mem.clone();
581 let paths = mounted.backend.list_entities().map_err(|e| {
582 EngineError::RetypeReferrerUnprobeable {
583 id: id_str.clone(),
584 mem: referrer_mem.clone(),
585 reason: e.to_string(),
586 }
587 })?;
588 let referrer_schema = self.schemas.get(&referrer_mem);
589 for path in paths {
590 let rel_path = path.to_string_lossy().into_owned();
591 let Some(bytes) =
592 mounted
593 .backend
594 .read_entity(Path::new(&rel_path))
595 .map_err(|e| EngineError::RetypeReferrerUnprobeable {
596 id: id_str.clone(),
597 mem: referrer_mem.clone(),
598 reason: format!("{rel_path}: {e}"),
599 })?
600 else {
601 continue;
602 };
603 let text = String::from_utf8_lossy(&bytes);
604 if !text.contains(id.name()) {
607 continue;
608 }
609 let declared_type = crate::entity::parser::peek_type_from_frontmatter(&text);
613 let type_def = referrer_schema
614 .and_then(|s| declared_type.as_deref().and_then(|t| s.get_type(t)))
615 .unwrap_or_else(|| fallback.clone());
616 let Ok(parsed) = parse_markdown(&text, &rel_path, type_def.as_ref(), &referrer_mem)
617 else {
618 continue;
619 };
620 for rel in &parsed.entity.relationships {
621 if rel.target != *id {
622 continue;
623 }
624 examined += 1;
625 let violation = match referrer_schema {
626 Some(referrer_schema) => self.edge_shape_violation(
627 referrer_schema,
628 &referrer_mem,
629 id.mem(),
630 &rel.rel_type,
631 &parsed.entity.entity_type,
632 Some(target_type),
633 ),
634 None => None,
635 };
636 if let Some(e) = violation {
637 problems.push(RetypeProblem::EdgeShape(RetypeEdge {
638 direction: RetypeEdgeDirection::Incoming,
639 from: parsed.entity.id.to_string(),
640 to: id_str.clone(),
641 rel_type: rel.rel_type.clone(),
642 cross_mem: true,
643 detail: e.details(),
644 }));
645 }
646 }
647 }
648 }
649 Ok(examined)
650 }
651}
652
653#[cfg(test)]
654mod tests {
655 use std::collections::BTreeMap;
656 use std::path::Path;
657
658 use indexmap::IndexMap;
659 use memstead_schema::SchemaRef;
660 use memstead_schema::workspace_config::CrossLinkValue;
661 use tempfile::TempDir;
662
663 use crate::backend::MemBackend;
664 use crate::engine::outcomes::{RetypeEdgeDirection, RetypeProblem};
665 use crate::engine::test_helpers::*;
666 use crate::engine::{
667 CreateEntityArgs, Engine, EngineError, RelateEntityArgs, RetypeEntityArgs,
668 };
669 use crate::ops::WarningHint;
670 use crate::storage::FilesystemMemWriter;
671 use crate::workspace::{
672 Mount, MountCapability, MountLifecycle, MountStorage, WorkspaceSettings,
673 };
674
675 const MAIN_MANIFEST: &str = r#"name: rt
676version: 0.1.0
677description: retype fixture
678when_to_use: tests
679types:
680 - claim
681 - finding
682relationships:
683 mode: strict
684 definitions:
685 - name: SUPPORTS
686 description: pinned both ends to claim
687 default_weight: 1.0
688 source_types: [claim]
689 target_types: [claim]
690 - name: CITES
691 description: unpinned
692 default_weight: 1.0
693 - name: _default
694 description: fallback
695 default_weight: 1.0
696community:
697 resolution: 1.0
698 seed: 42
699"#;
700
701 const PEER_MANIFEST: &str = r#"name: rt-peer
702version: 0.1.0
703description: peer fixture
704when_to_use: tests
705types:
706 - remark
707relationships:
708 mode: strict
709 definitions:
710 - name: _default
711 description: fallback
712 default_weight: 1.0
713cross_mem_relationships:
714 - to_schema: rt
715 definitions:
716 - name: COMMENTS_ON
717 description: pinned to claim on the target side
718 default_weight: 1.0
719 source_types: [remark]
720 target_types: [claim]
721community:
722 resolution: 1.0
723 seed: 42
724"#;
725
726 fn type_yaml(name: &str, main_section: &str, main_heading: &str) -> String {
727 format!(
728 r#"name: {name}
729description: t
730when_to_use: Here
731sections:
732 - key: {main_section}
733 heading: {main_heading}
734 required: true
735 search_weight: 10.0
736 write_rules: []
737 - key: notes
738 heading: Notes
739 required: false
740 search_weight: 1.0
741 catch_all: true
742 write_rules: []
743metadata_fields: []
744title_weight: 100.0
745text_fields:
746 - {main_section}
747hierarchy_relationship: _default
748no_self_loop_relationships: []
749updatable_fields:
750 - title
751 - {main_section}
752 - notes
753health_required_fields:
754 - {main_section}
755staleness_threshold_days: 90
756write_rules: []
757"#
758 )
759 }
760
761 fn write_schema(root: &Path, name: &str, manifest: &str, types: &[(&str, String)]) {
762 let dir = root.join(name);
763 std::fs::create_dir_all(dir.join("types")).unwrap();
764 std::fs::write(dir.join("schema.yaml"), manifest).unwrap();
765 for (t, body) in types {
766 std::fs::write(dir.join("types").join(format!("{t}.yaml")), body).unwrap();
767 }
768 }
769
770 fn mount(
771 mem: &str,
772 path: std::path::PathBuf,
773 pin: SchemaRef,
774 lifecycle: MountLifecycle,
775 ) -> Mount {
776 Mount {
777 mem: mem.to_string(),
778 schema: Some(pin),
779 storage: MountStorage::Folder { path },
780 capability: MountCapability::Write,
781 lifecycle,
782 cross_linkable: true,
783 migration_target: None,
784 }
785 }
786
787 struct Fixture {
788 _tmp: TempDir,
789 schemas_dir: std::path::PathBuf,
790 main_dir: std::path::PathBuf,
791 peer_dir: std::path::PathBuf,
792 }
793
794 fn fixture() -> Fixture {
795 let tmp = TempDir::new().unwrap();
796 let schemas_dir = tmp.path().join("schemas");
797 std::fs::create_dir_all(&schemas_dir).unwrap();
798 write_schema(
799 &schemas_dir,
800 "rt",
801 MAIN_MANIFEST,
802 &[
803 ("claim", type_yaml("claim", "statement", "Statement")),
804 ("finding", type_yaml("finding", "conclusion", "Conclusion")),
805 ],
806 );
807 write_schema(
808 &schemas_dir,
809 "rt-peer",
810 PEER_MANIFEST,
811 &[("remark", type_yaml("remark", "body", "Body"))],
812 );
813 let main_dir = tmp.path().join("mem-main");
814 let peer_dir = tmp.path().join("mem-peer");
815 std::fs::create_dir_all(&main_dir).unwrap();
816 std::fs::create_dir_all(&peer_dir).unwrap();
817 Fixture {
818 _tmp: tmp,
819 schemas_dir,
820 main_dir,
821 peer_dir,
822 }
823 }
824
825 fn boot(f: &Fixture, peer_lifecycle: MountLifecycle) -> Engine {
826 let main_pin = SchemaRef::new("rt", semver::Version::new(0, 1, 0));
827 let peer_pin = SchemaRef::new("rt-peer", semver::Version::new(0, 1, 0));
828 let mut engine = Engine::from_mounts_with_schemas_dir(
829 vec![
830 (
831 mount("main", f.main_dir.clone(), main_pin, MountLifecycle::Eager),
832 Box::new(FilesystemMemWriter::new(f.main_dir.clone())) as Box<dyn MemBackend>,
833 ),
834 (
835 mount("peer", f.peer_dir.clone(), peer_pin, peer_lifecycle),
836 Box::new(FilesystemMemWriter::new(f.peer_dir.clone())) as Box<dyn MemBackend>,
837 ),
838 ],
839 Some(&f.schemas_dir),
840 )
841 .expect("engine boots");
842 let mut settings = WorkspaceSettings::default();
843 let mut links: BTreeMap<String, CrossLinkValue> = BTreeMap::new();
844 links.insert("peer".to_string(), CrossLinkValue::Wildcard);
845 settings.cross_mem_links = links;
846 engine.set_settings(settings);
847 engine
848 }
849
850 fn create(
851 engine: &mut Engine,
852 mem: &str,
853 title: &str,
854 ty: &str,
855 section: &str,
856 ) -> crate::EntityId {
857 let (actor, client) = cli_actor();
858 engine
859 .create_entity(
860 CreateEntityArgs {
861 anchors: Vec::new(),
862 mem: mem.to_string(),
863 title: title.to_string(),
864 entity_type: ty.to_string(),
865 sections: IndexMap::from_iter([
866 (section.to_string(), format!("{title} says so.")),
867 ("notes".to_string(), "Some notes.".to_string()),
868 ]),
869 metadata: IndexMap::new(),
870 relations: Vec::new(),
871 dry_run: false,
872 },
873 actor,
874 Some(&client),
875 None,
876 )
877 .expect("creates")
878 .id
879 }
880
881 fn relate(engine: &mut Engine, from: &crate::EntityId, rel: &str, to: &crate::EntityId) {
882 let (actor, client) = cli_actor();
883 engine
884 .relate_entity(
885 RelateEntityArgs {
886 source: from.clone(),
887 expected_hash: None,
888 rel_type: rel.to_string(),
889 target: to.clone(),
890 remove: false,
891 description: None,
892 dry_run: false,
893 },
894 actor,
895 Some(&client),
896 None,
897 )
898 .expect("relates");
899 }
900
901 fn retype(
902 engine: &mut Engine,
903 id: &crate::EntityId,
904 target: &str,
905 map: &[(&str, &str)],
906 ) -> Result<crate::engine::RetypeEntityOutcome, EngineError> {
907 let (actor, client) = cli_actor();
908 let hash = engine.get_entity(id).unwrap().content_hash.clone();
909 engine.retype_entity(
910 RetypeEntityArgs {
911 id: id.clone(),
912 expected_hash: Some(hash),
913 target_type: target.to_string(),
914 section_map: map
915 .iter()
916 .map(|(a, b)| (a.to_string(), b.to_string()))
917 .collect(),
918 drop_metadata: Vec::new(),
919 dry_run: false,
920 },
921 actor,
922 Some(&client),
923 Some("test retype"),
924 )
925 }
926
927 fn file_bytes(f: &Fixture, id: &crate::EntityId) -> Vec<u8> {
928 std::fs::read(f.main_dir.join(format!("{}.md", id.name()))).unwrap()
929 }
930
931 #[test]
935 fn retype_keeps_identity_and_edges_and_states_staleness() {
936 let f = fixture();
937 let mut engine = boot(&f, MountLifecycle::Eager);
938 let a = create(&mut engine, "main", "Claim A", "claim", "statement");
939 let b = create(&mut engine, "main", "Claim B", "claim", "statement");
940 relate(&mut engine, &a, "CITES", &b);
942 relate(&mut engine, &b, "CITES", &a);
943 let edges_before = engine.store().incoming(&b).len() + engine.store().outgoing(&b).len();
944 let hash_before = engine.get_entity(&b).unwrap().content_hash.clone();
945
946 let out = retype(&mut engine, &b, "finding", &[("statement", "conclusion")])
947 .expect("retype succeeds");
948 assert_eq!(out.id, b);
949 assert_eq!(out.file_path, "claim-b.md");
950 assert_eq!(
951 (out.old_type.as_str(), out.new_type.as_str()),
952 ("claim", "finding")
953 );
954 assert_eq!(
955 out.sections_renamed,
956 vec![("statement".to_string(), "conclusion".to_string())]
957 );
958 assert_eq!(out.edges_rechecked, 2);
959 assert!(out.checks_stale);
960 assert!(
961 out.staleness_note
962 .contains("check records and derivation baselines")
963 );
964 assert!(out.staleness_note.contains(&hash_before));
965 assert!(!out.write_id.is_empty());
966
967 let e = engine.get_entity(&b).unwrap();
968 assert_eq!(
969 e.entity_type,
970 "finding",
971 "store entity after retype: {e:?}\nfile: {}",
972 String::from_utf8(file_bytes(&f, &b)).unwrap()
973 );
974 assert_eq!(e.file_path, "claim-b.md");
975 assert_eq!(
976 e.sections.get("conclusion").map(String::as_str),
977 Some("Claim B says so.")
978 );
979 assert!(!e.sections.contains_key("statement"));
980 assert_ne!(e.content_hash, hash_before);
981 assert_eq!(engine.store().incoming(&b).len(), 1, "incoming edge stays");
982 assert_eq!(engine.store().incoming(&b)[0].from, a);
983 let text = String::from_utf8(file_bytes(&f, &b)).unwrap();
984 assert!(text.contains("type: finding"), "{text}");
985 assert!(text.contains("## Conclusion"), "{text}");
986
987 let fresh = boot(&f, MountLifecycle::Eager);
989 let dropped = fresh
990 .load_warnings()
991 .iter()
992 .any(|w| matches!(w, WarningHint::ParsedRelationInvalid { .. }));
993 assert!(!dropped, "{:?}", fresh.load_warnings());
994 assert_eq!(
995 fresh.store().incoming(&b).len() + fresh.store().outgoing(&b).len(),
996 edges_before
997 );
998 assert_eq!(
999 fresh.get_entity(&b).unwrap().entity_type,
1000 "finding",
1001 "warnings: {:?}\nfile: {}",
1002 fresh.load_warnings(),
1003 String::from_utf8(file_bytes(&f, &b)).unwrap()
1004 );
1005 }
1006
1007 #[test]
1011 fn unknown_section_refuses_with_declared_sections_and_touches_nothing() {
1012 let f = fixture();
1013 let mut engine = boot(&f, MountLifecycle::Eager);
1014 let b = create(&mut engine, "main", "Claim B", "claim", "statement");
1015 let before = file_bytes(&f, &b);
1016 let err = retype(&mut engine, &b, "finding", &[("statement", "bogus")]).unwrap_err();
1017 assert_eq!(err.code(), "UNKNOWN_SECTION");
1018 let d = err.details();
1019 assert_eq!(
1020 d["target_sections"],
1021 serde_json::json!(["conclusion", "notes"])
1022 );
1023 assert_eq!(d["target_catch_all"], "notes");
1024 assert_eq!(d["proposed_section_map"]["statement"], "notes", "{d}");
1025 let codes: Vec<&str> = d["problems"]
1028 .as_array()
1029 .unwrap()
1030 .iter()
1031 .map(|p| p["code"].as_str().unwrap())
1032 .collect();
1033 assert_eq!(codes, vec!["UNKNOWN_SECTION", "MISSING_REQUIRED_SECTION"]);
1034 assert_eq!(d["problems"][0]["key"], "bogus");
1035 assert_eq!(
1036 file_bytes(&f, &b),
1037 before,
1038 "refusal leaves the file untouched"
1039 );
1040 assert_eq!(engine.get_entity(&b).unwrap().entity_type, "claim");
1041
1042 let err = retype(&mut engine, &b, "finding", &[]).unwrap_err();
1045 assert_eq!(
1046 err.code(),
1047 "UNKNOWN_SECTION",
1048 "a section-map defect dominates the envelope code"
1049 );
1050 let codes: Vec<String> = err.details()["problems"]
1051 .as_array()
1052 .unwrap()
1053 .iter()
1054 .map(|p| p["code"].as_str().unwrap().to_string())
1055 .collect();
1056 assert_eq!(codes, vec!["UNKNOWN_SECTION", "MISSING_REQUIRED_SECTION"]);
1057 assert_eq!(file_bytes(&f, &b), before);
1058 assert!(matches!(
1059 retype(&mut engine, &b, "claim", &[]).unwrap_err(),
1060 EngineError::RetypeNoOp { .. }
1061 ));
1062 }
1063
1064 #[test]
1069 fn edge_shapes_are_rechecked_in_both_directions_and_across_mems() {
1070 let f = fixture();
1071 let mut engine = boot(&f, MountLifecycle::Eager);
1072 let a = create(&mut engine, "main", "Claim A", "claim", "statement");
1073 let b = create(&mut engine, "main", "Claim B", "claim", "statement");
1074 relate(&mut engine, &a, "SUPPORTS", &b); let before_b = file_bytes(&f, &b);
1076
1077 let err = retype(&mut engine, &b, "finding", &[("statement", "conclusion")]).unwrap_err();
1079 assert_eq!(err.code(), "INVALID_REL_SHAPE", "{err}");
1080 let EngineError::RetypeRefused { problems, .. } = &err else {
1081 panic!("{err:?}")
1082 };
1083 assert_eq!(problems.len(), 1);
1084 let RetypeProblem::EdgeShape(edge) = &problems[0] else {
1085 panic!("{problems:?}")
1086 };
1087 assert_eq!(edge.direction, RetypeEdgeDirection::Incoming);
1088 assert_eq!(
1089 (edge.from.as_str(), edge.rel_type.as_str()),
1090 (a.as_ref(), "SUPPORTS")
1091 );
1092 assert!(!edge.cross_mem);
1093 assert_eq!(file_bytes(&f, &b), before_b);
1094
1095 let before_a = file_bytes(&f, &a);
1097 let err = retype(&mut engine, &a, "finding", &[("statement", "conclusion")]).unwrap_err();
1098 assert_eq!(err.code(), "INVALID_REL_SHAPE");
1099 let EngineError::RetypeRefused { problems, .. } = &err else {
1100 panic!("{err:?}")
1101 };
1102 let RetypeProblem::EdgeShape(edge) = &problems[0] else {
1103 panic!("{problems:?}")
1104 };
1105 assert_eq!(edge.direction, RetypeEdgeDirection::Outgoing);
1106 assert_eq!(edge.to, b.to_string());
1107 assert_eq!(file_bytes(&f, &a), before_a);
1108
1109 relate(&mut engine, &b, "SUPPORTS", &a);
1112 let before_b = file_bytes(&f, &b);
1113 let err = retype(&mut engine, &b, "finding", &[("statement", "conclusion")]).unwrap_err();
1114 assert_eq!(err.code(), "INVALID_REL_SHAPE");
1115 let d = err.details();
1116 let dirs: Vec<String> = d["problems"]
1117 .as_array()
1118 .unwrap()
1119 .iter()
1120 .map(|p| p["direction"].as_str().unwrap().to_string())
1121 .collect();
1122 assert_eq!(dirs, vec!["outgoing", "incoming"]);
1123 assert_eq!(file_bytes(&f, &b), before_b);
1124
1125 let c = create(&mut engine, "main", "Claim C", "claim", "statement");
1128 let r = create(&mut engine, "peer", "Remark R", "remark", "body");
1129 relate(&mut engine, &r, "COMMENTS_ON", &c);
1130 let before_c = file_bytes(&f, &c);
1131 let err = retype(&mut engine, &c, "finding", &[("statement", "conclusion")]).unwrap_err();
1132 assert_eq!(err.code(), "INVALID_REL_SHAPE", "{err}");
1133 let EngineError::RetypeRefused { problems, .. } = &err else {
1134 panic!("{err:?}")
1135 };
1136 let RetypeProblem::EdgeShape(edge) = &problems[0] else {
1137 panic!("{problems:?}")
1138 };
1139 assert!(edge.cross_mem);
1140 assert_eq!(edge.from, r.to_string());
1141 assert_eq!(file_bytes(&f, &c), before_c);
1142 }
1143
1144 #[test]
1149 fn same_schema_cross_mem_edges_are_rechecked() {
1150 let f = fixture();
1151 let tmp_twin = f._tmp.path().join("mem-twin");
1152 std::fs::create_dir_all(&tmp_twin).unwrap();
1153 let main_pin = SchemaRef::new("rt", semver::Version::new(0, 1, 0));
1154 let mut engine = Engine::from_mounts_with_schemas_dir(
1155 vec![
1156 (
1157 mount(
1158 "main",
1159 f.main_dir.clone(),
1160 main_pin.clone(),
1161 MountLifecycle::Eager,
1162 ),
1163 Box::new(FilesystemMemWriter::new(f.main_dir.clone())) as Box<dyn MemBackend>,
1164 ),
1165 (
1166 mount("twin", tmp_twin.clone(), main_pin, MountLifecycle::Eager),
1167 Box::new(FilesystemMemWriter::new(tmp_twin.clone())) as Box<dyn MemBackend>,
1168 ),
1169 ],
1170 Some(&f.schemas_dir),
1171 )
1172 .unwrap();
1173 let mut settings = WorkspaceSettings::default();
1174 let mut links: BTreeMap<String, CrossLinkValue> = BTreeMap::new();
1175 links.insert("twin".to_string(), CrossLinkValue::Wildcard);
1176 links.insert("main".to_string(), CrossLinkValue::Wildcard);
1177 settings.cross_mem_links = links;
1178 engine.set_settings(settings);
1179
1180 let v = create(&mut engine, "main", "Claim V", "claim", "statement");
1181 let w = create(&mut engine, "twin", "Claim W", "claim", "statement");
1182 relate(&mut engine, &w, "SUPPORTS", &v); let before = file_bytes(&f, &v);
1184 let err = retype(&mut engine, &v, "finding", &[("statement", "conclusion")]).unwrap_err();
1185 assert_eq!(err.code(), "INVALID_REL_SHAPE", "{err}");
1186 let EngineError::RetypeRefused { problems, .. } = &err else {
1187 panic!("{err:?}")
1188 };
1189 let RetypeProblem::EdgeShape(edge) = &problems[0] else {
1190 panic!("{problems:?}")
1191 };
1192 assert!(edge.cross_mem);
1193 assert_eq!(edge.from, w.to_string());
1194 assert_eq!(file_bytes(&f, &v), before);
1195
1196 let err = retype(&mut engine, &w, "finding", &[("statement", "conclusion")]).unwrap_err();
1198 assert_eq!(err.code(), "INVALID_REL_SHAPE", "{err}");
1199 }
1200
1201 #[test]
1206 fn deferred_referrers_are_probed_through_storage() {
1207 let f = fixture();
1208 {
1209 let mut engine = boot(&f, MountLifecycle::Eager);
1210 let c = create(&mut engine, "main", "Claim C", "claim", "statement");
1211 let r = create(&mut engine, "peer", "Remark R", "remark", "body");
1212 relate(&mut engine, &r, "COMMENTS_ON", &c);
1213 }
1214 let mut lazy = boot(&f, MountLifecycle::Lazy);
1215 let c = crate::EntityId::canonical("main--claim-c");
1216 assert!(
1217 lazy.mem_is_deferred("peer"),
1218 "peer stays unloaded until touched"
1219 );
1220 assert!(
1221 lazy.store().incoming(&c).is_empty(),
1222 "the lazy referrer's edge is not in the store"
1223 );
1224 let err = retype(&mut lazy, &c, "finding", &[("statement", "conclusion")]).unwrap_err();
1225 assert_eq!(err.code(), "INVALID_REL_SHAPE", "{err}");
1226 let EngineError::RetypeRefused { problems, .. } = &err else {
1227 panic!("{err:?}")
1228 };
1229 let RetypeProblem::EdgeShape(edge) = &problems[0] else {
1230 panic!("{problems:?}")
1231 };
1232 assert!(edge.cross_mem);
1233 assert_eq!(edge.from, "peer--remark-r");
1234 assert_eq!(lazy.get_entity(&c).unwrap().entity_type, "claim");
1235
1236 let d = create(&mut lazy, "main", "Claim D", "claim", "statement");
1239 let out = retype(&mut lazy, &d, "finding", &[("statement", "conclusion")]).unwrap();
1240 assert_eq!(out.edges_rechecked, 0);
1241 }
1242
1243 #[test]
1245 fn dry_run_validates_and_writes_nothing() {
1246 let f = fixture();
1247 let mut engine = boot(&f, MountLifecycle::Eager);
1248 let b = create(&mut engine, "main", "Claim B", "claim", "statement");
1249 let before = file_bytes(&f, &b);
1250 let (actor, client) = cli_actor();
1251 let out = engine
1252 .retype_entity(
1253 RetypeEntityArgs {
1254 id: b.clone(),
1255 expected_hash: None,
1256 target_type: "finding".to_string(),
1257 section_map: IndexMap::from_iter([(
1258 "statement".to_string(),
1259 "conclusion".to_string(),
1260 )]),
1261 drop_metadata: Vec::new(),
1262 dry_run: true,
1263 },
1264 actor,
1265 Some(&client),
1266 None,
1267 )
1268 .unwrap();
1269 assert!(out.write_id.is_empty());
1270 assert!(out.prospective_hash.is_some());
1271 assert_ne!(
1272 out.prospective_hash.as_deref(),
1273 Some(out.content_hash.as_str())
1274 );
1275 assert_eq!(file_bytes(&f, &b), before);
1276 assert_eq!(engine.get_entity(&b).unwrap().entity_type, "claim");
1277 }
1278}