1use std::path::Path;
5
6use crate::engine_fallback_type;
7use crate::entity::EntityId;
8use crate::entity::id::validate_and_derive_slug;
9use crate::entity::parser::parse_markdown;
10use crate::entity::store_builder::push_entities_into_store;
11use crate::ops::WarningHint;
12use crate::provenance::{Provenance, ProvenanceKind};
13use crate::vcs::{Actor, ClientId, CommitContext};
14use crate::workspace::MountCapability;
15
16use super::super::{Engine, EngineError, RenameEntityArgs, RenameEntityOutcome};
17use super::{make_stub, unknown_type_error};
18
19impl Engine {
20 pub fn rename_entity_with_ctx(
23 &mut self,
24 old_id: &EntityId,
25 new_title: &str,
26 expected_hash: &str,
27 ctx: &CommitContext<'_>,
28 ) -> Result<RenameEntityOutcome, EngineError> {
29 let args = RenameEntityArgs {
30 id: old_id.clone(),
31 expected_hash: Some(expected_hash.to_string()),
32 new_title: new_title.to_string(),
33 };
34 self.rename_entity(args, ctx.actor, ctx.client.as_ref(), ctx.note.as_deref())
35 }
36
37 pub fn rename_entity(
54 &mut self,
55 args: RenameEntityArgs,
56 actor: Actor,
57 client: Option<&ClientId>,
58 note: Option<&str>,
59 ) -> Result<RenameEntityOutcome, EngineError> {
60 let id = &args.id;
61 let mem = id.mem().to_string();
62
63 let mount_idx = self
64 .mounts
65 .iter()
66 .position(|m| m.mount.mem == mem)
67 .ok_or_else(|| self.unknown_mem_error(&mem))?;
68 if self.mounts[mount_idx].mount.capability != MountCapability::Write {
69 return Err(EngineError::ReadOnlyMount(mem));
70 }
71
72 let mut drift_warnings = self.reload_if_stale(Some(&mem));
77
78 let entity = self
79 .store
80 .get(id)
81 .ok_or_else(|| EngineError::NotFound { id: id.to_string() })?;
82
83 if entity.stub {
89 return Err(EngineError::StubNotRenamable { id: id.to_string() });
90 }
91
92 if let Some(expected) = args.expected_hash.as_deref()
93 && entity.content_hash != expected
94 {
95 return Err(EngineError::HashMismatch {
96 id: id.to_string(),
97 current: entity.content_hash.clone(),
98 is_stub: entity.stub,
99 });
100 }
101
102 let derivation = validate_and_derive_slug(&args.new_title)?;
103 let new_slug = derivation.slug.clone();
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 write_id: 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 let Some(existing) = self.store.get(&new_id) {
127 return Err(EngineError::AlreadyExists {
128 id: new_id.to_string(),
129 existing_title: existing.title.clone(),
130 existing_is_stub: existing.stub,
131 });
132 }
133
134 let schema = self
135 .schemas
136 .get(&mem)
137 .expect("schema present for every registered mount");
138 let type_def = schema
139 .get_type(&entity.entity_type)
140 .ok_or_else(|| unknown_type_error(schema, &entity.entity_type))?;
141
142 let old_file_path = entity.file_path.clone();
143 let new_file_path = format!("{new_slug}.md");
144
145 let mut next = entity.clone();
146 next.id = new_id.clone();
147 next.title = args.new_title.clone();
148 next.file_path = new_file_path.clone();
149
150 for rel in next.relationships.iter_mut() {
157 if rel.target == *id {
158 rel.target = new_id.clone();
159 }
160 }
161
162 let old_slug = id.name().to_string();
177 let new_slug_owned = new_slug.clone();
178 for body in next.sections.values_mut() {
179 let (rewritten, count) = crate::entity::wikilink_rewrite::rewrite_bare_slug(
180 body,
181 &old_slug,
182 &new_slug_owned,
183 );
184 if count > 0 {
185 *body = rewritten;
186 }
187 let (rewritten, count) = crate::entity::wikilink_rewrite::rewrite_cross_mem_slug(
188 body,
189 &mem,
190 &old_slug,
191 &new_slug_owned,
192 );
193 if count > 0 {
194 *body = rewritten;
195 }
196 }
197
198 let today = self.now_iso();
206 super::auto_stamp_timestamps(&mut next, type_def.as_ref(), &today);
207
208 let markdown = super::render_for_write(&next, type_def.as_ref())?;
209
210 let mut seen: std::collections::HashSet<EntityId> = std::collections::HashSet::new();
222 let mut same_mem_ids: Vec<EntityId> = Vec::new();
223 let mut cross_mem_ids: Vec<EntityId> = Vec::new();
224 for in_edge in self.store.incoming(id) {
225 if in_edge.from == *id || !seen.insert(in_edge.from.clone()) {
226 continue;
227 }
228 if in_edge.from.mem() == mem {
229 same_mem_ids.push(in_edge.from.clone());
230 } else {
231 cross_mem_ids.push(in_edge.from.clone());
232 }
233 }
234
235 let mut cross_mem_write_ids: Vec<EntityId> = Vec::new();
243 let mut readonly_referrers: Vec<EntityId> = Vec::new();
244 for from_id in cross_mem_ids {
245 match self
246 .mount(from_id.mem())
247 .map(|m| m.capability)
248 .unwrap_or(MountCapability::Write)
249 {
250 MountCapability::Write => cross_mem_write_ids.push(from_id),
251 MountCapability::ReadOnly => readonly_referrers.push(from_id),
252 }
253 }
254 readonly_referrers.sort_by_key(|a| a.to_string());
255
256 let mut blocked_counts: std::collections::BTreeMap<String, usize> =
266 std::collections::BTreeMap::new();
267 for from_id in &cross_mem_write_ids {
268 let peer_mem = from_id.mem().to_string();
269 if !self.cross_mem_link_allowed(&peer_mem, &mem) {
270 *blocked_counts.entry(peer_mem).or_insert(0) += 1;
271 }
272 }
273 if !blocked_counts.is_empty() {
274 let blocked_referrers: Vec<crate::engine::error::BlockedReferrer> = blocked_counts
275 .into_iter()
276 .map(|(peer_mem, count)| crate::engine::error::BlockedReferrer {
277 from_mem: peer_mem,
278 to_mem: mem.clone(),
279 count,
280 })
281 .collect();
282 return Err(EngineError::RenameBlockedByCrossMemPolicy {
283 from_mem: mem.clone(),
284 blocked_referrers,
285 });
286 }
287
288 same_mem_ids.sort_by_key(|a| a.to_string());
292 cross_mem_write_ids.sort_by_key(|a| a.to_string());
293
294 let mut same_mem_writes: Vec<(
300 String,
301 String,
302 std::sync::Arc<memstead_schema::TypeDefinition>,
303 )> = Vec::with_capacity(same_mem_ids.len());
304 for from_id in &same_mem_ids {
305 let Some(referrer) = self.store.get(from_id) else {
306 continue;
307 };
308 if referrer.stub {
309 continue;
310 }
311 let referrer_type_def = schema
312 .get_type(&referrer.entity_type)
313 .ok_or_else(|| unknown_type_error(schema, &referrer.entity_type))?;
314 let mut next_ref = referrer.clone();
315 for rel in next_ref.relationships.iter_mut() {
316 if rel.target == *id {
317 rel.target = new_id.clone();
318 }
319 }
320 for body in next_ref.sections.values_mut() {
321 let (rewritten, count) = crate::entity::wikilink_rewrite::rewrite_bare_slug(
322 body,
323 &old_slug,
324 &new_slug_owned,
325 );
326 if count > 0 {
327 *body = rewritten;
328 }
329 let (rewritten, count) = crate::entity::wikilink_rewrite::rewrite_cross_mem_slug(
334 body,
335 &mem,
336 &old_slug,
337 &new_slug_owned,
338 );
339 if count > 0 {
340 *body = rewritten;
341 }
342 }
343 let ref_markdown = super::render_for_write(&next_ref, referrer_type_def.as_ref())?;
354 same_mem_writes.push((ref_markdown, next_ref.file_path.clone(), referrer_type_def));
355 }
356
357 struct PeerMemPlan {
363 mount_idx: usize,
364 mem: String,
365 writes: Vec<(
366 String,
367 String,
368 std::sync::Arc<memstead_schema::TypeDefinition>,
369 )>,
370 }
371 let mut peer_plans: std::collections::BTreeMap<String, PeerMemPlan> =
372 std::collections::BTreeMap::new();
373 for from_id in &cross_mem_write_ids {
374 let peer_mem = from_id.mem().to_string();
375 let peer_mount_idx = self
376 .mounts
377 .iter()
378 .position(|m| m.mount.mem == peer_mem)
379 .expect("peer mount present for collected referrer id");
380 let peer_schema = self
381 .schemas
382 .get(&peer_mem)
383 .expect("schema present for every registered mount");
384
385 let Some(referrer) = self.store.get(from_id) else {
386 continue;
387 };
388 if referrer.stub {
389 continue;
390 }
391 let referrer_type_def = peer_schema
392 .get_type(&referrer.entity_type)
393 .ok_or_else(|| unknown_type_error(peer_schema, &referrer.entity_type))?;
394 let mut next_ref = referrer.clone();
395 for rel in next_ref.relationships.iter_mut() {
396 if rel.target == *id {
397 rel.target = new_id.clone();
398 }
399 }
400 for body in next_ref.sections.values_mut() {
406 let (rewritten, count) = crate::entity::wikilink_rewrite::rewrite_cross_mem_slug(
407 body,
408 &mem,
409 &old_slug,
410 &new_slug_owned,
411 );
412 if count > 0 {
413 *body = rewritten;
414 }
415 }
416 let ref_markdown = super::render_for_write(&next_ref, referrer_type_def.as_ref())?;
422
423 peer_plans
424 .entry(peer_mem.clone())
425 .or_insert_with(|| PeerMemPlan {
426 mount_idx: peer_mount_idx,
427 mem: peer_mem.clone(),
428 writes: Vec::new(),
429 })
430 .writes
431 .push((ref_markdown, next_ref.file_path.clone(), referrer_type_def));
432 }
433
434 let logical_op_id = crate::provenance::mint_logical_operation_id();
442
443 let backend = self.mounts[mount_idx].backend.as_ref();
444 backend.write_entity(Path::new(&new_file_path), markdown.as_bytes())?;
445 backend.delete_entity(Path::new(&old_file_path))?;
446 for (ref_markdown, ref_file_path, _) in &same_mem_writes {
447 backend.write_entity(Path::new(ref_file_path), ref_markdown.as_bytes())?;
448 }
449 super::stage_anchors_rename(backend, id, &new_id)?;
454 let commit_subject = format!("memstead: rename {} → {new_id}", id);
455 let ctx = CommitContext {
456 actor,
457 client: client.cloned(),
458 tool: Some("rename_entity"),
459 note: note.map(String::from),
460 role: self.current_role,
461 logical_operation_id: Some(logical_op_id.as_str()),
462 entity_ids: None,
463 };
464 let write_id = backend.commit(&commit_subject, &ctx)?;
465
466 backend.append_provenance(
467 &Provenance::new(
468 std::time::SystemTime::now(),
469 ProvenanceKind::Rename,
470 Some(new_id.to_string()),
471 actor,
472 client.cloned(),
473 note.map(String::from),
474 )
475 .with_role(self.current_role)
476 .with_logical_operation_id(logical_op_id.clone()),
477 )?;
478
479 self.record_self_write(mount_idx, &write_id);
480 let stamp_warnings = self.stamp_mutation_versions(mount_idx);
481 let mut peer_stamp_warnings: Vec<WarningHint> = Vec::new();
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_write_id = 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_write_id);
557 peer_stamp_warnings.extend(self.stamp_mutation_versions(plan.mount_idx));
561 committed_mems.push(plan.mem.clone());
562 }
563
564 let parse_result = parse_markdown(&markdown, &new_file_path, type_def.as_ref(), &mem)
566 .map_err(|e| EngineError::ParseAfterWrite(e.to_string()))?;
567 let content_hash = parse_result.entity.content_hash.clone();
568
569 let mut parse_results = vec![parse_result];
570 for (ref_markdown, ref_file_path, ref_type_def) in &same_mem_writes {
571 let pr = parse_markdown(ref_markdown, ref_file_path, ref_type_def.as_ref(), &mem)
572 .map_err(|e| EngineError::ParseAfterWrite(e.to_string()))?;
573 parse_results.push(pr);
574 }
575 for plan in peer_plans.values() {
576 for (ref_markdown, ref_file_path, ref_type_def) in &plan.writes {
577 let pr = parse_markdown(
578 ref_markdown,
579 ref_file_path,
580 ref_type_def.as_ref(),
581 &plan.mem,
582 )
583 .map_err(|e| EngineError::ParseAfterWrite(e.to_string()))?;
584 parse_results.push(pr);
585 }
586 }
587
588 let mut outcome_warnings: Vec<WarningHint> = Vec::new();
601 outcome_warnings.extend(stamp_warnings);
602 outcome_warnings.extend(peer_stamp_warnings);
603 outcome_warnings.append(&mut drift_warnings);
605 if !derivation.dropped_chars.is_empty() {
608 outcome_warnings.push(WarningHint::TitleCharsDroppedFromSlug {
609 title: args.new_title.trim().to_string(),
610 dropped_chars: derivation.dropped_chars.clone(),
611 slug: new_slug.clone(),
612 });
613 }
614 if readonly_referrers.is_empty() {
615 self.store.remove(id);
616 } else {
617 self.store.remove_edges_from(id);
622 self.store.upsert(
623 id.clone(),
624 make_stub(
625 id,
626 crate::entity::StubKind::Residual {
627 since_commit: write_id.clone(),
628 readonly_referrers: readonly_referrers.clone(),
629 },
630 ),
631 );
632 outcome_warnings.push(WarningHint::ResidualStubForReadOnlyReferrers {
633 id: id.clone(),
634 referrers: readonly_referrers,
635 });
636 }
637
638 let fallback = engine_fallback_type();
639 let mut touched: Vec<crate::EntityId> = parse_results
644 .iter()
645 .map(|pr| pr.entity.id.clone())
646 .collect();
647 touched.push(id.clone());
648 push_entities_into_store(&mut self.store, parse_results, fallback.as_ref(), None);
649 crate::entity::store_builder::remap_alias_target_edge_sources(
650 &mut self.store,
651 &self.schemas,
652 );
653
654 self.invalidate_communities();
655 self.maintain_search_indexes(&touched);
656
657 if let Some(w) = self.note_missing_warning("rename_entity", note) {
662 outcome_warnings.push(w);
663 }
664
665 Ok(RenameEntityOutcome {
666 old_id: id.clone(),
667 new_id,
668 old_path: old_file_path,
669 new_path: new_file_path,
670 content_hash,
671 write_id,
672 warnings: outcome_warnings,
673 })
674 }
675}
676
677#[cfg(test)]
678mod tests {
679 use std::path::PathBuf;
680
681 use tempfile::TempDir;
682
683 use crate::backend::MemBackend;
684 use crate::engine::test_helpers::*;
685 use crate::engine::{Engine, EngineError, RenameEntityArgs};
686 use crate::ops::WarningHint;
687 use crate::storage::FilesystemMemWriter;
688
689 #[test]
690 fn rename_moves_entity_anchors_to_new_id() {
691 let tmp = TempDir::new().unwrap();
692 let mem_dir = tmp.path().to_path_buf();
693 let writer = FilesystemMemWriter::new(mem_dir.clone());
694 let mut engine = Engine::from_mounts(vec![(
695 folder_mount("specs", mem_dir.clone()),
696 Box::new(writer) as Box<dyn MemBackend>,
697 )])
698 .unwrap();
699 let (actor, client) = cli_actor();
700 let mut args = empty_create_args("specs", "Old Anchored");
701 args.anchors = vec![crate::anchor::AnchorInput {
702 artifact: Some("src/lib.rs".into()),
703 grain: Some("file".into()),
704 class: Some("anchored".into()),
705 hash: Some("h1".into()),
706 hash_stability: Some("stable".into()),
707 ..Default::default()
708 }];
709 let seeded = engine
710 .create_entity(args, actor, Some(&client), None)
711 .unwrap();
712 let old_id = seeded.id.clone();
713
714 let outcome = engine
715 .rename_entity(
716 RenameEntityArgs {
717 id: old_id.clone(),
718 expected_hash: Some(seeded.content_hash.clone()),
719 new_title: "New Anchored".to_string(),
720 },
721 actor,
722 Some(&client),
723 None,
724 )
725 .unwrap();
726
727 assert!(engine.entity_anchors(&old_id).is_empty());
729 assert_eq!(engine.entity_anchors(&outcome.new_id).len(), 1);
730 assert_eq!(
731 engine.anchors_referencing_artifact("src/lib.rs"),
732 vec![(
733 outcome.new_id.clone(),
734 engine.entity_anchors(&outcome.new_id)[0].clone()
735 )]
736 );
737 }
738
739 #[test]
740 fn rename_entity_renames_file_and_id_persists_across_restart() {
741 let tmp = TempDir::new().unwrap();
742 let mem_dir = tmp.path().to_path_buf();
743
744 let (old_id, new_id, new_file) = {
745 let writer = FilesystemMemWriter::new(mem_dir.clone());
746 let mut engine = Engine::from_mounts(vec![(
747 folder_mount("specs", mem_dir.clone()),
748 Box::new(writer) as Box<dyn MemBackend>,
749 )])
750 .unwrap();
751 let (actor, client) = cli_actor();
752 let seeded = engine
753 .create_entity(
754 empty_create_args("specs", "Old Name"),
755 actor,
756 Some(&client),
757 None,
758 )
759 .unwrap();
760 let outcome = engine
761 .rename_entity(
762 RenameEntityArgs {
763 id: seeded.id.clone(),
764 expected_hash: Some(seeded.content_hash.clone()),
765 new_title: "New Name".to_string(),
766 },
767 actor,
768 Some(&client),
769 None,
770 )
771 .unwrap();
772 assert_eq!(outcome.old_id.to_string(), "specs--old-name");
773 assert_eq!(outcome.new_id.to_string(), "specs--new-name");
774 assert_eq!(outcome.new_path, "new-name.md");
775 assert!(!mem_dir.join(&outcome.old_path).exists());
777 assert!(mem_dir.join(&outcome.new_path).exists());
778 (outcome.old_id, outcome.new_id, outcome.new_path)
779 };
780
781 let writer2 = FilesystemMemWriter::new(mem_dir.clone());
783 let engine2 = Engine::from_mounts(vec![(
784 folder_mount("specs", mem_dir),
785 Box::new(writer2) as Box<dyn MemBackend>,
786 )])
787 .unwrap();
788 assert!(engine2.get_entity(&old_id).is_none());
789 let new_entity = engine2.get_entity(&new_id).expect("new id must persist");
790 assert_eq!(new_entity.title, "New Name");
791 assert_eq!(new_entity.file_path, new_file);
792 }
793
794 #[test]
795 fn rename_entity_returns_typed_warning_on_slug_noop() {
796 let tmp = TempDir::new().unwrap();
797 let (mut engine, seeded) = engine_with_seed(&tmp, "Same Slug");
798 let (actor, client) = cli_actor();
799 let outcome = engine
800 .rename_entity(
801 RenameEntityArgs {
802 id: seeded.id.clone(),
803 expected_hash: Some(seeded.content_hash.clone()),
804 new_title: "Same Slug".to_string(), },
806 actor,
807 Some(&client),
808 None,
809 )
810 .unwrap();
811 assert_eq!(outcome.old_id, outcome.new_id);
816 assert_eq!(outcome.old_path, outcome.new_path);
817 assert!(outcome.write_id.is_empty());
818 assert_eq!(outcome.warnings.len(), 1);
819 assert!(matches!(
820 outcome.warnings[0],
821 WarningHint::TitleNormalizedToSlugNoop { .. }
822 ));
823 }
824
825 #[test]
826 fn rename_entity_returns_write_id_on_real_rename() {
827 let tmp = TempDir::new().unwrap();
828 let (mut engine, seeded) = engine_with_seed(&tmp, "Old Name");
829 let (actor, client) = cli_actor();
830 let outcome = engine
831 .rename_entity(
832 RenameEntityArgs {
833 id: seeded.id.clone(),
834 expected_hash: Some(seeded.content_hash.clone()),
835 new_title: "Brand New Name".to_string(),
836 },
837 actor,
838 Some(&client),
839 None,
840 )
841 .unwrap();
842 assert_ne!(outcome.old_id, outcome.new_id);
845 assert!(
846 !outcome.write_id.is_empty(),
847 "write_id must be populated on a real rename"
848 );
849 assert!(outcome.warnings.is_empty());
850 }
851
852 #[test]
853 fn rename_entity_rewrites_self_references_in_body_and_relationships() {
854 use crate::entity::EntityId;
855 use indexmap::IndexMap;
856
857 let tmp = TempDir::new().unwrap();
858 let mem_dir = tmp.path().to_path_buf();
859 let writer = FilesystemMemWriter::new(mem_dir.clone());
860 let mut engine = Engine::from_mounts(vec![(
861 folder_mount("specs", mem_dir.clone()),
862 Box::new(writer) as Box<dyn MemBackend>,
863 )])
864 .unwrap();
865 let (actor, client) = cli_actor();
866
867 let mut sections: IndexMap<String, String> = IndexMap::new();
873 sections.insert("identity".to_string(), "the seed identity".to_string());
874 sections.insert(
875 "purpose".to_string(),
876 "see also [[old-name]] for prior context".to_string(),
877 );
878 let seeded = engine
885 .create_entity(
886 crate::engine::CreateEntityArgs {
887 anchors: Vec::new(),
888 mem: "specs".to_string(),
889 title: "Old Name".to_string(),
890 entity_type: "spec".to_string(),
891 sections,
892 metadata: IndexMap::new(),
893 relations: Vec::new(),
894 dry_run: false,
895 },
896 actor,
897 Some(&client),
898 None,
899 )
900 .unwrap();
901 assert_eq!(seeded.id.to_string(), "specs--old-name");
902 let related = seeded.clone();
903
904 let outcome = engine
905 .rename_entity(
906 RenameEntityArgs {
907 id: seeded.id.clone(),
908 expected_hash: Some(related.content_hash.clone()),
909 new_title: "Brand New Name".to_string(),
910 },
911 actor,
912 Some(&client),
913 None,
914 )
915 .unwrap();
916 assert_eq!(outcome.new_id.to_string(), "specs--brand-new-name");
917
918 let new_bytes = std::fs::read_to_string(mem_dir.join(&outcome.new_path)).unwrap();
921 assert!(
922 new_bytes.contains("[[brand-new-name]]"),
923 "expected new slug in body, got:\n{new_bytes}"
924 );
925 assert!(
926 !new_bytes.contains("[[old-name]]"),
927 "old slug must not survive in the rewritten file, got:\n{new_bytes}"
928 );
929
930 let in_mem = engine.get_entity(&outcome.new_id).unwrap();
933 assert!(
934 in_mem
935 .sections
936 .get("purpose")
937 .map(|s| s.contains("[[brand-new-name]]"))
938 .unwrap_or(false),
939 "section body must be rewritten in-memory; got {:?}",
940 in_mem.sections.get("purpose")
941 );
942 let new_self_target = EntityId::new("specs", "brand-new-name");
946 assert!(
947 in_mem
948 .relationships
949 .iter()
950 .all(|r| r.target != seeded.id && r.target != new_self_target),
951 "a self-referential body link must produce no self-relation (F11), got: {:?}",
952 in_mem.relationships
953 );
954 }
955
956 #[test]
965 fn rename_preserves_referrer_last_modified_but_rewrites_link() {
966 let tmp = TempDir::new().unwrap();
967 let mem_dir = tmp.path().to_path_buf();
968
969 std::fs::write(
970 mem_dir.join("target.md"),
971 "---\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",
972 )
973 .unwrap();
974 std::fs::write(
975 mem_dir.join("referrer.md"),
976 "---\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",
977 )
978 .unwrap();
979
980 let writer = FilesystemMemWriter::new(mem_dir.clone());
981 let mut engine = Engine::from_mounts(vec![(
982 folder_mount("specs", mem_dir.clone()),
983 Box::new(writer) as Box<dyn MemBackend>,
984 )])
985 .unwrap();
986 let (actor, client) = cli_actor();
987
988 let target_id = crate::entity::EntityId::new("specs", "target");
989 let target_hash = engine
990 .store()
991 .get(&target_id)
992 .expect("target loaded from disk")
993 .content_hash
994 .clone();
995
996 engine
997 .rename_entity(
998 RenameEntityArgs {
999 id: target_id,
1000 expected_hash: Some(target_hash),
1001 new_title: "Target Renamed".to_string(),
1002 },
1003 actor,
1004 Some(&client),
1005 None,
1006 )
1007 .expect("rename succeeds");
1008
1009 let referrer_md = std::fs::read_to_string(mem_dir.join("referrer.md")).unwrap();
1010 assert!(
1012 referrer_md.contains("last_modified: 2020-01-01"),
1013 "referrer's last_modified must be preserved across a rename-driven slug rewrite; got:\n{referrer_md}"
1014 );
1015 assert!(
1017 referrer_md.contains("[[target-renamed]]"),
1018 "referrer's body wiki-link must be rewritten to the new slug; got:\n{referrer_md}"
1019 );
1020 assert!(
1021 !referrer_md.contains("[[target]]"),
1022 "old slug must not survive in the referrer body; got:\n{referrer_md}"
1023 );
1024 }
1025
1026 #[test]
1027 fn rename_entity_rewrites_same_mem_referrers_atomically() {
1028 use crate::engine::CreateEntityArgs;
1029 use crate::entity::EntityId;
1030 use indexmap::IndexMap;
1031
1032 let tmp = TempDir::new().unwrap();
1033 let mem_dir = tmp.path().to_path_buf();
1034 let writer = FilesystemMemWriter::new(mem_dir.clone());
1035 let mut engine = Engine::from_mounts(vec![(
1036 folder_mount("specs", mem_dir.clone()),
1037 Box::new(writer) as Box<dyn MemBackend>,
1038 )])
1039 .unwrap();
1040 let (actor, client) = cli_actor();
1041
1042 let target = engine
1044 .create_entity(
1045 empty_create_args("specs", "Target Spec"),
1046 actor,
1047 Some(&client),
1048 None,
1049 )
1050 .unwrap();
1051 assert_eq!(target.id.to_string(), "specs--target-spec");
1052
1053 let mut sections_a: IndexMap<String, String> = IndexMap::new();
1056 sections_a.insert(
1057 "identity".to_string(),
1058 "referrer alpha identity".to_string(),
1059 );
1060 sections_a.insert(
1061 "purpose".to_string(),
1062 "rationale relies on [[target-spec]] for context".to_string(),
1063 );
1064 let referrer_a = engine
1065 .create_entity(
1066 CreateEntityArgs {
1067 anchors: Vec::new(),
1068 mem: "specs".to_string(),
1069 title: "Referrer Alpha".to_string(),
1070 entity_type: "spec".to_string(),
1071 sections: sections_a,
1072 metadata: IndexMap::new(),
1073 relations: Vec::new(),
1077 dry_run: false,
1078 },
1079 actor,
1080 Some(&client),
1081 None,
1082 )
1083 .unwrap();
1084
1085 let mut sections_b: IndexMap<String, String> = IndexMap::new();
1088 sections_b.insert("identity".to_string(), "referrer beta identity".to_string());
1089 sections_b.insert(
1090 "purpose".to_string(),
1091 "consult [[target-spec]] for the canonical phrasing".to_string(),
1092 );
1093 let referrer_b = engine
1094 .create_entity(
1095 CreateEntityArgs {
1096 anchors: Vec::new(),
1097 mem: "specs".to_string(),
1098 title: "Referrer Bravo".to_string(),
1099 entity_type: "spec".to_string(),
1100 sections: sections_b,
1101 metadata: IndexMap::new(),
1102 relations: Vec::new(),
1106 dry_run: false,
1107 },
1108 actor,
1109 Some(&client),
1110 None,
1111 )
1112 .unwrap();
1113
1114 let bystander = engine
1117 .create_entity(
1118 empty_create_args("specs", "Bystander"),
1119 actor,
1120 Some(&client),
1121 None,
1122 )
1123 .unwrap();
1124 let bystander_bytes_before =
1125 std::fs::read_to_string(mem_dir.join(&bystander.file_path)).unwrap();
1126
1127 let renamed = engine
1128 .rename_entity(
1129 RenameEntityArgs {
1130 id: target.id.clone(),
1131 expected_hash: Some(target.content_hash.clone()),
1132 new_title: "Renamed Spec".to_string(),
1133 },
1134 actor,
1135 Some(&client),
1136 None,
1137 )
1138 .unwrap();
1139 assert_eq!(renamed.new_id.to_string(), "specs--renamed-spec");
1140
1141 for path in std::fs::read_dir(&mem_dir).unwrap().flatten() {
1144 let p = path.path();
1145 if p.extension().and_then(|s| s.to_str()) != Some("md") {
1146 continue;
1147 }
1148 let body = std::fs::read_to_string(&p).unwrap();
1149 assert!(
1150 !body.contains("[[target-spec]]"),
1151 "old slug must not survive in {}, got:\n{body}",
1152 p.display()
1153 );
1154 }
1155
1156 let in_mem_a = engine.get_entity(&referrer_a.id).unwrap();
1158 assert!(
1159 in_mem_a
1160 .relationships
1161 .iter()
1162 .any(|r| r.rel_type == "REFERENCES"
1163 && r.target == EntityId::new("specs", "renamed-spec")),
1164 "expected referrer A's relation to point at renamed-spec, got {:?}",
1165 in_mem_a.relationships
1166 );
1167 assert!(
1168 in_mem_a
1169 .sections
1170 .get("purpose")
1171 .map(|s| s.contains("[[renamed-spec]]"))
1172 .unwrap_or(false),
1173 "referrer A's body must be rewritten"
1174 );
1175
1176 let in_mem_b = engine.get_entity(&referrer_b.id).unwrap();
1179 assert!(
1180 in_mem_b
1181 .sections
1182 .get("purpose")
1183 .map(|s| s.contains("[[renamed-spec]]"))
1184 .unwrap_or(false),
1185 "referrer B's body must be rewritten"
1186 );
1187
1188 let bystander_bytes_after =
1190 std::fs::read_to_string(mem_dir.join(&bystander.file_path)).unwrap();
1191 assert_eq!(
1192 bystander_bytes_before, bystander_bytes_after,
1193 "bystander must not be rewritten"
1194 );
1195 }
1196
1197 fn engine_with_two_mems_and_bidirectional_policy(
1203 specs_dir: PathBuf,
1204 memos_dir: PathBuf,
1205 ) -> Engine {
1206 use memstead_schema::workspace_config::CrossLinkValue;
1207 let writer_specs = FilesystemMemWriter::new(specs_dir.clone());
1208 let writer_memos = FilesystemMemWriter::new(memos_dir.clone());
1209 let mut engine = Engine::from_mounts(vec![
1210 (
1211 folder_mount("specs", specs_dir),
1212 Box::new(writer_specs) as Box<dyn MemBackend>,
1213 ),
1214 (
1215 folder_mount("memos", memos_dir),
1216 Box::new(writer_memos) as Box<dyn MemBackend>,
1217 ),
1218 ])
1219 .unwrap();
1220 let mut settings = crate::workspace::WorkspaceSettings::default();
1221 settings.cross_mem_links.insert(
1222 "memos".to_string(),
1223 CrossLinkValue::List(vec!["specs".to_string()]),
1224 );
1225 settings.cross_mem_links.insert(
1226 "specs".to_string(),
1227 CrossLinkValue::List(vec!["memos".to_string()]),
1228 );
1229 engine.set_settings(settings);
1230 engine
1231 }
1232
1233 #[test]
1234 fn rename_entity_rewrites_cross_mem_write_referrer() {
1235 use crate::engine::CreateEntityArgs;
1236 use crate::entity::EntityId;
1237 use indexmap::IndexMap;
1238
1239 let tmp_specs = TempDir::new().unwrap();
1240 let tmp_memos = TempDir::new().unwrap();
1241 let specs_dir = tmp_specs.path().to_path_buf();
1242 let memos_dir = tmp_memos.path().to_path_buf();
1243 let mut engine =
1244 engine_with_two_mems_and_bidirectional_policy(specs_dir.clone(), memos_dir.clone());
1245 let (actor, client) = cli_actor();
1246
1247 let target = engine
1249 .create_entity(
1250 empty_create_args("specs", "Target Spec"),
1251 actor,
1252 Some(&client),
1253 None,
1254 )
1255 .unwrap();
1256
1257 let mut sections: IndexMap<String, String> = IndexMap::new();
1264 sections.insert("claim".to_string(), "the claim".to_string());
1265 sections.insert(
1266 "context".to_string(),
1267 "discussion stems from [[specs:target-spec]]".to_string(),
1268 );
1269 let referrer = engine
1270 .create_entity(
1271 CreateEntityArgs {
1272 anchors: Vec::new(),
1273 mem: "memos".to_string(),
1274 title: "Cross Note".to_string(),
1275 entity_type: "memo".to_string(),
1276 sections,
1277 metadata: IndexMap::new(),
1278 relations: Vec::new(),
1282 dry_run: false,
1283 },
1284 actor,
1285 Some(&client),
1286 None,
1287 )
1288 .unwrap();
1289
1290 let renamed = engine
1292 .rename_entity(
1293 RenameEntityArgs {
1294 id: target.id.clone(),
1295 expected_hash: Some(target.content_hash.clone()),
1296 new_title: "Renamed Spec".to_string(),
1297 },
1298 actor,
1299 Some(&client),
1300 None,
1301 )
1302 .unwrap();
1303 assert_eq!(renamed.new_id.to_string(), "specs--renamed-spec");
1304
1305 let referrer_path = memos_dir.join(&referrer.file_path);
1308 let referrer_bytes = std::fs::read_to_string(&referrer_path).unwrap();
1309 assert!(
1310 referrer_bytes.contains("[[specs:renamed-spec]]"),
1311 "expected colon-form rewrite in referrer body, got:\n{referrer_bytes}"
1312 );
1313 assert!(
1314 !referrer_bytes.contains("target-spec"),
1315 "old slug must not survive in referrer file, got:\n{referrer_bytes}"
1316 );
1317
1318 let in_mem = engine.get_entity(&referrer.id).unwrap();
1320 assert!(
1321 in_mem
1322 .relationships
1323 .iter()
1324 .any(|r| r.rel_type == "REFERENCES"
1325 && r.target == EntityId::new("specs", "renamed-spec")),
1326 "expected cross-mem relation to point at renamed-spec, got {:?}",
1327 in_mem.relationships
1328 );
1329 assert!(
1330 in_mem.relationships.iter().all(|r| r.target != target.id),
1331 "no relationship may still target the old id, got: {:?}",
1332 in_mem.relationships
1333 );
1334 }
1335
1336 struct DriftingBackend {
1343 inner: Box<dyn MemBackend>,
1344 }
1345 impl DriftingBackend {
1346 fn new(inner: Box<dyn MemBackend>) -> Self {
1347 Self { inner }
1348 }
1349 }
1350 impl crate::backend::MemBackend for DriftingBackend {
1351 fn list_entities(&self) -> Result<Vec<PathBuf>, crate::backend::BackendError> {
1352 self.inner.list_entities()
1353 }
1354 fn read_entity(
1355 &self,
1356 rel: &std::path::Path,
1357 ) -> Result<Option<Vec<u8>>, crate::backend::BackendError> {
1358 self.inner.read_entity(rel)
1359 }
1360 fn write_entity(
1361 &self,
1362 rel: &std::path::Path,
1363 b: &[u8],
1364 ) -> Result<(), crate::backend::BackendError> {
1365 self.inner.write_entity(rel, b)
1366 }
1367 fn delete_entity(&self, rel: &std::path::Path) -> Result<(), crate::backend::BackendError> {
1368 self.inner.delete_entity(rel)
1369 }
1370 fn move_entity(
1371 &self,
1372 f: &std::path::Path,
1373 t: &std::path::Path,
1374 ) -> Result<(), crate::backend::BackendError> {
1375 self.inner.move_entity(f, t)
1376 }
1377 fn commit(
1378 &self,
1379 m: &str,
1380 c: &crate::vcs::CommitContext<'_>,
1381 ) -> Result<crate::storage::CommitId, crate::backend::BackendError> {
1382 self.inner.commit(m, c)
1383 }
1384 fn commit_with_expected_parent(
1385 &self,
1386 m: &str,
1387 c: &crate::vcs::CommitContext<'_>,
1388 expected_parent: Option<&str>,
1389 ) -> Result<crate::storage::CommitId, crate::backend::BackendError> {
1390 if let Some(expected) = expected_parent {
1391 Err(crate::backend::BackendError::ParentMismatch {
1392 expected: expected.to_string(),
1393 actual: "drifted-by-sibling-writer".to_string(),
1394 })
1395 } else {
1396 self.inner.commit(m, c)
1397 }
1398 }
1399 fn append_provenance(
1400 &self,
1401 r: &crate::Provenance,
1402 ) -> Result<(), crate::backend::BackendError> {
1403 self.inner.append_provenance(r)
1404 }
1405 fn read_provenance(
1406 &self,
1407 c: Option<&str>,
1408 ) -> Result<Vec<crate::Provenance>, crate::backend::BackendError> {
1409 self.inner.read_provenance(c)
1410 }
1411 fn current_head(&self) -> Result<Option<String>, crate::backend::BackendError> {
1412 Ok(Some("snapshot-head-sha".to_string()))
1415 }
1416 }
1417
1418 #[test]
1419 fn rename_entity_surfaces_partial_failure_when_peer_mem_drifts() {
1420 use crate::engine::CreateEntityArgs;
1421 use indexmap::IndexMap;
1422 use memstead_schema::workspace_config::CrossLinkValue;
1423
1424 let tmp_specs = TempDir::new().unwrap();
1425 let tmp_memos = TempDir::new().unwrap();
1426 let specs_dir = tmp_specs.path().to_path_buf();
1427 let memos_dir = tmp_memos.path().to_path_buf();
1428
1429 let writer_specs = FilesystemMemWriter::new(specs_dir.clone());
1434 let writer_memos_inner: Box<dyn MemBackend> =
1435 Box::new(FilesystemMemWriter::new(memos_dir.clone()));
1436 let writer_memos = DriftingBackend::new(writer_memos_inner);
1437
1438 let mut engine = Engine::from_mounts(vec![
1439 (
1440 folder_mount("specs", specs_dir.clone()),
1441 Box::new(writer_specs) as Box<dyn MemBackend>,
1442 ),
1443 (
1444 folder_mount("memos", memos_dir.clone()),
1445 Box::new(writer_memos) as Box<dyn MemBackend>,
1446 ),
1447 ])
1448 .unwrap();
1449 let mut settings = crate::workspace::WorkspaceSettings::default();
1450 settings.cross_mem_links.insert(
1451 "memos".to_string(),
1452 CrossLinkValue::List(vec!["specs".to_string()]),
1453 );
1454 settings.cross_mem_links.insert(
1455 "specs".to_string(),
1456 CrossLinkValue::List(vec!["memos".to_string()]),
1457 );
1458 engine.set_settings(settings);
1459
1460 let (actor, client) = cli_actor();
1461 let target = engine
1462 .create_entity(
1463 empty_create_args("specs", "Target Spec"),
1464 actor,
1465 Some(&client),
1466 None,
1467 )
1468 .unwrap();
1469 let mut sections: IndexMap<String, String> = IndexMap::new();
1470 sections.insert("claim".to_string(), "the claim".to_string());
1471 sections.insert(
1472 "context".to_string(),
1473 "see [[specs:target-spec]]".to_string(),
1474 );
1475 let _referrer = engine
1476 .create_entity(
1477 CreateEntityArgs {
1478 anchors: Vec::new(),
1479 mem: "memos".to_string(),
1480 title: "Cross Note".to_string(),
1481 entity_type: "memo".to_string(),
1482 sections,
1483 metadata: IndexMap::new(),
1484 relations: Vec::new(),
1488 dry_run: false,
1489 },
1490 actor,
1491 Some(&client),
1492 None,
1493 )
1494 .unwrap();
1495
1496 let err = engine
1497 .rename_entity(
1498 RenameEntityArgs {
1499 id: target.id.clone(),
1500 expected_hash: Some(target.content_hash.clone()),
1501 new_title: "Renamed Spec".to_string(),
1502 },
1503 actor,
1504 Some(&client),
1505 None,
1506 )
1507 .unwrap_err();
1508 match err {
1509 EngineError::RenamePartialFailure {
1510 committed_mems,
1511 failed_mem,
1512 failure_cause,
1513 } => {
1514 assert_eq!(committed_mems, vec!["specs".to_string()]);
1518 assert_eq!(failed_mem, "memos");
1519 assert_eq!(failure_cause, "drift");
1520 }
1521 other => panic!("expected RenamePartialFailure, got {other:?}"),
1522 }
1523 assert!(specs_dir.join("renamed-spec.md").exists());
1527 assert!(!specs_dir.join(&target.file_path).exists());
1528 }
1529
1530 #[test]
1531 fn rename_entity_tags_every_per_mem_commit_with_same_logical_operation_id() {
1532 use crate::backend::MemBackend;
1533 use crate::engine::CreateEntityArgs;
1534 use indexmap::IndexMap;
1535
1536 let tmp_specs = TempDir::new().unwrap();
1537 let tmp_memos = TempDir::new().unwrap();
1538 let specs_dir = tmp_specs.path().to_path_buf();
1539 let memos_dir = tmp_memos.path().to_path_buf();
1540 let mut engine =
1541 engine_with_two_mems_and_bidirectional_policy(specs_dir.clone(), memos_dir.clone());
1542 let (actor, client) = cli_actor();
1543
1544 let target = engine
1545 .create_entity(
1546 empty_create_args("specs", "Target Spec"),
1547 actor,
1548 Some(&client),
1549 None,
1550 )
1551 .unwrap();
1552 let mut sections: IndexMap<String, String> = IndexMap::new();
1553 sections.insert("claim".to_string(), "the claim".to_string());
1554 sections.insert(
1555 "context".to_string(),
1556 "discussion stems from [[specs:target-spec]]".to_string(),
1557 );
1558 let _referrer = engine
1559 .create_entity(
1560 CreateEntityArgs {
1561 anchors: Vec::new(),
1562 mem: "memos".to_string(),
1563 title: "Cross Note".to_string(),
1564 entity_type: "memo".to_string(),
1565 sections,
1566 metadata: IndexMap::new(),
1567 relations: Vec::new(),
1571 dry_run: false,
1572 },
1573 actor,
1574 Some(&client),
1575 None,
1576 )
1577 .unwrap();
1578
1579 let _ = engine
1580 .rename_entity(
1581 RenameEntityArgs {
1582 id: target.id.clone(),
1583 expected_hash: Some(target.content_hash.clone()),
1584 new_title: "Renamed Spec".to_string(),
1585 },
1586 actor,
1587 Some(&client),
1588 None,
1589 )
1590 .unwrap();
1591
1592 let specs_backend: Box<dyn MemBackend> =
1596 Box::new(FilesystemMemWriter::new(specs_dir.clone()));
1597 let memos_backend: Box<dyn MemBackend> =
1598 Box::new(FilesystemMemWriter::new(memos_dir.clone()));
1599 let specs_provenance = specs_backend.read_provenance(None).unwrap();
1600 let memos_provenance = memos_backend.read_provenance(None).unwrap();
1601
1602 let specs_rename = specs_provenance
1603 .iter()
1604 .find(|p| matches!(p.kind, crate::provenance::ProvenanceKind::Rename))
1605 .expect("specs mem must have a rename provenance entry");
1606 let memos_rename = memos_provenance
1607 .iter()
1608 .find(|p| matches!(p.kind, crate::provenance::ProvenanceKind::Rename))
1609 .expect("memos mem must have a rename provenance entry");
1610
1611 let specs_id = specs_rename
1612 .logical_operation_id
1613 .as_deref()
1614 .expect("specs rename entry must carry a logical_operation_id");
1615 let memos_id = memos_rename
1616 .logical_operation_id
1617 .as_deref()
1618 .expect("memos rename entry must carry a logical_operation_id");
1619 assert_eq!(
1620 specs_id, memos_id,
1621 "both per-mem rename commits must share the same logical_operation_id"
1622 );
1623 assert!(
1624 specs_id.starts_with("logop-"),
1625 "logical_operation_id must use the `logop-` prefix the engine mints; got {specs_id}"
1626 );
1627 }
1628
1629 #[test]
1630 fn rename_entity_refuses_when_cross_mem_referrer_blocked_by_policy() {
1631 use crate::engine::CreateEntityArgs;
1632 use indexmap::IndexMap;
1633 use memstead_schema::workspace_config::CrossLinkValue;
1634
1635 let tmp_specs = TempDir::new().unwrap();
1636 let tmp_memos = TempDir::new().unwrap();
1637 let specs_dir = tmp_specs.path().to_path_buf();
1638 let memos_dir = tmp_memos.path().to_path_buf();
1639
1640 let mut engine =
1643 engine_with_two_mems_and_bidirectional_policy(specs_dir.clone(), memos_dir.clone());
1644 let (actor, client) = cli_actor();
1645
1646 let target = engine
1647 .create_entity(
1648 empty_create_args("specs", "Target Spec"),
1649 actor,
1650 Some(&client),
1651 None,
1652 )
1653 .unwrap();
1654 let mut sections: IndexMap<String, String> = IndexMap::new();
1655 sections.insert("claim".to_string(), "the claim".to_string());
1656 sections.insert(
1657 "context".to_string(),
1658 "see [[specs:target-spec]]".to_string(),
1659 );
1660 let _referrer = engine
1661 .create_entity(
1662 CreateEntityArgs {
1663 anchors: Vec::new(),
1664 mem: "memos".to_string(),
1665 title: "Cross Note".to_string(),
1666 entity_type: "memo".to_string(),
1667 sections,
1668 metadata: IndexMap::new(),
1669 relations: Vec::new(),
1673 dry_run: false,
1674 },
1675 actor,
1676 Some(&client),
1677 None,
1678 )
1679 .unwrap();
1680
1681 let mut settings = crate::workspace::WorkspaceSettings::default();
1687 settings.cross_mem_links.insert(
1688 "specs".to_string(),
1689 CrossLinkValue::List(vec!["memos".to_string()]),
1690 );
1691 engine.set_settings(settings);
1693
1694 let err = engine
1695 .rename_entity(
1696 RenameEntityArgs {
1697 id: target.id.clone(),
1698 expected_hash: Some(target.content_hash.clone()),
1699 new_title: "Renamed Spec".to_string(),
1700 },
1701 actor,
1702 Some(&client),
1703 None,
1704 )
1705 .unwrap_err();
1706 match err {
1707 EngineError::RenameBlockedByCrossMemPolicy {
1708 from_mem,
1709 blocked_referrers,
1710 } => {
1711 assert_eq!(from_mem, "specs");
1712 assert_eq!(blocked_referrers.len(), 1);
1713 assert_eq!(blocked_referrers[0].from_mem, "memos");
1714 assert_eq!(blocked_referrers[0].to_mem, "specs");
1715 assert_eq!(blocked_referrers[0].count, 1);
1716 }
1717 other => panic!("expected RenameBlockedByCrossMemPolicy, got {other:?}"),
1718 }
1719 assert!(specs_dir.join(&target.file_path).exists());
1722 assert!(!specs_dir.join("renamed-spec.md").exists());
1723 }
1724
1725 #[test]
1736 fn rename_entity_demotes_to_stub_when_only_readonly_cross_mem_referrers_remain() {
1737 use crate::engine::test_helpers::{archive_mount, build_archive};
1738 use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
1739
1740 let tmp = TempDir::new().unwrap();
1741 let writable_dir = tmp.path().join("writable");
1742 std::fs::create_dir_all(&writable_dir).unwrap();
1743 let writer = FilesystemMemWriter::new(writable_dir.clone());
1744
1745 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";
1749 let archive_path = build_archive(
1750 tmp.path(),
1751 "archive",
1752 &[("archived-source.md", archive_md.as_bytes())],
1753 );
1754
1755 let folder_mount = Mount {
1756 mem: "specs".to_string(),
1757 schema: Some(crate::engine::test_helpers::pin("default")),
1758 storage: MountStorage::Folder {
1759 path: writable_dir.clone(),
1760 },
1761 capability: MountCapability::Write,
1762 lifecycle: MountLifecycle::Eager,
1763 cross_linkable: true,
1764 migration_target: None,
1765 };
1766 let archive_reader = crate::storage::ArchiveBackend::new(archive_path.clone());
1767 let mut engine = Engine::from_mounts(vec![
1768 (folder_mount, Box::new(writer) as Box<dyn MemBackend>),
1769 (
1770 archive_mount("archive", archive_path.clone()),
1771 Box::new(archive_reader) as Box<dyn MemBackend>,
1772 ),
1773 ])
1774 .unwrap();
1775
1776 let (actor, client) = cli_actor();
1777 let target = engine
1778 .create_entity(
1779 empty_create_args("specs", "Target"),
1780 actor,
1781 Some(&client),
1782 None,
1783 )
1784 .unwrap();
1785
1786 let archived_source_id = crate::EntityId::new("archive", "archived-source");
1789 let incoming_pre: Vec<_> = engine
1790 .store()
1791 .incoming(&target.id)
1792 .iter()
1793 .map(|e| e.from.clone())
1794 .collect();
1795 assert!(
1796 incoming_pre.contains(&archived_source_id),
1797 "archive wiki-link must produce an incoming edge on target; got {incoming_pre:?}"
1798 );
1799
1800 let outcome = engine
1803 .rename_entity(
1804 RenameEntityArgs {
1805 id: target.id.clone(),
1806 expected_hash: Some(target.content_hash.clone()),
1807 new_title: "Renamed Target".to_string(),
1808 },
1809 actor,
1810 Some(&client),
1811 None,
1812 )
1813 .unwrap();
1814 assert_eq!(outcome.new_id.to_string(), "specs--renamed-target");
1815
1816 assert!(writable_dir.join(&outcome.new_path).exists());
1818 let demoted = engine
1820 .get_entity(&target.id)
1821 .expect("residual stub must remain at old id");
1822 assert!(demoted.stub, "demoted entity must be flagged as stub");
1823 assert!(demoted.entity_type.is_empty());
1824 let incoming_old: Vec<_> = engine
1827 .store()
1828 .incoming(&target.id)
1829 .iter()
1830 .map(|e| e.from.clone())
1831 .collect();
1832 assert!(
1833 incoming_old.contains(&archived_source_id),
1834 "archive incoming edge must survive demotion at old id; got {incoming_old:?}"
1835 );
1836 let incoming_new: Vec<_> = engine
1839 .store()
1840 .incoming(&outcome.new_id)
1841 .iter()
1842 .map(|e| e.from.clone())
1843 .collect();
1844 assert!(
1845 !incoming_new.contains(&archived_source_id),
1846 "archive must not be wired to new id (markdown still references old slug); got {incoming_new:?}"
1847 );
1848 let referrers = outcome
1850 .warnings
1851 .iter()
1852 .find_map(|w| match w {
1853 WarningHint::ResidualStubForReadOnlyReferrers {
1854 id: warn_id,
1855 referrers,
1856 } => {
1857 assert_eq!(warn_id, &target.id);
1858 Some(referrers.clone())
1859 }
1860 _ => None,
1861 })
1862 .expect("ResidualStubForReadOnlyReferrers warning must surface");
1863 assert_eq!(referrers, vec![archived_source_id]);
1864 }
1865
1866 #[test]
1874 fn rename_entity_rewrites_full_id_form_body_link_on_same_mem_referrer() {
1875 use crate::engine::CreateEntityArgs;
1876 use indexmap::IndexMap;
1877 let tmp = TempDir::new().unwrap();
1878 let mem_dir = tmp.path().to_path_buf();
1879 let writer = FilesystemMemWriter::new(mem_dir.clone());
1880 let mut engine = Engine::from_mounts(vec![(
1881 folder_mount("specs", mem_dir.clone()),
1882 Box::new(writer) as Box<dyn MemBackend>,
1883 )])
1884 .unwrap();
1885 let (actor, client) = cli_actor();
1886
1887 let target = engine
1889 .create_entity(
1890 empty_create_args("specs", "Target Spec"),
1891 actor,
1892 Some(&client),
1893 None,
1894 )
1895 .unwrap();
1896 assert_eq!(target.id.to_string(), "specs--target-spec");
1897
1898 let mut sections: IndexMap<String, String> = IndexMap::new();
1903 sections.insert("identity".to_string(), "referrer identity".to_string());
1904 sections.insert(
1905 "purpose".to_string(),
1906 "see also [[specs--target-spec]] for context".to_string(),
1907 );
1908 let referrer = engine
1909 .create_entity(
1910 CreateEntityArgs {
1911 anchors: Vec::new(),
1912 mem: "specs".to_string(),
1913 title: "Referrer Full".to_string(),
1914 entity_type: "spec".to_string(),
1915 sections,
1916 metadata: IndexMap::new(),
1917 relations: Vec::new(),
1918 dry_run: false,
1919 },
1920 actor,
1921 Some(&client),
1922 None,
1923 )
1924 .unwrap();
1925
1926 let renamed = engine
1927 .rename_entity(
1928 RenameEntityArgs {
1929 id: target.id.clone(),
1930 expected_hash: Some(target.content_hash.clone()),
1931 new_title: "Renamed Spec".to_string(),
1932 },
1933 actor,
1934 Some(&client),
1935 None,
1936 )
1937 .unwrap();
1938 assert_eq!(renamed.new_id.to_string(), "specs--renamed-spec");
1939
1940 let referrer_path = mem_dir.join(&referrer.file_path);
1944 let body = std::fs::read_to_string(&referrer_path).unwrap();
1945 assert!(
1946 body.contains("[[specs--renamed-spec]]"),
1947 "expected full-id form retargeted to new slug, got:\n{body}"
1948 );
1949 assert!(
1950 !body.contains("target-spec"),
1951 "old slug must not survive in any form, got:\n{body}"
1952 );
1953 }
1954
1955 #[test]
1956 fn rename_entity_rejects_collision_with_existing_id() {
1957 let tmp = TempDir::new().unwrap();
1958 let (mut engine, first) = engine_with_seed(&tmp, "First");
1959 let (actor, client) = cli_actor();
1960 let _ = engine
1961 .create_entity(
1962 empty_create_args("specs", "Second"),
1963 actor,
1964 Some(&client),
1965 None,
1966 )
1967 .unwrap();
1968 let err = engine
1970 .rename_entity(
1971 RenameEntityArgs {
1972 id: first.id.clone(),
1973 expected_hash: Some(first.content_hash.clone()),
1974 new_title: "Second".to_string(),
1975 },
1976 actor,
1977 Some(&client),
1978 None,
1979 )
1980 .unwrap_err();
1981 assert!(matches!(
1982 err,
1983 EngineError::AlreadyExists { ref id, ref existing_title, existing_is_stub: false }
1984 if id == "specs--second" && existing_title == "Second"
1985 ));
1986 }
1987
1988 #[test]
1995 fn rename_propagation_gate_checks_actual_edge_direction() {
1996 use crate::engine::error::BlockedReferrer;
1997 use crate::engine::{CreateEntityArgs, RelateEntityArgs};
1998 use indexmap::IndexMap;
1999 use memstead_schema::workspace_config::CrossLinkValue;
2000
2001 let tmp_test = TempDir::new().unwrap();
2002 let tmp_other = TempDir::new().unwrap();
2003 let test_dir = tmp_test.path().to_path_buf();
2004 let other_dir = tmp_other.path().to_path_buf();
2005
2006 let writer_test = FilesystemMemWriter::new(test_dir.clone());
2010 let writer_other = FilesystemMemWriter::new(other_dir.clone());
2011 let mut engine = Engine::from_mounts(vec![
2012 (
2013 folder_mount("test", test_dir.clone()),
2014 Box::new(writer_test) as Box<dyn MemBackend>,
2015 ),
2016 (
2017 folder_mount("other", other_dir.clone()),
2018 Box::new(writer_other) as Box<dyn MemBackend>,
2019 ),
2020 ])
2021 .unwrap();
2022 let (actor, client) = cli_actor();
2023
2024 let mut settings = crate::workspace::WorkspaceSettings::default();
2027 settings.cross_mem_links.insert(
2028 "test".to_string(),
2029 CrossLinkValue::List(vec!["other".to_string()]),
2030 );
2031 engine.set_settings(settings);
2032
2033 let target = engine
2034 .create_entity(
2035 empty_create_args("other", "Target"),
2036 actor,
2037 Some(&client),
2038 None,
2039 )
2040 .unwrap();
2041 let mut src_sections: IndexMap<String, String> = IndexMap::new();
2042 src_sections.insert("identity".to_string(), "source identity".to_string());
2043 src_sections.insert("purpose".to_string(), "source purpose".to_string());
2044 let src = engine
2045 .create_entity(
2046 CreateEntityArgs {
2047 anchors: Vec::new(),
2048 mem: "test".to_string(),
2049 title: "Src".to_string(),
2050 entity_type: "spec".to_string(),
2051 sections: src_sections,
2052 metadata: IndexMap::new(),
2053 relations: Vec::new(),
2054 dry_run: false,
2055 },
2056 actor,
2057 Some(&client),
2058 None,
2059 )
2060 .unwrap();
2061 let src = engine
2062 .relate_entity(
2063 RelateEntityArgs {
2064 source: src.id.clone(),
2065 rel_type: "IMPLEMENTS".to_string(),
2066 target: target.id.clone(),
2067 expected_hash: Some(src.content_hash.clone()),
2068 remove: false,
2069 description: None,
2070 dry_run: false,
2071 },
2072 actor,
2073 Some(&client),
2074 None,
2075 )
2076 .unwrap();
2077 let _ = src; engine.set_settings(crate::workspace::WorkspaceSettings::default());
2084
2085 let err = engine
2086 .rename_entity(
2087 RenameEntityArgs {
2088 id: target.id.clone(),
2089 expected_hash: Some(target.content_hash.clone()),
2090 new_title: "Renamed".to_string(),
2091 },
2092 actor,
2093 Some(&client),
2094 None,
2095 )
2096 .unwrap_err();
2097 match err {
2098 EngineError::RenameBlockedByCrossMemPolicy {
2099 from_mem,
2100 blocked_referrers,
2101 } => {
2102 assert_eq!(from_mem, "other");
2103 assert_eq!(
2104 blocked_referrers,
2105 vec![BlockedReferrer {
2106 from_mem: "test".to_string(),
2107 to_mem: "other".to_string(),
2108 count: 1,
2109 }],
2110 "blocked_referrers must name the actual edge direction (test → other)",
2111 );
2112 }
2113 other => panic!("expected RenameBlockedByCrossMemPolicy, got {other:?}"),
2114 }
2115 assert!(other_dir.join(&target.file_path).exists());
2118 assert!(!other_dir.join("renamed.md").exists());
2119 }
2120
2121 #[test]
2125 fn rename_propagation_succeeds_when_actual_edge_direction_granted() {
2126 use crate::engine::{CreateEntityArgs, RelateEntityArgs};
2127 use indexmap::IndexMap;
2128 use memstead_schema::workspace_config::CrossLinkValue;
2129
2130 let tmp_test = TempDir::new().unwrap();
2131 let tmp_other = TempDir::new().unwrap();
2132 let test_dir = tmp_test.path().to_path_buf();
2133 let other_dir = tmp_other.path().to_path_buf();
2134
2135 let writer_test = FilesystemMemWriter::new(test_dir.clone());
2136 let writer_other = FilesystemMemWriter::new(other_dir.clone());
2137 let mut engine = Engine::from_mounts(vec![
2138 (
2139 folder_mount("test", test_dir),
2140 Box::new(writer_test) as Box<dyn MemBackend>,
2141 ),
2142 (
2143 folder_mount("other", other_dir),
2144 Box::new(writer_other) as Box<dyn MemBackend>,
2145 ),
2146 ])
2147 .unwrap();
2148 let (actor, client) = cli_actor();
2149
2150 let mut settings = crate::workspace::WorkspaceSettings::default();
2151 settings.cross_mem_links.insert(
2152 "test".to_string(),
2153 CrossLinkValue::List(vec!["other".to_string()]),
2154 );
2155 engine.set_settings(settings);
2156
2157 let target = engine
2158 .create_entity(
2159 empty_create_args("other", "Target"),
2160 actor,
2161 Some(&client),
2162 None,
2163 )
2164 .unwrap();
2165 let mut src_sections: IndexMap<String, String> = IndexMap::new();
2166 src_sections.insert("identity".to_string(), "source identity".to_string());
2167 src_sections.insert("purpose".to_string(), "source purpose".to_string());
2168 let src = engine
2169 .create_entity(
2170 CreateEntityArgs {
2171 anchors: Vec::new(),
2172 mem: "test".to_string(),
2173 title: "Src".to_string(),
2174 entity_type: "spec".to_string(),
2175 sections: src_sections,
2176 metadata: IndexMap::new(),
2177 relations: Vec::new(),
2178 dry_run: false,
2179 },
2180 actor,
2181 Some(&client),
2182 None,
2183 )
2184 .unwrap();
2185 let _ = engine
2186 .relate_entity(
2187 RelateEntityArgs {
2188 source: src.id.clone(),
2189 rel_type: "IMPLEMENTS".to_string(),
2190 target: target.id.clone(),
2191 expected_hash: Some(src.content_hash.clone()),
2192 remove: false,
2193 description: None,
2194 dry_run: false,
2195 },
2196 actor,
2197 Some(&client),
2198 None,
2199 )
2200 .unwrap();
2201
2202 let outcome = engine
2205 .rename_entity(
2206 RenameEntityArgs {
2207 id: target.id.clone(),
2208 expected_hash: Some(target.content_hash.clone()),
2209 new_title: "Renamed".to_string(),
2210 },
2211 actor,
2212 Some(&client),
2213 None,
2214 )
2215 .unwrap();
2216 assert_ne!(outcome.old_id, outcome.new_id);
2217 let renamed = engine
2218 .get_entity(&outcome.new_id)
2219 .expect("renamed entity persists");
2220 assert_eq!(renamed.title, "Renamed");
2221 let updated_src = engine.get_entity(&src.id).expect("source persists");
2223 assert!(
2224 updated_src
2225 .relationships
2226 .iter()
2227 .any(|r| r.rel_type == "IMPLEMENTS" && r.target == outcome.new_id),
2228 "referrer's IMPLEMENTS edge must point at the new id after rewrite"
2229 );
2230 }
2231
2232 #[test]
2236 fn rename_with_no_cross_mem_referrers_succeeds_regardless_of_policy() {
2237 let tmp = TempDir::new().unwrap();
2238 let mem_dir = tmp.path().to_path_buf();
2239 let writer = FilesystemMemWriter::new(mem_dir.clone());
2240 let mut engine = Engine::from_mounts(vec![(
2241 folder_mount("specs", mem_dir),
2242 Box::new(writer) as Box<dyn MemBackend>,
2243 )])
2244 .unwrap();
2245 let (actor, client) = cli_actor();
2246 let target = engine
2250 .create_entity(
2251 empty_create_args("specs", "Target"),
2252 actor,
2253 Some(&client),
2254 None,
2255 )
2256 .unwrap();
2257 let outcome = engine
2258 .rename_entity(
2259 RenameEntityArgs {
2260 id: target.id.clone(),
2261 expected_hash: Some(target.content_hash.clone()),
2262 new_title: "Renamed Target".to_string(),
2263 },
2264 actor,
2265 Some(&client),
2266 None,
2267 )
2268 .unwrap();
2269 assert_ne!(outcome.old_id, outcome.new_id);
2270 }
2271}