1use std::path::Path;
5
6use crate::engine_fallback_type;
7use crate::entity::EntityId;
8use crate::entity::generator::generate_markdown;
9use crate::entity::id::validate_and_derive_slug;
10use crate::entity::parser::parse_markdown;
11use crate::entity::store_builder::push_entities_into_store;
12use crate::ops::WarningHint;
13use crate::provenance::{Provenance, ProvenanceKind};
14use crate::vcs::{Actor, ClientId, CommitContext};
15use crate::workspace::MountCapability;
16
17use super::super::{Engine, EngineError, RenameEntityArgs, RenameEntityOutcome};
18use super::{make_stub, unknown_type_error};
19
20impl Engine {
21 pub fn rename_entity_with_ctx(
24 &mut self,
25 old_id: &EntityId,
26 new_title: &str,
27 expected_hash: &str,
28 ctx: &CommitContext<'_>,
29 ) -> Result<RenameEntityOutcome, EngineError> {
30 let args = RenameEntityArgs {
31 id: old_id.clone(),
32 expected_hash: Some(expected_hash.to_string()),
33 new_title: new_title.to_string(),
34 };
35 self.rename_entity(args, ctx.actor, ctx.client.as_ref(), ctx.note.as_deref())
36 }
37
38 pub fn rename_entity(
55 &mut self,
56 args: RenameEntityArgs,
57 actor: Actor,
58 client: Option<&ClientId>,
59 note: Option<&str>,
60 ) -> Result<RenameEntityOutcome, EngineError> {
61 let id = &args.id;
62 let mem = id.mem().to_string();
63
64 let mount_idx = self
65 .mounts
66 .iter()
67 .position(|m| m.mount.mem == mem)
68 .ok_or_else(|| EngineError::UnknownMem(mem.clone()))?;
69 if self.mounts[mount_idx].mount.capability != MountCapability::Write {
70 return Err(EngineError::ReadOnlyMount(mem));
71 }
72
73 let mut drift_warnings = self.reload_if_stale(Some(&mem));
78
79 let entity = self
80 .store
81 .get(id)
82 .ok_or_else(|| EngineError::NotFound { id: id.to_string() })?;
83
84 if entity.stub {
90 return Err(EngineError::StubNotRenamable { id: id.to_string() });
91 }
92
93 if let Some(expected) = args.expected_hash.as_deref()
94 && entity.content_hash != expected
95 {
96 return Err(EngineError::HashMismatch {
97 id: id.to_string(),
98 current: entity.content_hash.clone(),
99 is_stub: entity.stub,
100 });
101 }
102
103 let new_slug = validate_and_derive_slug(&args.new_title)?;
104 let new_id = EntityId::new(&mem, &new_slug);
105 crate::entity::id::enforce_id_length(new_id.as_ref())?;
106
107 if new_id == *id {
108 return Ok(RenameEntityOutcome {
114 old_id: id.clone(),
115 new_id: new_id.clone(),
116 old_path: entity.file_path.clone(),
117 new_path: entity.file_path.clone(),
118 content_hash: entity.content_hash.clone(),
119 commit_sha: String::new(),
120 warnings: vec![WarningHint::TitleNormalizedToSlugNoop {
121 requested_title: args.new_title.clone(),
122 current_slug: id.name().to_string(),
123 }],
124 });
125 }
126 if self.store.contains(&new_id) {
127 return Err(EngineError::AlreadyExists {
128 id: new_id.to_string(),
129 });
130 }
131
132 let schema = self
133 .schemas
134 .get(&mem)
135 .expect("schema present for every registered mount");
136 let type_def = schema
137 .get_type(&entity.entity_type)
138 .ok_or_else(|| unknown_type_error(schema, &entity.entity_type))?;
139
140 let old_file_path = entity.file_path.clone();
141 let new_file_path = format!("{new_slug}.md");
142
143 let mut next = entity.clone();
144 next.id = new_id.clone();
145 next.title = args.new_title.clone();
146 next.file_path = new_file_path.clone();
147
148 for rel in next.relationships.iter_mut() {
155 if rel.target == *id {
156 rel.target = new_id.clone();
157 }
158 }
159
160 let old_slug = id.name().to_string();
175 let new_slug_owned = new_slug.clone();
176 for body in next.sections.values_mut() {
177 let (rewritten, count) = crate::entity::wikilink_rewrite::rewrite_bare_slug(
178 body,
179 &old_slug,
180 &new_slug_owned,
181 );
182 if count > 0 {
183 *body = rewritten;
184 }
185 let (rewritten, count) = crate::entity::wikilink_rewrite::rewrite_cross_mem_slug(
186 body,
187 &mem,
188 &old_slug,
189 &new_slug_owned,
190 );
191 if count > 0 {
192 *body = rewritten;
193 }
194 }
195
196 let today = super::today_iso();
204 super::auto_stamp_timestamps(&mut next, type_def.as_ref(), &today);
205
206 let markdown = generate_markdown(&next, type_def.as_ref());
207
208 let mut seen: std::collections::HashSet<EntityId> = std::collections::HashSet::new();
220 let mut same_mem_ids: Vec<EntityId> = Vec::new();
221 let mut cross_mem_ids: Vec<EntityId> = Vec::new();
222 for in_edge in self.store.incoming(id) {
223 if in_edge.from == *id || !seen.insert(in_edge.from.clone()) {
224 continue;
225 }
226 if in_edge.from.mem() == mem {
227 same_mem_ids.push(in_edge.from.clone());
228 } else {
229 cross_mem_ids.push(in_edge.from.clone());
230 }
231 }
232
233 let mut cross_mem_write_ids: Vec<EntityId> = Vec::new();
241 let mut readonly_referrers: Vec<EntityId> = Vec::new();
242 for from_id in cross_mem_ids {
243 match self
244 .mount(from_id.mem())
245 .map(|m| m.capability)
246 .unwrap_or(MountCapability::Write)
247 {
248 MountCapability::Write => cross_mem_write_ids.push(from_id),
249 MountCapability::ReadOnly => readonly_referrers.push(from_id),
250 }
251 }
252 readonly_referrers.sort_by_key(|a| a.to_string());
253
254 let mut blocked_counts: std::collections::BTreeMap<String, usize> =
264 std::collections::BTreeMap::new();
265 for from_id in &cross_mem_write_ids {
266 let peer_mem = from_id.mem().to_string();
267 if !self.cross_mem_link_allowed(&peer_mem, &mem) {
268 *blocked_counts.entry(peer_mem).or_insert(0) += 1;
269 }
270 }
271 if !blocked_counts.is_empty() {
272 let blocked_referrers: Vec<crate::engine::error::BlockedReferrer> = blocked_counts
273 .into_iter()
274 .map(|(peer_mem, count)| crate::engine::error::BlockedReferrer {
275 from_mem: peer_mem,
276 to_mem: mem.clone(),
277 count,
278 })
279 .collect();
280 return Err(EngineError::RenameBlockedByCrossMemPolicy {
281 from_mem: mem.clone(),
282 blocked_referrers,
283 });
284 }
285
286 same_mem_ids.sort_by_key(|a| a.to_string());
290 cross_mem_write_ids.sort_by_key(|a| a.to_string());
291
292 let mut same_mem_writes: Vec<(
298 String,
299 String,
300 std::sync::Arc<memstead_schema::TypeDefinition>,
301 )> = Vec::with_capacity(same_mem_ids.len());
302 for from_id in &same_mem_ids {
303 let Some(referrer) = self.store.get(from_id) else {
304 continue;
305 };
306 if referrer.stub {
307 continue;
308 }
309 let referrer_type_def = schema
310 .get_type(&referrer.entity_type)
311 .ok_or_else(|| unknown_type_error(schema, &referrer.entity_type))?;
312 let mut next_ref = referrer.clone();
313 for rel in next_ref.relationships.iter_mut() {
314 if rel.target == *id {
315 rel.target = new_id.clone();
316 }
317 }
318 for body in next_ref.sections.values_mut() {
319 let (rewritten, count) = crate::entity::wikilink_rewrite::rewrite_bare_slug(
320 body,
321 &old_slug,
322 &new_slug_owned,
323 );
324 if count > 0 {
325 *body = rewritten;
326 }
327 let (rewritten, count) = crate::entity::wikilink_rewrite::rewrite_cross_mem_slug(
332 body,
333 &mem,
334 &old_slug,
335 &new_slug_owned,
336 );
337 if count > 0 {
338 *body = rewritten;
339 }
340 }
341 let ref_markdown = generate_markdown(&next_ref, referrer_type_def.as_ref());
352 same_mem_writes.push((ref_markdown, next_ref.file_path.clone(), referrer_type_def));
353 }
354
355 struct PeerMemPlan {
361 mount_idx: usize,
362 mem: String,
363 writes: Vec<(
364 String,
365 String,
366 std::sync::Arc<memstead_schema::TypeDefinition>,
367 )>,
368 }
369 let mut peer_plans: std::collections::BTreeMap<String, PeerMemPlan> =
370 std::collections::BTreeMap::new();
371 for from_id in &cross_mem_write_ids {
372 let peer_mem = from_id.mem().to_string();
373 let peer_mount_idx = self
374 .mounts
375 .iter()
376 .position(|m| m.mount.mem == peer_mem)
377 .expect("peer mount present for collected referrer id");
378 let peer_schema = self
379 .schemas
380 .get(&peer_mem)
381 .expect("schema present for every registered mount");
382
383 let Some(referrer) = self.store.get(from_id) else {
384 continue;
385 };
386 if referrer.stub {
387 continue;
388 }
389 let referrer_type_def = peer_schema
390 .get_type(&referrer.entity_type)
391 .ok_or_else(|| unknown_type_error(peer_schema, &referrer.entity_type))?;
392 let mut next_ref = referrer.clone();
393 for rel in next_ref.relationships.iter_mut() {
394 if rel.target == *id {
395 rel.target = new_id.clone();
396 }
397 }
398 for body in next_ref.sections.values_mut() {
404 let (rewritten, count) = crate::entity::wikilink_rewrite::rewrite_cross_mem_slug(
405 body,
406 &mem,
407 &old_slug,
408 &new_slug_owned,
409 );
410 if count > 0 {
411 *body = rewritten;
412 }
413 }
414 let ref_markdown = generate_markdown(&next_ref, referrer_type_def.as_ref());
420
421 peer_plans
422 .entry(peer_mem.clone())
423 .or_insert_with(|| PeerMemPlan {
424 mount_idx: peer_mount_idx,
425 mem: peer_mem.clone(),
426 writes: Vec::new(),
427 })
428 .writes
429 .push((ref_markdown, next_ref.file_path.clone(), referrer_type_def));
430 }
431
432 let logical_op_id = crate::provenance::mint_logical_operation_id();
440
441 let backend = self.mounts[mount_idx].backend.as_ref();
442 backend.write_entity(Path::new(&new_file_path), markdown.as_bytes())?;
443 backend.delete_entity(Path::new(&old_file_path))?;
444 for (ref_markdown, ref_file_path, _) in &same_mem_writes {
445 backend.write_entity(Path::new(ref_file_path), ref_markdown.as_bytes())?;
446 }
447 super::stage_anchors_rename(backend, id, &new_id)?;
452 let commit_subject = format!("memstead: rename {} → {new_id}", id);
453 let ctx = CommitContext {
454 actor,
455 client: client.cloned(),
456 tool: Some("rename_entity"),
457 note: note.map(String::from),
458 logical_operation_id: Some(logical_op_id.as_str()),
459 entity_ids: None,
460 };
461 let commit_sha = backend.commit(&commit_subject, &ctx)?;
462
463 backend.append_provenance(
464 &Provenance::new(
465 std::time::SystemTime::now(),
466 ProvenanceKind::Rename,
467 Some(new_id.to_string()),
468 actor,
469 client.cloned(),
470 note.map(String::from),
471 )
472 .with_logical_operation_id(logical_op_id.clone()),
473 )?;
474
475 self.record_self_write(mount_idx, &commit_sha);
476
477 let mut peer_snapshots: std::collections::BTreeMap<String, Option<String>> =
491 std::collections::BTreeMap::new();
492 for plan in peer_plans.values() {
493 let peer_backend = self.mounts[plan.mount_idx].backend.as_ref();
494 let snapshot = peer_backend.current_head()?;
495 peer_snapshots.insert(plan.mem.clone(), snapshot);
496 }
497
498 let mut committed_mems: Vec<String> = vec![mem.clone()];
503 for plan in peer_plans.values() {
504 let peer_backend = self.mounts[plan.mount_idx].backend.as_ref();
505 for (ref_markdown, ref_file_path, _) in &plan.writes {
506 peer_backend.write_entity(Path::new(ref_file_path), ref_markdown.as_bytes())?;
507 }
508 let peer_commit_subject = format!(
509 "memstead: rename {} → {new_id} (cross-mem rewrite in `{}`)",
510 id, plan.mem
511 );
512 let peer_ctx = CommitContext {
513 actor,
514 client: client.cloned(),
515 tool: Some("rename_entity"),
516 note: note.map(String::from),
517 logical_operation_id: Some(logical_op_id.as_str()),
518 entity_ids: None,
519 };
520 let expected = peer_snapshots.get(&plan.mem).cloned().unwrap_or(None);
521 let peer_commit_result = peer_backend.commit_with_expected_parent(
522 &peer_commit_subject,
523 &peer_ctx,
524 expected.as_deref(),
525 );
526 let peer_commit_sha = match peer_commit_result {
527 Ok(sha) => sha,
528 Err(crate::backend::BackendError::ParentMismatch { .. }) => {
529 return Err(EngineError::RenamePartialFailure {
530 committed_mems: std::mem::take(&mut committed_mems),
531 failed_mem: plan.mem.clone(),
532 failure_cause: "drift".to_string(),
533 });
534 }
535 Err(e) => return Err(e.into()),
536 };
537 peer_backend.append_provenance(
538 &Provenance::new(
539 std::time::SystemTime::now(),
540 ProvenanceKind::Rename,
541 Some(new_id.to_string()),
542 actor,
543 client.cloned(),
544 note.map(String::from),
545 )
546 .with_logical_operation_id(logical_op_id.clone()),
547 )?;
548 self.record_self_write(plan.mount_idx, &peer_commit_sha);
549 committed_mems.push(plan.mem.clone());
550 }
551
552 let parse_result = parse_markdown(&markdown, &new_file_path, type_def.as_ref(), &mem)
554 .map_err(|e| EngineError::ParseAfterWrite(e.to_string()))?;
555 let content_hash = parse_result.entity.content_hash.clone();
556
557 let mut parse_results = vec![parse_result];
558 for (ref_markdown, ref_file_path, ref_type_def) in &same_mem_writes {
559 let pr = parse_markdown(ref_markdown, ref_file_path, ref_type_def.as_ref(), &mem)
560 .map_err(|e| EngineError::ParseAfterWrite(e.to_string()))?;
561 parse_results.push(pr);
562 }
563 for plan in peer_plans.values() {
564 for (ref_markdown, ref_file_path, ref_type_def) in &plan.writes {
565 let pr = parse_markdown(
566 ref_markdown,
567 ref_file_path,
568 ref_type_def.as_ref(),
569 &plan.mem,
570 )
571 .map_err(|e| EngineError::ParseAfterWrite(e.to_string()))?;
572 parse_results.push(pr);
573 }
574 }
575
576 let mut outcome_warnings: Vec<WarningHint> = Vec::new();
589 outcome_warnings.append(&mut drift_warnings);
591 if readonly_referrers.is_empty() {
592 self.store.remove(id);
593 } else {
594 self.store.remove_edges_from(id);
599 self.store.upsert(
600 id.clone(),
601 make_stub(
602 id,
603 crate::entity::StubKind::Residual {
604 since_commit: commit_sha.clone(),
605 readonly_referrers: readonly_referrers.clone(),
606 },
607 ),
608 );
609 outcome_warnings.push(WarningHint::ResidualStubForReadOnlyReferrers {
610 id: id.clone(),
611 referrers: readonly_referrers,
612 });
613 }
614
615 let fallback = engine_fallback_type();
616 push_entities_into_store(&mut self.store, parse_results, fallback.as_ref(), None);
617 crate::entity::store_builder::remap_alias_target_edge_sources(
618 &mut self.store,
619 &self.schemas,
620 );
621
622 self.invalidate_communities();
623 self.invalidate_search_indexes();
624
625 if let Some(w) = self.note_missing_warning("rename_entity", note) {
630 outcome_warnings.push(w);
631 }
632
633 Ok(RenameEntityOutcome {
634 old_id: id.clone(),
635 new_id,
636 old_path: old_file_path,
637 new_path: new_file_path,
638 content_hash,
639 commit_sha,
640 warnings: outcome_warnings,
641 })
642 }
643}
644
645#[cfg(test)]
646mod tests {
647 use std::path::PathBuf;
648
649 use tempfile::TempDir;
650
651 use crate::backend::MemBackend;
652 use crate::engine::test_helpers::*;
653 use crate::engine::{Engine, EngineError, RenameEntityArgs};
654 use crate::ops::WarningHint;
655 use crate::storage::FilesystemMemWriter;
656
657 #[test]
658 fn rename_moves_entity_anchors_to_new_id() {
659 let tmp = TempDir::new().unwrap();
660 let mem_dir = tmp.path().to_path_buf();
661 let writer = FilesystemMemWriter::new(mem_dir.clone());
662 let mut engine = Engine::from_mounts(vec![(
663 folder_mount("specs", mem_dir.clone()),
664 Box::new(writer) as Box<dyn MemBackend>,
665 )])
666 .unwrap();
667 let (actor, client) = cli_actor();
668 let mut args = empty_create_args("specs", "Old Anchored");
669 args.anchors = vec![crate::anchor::AnchorInput {
670 artifact: Some("src/lib.rs".into()),
671 grain: Some("file".into()),
672 class: Some("anchored".into()),
673 hash: Some("h1".into()),
674 hash_stability: Some("stable".into()),
675 ..Default::default()
676 }];
677 let seeded = engine
678 .create_entity(args, actor, Some(&client), None)
679 .unwrap();
680 let old_id = seeded.id.clone();
681
682 let outcome = engine
683 .rename_entity(
684 RenameEntityArgs {
685 id: old_id.clone(),
686 expected_hash: Some(seeded.content_hash.clone()),
687 new_title: "New Anchored".to_string(),
688 },
689 actor,
690 Some(&client),
691 None,
692 )
693 .unwrap();
694
695 assert!(engine.entity_anchors(&old_id).is_empty());
697 assert_eq!(engine.entity_anchors(&outcome.new_id).len(), 1);
698 assert_eq!(
699 engine.anchors_referencing_artifact("src/lib.rs"),
700 vec![(
701 outcome.new_id.clone(),
702 engine.entity_anchors(&outcome.new_id)[0].clone()
703 )]
704 );
705 }
706
707 #[test]
708 fn rename_entity_renames_file_and_id_persists_across_restart() {
709 let tmp = TempDir::new().unwrap();
710 let mem_dir = tmp.path().to_path_buf();
711
712 let (old_id, new_id, new_file) = {
713 let writer = FilesystemMemWriter::new(mem_dir.clone());
714 let mut engine = Engine::from_mounts(vec![(
715 folder_mount("specs", mem_dir.clone()),
716 Box::new(writer) as Box<dyn MemBackend>,
717 )])
718 .unwrap();
719 let (actor, client) = cli_actor();
720 let seeded = engine
721 .create_entity(
722 empty_create_args("specs", "Old Name"),
723 actor,
724 Some(&client),
725 None,
726 )
727 .unwrap();
728 let outcome = engine
729 .rename_entity(
730 RenameEntityArgs {
731 id: seeded.id.clone(),
732 expected_hash: Some(seeded.content_hash.clone()),
733 new_title: "New Name".to_string(),
734 },
735 actor,
736 Some(&client),
737 None,
738 )
739 .unwrap();
740 assert_eq!(outcome.old_id.to_string(), "specs--old-name");
741 assert_eq!(outcome.new_id.to_string(), "specs--new-name");
742 assert_eq!(outcome.new_path, "new-name.md");
743 assert!(!mem_dir.join(&outcome.old_path).exists());
745 assert!(mem_dir.join(&outcome.new_path).exists());
746 (outcome.old_id, outcome.new_id, outcome.new_path)
747 };
748
749 let writer2 = FilesystemMemWriter::new(mem_dir.clone());
751 let engine2 = Engine::from_mounts(vec![(
752 folder_mount("specs", mem_dir),
753 Box::new(writer2) as Box<dyn MemBackend>,
754 )])
755 .unwrap();
756 assert!(engine2.get_entity(&old_id).is_none());
757 let new_entity = engine2.get_entity(&new_id).expect("new id must persist");
758 assert_eq!(new_entity.title, "New Name");
759 assert_eq!(new_entity.file_path, new_file);
760 }
761
762 #[test]
763 fn rename_entity_returns_typed_warning_on_slug_noop() {
764 let tmp = TempDir::new().unwrap();
765 let (mut engine, seeded) = engine_with_seed(&tmp, "Same Slug");
766 let (actor, client) = cli_actor();
767 let outcome = engine
768 .rename_entity(
769 RenameEntityArgs {
770 id: seeded.id.clone(),
771 expected_hash: Some(seeded.content_hash.clone()),
772 new_title: "Same Slug".to_string(), },
774 actor,
775 Some(&client),
776 None,
777 )
778 .unwrap();
779 assert_eq!(outcome.old_id, outcome.new_id);
784 assert_eq!(outcome.old_path, outcome.new_path);
785 assert!(outcome.commit_sha.is_empty());
786 assert_eq!(outcome.warnings.len(), 1);
787 assert!(matches!(
788 outcome.warnings[0],
789 WarningHint::TitleNormalizedToSlugNoop { .. }
790 ));
791 }
792
793 #[test]
794 fn rename_entity_returns_commit_sha_on_real_rename() {
795 let tmp = TempDir::new().unwrap();
796 let (mut engine, seeded) = engine_with_seed(&tmp, "Old Name");
797 let (actor, client) = cli_actor();
798 let outcome = engine
799 .rename_entity(
800 RenameEntityArgs {
801 id: seeded.id.clone(),
802 expected_hash: Some(seeded.content_hash.clone()),
803 new_title: "Brand New Name".to_string(),
804 },
805 actor,
806 Some(&client),
807 None,
808 )
809 .unwrap();
810 assert_ne!(outcome.old_id, outcome.new_id);
813 assert!(
814 !outcome.commit_sha.is_empty(),
815 "commit_sha must be populated on a real rename"
816 );
817 assert!(outcome.warnings.is_empty());
818 }
819
820 #[test]
821 fn rename_entity_rewrites_self_references_in_body_and_relationships() {
822 use crate::entity::EntityId;
823 use indexmap::IndexMap;
824
825 let tmp = TempDir::new().unwrap();
826 let mem_dir = tmp.path().to_path_buf();
827 let writer = FilesystemMemWriter::new(mem_dir.clone());
828 let mut engine = Engine::from_mounts(vec![(
829 folder_mount("specs", mem_dir.clone()),
830 Box::new(writer) as Box<dyn MemBackend>,
831 )])
832 .unwrap();
833 let (actor, client) = cli_actor();
834
835 let mut sections: IndexMap<String, String> = IndexMap::new();
841 sections.insert("identity".to_string(), "the seed identity".to_string());
842 sections.insert(
843 "purpose".to_string(),
844 "see also [[old-name]] for prior context".to_string(),
845 );
846 let seeded = engine
853 .create_entity(
854 crate::engine::CreateEntityArgs {
855 anchors: Vec::new(),
856 mem: "specs".to_string(),
857 title: "Old Name".to_string(),
858 entity_type: "spec".to_string(),
859 sections,
860 metadata: IndexMap::new(),
861 relations: Vec::new(),
862 dry_run: false,
863 },
864 actor,
865 Some(&client),
866 None,
867 )
868 .unwrap();
869 assert_eq!(seeded.id.to_string(), "specs--old-name");
870 let related = seeded.clone();
871
872 let outcome = engine
873 .rename_entity(
874 RenameEntityArgs {
875 id: seeded.id.clone(),
876 expected_hash: Some(related.content_hash.clone()),
877 new_title: "Brand New Name".to_string(),
878 },
879 actor,
880 Some(&client),
881 None,
882 )
883 .unwrap();
884 assert_eq!(outcome.new_id.to_string(), "specs--brand-new-name");
885
886 let new_bytes = std::fs::read_to_string(mem_dir.join(&outcome.new_path)).unwrap();
889 assert!(
890 new_bytes.contains("[[brand-new-name]]"),
891 "expected new slug in body, got:\n{new_bytes}"
892 );
893 assert!(
894 !new_bytes.contains("[[old-name]]"),
895 "old slug must not survive in the rewritten file, got:\n{new_bytes}"
896 );
897
898 let in_mem = engine.get_entity(&outcome.new_id).unwrap();
901 assert!(
902 in_mem
903 .sections
904 .get("purpose")
905 .map(|s| s.contains("[[brand-new-name]]"))
906 .unwrap_or(false),
907 "section body must be rewritten in-memory; got {:?}",
908 in_mem.sections.get("purpose")
909 );
910 let new_self_target = EntityId::new("specs", "brand-new-name");
914 assert!(
915 in_mem
916 .relationships
917 .iter()
918 .all(|r| r.target != seeded.id && r.target != new_self_target),
919 "a self-referential body link must produce no self-relation (F11), got: {:?}",
920 in_mem.relationships
921 );
922 }
923
924 #[test]
933 fn rename_preserves_referrer_last_modified_but_rewrites_link() {
934 let tmp = TempDir::new().unwrap();
935 let mem_dir = tmp.path().to_path_buf();
936
937 std::fs::write(
938 mem_dir.join("target.md"),
939 "---\ntype: spec\ncreated_date: 2020-01-01\nlast_modified: 2020-01-01\nlevel: M0\n---\n# Target\n\n## Identity\n\nT\n\n## Purpose\n\nP\n",
940 )
941 .unwrap();
942 std::fs::write(
943 mem_dir.join("referrer.md"),
944 "---\ntype: spec\ncreated_date: 2020-01-01\nlast_modified: 2020-01-01\nlevel: M0\n---\n# Referrer\n\n## Identity\n\nR\n\n## Purpose\n\nDepends on [[target]] for context.\n\n## Relationships\n\n- **REFERENCES**: [[target]]\n",
945 )
946 .unwrap();
947
948 let writer = FilesystemMemWriter::new(mem_dir.clone());
949 let mut engine = Engine::from_mounts(vec![(
950 folder_mount("specs", mem_dir.clone()),
951 Box::new(writer) as Box<dyn MemBackend>,
952 )])
953 .unwrap();
954 let (actor, client) = cli_actor();
955
956 let target_id = crate::entity::EntityId::new("specs", "target");
957 let target_hash = engine
958 .store()
959 .get(&target_id)
960 .expect("target loaded from disk")
961 .content_hash
962 .clone();
963
964 engine
965 .rename_entity(
966 RenameEntityArgs {
967 id: target_id,
968 expected_hash: Some(target_hash),
969 new_title: "Target Renamed".to_string(),
970 },
971 actor,
972 Some(&client),
973 None,
974 )
975 .expect("rename succeeds");
976
977 let referrer_md = std::fs::read_to_string(mem_dir.join("referrer.md")).unwrap();
978 assert!(
980 referrer_md.contains("last_modified: 2020-01-01"),
981 "referrer's last_modified must be preserved across a rename-driven slug rewrite; got:\n{referrer_md}"
982 );
983 assert!(
985 referrer_md.contains("[[target-renamed]]"),
986 "referrer's body wiki-link must be rewritten to the new slug; got:\n{referrer_md}"
987 );
988 assert!(
989 !referrer_md.contains("[[target]]"),
990 "old slug must not survive in the referrer body; got:\n{referrer_md}"
991 );
992 }
993
994 #[test]
995 fn rename_entity_rewrites_same_mem_referrers_atomically() {
996 use crate::engine::CreateEntityArgs;
997 use crate::entity::EntityId;
998 use indexmap::IndexMap;
999
1000 let tmp = TempDir::new().unwrap();
1001 let mem_dir = tmp.path().to_path_buf();
1002 let writer = FilesystemMemWriter::new(mem_dir.clone());
1003 let mut engine = Engine::from_mounts(vec![(
1004 folder_mount("specs", mem_dir.clone()),
1005 Box::new(writer) as Box<dyn MemBackend>,
1006 )])
1007 .unwrap();
1008 let (actor, client) = cli_actor();
1009
1010 let target = engine
1012 .create_entity(
1013 empty_create_args("specs", "Target Spec"),
1014 actor,
1015 Some(&client),
1016 None,
1017 )
1018 .unwrap();
1019 assert_eq!(target.id.to_string(), "specs--target-spec");
1020
1021 let mut sections_a: IndexMap<String, String> = IndexMap::new();
1024 sections_a.insert(
1025 "identity".to_string(),
1026 "referrer alpha identity".to_string(),
1027 );
1028 sections_a.insert(
1029 "purpose".to_string(),
1030 "rationale relies on [[target-spec]] for context".to_string(),
1031 );
1032 let referrer_a = engine
1033 .create_entity(
1034 CreateEntityArgs {
1035 anchors: Vec::new(),
1036 mem: "specs".to_string(),
1037 title: "Referrer Alpha".to_string(),
1038 entity_type: "spec".to_string(),
1039 sections: sections_a,
1040 metadata: IndexMap::new(),
1041 relations: Vec::new(),
1045 dry_run: false,
1046 },
1047 actor,
1048 Some(&client),
1049 None,
1050 )
1051 .unwrap();
1052
1053 let mut sections_b: IndexMap<String, String> = IndexMap::new();
1056 sections_b.insert("identity".to_string(), "referrer beta identity".to_string());
1057 sections_b.insert(
1058 "purpose".to_string(),
1059 "consult [[target-spec]] for the canonical phrasing".to_string(),
1060 );
1061 let referrer_b = engine
1062 .create_entity(
1063 CreateEntityArgs {
1064 anchors: Vec::new(),
1065 mem: "specs".to_string(),
1066 title: "Referrer Bravo".to_string(),
1067 entity_type: "spec".to_string(),
1068 sections: sections_b,
1069 metadata: IndexMap::new(),
1070 relations: Vec::new(),
1074 dry_run: false,
1075 },
1076 actor,
1077 Some(&client),
1078 None,
1079 )
1080 .unwrap();
1081
1082 let bystander = engine
1085 .create_entity(
1086 empty_create_args("specs", "Bystander"),
1087 actor,
1088 Some(&client),
1089 None,
1090 )
1091 .unwrap();
1092 let bystander_bytes_before =
1093 std::fs::read_to_string(mem_dir.join(&bystander.file_path)).unwrap();
1094
1095 let renamed = engine
1096 .rename_entity(
1097 RenameEntityArgs {
1098 id: target.id.clone(),
1099 expected_hash: Some(target.content_hash.clone()),
1100 new_title: "Renamed Spec".to_string(),
1101 },
1102 actor,
1103 Some(&client),
1104 None,
1105 )
1106 .unwrap();
1107 assert_eq!(renamed.new_id.to_string(), "specs--renamed-spec");
1108
1109 for path in std::fs::read_dir(&mem_dir).unwrap().flatten() {
1112 let p = path.path();
1113 if p.extension().and_then(|s| s.to_str()) != Some("md") {
1114 continue;
1115 }
1116 let body = std::fs::read_to_string(&p).unwrap();
1117 assert!(
1118 !body.contains("[[target-spec]]"),
1119 "old slug must not survive in {}, got:\n{body}",
1120 p.display()
1121 );
1122 }
1123
1124 let in_mem_a = engine.get_entity(&referrer_a.id).unwrap();
1126 assert!(
1127 in_mem_a
1128 .relationships
1129 .iter()
1130 .any(|r| r.rel_type == "REFERENCES"
1131 && r.target == EntityId::new("specs", "renamed-spec")),
1132 "expected referrer A's relation to point at renamed-spec, got {:?}",
1133 in_mem_a.relationships
1134 );
1135 assert!(
1136 in_mem_a
1137 .sections
1138 .get("purpose")
1139 .map(|s| s.contains("[[renamed-spec]]"))
1140 .unwrap_or(false),
1141 "referrer A's body must be rewritten"
1142 );
1143
1144 let in_mem_b = engine.get_entity(&referrer_b.id).unwrap();
1147 assert!(
1148 in_mem_b
1149 .sections
1150 .get("purpose")
1151 .map(|s| s.contains("[[renamed-spec]]"))
1152 .unwrap_or(false),
1153 "referrer B's body must be rewritten"
1154 );
1155
1156 let bystander_bytes_after =
1158 std::fs::read_to_string(mem_dir.join(&bystander.file_path)).unwrap();
1159 assert_eq!(
1160 bystander_bytes_before, bystander_bytes_after,
1161 "bystander must not be rewritten"
1162 );
1163 }
1164
1165 fn engine_with_two_mems_and_bidirectional_policy(
1171 specs_dir: PathBuf,
1172 memos_dir: PathBuf,
1173 ) -> Engine {
1174 use memstead_schema::workspace_config::CrossLinkValue;
1175 let writer_specs = FilesystemMemWriter::new(specs_dir.clone());
1176 let writer_memos = FilesystemMemWriter::new(memos_dir.clone());
1177 let mut engine = Engine::from_mounts(vec![
1178 (
1179 folder_mount("specs", specs_dir),
1180 Box::new(writer_specs) as Box<dyn MemBackend>,
1181 ),
1182 (
1183 folder_mount("memos", memos_dir),
1184 Box::new(writer_memos) as Box<dyn MemBackend>,
1185 ),
1186 ])
1187 .unwrap();
1188 let mut settings = crate::workspace::WorkspaceSettings::default();
1189 settings.cross_mem_links.insert(
1190 "memos".to_string(),
1191 CrossLinkValue::List(vec!["specs".to_string()]),
1192 );
1193 settings.cross_mem_links.insert(
1194 "specs".to_string(),
1195 CrossLinkValue::List(vec!["memos".to_string()]),
1196 );
1197 engine.set_settings(settings);
1198 engine
1199 }
1200
1201 #[test]
1202 fn rename_entity_rewrites_cross_mem_write_referrer() {
1203 use crate::engine::CreateEntityArgs;
1204 use crate::entity::EntityId;
1205 use indexmap::IndexMap;
1206
1207 let tmp_specs = TempDir::new().unwrap();
1208 let tmp_memos = TempDir::new().unwrap();
1209 let specs_dir = tmp_specs.path().to_path_buf();
1210 let memos_dir = tmp_memos.path().to_path_buf();
1211 let mut engine =
1212 engine_with_two_mems_and_bidirectional_policy(specs_dir.clone(), memos_dir.clone());
1213 let (actor, client) = cli_actor();
1214
1215 let target = engine
1217 .create_entity(
1218 empty_create_args("specs", "Target Spec"),
1219 actor,
1220 Some(&client),
1221 None,
1222 )
1223 .unwrap();
1224
1225 let mut sections: IndexMap<String, String> = IndexMap::new();
1232 sections.insert("claim".to_string(), "the claim".to_string());
1233 sections.insert(
1234 "context".to_string(),
1235 "discussion stems from [[specs:target-spec]]".to_string(),
1236 );
1237 let referrer = engine
1238 .create_entity(
1239 CreateEntityArgs {
1240 anchors: Vec::new(),
1241 mem: "memos".to_string(),
1242 title: "Cross Note".to_string(),
1243 entity_type: "memo".to_string(),
1244 sections,
1245 metadata: IndexMap::new(),
1246 relations: Vec::new(),
1250 dry_run: false,
1251 },
1252 actor,
1253 Some(&client),
1254 None,
1255 )
1256 .unwrap();
1257
1258 let renamed = engine
1260 .rename_entity(
1261 RenameEntityArgs {
1262 id: target.id.clone(),
1263 expected_hash: Some(target.content_hash.clone()),
1264 new_title: "Renamed Spec".to_string(),
1265 },
1266 actor,
1267 Some(&client),
1268 None,
1269 )
1270 .unwrap();
1271 assert_eq!(renamed.new_id.to_string(), "specs--renamed-spec");
1272
1273 let referrer_path = memos_dir.join(&referrer.file_path);
1276 let referrer_bytes = std::fs::read_to_string(&referrer_path).unwrap();
1277 assert!(
1278 referrer_bytes.contains("[[specs:renamed-spec]]"),
1279 "expected colon-form rewrite in referrer body, got:\n{referrer_bytes}"
1280 );
1281 assert!(
1282 !referrer_bytes.contains("target-spec"),
1283 "old slug must not survive in referrer file, got:\n{referrer_bytes}"
1284 );
1285
1286 let in_mem = engine.get_entity(&referrer.id).unwrap();
1288 assert!(
1289 in_mem
1290 .relationships
1291 .iter()
1292 .any(|r| r.rel_type == "REFERENCES"
1293 && r.target == EntityId::new("specs", "renamed-spec")),
1294 "expected cross-mem relation to point at renamed-spec, got {:?}",
1295 in_mem.relationships
1296 );
1297 assert!(
1298 in_mem.relationships.iter().all(|r| r.target != target.id),
1299 "no relationship may still target the old id, got: {:?}",
1300 in_mem.relationships
1301 );
1302 }
1303
1304 struct DriftingBackend {
1311 inner: Box<dyn MemBackend>,
1312 }
1313 impl DriftingBackend {
1314 fn new(inner: Box<dyn MemBackend>) -> Self {
1315 Self { inner }
1316 }
1317 }
1318 impl crate::backend::MemBackend for DriftingBackend {
1319 fn list_entities(&self) -> Result<Vec<PathBuf>, crate::backend::BackendError> {
1320 self.inner.list_entities()
1321 }
1322 fn read_entity(
1323 &self,
1324 rel: &std::path::Path,
1325 ) -> Result<Option<Vec<u8>>, crate::backend::BackendError> {
1326 self.inner.read_entity(rel)
1327 }
1328 fn write_entity(
1329 &self,
1330 rel: &std::path::Path,
1331 b: &[u8],
1332 ) -> Result<(), crate::backend::BackendError> {
1333 self.inner.write_entity(rel, b)
1334 }
1335 fn delete_entity(&self, rel: &std::path::Path) -> Result<(), crate::backend::BackendError> {
1336 self.inner.delete_entity(rel)
1337 }
1338 fn move_entity(
1339 &self,
1340 f: &std::path::Path,
1341 t: &std::path::Path,
1342 ) -> Result<(), crate::backend::BackendError> {
1343 self.inner.move_entity(f, t)
1344 }
1345 fn commit(
1346 &self,
1347 m: &str,
1348 c: &crate::vcs::CommitContext<'_>,
1349 ) -> Result<crate::storage::CommitId, crate::backend::BackendError> {
1350 self.inner.commit(m, c)
1351 }
1352 fn commit_with_expected_parent(
1353 &self,
1354 m: &str,
1355 c: &crate::vcs::CommitContext<'_>,
1356 expected_parent: Option<&str>,
1357 ) -> Result<crate::storage::CommitId, crate::backend::BackendError> {
1358 if let Some(expected) = expected_parent {
1359 Err(crate::backend::BackendError::ParentMismatch {
1360 expected: expected.to_string(),
1361 actual: "drifted-by-sibling-writer".to_string(),
1362 })
1363 } else {
1364 self.inner.commit(m, c)
1365 }
1366 }
1367 fn append_provenance(
1368 &self,
1369 r: &crate::Provenance,
1370 ) -> Result<(), crate::backend::BackendError> {
1371 self.inner.append_provenance(r)
1372 }
1373 fn read_provenance(
1374 &self,
1375 c: Option<&str>,
1376 ) -> Result<Vec<crate::Provenance>, crate::backend::BackendError> {
1377 self.inner.read_provenance(c)
1378 }
1379 fn current_head(&self) -> Result<Option<String>, crate::backend::BackendError> {
1380 Ok(Some("snapshot-head-sha".to_string()))
1383 }
1384 }
1385
1386 #[test]
1387 fn rename_entity_surfaces_partial_failure_when_peer_mem_drifts() {
1388 use crate::engine::CreateEntityArgs;
1389 use indexmap::IndexMap;
1390 use memstead_schema::workspace_config::CrossLinkValue;
1391
1392 let tmp_specs = TempDir::new().unwrap();
1393 let tmp_memos = TempDir::new().unwrap();
1394 let specs_dir = tmp_specs.path().to_path_buf();
1395 let memos_dir = tmp_memos.path().to_path_buf();
1396
1397 let writer_specs = FilesystemMemWriter::new(specs_dir.clone());
1402 let writer_memos_inner: Box<dyn MemBackend> =
1403 Box::new(FilesystemMemWriter::new(memos_dir.clone()));
1404 let writer_memos = DriftingBackend::new(writer_memos_inner);
1405
1406 let mut engine = Engine::from_mounts(vec![
1407 (
1408 folder_mount("specs", specs_dir.clone()),
1409 Box::new(writer_specs) as Box<dyn MemBackend>,
1410 ),
1411 (
1412 folder_mount("memos", memos_dir.clone()),
1413 Box::new(writer_memos) as Box<dyn MemBackend>,
1414 ),
1415 ])
1416 .unwrap();
1417 let mut settings = crate::workspace::WorkspaceSettings::default();
1418 settings.cross_mem_links.insert(
1419 "memos".to_string(),
1420 CrossLinkValue::List(vec!["specs".to_string()]),
1421 );
1422 settings.cross_mem_links.insert(
1423 "specs".to_string(),
1424 CrossLinkValue::List(vec!["memos".to_string()]),
1425 );
1426 engine.set_settings(settings);
1427
1428 let (actor, client) = cli_actor();
1429 let target = engine
1430 .create_entity(
1431 empty_create_args("specs", "Target Spec"),
1432 actor,
1433 Some(&client),
1434 None,
1435 )
1436 .unwrap();
1437 let mut sections: IndexMap<String, String> = IndexMap::new();
1438 sections.insert("claim".to_string(), "the claim".to_string());
1439 sections.insert(
1440 "context".to_string(),
1441 "see [[specs:target-spec]]".to_string(),
1442 );
1443 let _referrer = engine
1444 .create_entity(
1445 CreateEntityArgs {
1446 anchors: Vec::new(),
1447 mem: "memos".to_string(),
1448 title: "Cross Note".to_string(),
1449 entity_type: "memo".to_string(),
1450 sections,
1451 metadata: IndexMap::new(),
1452 relations: Vec::new(),
1456 dry_run: false,
1457 },
1458 actor,
1459 Some(&client),
1460 None,
1461 )
1462 .unwrap();
1463
1464 let err = engine
1465 .rename_entity(
1466 RenameEntityArgs {
1467 id: target.id.clone(),
1468 expected_hash: Some(target.content_hash.clone()),
1469 new_title: "Renamed Spec".to_string(),
1470 },
1471 actor,
1472 Some(&client),
1473 None,
1474 )
1475 .unwrap_err();
1476 match err {
1477 EngineError::RenamePartialFailure {
1478 committed_mems,
1479 failed_mem,
1480 failure_cause,
1481 } => {
1482 assert_eq!(committed_mems, vec!["specs".to_string()]);
1486 assert_eq!(failed_mem, "memos");
1487 assert_eq!(failure_cause, "drift");
1488 }
1489 other => panic!("expected RenamePartialFailure, got {other:?}"),
1490 }
1491 assert!(specs_dir.join("renamed-spec.md").exists());
1495 assert!(!specs_dir.join(&target.file_path).exists());
1496 }
1497
1498 #[test]
1499 fn rename_entity_tags_every_per_mem_commit_with_same_logical_operation_id() {
1500 use crate::backend::MemBackend;
1501 use crate::engine::CreateEntityArgs;
1502 use indexmap::IndexMap;
1503
1504 let tmp_specs = TempDir::new().unwrap();
1505 let tmp_memos = TempDir::new().unwrap();
1506 let specs_dir = tmp_specs.path().to_path_buf();
1507 let memos_dir = tmp_memos.path().to_path_buf();
1508 let mut engine =
1509 engine_with_two_mems_and_bidirectional_policy(specs_dir.clone(), memos_dir.clone());
1510 let (actor, client) = cli_actor();
1511
1512 let target = engine
1513 .create_entity(
1514 empty_create_args("specs", "Target Spec"),
1515 actor,
1516 Some(&client),
1517 None,
1518 )
1519 .unwrap();
1520 let mut sections: IndexMap<String, String> = IndexMap::new();
1521 sections.insert("claim".to_string(), "the claim".to_string());
1522 sections.insert(
1523 "context".to_string(),
1524 "discussion stems from [[specs:target-spec]]".to_string(),
1525 );
1526 let _referrer = engine
1527 .create_entity(
1528 CreateEntityArgs {
1529 anchors: Vec::new(),
1530 mem: "memos".to_string(),
1531 title: "Cross Note".to_string(),
1532 entity_type: "memo".to_string(),
1533 sections,
1534 metadata: IndexMap::new(),
1535 relations: Vec::new(),
1539 dry_run: false,
1540 },
1541 actor,
1542 Some(&client),
1543 None,
1544 )
1545 .unwrap();
1546
1547 let _ = engine
1548 .rename_entity(
1549 RenameEntityArgs {
1550 id: target.id.clone(),
1551 expected_hash: Some(target.content_hash.clone()),
1552 new_title: "Renamed Spec".to_string(),
1553 },
1554 actor,
1555 Some(&client),
1556 None,
1557 )
1558 .unwrap();
1559
1560 let specs_backend: Box<dyn MemBackend> =
1564 Box::new(FilesystemMemWriter::new(specs_dir.clone()));
1565 let memos_backend: Box<dyn MemBackend> =
1566 Box::new(FilesystemMemWriter::new(memos_dir.clone()));
1567 let specs_provenance = specs_backend.read_provenance(None).unwrap();
1568 let memos_provenance = memos_backend.read_provenance(None).unwrap();
1569
1570 let specs_rename = specs_provenance
1571 .iter()
1572 .find(|p| matches!(p.kind, crate::provenance::ProvenanceKind::Rename))
1573 .expect("specs mem must have a rename provenance entry");
1574 let memos_rename = memos_provenance
1575 .iter()
1576 .find(|p| matches!(p.kind, crate::provenance::ProvenanceKind::Rename))
1577 .expect("memos mem must have a rename provenance entry");
1578
1579 let specs_id = specs_rename
1580 .logical_operation_id
1581 .as_deref()
1582 .expect("specs rename entry must carry a logical_operation_id");
1583 let memos_id = memos_rename
1584 .logical_operation_id
1585 .as_deref()
1586 .expect("memos rename entry must carry a logical_operation_id");
1587 assert_eq!(
1588 specs_id, memos_id,
1589 "both per-mem rename commits must share the same logical_operation_id"
1590 );
1591 assert!(
1592 specs_id.starts_with("logop-"),
1593 "logical_operation_id must use the `logop-` prefix the engine mints; got {specs_id}"
1594 );
1595 }
1596
1597 #[test]
1598 fn rename_entity_refuses_when_cross_mem_referrer_blocked_by_policy() {
1599 use crate::engine::CreateEntityArgs;
1600 use indexmap::IndexMap;
1601 use memstead_schema::workspace_config::CrossLinkValue;
1602
1603 let tmp_specs = TempDir::new().unwrap();
1604 let tmp_memos = TempDir::new().unwrap();
1605 let specs_dir = tmp_specs.path().to_path_buf();
1606 let memos_dir = tmp_memos.path().to_path_buf();
1607
1608 let mut engine =
1611 engine_with_two_mems_and_bidirectional_policy(specs_dir.clone(), memos_dir.clone());
1612 let (actor, client) = cli_actor();
1613
1614 let target = engine
1615 .create_entity(
1616 empty_create_args("specs", "Target Spec"),
1617 actor,
1618 Some(&client),
1619 None,
1620 )
1621 .unwrap();
1622 let mut sections: IndexMap<String, String> = IndexMap::new();
1623 sections.insert("claim".to_string(), "the claim".to_string());
1624 sections.insert(
1625 "context".to_string(),
1626 "see [[specs:target-spec]]".to_string(),
1627 );
1628 let _referrer = engine
1629 .create_entity(
1630 CreateEntityArgs {
1631 anchors: Vec::new(),
1632 mem: "memos".to_string(),
1633 title: "Cross Note".to_string(),
1634 entity_type: "memo".to_string(),
1635 sections,
1636 metadata: IndexMap::new(),
1637 relations: Vec::new(),
1641 dry_run: false,
1642 },
1643 actor,
1644 Some(&client),
1645 None,
1646 )
1647 .unwrap();
1648
1649 let mut settings = crate::workspace::WorkspaceSettings::default();
1655 settings.cross_mem_links.insert(
1656 "specs".to_string(),
1657 CrossLinkValue::List(vec!["memos".to_string()]),
1658 );
1659 engine.set_settings(settings);
1661
1662 let err = engine
1663 .rename_entity(
1664 RenameEntityArgs {
1665 id: target.id.clone(),
1666 expected_hash: Some(target.content_hash.clone()),
1667 new_title: "Renamed Spec".to_string(),
1668 },
1669 actor,
1670 Some(&client),
1671 None,
1672 )
1673 .unwrap_err();
1674 match err {
1675 EngineError::RenameBlockedByCrossMemPolicy {
1676 from_mem,
1677 blocked_referrers,
1678 } => {
1679 assert_eq!(from_mem, "specs");
1680 assert_eq!(blocked_referrers.len(), 1);
1681 assert_eq!(blocked_referrers[0].from_mem, "memos");
1682 assert_eq!(blocked_referrers[0].to_mem, "specs");
1683 assert_eq!(blocked_referrers[0].count, 1);
1684 }
1685 other => panic!("expected RenameBlockedByCrossMemPolicy, got {other:?}"),
1686 }
1687 assert!(specs_dir.join(&target.file_path).exists());
1690 assert!(!specs_dir.join("renamed-spec.md").exists());
1691 }
1692
1693 #[test]
1704 fn rename_entity_demotes_to_stub_when_only_readonly_cross_mem_referrers_remain() {
1705 use crate::engine::test_helpers::{archive_mount, build_archive};
1706 use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
1707
1708 let tmp = TempDir::new().unwrap();
1709 let writable_dir = tmp.path().join("writable");
1710 std::fs::create_dir_all(&writable_dir).unwrap();
1711 let writer = FilesystemMemWriter::new(writable_dir.clone());
1712
1713 let archive_md = "---\ntype: spec\ncreated_date: 2026-01-15\nlast_modified: 2026-04-12\nlevel: M0\n---\n# Archived Source\n\n## Identity\n\nLinks to [[specs:target]].\n\n## Purpose\n\nFixture for rename residual-stub demotion.\n\n## Relationships\n\n- **REFERENCES**: [[specs:target]]\n";
1717 let archive_path = build_archive(
1718 tmp.path(),
1719 "archive",
1720 &[("archived-source.md", archive_md.as_bytes())],
1721 );
1722
1723 let folder_mount = Mount {
1724 mem: "specs".to_string(),
1725 schema: Some(crate::engine::test_helpers::pin("default")),
1726 storage: MountStorage::Folder {
1727 path: writable_dir.clone(),
1728 },
1729 capability: MountCapability::Write,
1730 lifecycle: MountLifecycle::Eager,
1731 cross_linkable: true,
1732 migration_target: None,
1733 };
1734 let archive_reader = crate::storage::ArchiveBackend::new(archive_path.clone());
1735 let mut engine = Engine::from_mounts(vec![
1736 (folder_mount, Box::new(writer) as Box<dyn MemBackend>),
1737 (
1738 archive_mount("archive", archive_path.clone()),
1739 Box::new(archive_reader) as Box<dyn MemBackend>,
1740 ),
1741 ])
1742 .unwrap();
1743
1744 let (actor, client) = cli_actor();
1745 let target = engine
1746 .create_entity(
1747 empty_create_args("specs", "Target"),
1748 actor,
1749 Some(&client),
1750 None,
1751 )
1752 .unwrap();
1753
1754 let archived_source_id = crate::EntityId::new("archive", "archived-source");
1757 let incoming_pre: Vec<_> = engine
1758 .store()
1759 .incoming(&target.id)
1760 .iter()
1761 .map(|e| e.from.clone())
1762 .collect();
1763 assert!(
1764 incoming_pre.contains(&archived_source_id),
1765 "archive wiki-link must produce an incoming edge on target; got {incoming_pre:?}"
1766 );
1767
1768 let outcome = engine
1771 .rename_entity(
1772 RenameEntityArgs {
1773 id: target.id.clone(),
1774 expected_hash: Some(target.content_hash.clone()),
1775 new_title: "Renamed Target".to_string(),
1776 },
1777 actor,
1778 Some(&client),
1779 None,
1780 )
1781 .unwrap();
1782 assert_eq!(outcome.new_id.to_string(), "specs--renamed-target");
1783
1784 assert!(writable_dir.join(&outcome.new_path).exists());
1786 let demoted = engine
1788 .get_entity(&target.id)
1789 .expect("residual stub must remain at old id");
1790 assert!(demoted.stub, "demoted entity must be flagged as stub");
1791 assert!(demoted.entity_type.is_empty());
1792 let incoming_old: Vec<_> = engine
1795 .store()
1796 .incoming(&target.id)
1797 .iter()
1798 .map(|e| e.from.clone())
1799 .collect();
1800 assert!(
1801 incoming_old.contains(&archived_source_id),
1802 "archive incoming edge must survive demotion at old id; got {incoming_old:?}"
1803 );
1804 let incoming_new: Vec<_> = engine
1807 .store()
1808 .incoming(&outcome.new_id)
1809 .iter()
1810 .map(|e| e.from.clone())
1811 .collect();
1812 assert!(
1813 !incoming_new.contains(&archived_source_id),
1814 "archive must not be wired to new id (markdown still references old slug); got {incoming_new:?}"
1815 );
1816 let referrers = outcome
1818 .warnings
1819 .iter()
1820 .find_map(|w| match w {
1821 WarningHint::ResidualStubForReadOnlyReferrers {
1822 id: warn_id,
1823 referrers,
1824 } => {
1825 assert_eq!(warn_id, &target.id);
1826 Some(referrers.clone())
1827 }
1828 _ => None,
1829 })
1830 .expect("ResidualStubForReadOnlyReferrers warning must surface");
1831 assert_eq!(referrers, vec![archived_source_id]);
1832 }
1833
1834 #[test]
1842 fn rename_entity_rewrites_full_id_form_body_link_on_same_mem_referrer() {
1843 use crate::engine::CreateEntityArgs;
1844 use indexmap::IndexMap;
1845 let tmp = TempDir::new().unwrap();
1846 let mem_dir = tmp.path().to_path_buf();
1847 let writer = FilesystemMemWriter::new(mem_dir.clone());
1848 let mut engine = Engine::from_mounts(vec![(
1849 folder_mount("specs", mem_dir.clone()),
1850 Box::new(writer) as Box<dyn MemBackend>,
1851 )])
1852 .unwrap();
1853 let (actor, client) = cli_actor();
1854
1855 let target = engine
1857 .create_entity(
1858 empty_create_args("specs", "Target Spec"),
1859 actor,
1860 Some(&client),
1861 None,
1862 )
1863 .unwrap();
1864 assert_eq!(target.id.to_string(), "specs--target-spec");
1865
1866 let mut sections: IndexMap<String, String> = IndexMap::new();
1871 sections.insert("identity".to_string(), "referrer identity".to_string());
1872 sections.insert(
1873 "purpose".to_string(),
1874 "see also [[specs--target-spec]] for context".to_string(),
1875 );
1876 let referrer = engine
1877 .create_entity(
1878 CreateEntityArgs {
1879 anchors: Vec::new(),
1880 mem: "specs".to_string(),
1881 title: "Referrer Full".to_string(),
1882 entity_type: "spec".to_string(),
1883 sections,
1884 metadata: IndexMap::new(),
1885 relations: Vec::new(),
1886 dry_run: false,
1887 },
1888 actor,
1889 Some(&client),
1890 None,
1891 )
1892 .unwrap();
1893
1894 let renamed = engine
1895 .rename_entity(
1896 RenameEntityArgs {
1897 id: target.id.clone(),
1898 expected_hash: Some(target.content_hash.clone()),
1899 new_title: "Renamed Spec".to_string(),
1900 },
1901 actor,
1902 Some(&client),
1903 None,
1904 )
1905 .unwrap();
1906 assert_eq!(renamed.new_id.to_string(), "specs--renamed-spec");
1907
1908 let referrer_path = mem_dir.join(&referrer.file_path);
1912 let body = std::fs::read_to_string(&referrer_path).unwrap();
1913 assert!(
1914 body.contains("[[specs--renamed-spec]]"),
1915 "expected full-id form retargeted to new slug, got:\n{body}"
1916 );
1917 assert!(
1918 !body.contains("target-spec"),
1919 "old slug must not survive in any form, got:\n{body}"
1920 );
1921 }
1922
1923 #[test]
1924 fn rename_entity_rejects_collision_with_existing_id() {
1925 let tmp = TempDir::new().unwrap();
1926 let (mut engine, first) = engine_with_seed(&tmp, "First");
1927 let (actor, client) = cli_actor();
1928 let _ = engine
1929 .create_entity(
1930 empty_create_args("specs", "Second"),
1931 actor,
1932 Some(&client),
1933 None,
1934 )
1935 .unwrap();
1936 let err = engine
1938 .rename_entity(
1939 RenameEntityArgs {
1940 id: first.id.clone(),
1941 expected_hash: Some(first.content_hash.clone()),
1942 new_title: "Second".to_string(),
1943 },
1944 actor,
1945 Some(&client),
1946 None,
1947 )
1948 .unwrap_err();
1949 assert!(matches!(err, EngineError::AlreadyExists { id } if id == "specs--second"));
1950 }
1951
1952 #[test]
1959 fn rename_propagation_gate_checks_actual_edge_direction() {
1960 use crate::engine::error::BlockedReferrer;
1961 use crate::engine::{CreateEntityArgs, RelateEntityArgs};
1962 use indexmap::IndexMap;
1963 use memstead_schema::workspace_config::CrossLinkValue;
1964
1965 let tmp_test = TempDir::new().unwrap();
1966 let tmp_other = TempDir::new().unwrap();
1967 let test_dir = tmp_test.path().to_path_buf();
1968 let other_dir = tmp_other.path().to_path_buf();
1969
1970 let writer_test = FilesystemMemWriter::new(test_dir.clone());
1974 let writer_other = FilesystemMemWriter::new(other_dir.clone());
1975 let mut engine = Engine::from_mounts(vec![
1976 (
1977 folder_mount("test", test_dir.clone()),
1978 Box::new(writer_test) as Box<dyn MemBackend>,
1979 ),
1980 (
1981 folder_mount("other", other_dir.clone()),
1982 Box::new(writer_other) as Box<dyn MemBackend>,
1983 ),
1984 ])
1985 .unwrap();
1986 let (actor, client) = cli_actor();
1987
1988 let mut settings = crate::workspace::WorkspaceSettings::default();
1991 settings.cross_mem_links.insert(
1992 "test".to_string(),
1993 CrossLinkValue::List(vec!["other".to_string()]),
1994 );
1995 engine.set_settings(settings);
1996
1997 let target = engine
1998 .create_entity(
1999 empty_create_args("other", "Target"),
2000 actor,
2001 Some(&client),
2002 None,
2003 )
2004 .unwrap();
2005 let mut src_sections: IndexMap<String, String> = IndexMap::new();
2006 src_sections.insert("identity".to_string(), "source identity".to_string());
2007 src_sections.insert("purpose".to_string(), "source purpose".to_string());
2008 let src = engine
2009 .create_entity(
2010 CreateEntityArgs {
2011 anchors: Vec::new(),
2012 mem: "test".to_string(),
2013 title: "Src".to_string(),
2014 entity_type: "spec".to_string(),
2015 sections: src_sections,
2016 metadata: IndexMap::new(),
2017 relations: Vec::new(),
2018 dry_run: false,
2019 },
2020 actor,
2021 Some(&client),
2022 None,
2023 )
2024 .unwrap();
2025 let src = engine
2026 .relate_entity(
2027 RelateEntityArgs {
2028 source: src.id.clone(),
2029 rel_type: "IMPLEMENTS".to_string(),
2030 target: target.id.clone(),
2031 expected_hash: Some(src.content_hash.clone()),
2032 remove: false,
2033 description: None,
2034 },
2035 actor,
2036 Some(&client),
2037 None,
2038 )
2039 .unwrap();
2040 let _ = src; engine.set_settings(crate::workspace::WorkspaceSettings::default());
2047
2048 let err = engine
2049 .rename_entity(
2050 RenameEntityArgs {
2051 id: target.id.clone(),
2052 expected_hash: Some(target.content_hash.clone()),
2053 new_title: "Renamed".to_string(),
2054 },
2055 actor,
2056 Some(&client),
2057 None,
2058 )
2059 .unwrap_err();
2060 match err {
2061 EngineError::RenameBlockedByCrossMemPolicy {
2062 from_mem,
2063 blocked_referrers,
2064 } => {
2065 assert_eq!(from_mem, "other");
2066 assert_eq!(
2067 blocked_referrers,
2068 vec![BlockedReferrer {
2069 from_mem: "test".to_string(),
2070 to_mem: "other".to_string(),
2071 count: 1,
2072 }],
2073 "blocked_referrers must name the actual edge direction (test → other)",
2074 );
2075 }
2076 other => panic!("expected RenameBlockedByCrossMemPolicy, got {other:?}"),
2077 }
2078 assert!(other_dir.join(&target.file_path).exists());
2081 assert!(!other_dir.join("renamed.md").exists());
2082 }
2083
2084 #[test]
2088 fn rename_propagation_succeeds_when_actual_edge_direction_granted() {
2089 use crate::engine::{CreateEntityArgs, RelateEntityArgs};
2090 use indexmap::IndexMap;
2091 use memstead_schema::workspace_config::CrossLinkValue;
2092
2093 let tmp_test = TempDir::new().unwrap();
2094 let tmp_other = TempDir::new().unwrap();
2095 let test_dir = tmp_test.path().to_path_buf();
2096 let other_dir = tmp_other.path().to_path_buf();
2097
2098 let writer_test = FilesystemMemWriter::new(test_dir.clone());
2099 let writer_other = FilesystemMemWriter::new(other_dir.clone());
2100 let mut engine = Engine::from_mounts(vec![
2101 (
2102 folder_mount("test", test_dir),
2103 Box::new(writer_test) as Box<dyn MemBackend>,
2104 ),
2105 (
2106 folder_mount("other", other_dir),
2107 Box::new(writer_other) as Box<dyn MemBackend>,
2108 ),
2109 ])
2110 .unwrap();
2111 let (actor, client) = cli_actor();
2112
2113 let mut settings = crate::workspace::WorkspaceSettings::default();
2114 settings.cross_mem_links.insert(
2115 "test".to_string(),
2116 CrossLinkValue::List(vec!["other".to_string()]),
2117 );
2118 engine.set_settings(settings);
2119
2120 let target = engine
2121 .create_entity(
2122 empty_create_args("other", "Target"),
2123 actor,
2124 Some(&client),
2125 None,
2126 )
2127 .unwrap();
2128 let mut src_sections: IndexMap<String, String> = IndexMap::new();
2129 src_sections.insert("identity".to_string(), "source identity".to_string());
2130 src_sections.insert("purpose".to_string(), "source purpose".to_string());
2131 let src = engine
2132 .create_entity(
2133 CreateEntityArgs {
2134 anchors: Vec::new(),
2135 mem: "test".to_string(),
2136 title: "Src".to_string(),
2137 entity_type: "spec".to_string(),
2138 sections: src_sections,
2139 metadata: IndexMap::new(),
2140 relations: Vec::new(),
2141 dry_run: false,
2142 },
2143 actor,
2144 Some(&client),
2145 None,
2146 )
2147 .unwrap();
2148 let _ = engine
2149 .relate_entity(
2150 RelateEntityArgs {
2151 source: src.id.clone(),
2152 rel_type: "IMPLEMENTS".to_string(),
2153 target: target.id.clone(),
2154 expected_hash: Some(src.content_hash.clone()),
2155 remove: false,
2156 description: None,
2157 },
2158 actor,
2159 Some(&client),
2160 None,
2161 )
2162 .unwrap();
2163
2164 let outcome = engine
2167 .rename_entity(
2168 RenameEntityArgs {
2169 id: target.id.clone(),
2170 expected_hash: Some(target.content_hash.clone()),
2171 new_title: "Renamed".to_string(),
2172 },
2173 actor,
2174 Some(&client),
2175 None,
2176 )
2177 .unwrap();
2178 assert_ne!(outcome.old_id, outcome.new_id);
2179 let renamed = engine
2180 .get_entity(&outcome.new_id)
2181 .expect("renamed entity persists");
2182 assert_eq!(renamed.title, "Renamed");
2183 let updated_src = engine.get_entity(&src.id).expect("source persists");
2185 assert!(
2186 updated_src
2187 .relationships
2188 .iter()
2189 .any(|r| r.rel_type == "IMPLEMENTS" && r.target == outcome.new_id),
2190 "referrer's IMPLEMENTS edge must point at the new id after rewrite"
2191 );
2192 }
2193
2194 #[test]
2198 fn rename_with_no_cross_mem_referrers_succeeds_regardless_of_policy() {
2199 let tmp = TempDir::new().unwrap();
2200 let mem_dir = tmp.path().to_path_buf();
2201 let writer = FilesystemMemWriter::new(mem_dir.clone());
2202 let mut engine = Engine::from_mounts(vec![(
2203 folder_mount("specs", mem_dir),
2204 Box::new(writer) as Box<dyn MemBackend>,
2205 )])
2206 .unwrap();
2207 let (actor, client) = cli_actor();
2208 let target = engine
2212 .create_entity(
2213 empty_create_args("specs", "Target"),
2214 actor,
2215 Some(&client),
2216 None,
2217 )
2218 .unwrap();
2219 let outcome = engine
2220 .rename_entity(
2221 RenameEntityArgs {
2222 id: target.id.clone(),
2223 expected_hash: Some(target.content_hash.clone()),
2224 new_title: "Renamed Target".to_string(),
2225 },
2226 actor,
2227 Some(&client),
2228 None,
2229 )
2230 .unwrap();
2231 assert_ne!(outcome.old_id, outcome.new_id);
2232 }
2233}