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