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 let commit_subject = format!("memstead: rename {} → {new_id}", id);
448 let ctx = CommitContext {
449 actor,
450 client: client.cloned(),
451 tool: Some("rename_entity"),
452 note: note.map(String::from),
453 logical_operation_id: Some(logical_op_id.as_str()),
454 entity_ids: None,
455 };
456 let commit_sha = backend.commit(&commit_subject, &ctx)?;
457
458 backend.append_provenance(
459 &Provenance::new(
460 std::time::SystemTime::now(),
461 ProvenanceKind::Rename,
462 Some(new_id.to_string()),
463 actor,
464 client.cloned(),
465 note.map(String::from),
466 )
467 .with_logical_operation_id(logical_op_id.clone()),
468 )?;
469
470 self.record_self_write(mount_idx, &commit_sha);
471
472 let mut peer_snapshots: std::collections::BTreeMap<String, Option<String>> =
486 std::collections::BTreeMap::new();
487 for plan in peer_plans.values() {
488 let peer_backend = self.mounts[plan.mount_idx].backend.as_ref();
489 let snapshot = peer_backend.current_head()?;
490 peer_snapshots.insert(plan.mem.clone(), snapshot);
491 }
492
493 let mut committed_mems: Vec<String> = vec![mem.clone()];
498 for plan in peer_plans.values() {
499 let peer_backend = self.mounts[plan.mount_idx].backend.as_ref();
500 for (ref_markdown, ref_file_path, _) in &plan.writes {
501 peer_backend.write_entity(Path::new(ref_file_path), ref_markdown.as_bytes())?;
502 }
503 let peer_commit_subject = format!(
504 "memstead: rename {} → {new_id} (cross-mem rewrite in `{}`)",
505 id, plan.mem
506 );
507 let peer_ctx = CommitContext {
508 actor,
509 client: client.cloned(),
510 tool: Some("rename_entity"),
511 note: note.map(String::from),
512 logical_operation_id: Some(logical_op_id.as_str()),
513 entity_ids: None,
514 };
515 let expected = peer_snapshots.get(&plan.mem).cloned().unwrap_or(None);
516 let peer_commit_result = peer_backend.commit_with_expected_parent(
517 &peer_commit_subject,
518 &peer_ctx,
519 expected.as_deref(),
520 );
521 let peer_commit_sha = match peer_commit_result {
522 Ok(sha) => sha,
523 Err(crate::backend::BackendError::ParentMismatch { .. }) => {
524 return Err(EngineError::RenamePartialFailure {
525 committed_mems: std::mem::take(&mut committed_mems),
526 failed_mem: plan.mem.clone(),
527 failure_cause: "drift".to_string(),
528 });
529 }
530 Err(e) => return Err(e.into()),
531 };
532 peer_backend.append_provenance(
533 &Provenance::new(
534 std::time::SystemTime::now(),
535 ProvenanceKind::Rename,
536 Some(new_id.to_string()),
537 actor,
538 client.cloned(),
539 note.map(String::from),
540 )
541 .with_logical_operation_id(logical_op_id.clone()),
542 )?;
543 self.record_self_write(plan.mount_idx, &peer_commit_sha);
544 committed_mems.push(plan.mem.clone());
545 }
546
547 let parse_result = parse_markdown(&markdown, &new_file_path, type_def.as_ref(), &mem)
549 .map_err(|e| EngineError::ParseAfterWrite(e.to_string()))?;
550 let content_hash = parse_result.entity.content_hash.clone();
551
552 let mut parse_results = vec![parse_result];
553 for (ref_markdown, ref_file_path, ref_type_def) in &same_mem_writes {
554 let pr = parse_markdown(ref_markdown, ref_file_path, ref_type_def.as_ref(), &mem)
555 .map_err(|e| EngineError::ParseAfterWrite(e.to_string()))?;
556 parse_results.push(pr);
557 }
558 for plan in peer_plans.values() {
559 for (ref_markdown, ref_file_path, ref_type_def) in &plan.writes {
560 let pr = parse_markdown(
561 ref_markdown,
562 ref_file_path,
563 ref_type_def.as_ref(),
564 &plan.mem,
565 )
566 .map_err(|e| EngineError::ParseAfterWrite(e.to_string()))?;
567 parse_results.push(pr);
568 }
569 }
570
571 let mut outcome_warnings: Vec<WarningHint> = Vec::new();
584 outcome_warnings.append(&mut drift_warnings);
586 if readonly_referrers.is_empty() {
587 self.store.remove(id);
588 } else {
589 self.store.remove_edges_from(id);
594 self.store.upsert(
595 id.clone(),
596 make_stub(
597 id,
598 crate::entity::StubKind::Residual {
599 since_commit: commit_sha.clone(),
600 readonly_referrers: readonly_referrers.clone(),
601 },
602 ),
603 );
604 outcome_warnings.push(WarningHint::ResidualStubForReadOnlyReferrers {
605 id: id.clone(),
606 referrers: readonly_referrers,
607 });
608 }
609
610 let fallback = engine_fallback_type();
611 push_entities_into_store(&mut self.store, parse_results, fallback.as_ref(), None);
612 crate::entity::store_builder::remap_alias_target_edge_sources(
613 &mut self.store,
614 &self.schemas,
615 );
616
617 self.invalidate_communities();
618 self.invalidate_search_indexes();
619
620 if let Some(w) = self.note_missing_warning("rename_entity", note) {
625 outcome_warnings.push(w);
626 }
627
628 Ok(RenameEntityOutcome {
629 old_id: id.clone(),
630 new_id,
631 old_path: old_file_path,
632 new_path: new_file_path,
633 content_hash,
634 commit_sha,
635 warnings: outcome_warnings,
636 })
637 }
638}
639
640#[cfg(test)]
641mod tests {
642 use std::path::PathBuf;
643
644 use tempfile::TempDir;
645
646 use crate::backend::MemBackend;
647 use crate::engine::test_helpers::*;
648 use crate::engine::{Engine, EngineError, RenameEntityArgs};
649 use crate::ops::WarningHint;
650 use crate::storage::FilesystemMemWriter;
651
652 #[test]
653 fn rename_entity_renames_file_and_id_persists_across_restart() {
654 let tmp = TempDir::new().unwrap();
655 let mem_dir = tmp.path().to_path_buf();
656
657 let (old_id, new_id, new_file) = {
658 let writer = FilesystemMemWriter::new(mem_dir.clone());
659 let mut engine = Engine::from_mounts(vec![(
660 folder_mount("specs", mem_dir.clone()),
661 Box::new(writer) as Box<dyn MemBackend>,
662 )])
663 .unwrap();
664 let (actor, client) = cli_actor();
665 let seeded = engine
666 .create_entity(
667 empty_create_args("specs", "Old Name"),
668 actor,
669 Some(&client),
670 None,
671 )
672 .unwrap();
673 let outcome = engine
674 .rename_entity(
675 RenameEntityArgs {
676 id: seeded.id.clone(),
677 expected_hash: Some(seeded.content_hash.clone()),
678 new_title: "New Name".to_string(),
679 },
680 actor,
681 Some(&client),
682 None,
683 )
684 .unwrap();
685 assert_eq!(outcome.old_id.to_string(), "specs--old-name");
686 assert_eq!(outcome.new_id.to_string(), "specs--new-name");
687 assert_eq!(outcome.new_path, "new-name.md");
688 assert!(!mem_dir.join(&outcome.old_path).exists());
690 assert!(mem_dir.join(&outcome.new_path).exists());
691 (outcome.old_id, outcome.new_id, outcome.new_path)
692 };
693
694 let writer2 = FilesystemMemWriter::new(mem_dir.clone());
696 let engine2 = Engine::from_mounts(vec![(
697 folder_mount("specs", mem_dir),
698 Box::new(writer2) as Box<dyn MemBackend>,
699 )])
700 .unwrap();
701 assert!(engine2.get_entity(&old_id).is_none());
702 let new_entity = engine2.get_entity(&new_id).expect("new id must persist");
703 assert_eq!(new_entity.title, "New Name");
704 assert_eq!(new_entity.file_path, new_file);
705 }
706
707 #[test]
708 fn rename_entity_returns_typed_warning_on_slug_noop() {
709 let tmp = TempDir::new().unwrap();
710 let (mut engine, seeded) = engine_with_seed(&tmp, "Same Slug");
711 let (actor, client) = cli_actor();
712 let outcome = engine
713 .rename_entity(
714 RenameEntityArgs {
715 id: seeded.id.clone(),
716 expected_hash: Some(seeded.content_hash.clone()),
717 new_title: "Same Slug".to_string(), },
719 actor,
720 Some(&client),
721 None,
722 )
723 .unwrap();
724 assert_eq!(outcome.old_id, outcome.new_id);
729 assert_eq!(outcome.old_path, outcome.new_path);
730 assert!(outcome.commit_sha.is_empty());
731 assert_eq!(outcome.warnings.len(), 1);
732 assert!(matches!(
733 outcome.warnings[0],
734 WarningHint::TitleNormalizedToSlugNoop { .. }
735 ));
736 }
737
738 #[test]
739 fn rename_entity_returns_commit_sha_on_real_rename() {
740 let tmp = TempDir::new().unwrap();
741 let (mut engine, seeded) = engine_with_seed(&tmp, "Old Name");
742 let (actor, client) = cli_actor();
743 let outcome = engine
744 .rename_entity(
745 RenameEntityArgs {
746 id: seeded.id.clone(),
747 expected_hash: Some(seeded.content_hash.clone()),
748 new_title: "Brand New Name".to_string(),
749 },
750 actor,
751 Some(&client),
752 None,
753 )
754 .unwrap();
755 assert_ne!(outcome.old_id, outcome.new_id);
758 assert!(
759 !outcome.commit_sha.is_empty(),
760 "commit_sha must be populated on a real rename"
761 );
762 assert!(outcome.warnings.is_empty());
763 }
764
765 #[test]
766 fn rename_entity_rewrites_self_references_in_body_and_relationships() {
767 use crate::entity::EntityId;
768 use indexmap::IndexMap;
769
770 let tmp = TempDir::new().unwrap();
771 let mem_dir = tmp.path().to_path_buf();
772 let writer = FilesystemMemWriter::new(mem_dir.clone());
773 let mut engine = Engine::from_mounts(vec![(
774 folder_mount("specs", mem_dir.clone()),
775 Box::new(writer) as Box<dyn MemBackend>,
776 )])
777 .unwrap();
778 let (actor, client) = cli_actor();
779
780 let mut sections: IndexMap<String, String> = IndexMap::new();
786 sections.insert("identity".to_string(), "the seed identity".to_string());
787 sections.insert(
788 "purpose".to_string(),
789 "see also [[old-name]] for prior context".to_string(),
790 );
791 let seeded = engine
798 .create_entity(
799 crate::engine::CreateEntityArgs {
800 mem: "specs".to_string(),
801 title: "Old Name".to_string(),
802 entity_type: "spec".to_string(),
803 sections,
804 metadata: IndexMap::new(),
805 relations: Vec::new(),
806 dry_run: false,
807 },
808 actor,
809 Some(&client),
810 None,
811 )
812 .unwrap();
813 assert_eq!(seeded.id.to_string(), "specs--old-name");
814 let related = seeded.clone();
815
816 let outcome = engine
817 .rename_entity(
818 RenameEntityArgs {
819 id: seeded.id.clone(),
820 expected_hash: Some(related.content_hash.clone()),
821 new_title: "Brand New Name".to_string(),
822 },
823 actor,
824 Some(&client),
825 None,
826 )
827 .unwrap();
828 assert_eq!(outcome.new_id.to_string(), "specs--brand-new-name");
829
830 let new_bytes = std::fs::read_to_string(mem_dir.join(&outcome.new_path)).unwrap();
833 assert!(
834 new_bytes.contains("[[brand-new-name]]"),
835 "expected new slug in body, got:\n{new_bytes}"
836 );
837 assert!(
838 !new_bytes.contains("[[old-name]]"),
839 "old slug must not survive in the rewritten file, got:\n{new_bytes}"
840 );
841
842 let in_mem = engine.get_entity(&outcome.new_id).unwrap();
845 assert!(
846 in_mem
847 .sections
848 .get("purpose")
849 .map(|s| s.contains("[[brand-new-name]]"))
850 .unwrap_or(false),
851 "section body must be rewritten in-memory; got {:?}",
852 in_mem.sections.get("purpose")
853 );
854 let new_self_target = EntityId::new("specs", "brand-new-name");
858 assert!(
859 in_mem
860 .relationships
861 .iter()
862 .all(|r| r.target != seeded.id && r.target != new_self_target),
863 "a self-referential body link must produce no self-relation (F11), got: {:?}",
864 in_mem.relationships
865 );
866 }
867
868 #[test]
877 fn rename_preserves_referrer_last_modified_but_rewrites_link() {
878 let tmp = TempDir::new().unwrap();
879 let mem_dir = tmp.path().to_path_buf();
880
881 std::fs::write(
882 mem_dir.join("target.md"),
883 "---\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",
884 )
885 .unwrap();
886 std::fs::write(
887 mem_dir.join("referrer.md"),
888 "---\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",
889 )
890 .unwrap();
891
892 let writer = FilesystemMemWriter::new(mem_dir.clone());
893 let mut engine = Engine::from_mounts(vec![(
894 folder_mount("specs", mem_dir.clone()),
895 Box::new(writer) as Box<dyn MemBackend>,
896 )])
897 .unwrap();
898 let (actor, client) = cli_actor();
899
900 let target_id = crate::entity::EntityId::new("specs", "target");
901 let target_hash = engine
902 .store()
903 .get(&target_id)
904 .expect("target loaded from disk")
905 .content_hash
906 .clone();
907
908 engine
909 .rename_entity(
910 RenameEntityArgs {
911 id: target_id,
912 expected_hash: Some(target_hash),
913 new_title: "Target Renamed".to_string(),
914 },
915 actor,
916 Some(&client),
917 None,
918 )
919 .expect("rename succeeds");
920
921 let referrer_md = std::fs::read_to_string(mem_dir.join("referrer.md")).unwrap();
922 assert!(
924 referrer_md.contains("last_modified: 2020-01-01"),
925 "referrer's last_modified must be preserved across a rename-driven slug rewrite; got:\n{referrer_md}"
926 );
927 assert!(
929 referrer_md.contains("[[target-renamed]]"),
930 "referrer's body wiki-link must be rewritten to the new slug; got:\n{referrer_md}"
931 );
932 assert!(
933 !referrer_md.contains("[[target]]"),
934 "old slug must not survive in the referrer body; got:\n{referrer_md}"
935 );
936 }
937
938 #[test]
939 fn rename_entity_rewrites_same_mem_referrers_atomically() {
940 use crate::engine::CreateEntityArgs;
941 use crate::entity::EntityId;
942 use indexmap::IndexMap;
943
944 let tmp = TempDir::new().unwrap();
945 let mem_dir = tmp.path().to_path_buf();
946 let writer = FilesystemMemWriter::new(mem_dir.clone());
947 let mut engine = Engine::from_mounts(vec![(
948 folder_mount("specs", mem_dir.clone()),
949 Box::new(writer) as Box<dyn MemBackend>,
950 )])
951 .unwrap();
952 let (actor, client) = cli_actor();
953
954 let target = engine
956 .create_entity(
957 empty_create_args("specs", "Target Spec"),
958 actor,
959 Some(&client),
960 None,
961 )
962 .unwrap();
963 assert_eq!(target.id.to_string(), "specs--target-spec");
964
965 let mut sections_a: IndexMap<String, String> = IndexMap::new();
968 sections_a.insert(
969 "identity".to_string(),
970 "referrer alpha identity".to_string(),
971 );
972 sections_a.insert(
973 "purpose".to_string(),
974 "rationale relies on [[target-spec]] for context".to_string(),
975 );
976 let referrer_a = engine
977 .create_entity(
978 CreateEntityArgs {
979 mem: "specs".to_string(),
980 title: "Referrer Alpha".to_string(),
981 entity_type: "spec".to_string(),
982 sections: sections_a,
983 metadata: IndexMap::new(),
984 relations: Vec::new(),
988 dry_run: false,
989 },
990 actor,
991 Some(&client),
992 None,
993 )
994 .unwrap();
995
996 let mut sections_b: IndexMap<String, String> = IndexMap::new();
999 sections_b.insert("identity".to_string(), "referrer beta identity".to_string());
1000 sections_b.insert(
1001 "purpose".to_string(),
1002 "consult [[target-spec]] for the canonical phrasing".to_string(),
1003 );
1004 let referrer_b = engine
1005 .create_entity(
1006 CreateEntityArgs {
1007 mem: "specs".to_string(),
1008 title: "Referrer Bravo".to_string(),
1009 entity_type: "spec".to_string(),
1010 sections: sections_b,
1011 metadata: IndexMap::new(),
1012 relations: Vec::new(),
1016 dry_run: false,
1017 },
1018 actor,
1019 Some(&client),
1020 None,
1021 )
1022 .unwrap();
1023
1024 let bystander = engine
1027 .create_entity(
1028 empty_create_args("specs", "Bystander"),
1029 actor,
1030 Some(&client),
1031 None,
1032 )
1033 .unwrap();
1034 let bystander_bytes_before =
1035 std::fs::read_to_string(mem_dir.join(&bystander.file_path)).unwrap();
1036
1037 let renamed = engine
1038 .rename_entity(
1039 RenameEntityArgs {
1040 id: target.id.clone(),
1041 expected_hash: Some(target.content_hash.clone()),
1042 new_title: "Renamed Spec".to_string(),
1043 },
1044 actor,
1045 Some(&client),
1046 None,
1047 )
1048 .unwrap();
1049 assert_eq!(renamed.new_id.to_string(), "specs--renamed-spec");
1050
1051 for path in std::fs::read_dir(&mem_dir).unwrap().flatten() {
1054 let p = path.path();
1055 if p.extension().and_then(|s| s.to_str()) != Some("md") {
1056 continue;
1057 }
1058 let body = std::fs::read_to_string(&p).unwrap();
1059 assert!(
1060 !body.contains("[[target-spec]]"),
1061 "old slug must not survive in {}, got:\n{body}",
1062 p.display()
1063 );
1064 }
1065
1066 let in_mem_a = engine.get_entity(&referrer_a.id).unwrap();
1068 assert!(
1069 in_mem_a
1070 .relationships
1071 .iter()
1072 .any(|r| r.rel_type == "REFERENCES"
1073 && r.target == EntityId::new("specs", "renamed-spec")),
1074 "expected referrer A's relation to point at renamed-spec, got {:?}",
1075 in_mem_a.relationships
1076 );
1077 assert!(
1078 in_mem_a
1079 .sections
1080 .get("purpose")
1081 .map(|s| s.contains("[[renamed-spec]]"))
1082 .unwrap_or(false),
1083 "referrer A's body must be rewritten"
1084 );
1085
1086 let in_mem_b = engine.get_entity(&referrer_b.id).unwrap();
1089 assert!(
1090 in_mem_b
1091 .sections
1092 .get("purpose")
1093 .map(|s| s.contains("[[renamed-spec]]"))
1094 .unwrap_or(false),
1095 "referrer B's body must be rewritten"
1096 );
1097
1098 let bystander_bytes_after =
1100 std::fs::read_to_string(mem_dir.join(&bystander.file_path)).unwrap();
1101 assert_eq!(
1102 bystander_bytes_before, bystander_bytes_after,
1103 "bystander must not be rewritten"
1104 );
1105 }
1106
1107 fn engine_with_two_mems_and_bidirectional_policy(
1113 specs_dir: PathBuf,
1114 memos_dir: PathBuf,
1115 ) -> Engine {
1116 use memstead_schema::workspace_config::CrossLinkValue;
1117 let writer_specs = FilesystemMemWriter::new(specs_dir.clone());
1118 let writer_memos = FilesystemMemWriter::new(memos_dir.clone());
1119 let mut engine = Engine::from_mounts(vec![
1120 (
1121 folder_mount("specs", specs_dir),
1122 Box::new(writer_specs) as Box<dyn MemBackend>,
1123 ),
1124 (
1125 folder_mount("memos", memos_dir),
1126 Box::new(writer_memos) as Box<dyn MemBackend>,
1127 ),
1128 ])
1129 .unwrap();
1130 let mut settings = crate::workspace::WorkspaceSettings::default();
1131 settings.cross_mem_links.insert(
1132 "memos".to_string(),
1133 CrossLinkValue::List(vec!["specs".to_string()]),
1134 );
1135 settings.cross_mem_links.insert(
1136 "specs".to_string(),
1137 CrossLinkValue::List(vec!["memos".to_string()]),
1138 );
1139 engine.set_settings(settings);
1140 engine
1141 }
1142
1143 #[test]
1144 fn rename_entity_rewrites_cross_mem_write_referrer() {
1145 use crate::engine::CreateEntityArgs;
1146 use crate::entity::EntityId;
1147 use indexmap::IndexMap;
1148
1149 let tmp_specs = TempDir::new().unwrap();
1150 let tmp_memos = TempDir::new().unwrap();
1151 let specs_dir = tmp_specs.path().to_path_buf();
1152 let memos_dir = tmp_memos.path().to_path_buf();
1153 let mut engine =
1154 engine_with_two_mems_and_bidirectional_policy(specs_dir.clone(), memos_dir.clone());
1155 let (actor, client) = cli_actor();
1156
1157 let target = engine
1159 .create_entity(
1160 empty_create_args("specs", "Target Spec"),
1161 actor,
1162 Some(&client),
1163 None,
1164 )
1165 .unwrap();
1166
1167 let mut sections: IndexMap<String, String> = IndexMap::new();
1174 sections.insert("claim".to_string(), "the claim".to_string());
1175 sections.insert(
1176 "context".to_string(),
1177 "discussion stems from [[specs:target-spec]]".to_string(),
1178 );
1179 let referrer = engine
1180 .create_entity(
1181 CreateEntityArgs {
1182 mem: "memos".to_string(),
1183 title: "Cross Note".to_string(),
1184 entity_type: "memo".to_string(),
1185 sections,
1186 metadata: IndexMap::new(),
1187 relations: Vec::new(),
1191 dry_run: false,
1192 },
1193 actor,
1194 Some(&client),
1195 None,
1196 )
1197 .unwrap();
1198
1199 let renamed = engine
1201 .rename_entity(
1202 RenameEntityArgs {
1203 id: target.id.clone(),
1204 expected_hash: Some(target.content_hash.clone()),
1205 new_title: "Renamed Spec".to_string(),
1206 },
1207 actor,
1208 Some(&client),
1209 None,
1210 )
1211 .unwrap();
1212 assert_eq!(renamed.new_id.to_string(), "specs--renamed-spec");
1213
1214 let referrer_path = memos_dir.join(&referrer.file_path);
1217 let referrer_bytes = std::fs::read_to_string(&referrer_path).unwrap();
1218 assert!(
1219 referrer_bytes.contains("[[specs:renamed-spec]]"),
1220 "expected colon-form rewrite in referrer body, got:\n{referrer_bytes}"
1221 );
1222 assert!(
1223 !referrer_bytes.contains("target-spec"),
1224 "old slug must not survive in referrer file, got:\n{referrer_bytes}"
1225 );
1226
1227 let in_mem = engine.get_entity(&referrer.id).unwrap();
1229 assert!(
1230 in_mem
1231 .relationships
1232 .iter()
1233 .any(|r| r.rel_type == "REFERENCES"
1234 && r.target == EntityId::new("specs", "renamed-spec")),
1235 "expected cross-mem relation to point at renamed-spec, got {:?}",
1236 in_mem.relationships
1237 );
1238 assert!(
1239 in_mem.relationships.iter().all(|r| r.target != target.id),
1240 "no relationship may still target the old id, got: {:?}",
1241 in_mem.relationships
1242 );
1243 }
1244
1245 struct DriftingBackend {
1252 inner: Box<dyn MemBackend>,
1253 }
1254 impl DriftingBackend {
1255 fn new(inner: Box<dyn MemBackend>) -> Self {
1256 Self { inner }
1257 }
1258 }
1259 impl crate::backend::MemBackend for DriftingBackend {
1260 fn list_entities(&self) -> Result<Vec<PathBuf>, crate::backend::BackendError> {
1261 self.inner.list_entities()
1262 }
1263 fn read_entity(
1264 &self,
1265 rel: &std::path::Path,
1266 ) -> Result<Option<Vec<u8>>, crate::backend::BackendError> {
1267 self.inner.read_entity(rel)
1268 }
1269 fn write_entity(
1270 &self,
1271 rel: &std::path::Path,
1272 b: &[u8],
1273 ) -> Result<(), crate::backend::BackendError> {
1274 self.inner.write_entity(rel, b)
1275 }
1276 fn delete_entity(&self, rel: &std::path::Path) -> Result<(), crate::backend::BackendError> {
1277 self.inner.delete_entity(rel)
1278 }
1279 fn move_entity(
1280 &self,
1281 f: &std::path::Path,
1282 t: &std::path::Path,
1283 ) -> Result<(), crate::backend::BackendError> {
1284 self.inner.move_entity(f, t)
1285 }
1286 fn commit(
1287 &self,
1288 m: &str,
1289 c: &crate::vcs::CommitContext<'_>,
1290 ) -> Result<crate::storage::CommitId, crate::backend::BackendError> {
1291 self.inner.commit(m, c)
1292 }
1293 fn commit_with_expected_parent(
1294 &self,
1295 m: &str,
1296 c: &crate::vcs::CommitContext<'_>,
1297 expected_parent: Option<&str>,
1298 ) -> Result<crate::storage::CommitId, crate::backend::BackendError> {
1299 if let Some(expected) = expected_parent {
1300 Err(crate::backend::BackendError::ParentMismatch {
1301 expected: expected.to_string(),
1302 actual: "drifted-by-sibling-writer".to_string(),
1303 })
1304 } else {
1305 self.inner.commit(m, c)
1306 }
1307 }
1308 fn append_provenance(
1309 &self,
1310 r: &crate::Provenance,
1311 ) -> Result<(), crate::backend::BackendError> {
1312 self.inner.append_provenance(r)
1313 }
1314 fn read_provenance(
1315 &self,
1316 c: Option<&str>,
1317 ) -> Result<Vec<crate::Provenance>, crate::backend::BackendError> {
1318 self.inner.read_provenance(c)
1319 }
1320 fn current_head(&self) -> Result<Option<String>, crate::backend::BackendError> {
1321 Ok(Some("snapshot-head-sha".to_string()))
1324 }
1325 }
1326
1327 #[test]
1328 fn rename_entity_surfaces_partial_failure_when_peer_mem_drifts() {
1329 use crate::engine::CreateEntityArgs;
1330 use indexmap::IndexMap;
1331 use memstead_schema::workspace_config::CrossLinkValue;
1332
1333 let tmp_specs = TempDir::new().unwrap();
1334 let tmp_memos = TempDir::new().unwrap();
1335 let specs_dir = tmp_specs.path().to_path_buf();
1336 let memos_dir = tmp_memos.path().to_path_buf();
1337
1338 let writer_specs = FilesystemMemWriter::new(specs_dir.clone());
1343 let writer_memos_inner: Box<dyn MemBackend> =
1344 Box::new(FilesystemMemWriter::new(memos_dir.clone()));
1345 let writer_memos = DriftingBackend::new(writer_memos_inner);
1346
1347 let mut engine = Engine::from_mounts(vec![
1348 (
1349 folder_mount("specs", specs_dir.clone()),
1350 Box::new(writer_specs) as Box<dyn MemBackend>,
1351 ),
1352 (
1353 folder_mount("memos", memos_dir.clone()),
1354 Box::new(writer_memos) as Box<dyn MemBackend>,
1355 ),
1356 ])
1357 .unwrap();
1358 let mut settings = crate::workspace::WorkspaceSettings::default();
1359 settings.cross_mem_links.insert(
1360 "memos".to_string(),
1361 CrossLinkValue::List(vec!["specs".to_string()]),
1362 );
1363 settings.cross_mem_links.insert(
1364 "specs".to_string(),
1365 CrossLinkValue::List(vec!["memos".to_string()]),
1366 );
1367 engine.set_settings(settings);
1368
1369 let (actor, client) = cli_actor();
1370 let target = engine
1371 .create_entity(
1372 empty_create_args("specs", "Target Spec"),
1373 actor,
1374 Some(&client),
1375 None,
1376 )
1377 .unwrap();
1378 let mut sections: IndexMap<String, String> = IndexMap::new();
1379 sections.insert("claim".to_string(), "the claim".to_string());
1380 sections.insert(
1381 "context".to_string(),
1382 "see [[specs:target-spec]]".to_string(),
1383 );
1384 let _referrer = engine
1385 .create_entity(
1386 CreateEntityArgs {
1387 mem: "memos".to_string(),
1388 title: "Cross Note".to_string(),
1389 entity_type: "memo".to_string(),
1390 sections,
1391 metadata: IndexMap::new(),
1392 relations: Vec::new(),
1396 dry_run: false,
1397 },
1398 actor,
1399 Some(&client),
1400 None,
1401 )
1402 .unwrap();
1403
1404 let err = engine
1405 .rename_entity(
1406 RenameEntityArgs {
1407 id: target.id.clone(),
1408 expected_hash: Some(target.content_hash.clone()),
1409 new_title: "Renamed Spec".to_string(),
1410 },
1411 actor,
1412 Some(&client),
1413 None,
1414 )
1415 .unwrap_err();
1416 match err {
1417 EngineError::RenamePartialFailure {
1418 committed_mems,
1419 failed_mem,
1420 failure_cause,
1421 } => {
1422 assert_eq!(committed_mems, vec!["specs".to_string()]);
1426 assert_eq!(failed_mem, "memos");
1427 assert_eq!(failure_cause, "drift");
1428 }
1429 other => panic!("expected RenamePartialFailure, got {other:?}"),
1430 }
1431 assert!(specs_dir.join("renamed-spec.md").exists());
1435 assert!(!specs_dir.join(&target.file_path).exists());
1436 }
1437
1438 #[test]
1439 fn rename_entity_tags_every_per_mem_commit_with_same_logical_operation_id() {
1440 use crate::backend::MemBackend;
1441 use crate::engine::CreateEntityArgs;
1442 use indexmap::IndexMap;
1443
1444 let tmp_specs = TempDir::new().unwrap();
1445 let tmp_memos = TempDir::new().unwrap();
1446 let specs_dir = tmp_specs.path().to_path_buf();
1447 let memos_dir = tmp_memos.path().to_path_buf();
1448 let mut engine =
1449 engine_with_two_mems_and_bidirectional_policy(specs_dir.clone(), memos_dir.clone());
1450 let (actor, client) = cli_actor();
1451
1452 let target = engine
1453 .create_entity(
1454 empty_create_args("specs", "Target Spec"),
1455 actor,
1456 Some(&client),
1457 None,
1458 )
1459 .unwrap();
1460 let mut sections: IndexMap<String, String> = IndexMap::new();
1461 sections.insert("claim".to_string(), "the claim".to_string());
1462 sections.insert(
1463 "context".to_string(),
1464 "discussion stems from [[specs:target-spec]]".to_string(),
1465 );
1466 let _referrer = engine
1467 .create_entity(
1468 CreateEntityArgs {
1469 mem: "memos".to_string(),
1470 title: "Cross Note".to_string(),
1471 entity_type: "memo".to_string(),
1472 sections,
1473 metadata: IndexMap::new(),
1474 relations: Vec::new(),
1478 dry_run: false,
1479 },
1480 actor,
1481 Some(&client),
1482 None,
1483 )
1484 .unwrap();
1485
1486 let _ = engine
1487 .rename_entity(
1488 RenameEntityArgs {
1489 id: target.id.clone(),
1490 expected_hash: Some(target.content_hash.clone()),
1491 new_title: "Renamed Spec".to_string(),
1492 },
1493 actor,
1494 Some(&client),
1495 None,
1496 )
1497 .unwrap();
1498
1499 let specs_backend: Box<dyn MemBackend> =
1503 Box::new(FilesystemMemWriter::new(specs_dir.clone()));
1504 let memos_backend: Box<dyn MemBackend> =
1505 Box::new(FilesystemMemWriter::new(memos_dir.clone()));
1506 let specs_provenance = specs_backend.read_provenance(None).unwrap();
1507 let memos_provenance = memos_backend.read_provenance(None).unwrap();
1508
1509 let specs_rename = specs_provenance
1510 .iter()
1511 .find(|p| matches!(p.kind, crate::provenance::ProvenanceKind::Rename))
1512 .expect("specs mem must have a rename provenance entry");
1513 let memos_rename = memos_provenance
1514 .iter()
1515 .find(|p| matches!(p.kind, crate::provenance::ProvenanceKind::Rename))
1516 .expect("memos mem must have a rename provenance entry");
1517
1518 let specs_id = specs_rename
1519 .logical_operation_id
1520 .as_deref()
1521 .expect("specs rename entry must carry a logical_operation_id");
1522 let memos_id = memos_rename
1523 .logical_operation_id
1524 .as_deref()
1525 .expect("memos rename entry must carry a logical_operation_id");
1526 assert_eq!(
1527 specs_id, memos_id,
1528 "both per-mem rename commits must share the same logical_operation_id"
1529 );
1530 assert!(
1531 specs_id.starts_with("logop-"),
1532 "logical_operation_id must use the `logop-` prefix the engine mints; got {specs_id}"
1533 );
1534 }
1535
1536 #[test]
1537 fn rename_entity_refuses_when_cross_mem_referrer_blocked_by_policy() {
1538 use crate::engine::CreateEntityArgs;
1539 use indexmap::IndexMap;
1540 use memstead_schema::workspace_config::CrossLinkValue;
1541
1542 let tmp_specs = TempDir::new().unwrap();
1543 let tmp_memos = TempDir::new().unwrap();
1544 let specs_dir = tmp_specs.path().to_path_buf();
1545 let memos_dir = tmp_memos.path().to_path_buf();
1546
1547 let mut engine =
1550 engine_with_two_mems_and_bidirectional_policy(specs_dir.clone(), memos_dir.clone());
1551 let (actor, client) = cli_actor();
1552
1553 let target = engine
1554 .create_entity(
1555 empty_create_args("specs", "Target Spec"),
1556 actor,
1557 Some(&client),
1558 None,
1559 )
1560 .unwrap();
1561 let mut sections: IndexMap<String, String> = IndexMap::new();
1562 sections.insert("claim".to_string(), "the claim".to_string());
1563 sections.insert(
1564 "context".to_string(),
1565 "see [[specs:target-spec]]".to_string(),
1566 );
1567 let _referrer = engine
1568 .create_entity(
1569 CreateEntityArgs {
1570 mem: "memos".to_string(),
1571 title: "Cross Note".to_string(),
1572 entity_type: "memo".to_string(),
1573 sections,
1574 metadata: IndexMap::new(),
1575 relations: Vec::new(),
1579 dry_run: false,
1580 },
1581 actor,
1582 Some(&client),
1583 None,
1584 )
1585 .unwrap();
1586
1587 let mut settings = crate::workspace::WorkspaceSettings::default();
1593 settings.cross_mem_links.insert(
1594 "specs".to_string(),
1595 CrossLinkValue::List(vec!["memos".to_string()]),
1596 );
1597 engine.set_settings(settings);
1599
1600 let err = engine
1601 .rename_entity(
1602 RenameEntityArgs {
1603 id: target.id.clone(),
1604 expected_hash: Some(target.content_hash.clone()),
1605 new_title: "Renamed Spec".to_string(),
1606 },
1607 actor,
1608 Some(&client),
1609 None,
1610 )
1611 .unwrap_err();
1612 match err {
1613 EngineError::RenameBlockedByCrossMemPolicy {
1614 from_mem,
1615 blocked_referrers,
1616 } => {
1617 assert_eq!(from_mem, "specs");
1618 assert_eq!(blocked_referrers.len(), 1);
1619 assert_eq!(blocked_referrers[0].from_mem, "memos");
1620 assert_eq!(blocked_referrers[0].to_mem, "specs");
1621 assert_eq!(blocked_referrers[0].count, 1);
1622 }
1623 other => panic!("expected RenameBlockedByCrossMemPolicy, got {other:?}"),
1624 }
1625 assert!(specs_dir.join(&target.file_path).exists());
1628 assert!(!specs_dir.join("renamed-spec.md").exists());
1629 }
1630
1631 #[test]
1642 fn rename_entity_demotes_to_stub_when_only_readonly_cross_mem_referrers_remain() {
1643 use crate::engine::test_helpers::{archive_mount, build_archive};
1644 use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
1645
1646 let tmp = TempDir::new().unwrap();
1647 let writable_dir = tmp.path().join("writable");
1648 std::fs::create_dir_all(&writable_dir).unwrap();
1649 let writer = FilesystemMemWriter::new(writable_dir.clone());
1650
1651 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";
1655 let archive_path = build_archive(
1656 tmp.path(),
1657 "archive",
1658 &[("archived-source.md", archive_md.as_bytes())],
1659 );
1660
1661 let folder_mount = Mount {
1662 mem: "specs".to_string(),
1663 schema: Some(crate::engine::test_helpers::pin("default")),
1664 storage: MountStorage::Folder {
1665 path: writable_dir.clone(),
1666 },
1667 capability: MountCapability::Write,
1668 lifecycle: MountLifecycle::Eager,
1669 cross_linkable: true,
1670 migration_target: None,
1671 };
1672 let archive_reader = crate::storage::ArchiveBackend::new(archive_path.clone());
1673 let mut engine = Engine::from_mounts(vec![
1674 (folder_mount, Box::new(writer) as Box<dyn MemBackend>),
1675 (
1676 archive_mount("archive", archive_path.clone()),
1677 Box::new(archive_reader) as Box<dyn MemBackend>,
1678 ),
1679 ])
1680 .unwrap();
1681
1682 let (actor, client) = cli_actor();
1683 let target = engine
1684 .create_entity(
1685 empty_create_args("specs", "Target"),
1686 actor,
1687 Some(&client),
1688 None,
1689 )
1690 .unwrap();
1691
1692 let archived_source_id = crate::EntityId::new("archive", "archived-source");
1695 let incoming_pre: Vec<_> = engine
1696 .store()
1697 .incoming(&target.id)
1698 .iter()
1699 .map(|e| e.from.clone())
1700 .collect();
1701 assert!(
1702 incoming_pre.contains(&archived_source_id),
1703 "archive wiki-link must produce an incoming edge on target; got {incoming_pre:?}"
1704 );
1705
1706 let outcome = engine
1709 .rename_entity(
1710 RenameEntityArgs {
1711 id: target.id.clone(),
1712 expected_hash: Some(target.content_hash.clone()),
1713 new_title: "Renamed Target".to_string(),
1714 },
1715 actor,
1716 Some(&client),
1717 None,
1718 )
1719 .unwrap();
1720 assert_eq!(outcome.new_id.to_string(), "specs--renamed-target");
1721
1722 assert!(writable_dir.join(&outcome.new_path).exists());
1724 let demoted = engine
1726 .get_entity(&target.id)
1727 .expect("residual stub must remain at old id");
1728 assert!(demoted.stub, "demoted entity must be flagged as stub");
1729 assert!(demoted.entity_type.is_empty());
1730 let incoming_old: Vec<_> = engine
1733 .store()
1734 .incoming(&target.id)
1735 .iter()
1736 .map(|e| e.from.clone())
1737 .collect();
1738 assert!(
1739 incoming_old.contains(&archived_source_id),
1740 "archive incoming edge must survive demotion at old id; got {incoming_old:?}"
1741 );
1742 let incoming_new: Vec<_> = engine
1745 .store()
1746 .incoming(&outcome.new_id)
1747 .iter()
1748 .map(|e| e.from.clone())
1749 .collect();
1750 assert!(
1751 !incoming_new.contains(&archived_source_id),
1752 "archive must not be wired to new id (markdown still references old slug); got {incoming_new:?}"
1753 );
1754 let referrers = outcome
1756 .warnings
1757 .iter()
1758 .find_map(|w| match w {
1759 WarningHint::ResidualStubForReadOnlyReferrers {
1760 id: warn_id,
1761 referrers,
1762 } => {
1763 assert_eq!(warn_id, &target.id);
1764 Some(referrers.clone())
1765 }
1766 _ => None,
1767 })
1768 .expect("ResidualStubForReadOnlyReferrers warning must surface");
1769 assert_eq!(referrers, vec![archived_source_id]);
1770 }
1771
1772 #[test]
1780 fn rename_entity_rewrites_full_id_form_body_link_on_same_mem_referrer() {
1781 use crate::engine::CreateEntityArgs;
1782 use indexmap::IndexMap;
1783 let tmp = TempDir::new().unwrap();
1784 let mem_dir = tmp.path().to_path_buf();
1785 let writer = FilesystemMemWriter::new(mem_dir.clone());
1786 let mut engine = Engine::from_mounts(vec![(
1787 folder_mount("specs", mem_dir.clone()),
1788 Box::new(writer) as Box<dyn MemBackend>,
1789 )])
1790 .unwrap();
1791 let (actor, client) = cli_actor();
1792
1793 let target = engine
1795 .create_entity(
1796 empty_create_args("specs", "Target Spec"),
1797 actor,
1798 Some(&client),
1799 None,
1800 )
1801 .unwrap();
1802 assert_eq!(target.id.to_string(), "specs--target-spec");
1803
1804 let mut sections: IndexMap<String, String> = IndexMap::new();
1809 sections.insert("identity".to_string(), "referrer identity".to_string());
1810 sections.insert(
1811 "purpose".to_string(),
1812 "see also [[specs--target-spec]] for context".to_string(),
1813 );
1814 let referrer = engine
1815 .create_entity(
1816 CreateEntityArgs {
1817 mem: "specs".to_string(),
1818 title: "Referrer Full".to_string(),
1819 entity_type: "spec".to_string(),
1820 sections,
1821 metadata: IndexMap::new(),
1822 relations: Vec::new(),
1823 dry_run: false,
1824 },
1825 actor,
1826 Some(&client),
1827 None,
1828 )
1829 .unwrap();
1830
1831 let renamed = engine
1832 .rename_entity(
1833 RenameEntityArgs {
1834 id: target.id.clone(),
1835 expected_hash: Some(target.content_hash.clone()),
1836 new_title: "Renamed Spec".to_string(),
1837 },
1838 actor,
1839 Some(&client),
1840 None,
1841 )
1842 .unwrap();
1843 assert_eq!(renamed.new_id.to_string(), "specs--renamed-spec");
1844
1845 let referrer_path = mem_dir.join(&referrer.file_path);
1849 let body = std::fs::read_to_string(&referrer_path).unwrap();
1850 assert!(
1851 body.contains("[[specs--renamed-spec]]"),
1852 "expected full-id form retargeted to new slug, got:\n{body}"
1853 );
1854 assert!(
1855 !body.contains("target-spec"),
1856 "old slug must not survive in any form, got:\n{body}"
1857 );
1858 }
1859
1860 #[test]
1861 fn rename_entity_rejects_collision_with_existing_id() {
1862 let tmp = TempDir::new().unwrap();
1863 let (mut engine, first) = engine_with_seed(&tmp, "First");
1864 let (actor, client) = cli_actor();
1865 let _ = engine
1866 .create_entity(
1867 empty_create_args("specs", "Second"),
1868 actor,
1869 Some(&client),
1870 None,
1871 )
1872 .unwrap();
1873 let err = engine
1875 .rename_entity(
1876 RenameEntityArgs {
1877 id: first.id.clone(),
1878 expected_hash: Some(first.content_hash.clone()),
1879 new_title: "Second".to_string(),
1880 },
1881 actor,
1882 Some(&client),
1883 None,
1884 )
1885 .unwrap_err();
1886 assert!(matches!(err, EngineError::AlreadyExists { id } if id == "specs--second"));
1887 }
1888
1889 #[test]
1896 fn rename_propagation_gate_checks_actual_edge_direction() {
1897 use crate::engine::error::BlockedReferrer;
1898 use crate::engine::{CreateEntityArgs, RelateEntityArgs};
1899 use indexmap::IndexMap;
1900 use memstead_schema::workspace_config::CrossLinkValue;
1901
1902 let tmp_test = TempDir::new().unwrap();
1903 let tmp_other = TempDir::new().unwrap();
1904 let test_dir = tmp_test.path().to_path_buf();
1905 let other_dir = tmp_other.path().to_path_buf();
1906
1907 let writer_test = FilesystemMemWriter::new(test_dir.clone());
1911 let writer_other = FilesystemMemWriter::new(other_dir.clone());
1912 let mut engine = Engine::from_mounts(vec![
1913 (
1914 folder_mount("test", test_dir.clone()),
1915 Box::new(writer_test) as Box<dyn MemBackend>,
1916 ),
1917 (
1918 folder_mount("other", other_dir.clone()),
1919 Box::new(writer_other) as Box<dyn MemBackend>,
1920 ),
1921 ])
1922 .unwrap();
1923 let (actor, client) = cli_actor();
1924
1925 let mut settings = crate::workspace::WorkspaceSettings::default();
1928 settings.cross_mem_links.insert(
1929 "test".to_string(),
1930 CrossLinkValue::List(vec!["other".to_string()]),
1931 );
1932 engine.set_settings(settings);
1933
1934 let target = engine
1935 .create_entity(
1936 empty_create_args("other", "Target"),
1937 actor,
1938 Some(&client),
1939 None,
1940 )
1941 .unwrap();
1942 let mut src_sections: IndexMap<String, String> = IndexMap::new();
1943 src_sections.insert("identity".to_string(), "source identity".to_string());
1944 src_sections.insert("purpose".to_string(), "source purpose".to_string());
1945 let src = engine
1946 .create_entity(
1947 CreateEntityArgs {
1948 mem: "test".to_string(),
1949 title: "Src".to_string(),
1950 entity_type: "spec".to_string(),
1951 sections: src_sections,
1952 metadata: IndexMap::new(),
1953 relations: Vec::new(),
1954 dry_run: false,
1955 },
1956 actor,
1957 Some(&client),
1958 None,
1959 )
1960 .unwrap();
1961 let src = engine
1962 .relate_entity(
1963 RelateEntityArgs {
1964 source: src.id.clone(),
1965 rel_type: "IMPLEMENTS".to_string(),
1966 target: target.id.clone(),
1967 expected_hash: Some(src.content_hash.clone()),
1968 remove: false,
1969 description: None,
1970 },
1971 actor,
1972 Some(&client),
1973 None,
1974 )
1975 .unwrap();
1976 let _ = src; engine.set_settings(crate::workspace::WorkspaceSettings::default());
1983
1984 let err = engine
1985 .rename_entity(
1986 RenameEntityArgs {
1987 id: target.id.clone(),
1988 expected_hash: Some(target.content_hash.clone()),
1989 new_title: "Renamed".to_string(),
1990 },
1991 actor,
1992 Some(&client),
1993 None,
1994 )
1995 .unwrap_err();
1996 match err {
1997 EngineError::RenameBlockedByCrossMemPolicy {
1998 from_mem,
1999 blocked_referrers,
2000 } => {
2001 assert_eq!(from_mem, "other");
2002 assert_eq!(
2003 blocked_referrers,
2004 vec![BlockedReferrer {
2005 from_mem: "test".to_string(),
2006 to_mem: "other".to_string(),
2007 count: 1,
2008 }],
2009 "blocked_referrers must name the actual edge direction (test → other)",
2010 );
2011 }
2012 other => panic!("expected RenameBlockedByCrossMemPolicy, got {other:?}"),
2013 }
2014 assert!(other_dir.join(&target.file_path).exists());
2017 assert!(!other_dir.join("renamed.md").exists());
2018 }
2019
2020 #[test]
2024 fn rename_propagation_succeeds_when_actual_edge_direction_granted() {
2025 use crate::engine::{CreateEntityArgs, RelateEntityArgs};
2026 use indexmap::IndexMap;
2027 use memstead_schema::workspace_config::CrossLinkValue;
2028
2029 let tmp_test = TempDir::new().unwrap();
2030 let tmp_other = TempDir::new().unwrap();
2031 let test_dir = tmp_test.path().to_path_buf();
2032 let other_dir = tmp_other.path().to_path_buf();
2033
2034 let writer_test = FilesystemMemWriter::new(test_dir.clone());
2035 let writer_other = FilesystemMemWriter::new(other_dir.clone());
2036 let mut engine = Engine::from_mounts(vec![
2037 (
2038 folder_mount("test", test_dir),
2039 Box::new(writer_test) as Box<dyn MemBackend>,
2040 ),
2041 (
2042 folder_mount("other", other_dir),
2043 Box::new(writer_other) as Box<dyn MemBackend>,
2044 ),
2045 ])
2046 .unwrap();
2047 let (actor, client) = cli_actor();
2048
2049 let mut settings = crate::workspace::WorkspaceSettings::default();
2050 settings.cross_mem_links.insert(
2051 "test".to_string(),
2052 CrossLinkValue::List(vec!["other".to_string()]),
2053 );
2054 engine.set_settings(settings);
2055
2056 let target = engine
2057 .create_entity(
2058 empty_create_args("other", "Target"),
2059 actor,
2060 Some(&client),
2061 None,
2062 )
2063 .unwrap();
2064 let mut src_sections: IndexMap<String, String> = IndexMap::new();
2065 src_sections.insert("identity".to_string(), "source identity".to_string());
2066 src_sections.insert("purpose".to_string(), "source purpose".to_string());
2067 let src = engine
2068 .create_entity(
2069 CreateEntityArgs {
2070 mem: "test".to_string(),
2071 title: "Src".to_string(),
2072 entity_type: "spec".to_string(),
2073 sections: src_sections,
2074 metadata: IndexMap::new(),
2075 relations: Vec::new(),
2076 dry_run: false,
2077 },
2078 actor,
2079 Some(&client),
2080 None,
2081 )
2082 .unwrap();
2083 let _ = engine
2084 .relate_entity(
2085 RelateEntityArgs {
2086 source: src.id.clone(),
2087 rel_type: "IMPLEMENTS".to_string(),
2088 target: target.id.clone(),
2089 expected_hash: Some(src.content_hash.clone()),
2090 remove: false,
2091 description: None,
2092 },
2093 actor,
2094 Some(&client),
2095 None,
2096 )
2097 .unwrap();
2098
2099 let outcome = engine
2102 .rename_entity(
2103 RenameEntityArgs {
2104 id: target.id.clone(),
2105 expected_hash: Some(target.content_hash.clone()),
2106 new_title: "Renamed".to_string(),
2107 },
2108 actor,
2109 Some(&client),
2110 None,
2111 )
2112 .unwrap();
2113 assert_ne!(outcome.old_id, outcome.new_id);
2114 let renamed = engine
2115 .get_entity(&outcome.new_id)
2116 .expect("renamed entity persists");
2117 assert_eq!(renamed.title, "Renamed");
2118 let updated_src = engine.get_entity(&src.id).expect("source persists");
2120 assert!(
2121 updated_src
2122 .relationships
2123 .iter()
2124 .any(|r| r.rel_type == "IMPLEMENTS" && r.target == outcome.new_id),
2125 "referrer's IMPLEMENTS edge must point at the new id after rewrite"
2126 );
2127 }
2128
2129 #[test]
2133 fn rename_with_no_cross_mem_referrers_succeeds_regardless_of_policy() {
2134 let tmp = TempDir::new().unwrap();
2135 let mem_dir = tmp.path().to_path_buf();
2136 let writer = FilesystemMemWriter::new(mem_dir.clone());
2137 let mut engine = Engine::from_mounts(vec![(
2138 folder_mount("specs", mem_dir),
2139 Box::new(writer) as Box<dyn MemBackend>,
2140 )])
2141 .unwrap();
2142 let (actor, client) = cli_actor();
2143 let target = engine
2147 .create_entity(
2148 empty_create_args("specs", "Target"),
2149 actor,
2150 Some(&client),
2151 None,
2152 )
2153 .unwrap();
2154 let outcome = engine
2155 .rename_entity(
2156 RenameEntityArgs {
2157 id: target.id.clone(),
2158 expected_hash: Some(target.content_hash.clone()),
2159 new_title: "Renamed Target".to_string(),
2160 },
2161 actor,
2162 Some(&client),
2163 None,
2164 )
2165 .unwrap();
2166 assert_ne!(outcome.old_id, outcome.new_id);
2167 }
2168}