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 validate_section_content(args.sections.iter().map(|(k, v)| (k.as_str(), v.as_str())))?;
153
154 let slug = validate_and_derive_slug(&args.title)?;
161 let id = EntityId::new(&args.mem, &slug);
162 crate::entity::id::enforce_id_length(id.as_ref())?;
163 if let Some(existing) = self.store.get(&id)
164 && !existing.stub
165 {
166 return Err(EngineError::AlreadyExists { id: id.to_string() });
167 }
168 let file_path = format!("{slug}.md");
169
170 let mut metadata: IndexMap<String, MetadataValue> = IndexMap::new();
174 metadata.insert(
175 "type".to_string(),
176 MetadataValue::String(args.entity_type.clone()),
177 );
178 for (k, v) in &args.metadata {
179 let parsed = parse_metadata_value(k.as_str(), v.as_str(), type_def.as_ref())?;
180 metadata.insert(k.clone(), parsed);
181 }
182
183 let today = today_iso();
191 let mut ignored_readonly: Vec<WarningHint> = Vec::new();
199 for field_def in &type_def.metadata_fields {
200 if field_def.init_timestamp || field_def.auto_timestamp {
201 if let Some(supplied) = args.metadata.get(field_def.key.as_str()) {
202 ignored_readonly.push(WarningHint::IgnoredReadonlyField {
203 field: field_def.key.clone(),
204 supplied: supplied.clone(),
205 });
206 }
207 metadata.insert(field_def.key.clone(), MetadataValue::String(today.clone()));
208 }
209 }
210
211 let missing_sections = missing_required_sections(type_def.as_ref(), &args.sections);
225 if !missing_sections.is_empty() {
226 let mut type_guidance: BTreeMap<String, Vec<String>> = BTreeMap::new();
227 if missing_sections
228 .iter()
229 .any(|m| m.entity_type == type_def.name)
230 {
231 type_guidance.insert(type_def.name.clone(), type_def.write_rules.clone());
232 }
233 return Err(EngineError::MissingRequiredSection {
234 entity_type: type_def.name.clone(),
235 missing_count: missing_sections.len(),
236 sections: missing_sections,
237 type_guidance,
238 });
239 }
240
241 let missing_fields = missing_required_fields(type_def.as_ref(), &args.metadata);
251 if !missing_fields.is_empty() {
252 let first = missing_fields[0].clone();
259 return Err(EngineError::RequiredFieldUnset {
260 field: first.key,
261 entity_type: first.entity_type,
262 field_description: Some(first.description),
263 enum_values: first.enum_values,
264 type_write_rules: type_def.write_rules.clone(),
265 on_create: true,
271 missing: missing_fields,
272 });
273 }
274
275 let mut warnings: Vec<WarningHint> = Vec::new();
276
277 warnings.append(&mut drift_warnings);
281
282 warnings.append(&mut ignored_readonly);
285
286 if let Some(w) = title_trimmed_warning.take() {
289 warnings.push(w);
290 }
291
292 let type_guidance = build_type_guidance(&warnings, type_def.as_ref());
300
301 for rel in &args.relations {
316 validate_relation_target_grammar(&rel.to)?;
317 let target_mem = rel.to.mem().to_string();
318 super::validate_cross_mem_add_policy(self, &args.mem, &target_mem)?;
323 let target_type = self
329 .store
330 .get(&rel.to)
331 .map(|e| e.entity_type.clone())
332 .filter(|t| !t.is_empty());
333 match route_edge_validation(
334 self,
335 &rel.rel_type,
336 args.entity_type.as_str(),
337 target_type.as_deref(),
338 &args.mem,
339 &target_mem,
340 &id,
341 &rel.to,
342 true,
343 )? {
344 EdgeRouteOutcome::Ok => {}
345 EdgeRouteOutcome::OpenModeWarning(w) => warnings.push(*w),
346 }
347 let normalised = normalise_description(rel.description.as_deref());
350 super::validate_description_posture(
351 self,
352 &rel.rel_type,
353 normalised.as_deref(),
354 &args.mem,
355 &target_mem,
356 &id,
357 &rel.to,
358 )?;
359 super::validate_manual_authoring_posture(self, &rel.rel_type, &args.mem, &id, &rel.to)?;
363 }
364
365 let relationships: Vec<Relationship> = args
372 .relations
373 .iter()
374 .map(|r| Relationship {
375 rel_type: r.rel_type.clone(),
376 target: r.to.clone(),
377 description: normalise_description(r.description.as_deref()),
378 })
379 .collect();
380 let relations_declared: Vec<crate::engine::outcomes::RelationDeclared> = args
387 .relations
388 .iter()
389 .map(|r| crate::engine::outcomes::RelationDeclared {
390 rel_type: r.rel_type.clone(),
391 target: r.to.clone(),
392 target_was_stubbed: !self.store.contains(&r.to),
393 })
394 .collect();
395 let mut entity_for_render = Entity {
396 id: id.clone(),
397 title: args.title.clone(),
398 entity_type: args.entity_type.clone(),
399 mem: args.mem.clone(),
400 file_path: file_path.clone(),
401 metadata,
402 sections: args.sections,
403 relationships,
404 content_hash: String::new(),
405 stub: false,
406 stub_kind: None,
407 heading_spans: HashMap::new(),
408 };
409 let empty_prev_targets = std::collections::HashSet::new();
424 let (synthesised_relations, self_link_ignored) =
425 super::synthesise_alias_relations(self, &empty_prev_targets, &mut entity_for_render)?;
426 if self_link_ignored {
427 warnings.push(WarningHint::SelfLinkIgnored { id: id.clone() });
428 }
429
430 let missing = super::scan_wikilinks_without_relation(&entity_for_render)?;
435 if !missing.is_empty() {
436 return Err(EngineError::WikiLinkWithoutRelation {
437 from_id: id.to_string(),
438 missing: missing
439 .into_iter()
440 .map(|(section_key, target)| crate::engine::MissingWikiLink {
441 section_key,
442 target_id: target.to_string(),
443 })
444 .collect(),
445 });
446 }
447
448 let markdown = generate_markdown(&entity_for_render, type_def.as_ref());
449
450 let auto_stubbed: Vec<EntityId> = synthesised_relations
466 .iter()
467 .filter_map(|rel| {
468 if !self.store.contains(&rel.target) {
469 Some(rel.target.clone())
470 } else {
471 None
472 }
473 })
474 .collect();
475 if !auto_stubbed.is_empty() {
476 warnings.push(WarningHint::InlineWikiLinkAutoStubbed {
477 from: id.clone(),
478 stubs: auto_stubbed,
479 });
480 }
481
482 if args.dry_run {
490 let prospective_hash = crate::entity::parser::compute_hash(&markdown);
491 let created_date = entity_for_render
495 .metadata
496 .get("created_date")
497 .map(|v| v.to_frontmatter_string())
498 .unwrap_or_default();
499 let incoming = project_incoming(self.store.incoming(&id));
503 let incoming_count = (!incoming.is_empty()).then_some(incoming.len());
504 return Ok(CreateEntityOutcome {
505 id,
506 title: args.title,
507 mem: args.mem,
508 file_path,
509 content_hash: prospective_hash,
510 commit_sha: String::new(),
511 created_date,
512 warnings,
513 type_guidance,
514 incoming_count,
515 incoming,
516 relations_declared: relations_declared.clone(),
517 });
518 }
519
520 let backend = self.mounts[mount_idx].backend.as_ref();
526 backend.write_entity(Path::new(&file_path), markdown.as_bytes())?;
527 let commit_subject = format!("memstead: create {id}");
528 let ctx = CommitContext {
529 actor,
530 client: client.cloned(),
531 tool: Some("create_entity"),
532 note: note.map(String::from),
533 logical_operation_id: None,
534 entity_ids: None,
535 };
536 let commit_sha = backend.commit(&commit_subject, &ctx)?;
537
538 backend.append_provenance(&Provenance::new(
541 std::time::SystemTime::now(),
542 ProvenanceKind::Create,
543 Some(id.to_string()),
544 actor,
545 client.cloned(),
546 note.map(String::from),
547 ))?;
548
549 self.record_self_write(mount_idx, &commit_sha);
553
554 let parse_result = parse_markdown(&markdown, &file_path, type_def.as_ref(), &args.mem)
557 .map_err(|e| EngineError::ParseAfterWrite(e.to_string()))?;
558 let content_hash = parse_result.entity.content_hash.clone();
559
560 let created_date = parse_result
567 .entity
568 .metadata
569 .get("created_date")
570 .map(|v| v.to_frontmatter_string())
571 .unwrap_or_default();
572
573 let fallback = engine_fallback_type();
574 push_entities_into_store(&mut self.store, vec![parse_result], fallback.as_ref(), None);
575 crate::entity::store_builder::remap_alias_target_edge_sources(
576 &mut self.store,
577 &self.schemas,
578 );
579
580 for rel in &args.relations {
588 if !self.store.contains(&rel.to) {
589 self.store.upsert(
590 rel.to.clone(),
591 make_stub(&rel.to, crate::entity::StubKind::ForwardReference),
592 );
593 }
594 }
595
596 self.invalidate_communities();
597 self.invalidate_search_indexes();
598
599 let incoming = project_incoming(self.store.incoming(&id));
603 let incoming_count = (!incoming.is_empty()).then_some(incoming.len());
604
605 if let Some(w) = self.note_missing_warning("create_entity", note) {
610 warnings.push(w);
611 }
612
613 Ok(CreateEntityOutcome {
614 id,
615 title: args.title,
616 mem: args.mem,
617 file_path,
618 content_hash,
619 commit_sha,
620 created_date,
621 warnings,
622 type_guidance,
623 incoming_count,
624 incoming,
625 relations_declared,
626 })
627 }
628
629 pub fn create_entity_with_ctx(
633 &mut self,
634 args: CreateEntityArgs,
635 ctx: &CommitContext<'_>,
636 ) -> Result<CreateEntityOutcome, EngineError> {
637 self.create_entity(args, ctx.actor, ctx.client.as_ref(), ctx.note.as_deref())
638 }
639}
640
641#[cfg(test)]
642mod tests {
643
644 use indexmap::IndexMap;
645 use tempfile::TempDir;
646
647 use crate::backend::MemBackend;
648 use crate::engine::test_helpers::*;
649 use crate::engine::{
650 CreateEntityArgs, CreateEntityOutcome, Engine, EngineError, RelateEntityArgs,
651 };
652 use crate::ops::WarningHint;
653 use crate::storage::{ArchiveBackend, FilesystemMemWriter};
654
655 #[test]
656 fn create_entity_writes_through_folder_backend_and_updates_store() {
657 let tmp = TempDir::new().unwrap();
658 let mem_dir = tmp.path().to_path_buf();
659 let writer = FilesystemMemWriter::new(mem_dir.clone());
660 let mut engine = Engine::from_mounts(vec![(
661 folder_mount("specs", mem_dir.clone()),
662 Box::new(writer) as Box<dyn MemBackend>,
663 )])
664 .unwrap();
665 let (actor, client) = cli_actor();
666
667 let outcome = engine
668 .create_entity(
669 empty_create_args("specs", "Hello World"),
670 actor,
671 Some(&client),
672 Some("first draft"),
673 )
674 .unwrap();
675
676 assert_eq!(outcome.id.to_string(), "specs--hello-world");
678 assert_eq!(outcome.file_path, "hello-world.md");
679 assert!(!outcome.content_hash.is_empty());
680
681 let entity = engine
683 .get_entity(&crate::EntityId::new("specs", "hello-world"))
684 .expect("entity must be in the store after create");
685 assert_eq!(entity.title, "Hello World");
686 assert_eq!(entity.entity_type, "spec");
687 assert_eq!(entity.content_hash, outcome.content_hash);
688
689 let on_disk = std::fs::read_to_string(mem_dir.join("hello-world.md")).unwrap();
691 assert!(on_disk.contains("# Hello World"));
692 assert!(on_disk.contains("type: spec"));
693
694 let log_path = mem_dir.join(".memstead").join("changes.jsonl");
696 let log = std::fs::read_to_string(&log_path).unwrap();
697 assert!(log.contains("\"kind\":\"create\""));
698 assert!(log.contains("\"entity\":\"specs--hello-world\""));
699 assert!(log.contains("\"actor\":\"cli\""));
700 assert!(log.contains("\"note\":\"first draft\""));
701 }
702
703 #[test]
709 fn create_entity_warns_on_supplied_auto_managed_field() {
710 let tmp = TempDir::new().unwrap();
711 let mem_dir = tmp.path().to_path_buf();
712 let writer = FilesystemMemWriter::new(mem_dir.clone());
713 let mut engine = Engine::from_mounts(vec![(
714 folder_mount("specs", mem_dir),
715 Box::new(writer) as Box<dyn MemBackend>,
716 )])
717 .unwrap();
718 let (actor, client) = cli_actor();
719
720 let mut args = empty_create_args("specs", "Dated Entity");
721 args.metadata
722 .insert("created_date".to_string(), "2020-01-01".to_string());
723
724 let outcome = engine
725 .create_entity(args, actor, Some(&client), None)
726 .unwrap();
727
728 let warned = outcome.warnings.iter().any(|w| {
729 w.code() == "IGNORED_READONLY_FIELD"
730 && matches!(w, WarningHint::IgnoredReadonlyField { field, supplied }
731 if field == "created_date" && supplied == "2020-01-01")
732 });
733 assert!(
734 warned,
735 "expected IGNORED_READONLY_FIELD; got {:?}",
736 outcome.warnings
737 );
738
739 assert_ne!(outcome.created_date, "2020-01-01");
741 }
742
743 #[test]
746 fn create_entity_no_warning_when_auto_managed_field_absent() {
747 let tmp = TempDir::new().unwrap();
748 let mem_dir = tmp.path().to_path_buf();
749 let writer = FilesystemMemWriter::new(mem_dir.clone());
750 let mut engine = Engine::from_mounts(vec![(
751 folder_mount("specs", mem_dir),
752 Box::new(writer) as Box<dyn MemBackend>,
753 )])
754 .unwrap();
755 let (actor, client) = cli_actor();
756
757 let outcome = engine
758 .create_entity(
759 empty_create_args("specs", "Plain Entity"),
760 actor,
761 Some(&client),
762 None,
763 )
764 .unwrap();
765 assert!(
766 !outcome
767 .warnings
768 .iter()
769 .any(|w| w.code() == "IGNORED_READONLY_FIELD"),
770 "no auto-managed field supplied — no warning expected; got {:?}",
771 outcome.warnings
772 );
773 }
774
775 #[test]
776 fn create_entity_returns_commit_sha_title_mem_on_real_write() {
777 let tmp = TempDir::new().unwrap();
778 let mem_dir = tmp.path().to_path_buf();
779 let writer = FilesystemMemWriter::new(mem_dir.clone());
780 let mut engine = Engine::from_mounts(vec![(
781 folder_mount("specs", mem_dir),
782 Box::new(writer) as Box<dyn MemBackend>,
783 )])
784 .unwrap();
785 let (actor, client) = cli_actor();
786
787 let outcome = engine
788 .create_entity(
789 empty_create_args("specs", "Rich Shape"),
790 actor,
791 Some(&client),
792 None,
793 )
794 .unwrap();
795
796 assert!(
799 !outcome.commit_sha.is_empty(),
800 "commit_sha must be populated on a real create"
801 );
802 assert_eq!(outcome.title, "Rich Shape");
804 assert_eq!(outcome.mem, "specs");
805 assert!(
811 !outcome
812 .warnings
813 .iter()
814 .any(|w| matches!(w, WarningHint::MissingRequiredSection { .. })),
815 "success path must not carry MissingRequiredSection warnings — those refuse on create now",
816 );
817 }
818
819 #[test]
825 fn create_entity_refuses_missing_required_sections_with_typed_envelope() {
826 let tmp = TempDir::new().unwrap();
827 let mem_dir = tmp.path().to_path_buf();
828 let writer = FilesystemMemWriter::new(mem_dir.clone());
829 let mut engine = Engine::from_mounts(vec![(
830 folder_mount("specs", mem_dir),
831 Box::new(writer) as Box<dyn MemBackend>,
832 )])
833 .unwrap();
834 let (actor, client) = cli_actor();
835
836 let args = CreateEntityArgs {
838 mem: "specs".to_string(),
839 title: "Half Done".to_string(),
840 entity_type: "spec".to_string(),
841 sections: IndexMap::new(),
842 metadata: IndexMap::new(),
843 relations: Vec::new(),
844 dry_run: false,
845 };
846 let err = engine
847 .create_entity(args, actor, Some(&client), None)
848 .unwrap_err();
849 match err {
850 EngineError::MissingRequiredSection {
851 entity_type,
852 missing_count,
853 sections,
854 type_guidance,
855 } => {
856 assert_eq!(entity_type, "spec");
857 assert_eq!(missing_count, sections.len());
858 assert!(
859 missing_count >= 2,
860 "expected ≥2 missing sections, got {missing_count}"
861 );
862 let keys: Vec<String> = sections.iter().map(|s| s.key.clone()).collect();
863 assert!(
864 keys.contains(&"identity".to_string()),
865 "missing keys: {keys:?}"
866 );
867 assert!(
868 keys.contains(&"purpose".to_string()),
869 "missing keys: {keys:?}"
870 );
871 assert!(
872 type_guidance.contains_key("spec"),
873 "type_guidance must include `spec` entry, got: {type_guidance:?}",
874 );
875 }
876 other => panic!("expected MissingRequiredSection, got {other:?}"),
877 }
878
879 let id = crate::EntityId::new("specs", "half-done");
881 assert!(
882 engine.store().get(&id).is_none(),
883 "refused create must not persist any entity"
884 );
885 }
886
887 #[test]
891 fn create_entity_dry_run_returns_same_refusal_envelope_as_real_call() {
892 let tmp = TempDir::new().unwrap();
893 let mem_dir = tmp.path().to_path_buf();
894 let writer = FilesystemMemWriter::new(mem_dir.clone());
895 let mut engine = Engine::from_mounts(vec![(
896 folder_mount("specs", mem_dir),
897 Box::new(writer) as Box<dyn MemBackend>,
898 )])
899 .unwrap();
900 let (actor, client) = cli_actor();
901
902 let args = CreateEntityArgs {
903 mem: "specs".to_string(),
904 title: "Half Done Dry".to_string(),
905 entity_type: "spec".to_string(),
906 sections: IndexMap::new(),
907 metadata: IndexMap::new(),
908 relations: Vec::new(),
909 dry_run: true,
910 };
911 let err = engine
912 .create_entity(args, actor, Some(&client), None)
913 .unwrap_err();
914 assert!(
915 matches!(err, EngineError::MissingRequiredSection { .. }),
916 "dry_run must surface the same refusal envelope, got {err:?}"
917 );
918 }
919
920 #[test]
924 fn create_entity_succeeds_after_filling_in_required_sections() {
925 let tmp = TempDir::new().unwrap();
926 let mem_dir = tmp.path().to_path_buf();
927 let writer = FilesystemMemWriter::new(mem_dir.clone());
928 let mut engine = Engine::from_mounts(vec![(
929 folder_mount("specs", mem_dir),
930 Box::new(writer) as Box<dyn MemBackend>,
931 )])
932 .unwrap();
933 let (actor, client) = cli_actor();
934
935 let mut sections = IndexMap::new();
936 sections.insert("identity".to_string(), "the identity body".to_string());
937 sections.insert("purpose".to_string(), "the purpose body".to_string());
938 let args = CreateEntityArgs {
939 mem: "specs".to_string(),
940 title: "Complete".to_string(),
941 entity_type: "spec".to_string(),
942 sections,
943 metadata: IndexMap::new(),
944 relations: Vec::new(),
945 dry_run: false,
946 };
947 let outcome = engine
948 .create_entity(args, actor, Some(&client), None)
949 .expect("complete create succeeds");
950 assert_eq!(outcome.title, "Complete");
951 }
952
953 #[test]
954 fn create_entity_promotes_existing_stub_and_preserves_incoming_edges() {
955 let tmp = TempDir::new().unwrap();
956 let (mut engine, source) = engine_with_seed(&tmp, "Source");
957 let (actor, client) = cli_actor();
958
959 let stub_target = crate::EntityId::new("specs", "ghost-target");
962 engine
963 .relate_entity(
964 RelateEntityArgs {
965 source: source.id.clone(),
966 expected_hash: Some(source.content_hash.clone()),
967 rel_type: "USES".to_string(),
968 target: stub_target.clone(),
969 remove: false,
970 description: None,
971 },
972 actor,
973 Some(&client),
974 None,
975 )
976 .unwrap();
977 let stub = engine
978 .store()
979 .get(&stub_target)
980 .expect("stub must be in store");
981 assert!(stub.stub);
982 assert_eq!(engine.store().incoming(&stub_target).len(), 1);
983
984 let outcome = engine
987 .create_entity(
988 empty_create_args("specs", "Ghost Target"),
989 actor,
990 Some(&client),
991 None,
992 )
993 .unwrap();
994
995 assert_eq!(outcome.id, stub_target);
997 let real = engine
999 .store()
1000 .get(&stub_target)
1001 .expect("entity must still be in store");
1002 assert!(!real.stub);
1003 assert_eq!(engine.store().incoming(&stub_target).len(), 1);
1005 assert_eq!(outcome.incoming_count, Some(1));
1007 assert_eq!(outcome.incoming.len(), 1);
1008 assert_eq!(outcome.incoming[0].from, source.id);
1009 assert_eq!(outcome.incoming[0].rel_type, "USES");
1010 }
1011
1012 #[test]
1013 fn create_entity_reports_no_incoming_on_greenfield_create() {
1014 let tmp = TempDir::new().unwrap();
1015 let mem_dir = tmp.path().to_path_buf();
1016 let writer = FilesystemMemWriter::new(mem_dir.clone());
1017 let mut engine = Engine::from_mounts(vec![(
1018 folder_mount("specs", mem_dir),
1019 Box::new(writer) as Box<dyn MemBackend>,
1020 )])
1021 .unwrap();
1022 let (actor, client) = cli_actor();
1023
1024 let outcome = engine
1025 .create_entity(
1026 empty_create_args("specs", "Greenfield"),
1027 actor,
1028 Some(&client),
1029 None,
1030 )
1031 .unwrap();
1032 assert!(outcome.incoming_count.is_none());
1035 assert!(outcome.incoming.is_empty());
1036 }
1037
1038 #[test]
1039 fn create_entity_populates_created_date_from_schema_auto_stamp() {
1040 let tmp = TempDir::new().unwrap();
1041 let mem_dir = tmp.path().to_path_buf();
1042 let writer = FilesystemMemWriter::new(mem_dir.clone());
1043 let mut engine = Engine::from_mounts(vec![(
1044 folder_mount("specs", mem_dir),
1045 Box::new(writer) as Box<dyn MemBackend>,
1046 )])
1047 .unwrap();
1048 let (actor, client) = cli_actor();
1049
1050 let outcome = engine
1051 .create_entity(
1052 empty_create_args("specs", "Has Date"),
1053 actor,
1054 Some(&client),
1055 None,
1056 )
1057 .unwrap();
1058 assert!(
1063 !outcome.created_date.is_empty(),
1064 "created_date must be populated when the schema auto-stamps it"
1065 );
1066 }
1067
1068 #[test]
1069 fn create_overrides_user_supplied_timestamps_update_rejects_them() {
1070 let tmp = TempDir::new().unwrap();
1081 let mem_dir = tmp.path().to_path_buf();
1082 let writer = FilesystemMemWriter::new(mem_dir.clone());
1083 let mut engine = Engine::from_mounts(vec![(
1084 folder_mount("specs", mem_dir),
1085 Box::new(writer) as Box<dyn MemBackend>,
1086 )])
1087 .unwrap();
1088 let (actor, client) = cli_actor();
1089
1090 let mut args = empty_create_args("specs", "Stamped Today");
1094 args.metadata
1095 .insert("created_date".to_string(), "2020-01-01".to_string());
1096 args.metadata
1097 .insert("last_modified".to_string(), "2020-01-01".to_string());
1098
1099 let outcome = engine
1100 .create_entity(args, actor, Some(&client), None)
1101 .unwrap();
1102
1103 let today = super::today_iso();
1106 assert_eq!(outcome.created_date, today);
1107 let entity = engine
1108 .get_entity(&outcome.id)
1109 .expect("entity must be in store after create");
1110 assert_eq!(
1111 entity
1112 .metadata
1113 .get("created_date")
1114 .and_then(|v| v.as_str())
1115 .unwrap_or_default(),
1116 today,
1117 "init_timestamp field must be engine-determined on create, not user-supplied"
1118 );
1119 assert_eq!(
1120 entity
1121 .metadata
1122 .get("last_modified")
1123 .and_then(|v| v.as_str())
1124 .unwrap_or_default(),
1125 today,
1126 "auto_timestamp field must be engine-determined on create, not user-supplied"
1127 );
1128
1129 let attempt_update = |key: &str, value: &str| {
1133 let mut metadata = IndexMap::new();
1134 metadata.insert(key.to_string(), value.to_string());
1135 crate::engine::UpdateEntityArgs {
1136 id: outcome.id.clone(),
1137 metadata,
1138 metadata_unset: Vec::new(),
1139 sections: IndexMap::new(),
1140 append_sections: IndexMap::new(),
1141 patch_sections: IndexMap::new(),
1142 expected_hash: Some(outcome.content_hash.clone()),
1143 dry_run: false,
1144 declare_relations: Vec::new(),
1145 relations_unset: Vec::new(),
1146 }
1147 };
1148 for key in ["created_date", "last_modified"] {
1149 let err = engine
1150 .update_entity(
1151 attempt_update(key, "2019-12-31"),
1152 actor,
1153 Some(&client),
1154 None,
1155 )
1156 .expect_err("schema-managed timestamp must be rejected on update");
1157 assert_eq!(err.code(), "READ_ONLY_FIELD", "got: {err:?}");
1158 }
1159 let entity = engine
1161 .get_entity(&outcome.id)
1162 .expect("entity must remain in store after rejected update");
1163 assert_eq!(
1164 entity
1165 .metadata
1166 .get("last_modified")
1167 .and_then(|v| v.as_str())
1168 .unwrap_or_default(),
1169 today,
1170 "rejected update must not mutate the auto_timestamp field"
1171 );
1172 }
1173
1174 #[test]
1175 fn create_entity_wires_inline_relations_and_stubs_absent_targets() {
1176 let tmp = TempDir::new().unwrap();
1177 let (mut engine, existing) = engine_with_seed(&tmp, "Existing Target");
1178 let (actor, client) = cli_actor();
1179 let absent = crate::EntityId::new("specs", "future-target");
1180 assert!(!engine.store().contains(&absent));
1181
1182 let mut args = empty_create_args("specs", "Source With Relations");
1183 args.relations = vec![
1184 crate::ops::RelateArg {
1185 to: existing.id.clone(),
1186 rel_type: "USES".to_string(),
1187 description: None,
1188 },
1189 crate::ops::RelateArg {
1190 to: absent.clone(),
1191 rel_type: "USES".to_string(),
1192 description: None,
1193 },
1194 ];
1195
1196 let outcome = engine
1197 .create_entity(args, actor, Some(&client), None)
1198 .unwrap();
1199
1200 let source = engine
1202 .store()
1203 .get(&outcome.id)
1204 .expect("source must be in store");
1205 assert_eq!(source.relationships.len(), 2);
1206 assert!(
1207 source
1208 .relationships
1209 .iter()
1210 .any(|r| r.target == existing.id && r.rel_type == "USES")
1211 );
1212 assert!(
1213 source
1214 .relationships
1215 .iter()
1216 .any(|r| r.target == absent && r.rel_type == "USES")
1217 );
1218
1219 let stub = engine
1222 .store()
1223 .get(&absent)
1224 .expect("absent relation target must be auto-stubbed");
1225 assert!(stub.stub);
1226 let existing_after = engine.store().get(&existing.id).unwrap();
1228 assert!(!existing_after.stub);
1229 }
1230
1231 fn engine_with_planning_schema(tmp: &TempDir) -> Engine {
1236 use crate::workspace::Mount;
1237 use crate::workspace::{MountCapability, MountLifecycle, MountStorage};
1238 let mem_dir = tmp.path().to_path_buf();
1239 let writer = FilesystemMemWriter::new(mem_dir.clone());
1240 let mount = Mount {
1241 mem: "planning".to_string(),
1242 schema: Some(memstead_schema::SchemaRef::new(
1243 "planning",
1244 semver::Version::new(0, 1, 0),
1245 )),
1246 storage: MountStorage::Folder { path: mem_dir },
1247 capability: MountCapability::Write,
1248 lifecycle: MountLifecycle::Eager,
1249 cross_linkable: true,
1250 migration_target: None,
1251 };
1252 Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap()
1253 }
1254
1255 #[test]
1264 fn create_entity_refuses_unsupplied_no_default_required_field() {
1265 let tmp = TempDir::new().unwrap();
1269 let mut engine = engine_with_planning_schema(&tmp);
1270 let (actor, client) = cli_actor();
1271
1272 let mut args = CreateEntityArgs {
1273 mem: "planning".to_string(),
1274 title: "Skip Postgres".to_string(),
1275 entity_type: "decision".to_string(),
1276 sections: IndexMap::from_iter([
1277 ("decision".to_string(), "Use SQLite locally.".to_string()),
1278 ("context".to_string(), "Single-user dev.".to_string()),
1279 ("consequences".to_string(), "Lose multi-writer.".to_string()),
1280 ]),
1281 metadata: IndexMap::new(),
1282 relations: Vec::new(),
1283 dry_run: false,
1284 };
1285
1286 let err = engine
1289 .create_entity(args.clone(), actor, Some(&client), None)
1290 .unwrap_err();
1291 match err {
1292 EngineError::RequiredFieldUnset {
1293 field, entity_type, ..
1294 } => {
1295 assert!(
1296 field == "decided_on" || field == "deciders",
1297 "expected first missing field, got {field:?}"
1298 );
1299 assert_eq!(entity_type, "decision");
1300 }
1301 other => panic!("expected RequiredFieldUnset, got {other:?}"),
1302 }
1303
1304 args.title = "Different Title".to_string();
1308 args.dry_run = true;
1309 let dry_err = engine
1310 .create_entity(args, actor, Some(&client), None)
1311 .unwrap_err();
1312 assert!(
1313 matches!(dry_err, EngineError::RequiredFieldUnset { .. }),
1314 "dry_run must surface the same refusal envelope, got {dry_err:?}"
1315 );
1316 }
1317
1318 #[test]
1322 fn create_entity_succeeds_when_all_required_no_default_fields_supplied() {
1323 let tmp = TempDir::new().unwrap();
1324 let mut engine = engine_with_planning_schema(&tmp);
1325 let (actor, client) = cli_actor();
1326
1327 let mut metadata = IndexMap::new();
1328 metadata.insert("decided_on".to_string(), "2026-05-13".to_string());
1329 metadata.insert("deciders".to_string(), "alice, bob".to_string());
1330
1331 let outcome = engine
1332 .create_entity(
1333 CreateEntityArgs {
1334 mem: "planning".to_string(),
1335 title: "Complete Decision".to_string(),
1336 entity_type: "decision".to_string(),
1337 sections: IndexMap::from_iter([
1338 ("decision".to_string(), "x".to_string()),
1339 ("context".to_string(), "y".to_string()),
1340 ("consequences".to_string(), "z".to_string()),
1341 ]),
1342 metadata,
1343 relations: Vec::new(),
1344 dry_run: false,
1345 },
1346 actor,
1347 Some(&client),
1348 None,
1349 )
1350 .expect("complete decision create succeeds");
1351 let missing_field_warnings: Vec<&WarningHint> = outcome
1354 .warnings
1355 .iter()
1356 .filter(|w| matches!(w, WarningHint::MissingRequiredField { .. }))
1357 .collect();
1358 assert!(
1359 missing_field_warnings.is_empty(),
1360 "success path must not carry MissingRequiredField warnings, got: {missing_field_warnings:?}"
1361 );
1362 }
1363
1364 #[test]
1369 fn create_entity_rejects_inline_relation_with_malformed_target_id() {
1370 let tmp = TempDir::new().unwrap();
1371 let mem_dir = tmp.path().to_path_buf();
1372 let writer = FilesystemMemWriter::new(mem_dir.clone());
1373 let mut engine = Engine::from_mounts(vec![(
1374 folder_mount("specs", mem_dir),
1375 Box::new(writer) as Box<dyn MemBackend>,
1376 )])
1377 .unwrap();
1378 let (actor, client) = cli_actor();
1379
1380 let mut args = empty_create_args("specs", "Source");
1381 args.relations = vec![crate::ops::RelateArg {
1382 to: crate::EntityId("specs--bad target with spaces!!".to_string()),
1383 rel_type: "USES".to_string(),
1384 description: None,
1385 }];
1386 let err = engine
1387 .create_entity(args, actor, Some(&client), None)
1388 .unwrap_err();
1389 assert!(
1390 matches!(err, EngineError::InvalidEntityId { .. }),
1391 "malformed target id must trip INVALID_ENTITY_ID on the create path; got {err:?}",
1392 );
1393 }
1394
1395 #[test]
1404 fn create_entity_rejects_inline_relation_with_shape_violation() {
1405 use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
1406 let tmp = TempDir::new().unwrap();
1407 let mem_dir = tmp.path().to_path_buf();
1408 let writer = FilesystemMemWriter::new(mem_dir.clone());
1409 let mount = Mount {
1410 mem: "code".to_string(),
1411 schema: Some(memstead_schema::SchemaRef::new(
1412 "software",
1413 semver::Version::new(0, 1, 0),
1414 )),
1415 storage: MountStorage::Folder { path: mem_dir },
1416 capability: MountCapability::Write,
1417 lifecycle: MountLifecycle::Eager,
1418 cross_linkable: true,
1419 migration_target: None,
1420 };
1421 let mut engine =
1422 Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap();
1423 let (actor, client) = cli_actor();
1424
1425 let target = engine
1433 .create_entity(
1434 CreateEntityArgs {
1435 mem: "code".to_string(),
1436 title: "Target Requirement".to_string(),
1437 entity_type: "requirement".to_string(),
1438 sections: IndexMap::from_iter([
1439 ("statement".to_string(), "MUST hold.".to_string()),
1440 ("rationale".to_string(), "Because tests.".to_string()),
1441 ]),
1442 metadata: IndexMap::from_iter([
1443 ("verified_on".to_string(), "2026-05-19".to_string()),
1444 ("source".to_string(), "test fixture".to_string()),
1445 ]),
1446 relations: Vec::new(),
1447 dry_run: false,
1448 },
1449 actor,
1450 Some(&client),
1451 None,
1452 )
1453 .unwrap();
1454
1455 let args = CreateEntityArgs {
1461 mem: "code".to_string(),
1462 title: "Misshape Source".to_string(),
1463 entity_type: "spec".to_string(),
1464 sections: IndexMap::from_iter([
1465 ("identity".to_string(), "this spec".to_string()),
1466 (
1467 "purpose".to_string(),
1468 "exercising the shape gate".to_string(),
1469 ),
1470 ]),
1471 metadata: IndexMap::new(),
1472 relations: vec![crate::ops::RelateArg {
1473 to: target.id.clone(),
1474 rel_type: "VIOLATES".to_string(),
1475 description: None,
1476 }],
1477 dry_run: false,
1478 };
1479 let err = engine
1480 .create_entity(args, actor, Some(&client), None)
1481 .unwrap_err();
1482 assert!(
1483 matches!(err, EngineError::Validation(_)),
1484 "shape violation must trip Validation(InvalidRelationshipShape); got {err:?}",
1485 );
1486 }
1487
1488 #[test]
1489 fn create_entity_canonicalises_inline_relation_rel_types_to_upper_snake_case() {
1490 let tmp = TempDir::new().unwrap();
1494 let (mut engine, existing) = engine_with_seed(&tmp, "Existing Target");
1495 let (actor, client) = cli_actor();
1496
1497 let mut args = empty_create_args("specs", "Source With Mixed Case Rel");
1498 args.relations = vec![crate::ops::RelateArg {
1499 to: existing.id.clone(),
1500 rel_type: "uses".to_string(),
1501 description: None,
1502 }];
1503
1504 let outcome = engine
1505 .create_entity(args, actor, Some(&client), None)
1506 .unwrap();
1507
1508 let source = engine
1509 .store()
1510 .get(&outcome.id)
1511 .expect("source must be in store");
1512 assert_eq!(source.relationships.len(), 1);
1513 assert_eq!(
1514 source.relationships[0].rel_type, "USES",
1515 "inline relation rel_type must be stored UPPER_SNAKE_CASE",
1516 );
1517 }
1518
1519 #[test]
1520 fn create_entity_dry_run_skips_disk_and_store_yet_returns_hash() {
1521 let tmp = TempDir::new().unwrap();
1522 let mem_dir = tmp.path().to_path_buf();
1523 let writer = FilesystemMemWriter::new(mem_dir.clone());
1524 let mut engine = Engine::from_mounts(vec![(
1525 folder_mount("specs", mem_dir.clone()),
1526 Box::new(writer) as Box<dyn MemBackend>,
1527 )])
1528 .unwrap();
1529 let (actor, client) = cli_actor();
1530
1531 let mut args = empty_create_args("specs", "Preview Only");
1532 args.dry_run = true;
1533
1534 let outcome = engine
1535 .create_entity(args, actor, Some(&client), None)
1536 .unwrap();
1537
1538 assert_eq!(outcome.id.to_string(), "specs--preview-only");
1540 assert!(
1541 !outcome.content_hash.is_empty(),
1542 "prospective hash populated"
1543 );
1544 assert!(outcome.commit_sha.is_empty(), "no commit on dry_run");
1545 assert!(
1547 engine.store().get(&outcome.id).is_none(),
1548 "dry_run must not mutate the store",
1549 );
1550 assert!(
1552 !mem_dir.join("preview-only.md").exists(),
1553 "dry_run must not touch disk",
1554 );
1555 let log = mem_dir.join(".memstead").join("changes.jsonl");
1557 assert!(
1558 !log.exists()
1559 || !std::fs::read_to_string(&log)
1560 .unwrap()
1561 .contains("preview-only"),
1562 "dry_run must not append provenance",
1563 );
1564 }
1565
1566 #[test]
1567 fn create_entity_rejects_read_only_mount_before_backend() {
1568 let tmp = TempDir::new().unwrap();
1569 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"# a")]);
1570 let mut engine = Engine::from_mounts(vec![(
1571 archive_mount("external", archive_path.clone()),
1572 Box::new(ArchiveBackend::new(archive_path)),
1573 )])
1574 .unwrap();
1575 let (actor, client) = cli_actor();
1576
1577 let err = engine
1578 .create_entity(
1579 empty_create_args("external", "Should Fail"),
1580 actor,
1581 Some(&client),
1582 None,
1583 )
1584 .unwrap_err();
1585 match err {
1586 EngineError::ReadOnlyMount(v) => assert_eq!(v, "external"),
1587 other => panic!("expected ReadOnlyMount, got {other:?}"),
1588 }
1589 }
1593
1594 #[test]
1595 fn create_entity_rejects_unknown_mem() {
1596 let tmp = TempDir::new().unwrap();
1597 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
1598 let mut engine = Engine::from_mounts(vec![(
1599 folder_mount("specs", tmp.path().to_path_buf()),
1600 Box::new(writer) as Box<dyn MemBackend>,
1601 )])
1602 .unwrap();
1603 let (actor, client) = cli_actor();
1604
1605 let err = engine
1606 .create_entity(
1607 empty_create_args("does-not-exist", "Anything"),
1608 actor,
1609 Some(&client),
1610 None,
1611 )
1612 .unwrap_err();
1613 assert!(matches!(err, EngineError::UnknownMem(v) if v == "does-not-exist"));
1614 }
1615
1616 #[test]
1617 fn create_entity_rejects_unknown_type_against_pinned_schema() {
1618 let tmp = TempDir::new().unwrap();
1619 let mem_dir = tmp.path().to_path_buf();
1620 let writer = FilesystemMemWriter::new(mem_dir.clone());
1621 let mut engine = Engine::from_mounts(vec![(
1622 folder_mount("specs", mem_dir),
1623 Box::new(writer) as Box<dyn MemBackend>,
1624 )])
1625 .unwrap();
1626 let (actor, client) = cli_actor();
1627
1628 let mut args = empty_create_args("specs", "Anything");
1629 args.entity_type = "definitely-not-a-real-type".to_string();
1630 let err = engine
1631 .create_entity(args, actor, Some(&client), None)
1632 .unwrap_err();
1633 match err {
1634 EngineError::UnknownType { name, declared, .. } => {
1635 assert_eq!(name, "definitely-not-a-real-type");
1636 assert!(!declared.is_empty(), "declared types must be listed");
1637 }
1638 other => panic!("expected UnknownType, got {other:?}"),
1639 }
1640 }
1641
1642 #[test]
1643 fn create_entity_rejects_duplicate_id() {
1644 let tmp = TempDir::new().unwrap();
1645 let mem_dir = tmp.path().to_path_buf();
1646 let writer = FilesystemMemWriter::new(mem_dir.clone());
1647 let mut engine = Engine::from_mounts(vec![(
1648 folder_mount("specs", mem_dir),
1649 Box::new(writer) as Box<dyn MemBackend>,
1650 )])
1651 .unwrap();
1652 let (actor, client) = cli_actor();
1653
1654 engine
1655 .create_entity(
1656 empty_create_args("specs", "Same Slug"),
1657 actor,
1658 Some(&client),
1659 None,
1660 )
1661 .unwrap();
1662 let err = engine
1663 .create_entity(
1664 empty_create_args("specs", "Same Slug"),
1665 actor,
1666 Some(&client),
1667 None,
1668 )
1669 .unwrap_err();
1670 match err {
1671 EngineError::AlreadyExists { id } => assert_eq!(id, "specs--same-slug"),
1672 other => panic!("expected AlreadyExists, got {other:?}"),
1673 }
1674 }
1675
1676 #[test]
1677 fn create_entity_rejects_invalid_title() {
1678 let tmp = TempDir::new().unwrap();
1679 let mem_dir = tmp.path().to_path_buf();
1680 let writer = FilesystemMemWriter::new(mem_dir.clone());
1681 let mut engine = Engine::from_mounts(vec![(
1682 folder_mount("specs", mem_dir),
1683 Box::new(writer) as Box<dyn MemBackend>,
1684 )])
1685 .unwrap();
1686 let (actor, client) = cli_actor();
1687
1688 let err = engine
1694 .create_entity(empty_create_args("specs", " "), actor, Some(&client), None)
1695 .unwrap_err();
1696 match err {
1697 EngineError::InvalidTitle(slug_err) => {
1698 assert_eq!(slug_err.reason(), "empty", "expected empty reason");
1699 }
1700 other => panic!("expected InvalidTitle/TitleEmpty, got {other:?}"),
1701 }
1702
1703 let err = engine
1706 .create_entity(
1707 empty_create_args("specs", "Hello, World!"),
1708 actor,
1709 Some(&client),
1710 None,
1711 )
1712 .unwrap_err();
1713 match err {
1714 EngineError::InvalidTitle(crate::SlugError::TitleHasInvalidChars {
1715 invalid_chars,
1716 proposed_slug,
1717 ..
1718 }) => {
1719 assert!(invalid_chars.contains(&',') && invalid_chars.contains(&'!'));
1720 assert_eq!(proposed_slug, "hello-world");
1721 }
1722 other => panic!("expected InvalidTitle/TitleHasInvalidChars, got {other:?}"),
1723 }
1724
1725 let err = engine
1728 .create_entity(
1729 empty_create_args("specs", "../etc/passwd"),
1730 actor,
1731 Some(&client),
1732 None,
1733 )
1734 .unwrap_err();
1735 match err {
1736 EngineError::InvalidTitle(slug_err) => {
1737 assert_eq!(slug_err.reason(), "invalid_chars");
1738 }
1739 other => panic!("expected InvalidTitle, got {other:?}"),
1740 }
1741 }
1742
1743 #[test]
1744 fn create_entity_rejects_unknown_section_key() {
1745 let tmp = TempDir::new().unwrap();
1746 let mem_dir = tmp.path().to_path_buf();
1747 let writer = FilesystemMemWriter::new(mem_dir.clone());
1748 let mut engine = Engine::from_mounts(vec![(
1749 folder_mount("specs", mem_dir),
1750 Box::new(writer) as Box<dyn MemBackend>,
1751 )])
1752 .unwrap();
1753 let (actor, client) = cli_actor();
1754
1755 let mut args = empty_create_args("specs", "Bad Sections");
1756 args.sections
1757 .insert("not-a-real-section-key".to_string(), "body".to_string());
1758 let err = engine
1759 .create_entity(args, actor, Some(&client), None)
1760 .unwrap_err();
1761 assert!(matches!(err, EngineError::Validation(_)));
1762 }
1763
1764 #[test]
1765 fn create_entity_persists_across_engine_restart() {
1766 let tmp = TempDir::new().unwrap();
1767 let mem_dir = tmp.path().to_path_buf();
1768 {
1769 let writer = FilesystemMemWriter::new(mem_dir.clone());
1770 let mut engine = Engine::from_mounts(vec![(
1771 folder_mount("specs", mem_dir.clone()),
1772 Box::new(writer) as Box<dyn MemBackend>,
1773 )])
1774 .unwrap();
1775 let (actor, client) = cli_actor();
1776 engine
1777 .create_entity(
1778 empty_create_args("specs", "Survives Restart"),
1779 actor,
1780 Some(&client),
1781 None,
1782 )
1783 .unwrap();
1784 }
1785 let writer2 = FilesystemMemWriter::new(mem_dir.clone());
1787 let engine2 = Engine::from_mounts(vec![(
1788 folder_mount("specs", mem_dir),
1789 Box::new(writer2) as Box<dyn MemBackend>,
1790 )])
1791 .unwrap();
1792 let entity = engine2
1793 .get_entity(&crate::EntityId::new("specs", "survives-restart"))
1794 .expect("entity must persist across engine restart");
1795 assert_eq!(entity.title, "Survives Restart");
1796 }
1797
1798 fn engine_with_seed(tmp: &TempDir, title: &str) -> (Engine, CreateEntityOutcome) {
1805 let mem_dir = tmp.path().to_path_buf();
1806 let writer = FilesystemMemWriter::new(mem_dir.clone());
1807 let mut engine = Engine::from_mounts(vec![(
1808 folder_mount("specs", mem_dir),
1809 Box::new(writer) as Box<dyn MemBackend>,
1810 )])
1811 .unwrap();
1812 let (actor, client) = cli_actor();
1813 let outcome = engine
1814 .create_entity(
1815 empty_create_args("specs", title),
1816 actor,
1817 Some(&client),
1818 None,
1819 )
1820 .unwrap();
1821 (engine, outcome)
1822 }
1823
1824 #[test]
1832 fn create_entity_emits_inline_wiki_link_auto_stubbed_for_new_stub_target() {
1833 let tmp = TempDir::new().unwrap();
1834 let mem_dir = tmp.path().to_path_buf();
1835 let writer = FilesystemMemWriter::new(mem_dir.clone());
1836 let mut engine = Engine::from_mounts(vec![(
1837 folder_mount("specs", mem_dir),
1838 Box::new(writer) as Box<dyn MemBackend>,
1839 )])
1840 .unwrap();
1841 let (actor, client) = cli_actor();
1842
1843 let ghost = crate::EntityId::new("specs", "ghost-target");
1844 assert!(!engine.store().contains(&ghost), "ghost must not pre-exist");
1845
1846 let mut args = empty_create_args("specs", "Source With Body Link");
1847 args.sections.insert(
1848 "identity".to_string(),
1849 "ref [[ghost-target]] for context".to_string(),
1850 );
1851
1852 let outcome = engine
1853 .create_entity(args, actor, Some(&client), None)
1854 .unwrap();
1855 let stubbed: Vec<&crate::EntityId> = outcome
1856 .warnings
1857 .iter()
1858 .filter_map(|w| match w {
1859 WarningHint::InlineWikiLinkAutoStubbed { stubs, .. } => Some(stubs),
1860 _ => None,
1861 })
1862 .flatten()
1863 .collect();
1864 assert!(
1865 stubbed.contains(&&ghost),
1866 "INLINE_WIKI_LINK_AUTO_STUBBED warning must name the ghost target; got: {:?}",
1867 outcome.warnings,
1868 );
1869 assert!(
1871 engine.store().contains(&ghost),
1872 "ghost stub must materialise"
1873 );
1874 }
1875
1876 #[test]
1881 fn create_entity_drops_self_link_keeps_other_links_and_warns() {
1882 let tmp = TempDir::new().unwrap();
1883 let mem_dir = tmp.path().to_path_buf();
1884 let writer = FilesystemMemWriter::new(mem_dir.clone());
1885 let mut engine = Engine::from_mounts(vec![(
1886 folder_mount("specs", mem_dir),
1887 Box::new(writer) as Box<dyn MemBackend>,
1888 )])
1889 .unwrap();
1890 let (actor, client) = cli_actor();
1891
1892 let mut args = empty_create_args("specs", "Selfie");
1895 args.sections.insert(
1896 "identity".to_string(),
1897 "see [[selfie]] itself and also [[other-ref]]".to_string(),
1898 );
1899 let outcome = engine
1900 .create_entity(args, actor, Some(&client), None)
1901 .unwrap();
1902 let self_id = outcome.id.clone();
1903 assert_eq!(self_id.to_string(), "specs--selfie");
1904 let other_id = crate::EntityId::new("specs", "other-ref");
1905
1906 assert!(
1908 outcome.warnings.iter().any(|w| matches!(
1909 w, WarningHint::SelfLinkIgnored { id } if *id == self_id
1910 )),
1911 "self-link must emit SELF_LINK_IGNORED; got: {:?}",
1912 outcome.warnings,
1913 );
1914
1915 let ent = engine.get_entity(&self_id).unwrap();
1917 assert!(
1918 ent.relationships.iter().all(|r| r.target != self_id),
1919 "no self-relation may be synthesised; got: {:?}",
1920 ent.relationships,
1921 );
1922 assert!(
1923 engine
1924 .store()
1925 .outgoing(&self_id)
1926 .iter()
1927 .all(|e| e.target != self_id),
1928 "self must not be its own Outgoing neighbour",
1929 );
1930 assert!(
1931 engine
1932 .store()
1933 .incoming(&self_id)
1934 .iter()
1935 .all(|e| e.from != self_id),
1936 "self must not be its own Incoming neighbour",
1937 );
1938
1939 assert!(
1942 ent.relationships
1943 .iter()
1944 .any(|r| r.rel_type == "REFERENCES" && r.target == other_id),
1945 "non-self body link must still synthesise its edge; got: {:?}",
1946 ent.relationships,
1947 );
1948 }
1949
1950 #[test]
1952 fn create_entity_dry_run_emits_same_auto_stub_warning() {
1953 let tmp = TempDir::new().unwrap();
1954 let mem_dir = tmp.path().to_path_buf();
1955 let writer = FilesystemMemWriter::new(mem_dir.clone());
1956 let mut engine = Engine::from_mounts(vec![(
1957 folder_mount("specs", mem_dir),
1958 Box::new(writer) as Box<dyn MemBackend>,
1959 )])
1960 .unwrap();
1961 let (actor, client) = cli_actor();
1962
1963 let mut args = empty_create_args("specs", "Dry Run Body Link");
1964 args.dry_run = true;
1965 args.sections
1966 .insert("identity".to_string(), "see [[dry-run-ghost]]".to_string());
1967
1968 let outcome = engine
1969 .create_entity(args, actor, Some(&client), None)
1970 .unwrap();
1971 let has_warning = outcome.warnings.iter().any(|w| {
1972 matches!(
1973 w,
1974 WarningHint::InlineWikiLinkAutoStubbed { stubs, .. }
1975 if stubs.iter().any(|t| t.to_string() == "specs--dry-run-ghost")
1976 )
1977 });
1978 assert!(
1979 has_warning,
1980 "dry_run must emit the same warning as real write: {:?}",
1981 outcome.warnings
1982 );
1983 }
1984
1985 #[test]
1988 fn create_entity_no_auto_stub_warning_when_target_exists() {
1989 let tmp = TempDir::new().unwrap();
1990 let (mut engine, existing) = engine_with_seed(&tmp, "Existing Target");
1991 let (actor, client) = cli_actor();
1992
1993 let mut args = empty_create_args("specs", "Source Linking Existing");
1994 let body = format!("ref [[{}]]", existing.id.path());
1995 args.sections.insert("identity".to_string(), body);
1996
1997 let outcome = engine
1998 .create_entity(args, actor, Some(&client), None)
1999 .unwrap();
2000 let has_warning = outcome
2001 .warnings
2002 .iter()
2003 .any(|w| matches!(w, WarningHint::InlineWikiLinkAutoStubbed { .. }));
2004 assert!(
2005 !has_warning,
2006 "no auto-stub warning when target pre-exists; got: {:?}",
2007 outcome.warnings
2008 );
2009 }
2010
2011 fn engine_with_two_default_mems() -> (TempDir, TempDir, Engine) {
2016 let tmp_test = TempDir::new().unwrap();
2017 let tmp_other = TempDir::new().unwrap();
2018 let test_dir = tmp_test.path().to_path_buf();
2019 let other_dir = tmp_other.path().to_path_buf();
2020 let writer_test = FilesystemMemWriter::new(test_dir.clone());
2021 let writer_other = FilesystemMemWriter::new(other_dir.clone());
2022 let engine = Engine::from_mounts(vec![
2023 (
2024 folder_mount("test", test_dir),
2025 Box::new(writer_test) as Box<dyn MemBackend>,
2026 ),
2027 (
2028 folder_mount("other", other_dir),
2029 Box::new(writer_other) as Box<dyn MemBackend>,
2030 ),
2031 ])
2032 .unwrap();
2033 (tmp_test, tmp_other, engine)
2034 }
2035
2036 #[test]
2041 fn create_entity_refuses_inline_cross_mem_relation_when_policy_denies() {
2042 use crate::entity::EntityId;
2043 use crate::ops::RelateArg;
2044 use memstead_schema::workspace_config::CrossLinkValue;
2045
2046 let (_tmp_test, _tmp_other, mut engine) = engine_with_two_default_mems();
2047 let (actor, client) = cli_actor();
2048
2049 let mut settings = crate::workspace::WorkspaceSettings::default();
2052 settings.cross_mem_links.insert(
2053 "test".to_string(),
2054 CrossLinkValue::List(vec!["other".to_string()]),
2055 );
2056 engine.set_settings(settings);
2057
2058 let target = engine
2063 .create_entity(
2064 empty_create_args("test", "Target"),
2065 actor,
2066 Some(&client),
2067 None,
2068 )
2069 .unwrap();
2070
2071 let mut args = empty_create_args("other", "Source");
2072 args.relations = vec![RelateArg {
2073 rel_type: "IMPLEMENTS".to_string(),
2074 to: target.id.clone(),
2075 description: None,
2076 }];
2077 let err = engine
2078 .create_entity(args, actor, Some(&client), None)
2079 .unwrap_err();
2080 match err {
2081 EngineError::CrossMemLinkNotAllowed { from_mem, to_mem } => {
2082 assert_eq!(from_mem, "other");
2083 assert_eq!(to_mem, "test");
2084 }
2085 other => panic!("expected CROSS_MEM_LINK_NOT_ALLOWED, got {other:?}"),
2086 }
2087
2088 let would_be = EntityId::new("other", "source");
2090 assert!(
2091 engine.get_entity(&would_be).is_none(),
2092 "entity must not persist when inline relation refuses"
2093 );
2094 }
2095
2096 #[test]
2099 fn create_entity_allows_inline_cross_mem_relation_when_policy_grants() {
2100 use crate::ops::RelateArg;
2101 use memstead_schema::workspace_config::CrossLinkValue;
2102
2103 let (_tmp_test, _tmp_other, mut engine) = engine_with_two_default_mems();
2104 let (actor, client) = cli_actor();
2105
2106 let mut settings = crate::workspace::WorkspaceSettings::default();
2107 settings.cross_mem_links.insert(
2108 "other".to_string(),
2109 CrossLinkValue::List(vec!["test".to_string()]),
2110 );
2111 engine.set_settings(settings);
2112
2113 let target = engine
2114 .create_entity(
2115 empty_create_args("test", "Target"),
2116 actor,
2117 Some(&client),
2118 None,
2119 )
2120 .unwrap();
2121
2122 let mut args = empty_create_args("other", "Source");
2123 args.relations = vec![RelateArg {
2124 rel_type: "IMPLEMENTS".to_string(),
2125 to: target.id.clone(),
2126 description: None,
2127 }];
2128 let outcome = engine
2129 .create_entity(args, actor, Some(&client), None)
2130 .unwrap();
2131 let stored = engine.get_entity(&outcome.id).expect("entity persists");
2132 assert!(
2133 stored
2134 .relationships
2135 .iter()
2136 .any(|r| r.rel_type == "IMPLEMENTS" && r.target == target.id),
2137 "IMPLEMENTS edge must persist on the source's relationships",
2138 );
2139 }
2140
2141 #[test]
2145 fn create_entity_admits_same_mem_inline_relation_regardless_of_policy() {
2146 use crate::ops::RelateArg;
2147
2148 let (_tmp_test, _tmp_other, mut engine) = engine_with_two_default_mems();
2149 let (actor, client) = cli_actor();
2150 let target = engine
2153 .create_entity(
2154 empty_create_args("test", "Target"),
2155 actor,
2156 Some(&client),
2157 None,
2158 )
2159 .unwrap();
2160 let mut args = empty_create_args("test", "Source");
2161 args.relations = vec![RelateArg {
2162 rel_type: "USES".to_string(),
2163 to: target.id.clone(),
2164 description: None,
2165 }];
2166 let outcome = engine
2167 .create_entity(args, actor, Some(&client), None)
2168 .unwrap();
2169 let stored = engine.get_entity(&outcome.id).expect("entity persists");
2170 assert!(
2171 stored
2172 .relationships
2173 .iter()
2174 .any(|r| r.rel_type == "USES" && r.target == target.id),
2175 "same-mem USES edge must persist",
2176 );
2177 }
2178
2179 #[test]
2184 fn relate_and_create_refuse_cross_mem_policy_with_identical_envelope() {
2185 use crate::ops::RelateArg;
2186 use memstead_schema::workspace_config::CrossLinkValue;
2187
2188 let (_tmp_test, _tmp_other, mut engine) = engine_with_two_default_mems();
2189 let (actor, client) = cli_actor();
2190
2191 let mut settings = crate::workspace::WorkspaceSettings::default();
2192 settings.cross_mem_links.insert(
2193 "test".to_string(),
2194 CrossLinkValue::List(vec!["other".to_string()]),
2195 );
2196 engine.set_settings(settings);
2197
2198 let target = engine
2199 .create_entity(
2200 empty_create_args("test", "Target"),
2201 actor,
2202 Some(&client),
2203 None,
2204 )
2205 .unwrap();
2206 let src = engine
2207 .create_entity(
2208 empty_create_args("other", "Source"),
2209 actor,
2210 Some(&client),
2211 None,
2212 )
2213 .unwrap();
2214
2215 let relate_err = engine
2217 .relate_entity(
2218 RelateEntityArgs {
2219 source: src.id.clone(),
2220 rel_type: "IMPLEMENTS".to_string(),
2221 target: target.id.clone(),
2222 expected_hash: Some(src.content_hash.clone()),
2223 remove: false,
2224 description: None,
2225 },
2226 actor,
2227 Some(&client),
2228 None,
2229 )
2230 .unwrap_err();
2231
2232 let mut create_args = empty_create_args("other", "Source Two");
2235 create_args.relations = vec![RelateArg {
2236 rel_type: "IMPLEMENTS".to_string(),
2237 to: target.id.clone(),
2238 description: None,
2239 }];
2240 let create_err = engine
2241 .create_entity(create_args, actor, Some(&client), None)
2242 .unwrap_err();
2243
2244 match (relate_err, create_err) {
2247 (
2248 EngineError::CrossMemLinkNotAllowed {
2249 from_mem: rfv,
2250 to_mem: rtv,
2251 },
2252 EngineError::CrossMemLinkNotAllowed {
2253 from_mem: cfv,
2254 to_mem: ctv,
2255 },
2256 ) => {
2257 assert_eq!(rfv, "other");
2258 assert_eq!(rtv, "test");
2259 assert_eq!(cfv, "other");
2260 assert_eq!(ctv, "test");
2261 }
2262 (a, b) => panic!(
2263 "expected matching CROSS_MEM_LINK_NOT_ALLOWED on both surfaces; got relate={a:?}, create={b:?}"
2264 ),
2265 }
2266 }
2267
2268 #[test]
2274 fn create_entity_body_link_cross_mem_dash_form_routes_correctly() {
2275 use crate::entity::EntityId;
2276 use indexmap::IndexMap;
2277 use memstead_schema::workspace_config::CrossLinkValue;
2278
2279 let (_tmp_test, _tmp_other, mut engine) = engine_with_two_default_mems();
2280 let (actor, client) = cli_actor();
2281
2282 let mut settings = crate::workspace::WorkspaceSettings::default();
2283 settings.cross_mem_links.insert(
2284 "test".to_string(),
2285 CrossLinkValue::List(vec!["other".to_string()]),
2286 );
2287 engine.set_settings(settings);
2288
2289 let mut sections: IndexMap<String, String> = IndexMap::new();
2290 sections.insert(
2291 "identity".to_string(),
2292 "see [[other--target]] for details".to_string(),
2293 );
2294 sections.insert("purpose".to_string(), "source purpose".to_string());
2295 let outcome = engine
2296 .create_entity(
2297 crate::engine::CreateEntityArgs {
2298 mem: "test".to_string(),
2299 title: "Source".to_string(),
2300 entity_type: "spec".to_string(),
2301 sections,
2302 metadata: IndexMap::new(),
2303 relations: Vec::new(),
2304 dry_run: false,
2305 },
2306 actor,
2307 Some(&client),
2308 None,
2309 )
2310 .unwrap();
2311
2312 let canonical = EntityId::new("other", "target");
2314 assert!(
2315 engine.get_entity(&canonical).is_some(),
2316 "auto-stub must land at the canonical cross-mem id"
2317 );
2318 let phantom = EntityId::new("test", "other--target");
2319 assert!(
2320 engine.get_entity(&phantom).is_none(),
2321 "no double-prefixed phantom stub"
2322 );
2323
2324 let source = engine.get_entity(&outcome.id).unwrap();
2326 let references_count = source
2327 .relationships
2328 .iter()
2329 .filter(|r| r.rel_type == "REFERENCES" && r.target == canonical)
2330 .count();
2331 assert_eq!(
2332 references_count, 1,
2333 "alias-synthesis must emit exactly one REFERENCES edge per cross-mem body link",
2334 );
2335 }
2336
2337 #[test]
2341 fn create_entity_body_link_cross_mem_refused_when_policy_denies() {
2342 use indexmap::IndexMap;
2343
2344 let (_tmp_test, _tmp_other, mut engine) = engine_with_two_default_mems();
2345 let (actor, client) = cli_actor();
2346 let mut sections: IndexMap<String, String> = IndexMap::new();
2349 sections.insert(
2350 "identity".to_string(),
2351 "see [[other--target]] for details".to_string(),
2352 );
2353 sections.insert("purpose".to_string(), "source purpose".to_string());
2354 let err = engine
2355 .create_entity(
2356 crate::engine::CreateEntityArgs {
2357 mem: "test".to_string(),
2358 title: "Source".to_string(),
2359 entity_type: "spec".to_string(),
2360 sections,
2361 metadata: IndexMap::new(),
2362 relations: Vec::new(),
2363 dry_run: false,
2364 },
2365 actor,
2366 Some(&client),
2367 None,
2368 )
2369 .unwrap_err();
2370 match err {
2371 EngineError::CrossMemLinkNotAllowed { from_mem, to_mem } => {
2372 assert_eq!(from_mem, "test");
2373 assert_eq!(to_mem, "other");
2374 }
2375 other => panic!("expected CROSS_MEM_LINK_NOT_ALLOWED, got {other:?}"),
2376 }
2377 }
2378
2379 #[test]
2387 fn require_notes_drives_single_note_missing_warning_per_noteless_mutation() {
2388 use crate::engine::UpdateEntityArgs;
2389 use crate::workspace::{MutationsSection, WorkspaceSettings};
2390 use indexmap::IndexMap;
2391
2392 let tmp = TempDir::new().unwrap();
2393 let mem_dir = tmp.path().to_path_buf();
2394 let writer = FilesystemMemWriter::new(mem_dir.clone());
2395 let mut engine = Engine::from_mounts(vec![(
2396 folder_mount("specs", mem_dir.clone()),
2397 Box::new(writer) as Box<dyn MemBackend>,
2398 )])
2399 .unwrap();
2400 engine.set_workspace_root(mem_dir.clone());
2401 engine.set_settings(WorkspaceSettings {
2402 mutations: MutationsSection {
2403 require_notes: Some(true),
2404 },
2405 ..Default::default()
2406 });
2407 let (actor, client) = cli_actor();
2408
2409 let note_missing = |ws: &[WarningHint]| -> usize {
2410 ws.iter()
2411 .filter(|w| matches!(w, WarningHint::NoteMissing { tool: _ }))
2412 .count()
2413 };
2414
2415 let created = engine
2417 .create_entity(
2418 empty_create_args("specs", "Noteless"),
2419 actor,
2420 Some(&client),
2421 None,
2422 )
2423 .unwrap();
2424 assert_eq!(
2425 note_missing(&created.warnings),
2426 1,
2427 "create under require_notes must emit exactly one NOTE_MISSING; got {:?}",
2428 created.warnings,
2429 );
2430 assert!(
2431 matches!(
2432 created.warnings.iter().find(|w| matches!(w, WarningHint::NoteMissing { .. })),
2433 Some(WarningHint::NoteMissing { tool }) if tool == "create_entity"
2434 ),
2435 "the warning names the engine-level verb",
2436 );
2437 assert!(
2438 !created.commit_sha.is_empty(),
2439 "create still commits (nudge, not block)"
2440 );
2441
2442 let mut edit: IndexMap<String, String> = IndexMap::new();
2444 edit.insert("identity".to_string(), "revised".to_string());
2445 let updated = engine
2446 .update_entity(
2447 UpdateEntityArgs {
2448 id: created.id.clone(),
2449 expected_hash: Some(created.content_hash.clone()),
2450 sections: edit,
2451 append_sections: IndexMap::new(),
2452 patch_sections: IndexMap::new(),
2453 metadata: IndexMap::new(),
2454 metadata_unset: Vec::new(),
2455 declare_relations: Vec::new(),
2456 dry_run: false,
2457 relations_unset: Vec::new(),
2458 },
2459 actor,
2460 Some(&client),
2461 None,
2462 )
2463 .unwrap();
2464 assert_eq!(
2465 note_missing(&updated.warnings),
2466 1,
2467 "update emits NOTE_MISSING"
2468 );
2469 assert!(!updated.commit_sha.is_empty(), "update still commits");
2470
2471 let target = engine
2473 .create_entity(
2474 empty_create_args("specs", "Target"),
2475 actor,
2476 Some(&client),
2477 Some("seed"),
2478 )
2479 .unwrap();
2480 let related = engine
2481 .relate_entity(
2482 RelateEntityArgs {
2483 source: updated.id.clone(),
2484 expected_hash: Some(updated.content_hash.clone()),
2485 rel_type: "USES".to_string(),
2486 target: target.id.clone(),
2487 remove: false,
2488 description: None,
2489 },
2490 actor,
2491 Some(&client),
2492 None,
2493 )
2494 .unwrap();
2495 assert_eq!(
2496 note_missing(&related.warnings),
2497 1,
2498 "relate emits NOTE_MISSING"
2499 );
2500 assert!(!related.commit_sha.is_empty(), "relate still commits");
2501
2502 let with_note = engine
2504 .create_entity(
2505 empty_create_args("specs", "Documented"),
2506 actor,
2507 Some(&client),
2508 Some("a real provenance note"),
2509 )
2510 .unwrap();
2511 assert_eq!(
2512 note_missing(&with_note.warnings),
2513 0,
2514 "a supplied note suppresses the warning",
2515 );
2516
2517 engine.set_settings(WorkspaceSettings::default());
2519 let after_off = engine
2520 .create_entity(
2521 empty_create_args("specs", "Quiet"),
2522 actor,
2523 Some(&client),
2524 None,
2525 )
2526 .unwrap();
2527 assert_eq!(
2528 note_missing(&after_off.warnings),
2529 0,
2530 "no NOTE_MISSING when require_notes is unset",
2531 );
2532 }
2533}