1use std::collections::{BTreeMap, HashMap};
5use std::path::Path;
6
7use indexmap::IndexMap;
8
9use memstead_schema::TypeDefinition;
10
11fn build_type_guidance(
19 warnings: &[WarningHint],
20 type_def: &TypeDefinition,
21) -> BTreeMap<String, Vec<String>> {
22 let mut out: BTreeMap<String, Vec<String>> = BTreeMap::new();
23 for w in warnings {
24 let entity_type = match w {
25 WarningHint::MissingRequiredSection { entity_type, .. }
26 | WarningHint::MissingRequiredField { entity_type, .. } => entity_type.as_str(),
27 _ => continue,
28 };
29 if !out.contains_key(entity_type) && entity_type == type_def.name {
30 out.insert(entity_type.to_string(), type_def.write_rules.clone());
31 }
32 }
33 out
34}
35
36use crate::engine_fallback_type;
37use crate::entity::generator::generate_markdown;
38use crate::entity::id::validate_and_derive_slug;
39use crate::entity::parser::parse_markdown;
40use crate::entity::store_builder::push_entities_into_store;
41use crate::entity::{Entity, EntityId, MetadataValue, Relationship, normalise_description};
42use crate::ops::{WarningHint, project_incoming};
43use crate::provenance::{Provenance, ProvenanceKind};
44use crate::runtime_validator::{
45 missing_required_fields, missing_required_sections, parse_metadata_value,
46 validate_section_content, validate_section_keys,
47};
48use crate::vcs::{Actor, ClientId, CommitContext};
49use crate::workspace::MountCapability;
50
51use super::super::{CreateEntityArgs, CreateEntityOutcome, Engine, EngineError};
52use super::{
53 EdgeRouteOutcome, make_stub, route_edge_validation, today_iso, unknown_type_error,
54 validate_relation_target_grammar,
55};
56
57impl Engine {
58 pub fn create_entity(
81 &mut self,
82 args: CreateEntityArgs,
83 actor: Actor,
84 client: Option<&ClientId>,
85 note: Option<&str>,
86 ) -> Result<CreateEntityOutcome, EngineError> {
87 let mut args = args;
88 for rel in &mut args.relations {
93 if let Ok(canonical) = crate::entity::id::validate_rel_type(&rel.rel_type) {
94 rel.rel_type = canonical;
95 }
96 }
97
98 let mut title_trimmed_warning: Option<crate::ops::WarningHint> = None;
107 let trimmed_title = args.title.trim();
108 if trimmed_title.len() != args.title.len() {
109 title_trimmed_warning = Some(crate::ops::WarningHint::TitleTrimmed {
110 original: args.title.clone(),
111 trimmed: trimmed_title.to_string(),
112 });
113 args.title = trimmed_title.to_string();
114 }
115
116 let mount_idx = self
118 .mounts
119 .iter()
120 .position(|m| m.mount.mem == args.mem)
121 .ok_or_else(|| EngineError::UnknownMem(args.mem.clone()))?;
122 if self.mounts[mount_idx].mount.capability != MountCapability::Write {
123 return Err(EngineError::ReadOnlyMount(args.mem));
124 }
125
126 let mut drift_warnings = self.reload_if_stale(Some(&args.mem));
135
136 let schema = self
139 .schemas
140 .get(&args.mem)
141 .expect("schema present for every registered mount");
142 let type_def = schema
143 .get_type(&args.entity_type)
144 .ok_or_else(|| unknown_type_error(schema, &args.entity_type))?;
145
146 validate_section_keys(args.sections.keys().map(String::as_str), type_def.as_ref())?;
148 let validated_anchors = self.validate_anchor_inputs(&args.mem, &args.anchors)?;
158 validate_section_content(args.sections.iter().map(|(k, v)| (k.as_str(), v.as_str())))?;
163
164 let slug = validate_and_derive_slug(&args.title)?;
171 let id = EntityId::new(&args.mem, &slug);
172 crate::entity::id::enforce_id_length(id.as_ref())?;
173 if let Some(existing) = self.store.get(&id)
174 && !existing.stub
175 {
176 return Err(EngineError::AlreadyExists { id: id.to_string() });
177 }
178 let file_path = format!("{slug}.md");
179
180 let mut metadata: IndexMap<String, MetadataValue> = IndexMap::new();
184 metadata.insert(
185 "type".to_string(),
186 MetadataValue::String(args.entity_type.clone()),
187 );
188 for (k, v) in &args.metadata {
189 let parsed = parse_metadata_value(k.as_str(), v.as_str(), type_def.as_ref())?;
190 metadata.insert(k.clone(), parsed);
191 }
192
193 let today = today_iso();
201 let mut ignored_readonly: Vec<WarningHint> = Vec::new();
209 for field_def in &type_def.metadata_fields {
210 if field_def.init_timestamp || field_def.auto_timestamp {
211 if let Some(supplied) = args.metadata.get(field_def.key.as_str()) {
212 ignored_readonly.push(WarningHint::IgnoredReadonlyField {
213 field: field_def.key.clone(),
214 supplied: supplied.clone(),
215 });
216 }
217 metadata.insert(field_def.key.clone(), MetadataValue::String(today.clone()));
218 }
219 }
220
221 let missing_sections = missing_required_sections(type_def.as_ref(), &args.sections);
235 if !missing_sections.is_empty() {
236 let mut type_guidance: BTreeMap<String, Vec<String>> = BTreeMap::new();
237 if missing_sections
238 .iter()
239 .any(|m| m.entity_type == type_def.name)
240 {
241 type_guidance.insert(type_def.name.clone(), type_def.write_rules.clone());
242 }
243 return Err(EngineError::MissingRequiredSection {
244 entity_type: type_def.name.clone(),
245 missing_count: missing_sections.len(),
246 sections: missing_sections,
247 type_guidance,
248 });
249 }
250
251 let missing_fields = missing_required_fields(type_def.as_ref(), &args.metadata);
261 if !missing_fields.is_empty() {
262 let first = missing_fields[0].clone();
269 return Err(EngineError::RequiredFieldUnset {
270 field: first.key,
271 entity_type: first.entity_type,
272 field_description: Some(first.description),
273 enum_values: first.enum_values,
274 type_write_rules: type_def.write_rules.clone(),
275 on_create: true,
281 missing: missing_fields,
282 });
283 }
284
285 let mut warnings: Vec<WarningHint> = Vec::new();
286
287 warnings.append(&mut drift_warnings);
291
292 warnings.append(&mut ignored_readonly);
295
296 if let Some(w) = title_trimmed_warning.take() {
299 warnings.push(w);
300 }
301
302 let type_guidance = build_type_guidance(&warnings, type_def.as_ref());
310
311 for rel in &args.relations {
326 validate_relation_target_grammar(&rel.to)?;
327 let target_mem = rel.to.mem().to_string();
328 super::validate_cross_mem_add_policy(self, &args.mem, &rel.to)?;
333 let target_type = self
339 .store
340 .get(&rel.to)
341 .map(|e| e.entity_type.clone())
342 .filter(|t| !t.is_empty());
343 match route_edge_validation(
344 self,
345 &rel.rel_type,
346 args.entity_type.as_str(),
347 target_type.as_deref(),
348 &args.mem,
349 &target_mem,
350 &id,
351 &rel.to,
352 true,
353 )? {
354 EdgeRouteOutcome::Ok => {}
355 EdgeRouteOutcome::OpenModeWarning(w) => warnings.push(*w),
356 }
357 let normalised = normalise_description(rel.description.as_deref());
360 super::validate_description_posture(
361 self,
362 &rel.rel_type,
363 normalised.as_deref(),
364 &args.mem,
365 &target_mem,
366 &id,
367 &rel.to,
368 )?;
369 super::validate_manual_authoring_posture(self, &rel.rel_type, &args.mem, &id, &rel.to)?;
373 }
374
375 let relationships: Vec<Relationship> = args
382 .relations
383 .iter()
384 .map(|r| Relationship {
385 rel_type: r.rel_type.clone(),
386 target: r.to.clone(),
387 description: normalise_description(r.description.as_deref()),
388 })
389 .collect();
390 let relations_declared: Vec<crate::engine::outcomes::RelationDeclared> = args
397 .relations
398 .iter()
399 .map(|r| crate::engine::outcomes::RelationDeclared {
400 rel_type: r.rel_type.clone(),
401 target: r.to.clone(),
402 target_was_stubbed: !self.store.contains(&r.to),
403 })
404 .collect();
405 let mut entity_for_render = Entity {
406 id: id.clone(),
407 title: args.title.clone(),
408 entity_type: args.entity_type.clone(),
409 mem: args.mem.clone(),
410 file_path: file_path.clone(),
411 metadata,
412 sections: args.sections,
413 relationships,
414 content_hash: String::new(),
415 stub: false,
416 stub_kind: None,
417 heading_spans: HashMap::new(),
418 };
419 let empty_prev_targets = std::collections::HashSet::new();
434 let (synthesised_relations, self_link_ignored) =
435 super::synthesise_alias_relations(self, &empty_prev_targets, &mut entity_for_render)?;
436 if self_link_ignored {
437 warnings.push(WarningHint::SelfLinkIgnored { id: id.clone() });
438 }
439
440 let missing = super::scan_wikilinks_without_relation(&entity_for_render)?;
445 if !missing.is_empty() {
446 return Err(EngineError::WikiLinkWithoutRelation {
447 from_id: id.to_string(),
448 missing: missing
449 .into_iter()
450 .map(|(section_key, target)| crate::engine::MissingWikiLink {
451 section_key,
452 target_id: target.to_string(),
453 })
454 .collect(),
455 });
456 }
457
458 let markdown = generate_markdown(&entity_for_render, type_def.as_ref());
459
460 let auto_stubbed: Vec<EntityId> = synthesised_relations
476 .iter()
477 .filter_map(|rel| {
478 if !self.store.contains(&rel.target) {
479 Some(rel.target.clone())
480 } else {
481 None
482 }
483 })
484 .collect();
485 if !auto_stubbed.is_empty() {
486 warnings.push(WarningHint::InlineWikiLinkAutoStubbed {
487 from: id.clone(),
488 stubs: auto_stubbed,
489 });
490 }
491
492 if args.dry_run {
500 let prospective_hash = crate::entity::parser::compute_hash(&markdown);
501 let created_date = entity_for_render
505 .metadata
506 .get("created_date")
507 .map(|v| v.to_frontmatter_string())
508 .unwrap_or_default();
509 let incoming = project_incoming(self.store.incoming(&id));
513 let incoming_count = (!incoming.is_empty()).then_some(incoming.len());
514 return Ok(CreateEntityOutcome {
515 id,
516 title: args.title,
517 mem: args.mem,
518 file_path,
519 content_hash: prospective_hash,
520 commit_sha: String::new(),
521 created_date,
522 warnings,
523 type_guidance,
524 incoming_count,
525 incoming,
526 relations_declared: relations_declared.clone(),
527 });
528 }
529
530 let backend = self.mounts[mount_idx].backend.as_ref();
536 backend.write_entity(Path::new(&file_path), markdown.as_bytes())?;
537 if !validated_anchors.is_empty() {
542 super::stage_anchors_sidecar(backend, &id, validated_anchors)?;
543 }
544 let commit_subject = format!("memstead: create {id}");
545 let ctx = CommitContext {
546 actor,
547 client: client.cloned(),
548 tool: Some("create_entity"),
549 note: note.map(String::from),
550 logical_operation_id: None,
551 entity_ids: None,
552 };
553 let commit_sha = backend.commit(&commit_subject, &ctx)?;
554
555 backend.append_provenance(&Provenance::new(
558 std::time::SystemTime::now(),
559 ProvenanceKind::Create,
560 Some(id.to_string()),
561 actor,
562 client.cloned(),
563 note.map(String::from),
564 ))?;
565
566 self.record_self_write(mount_idx, &commit_sha);
570
571 let parse_result = parse_markdown(&markdown, &file_path, type_def.as_ref(), &args.mem)
574 .map_err(|e| EngineError::ParseAfterWrite(e.to_string()))?;
575 let content_hash = parse_result.entity.content_hash.clone();
576
577 let created_date = parse_result
584 .entity
585 .metadata
586 .get("created_date")
587 .map(|v| v.to_frontmatter_string())
588 .unwrap_or_default();
589
590 let fallback = engine_fallback_type();
591 push_entities_into_store(&mut self.store, vec![parse_result], fallback.as_ref(), None);
592 crate::entity::store_builder::remap_alias_target_edge_sources(
593 &mut self.store,
594 &self.schemas,
595 );
596
597 for rel in &args.relations {
605 if !self.store.contains(&rel.to) {
606 self.store.upsert(
607 rel.to.clone(),
608 make_stub(&rel.to, crate::entity::StubKind::ForwardReference),
609 );
610 }
611 }
612
613 self.invalidate_communities();
614 self.invalidate_search_indexes();
615
616 let incoming = project_incoming(self.store.incoming(&id));
620 let incoming_count = (!incoming.is_empty()).then_some(incoming.len());
621
622 if let Some(w) = self.note_missing_warning("create_entity", note) {
627 warnings.push(w);
628 }
629
630 Ok(CreateEntityOutcome {
631 id,
632 title: args.title,
633 mem: args.mem,
634 file_path,
635 content_hash,
636 commit_sha,
637 created_date,
638 warnings,
639 type_guidance,
640 incoming_count,
641 incoming,
642 relations_declared,
643 })
644 }
645
646 pub fn create_entity_with_ctx(
650 &mut self,
651 args: CreateEntityArgs,
652 ctx: &CommitContext<'_>,
653 ) -> Result<CreateEntityOutcome, EngineError> {
654 self.create_entity(args, ctx.actor, ctx.client.as_ref(), ctx.note.as_deref())
655 }
656}
657
658#[cfg(test)]
659mod tests {
660
661 use indexmap::IndexMap;
662 use tempfile::TempDir;
663
664 use crate::backend::MemBackend;
665 use crate::engine::test_helpers::*;
666 use crate::engine::{
667 CreateEntityArgs, CreateEntityOutcome, Engine, EngineError, RelateEntityArgs,
668 };
669 use crate::ops::WarningHint;
670 use crate::storage::{ArchiveBackend, FilesystemMemWriter};
671
672 #[test]
673 fn create_entity_writes_through_folder_backend_and_updates_store() {
674 let tmp = TempDir::new().unwrap();
675 let mem_dir = tmp.path().to_path_buf();
676 let writer = FilesystemMemWriter::new(mem_dir.clone());
677 let mut engine = Engine::from_mounts(vec![(
678 folder_mount("specs", mem_dir.clone()),
679 Box::new(writer) as Box<dyn MemBackend>,
680 )])
681 .unwrap();
682 let (actor, client) = cli_actor();
683
684 let outcome = engine
685 .create_entity(
686 empty_create_args("specs", "Hello World"),
687 actor,
688 Some(&client),
689 Some("first draft"),
690 )
691 .unwrap();
692
693 assert_eq!(outcome.id.to_string(), "specs--hello-world");
695 assert_eq!(outcome.file_path, "hello-world.md");
696 assert!(!outcome.content_hash.is_empty());
697
698 let entity = engine
700 .get_entity(&crate::EntityId::new("specs", "hello-world"))
701 .expect("entity must be in the store after create");
702 assert_eq!(entity.title, "Hello World");
703 assert_eq!(entity.entity_type, "spec");
704 assert_eq!(entity.content_hash, outcome.content_hash);
705
706 let on_disk = std::fs::read_to_string(mem_dir.join("hello-world.md")).unwrap();
708 assert!(on_disk.contains("# Hello World"));
709 assert!(on_disk.contains("type: spec"));
710
711 let log_path = mem_dir.join(".memstead").join("changes.jsonl");
713 let log = std::fs::read_to_string(&log_path).unwrap();
714 assert!(log.contains("\"kind\":\"create\""));
715 assert!(log.contains("\"entity\":\"specs--hello-world\""));
716 assert!(log.contains("\"actor\":\"cli\""));
717 assert!(log.contains("\"note\":\"first draft\""));
718 }
719
720 #[test]
726 fn create_entity_warns_on_supplied_auto_managed_field() {
727 let tmp = TempDir::new().unwrap();
728 let mem_dir = tmp.path().to_path_buf();
729 let writer = FilesystemMemWriter::new(mem_dir.clone());
730 let mut engine = Engine::from_mounts(vec![(
731 folder_mount("specs", mem_dir),
732 Box::new(writer) as Box<dyn MemBackend>,
733 )])
734 .unwrap();
735 let (actor, client) = cli_actor();
736
737 let mut args = empty_create_args("specs", "Dated Entity");
738 args.metadata
739 .insert("created_date".to_string(), "2020-01-01".to_string());
740
741 let outcome = engine
742 .create_entity(args, actor, Some(&client), None)
743 .unwrap();
744
745 let warned = outcome.warnings.iter().any(|w| {
746 w.code() == "IGNORED_READONLY_FIELD"
747 && matches!(w, WarningHint::IgnoredReadonlyField { field, supplied }
748 if field == "created_date" && supplied == "2020-01-01")
749 });
750 assert!(
751 warned,
752 "expected IGNORED_READONLY_FIELD; got {:?}",
753 outcome.warnings
754 );
755
756 assert_ne!(outcome.created_date, "2020-01-01");
758 }
759
760 #[test]
763 fn create_entity_no_warning_when_auto_managed_field_absent() {
764 let tmp = TempDir::new().unwrap();
765 let mem_dir = tmp.path().to_path_buf();
766 let writer = FilesystemMemWriter::new(mem_dir.clone());
767 let mut engine = Engine::from_mounts(vec![(
768 folder_mount("specs", mem_dir),
769 Box::new(writer) as Box<dyn MemBackend>,
770 )])
771 .unwrap();
772 let (actor, client) = cli_actor();
773
774 let outcome = engine
775 .create_entity(
776 empty_create_args("specs", "Plain Entity"),
777 actor,
778 Some(&client),
779 None,
780 )
781 .unwrap();
782 assert!(
783 !outcome
784 .warnings
785 .iter()
786 .any(|w| w.code() == "IGNORED_READONLY_FIELD"),
787 "no auto-managed field supplied — no warning expected; got {:?}",
788 outcome.warnings
789 );
790 }
791
792 #[test]
793 fn create_entity_returns_commit_sha_title_mem_on_real_write() {
794 let tmp = TempDir::new().unwrap();
795 let mem_dir = tmp.path().to_path_buf();
796 let writer = FilesystemMemWriter::new(mem_dir.clone());
797 let mut engine = Engine::from_mounts(vec![(
798 folder_mount("specs", mem_dir),
799 Box::new(writer) as Box<dyn MemBackend>,
800 )])
801 .unwrap();
802 let (actor, client) = cli_actor();
803
804 let outcome = engine
805 .create_entity(
806 empty_create_args("specs", "Rich Shape"),
807 actor,
808 Some(&client),
809 None,
810 )
811 .unwrap();
812
813 assert!(
816 !outcome.commit_sha.is_empty(),
817 "commit_sha must be populated on a real create"
818 );
819 assert_eq!(outcome.title, "Rich Shape");
821 assert_eq!(outcome.mem, "specs");
822 assert!(
828 !outcome
829 .warnings
830 .iter()
831 .any(|w| matches!(w, WarningHint::MissingRequiredSection { .. })),
832 "success path must not carry MissingRequiredSection warnings — those refuse on create now",
833 );
834 }
835
836 #[test]
842 fn create_entity_refuses_missing_required_sections_with_typed_envelope() {
843 let tmp = TempDir::new().unwrap();
844 let mem_dir = tmp.path().to_path_buf();
845 let writer = FilesystemMemWriter::new(mem_dir.clone());
846 let mut engine = Engine::from_mounts(vec![(
847 folder_mount("specs", mem_dir),
848 Box::new(writer) as Box<dyn MemBackend>,
849 )])
850 .unwrap();
851 let (actor, client) = cli_actor();
852
853 let args = CreateEntityArgs {
855 anchors: Vec::new(),
856 mem: "specs".to_string(),
857 title: "Half Done".to_string(),
858 entity_type: "spec".to_string(),
859 sections: IndexMap::new(),
860 metadata: IndexMap::new(),
861 relations: Vec::new(),
862 dry_run: false,
863 };
864 let err = engine
865 .create_entity(args, actor, Some(&client), None)
866 .unwrap_err();
867 match err {
868 EngineError::MissingRequiredSection {
869 entity_type,
870 missing_count,
871 sections,
872 type_guidance,
873 } => {
874 assert_eq!(entity_type, "spec");
875 assert_eq!(missing_count, sections.len());
876 assert!(
877 missing_count >= 2,
878 "expected ≥2 missing sections, got {missing_count}"
879 );
880 let keys: Vec<String> = sections.iter().map(|s| s.key.clone()).collect();
881 assert!(
882 keys.contains(&"identity".to_string()),
883 "missing keys: {keys:?}"
884 );
885 assert!(
886 keys.contains(&"purpose".to_string()),
887 "missing keys: {keys:?}"
888 );
889 assert!(
890 type_guidance.contains_key("spec"),
891 "type_guidance must include `spec` entry, got: {type_guidance:?}",
892 );
893 }
894 other => panic!("expected MissingRequiredSection, got {other:?}"),
895 }
896
897 let id = crate::EntityId::new("specs", "half-done");
899 assert!(
900 engine.store().get(&id).is_none(),
901 "refused create must not persist any entity"
902 );
903 }
904
905 #[test]
909 fn create_entity_dry_run_returns_same_refusal_envelope_as_real_call() {
910 let tmp = TempDir::new().unwrap();
911 let mem_dir = tmp.path().to_path_buf();
912 let writer = FilesystemMemWriter::new(mem_dir.clone());
913 let mut engine = Engine::from_mounts(vec![(
914 folder_mount("specs", mem_dir),
915 Box::new(writer) as Box<dyn MemBackend>,
916 )])
917 .unwrap();
918 let (actor, client) = cli_actor();
919
920 let args = CreateEntityArgs {
921 anchors: Vec::new(),
922 mem: "specs".to_string(),
923 title: "Half Done Dry".to_string(),
924 entity_type: "spec".to_string(),
925 sections: IndexMap::new(),
926 metadata: IndexMap::new(),
927 relations: Vec::new(),
928 dry_run: true,
929 };
930 let err = engine
931 .create_entity(args, actor, Some(&client), None)
932 .unwrap_err();
933 assert!(
934 matches!(err, EngineError::MissingRequiredSection { .. }),
935 "dry_run must surface the same refusal envelope, got {err:?}"
936 );
937 }
938
939 #[test]
943 fn create_entity_succeeds_after_filling_in_required_sections() {
944 let tmp = TempDir::new().unwrap();
945 let mem_dir = tmp.path().to_path_buf();
946 let writer = FilesystemMemWriter::new(mem_dir.clone());
947 let mut engine = Engine::from_mounts(vec![(
948 folder_mount("specs", mem_dir),
949 Box::new(writer) as Box<dyn MemBackend>,
950 )])
951 .unwrap();
952 let (actor, client) = cli_actor();
953
954 let mut sections = IndexMap::new();
955 sections.insert("identity".to_string(), "the identity body".to_string());
956 sections.insert("purpose".to_string(), "the purpose body".to_string());
957 let args = CreateEntityArgs {
958 anchors: Vec::new(),
959 mem: "specs".to_string(),
960 title: "Complete".to_string(),
961 entity_type: "spec".to_string(),
962 sections,
963 metadata: IndexMap::new(),
964 relations: Vec::new(),
965 dry_run: false,
966 };
967 let outcome = engine
968 .create_entity(args, actor, Some(&client), None)
969 .expect("complete create succeeds");
970 assert_eq!(outcome.title, "Complete");
971 }
972
973 #[test]
974 fn create_entity_promotes_existing_stub_and_preserves_incoming_edges() {
975 let tmp = TempDir::new().unwrap();
976 let (mut engine, source) = engine_with_seed(&tmp, "Source");
977 let (actor, client) = cli_actor();
978
979 let stub_target = crate::EntityId::new("specs", "ghost-target");
982 engine
983 .relate_entity(
984 RelateEntityArgs {
985 source: source.id.clone(),
986 expected_hash: Some(source.content_hash.clone()),
987 rel_type: "USES".to_string(),
988 target: stub_target.clone(),
989 remove: false,
990 description: None,
991 },
992 actor,
993 Some(&client),
994 None,
995 )
996 .unwrap();
997 let stub = engine
998 .store()
999 .get(&stub_target)
1000 .expect("stub must be in store");
1001 assert!(stub.stub);
1002 assert_eq!(engine.store().incoming(&stub_target).len(), 1);
1003
1004 let outcome = engine
1007 .create_entity(
1008 empty_create_args("specs", "Ghost Target"),
1009 actor,
1010 Some(&client),
1011 None,
1012 )
1013 .unwrap();
1014
1015 assert_eq!(outcome.id, stub_target);
1017 let real = engine
1019 .store()
1020 .get(&stub_target)
1021 .expect("entity must still be in store");
1022 assert!(!real.stub);
1023 assert_eq!(engine.store().incoming(&stub_target).len(), 1);
1025 assert_eq!(outcome.incoming_count, Some(1));
1027 assert_eq!(outcome.incoming.len(), 1);
1028 assert_eq!(outcome.incoming[0].from, source.id);
1029 assert_eq!(outcome.incoming[0].rel_type, "USES");
1030 }
1031
1032 #[test]
1033 fn create_entity_reports_no_incoming_on_greenfield_create() {
1034 let tmp = TempDir::new().unwrap();
1035 let mem_dir = tmp.path().to_path_buf();
1036 let writer = FilesystemMemWriter::new(mem_dir.clone());
1037 let mut engine = Engine::from_mounts(vec![(
1038 folder_mount("specs", mem_dir),
1039 Box::new(writer) as Box<dyn MemBackend>,
1040 )])
1041 .unwrap();
1042 let (actor, client) = cli_actor();
1043
1044 let outcome = engine
1045 .create_entity(
1046 empty_create_args("specs", "Greenfield"),
1047 actor,
1048 Some(&client),
1049 None,
1050 )
1051 .unwrap();
1052 assert!(outcome.incoming_count.is_none());
1055 assert!(outcome.incoming.is_empty());
1056 }
1057
1058 #[test]
1059 fn create_entity_populates_created_date_from_schema_auto_stamp() {
1060 let tmp = TempDir::new().unwrap();
1061 let mem_dir = tmp.path().to_path_buf();
1062 let writer = FilesystemMemWriter::new(mem_dir.clone());
1063 let mut engine = Engine::from_mounts(vec![(
1064 folder_mount("specs", mem_dir),
1065 Box::new(writer) as Box<dyn MemBackend>,
1066 )])
1067 .unwrap();
1068 let (actor, client) = cli_actor();
1069
1070 let outcome = engine
1071 .create_entity(
1072 empty_create_args("specs", "Has Date"),
1073 actor,
1074 Some(&client),
1075 None,
1076 )
1077 .unwrap();
1078 assert!(
1083 !outcome.created_date.is_empty(),
1084 "created_date must be populated when the schema auto-stamps it"
1085 );
1086 }
1087
1088 #[test]
1089 fn create_overrides_user_supplied_timestamps_update_rejects_them() {
1090 let tmp = TempDir::new().unwrap();
1101 let mem_dir = tmp.path().to_path_buf();
1102 let writer = FilesystemMemWriter::new(mem_dir.clone());
1103 let mut engine = Engine::from_mounts(vec![(
1104 folder_mount("specs", mem_dir),
1105 Box::new(writer) as Box<dyn MemBackend>,
1106 )])
1107 .unwrap();
1108 let (actor, client) = cli_actor();
1109
1110 let mut args = empty_create_args("specs", "Stamped Today");
1114 args.metadata
1115 .insert("created_date".to_string(), "2020-01-01".to_string());
1116 args.metadata
1117 .insert("last_modified".to_string(), "2020-01-01".to_string());
1118
1119 let outcome = engine
1120 .create_entity(args, actor, Some(&client), None)
1121 .unwrap();
1122
1123 let today = super::today_iso();
1126 assert_eq!(outcome.created_date, today);
1127 let entity = engine
1128 .get_entity(&outcome.id)
1129 .expect("entity must be in store after create");
1130 assert_eq!(
1131 entity
1132 .metadata
1133 .get("created_date")
1134 .and_then(|v| v.as_str())
1135 .unwrap_or_default(),
1136 today,
1137 "init_timestamp field must be engine-determined on create, not user-supplied"
1138 );
1139 assert_eq!(
1140 entity
1141 .metadata
1142 .get("last_modified")
1143 .and_then(|v| v.as_str())
1144 .unwrap_or_default(),
1145 today,
1146 "auto_timestamp field must be engine-determined on create, not user-supplied"
1147 );
1148
1149 let attempt_update = |key: &str, value: &str| {
1153 let mut metadata = IndexMap::new();
1154 metadata.insert(key.to_string(), value.to_string());
1155 crate::engine::UpdateEntityArgs {
1156 anchors: Vec::new(),
1157 id: outcome.id.clone(),
1158 metadata,
1159 metadata_unset: Vec::new(),
1160 sections: IndexMap::new(),
1161 append_sections: IndexMap::new(),
1162 patch_sections: IndexMap::new(),
1163 expected_hash: Some(outcome.content_hash.clone()),
1164 dry_run: false,
1165 declare_relations: Vec::new(),
1166 relations_unset: Vec::new(),
1167 }
1168 };
1169 for key in ["created_date", "last_modified"] {
1170 let err = engine
1171 .update_entity(
1172 attempt_update(key, "2019-12-31"),
1173 actor,
1174 Some(&client),
1175 None,
1176 )
1177 .expect_err("schema-managed timestamp must be rejected on update");
1178 assert_eq!(err.code(), "READ_ONLY_FIELD", "got: {err:?}");
1179 }
1180 let entity = engine
1182 .get_entity(&outcome.id)
1183 .expect("entity must remain in store after rejected update");
1184 assert_eq!(
1185 entity
1186 .metadata
1187 .get("last_modified")
1188 .and_then(|v| v.as_str())
1189 .unwrap_or_default(),
1190 today,
1191 "rejected update must not mutate the auto_timestamp field"
1192 );
1193 }
1194
1195 #[test]
1196 fn create_entity_wires_inline_relations_and_stubs_absent_targets() {
1197 let tmp = TempDir::new().unwrap();
1198 let (mut engine, existing) = engine_with_seed(&tmp, "Existing Target");
1199 let (actor, client) = cli_actor();
1200 let absent = crate::EntityId::new("specs", "future-target");
1201 assert!(!engine.store().contains(&absent));
1202
1203 let mut args = empty_create_args("specs", "Source With Relations");
1204 args.relations = vec![
1205 crate::ops::RelateArg {
1206 to: existing.id.clone(),
1207 rel_type: "USES".to_string(),
1208 description: None,
1209 },
1210 crate::ops::RelateArg {
1211 to: absent.clone(),
1212 rel_type: "USES".to_string(),
1213 description: None,
1214 },
1215 ];
1216
1217 let outcome = engine
1218 .create_entity(args, actor, Some(&client), None)
1219 .unwrap();
1220
1221 let source = engine
1223 .store()
1224 .get(&outcome.id)
1225 .expect("source must be in store");
1226 assert_eq!(source.relationships.len(), 2);
1227 assert!(
1228 source
1229 .relationships
1230 .iter()
1231 .any(|r| r.target == existing.id && r.rel_type == "USES")
1232 );
1233 assert!(
1234 source
1235 .relationships
1236 .iter()
1237 .any(|r| r.target == absent && r.rel_type == "USES")
1238 );
1239
1240 let stub = engine
1243 .store()
1244 .get(&absent)
1245 .expect("absent relation target must be auto-stubbed");
1246 assert!(stub.stub);
1247 let existing_after = engine.store().get(&existing.id).unwrap();
1249 assert!(!existing_after.stub);
1250 }
1251
1252 fn engine_with_planning_schema(tmp: &TempDir) -> Engine {
1257 use crate::workspace::Mount;
1258 use crate::workspace::{MountCapability, MountLifecycle, MountStorage};
1259 let mem_dir = tmp.path().to_path_buf();
1260 let writer = FilesystemMemWriter::new(mem_dir.clone());
1261 let mount = Mount {
1262 mem: "planning".to_string(),
1263 schema: Some(memstead_schema::SchemaRef::new(
1264 "planning",
1265 semver::Version::new(0, 1, 0),
1266 )),
1267 storage: MountStorage::Folder { path: mem_dir },
1268 capability: MountCapability::Write,
1269 lifecycle: MountLifecycle::Eager,
1270 cross_linkable: true,
1271 migration_target: None,
1272 };
1273 Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap()
1274 }
1275
1276 #[test]
1285 fn create_entity_refuses_unsupplied_no_default_required_field() {
1286 let tmp = TempDir::new().unwrap();
1290 let mut engine = engine_with_planning_schema(&tmp);
1291 let (actor, client) = cli_actor();
1292
1293 let mut args = CreateEntityArgs {
1294 anchors: Vec::new(),
1295 mem: "planning".to_string(),
1296 title: "Skip Postgres".to_string(),
1297 entity_type: "decision".to_string(),
1298 sections: IndexMap::from_iter([
1299 ("decision".to_string(), "Use SQLite locally.".to_string()),
1300 ("context".to_string(), "Single-user dev.".to_string()),
1301 ("consequences".to_string(), "Lose multi-writer.".to_string()),
1302 ]),
1303 metadata: IndexMap::new(),
1304 relations: Vec::new(),
1305 dry_run: false,
1306 };
1307
1308 let err = engine
1311 .create_entity(args.clone(), actor, Some(&client), None)
1312 .unwrap_err();
1313 match err {
1314 EngineError::RequiredFieldUnset {
1315 field, entity_type, ..
1316 } => {
1317 assert!(
1318 field == "decided_on" || field == "deciders",
1319 "expected first missing field, got {field:?}"
1320 );
1321 assert_eq!(entity_type, "decision");
1322 }
1323 other => panic!("expected RequiredFieldUnset, got {other:?}"),
1324 }
1325
1326 args.title = "Different Title".to_string();
1330 args.dry_run = true;
1331 let dry_err = engine
1332 .create_entity(args, actor, Some(&client), None)
1333 .unwrap_err();
1334 assert!(
1335 matches!(dry_err, EngineError::RequiredFieldUnset { .. }),
1336 "dry_run must surface the same refusal envelope, got {dry_err:?}"
1337 );
1338 }
1339
1340 #[test]
1344 fn create_entity_succeeds_when_all_required_no_default_fields_supplied() {
1345 let tmp = TempDir::new().unwrap();
1346 let mut engine = engine_with_planning_schema(&tmp);
1347 let (actor, client) = cli_actor();
1348
1349 let mut metadata = IndexMap::new();
1350 metadata.insert("decided_on".to_string(), "2026-05-13".to_string());
1351 metadata.insert("deciders".to_string(), "alice, bob".to_string());
1352
1353 let outcome = engine
1354 .create_entity(
1355 CreateEntityArgs {
1356 anchors: Vec::new(),
1357 mem: "planning".to_string(),
1358 title: "Complete Decision".to_string(),
1359 entity_type: "decision".to_string(),
1360 sections: IndexMap::from_iter([
1361 ("decision".to_string(), "x".to_string()),
1362 ("context".to_string(), "y".to_string()),
1363 ("consequences".to_string(), "z".to_string()),
1364 ]),
1365 metadata,
1366 relations: Vec::new(),
1367 dry_run: false,
1368 },
1369 actor,
1370 Some(&client),
1371 None,
1372 )
1373 .expect("complete decision create succeeds");
1374 let missing_field_warnings: Vec<&WarningHint> = outcome
1377 .warnings
1378 .iter()
1379 .filter(|w| matches!(w, WarningHint::MissingRequiredField { .. }))
1380 .collect();
1381 assert!(
1382 missing_field_warnings.is_empty(),
1383 "success path must not carry MissingRequiredField warnings, got: {missing_field_warnings:?}"
1384 );
1385 }
1386
1387 #[test]
1392 fn create_entity_rejects_inline_relation_with_malformed_target_id() {
1393 let tmp = TempDir::new().unwrap();
1394 let mem_dir = tmp.path().to_path_buf();
1395 let writer = FilesystemMemWriter::new(mem_dir.clone());
1396 let mut engine = Engine::from_mounts(vec![(
1397 folder_mount("specs", mem_dir),
1398 Box::new(writer) as Box<dyn MemBackend>,
1399 )])
1400 .unwrap();
1401 let (actor, client) = cli_actor();
1402
1403 let mut args = empty_create_args("specs", "Source");
1404 args.relations = vec![crate::ops::RelateArg {
1405 to: crate::EntityId("specs--bad target with spaces!!".to_string()),
1406 rel_type: "USES".to_string(),
1407 description: None,
1408 }];
1409 let err = engine
1410 .create_entity(args, actor, Some(&client), None)
1411 .unwrap_err();
1412 assert!(
1413 matches!(err, EngineError::InvalidEntityId { .. }),
1414 "malformed target id must trip INVALID_ENTITY_ID on the create path; got {err:?}",
1415 );
1416 }
1417
1418 #[test]
1427 fn create_entity_rejects_inline_relation_with_shape_violation() {
1428 use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
1429 let tmp = TempDir::new().unwrap();
1430 let mem_dir = tmp.path().to_path_buf();
1431 let writer = FilesystemMemWriter::new(mem_dir.clone());
1432 let mount = Mount {
1433 mem: "code".to_string(),
1434 schema: Some(memstead_schema::SchemaRef::new(
1435 "software",
1436 semver::Version::new(0, 1, 0),
1437 )),
1438 storage: MountStorage::Folder { path: mem_dir },
1439 capability: MountCapability::Write,
1440 lifecycle: MountLifecycle::Eager,
1441 cross_linkable: true,
1442 migration_target: None,
1443 };
1444 let mut engine =
1445 Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap();
1446 let (actor, client) = cli_actor();
1447
1448 let target = engine
1456 .create_entity(
1457 CreateEntityArgs {
1458 anchors: Vec::new(),
1459 mem: "code".to_string(),
1460 title: "Target Requirement".to_string(),
1461 entity_type: "requirement".to_string(),
1462 sections: IndexMap::from_iter([
1463 ("statement".to_string(), "MUST hold.".to_string()),
1464 ("rationale".to_string(), "Because tests.".to_string()),
1465 ]),
1466 metadata: IndexMap::from_iter([
1467 ("verified_on".to_string(), "2026-05-19".to_string()),
1468 ("source".to_string(), "test fixture".to_string()),
1469 ]),
1470 relations: Vec::new(),
1471 dry_run: false,
1472 },
1473 actor,
1474 Some(&client),
1475 None,
1476 )
1477 .unwrap();
1478
1479 let args = CreateEntityArgs {
1485 anchors: Vec::new(),
1486 mem: "code".to_string(),
1487 title: "Misshape Source".to_string(),
1488 entity_type: "spec".to_string(),
1489 sections: IndexMap::from_iter([
1490 ("identity".to_string(), "this spec".to_string()),
1491 (
1492 "purpose".to_string(),
1493 "exercising the shape gate".to_string(),
1494 ),
1495 ]),
1496 metadata: IndexMap::new(),
1497 relations: vec![crate::ops::RelateArg {
1498 to: target.id.clone(),
1499 rel_type: "VIOLATES".to_string(),
1500 description: None,
1501 }],
1502 dry_run: false,
1503 };
1504 let err = engine
1505 .create_entity(args, actor, Some(&client), None)
1506 .unwrap_err();
1507 assert!(
1508 matches!(err, EngineError::Validation(_)),
1509 "shape violation must trip Validation(InvalidRelationshipShape); got {err:?}",
1510 );
1511 }
1512
1513 #[test]
1514 fn create_entity_canonicalises_inline_relation_rel_types_to_upper_snake_case() {
1515 let tmp = TempDir::new().unwrap();
1519 let (mut engine, existing) = engine_with_seed(&tmp, "Existing Target");
1520 let (actor, client) = cli_actor();
1521
1522 let mut args = empty_create_args("specs", "Source With Mixed Case Rel");
1523 args.relations = vec![crate::ops::RelateArg {
1524 to: existing.id.clone(),
1525 rel_type: "uses".to_string(),
1526 description: None,
1527 }];
1528
1529 let outcome = engine
1530 .create_entity(args, actor, Some(&client), None)
1531 .unwrap();
1532
1533 let source = engine
1534 .store()
1535 .get(&outcome.id)
1536 .expect("source must be in store");
1537 assert_eq!(source.relationships.len(), 1);
1538 assert_eq!(
1539 source.relationships[0].rel_type, "USES",
1540 "inline relation rel_type must be stored UPPER_SNAKE_CASE",
1541 );
1542 }
1543
1544 #[test]
1545 fn create_entity_dry_run_skips_disk_and_store_yet_returns_hash() {
1546 let tmp = TempDir::new().unwrap();
1547 let mem_dir = tmp.path().to_path_buf();
1548 let writer = FilesystemMemWriter::new(mem_dir.clone());
1549 let mut engine = Engine::from_mounts(vec![(
1550 folder_mount("specs", mem_dir.clone()),
1551 Box::new(writer) as Box<dyn MemBackend>,
1552 )])
1553 .unwrap();
1554 let (actor, client) = cli_actor();
1555
1556 let mut args = empty_create_args("specs", "Preview Only");
1557 args.dry_run = true;
1558
1559 let outcome = engine
1560 .create_entity(args, actor, Some(&client), None)
1561 .unwrap();
1562
1563 assert_eq!(outcome.id.to_string(), "specs--preview-only");
1565 assert!(
1566 !outcome.content_hash.is_empty(),
1567 "prospective hash populated"
1568 );
1569 assert!(outcome.commit_sha.is_empty(), "no commit on dry_run");
1570 assert!(
1572 engine.store().get(&outcome.id).is_none(),
1573 "dry_run must not mutate the store",
1574 );
1575 assert!(
1577 !mem_dir.join("preview-only.md").exists(),
1578 "dry_run must not touch disk",
1579 );
1580 let log = mem_dir.join(".memstead").join("changes.jsonl");
1582 assert!(
1583 !log.exists()
1584 || !std::fs::read_to_string(&log)
1585 .unwrap()
1586 .contains("preview-only"),
1587 "dry_run must not append provenance",
1588 );
1589 }
1590
1591 #[test]
1592 fn create_entity_rejects_read_only_mount_before_backend() {
1593 let tmp = TempDir::new().unwrap();
1594 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"# a")]);
1595 let mut engine = Engine::from_mounts(vec![(
1596 archive_mount("external", archive_path.clone()),
1597 Box::new(ArchiveBackend::new(archive_path)),
1598 )])
1599 .unwrap();
1600 let (actor, client) = cli_actor();
1601
1602 let err = engine
1603 .create_entity(
1604 empty_create_args("external", "Should Fail"),
1605 actor,
1606 Some(&client),
1607 None,
1608 )
1609 .unwrap_err();
1610 match err {
1611 EngineError::ReadOnlyMount(v) => assert_eq!(v, "external"),
1612 other => panic!("expected ReadOnlyMount, got {other:?}"),
1613 }
1614 }
1618
1619 #[test]
1620 fn create_entity_rejects_unknown_mem() {
1621 let tmp = TempDir::new().unwrap();
1622 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
1623 let mut engine = Engine::from_mounts(vec![(
1624 folder_mount("specs", tmp.path().to_path_buf()),
1625 Box::new(writer) as Box<dyn MemBackend>,
1626 )])
1627 .unwrap();
1628 let (actor, client) = cli_actor();
1629
1630 let err = engine
1631 .create_entity(
1632 empty_create_args("does-not-exist", "Anything"),
1633 actor,
1634 Some(&client),
1635 None,
1636 )
1637 .unwrap_err();
1638 assert!(matches!(err, EngineError::UnknownMem(v) if v == "does-not-exist"));
1639 }
1640
1641 #[test]
1642 fn create_entity_rejects_unknown_type_against_pinned_schema() {
1643 let tmp = TempDir::new().unwrap();
1644 let mem_dir = tmp.path().to_path_buf();
1645 let writer = FilesystemMemWriter::new(mem_dir.clone());
1646 let mut engine = Engine::from_mounts(vec![(
1647 folder_mount("specs", mem_dir),
1648 Box::new(writer) as Box<dyn MemBackend>,
1649 )])
1650 .unwrap();
1651 let (actor, client) = cli_actor();
1652
1653 let mut args = empty_create_args("specs", "Anything");
1654 args.entity_type = "definitely-not-a-real-type".to_string();
1655 let err = engine
1656 .create_entity(args, actor, Some(&client), None)
1657 .unwrap_err();
1658 match err {
1659 EngineError::UnknownType { name, declared, .. } => {
1660 assert_eq!(name, "definitely-not-a-real-type");
1661 assert!(!declared.is_empty(), "declared types must be listed");
1662 }
1663 other => panic!("expected UnknownType, got {other:?}"),
1664 }
1665 }
1666
1667 #[test]
1668 fn create_entity_rejects_duplicate_id() {
1669 let tmp = TempDir::new().unwrap();
1670 let mem_dir = tmp.path().to_path_buf();
1671 let writer = FilesystemMemWriter::new(mem_dir.clone());
1672 let mut engine = Engine::from_mounts(vec![(
1673 folder_mount("specs", mem_dir),
1674 Box::new(writer) as Box<dyn MemBackend>,
1675 )])
1676 .unwrap();
1677 let (actor, client) = cli_actor();
1678
1679 engine
1680 .create_entity(
1681 empty_create_args("specs", "Same Slug"),
1682 actor,
1683 Some(&client),
1684 None,
1685 )
1686 .unwrap();
1687 let err = engine
1688 .create_entity(
1689 empty_create_args("specs", "Same Slug"),
1690 actor,
1691 Some(&client),
1692 None,
1693 )
1694 .unwrap_err();
1695 match err {
1696 EngineError::AlreadyExists { id } => assert_eq!(id, "specs--same-slug"),
1697 other => panic!("expected AlreadyExists, got {other:?}"),
1698 }
1699 }
1700
1701 #[test]
1702 fn create_entity_rejects_invalid_title() {
1703 let tmp = TempDir::new().unwrap();
1704 let mem_dir = tmp.path().to_path_buf();
1705 let writer = FilesystemMemWriter::new(mem_dir.clone());
1706 let mut engine = Engine::from_mounts(vec![(
1707 folder_mount("specs", mem_dir),
1708 Box::new(writer) as Box<dyn MemBackend>,
1709 )])
1710 .unwrap();
1711 let (actor, client) = cli_actor();
1712
1713 let err = engine
1719 .create_entity(empty_create_args("specs", " "), actor, Some(&client), None)
1720 .unwrap_err();
1721 match err {
1722 EngineError::InvalidTitle(slug_err) => {
1723 assert_eq!(slug_err.reason(), "empty", "expected empty reason");
1724 }
1725 other => panic!("expected InvalidTitle/TitleEmpty, got {other:?}"),
1726 }
1727
1728 let err = engine
1731 .create_entity(
1732 empty_create_args("specs", "Hello, World!"),
1733 actor,
1734 Some(&client),
1735 None,
1736 )
1737 .unwrap_err();
1738 match err {
1739 EngineError::InvalidTitle(crate::SlugError::TitleHasInvalidChars {
1740 invalid_chars,
1741 proposed_slug,
1742 ..
1743 }) => {
1744 assert!(invalid_chars.contains(&',') && invalid_chars.contains(&'!'));
1745 assert_eq!(proposed_slug, "hello-world");
1746 }
1747 other => panic!("expected InvalidTitle/TitleHasInvalidChars, got {other:?}"),
1748 }
1749
1750 let err = engine
1753 .create_entity(
1754 empty_create_args("specs", "../etc/passwd"),
1755 actor,
1756 Some(&client),
1757 None,
1758 )
1759 .unwrap_err();
1760 match err {
1761 EngineError::InvalidTitle(slug_err) => {
1762 assert_eq!(slug_err.reason(), "invalid_chars");
1763 }
1764 other => panic!("expected InvalidTitle, got {other:?}"),
1765 }
1766 }
1767
1768 #[test]
1769 fn create_entity_rejects_unknown_section_key() {
1770 let tmp = TempDir::new().unwrap();
1771 let mem_dir = tmp.path().to_path_buf();
1772 let writer = FilesystemMemWriter::new(mem_dir.clone());
1773 let mut engine = Engine::from_mounts(vec![(
1774 folder_mount("specs", mem_dir),
1775 Box::new(writer) as Box<dyn MemBackend>,
1776 )])
1777 .unwrap();
1778 let (actor, client) = cli_actor();
1779
1780 let mut args = empty_create_args("specs", "Bad Sections");
1781 args.sections
1782 .insert("not-a-real-section-key".to_string(), "body".to_string());
1783 let err = engine
1784 .create_entity(args, actor, Some(&client), None)
1785 .unwrap_err();
1786 assert!(matches!(err, EngineError::Validation(_)));
1787 }
1788
1789 #[test]
1790 fn create_entity_persists_across_engine_restart() {
1791 let tmp = TempDir::new().unwrap();
1792 let mem_dir = tmp.path().to_path_buf();
1793 {
1794 let writer = FilesystemMemWriter::new(mem_dir.clone());
1795 let mut engine = Engine::from_mounts(vec![(
1796 folder_mount("specs", mem_dir.clone()),
1797 Box::new(writer) as Box<dyn MemBackend>,
1798 )])
1799 .unwrap();
1800 let (actor, client) = cli_actor();
1801 engine
1802 .create_entity(
1803 empty_create_args("specs", "Survives Restart"),
1804 actor,
1805 Some(&client),
1806 None,
1807 )
1808 .unwrap();
1809 }
1810 let writer2 = FilesystemMemWriter::new(mem_dir.clone());
1812 let engine2 = Engine::from_mounts(vec![(
1813 folder_mount("specs", mem_dir),
1814 Box::new(writer2) as Box<dyn MemBackend>,
1815 )])
1816 .unwrap();
1817 let entity = engine2
1818 .get_entity(&crate::EntityId::new("specs", "survives-restart"))
1819 .expect("entity must persist across engine restart");
1820 assert_eq!(entity.title, "Survives Restart");
1821 }
1822
1823 fn engine_with_seed(tmp: &TempDir, title: &str) -> (Engine, CreateEntityOutcome) {
1830 let mem_dir = tmp.path().to_path_buf();
1831 let writer = FilesystemMemWriter::new(mem_dir.clone());
1832 let mut engine = Engine::from_mounts(vec![(
1833 folder_mount("specs", mem_dir),
1834 Box::new(writer) as Box<dyn MemBackend>,
1835 )])
1836 .unwrap();
1837 let (actor, client) = cli_actor();
1838 let outcome = engine
1839 .create_entity(
1840 empty_create_args("specs", title),
1841 actor,
1842 Some(&client),
1843 None,
1844 )
1845 .unwrap();
1846 (engine, outcome)
1847 }
1848
1849 #[test]
1857 fn create_entity_emits_inline_wiki_link_auto_stubbed_for_new_stub_target() {
1858 let tmp = TempDir::new().unwrap();
1859 let mem_dir = tmp.path().to_path_buf();
1860 let writer = FilesystemMemWriter::new(mem_dir.clone());
1861 let mut engine = Engine::from_mounts(vec![(
1862 folder_mount("specs", mem_dir),
1863 Box::new(writer) as Box<dyn MemBackend>,
1864 )])
1865 .unwrap();
1866 let (actor, client) = cli_actor();
1867
1868 let ghost = crate::EntityId::new("specs", "ghost-target");
1869 assert!(!engine.store().contains(&ghost), "ghost must not pre-exist");
1870
1871 let mut args = empty_create_args("specs", "Source With Body Link");
1872 args.sections.insert(
1873 "identity".to_string(),
1874 "ref [[ghost-target]] for context".to_string(),
1875 );
1876
1877 let outcome = engine
1878 .create_entity(args, actor, Some(&client), None)
1879 .unwrap();
1880 let stubbed: Vec<&crate::EntityId> = outcome
1881 .warnings
1882 .iter()
1883 .filter_map(|w| match w {
1884 WarningHint::InlineWikiLinkAutoStubbed { stubs, .. } => Some(stubs),
1885 _ => None,
1886 })
1887 .flatten()
1888 .collect();
1889 assert!(
1890 stubbed.contains(&&ghost),
1891 "INLINE_WIKI_LINK_AUTO_STUBBED warning must name the ghost target; got: {:?}",
1892 outcome.warnings,
1893 );
1894 assert!(
1896 engine.store().contains(&ghost),
1897 "ghost stub must materialise"
1898 );
1899 }
1900
1901 #[test]
1906 fn create_entity_drops_self_link_keeps_other_links_and_warns() {
1907 let tmp = TempDir::new().unwrap();
1908 let mem_dir = tmp.path().to_path_buf();
1909 let writer = FilesystemMemWriter::new(mem_dir.clone());
1910 let mut engine = Engine::from_mounts(vec![(
1911 folder_mount("specs", mem_dir),
1912 Box::new(writer) as Box<dyn MemBackend>,
1913 )])
1914 .unwrap();
1915 let (actor, client) = cli_actor();
1916
1917 let mut args = empty_create_args("specs", "Selfie");
1920 args.sections.insert(
1921 "identity".to_string(),
1922 "see [[selfie]] itself and also [[other-ref]]".to_string(),
1923 );
1924 let outcome = engine
1925 .create_entity(args, actor, Some(&client), None)
1926 .unwrap();
1927 let self_id = outcome.id.clone();
1928 assert_eq!(self_id.to_string(), "specs--selfie");
1929 let other_id = crate::EntityId::new("specs", "other-ref");
1930
1931 assert!(
1933 outcome.warnings.iter().any(|w| matches!(
1934 w, WarningHint::SelfLinkIgnored { id } if *id == self_id
1935 )),
1936 "self-link must emit SELF_LINK_IGNORED; got: {:?}",
1937 outcome.warnings,
1938 );
1939
1940 let ent = engine.get_entity(&self_id).unwrap();
1942 assert!(
1943 ent.relationships.iter().all(|r| r.target != self_id),
1944 "no self-relation may be synthesised; got: {:?}",
1945 ent.relationships,
1946 );
1947 assert!(
1948 engine
1949 .store()
1950 .outgoing(&self_id)
1951 .iter()
1952 .all(|e| e.target != self_id),
1953 "self must not be its own Outgoing neighbour",
1954 );
1955 assert!(
1956 engine
1957 .store()
1958 .incoming(&self_id)
1959 .iter()
1960 .all(|e| e.from != self_id),
1961 "self must not be its own Incoming neighbour",
1962 );
1963
1964 assert!(
1967 ent.relationships
1968 .iter()
1969 .any(|r| r.rel_type == "REFERENCES" && r.target == other_id),
1970 "non-self body link must still synthesise its edge; got: {:?}",
1971 ent.relationships,
1972 );
1973 }
1974
1975 #[test]
1977 fn create_entity_dry_run_emits_same_auto_stub_warning() {
1978 let tmp = TempDir::new().unwrap();
1979 let mem_dir = tmp.path().to_path_buf();
1980 let writer = FilesystemMemWriter::new(mem_dir.clone());
1981 let mut engine = Engine::from_mounts(vec![(
1982 folder_mount("specs", mem_dir),
1983 Box::new(writer) as Box<dyn MemBackend>,
1984 )])
1985 .unwrap();
1986 let (actor, client) = cli_actor();
1987
1988 let mut args = empty_create_args("specs", "Dry Run Body Link");
1989 args.dry_run = true;
1990 args.sections
1991 .insert("identity".to_string(), "see [[dry-run-ghost]]".to_string());
1992
1993 let outcome = engine
1994 .create_entity(args, actor, Some(&client), None)
1995 .unwrap();
1996 let has_warning = outcome.warnings.iter().any(|w| {
1997 matches!(
1998 w,
1999 WarningHint::InlineWikiLinkAutoStubbed { stubs, .. }
2000 if stubs.iter().any(|t| t.to_string() == "specs--dry-run-ghost")
2001 )
2002 });
2003 assert!(
2004 has_warning,
2005 "dry_run must emit the same warning as real write: {:?}",
2006 outcome.warnings
2007 );
2008 }
2009
2010 #[test]
2013 fn create_entity_no_auto_stub_warning_when_target_exists() {
2014 let tmp = TempDir::new().unwrap();
2015 let (mut engine, existing) = engine_with_seed(&tmp, "Existing Target");
2016 let (actor, client) = cli_actor();
2017
2018 let mut args = empty_create_args("specs", "Source Linking Existing");
2019 let body = format!("ref [[{}]]", existing.id.path());
2020 args.sections.insert("identity".to_string(), body);
2021
2022 let outcome = engine
2023 .create_entity(args, actor, Some(&client), None)
2024 .unwrap();
2025 let has_warning = outcome
2026 .warnings
2027 .iter()
2028 .any(|w| matches!(w, WarningHint::InlineWikiLinkAutoStubbed { .. }));
2029 assert!(
2030 !has_warning,
2031 "no auto-stub warning when target pre-exists; got: {:?}",
2032 outcome.warnings
2033 );
2034 }
2035
2036 fn engine_with_two_default_mems() -> (TempDir, TempDir, Engine) {
2041 let tmp_test = TempDir::new().unwrap();
2042 let tmp_other = TempDir::new().unwrap();
2043 let test_dir = tmp_test.path().to_path_buf();
2044 let other_dir = tmp_other.path().to_path_buf();
2045 let writer_test = FilesystemMemWriter::new(test_dir.clone());
2046 let writer_other = FilesystemMemWriter::new(other_dir.clone());
2047 let engine = Engine::from_mounts(vec![
2048 (
2049 folder_mount("test", test_dir),
2050 Box::new(writer_test) as Box<dyn MemBackend>,
2051 ),
2052 (
2053 folder_mount("other", other_dir),
2054 Box::new(writer_other) as Box<dyn MemBackend>,
2055 ),
2056 ])
2057 .unwrap();
2058 (tmp_test, tmp_other, engine)
2059 }
2060
2061 #[test]
2066 fn create_entity_refuses_inline_cross_mem_relation_when_policy_denies() {
2067 use crate::entity::EntityId;
2068 use crate::ops::RelateArg;
2069 use memstead_schema::workspace_config::CrossLinkValue;
2070
2071 let (_tmp_test, _tmp_other, mut engine) = engine_with_two_default_mems();
2072 let (actor, client) = cli_actor();
2073
2074 let mut settings = crate::workspace::WorkspaceSettings::default();
2077 settings.cross_mem_links.insert(
2078 "test".to_string(),
2079 CrossLinkValue::List(vec!["other".to_string()]),
2080 );
2081 engine.set_settings(settings);
2082
2083 let target = engine
2088 .create_entity(
2089 empty_create_args("test", "Target"),
2090 actor,
2091 Some(&client),
2092 None,
2093 )
2094 .unwrap();
2095
2096 let mut args = empty_create_args("other", "Source");
2097 args.relations = vec![RelateArg {
2098 rel_type: "IMPLEMENTS".to_string(),
2099 to: target.id.clone(),
2100 description: None,
2101 }];
2102 let err = engine
2103 .create_entity(args, actor, Some(&client), None)
2104 .unwrap_err();
2105 match err {
2106 EngineError::CrossMemLinkNotAllowed { from_mem, to_mem } => {
2107 assert_eq!(from_mem, "other");
2108 assert_eq!(to_mem, "test");
2109 }
2110 other => panic!("expected CROSS_MEM_LINK_NOT_ALLOWED, got {other:?}"),
2111 }
2112
2113 let would_be = EntityId::new("other", "source");
2115 assert!(
2116 engine.get_entity(&would_be).is_none(),
2117 "entity must not persist when inline relation refuses"
2118 );
2119 }
2120
2121 #[test]
2124 fn create_entity_allows_inline_cross_mem_relation_when_policy_grants() {
2125 use crate::ops::RelateArg;
2126 use memstead_schema::workspace_config::CrossLinkValue;
2127
2128 let (_tmp_test, _tmp_other, mut engine) = engine_with_two_default_mems();
2129 let (actor, client) = cli_actor();
2130
2131 let mut settings = crate::workspace::WorkspaceSettings::default();
2132 settings.cross_mem_links.insert(
2133 "other".to_string(),
2134 CrossLinkValue::List(vec!["test".to_string()]),
2135 );
2136 engine.set_settings(settings);
2137
2138 let target = engine
2139 .create_entity(
2140 empty_create_args("test", "Target"),
2141 actor,
2142 Some(&client),
2143 None,
2144 )
2145 .unwrap();
2146
2147 let mut args = empty_create_args("other", "Source");
2148 args.relations = vec![RelateArg {
2149 rel_type: "IMPLEMENTS".to_string(),
2150 to: target.id.clone(),
2151 description: None,
2152 }];
2153 let outcome = engine
2154 .create_entity(args, actor, Some(&client), None)
2155 .unwrap();
2156 let stored = engine.get_entity(&outcome.id).expect("entity persists");
2157 assert!(
2158 stored
2159 .relationships
2160 .iter()
2161 .any(|r| r.rel_type == "IMPLEMENTS" && r.target == target.id),
2162 "IMPLEMENTS edge must persist on the source's relationships",
2163 );
2164 }
2165
2166 #[test]
2170 fn create_entity_admits_same_mem_inline_relation_regardless_of_policy() {
2171 use crate::ops::RelateArg;
2172
2173 let (_tmp_test, _tmp_other, mut engine) = engine_with_two_default_mems();
2174 let (actor, client) = cli_actor();
2175 let target = engine
2178 .create_entity(
2179 empty_create_args("test", "Target"),
2180 actor,
2181 Some(&client),
2182 None,
2183 )
2184 .unwrap();
2185 let mut args = empty_create_args("test", "Source");
2186 args.relations = vec![RelateArg {
2187 rel_type: "USES".to_string(),
2188 to: target.id.clone(),
2189 description: None,
2190 }];
2191 let outcome = engine
2192 .create_entity(args, actor, Some(&client), None)
2193 .unwrap();
2194 let stored = engine.get_entity(&outcome.id).expect("entity persists");
2195 assert!(
2196 stored
2197 .relationships
2198 .iter()
2199 .any(|r| r.rel_type == "USES" && r.target == target.id),
2200 "same-mem USES edge must persist",
2201 );
2202 }
2203
2204 #[test]
2209 fn relate_and_create_refuse_cross_mem_policy_with_identical_envelope() {
2210 use crate::ops::RelateArg;
2211 use memstead_schema::workspace_config::CrossLinkValue;
2212
2213 let (_tmp_test, _tmp_other, mut engine) = engine_with_two_default_mems();
2214 let (actor, client) = cli_actor();
2215
2216 let mut settings = crate::workspace::WorkspaceSettings::default();
2217 settings.cross_mem_links.insert(
2218 "test".to_string(),
2219 CrossLinkValue::List(vec!["other".to_string()]),
2220 );
2221 engine.set_settings(settings);
2222
2223 let target = engine
2224 .create_entity(
2225 empty_create_args("test", "Target"),
2226 actor,
2227 Some(&client),
2228 None,
2229 )
2230 .unwrap();
2231 let src = engine
2232 .create_entity(
2233 empty_create_args("other", "Source"),
2234 actor,
2235 Some(&client),
2236 None,
2237 )
2238 .unwrap();
2239
2240 let relate_err = engine
2242 .relate_entity(
2243 RelateEntityArgs {
2244 source: src.id.clone(),
2245 rel_type: "IMPLEMENTS".to_string(),
2246 target: target.id.clone(),
2247 expected_hash: Some(src.content_hash.clone()),
2248 remove: false,
2249 description: None,
2250 },
2251 actor,
2252 Some(&client),
2253 None,
2254 )
2255 .unwrap_err();
2256
2257 let mut create_args = empty_create_args("other", "Source Two");
2260 create_args.relations = vec![RelateArg {
2261 rel_type: "IMPLEMENTS".to_string(),
2262 to: target.id.clone(),
2263 description: None,
2264 }];
2265 let create_err = engine
2266 .create_entity(create_args, actor, Some(&client), None)
2267 .unwrap_err();
2268
2269 match (relate_err, create_err) {
2272 (
2273 EngineError::CrossMemLinkNotAllowed {
2274 from_mem: rfv,
2275 to_mem: rtv,
2276 },
2277 EngineError::CrossMemLinkNotAllowed {
2278 from_mem: cfv,
2279 to_mem: ctv,
2280 },
2281 ) => {
2282 assert_eq!(rfv, "other");
2283 assert_eq!(rtv, "test");
2284 assert_eq!(cfv, "other");
2285 assert_eq!(ctv, "test");
2286 }
2287 (a, b) => panic!(
2288 "expected matching CROSS_MEM_LINK_NOT_ALLOWED on both surfaces; got relate={a:?}, create={b:?}"
2289 ),
2290 }
2291 }
2292
2293 #[test]
2299 fn create_entity_body_link_cross_mem_dash_form_routes_correctly() {
2300 use crate::entity::EntityId;
2301 use indexmap::IndexMap;
2302 use memstead_schema::workspace_config::CrossLinkValue;
2303
2304 let (_tmp_test, _tmp_other, mut engine) = engine_with_two_default_mems();
2305 let (actor, client) = cli_actor();
2306
2307 let mut settings = crate::workspace::WorkspaceSettings::default();
2308 settings.cross_mem_links.insert(
2309 "test".to_string(),
2310 CrossLinkValue::List(vec!["other".to_string()]),
2311 );
2312 engine.set_settings(settings);
2313
2314 let mut sections: IndexMap<String, String> = IndexMap::new();
2315 sections.insert(
2316 "identity".to_string(),
2317 "see [[other--target]] for details".to_string(),
2318 );
2319 sections.insert("purpose".to_string(), "source purpose".to_string());
2320 let outcome = engine
2321 .create_entity(
2322 crate::engine::CreateEntityArgs {
2323 anchors: Vec::new(),
2324 mem: "test".to_string(),
2325 title: "Source".to_string(),
2326 entity_type: "spec".to_string(),
2327 sections,
2328 metadata: IndexMap::new(),
2329 relations: Vec::new(),
2330 dry_run: false,
2331 },
2332 actor,
2333 Some(&client),
2334 None,
2335 )
2336 .unwrap();
2337
2338 let canonical = EntityId::new("other", "target");
2340 assert!(
2341 engine.get_entity(&canonical).is_some(),
2342 "auto-stub must land at the canonical cross-mem id"
2343 );
2344 let phantom = EntityId::new("test", "other--target");
2345 assert!(
2346 engine.get_entity(&phantom).is_none(),
2347 "no double-prefixed phantom stub"
2348 );
2349
2350 let source = engine.get_entity(&outcome.id).unwrap();
2352 let references_count = source
2353 .relationships
2354 .iter()
2355 .filter(|r| r.rel_type == "REFERENCES" && r.target == canonical)
2356 .count();
2357 assert_eq!(
2358 references_count, 1,
2359 "alias-synthesis must emit exactly one REFERENCES edge per cross-mem body link",
2360 );
2361 }
2362
2363 #[test]
2367 fn create_entity_body_link_cross_mem_refused_when_policy_denies() {
2368 use indexmap::IndexMap;
2369
2370 let (_tmp_test, _tmp_other, mut engine) = engine_with_two_default_mems();
2371 let (actor, client) = cli_actor();
2372 let mut sections: IndexMap<String, String> = IndexMap::new();
2375 sections.insert(
2376 "identity".to_string(),
2377 "see [[other--target]] for details".to_string(),
2378 );
2379 sections.insert("purpose".to_string(), "source purpose".to_string());
2380 let err = engine
2381 .create_entity(
2382 crate::engine::CreateEntityArgs {
2383 anchors: Vec::new(),
2384 mem: "test".to_string(),
2385 title: "Source".to_string(),
2386 entity_type: "spec".to_string(),
2387 sections,
2388 metadata: IndexMap::new(),
2389 relations: Vec::new(),
2390 dry_run: false,
2391 },
2392 actor,
2393 Some(&client),
2394 None,
2395 )
2396 .unwrap_err();
2397 match err {
2398 EngineError::CrossMemLinkNotAllowed { from_mem, to_mem } => {
2399 assert_eq!(from_mem, "test");
2400 assert_eq!(to_mem, "other");
2401 }
2402 other => panic!("expected CROSS_MEM_LINK_NOT_ALLOWED, got {other:?}"),
2403 }
2404 }
2405
2406 #[test]
2414 fn require_notes_drives_single_note_missing_warning_per_noteless_mutation() {
2415 use crate::engine::UpdateEntityArgs;
2416 use crate::workspace::{MutationsSection, WorkspaceSettings};
2417 use indexmap::IndexMap;
2418
2419 let tmp = TempDir::new().unwrap();
2420 let mem_dir = tmp.path().to_path_buf();
2421 let writer = FilesystemMemWriter::new(mem_dir.clone());
2422 let mut engine = Engine::from_mounts(vec![(
2423 folder_mount("specs", mem_dir.clone()),
2424 Box::new(writer) as Box<dyn MemBackend>,
2425 )])
2426 .unwrap();
2427 engine.set_workspace_root(mem_dir.clone());
2428 engine.set_settings(WorkspaceSettings {
2429 mutations: MutationsSection {
2430 require_notes: Some(true),
2431 },
2432 ..Default::default()
2433 });
2434 let (actor, client) = cli_actor();
2435
2436 let note_missing = |ws: &[WarningHint]| -> usize {
2437 ws.iter()
2438 .filter(|w| matches!(w, WarningHint::NoteMissing { tool: _ }))
2439 .count()
2440 };
2441
2442 let created = engine
2444 .create_entity(
2445 empty_create_args("specs", "Noteless"),
2446 actor,
2447 Some(&client),
2448 None,
2449 )
2450 .unwrap();
2451 assert_eq!(
2452 note_missing(&created.warnings),
2453 1,
2454 "create under require_notes must emit exactly one NOTE_MISSING; got {:?}",
2455 created.warnings,
2456 );
2457 assert!(
2458 matches!(
2459 created.warnings.iter().find(|w| matches!(w, WarningHint::NoteMissing { .. })),
2460 Some(WarningHint::NoteMissing { tool }) if tool == "create_entity"
2461 ),
2462 "the warning names the engine-level verb",
2463 );
2464 assert!(
2465 !created.commit_sha.is_empty(),
2466 "create still commits (nudge, not block)"
2467 );
2468
2469 let mut edit: IndexMap<String, String> = IndexMap::new();
2471 edit.insert("identity".to_string(), "revised".to_string());
2472 let updated = engine
2473 .update_entity(
2474 UpdateEntityArgs {
2475 anchors: Vec::new(),
2476 id: created.id.clone(),
2477 expected_hash: Some(created.content_hash.clone()),
2478 sections: edit,
2479 append_sections: IndexMap::new(),
2480 patch_sections: IndexMap::new(),
2481 metadata: IndexMap::new(),
2482 metadata_unset: Vec::new(),
2483 declare_relations: Vec::new(),
2484 dry_run: false,
2485 relations_unset: Vec::new(),
2486 },
2487 actor,
2488 Some(&client),
2489 None,
2490 )
2491 .unwrap();
2492 assert_eq!(
2493 note_missing(&updated.warnings),
2494 1,
2495 "update emits NOTE_MISSING"
2496 );
2497 assert!(!updated.commit_sha.is_empty(), "update still commits");
2498
2499 let target = engine
2501 .create_entity(
2502 empty_create_args("specs", "Target"),
2503 actor,
2504 Some(&client),
2505 Some("seed"),
2506 )
2507 .unwrap();
2508 let related = engine
2509 .relate_entity(
2510 RelateEntityArgs {
2511 source: updated.id.clone(),
2512 expected_hash: Some(updated.content_hash.clone()),
2513 rel_type: "USES".to_string(),
2514 target: target.id.clone(),
2515 remove: false,
2516 description: None,
2517 },
2518 actor,
2519 Some(&client),
2520 None,
2521 )
2522 .unwrap();
2523 assert_eq!(
2524 note_missing(&related.warnings),
2525 1,
2526 "relate emits NOTE_MISSING"
2527 );
2528 assert!(!related.commit_sha.is_empty(), "relate still commits");
2529
2530 let with_note = engine
2532 .create_entity(
2533 empty_create_args("specs", "Documented"),
2534 actor,
2535 Some(&client),
2536 Some("a real provenance note"),
2537 )
2538 .unwrap();
2539 assert_eq!(
2540 note_missing(&with_note.warnings),
2541 0,
2542 "a supplied note suppresses the warning",
2543 );
2544
2545 engine.set_settings(WorkspaceSettings::default());
2547 let after_off = engine
2548 .create_entity(
2549 empty_create_args("specs", "Quiet"),
2550 actor,
2551 Some(&client),
2552 None,
2553 )
2554 .unwrap();
2555 assert_eq!(
2556 note_missing(&after_off.warnings),
2557 0,
2558 "no NOTE_MISSING when require_notes is unset",
2559 );
2560 }
2561
2562 fn file_anchor(artifact: &str, hash: &str) -> crate::anchor::AnchorInput {
2565 crate::anchor::AnchorInput {
2566 artifact: Some(artifact.to_string()),
2567 grain: Some("file".to_string()),
2568 class: Some("anchored".to_string()),
2569 hash: Some(hash.to_string()),
2570 hash_stability: Some("stable".to_string()),
2571 ..Default::default()
2572 }
2573 }
2574
2575 fn folder_engine(mem: &str) -> (Engine, TempDir) {
2576 let tmp = TempDir::new().unwrap();
2577 let dir = tmp.path().to_path_buf();
2578 let writer = FilesystemMemWriter::new(dir.clone());
2579 let engine = Engine::from_mounts(vec![(
2580 folder_mount(mem, dir.clone()),
2581 Box::new(writer) as Box<dyn MemBackend>,
2582 )])
2583 .unwrap();
2584 (engine, tmp)
2585 }
2586
2587 #[test]
2588 fn create_with_anchors_persists_and_survives_reload() {
2589 let (mut engine, tmp) = folder_engine("specs");
2590 let dir = tmp.path().to_path_buf();
2591 let (actor, client) = cli_actor();
2592 let mut args = empty_create_args("specs", "Anchored Entity");
2593 args.anchors = vec![file_anchor("src/lib.rs", "h1")];
2594 engine
2595 .create_entity(args, actor, Some(&client), None)
2596 .unwrap();
2597
2598 let id = crate::EntityId::new("specs", "anchored-entity");
2599 let anchors = engine.entity_anchors(&id);
2600 assert_eq!(anchors.len(), 1);
2601 assert_eq!(anchors[0].artifact, "src/lib.rs");
2602 assert_eq!(
2603 anchors[0].class,
2604 crate::anchor::AnchorProvenanceClass::Anchored
2605 );
2606
2607 let writer = FilesystemMemWriter::new(dir.clone());
2609 let reloaded = Engine::from_mounts(vec![(
2610 folder_mount("specs", dir.clone()),
2611 Box::new(writer) as Box<dyn MemBackend>,
2612 )])
2613 .unwrap();
2614 assert_eq!(reloaded.entity_anchors(&id).len(), 1);
2615 assert_eq!(reloaded.anchors_referencing_artifact("src/lib.rs").len(), 1);
2617 }
2618
2619 #[test]
2620 fn malformed_anchor_refuses_and_entity_not_written() {
2621 let (mut engine, tmp) = folder_engine("specs");
2622 let (actor, client) = cli_actor();
2623 let mut args = empty_create_args("specs", "Bad Anchor");
2624 args.anchors = vec![crate::anchor::AnchorInput {
2625 artifact: Some("x".into()),
2626 grain: Some("paragraph".into()), class: Some("anchored".into()),
2628 ..Default::default()
2629 }];
2630 let err = engine
2631 .create_entity(args, actor, Some(&client), None)
2632 .unwrap_err();
2633 assert_eq!(err.code(), crate::anchor::INVALID_ANCHOR_CODE);
2634 assert!(
2636 engine
2637 .get_entity(&crate::EntityId::new("specs", "bad-anchor"))
2638 .is_none()
2639 );
2640 assert!(!tmp.path().join("bad-anchor.md").exists());
2641 }
2642
2643 #[test]
2644 fn anchors_are_not_folded_into_content_hash() {
2645 let (mut anchored, _t1) = folder_engine("specs");
2649 let (mut plain, _t2) = folder_engine("specs");
2650 let (actor, client) = cli_actor();
2651
2652 let mut a = empty_create_args("specs", "Same Title");
2653 a.anchors = vec![file_anchor("src/lib.rs", "h1")];
2654 let with = anchored
2655 .create_entity(a, actor, Some(&client), None)
2656 .unwrap();
2657
2658 let p = empty_create_args("specs", "Same Title");
2659 let without = plain.create_entity(p, actor, Some(&client), None).unwrap();
2660
2661 assert_eq!(
2662 with.content_hash, without.content_hash,
2663 "anchors must not change the entity content hash"
2664 );
2665 }
2666
2667 #[test]
2668 fn anchorless_create_writes_no_sidecar() {
2669 let (mut engine, _tmp) = folder_engine("specs");
2670 let (actor, client) = cli_actor();
2671 engine
2672 .create_entity(
2673 empty_create_args("specs", "No Anchors"),
2674 actor,
2675 Some(&client),
2676 None,
2677 )
2678 .unwrap();
2679 assert!(
2680 engine
2681 .entity_anchors(&crate::EntityId::new("specs", "no-anchors"))
2682 .is_empty()
2683 );
2684 }
2685}