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