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(|| self.unknown_mem_error(&mem))?;
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 derivation = validate_and_derive_slug(&args.new_title)?;
104 let new_slug = derivation.slug.clone();
105 let new_id = EntityId::new(&mem, &new_slug);
106 crate::entity::id::enforce_id_length(new_id.as_ref())?;
107
108 if new_id == *id {
109 return Ok(RenameEntityOutcome {
115 old_id: id.clone(),
116 new_id: new_id.clone(),
117 old_path: entity.file_path.clone(),
118 new_path: entity.file_path.clone(),
119 content_hash: entity.content_hash.clone(),
120 commit_sha: String::new(),
121 warnings: vec![WarningHint::TitleNormalizedToSlugNoop {
122 requested_title: args.new_title.clone(),
123 current_slug: id.name().to_string(),
124 }],
125 });
126 }
127 if let Some(existing) = self.store.get(&new_id) {
128 return Err(EngineError::AlreadyExists {
129 id: new_id.to_string(),
130 existing_title: existing.title.clone(),
131 existing_is_stub: existing.stub,
132 });
133 }
134
135 let schema = self
136 .schemas
137 .get(&mem)
138 .expect("schema present for every registered mount");
139 let type_def = schema
140 .get_type(&entity.entity_type)
141 .ok_or_else(|| unknown_type_error(schema, &entity.entity_type))?;
142
143 let old_file_path = entity.file_path.clone();
144 let new_file_path = format!("{new_slug}.md");
145
146 let mut next = entity.clone();
147 next.id = new_id.clone();
148 next.title = args.new_title.clone();
149 next.file_path = new_file_path.clone();
150
151 for rel in next.relationships.iter_mut() {
158 if rel.target == *id {
159 rel.target = new_id.clone();
160 }
161 }
162
163 let old_slug = id.name().to_string();
178 let new_slug_owned = new_slug.clone();
179 for body in next.sections.values_mut() {
180 let (rewritten, count) = crate::entity::wikilink_rewrite::rewrite_bare_slug(
181 body,
182 &old_slug,
183 &new_slug_owned,
184 );
185 if count > 0 {
186 *body = rewritten;
187 }
188 let (rewritten, count) = crate::entity::wikilink_rewrite::rewrite_cross_mem_slug(
189 body,
190 &mem,
191 &old_slug,
192 &new_slug_owned,
193 );
194 if count > 0 {
195 *body = rewritten;
196 }
197 }
198
199 let today = self.now_iso();
207 super::auto_stamp_timestamps(&mut next, type_def.as_ref(), &today);
208
209 let markdown = generate_markdown(&next, type_def.as_ref());
210
211 let mut seen: std::collections::HashSet<EntityId> = std::collections::HashSet::new();
223 let mut same_mem_ids: Vec<EntityId> = Vec::new();
224 let mut cross_mem_ids: Vec<EntityId> = Vec::new();
225 for in_edge in self.store.incoming(id) {
226 if in_edge.from == *id || !seen.insert(in_edge.from.clone()) {
227 continue;
228 }
229 if in_edge.from.mem() == mem {
230 same_mem_ids.push(in_edge.from.clone());
231 } else {
232 cross_mem_ids.push(in_edge.from.clone());
233 }
234 }
235
236 let mut cross_mem_write_ids: Vec<EntityId> = Vec::new();
244 let mut readonly_referrers: Vec<EntityId> = Vec::new();
245 for from_id in cross_mem_ids {
246 match self
247 .mount(from_id.mem())
248 .map(|m| m.capability)
249 .unwrap_or(MountCapability::Write)
250 {
251 MountCapability::Write => cross_mem_write_ids.push(from_id),
252 MountCapability::ReadOnly => readonly_referrers.push(from_id),
253 }
254 }
255 readonly_referrers.sort_by_key(|a| a.to_string());
256
257 let mut blocked_counts: std::collections::BTreeMap<String, usize> =
267 std::collections::BTreeMap::new();
268 for from_id in &cross_mem_write_ids {
269 let peer_mem = from_id.mem().to_string();
270 if !self.cross_mem_link_allowed(&peer_mem, &mem) {
271 *blocked_counts.entry(peer_mem).or_insert(0) += 1;
272 }
273 }
274 if !blocked_counts.is_empty() {
275 let blocked_referrers: Vec<crate::engine::error::BlockedReferrer> = blocked_counts
276 .into_iter()
277 .map(|(peer_mem, count)| crate::engine::error::BlockedReferrer {
278 from_mem: peer_mem,
279 to_mem: mem.clone(),
280 count,
281 })
282 .collect();
283 return Err(EngineError::RenameBlockedByCrossMemPolicy {
284 from_mem: mem.clone(),
285 blocked_referrers,
286 });
287 }
288
289 same_mem_ids.sort_by_key(|a| a.to_string());
293 cross_mem_write_ids.sort_by_key(|a| a.to_string());
294
295 let mut same_mem_writes: Vec<(
301 String,
302 String,
303 std::sync::Arc<memstead_schema::TypeDefinition>,
304 )> = Vec::with_capacity(same_mem_ids.len());
305 for from_id in &same_mem_ids {
306 let Some(referrer) = self.store.get(from_id) else {
307 continue;
308 };
309 if referrer.stub {
310 continue;
311 }
312 let referrer_type_def = schema
313 .get_type(&referrer.entity_type)
314 .ok_or_else(|| unknown_type_error(schema, &referrer.entity_type))?;
315 let mut next_ref = referrer.clone();
316 for rel in next_ref.relationships.iter_mut() {
317 if rel.target == *id {
318 rel.target = new_id.clone();
319 }
320 }
321 for body in next_ref.sections.values_mut() {
322 let (rewritten, count) = crate::entity::wikilink_rewrite::rewrite_bare_slug(
323 body,
324 &old_slug,
325 &new_slug_owned,
326 );
327 if count > 0 {
328 *body = rewritten;
329 }
330 let (rewritten, count) = crate::entity::wikilink_rewrite::rewrite_cross_mem_slug(
335 body,
336 &mem,
337 &old_slug,
338 &new_slug_owned,
339 );
340 if count > 0 {
341 *body = rewritten;
342 }
343 }
344 let ref_markdown = generate_markdown(&next_ref, referrer_type_def.as_ref());
355 same_mem_writes.push((ref_markdown, next_ref.file_path.clone(), referrer_type_def));
356 }
357
358 struct PeerMemPlan {
364 mount_idx: usize,
365 mem: String,
366 writes: Vec<(
367 String,
368 String,
369 std::sync::Arc<memstead_schema::TypeDefinition>,
370 )>,
371 }
372 let mut peer_plans: std::collections::BTreeMap<String, PeerMemPlan> =
373 std::collections::BTreeMap::new();
374 for from_id in &cross_mem_write_ids {
375 let peer_mem = from_id.mem().to_string();
376 let peer_mount_idx = self
377 .mounts
378 .iter()
379 .position(|m| m.mount.mem == peer_mem)
380 .expect("peer mount present for collected referrer id");
381 let peer_schema = self
382 .schemas
383 .get(&peer_mem)
384 .expect("schema present for every registered mount");
385
386 let Some(referrer) = self.store.get(from_id) else {
387 continue;
388 };
389 if referrer.stub {
390 continue;
391 }
392 let referrer_type_def = peer_schema
393 .get_type(&referrer.entity_type)
394 .ok_or_else(|| unknown_type_error(peer_schema, &referrer.entity_type))?;
395 let mut next_ref = referrer.clone();
396 for rel in next_ref.relationships.iter_mut() {
397 if rel.target == *id {
398 rel.target = new_id.clone();
399 }
400 }
401 for body in next_ref.sections.values_mut() {
407 let (rewritten, count) = crate::entity::wikilink_rewrite::rewrite_cross_mem_slug(
408 body,
409 &mem,
410 &old_slug,
411 &new_slug_owned,
412 );
413 if count > 0 {
414 *body = rewritten;
415 }
416 }
417 let ref_markdown = generate_markdown(&next_ref, referrer_type_def.as_ref());
423
424 peer_plans
425 .entry(peer_mem.clone())
426 .or_insert_with(|| PeerMemPlan {
427 mount_idx: peer_mount_idx,
428 mem: peer_mem.clone(),
429 writes: Vec::new(),
430 })
431 .writes
432 .push((ref_markdown, next_ref.file_path.clone(), referrer_type_def));
433 }
434
435 let logical_op_id = crate::provenance::mint_logical_operation_id();
443
444 let backend = self.mounts[mount_idx].backend.as_ref();
445 backend.write_entity(Path::new(&new_file_path), markdown.as_bytes())?;
446 backend.delete_entity(Path::new(&old_file_path))?;
447 for (ref_markdown, ref_file_path, _) in &same_mem_writes {
448 backend.write_entity(Path::new(ref_file_path), ref_markdown.as_bytes())?;
449 }
450 super::stage_anchors_rename(backend, id, &new_id)?;
455 let commit_subject = format!("memstead: rename {} → {new_id}", id);
456 let ctx = CommitContext {
457 actor,
458 client: client.cloned(),
459 tool: Some("rename_entity"),
460 note: note.map(String::from),
461 role: self.current_role,
462 logical_operation_id: Some(logical_op_id.as_str()),
463 entity_ids: None,
464 };
465 let commit_sha = backend.commit(&commit_subject, &ctx)?;
466
467 backend.append_provenance(
468 &Provenance::new(
469 std::time::SystemTime::now(),
470 ProvenanceKind::Rename,
471 Some(new_id.to_string()),
472 actor,
473 client.cloned(),
474 note.map(String::from),
475 )
476 .with_role(self.current_role)
477 .with_logical_operation_id(logical_op_id.clone()),
478 )?;
479
480 self.record_self_write(mount_idx, &commit_sha);
481 self.stamp_mutation_versions(mount_idx);
482
483 let mut peer_snapshots: std::collections::BTreeMap<String, Option<String>> =
497 std::collections::BTreeMap::new();
498 for plan in peer_plans.values() {
499 let peer_backend = self.mounts[plan.mount_idx].backend.as_ref();
500 let snapshot = peer_backend.current_head()?;
501 peer_snapshots.insert(plan.mem.clone(), snapshot);
502 }
503
504 let mut committed_mems: Vec<String> = vec![mem.clone()];
509 for plan in peer_plans.values() {
510 let peer_backend = self.mounts[plan.mount_idx].backend.as_ref();
511 for (ref_markdown, ref_file_path, _) in &plan.writes {
512 peer_backend.write_entity(Path::new(ref_file_path), ref_markdown.as_bytes())?;
513 }
514 let peer_commit_subject = format!(
515 "memstead: rename {} → {new_id} (cross-mem rewrite in `{}`)",
516 id, plan.mem
517 );
518 let peer_ctx = CommitContext {
519 actor,
520 client: client.cloned(),
521 tool: Some("rename_entity"),
522 note: note.map(String::from),
523 role: self.current_role,
524 logical_operation_id: Some(logical_op_id.as_str()),
525 entity_ids: None,
526 };
527 let expected = peer_snapshots.get(&plan.mem).cloned().unwrap_or(None);
528 let peer_commit_result = peer_backend.commit_with_expected_parent(
529 &peer_commit_subject,
530 &peer_ctx,
531 expected.as_deref(),
532 );
533 let peer_commit_sha = match peer_commit_result {
534 Ok(sha) => sha,
535 Err(crate::backend::BackendError::ParentMismatch { .. }) => {
536 return Err(EngineError::RenamePartialFailure {
537 committed_mems: std::mem::take(&mut committed_mems),
538 failed_mem: plan.mem.clone(),
539 failure_cause: "drift".to_string(),
540 });
541 }
542 Err(e) => return Err(e.into()),
543 };
544 peer_backend.append_provenance(
545 &Provenance::new(
546 std::time::SystemTime::now(),
547 ProvenanceKind::Rename,
548 Some(new_id.to_string()),
549 actor,
550 client.cloned(),
551 note.map(String::from),
552 )
553 .with_role(self.current_role)
554 .with_logical_operation_id(logical_op_id.clone()),
555 )?;
556 self.record_self_write(plan.mount_idx, &peer_commit_sha);
557 self.stamp_mutation_versions(plan.mount_idx);
558 committed_mems.push(plan.mem.clone());
559 }
560
561 let parse_result = parse_markdown(&markdown, &new_file_path, type_def.as_ref(), &mem)
563 .map_err(|e| EngineError::ParseAfterWrite(e.to_string()))?;
564 let content_hash = parse_result.entity.content_hash.clone();
565
566 let mut parse_results = vec![parse_result];
567 for (ref_markdown, ref_file_path, ref_type_def) in &same_mem_writes {
568 let pr = parse_markdown(ref_markdown, ref_file_path, ref_type_def.as_ref(), &mem)
569 .map_err(|e| EngineError::ParseAfterWrite(e.to_string()))?;
570 parse_results.push(pr);
571 }
572 for plan in peer_plans.values() {
573 for (ref_markdown, ref_file_path, ref_type_def) in &plan.writes {
574 let pr = parse_markdown(
575 ref_markdown,
576 ref_file_path,
577 ref_type_def.as_ref(),
578 &plan.mem,
579 )
580 .map_err(|e| EngineError::ParseAfterWrite(e.to_string()))?;
581 parse_results.push(pr);
582 }
583 }
584
585 let mut outcome_warnings: Vec<WarningHint> = Vec::new();
598 outcome_warnings.append(&mut drift_warnings);
600 if !derivation.dropped_chars.is_empty() {
603 outcome_warnings.push(WarningHint::TitleCharsDroppedFromSlug {
604 title: args.new_title.trim().to_string(),
605 dropped_chars: derivation.dropped_chars.clone(),
606 slug: new_slug.clone(),
607 });
608 }
609 if readonly_referrers.is_empty() {
610 self.store.remove(id);
611 } else {
612 self.store.remove_edges_from(id);
617 self.store.upsert(
618 id.clone(),
619 make_stub(
620 id,
621 crate::entity::StubKind::Residual {
622 since_commit: commit_sha.clone(),
623 readonly_referrers: readonly_referrers.clone(),
624 },
625 ),
626 );
627 outcome_warnings.push(WarningHint::ResidualStubForReadOnlyReferrers {
628 id: id.clone(),
629 referrers: readonly_referrers,
630 });
631 }
632
633 let fallback = engine_fallback_type();
634 push_entities_into_store(&mut self.store, parse_results, fallback.as_ref(), None);
635 crate::entity::store_builder::remap_alias_target_edge_sources(
636 &mut self.store,
637 &self.schemas,
638 );
639
640 self.invalidate_communities();
641 self.invalidate_search_indexes();
642
643 if let Some(w) = self.note_missing_warning("rename_entity", note) {
648 outcome_warnings.push(w);
649 }
650
651 Ok(RenameEntityOutcome {
652 old_id: id.clone(),
653 new_id,
654 old_path: old_file_path,
655 new_path: new_file_path,
656 content_hash,
657 commit_sha,
658 warnings: outcome_warnings,
659 })
660 }
661}
662
663#[cfg(test)]
664mod tests {
665 use std::path::PathBuf;
666
667 use tempfile::TempDir;
668
669 use crate::backend::MemBackend;
670 use crate::engine::test_helpers::*;
671 use crate::engine::{Engine, EngineError, RenameEntityArgs};
672 use crate::ops::WarningHint;
673 use crate::storage::FilesystemMemWriter;
674
675 #[test]
676 fn rename_moves_entity_anchors_to_new_id() {
677 let tmp = TempDir::new().unwrap();
678 let mem_dir = tmp.path().to_path_buf();
679 let writer = FilesystemMemWriter::new(mem_dir.clone());
680 let mut engine = Engine::from_mounts(vec![(
681 folder_mount("specs", mem_dir.clone()),
682 Box::new(writer) as Box<dyn MemBackend>,
683 )])
684 .unwrap();
685 let (actor, client) = cli_actor();
686 let mut args = empty_create_args("specs", "Old Anchored");
687 args.anchors = vec![crate::anchor::AnchorInput {
688 artifact: Some("src/lib.rs".into()),
689 grain: Some("file".into()),
690 class: Some("anchored".into()),
691 hash: Some("h1".into()),
692 hash_stability: Some("stable".into()),
693 ..Default::default()
694 }];
695 let seeded = engine
696 .create_entity(args, actor, Some(&client), None)
697 .unwrap();
698 let old_id = seeded.id.clone();
699
700 let outcome = engine
701 .rename_entity(
702 RenameEntityArgs {
703 id: old_id.clone(),
704 expected_hash: Some(seeded.content_hash.clone()),
705 new_title: "New Anchored".to_string(),
706 },
707 actor,
708 Some(&client),
709 None,
710 )
711 .unwrap();
712
713 assert!(engine.entity_anchors(&old_id).is_empty());
715 assert_eq!(engine.entity_anchors(&outcome.new_id).len(), 1);
716 assert_eq!(
717 engine.anchors_referencing_artifact("src/lib.rs"),
718 vec![(
719 outcome.new_id.clone(),
720 engine.entity_anchors(&outcome.new_id)[0].clone()
721 )]
722 );
723 }
724
725 #[test]
726 fn rename_entity_renames_file_and_id_persists_across_restart() {
727 let tmp = TempDir::new().unwrap();
728 let mem_dir = tmp.path().to_path_buf();
729
730 let (old_id, new_id, new_file) = {
731 let writer = FilesystemMemWriter::new(mem_dir.clone());
732 let mut engine = Engine::from_mounts(vec![(
733 folder_mount("specs", mem_dir.clone()),
734 Box::new(writer) as Box<dyn MemBackend>,
735 )])
736 .unwrap();
737 let (actor, client) = cli_actor();
738 let seeded = engine
739 .create_entity(
740 empty_create_args("specs", "Old Name"),
741 actor,
742 Some(&client),
743 None,
744 )
745 .unwrap();
746 let outcome = engine
747 .rename_entity(
748 RenameEntityArgs {
749 id: seeded.id.clone(),
750 expected_hash: Some(seeded.content_hash.clone()),
751 new_title: "New Name".to_string(),
752 },
753 actor,
754 Some(&client),
755 None,
756 )
757 .unwrap();
758 assert_eq!(outcome.old_id.to_string(), "specs--old-name");
759 assert_eq!(outcome.new_id.to_string(), "specs--new-name");
760 assert_eq!(outcome.new_path, "new-name.md");
761 assert!(!mem_dir.join(&outcome.old_path).exists());
763 assert!(mem_dir.join(&outcome.new_path).exists());
764 (outcome.old_id, outcome.new_id, outcome.new_path)
765 };
766
767 let writer2 = FilesystemMemWriter::new(mem_dir.clone());
769 let engine2 = Engine::from_mounts(vec![(
770 folder_mount("specs", mem_dir),
771 Box::new(writer2) as Box<dyn MemBackend>,
772 )])
773 .unwrap();
774 assert!(engine2.get_entity(&old_id).is_none());
775 let new_entity = engine2.get_entity(&new_id).expect("new id must persist");
776 assert_eq!(new_entity.title, "New Name");
777 assert_eq!(new_entity.file_path, new_file);
778 }
779
780 #[test]
781 fn rename_entity_returns_typed_warning_on_slug_noop() {
782 let tmp = TempDir::new().unwrap();
783 let (mut engine, seeded) = engine_with_seed(&tmp, "Same Slug");
784 let (actor, client) = cli_actor();
785 let outcome = engine
786 .rename_entity(
787 RenameEntityArgs {
788 id: seeded.id.clone(),
789 expected_hash: Some(seeded.content_hash.clone()),
790 new_title: "Same Slug".to_string(), },
792 actor,
793 Some(&client),
794 None,
795 )
796 .unwrap();
797 assert_eq!(outcome.old_id, outcome.new_id);
802 assert_eq!(outcome.old_path, outcome.new_path);
803 assert!(outcome.commit_sha.is_empty());
804 assert_eq!(outcome.warnings.len(), 1);
805 assert!(matches!(
806 outcome.warnings[0],
807 WarningHint::TitleNormalizedToSlugNoop { .. }
808 ));
809 }
810
811 #[test]
812 fn rename_entity_returns_commit_sha_on_real_rename() {
813 let tmp = TempDir::new().unwrap();
814 let (mut engine, seeded) = engine_with_seed(&tmp, "Old Name");
815 let (actor, client) = cli_actor();
816 let outcome = engine
817 .rename_entity(
818 RenameEntityArgs {
819 id: seeded.id.clone(),
820 expected_hash: Some(seeded.content_hash.clone()),
821 new_title: "Brand New Name".to_string(),
822 },
823 actor,
824 Some(&client),
825 None,
826 )
827 .unwrap();
828 assert_ne!(outcome.old_id, outcome.new_id);
831 assert!(
832 !outcome.commit_sha.is_empty(),
833 "commit_sha must be populated on a real rename"
834 );
835 assert!(outcome.warnings.is_empty());
836 }
837
838 #[test]
839 fn rename_entity_rewrites_self_references_in_body_and_relationships() {
840 use crate::entity::EntityId;
841 use indexmap::IndexMap;
842
843 let tmp = TempDir::new().unwrap();
844 let mem_dir = tmp.path().to_path_buf();
845 let writer = FilesystemMemWriter::new(mem_dir.clone());
846 let mut engine = Engine::from_mounts(vec![(
847 folder_mount("specs", mem_dir.clone()),
848 Box::new(writer) as Box<dyn MemBackend>,
849 )])
850 .unwrap();
851 let (actor, client) = cli_actor();
852
853 let mut sections: IndexMap<String, String> = IndexMap::new();
859 sections.insert("identity".to_string(), "the seed identity".to_string());
860 sections.insert(
861 "purpose".to_string(),
862 "see also [[old-name]] for prior context".to_string(),
863 );
864 let seeded = engine
871 .create_entity(
872 crate::engine::CreateEntityArgs {
873 anchors: Vec::new(),
874 mem: "specs".to_string(),
875 title: "Old Name".to_string(),
876 entity_type: "spec".to_string(),
877 sections,
878 metadata: IndexMap::new(),
879 relations: Vec::new(),
880 dry_run: false,
881 },
882 actor,
883 Some(&client),
884 None,
885 )
886 .unwrap();
887 assert_eq!(seeded.id.to_string(), "specs--old-name");
888 let related = seeded.clone();
889
890 let outcome = engine
891 .rename_entity(
892 RenameEntityArgs {
893 id: seeded.id.clone(),
894 expected_hash: Some(related.content_hash.clone()),
895 new_title: "Brand New Name".to_string(),
896 },
897 actor,
898 Some(&client),
899 None,
900 )
901 .unwrap();
902 assert_eq!(outcome.new_id.to_string(), "specs--brand-new-name");
903
904 let new_bytes = std::fs::read_to_string(mem_dir.join(&outcome.new_path)).unwrap();
907 assert!(
908 new_bytes.contains("[[brand-new-name]]"),
909 "expected new slug in body, got:\n{new_bytes}"
910 );
911 assert!(
912 !new_bytes.contains("[[old-name]]"),
913 "old slug must not survive in the rewritten file, got:\n{new_bytes}"
914 );
915
916 let in_mem = engine.get_entity(&outcome.new_id).unwrap();
919 assert!(
920 in_mem
921 .sections
922 .get("purpose")
923 .map(|s| s.contains("[[brand-new-name]]"))
924 .unwrap_or(false),
925 "section body must be rewritten in-memory; got {:?}",
926 in_mem.sections.get("purpose")
927 );
928 let new_self_target = EntityId::new("specs", "brand-new-name");
932 assert!(
933 in_mem
934 .relationships
935 .iter()
936 .all(|r| r.target != seeded.id && r.target != new_self_target),
937 "a self-referential body link must produce no self-relation (F11), got: {:?}",
938 in_mem.relationships
939 );
940 }
941
942 #[test]
951 fn rename_preserves_referrer_last_modified_but_rewrites_link() {
952 let tmp = TempDir::new().unwrap();
953 let mem_dir = tmp.path().to_path_buf();
954
955 std::fs::write(
956 mem_dir.join("target.md"),
957 "---\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",
958 )
959 .unwrap();
960 std::fs::write(
961 mem_dir.join("referrer.md"),
962 "---\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",
963 )
964 .unwrap();
965
966 let writer = FilesystemMemWriter::new(mem_dir.clone());
967 let mut engine = Engine::from_mounts(vec![(
968 folder_mount("specs", mem_dir.clone()),
969 Box::new(writer) as Box<dyn MemBackend>,
970 )])
971 .unwrap();
972 let (actor, client) = cli_actor();
973
974 let target_id = crate::entity::EntityId::new("specs", "target");
975 let target_hash = engine
976 .store()
977 .get(&target_id)
978 .expect("target loaded from disk")
979 .content_hash
980 .clone();
981
982 engine
983 .rename_entity(
984 RenameEntityArgs {
985 id: target_id,
986 expected_hash: Some(target_hash),
987 new_title: "Target Renamed".to_string(),
988 },
989 actor,
990 Some(&client),
991 None,
992 )
993 .expect("rename succeeds");
994
995 let referrer_md = std::fs::read_to_string(mem_dir.join("referrer.md")).unwrap();
996 assert!(
998 referrer_md.contains("last_modified: 2020-01-01"),
999 "referrer's last_modified must be preserved across a rename-driven slug rewrite; got:\n{referrer_md}"
1000 );
1001 assert!(
1003 referrer_md.contains("[[target-renamed]]"),
1004 "referrer's body wiki-link must be rewritten to the new slug; got:\n{referrer_md}"
1005 );
1006 assert!(
1007 !referrer_md.contains("[[target]]"),
1008 "old slug must not survive in the referrer body; got:\n{referrer_md}"
1009 );
1010 }
1011
1012 #[test]
1013 fn rename_entity_rewrites_same_mem_referrers_atomically() {
1014 use crate::engine::CreateEntityArgs;
1015 use crate::entity::EntityId;
1016 use indexmap::IndexMap;
1017
1018 let tmp = TempDir::new().unwrap();
1019 let mem_dir = tmp.path().to_path_buf();
1020 let writer = FilesystemMemWriter::new(mem_dir.clone());
1021 let mut engine = Engine::from_mounts(vec![(
1022 folder_mount("specs", mem_dir.clone()),
1023 Box::new(writer) as Box<dyn MemBackend>,
1024 )])
1025 .unwrap();
1026 let (actor, client) = cli_actor();
1027
1028 let target = engine
1030 .create_entity(
1031 empty_create_args("specs", "Target Spec"),
1032 actor,
1033 Some(&client),
1034 None,
1035 )
1036 .unwrap();
1037 assert_eq!(target.id.to_string(), "specs--target-spec");
1038
1039 let mut sections_a: IndexMap<String, String> = IndexMap::new();
1042 sections_a.insert(
1043 "identity".to_string(),
1044 "referrer alpha identity".to_string(),
1045 );
1046 sections_a.insert(
1047 "purpose".to_string(),
1048 "rationale relies on [[target-spec]] for context".to_string(),
1049 );
1050 let referrer_a = engine
1051 .create_entity(
1052 CreateEntityArgs {
1053 anchors: Vec::new(),
1054 mem: "specs".to_string(),
1055 title: "Referrer Alpha".to_string(),
1056 entity_type: "spec".to_string(),
1057 sections: sections_a,
1058 metadata: IndexMap::new(),
1059 relations: Vec::new(),
1063 dry_run: false,
1064 },
1065 actor,
1066 Some(&client),
1067 None,
1068 )
1069 .unwrap();
1070
1071 let mut sections_b: IndexMap<String, String> = IndexMap::new();
1074 sections_b.insert("identity".to_string(), "referrer beta identity".to_string());
1075 sections_b.insert(
1076 "purpose".to_string(),
1077 "consult [[target-spec]] for the canonical phrasing".to_string(),
1078 );
1079 let referrer_b = engine
1080 .create_entity(
1081 CreateEntityArgs {
1082 anchors: Vec::new(),
1083 mem: "specs".to_string(),
1084 title: "Referrer Bravo".to_string(),
1085 entity_type: "spec".to_string(),
1086 sections: sections_b,
1087 metadata: IndexMap::new(),
1088 relations: Vec::new(),
1092 dry_run: false,
1093 },
1094 actor,
1095 Some(&client),
1096 None,
1097 )
1098 .unwrap();
1099
1100 let bystander = engine
1103 .create_entity(
1104 empty_create_args("specs", "Bystander"),
1105 actor,
1106 Some(&client),
1107 None,
1108 )
1109 .unwrap();
1110 let bystander_bytes_before =
1111 std::fs::read_to_string(mem_dir.join(&bystander.file_path)).unwrap();
1112
1113 let renamed = engine
1114 .rename_entity(
1115 RenameEntityArgs {
1116 id: target.id.clone(),
1117 expected_hash: Some(target.content_hash.clone()),
1118 new_title: "Renamed Spec".to_string(),
1119 },
1120 actor,
1121 Some(&client),
1122 None,
1123 )
1124 .unwrap();
1125 assert_eq!(renamed.new_id.to_string(), "specs--renamed-spec");
1126
1127 for path in std::fs::read_dir(&mem_dir).unwrap().flatten() {
1130 let p = path.path();
1131 if p.extension().and_then(|s| s.to_str()) != Some("md") {
1132 continue;
1133 }
1134 let body = std::fs::read_to_string(&p).unwrap();
1135 assert!(
1136 !body.contains("[[target-spec]]"),
1137 "old slug must not survive in {}, got:\n{body}",
1138 p.display()
1139 );
1140 }
1141
1142 let in_mem_a = engine.get_entity(&referrer_a.id).unwrap();
1144 assert!(
1145 in_mem_a
1146 .relationships
1147 .iter()
1148 .any(|r| r.rel_type == "REFERENCES"
1149 && r.target == EntityId::new("specs", "renamed-spec")),
1150 "expected referrer A's relation to point at renamed-spec, got {:?}",
1151 in_mem_a.relationships
1152 );
1153 assert!(
1154 in_mem_a
1155 .sections
1156 .get("purpose")
1157 .map(|s| s.contains("[[renamed-spec]]"))
1158 .unwrap_or(false),
1159 "referrer A's body must be rewritten"
1160 );
1161
1162 let in_mem_b = engine.get_entity(&referrer_b.id).unwrap();
1165 assert!(
1166 in_mem_b
1167 .sections
1168 .get("purpose")
1169 .map(|s| s.contains("[[renamed-spec]]"))
1170 .unwrap_or(false),
1171 "referrer B's body must be rewritten"
1172 );
1173
1174 let bystander_bytes_after =
1176 std::fs::read_to_string(mem_dir.join(&bystander.file_path)).unwrap();
1177 assert_eq!(
1178 bystander_bytes_before, bystander_bytes_after,
1179 "bystander must not be rewritten"
1180 );
1181 }
1182
1183 fn engine_with_two_mems_and_bidirectional_policy(
1189 specs_dir: PathBuf,
1190 memos_dir: PathBuf,
1191 ) -> Engine {
1192 use memstead_schema::workspace_config::CrossLinkValue;
1193 let writer_specs = FilesystemMemWriter::new(specs_dir.clone());
1194 let writer_memos = FilesystemMemWriter::new(memos_dir.clone());
1195 let mut engine = Engine::from_mounts(vec![
1196 (
1197 folder_mount("specs", specs_dir),
1198 Box::new(writer_specs) as Box<dyn MemBackend>,
1199 ),
1200 (
1201 folder_mount("memos", memos_dir),
1202 Box::new(writer_memos) as Box<dyn MemBackend>,
1203 ),
1204 ])
1205 .unwrap();
1206 let mut settings = crate::workspace::WorkspaceSettings::default();
1207 settings.cross_mem_links.insert(
1208 "memos".to_string(),
1209 CrossLinkValue::List(vec!["specs".to_string()]),
1210 );
1211 settings.cross_mem_links.insert(
1212 "specs".to_string(),
1213 CrossLinkValue::List(vec!["memos".to_string()]),
1214 );
1215 engine.set_settings(settings);
1216 engine
1217 }
1218
1219 #[test]
1220 fn rename_entity_rewrites_cross_mem_write_referrer() {
1221 use crate::engine::CreateEntityArgs;
1222 use crate::entity::EntityId;
1223 use indexmap::IndexMap;
1224
1225 let tmp_specs = TempDir::new().unwrap();
1226 let tmp_memos = TempDir::new().unwrap();
1227 let specs_dir = tmp_specs.path().to_path_buf();
1228 let memos_dir = tmp_memos.path().to_path_buf();
1229 let mut engine =
1230 engine_with_two_mems_and_bidirectional_policy(specs_dir.clone(), memos_dir.clone());
1231 let (actor, client) = cli_actor();
1232
1233 let target = engine
1235 .create_entity(
1236 empty_create_args("specs", "Target Spec"),
1237 actor,
1238 Some(&client),
1239 None,
1240 )
1241 .unwrap();
1242
1243 let mut sections: IndexMap<String, String> = IndexMap::new();
1250 sections.insert("claim".to_string(), "the claim".to_string());
1251 sections.insert(
1252 "context".to_string(),
1253 "discussion stems from [[specs:target-spec]]".to_string(),
1254 );
1255 let referrer = engine
1256 .create_entity(
1257 CreateEntityArgs {
1258 anchors: Vec::new(),
1259 mem: "memos".to_string(),
1260 title: "Cross Note".to_string(),
1261 entity_type: "memo".to_string(),
1262 sections,
1263 metadata: IndexMap::new(),
1264 relations: Vec::new(),
1268 dry_run: false,
1269 },
1270 actor,
1271 Some(&client),
1272 None,
1273 )
1274 .unwrap();
1275
1276 let renamed = engine
1278 .rename_entity(
1279 RenameEntityArgs {
1280 id: target.id.clone(),
1281 expected_hash: Some(target.content_hash.clone()),
1282 new_title: "Renamed Spec".to_string(),
1283 },
1284 actor,
1285 Some(&client),
1286 None,
1287 )
1288 .unwrap();
1289 assert_eq!(renamed.new_id.to_string(), "specs--renamed-spec");
1290
1291 let referrer_path = memos_dir.join(&referrer.file_path);
1294 let referrer_bytes = std::fs::read_to_string(&referrer_path).unwrap();
1295 assert!(
1296 referrer_bytes.contains("[[specs:renamed-spec]]"),
1297 "expected colon-form rewrite in referrer body, got:\n{referrer_bytes}"
1298 );
1299 assert!(
1300 !referrer_bytes.contains("target-spec"),
1301 "old slug must not survive in referrer file, got:\n{referrer_bytes}"
1302 );
1303
1304 let in_mem = engine.get_entity(&referrer.id).unwrap();
1306 assert!(
1307 in_mem
1308 .relationships
1309 .iter()
1310 .any(|r| r.rel_type == "REFERENCES"
1311 && r.target == EntityId::new("specs", "renamed-spec")),
1312 "expected cross-mem relation to point at renamed-spec, got {:?}",
1313 in_mem.relationships
1314 );
1315 assert!(
1316 in_mem.relationships.iter().all(|r| r.target != target.id),
1317 "no relationship may still target the old id, got: {:?}",
1318 in_mem.relationships
1319 );
1320 }
1321
1322 struct DriftingBackend {
1329 inner: Box<dyn MemBackend>,
1330 }
1331 impl DriftingBackend {
1332 fn new(inner: Box<dyn MemBackend>) -> Self {
1333 Self { inner }
1334 }
1335 }
1336 impl crate::backend::MemBackend for DriftingBackend {
1337 fn list_entities(&self) -> Result<Vec<PathBuf>, crate::backend::BackendError> {
1338 self.inner.list_entities()
1339 }
1340 fn read_entity(
1341 &self,
1342 rel: &std::path::Path,
1343 ) -> Result<Option<Vec<u8>>, crate::backend::BackendError> {
1344 self.inner.read_entity(rel)
1345 }
1346 fn write_entity(
1347 &self,
1348 rel: &std::path::Path,
1349 b: &[u8],
1350 ) -> Result<(), crate::backend::BackendError> {
1351 self.inner.write_entity(rel, b)
1352 }
1353 fn delete_entity(&self, rel: &std::path::Path) -> Result<(), crate::backend::BackendError> {
1354 self.inner.delete_entity(rel)
1355 }
1356 fn move_entity(
1357 &self,
1358 f: &std::path::Path,
1359 t: &std::path::Path,
1360 ) -> Result<(), crate::backend::BackendError> {
1361 self.inner.move_entity(f, t)
1362 }
1363 fn commit(
1364 &self,
1365 m: &str,
1366 c: &crate::vcs::CommitContext<'_>,
1367 ) -> Result<crate::storage::CommitId, crate::backend::BackendError> {
1368 self.inner.commit(m, c)
1369 }
1370 fn commit_with_expected_parent(
1371 &self,
1372 m: &str,
1373 c: &crate::vcs::CommitContext<'_>,
1374 expected_parent: Option<&str>,
1375 ) -> Result<crate::storage::CommitId, crate::backend::BackendError> {
1376 if let Some(expected) = expected_parent {
1377 Err(crate::backend::BackendError::ParentMismatch {
1378 expected: expected.to_string(),
1379 actual: "drifted-by-sibling-writer".to_string(),
1380 })
1381 } else {
1382 self.inner.commit(m, c)
1383 }
1384 }
1385 fn append_provenance(
1386 &self,
1387 r: &crate::Provenance,
1388 ) -> Result<(), crate::backend::BackendError> {
1389 self.inner.append_provenance(r)
1390 }
1391 fn read_provenance(
1392 &self,
1393 c: Option<&str>,
1394 ) -> Result<Vec<crate::Provenance>, crate::backend::BackendError> {
1395 self.inner.read_provenance(c)
1396 }
1397 fn current_head(&self) -> Result<Option<String>, crate::backend::BackendError> {
1398 Ok(Some("snapshot-head-sha".to_string()))
1401 }
1402 }
1403
1404 #[test]
1405 fn rename_entity_surfaces_partial_failure_when_peer_mem_drifts() {
1406 use crate::engine::CreateEntityArgs;
1407 use indexmap::IndexMap;
1408 use memstead_schema::workspace_config::CrossLinkValue;
1409
1410 let tmp_specs = TempDir::new().unwrap();
1411 let tmp_memos = TempDir::new().unwrap();
1412 let specs_dir = tmp_specs.path().to_path_buf();
1413 let memos_dir = tmp_memos.path().to_path_buf();
1414
1415 let writer_specs = FilesystemMemWriter::new(specs_dir.clone());
1420 let writer_memos_inner: Box<dyn MemBackend> =
1421 Box::new(FilesystemMemWriter::new(memos_dir.clone()));
1422 let writer_memos = DriftingBackend::new(writer_memos_inner);
1423
1424 let mut engine = Engine::from_mounts(vec![
1425 (
1426 folder_mount("specs", specs_dir.clone()),
1427 Box::new(writer_specs) as Box<dyn MemBackend>,
1428 ),
1429 (
1430 folder_mount("memos", memos_dir.clone()),
1431 Box::new(writer_memos) as Box<dyn MemBackend>,
1432 ),
1433 ])
1434 .unwrap();
1435 let mut settings = crate::workspace::WorkspaceSettings::default();
1436 settings.cross_mem_links.insert(
1437 "memos".to_string(),
1438 CrossLinkValue::List(vec!["specs".to_string()]),
1439 );
1440 settings.cross_mem_links.insert(
1441 "specs".to_string(),
1442 CrossLinkValue::List(vec!["memos".to_string()]),
1443 );
1444 engine.set_settings(settings);
1445
1446 let (actor, client) = cli_actor();
1447 let target = engine
1448 .create_entity(
1449 empty_create_args("specs", "Target Spec"),
1450 actor,
1451 Some(&client),
1452 None,
1453 )
1454 .unwrap();
1455 let mut sections: IndexMap<String, String> = IndexMap::new();
1456 sections.insert("claim".to_string(), "the claim".to_string());
1457 sections.insert(
1458 "context".to_string(),
1459 "see [[specs:target-spec]]".to_string(),
1460 );
1461 let _referrer = engine
1462 .create_entity(
1463 CreateEntityArgs {
1464 anchors: Vec::new(),
1465 mem: "memos".to_string(),
1466 title: "Cross Note".to_string(),
1467 entity_type: "memo".to_string(),
1468 sections,
1469 metadata: IndexMap::new(),
1470 relations: Vec::new(),
1474 dry_run: false,
1475 },
1476 actor,
1477 Some(&client),
1478 None,
1479 )
1480 .unwrap();
1481
1482 let err = engine
1483 .rename_entity(
1484 RenameEntityArgs {
1485 id: target.id.clone(),
1486 expected_hash: Some(target.content_hash.clone()),
1487 new_title: "Renamed Spec".to_string(),
1488 },
1489 actor,
1490 Some(&client),
1491 None,
1492 )
1493 .unwrap_err();
1494 match err {
1495 EngineError::RenamePartialFailure {
1496 committed_mems,
1497 failed_mem,
1498 failure_cause,
1499 } => {
1500 assert_eq!(committed_mems, vec!["specs".to_string()]);
1504 assert_eq!(failed_mem, "memos");
1505 assert_eq!(failure_cause, "drift");
1506 }
1507 other => panic!("expected RenamePartialFailure, got {other:?}"),
1508 }
1509 assert!(specs_dir.join("renamed-spec.md").exists());
1513 assert!(!specs_dir.join(&target.file_path).exists());
1514 }
1515
1516 #[test]
1517 fn rename_entity_tags_every_per_mem_commit_with_same_logical_operation_id() {
1518 use crate::backend::MemBackend;
1519 use crate::engine::CreateEntityArgs;
1520 use indexmap::IndexMap;
1521
1522 let tmp_specs = TempDir::new().unwrap();
1523 let tmp_memos = TempDir::new().unwrap();
1524 let specs_dir = tmp_specs.path().to_path_buf();
1525 let memos_dir = tmp_memos.path().to_path_buf();
1526 let mut engine =
1527 engine_with_two_mems_and_bidirectional_policy(specs_dir.clone(), memos_dir.clone());
1528 let (actor, client) = cli_actor();
1529
1530 let target = engine
1531 .create_entity(
1532 empty_create_args("specs", "Target Spec"),
1533 actor,
1534 Some(&client),
1535 None,
1536 )
1537 .unwrap();
1538 let mut sections: IndexMap<String, String> = IndexMap::new();
1539 sections.insert("claim".to_string(), "the claim".to_string());
1540 sections.insert(
1541 "context".to_string(),
1542 "discussion stems from [[specs:target-spec]]".to_string(),
1543 );
1544 let _referrer = engine
1545 .create_entity(
1546 CreateEntityArgs {
1547 anchors: Vec::new(),
1548 mem: "memos".to_string(),
1549 title: "Cross Note".to_string(),
1550 entity_type: "memo".to_string(),
1551 sections,
1552 metadata: IndexMap::new(),
1553 relations: Vec::new(),
1557 dry_run: false,
1558 },
1559 actor,
1560 Some(&client),
1561 None,
1562 )
1563 .unwrap();
1564
1565 let _ = engine
1566 .rename_entity(
1567 RenameEntityArgs {
1568 id: target.id.clone(),
1569 expected_hash: Some(target.content_hash.clone()),
1570 new_title: "Renamed Spec".to_string(),
1571 },
1572 actor,
1573 Some(&client),
1574 None,
1575 )
1576 .unwrap();
1577
1578 let specs_backend: Box<dyn MemBackend> =
1582 Box::new(FilesystemMemWriter::new(specs_dir.clone()));
1583 let memos_backend: Box<dyn MemBackend> =
1584 Box::new(FilesystemMemWriter::new(memos_dir.clone()));
1585 let specs_provenance = specs_backend.read_provenance(None).unwrap();
1586 let memos_provenance = memos_backend.read_provenance(None).unwrap();
1587
1588 let specs_rename = specs_provenance
1589 .iter()
1590 .find(|p| matches!(p.kind, crate::provenance::ProvenanceKind::Rename))
1591 .expect("specs mem must have a rename provenance entry");
1592 let memos_rename = memos_provenance
1593 .iter()
1594 .find(|p| matches!(p.kind, crate::provenance::ProvenanceKind::Rename))
1595 .expect("memos mem must have a rename provenance entry");
1596
1597 let specs_id = specs_rename
1598 .logical_operation_id
1599 .as_deref()
1600 .expect("specs rename entry must carry a logical_operation_id");
1601 let memos_id = memos_rename
1602 .logical_operation_id
1603 .as_deref()
1604 .expect("memos rename entry must carry a logical_operation_id");
1605 assert_eq!(
1606 specs_id, memos_id,
1607 "both per-mem rename commits must share the same logical_operation_id"
1608 );
1609 assert!(
1610 specs_id.starts_with("logop-"),
1611 "logical_operation_id must use the `logop-` prefix the engine mints; got {specs_id}"
1612 );
1613 }
1614
1615 #[test]
1616 fn rename_entity_refuses_when_cross_mem_referrer_blocked_by_policy() {
1617 use crate::engine::CreateEntityArgs;
1618 use indexmap::IndexMap;
1619 use memstead_schema::workspace_config::CrossLinkValue;
1620
1621 let tmp_specs = TempDir::new().unwrap();
1622 let tmp_memos = TempDir::new().unwrap();
1623 let specs_dir = tmp_specs.path().to_path_buf();
1624 let memos_dir = tmp_memos.path().to_path_buf();
1625
1626 let mut engine =
1629 engine_with_two_mems_and_bidirectional_policy(specs_dir.clone(), memos_dir.clone());
1630 let (actor, client) = cli_actor();
1631
1632 let target = engine
1633 .create_entity(
1634 empty_create_args("specs", "Target Spec"),
1635 actor,
1636 Some(&client),
1637 None,
1638 )
1639 .unwrap();
1640 let mut sections: IndexMap<String, String> = IndexMap::new();
1641 sections.insert("claim".to_string(), "the claim".to_string());
1642 sections.insert(
1643 "context".to_string(),
1644 "see [[specs:target-spec]]".to_string(),
1645 );
1646 let _referrer = engine
1647 .create_entity(
1648 CreateEntityArgs {
1649 anchors: Vec::new(),
1650 mem: "memos".to_string(),
1651 title: "Cross Note".to_string(),
1652 entity_type: "memo".to_string(),
1653 sections,
1654 metadata: IndexMap::new(),
1655 relations: Vec::new(),
1659 dry_run: false,
1660 },
1661 actor,
1662 Some(&client),
1663 None,
1664 )
1665 .unwrap();
1666
1667 let mut settings = crate::workspace::WorkspaceSettings::default();
1673 settings.cross_mem_links.insert(
1674 "specs".to_string(),
1675 CrossLinkValue::List(vec!["memos".to_string()]),
1676 );
1677 engine.set_settings(settings);
1679
1680 let err = engine
1681 .rename_entity(
1682 RenameEntityArgs {
1683 id: target.id.clone(),
1684 expected_hash: Some(target.content_hash.clone()),
1685 new_title: "Renamed Spec".to_string(),
1686 },
1687 actor,
1688 Some(&client),
1689 None,
1690 )
1691 .unwrap_err();
1692 match err {
1693 EngineError::RenameBlockedByCrossMemPolicy {
1694 from_mem,
1695 blocked_referrers,
1696 } => {
1697 assert_eq!(from_mem, "specs");
1698 assert_eq!(blocked_referrers.len(), 1);
1699 assert_eq!(blocked_referrers[0].from_mem, "memos");
1700 assert_eq!(blocked_referrers[0].to_mem, "specs");
1701 assert_eq!(blocked_referrers[0].count, 1);
1702 }
1703 other => panic!("expected RenameBlockedByCrossMemPolicy, got {other:?}"),
1704 }
1705 assert!(specs_dir.join(&target.file_path).exists());
1708 assert!(!specs_dir.join("renamed-spec.md").exists());
1709 }
1710
1711 #[test]
1722 fn rename_entity_demotes_to_stub_when_only_readonly_cross_mem_referrers_remain() {
1723 use crate::engine::test_helpers::{archive_mount, build_archive};
1724 use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
1725
1726 let tmp = TempDir::new().unwrap();
1727 let writable_dir = tmp.path().join("writable");
1728 std::fs::create_dir_all(&writable_dir).unwrap();
1729 let writer = FilesystemMemWriter::new(writable_dir.clone());
1730
1731 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";
1735 let archive_path = build_archive(
1736 tmp.path(),
1737 "archive",
1738 &[("archived-source.md", archive_md.as_bytes())],
1739 );
1740
1741 let folder_mount = Mount {
1742 mem: "specs".to_string(),
1743 schema: Some(crate::engine::test_helpers::pin("default")),
1744 storage: MountStorage::Folder {
1745 path: writable_dir.clone(),
1746 },
1747 capability: MountCapability::Write,
1748 lifecycle: MountLifecycle::Eager,
1749 cross_linkable: true,
1750 migration_target: None,
1751 };
1752 let archive_reader = crate::storage::ArchiveBackend::new(archive_path.clone());
1753 let mut engine = Engine::from_mounts(vec![
1754 (folder_mount, Box::new(writer) as Box<dyn MemBackend>),
1755 (
1756 archive_mount("archive", archive_path.clone()),
1757 Box::new(archive_reader) as Box<dyn MemBackend>,
1758 ),
1759 ])
1760 .unwrap();
1761
1762 let (actor, client) = cli_actor();
1763 let target = engine
1764 .create_entity(
1765 empty_create_args("specs", "Target"),
1766 actor,
1767 Some(&client),
1768 None,
1769 )
1770 .unwrap();
1771
1772 let archived_source_id = crate::EntityId::new("archive", "archived-source");
1775 let incoming_pre: Vec<_> = engine
1776 .store()
1777 .incoming(&target.id)
1778 .iter()
1779 .map(|e| e.from.clone())
1780 .collect();
1781 assert!(
1782 incoming_pre.contains(&archived_source_id),
1783 "archive wiki-link must produce an incoming edge on target; got {incoming_pre:?}"
1784 );
1785
1786 let outcome = engine
1789 .rename_entity(
1790 RenameEntityArgs {
1791 id: target.id.clone(),
1792 expected_hash: Some(target.content_hash.clone()),
1793 new_title: "Renamed Target".to_string(),
1794 },
1795 actor,
1796 Some(&client),
1797 None,
1798 )
1799 .unwrap();
1800 assert_eq!(outcome.new_id.to_string(), "specs--renamed-target");
1801
1802 assert!(writable_dir.join(&outcome.new_path).exists());
1804 let demoted = engine
1806 .get_entity(&target.id)
1807 .expect("residual stub must remain at old id");
1808 assert!(demoted.stub, "demoted entity must be flagged as stub");
1809 assert!(demoted.entity_type.is_empty());
1810 let incoming_old: Vec<_> = engine
1813 .store()
1814 .incoming(&target.id)
1815 .iter()
1816 .map(|e| e.from.clone())
1817 .collect();
1818 assert!(
1819 incoming_old.contains(&archived_source_id),
1820 "archive incoming edge must survive demotion at old id; got {incoming_old:?}"
1821 );
1822 let incoming_new: Vec<_> = engine
1825 .store()
1826 .incoming(&outcome.new_id)
1827 .iter()
1828 .map(|e| e.from.clone())
1829 .collect();
1830 assert!(
1831 !incoming_new.contains(&archived_source_id),
1832 "archive must not be wired to new id (markdown still references old slug); got {incoming_new:?}"
1833 );
1834 let referrers = outcome
1836 .warnings
1837 .iter()
1838 .find_map(|w| match w {
1839 WarningHint::ResidualStubForReadOnlyReferrers {
1840 id: warn_id,
1841 referrers,
1842 } => {
1843 assert_eq!(warn_id, &target.id);
1844 Some(referrers.clone())
1845 }
1846 _ => None,
1847 })
1848 .expect("ResidualStubForReadOnlyReferrers warning must surface");
1849 assert_eq!(referrers, vec![archived_source_id]);
1850 }
1851
1852 #[test]
1860 fn rename_entity_rewrites_full_id_form_body_link_on_same_mem_referrer() {
1861 use crate::engine::CreateEntityArgs;
1862 use indexmap::IndexMap;
1863 let tmp = TempDir::new().unwrap();
1864 let mem_dir = tmp.path().to_path_buf();
1865 let writer = FilesystemMemWriter::new(mem_dir.clone());
1866 let mut engine = Engine::from_mounts(vec![(
1867 folder_mount("specs", mem_dir.clone()),
1868 Box::new(writer) as Box<dyn MemBackend>,
1869 )])
1870 .unwrap();
1871 let (actor, client) = cli_actor();
1872
1873 let target = engine
1875 .create_entity(
1876 empty_create_args("specs", "Target Spec"),
1877 actor,
1878 Some(&client),
1879 None,
1880 )
1881 .unwrap();
1882 assert_eq!(target.id.to_string(), "specs--target-spec");
1883
1884 let mut sections: IndexMap<String, String> = IndexMap::new();
1889 sections.insert("identity".to_string(), "referrer identity".to_string());
1890 sections.insert(
1891 "purpose".to_string(),
1892 "see also [[specs--target-spec]] for context".to_string(),
1893 );
1894 let referrer = engine
1895 .create_entity(
1896 CreateEntityArgs {
1897 anchors: Vec::new(),
1898 mem: "specs".to_string(),
1899 title: "Referrer Full".to_string(),
1900 entity_type: "spec".to_string(),
1901 sections,
1902 metadata: IndexMap::new(),
1903 relations: Vec::new(),
1904 dry_run: false,
1905 },
1906 actor,
1907 Some(&client),
1908 None,
1909 )
1910 .unwrap();
1911
1912 let renamed = engine
1913 .rename_entity(
1914 RenameEntityArgs {
1915 id: target.id.clone(),
1916 expected_hash: Some(target.content_hash.clone()),
1917 new_title: "Renamed Spec".to_string(),
1918 },
1919 actor,
1920 Some(&client),
1921 None,
1922 )
1923 .unwrap();
1924 assert_eq!(renamed.new_id.to_string(), "specs--renamed-spec");
1925
1926 let referrer_path = mem_dir.join(&referrer.file_path);
1930 let body = std::fs::read_to_string(&referrer_path).unwrap();
1931 assert!(
1932 body.contains("[[specs--renamed-spec]]"),
1933 "expected full-id form retargeted to new slug, got:\n{body}"
1934 );
1935 assert!(
1936 !body.contains("target-spec"),
1937 "old slug must not survive in any form, got:\n{body}"
1938 );
1939 }
1940
1941 #[test]
1942 fn rename_entity_rejects_collision_with_existing_id() {
1943 let tmp = TempDir::new().unwrap();
1944 let (mut engine, first) = engine_with_seed(&tmp, "First");
1945 let (actor, client) = cli_actor();
1946 let _ = engine
1947 .create_entity(
1948 empty_create_args("specs", "Second"),
1949 actor,
1950 Some(&client),
1951 None,
1952 )
1953 .unwrap();
1954 let err = engine
1956 .rename_entity(
1957 RenameEntityArgs {
1958 id: first.id.clone(),
1959 expected_hash: Some(first.content_hash.clone()),
1960 new_title: "Second".to_string(),
1961 },
1962 actor,
1963 Some(&client),
1964 None,
1965 )
1966 .unwrap_err();
1967 assert!(matches!(
1968 err,
1969 EngineError::AlreadyExists { ref id, ref existing_title, existing_is_stub: false }
1970 if id == "specs--second" && existing_title == "Second"
1971 ));
1972 }
1973
1974 #[test]
1981 fn rename_propagation_gate_checks_actual_edge_direction() {
1982 use crate::engine::error::BlockedReferrer;
1983 use crate::engine::{CreateEntityArgs, RelateEntityArgs};
1984 use indexmap::IndexMap;
1985 use memstead_schema::workspace_config::CrossLinkValue;
1986
1987 let tmp_test = TempDir::new().unwrap();
1988 let tmp_other = TempDir::new().unwrap();
1989 let test_dir = tmp_test.path().to_path_buf();
1990 let other_dir = tmp_other.path().to_path_buf();
1991
1992 let writer_test = FilesystemMemWriter::new(test_dir.clone());
1996 let writer_other = FilesystemMemWriter::new(other_dir.clone());
1997 let mut engine = Engine::from_mounts(vec![
1998 (
1999 folder_mount("test", test_dir.clone()),
2000 Box::new(writer_test) as Box<dyn MemBackend>,
2001 ),
2002 (
2003 folder_mount("other", other_dir.clone()),
2004 Box::new(writer_other) as Box<dyn MemBackend>,
2005 ),
2006 ])
2007 .unwrap();
2008 let (actor, client) = cli_actor();
2009
2010 let mut settings = crate::workspace::WorkspaceSettings::default();
2013 settings.cross_mem_links.insert(
2014 "test".to_string(),
2015 CrossLinkValue::List(vec!["other".to_string()]),
2016 );
2017 engine.set_settings(settings);
2018
2019 let target = engine
2020 .create_entity(
2021 empty_create_args("other", "Target"),
2022 actor,
2023 Some(&client),
2024 None,
2025 )
2026 .unwrap();
2027 let mut src_sections: IndexMap<String, String> = IndexMap::new();
2028 src_sections.insert("identity".to_string(), "source identity".to_string());
2029 src_sections.insert("purpose".to_string(), "source purpose".to_string());
2030 let src = engine
2031 .create_entity(
2032 CreateEntityArgs {
2033 anchors: Vec::new(),
2034 mem: "test".to_string(),
2035 title: "Src".to_string(),
2036 entity_type: "spec".to_string(),
2037 sections: src_sections,
2038 metadata: IndexMap::new(),
2039 relations: Vec::new(),
2040 dry_run: false,
2041 },
2042 actor,
2043 Some(&client),
2044 None,
2045 )
2046 .unwrap();
2047 let src = engine
2048 .relate_entity(
2049 RelateEntityArgs {
2050 source: src.id.clone(),
2051 rel_type: "IMPLEMENTS".to_string(),
2052 target: target.id.clone(),
2053 expected_hash: Some(src.content_hash.clone()),
2054 remove: false,
2055 description: None,
2056 dry_run: false,
2057 },
2058 actor,
2059 Some(&client),
2060 None,
2061 )
2062 .unwrap();
2063 let _ = src; engine.set_settings(crate::workspace::WorkspaceSettings::default());
2070
2071 let err = engine
2072 .rename_entity(
2073 RenameEntityArgs {
2074 id: target.id.clone(),
2075 expected_hash: Some(target.content_hash.clone()),
2076 new_title: "Renamed".to_string(),
2077 },
2078 actor,
2079 Some(&client),
2080 None,
2081 )
2082 .unwrap_err();
2083 match err {
2084 EngineError::RenameBlockedByCrossMemPolicy {
2085 from_mem,
2086 blocked_referrers,
2087 } => {
2088 assert_eq!(from_mem, "other");
2089 assert_eq!(
2090 blocked_referrers,
2091 vec![BlockedReferrer {
2092 from_mem: "test".to_string(),
2093 to_mem: "other".to_string(),
2094 count: 1,
2095 }],
2096 "blocked_referrers must name the actual edge direction (test → other)",
2097 );
2098 }
2099 other => panic!("expected RenameBlockedByCrossMemPolicy, got {other:?}"),
2100 }
2101 assert!(other_dir.join(&target.file_path).exists());
2104 assert!(!other_dir.join("renamed.md").exists());
2105 }
2106
2107 #[test]
2111 fn rename_propagation_succeeds_when_actual_edge_direction_granted() {
2112 use crate::engine::{CreateEntityArgs, RelateEntityArgs};
2113 use indexmap::IndexMap;
2114 use memstead_schema::workspace_config::CrossLinkValue;
2115
2116 let tmp_test = TempDir::new().unwrap();
2117 let tmp_other = TempDir::new().unwrap();
2118 let test_dir = tmp_test.path().to_path_buf();
2119 let other_dir = tmp_other.path().to_path_buf();
2120
2121 let writer_test = FilesystemMemWriter::new(test_dir.clone());
2122 let writer_other = FilesystemMemWriter::new(other_dir.clone());
2123 let mut engine = Engine::from_mounts(vec![
2124 (
2125 folder_mount("test", test_dir),
2126 Box::new(writer_test) as Box<dyn MemBackend>,
2127 ),
2128 (
2129 folder_mount("other", other_dir),
2130 Box::new(writer_other) as Box<dyn MemBackend>,
2131 ),
2132 ])
2133 .unwrap();
2134 let (actor, client) = cli_actor();
2135
2136 let mut settings = crate::workspace::WorkspaceSettings::default();
2137 settings.cross_mem_links.insert(
2138 "test".to_string(),
2139 CrossLinkValue::List(vec!["other".to_string()]),
2140 );
2141 engine.set_settings(settings);
2142
2143 let target = engine
2144 .create_entity(
2145 empty_create_args("other", "Target"),
2146 actor,
2147 Some(&client),
2148 None,
2149 )
2150 .unwrap();
2151 let mut src_sections: IndexMap<String, String> = IndexMap::new();
2152 src_sections.insert("identity".to_string(), "source identity".to_string());
2153 src_sections.insert("purpose".to_string(), "source purpose".to_string());
2154 let src = engine
2155 .create_entity(
2156 CreateEntityArgs {
2157 anchors: Vec::new(),
2158 mem: "test".to_string(),
2159 title: "Src".to_string(),
2160 entity_type: "spec".to_string(),
2161 sections: src_sections,
2162 metadata: IndexMap::new(),
2163 relations: Vec::new(),
2164 dry_run: false,
2165 },
2166 actor,
2167 Some(&client),
2168 None,
2169 )
2170 .unwrap();
2171 let _ = engine
2172 .relate_entity(
2173 RelateEntityArgs {
2174 source: src.id.clone(),
2175 rel_type: "IMPLEMENTS".to_string(),
2176 target: target.id.clone(),
2177 expected_hash: Some(src.content_hash.clone()),
2178 remove: false,
2179 description: None,
2180 dry_run: false,
2181 },
2182 actor,
2183 Some(&client),
2184 None,
2185 )
2186 .unwrap();
2187
2188 let outcome = engine
2191 .rename_entity(
2192 RenameEntityArgs {
2193 id: target.id.clone(),
2194 expected_hash: Some(target.content_hash.clone()),
2195 new_title: "Renamed".to_string(),
2196 },
2197 actor,
2198 Some(&client),
2199 None,
2200 )
2201 .unwrap();
2202 assert_ne!(outcome.old_id, outcome.new_id);
2203 let renamed = engine
2204 .get_entity(&outcome.new_id)
2205 .expect("renamed entity persists");
2206 assert_eq!(renamed.title, "Renamed");
2207 let updated_src = engine.get_entity(&src.id).expect("source persists");
2209 assert!(
2210 updated_src
2211 .relationships
2212 .iter()
2213 .any(|r| r.rel_type == "IMPLEMENTS" && r.target == outcome.new_id),
2214 "referrer's IMPLEMENTS edge must point at the new id after rewrite"
2215 );
2216 }
2217
2218 #[test]
2222 fn rename_with_no_cross_mem_referrers_succeeds_regardless_of_policy() {
2223 let tmp = TempDir::new().unwrap();
2224 let mem_dir = tmp.path().to_path_buf();
2225 let writer = FilesystemMemWriter::new(mem_dir.clone());
2226 let mut engine = Engine::from_mounts(vec![(
2227 folder_mount("specs", mem_dir),
2228 Box::new(writer) as Box<dyn MemBackend>,
2229 )])
2230 .unwrap();
2231 let (actor, client) = cli_actor();
2232 let target = engine
2236 .create_entity(
2237 empty_create_args("specs", "Target"),
2238 actor,
2239 Some(&client),
2240 None,
2241 )
2242 .unwrap();
2243 let outcome = engine
2244 .rename_entity(
2245 RenameEntityArgs {
2246 id: target.id.clone(),
2247 expected_hash: Some(target.content_hash.clone()),
2248 new_title: "Renamed Target".to_string(),
2249 },
2250 actor,
2251 Some(&client),
2252 None,
2253 )
2254 .unwrap();
2255 assert_ne!(outcome.old_id, outcome.new_id);
2256 }
2257}