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 for entity in self.store.all_entities() {
246 if entity.id == *id || entity.stub || seen.contains(&entity.id) {
247 continue;
248 }
249 let mentions = entity.sections.values().any(|body| {
250 body.contains(old_slug.as_str())
251 && crate::entity::parser::extract_inline_links_lenient(body, entity.id.mem())
252 .iter()
253 .any(|t| t == id)
254 });
255 if !mentions {
256 continue;
257 }
258 seen.insert(entity.id.clone());
259 if entity.id.mem() == mem {
260 same_mem_ids.push(entity.id.clone());
261 } else {
262 cross_mem_ids.push(entity.id.clone());
263 }
264 }
265
266 let mut cross_mem_write_ids: Vec<EntityId> = Vec::new();
274 let mut readonly_referrers: Vec<EntityId> = Vec::new();
275 for from_id in cross_mem_ids {
276 match self
277 .mount(from_id.mem())
278 .map(|m| m.capability)
279 .unwrap_or(MountCapability::Write)
280 {
281 MountCapability::Write => cross_mem_write_ids.push(from_id),
282 MountCapability::ReadOnly => readonly_referrers.push(from_id),
283 }
284 }
285 readonly_referrers.sort_by_key(|a| a.to_string());
286
287 let mut blocked_counts: std::collections::BTreeMap<String, usize> =
297 std::collections::BTreeMap::new();
298 for from_id in &cross_mem_write_ids {
299 let peer_mem = from_id.mem().to_string();
300 if !self.cross_mem_link_allowed(&peer_mem, &mem) {
301 *blocked_counts.entry(peer_mem).or_insert(0) += 1;
302 }
303 }
304 if !blocked_counts.is_empty() {
305 let blocked_referrers: Vec<crate::engine::error::BlockedReferrer> = blocked_counts
306 .into_iter()
307 .map(|(peer_mem, count)| crate::engine::error::BlockedReferrer {
308 from_mem: peer_mem,
309 to_mem: mem.clone(),
310 count,
311 })
312 .collect();
313 return Err(EngineError::RenameBlockedByCrossMemPolicy {
314 from_mem: mem.clone(),
315 blocked_referrers,
316 });
317 }
318
319 same_mem_ids.sort_by_key(|a| a.to_string());
323 cross_mem_write_ids.sort_by_key(|a| a.to_string());
324
325 let mut same_mem_writes: Vec<(
331 String,
332 String,
333 std::sync::Arc<memstead_schema::TypeDefinition>,
334 )> = Vec::with_capacity(same_mem_ids.len());
335 for from_id in &same_mem_ids {
336 let Some(referrer) = self.store.get(from_id) else {
337 continue;
338 };
339 if referrer.stub {
340 continue;
341 }
342 let referrer_type_def = schema
343 .get_type(&referrer.entity_type)
344 .ok_or_else(|| unknown_type_error(schema, &referrer.entity_type))?;
345 let mut next_ref = referrer.clone();
346 for rel in next_ref.relationships.iter_mut() {
347 if rel.target == *id {
348 rel.target = new_id.clone();
349 }
350 }
351 for body in next_ref.sections.values_mut() {
352 let (rewritten, count) = crate::entity::wikilink_rewrite::rewrite_bare_slug(
353 body,
354 &old_slug,
355 &new_slug_owned,
356 );
357 if count > 0 {
358 *body = rewritten;
359 }
360 let (rewritten, count) = crate::entity::wikilink_rewrite::rewrite_cross_mem_slug(
365 body,
366 &mem,
367 &old_slug,
368 &new_slug_owned,
369 );
370 if count > 0 {
371 *body = rewritten;
372 }
373 }
374 let ref_markdown = super::render_for_write(&next_ref, referrer_type_def.as_ref())?;
385 same_mem_writes.push((ref_markdown, next_ref.file_path.clone(), referrer_type_def));
386 }
387
388 struct PeerMemPlan {
394 mount_idx: usize,
395 mem: String,
396 writes: Vec<(
397 String,
398 String,
399 std::sync::Arc<memstead_schema::TypeDefinition>,
400 )>,
401 }
402 let mut peer_plans: std::collections::BTreeMap<String, PeerMemPlan> =
403 std::collections::BTreeMap::new();
404 for from_id in &cross_mem_write_ids {
405 let peer_mem = from_id.mem().to_string();
406 let peer_mount_idx = self
407 .mounts
408 .iter()
409 .position(|m| m.mount.mem == peer_mem)
410 .expect("peer mount present for collected referrer id");
411 let peer_schema = self
412 .schemas
413 .get(&peer_mem)
414 .expect("schema present for every registered mount");
415
416 let Some(referrer) = self.store.get(from_id) else {
417 continue;
418 };
419 if referrer.stub {
420 continue;
421 }
422 let referrer_type_def = peer_schema
423 .get_type(&referrer.entity_type)
424 .ok_or_else(|| unknown_type_error(peer_schema, &referrer.entity_type))?;
425 let mut next_ref = referrer.clone();
426 for rel in next_ref.relationships.iter_mut() {
427 if rel.target == *id {
428 rel.target = new_id.clone();
429 }
430 }
431 for body in next_ref.sections.values_mut() {
437 let (rewritten, count) = crate::entity::wikilink_rewrite::rewrite_cross_mem_slug(
438 body,
439 &mem,
440 &old_slug,
441 &new_slug_owned,
442 );
443 if count > 0 {
444 *body = rewritten;
445 }
446 }
447 let ref_markdown = super::render_for_write(&next_ref, referrer_type_def.as_ref())?;
453
454 peer_plans
455 .entry(peer_mem.clone())
456 .or_insert_with(|| PeerMemPlan {
457 mount_idx: peer_mount_idx,
458 mem: peer_mem.clone(),
459 writes: Vec::new(),
460 })
461 .writes
462 .push((ref_markdown, next_ref.file_path.clone(), referrer_type_def));
463 }
464
465 let logical_op_id = crate::provenance::mint_logical_operation_id();
473
474 let backend = self.mounts[mount_idx].backend.as_ref();
475 backend.write_entity(Path::new(&new_file_path), markdown.as_bytes())?;
476 backend.delete_entity(Path::new(&old_file_path))?;
477 for (ref_markdown, ref_file_path, _) in &same_mem_writes {
478 backend.write_entity(Path::new(ref_file_path), ref_markdown.as_bytes())?;
479 }
480 super::stage_anchors_rename(backend, id, &new_id)?;
485 let commit_subject = format!("memstead: rename {} → {new_id}", id);
486 let ctx = CommitContext {
487 actor,
488 client: client.cloned(),
489 tool: Some("rename_entity"),
490 note: note.map(String::from),
491 role: self.current_role,
492 identity: self.current_identity.clone(),
493 logical_operation_id: Some(logical_op_id.as_str()),
494 entity_ids: None,
495 };
496 let write_id = backend.commit(&commit_subject, &ctx)?;
497
498 backend.append_provenance(
499 &Provenance::new(
500 std::time::SystemTime::now(),
501 ProvenanceKind::Rename,
502 Some(new_id.to_string()),
503 actor,
504 client.cloned(),
505 note.map(String::from),
506 )
507 .with_role(self.current_role)
508 .with_identity(self.current_identity.clone())
509 .with_logical_operation_id(logical_op_id.clone()),
510 )?;
511
512 self.record_self_write(mount_idx, &write_id);
513 let stamp_warnings = self.stamp_mutation_versions(mount_idx);
514 let mut peer_stamp_warnings: Vec<WarningHint> = Vec::new();
515
516 let mut peer_snapshots: std::collections::BTreeMap<String, Option<String>> =
530 std::collections::BTreeMap::new();
531 for plan in peer_plans.values() {
532 let peer_backend = self.mounts[plan.mount_idx].backend.as_ref();
533 let snapshot = peer_backend.current_head()?;
534 peer_snapshots.insert(plan.mem.clone(), snapshot);
535 }
536
537 let mut committed_mems: Vec<String> = vec![mem.clone()];
542 for plan in peer_plans.values() {
543 let peer_backend = self.mounts[plan.mount_idx].backend.as_ref();
544 for (ref_markdown, ref_file_path, _) in &plan.writes {
545 peer_backend.write_entity(Path::new(ref_file_path), ref_markdown.as_bytes())?;
546 }
547 let peer_commit_subject = format!(
548 "memstead: rename {} → {new_id} (cross-mem rewrite in `{}`)",
549 id, plan.mem
550 );
551 let peer_ctx = CommitContext {
552 actor,
553 client: client.cloned(),
554 tool: Some("rename_entity"),
555 note: note.map(String::from),
556 role: self.current_role,
557 identity: self.current_identity.clone(),
558 logical_operation_id: Some(logical_op_id.as_str()),
559 entity_ids: None,
560 };
561 let expected = peer_snapshots.get(&plan.mem).cloned().unwrap_or(None);
562 let peer_commit_result = peer_backend.commit_with_expected_parent(
563 &peer_commit_subject,
564 &peer_ctx,
565 expected.as_deref(),
566 );
567 let peer_write_id = match peer_commit_result {
568 Ok(sha) => sha,
569 Err(crate::backend::BackendError::ParentMismatch { .. }) => {
570 return Err(EngineError::RenamePartialFailure {
571 committed_mems: std::mem::take(&mut committed_mems),
572 failed_mem: plan.mem.clone(),
573 failure_cause: "drift".to_string(),
574 });
575 }
576 Err(e) => return Err(e.into()),
577 };
578 peer_backend.append_provenance(
579 &Provenance::new(
580 std::time::SystemTime::now(),
581 ProvenanceKind::Rename,
582 Some(new_id.to_string()),
583 actor,
584 client.cloned(),
585 note.map(String::from),
586 )
587 .with_role(self.current_role)
588 .with_identity(self.current_identity.clone())
589 .with_logical_operation_id(logical_op_id.clone()),
590 )?;
591 self.record_self_write(plan.mount_idx, &peer_write_id);
592 peer_stamp_warnings.extend(self.stamp_mutation_versions(plan.mount_idx));
596 committed_mems.push(plan.mem.clone());
597 }
598
599 let parse_result = parse_markdown(&markdown, &new_file_path, type_def.as_ref(), &mem)
601 .map_err(|e| EngineError::ParseAfterWrite(e.to_string()))?;
602 let content_hash = parse_result.entity.content_hash.clone();
603
604 let mut parse_results = vec![parse_result];
605 for (ref_markdown, ref_file_path, ref_type_def) in &same_mem_writes {
606 let pr = parse_markdown(ref_markdown, ref_file_path, ref_type_def.as_ref(), &mem)
607 .map_err(|e| EngineError::ParseAfterWrite(e.to_string()))?;
608 parse_results.push(pr);
609 }
610 for plan in peer_plans.values() {
611 for (ref_markdown, ref_file_path, ref_type_def) in &plan.writes {
612 let pr = parse_markdown(
613 ref_markdown,
614 ref_file_path,
615 ref_type_def.as_ref(),
616 &plan.mem,
617 )
618 .map_err(|e| EngineError::ParseAfterWrite(e.to_string()))?;
619 parse_results.push(pr);
620 }
621 }
622
623 let mut outcome_warnings: Vec<WarningHint> = Vec::new();
636 outcome_warnings.extend(stamp_warnings);
637 outcome_warnings.extend(peer_stamp_warnings);
638 outcome_warnings.append(&mut drift_warnings);
640 if !derivation.dropped_chars.is_empty() {
643 outcome_warnings.push(WarningHint::TitleCharsDroppedFromSlug {
644 title: args.new_title.trim().to_string(),
645 dropped_chars: derivation.dropped_chars.clone(),
646 slug: new_slug.clone(),
647 });
648 }
649 if readonly_referrers.is_empty() {
650 self.store.remove(id);
651 } else {
652 self.store.remove_edges_from(id);
657 self.store.upsert(
658 id.clone(),
659 make_stub(
660 id,
661 crate::entity::StubKind::Residual {
662 since_commit: write_id.clone(),
663 readonly_referrers: readonly_referrers.clone(),
664 },
665 ),
666 );
667 outcome_warnings.push(WarningHint::ResidualStubForReadOnlyReferrers {
668 id: id.clone(),
669 referrers: readonly_referrers,
670 });
671 }
672
673 let fallback = engine_fallback_type();
674 let mut touched: Vec<crate::EntityId> = parse_results
679 .iter()
680 .map(|pr| pr.entity.id.clone())
681 .collect();
682 touched.push(id.clone());
683 push_entities_into_store(&mut self.store, parse_results, fallback.as_ref(), None);
684 crate::entity::store_builder::remap_alias_target_edge_sources(
685 &mut self.store,
686 &self.schemas,
687 );
688
689 self.invalidate_communities();
690 self.maintain_search_indexes(&touched);
691
692 if let Some(w) = self.note_missing_warning("rename_entity", note) {
697 outcome_warnings.push(w);
698 }
699
700 Ok(RenameEntityOutcome {
701 old_id: id.clone(),
702 new_id,
703 old_path: old_file_path,
704 new_path: new_file_path,
705 content_hash,
706 write_id,
707 warnings: outcome_warnings,
708 })
709 }
710}
711
712#[cfg(test)]
713mod tests {
714 use std::path::PathBuf;
715
716 use tempfile::TempDir;
717
718 use crate::backend::MemBackend;
719 use crate::engine::test_helpers::*;
720 use crate::engine::{Engine, EngineError, RenameEntityArgs};
721 use crate::ops::WarningHint;
722 use crate::storage::FilesystemMemWriter;
723
724 #[test]
725 fn rename_moves_entity_anchors_to_new_id() {
726 let tmp = TempDir::new().unwrap();
727 let mem_dir = tmp.path().to_path_buf();
728 let writer = FilesystemMemWriter::new(mem_dir.clone());
729 let mut engine = Engine::from_mounts(vec![(
730 folder_mount("specs", mem_dir.clone()),
731 Box::new(writer) as Box<dyn MemBackend>,
732 )])
733 .unwrap();
734 let (actor, client) = cli_actor();
735 let mut args = empty_create_args("specs", "Old Anchored");
736 args.anchors = vec![crate::anchor::AnchorInput {
737 artifact: Some("src/lib.rs".into()),
738 grain: Some("file".into()),
739 class: Some("anchored".into()),
740 hash: Some("h1".into()),
741 hash_stability: Some("stable".into()),
742 ..Default::default()
743 }];
744 let seeded = engine
745 .create_entity(args, actor, Some(&client), None)
746 .unwrap();
747 let old_id = seeded.id.clone();
748
749 let outcome = engine
750 .rename_entity(
751 RenameEntityArgs {
752 id: old_id.clone(),
753 expected_hash: Some(seeded.content_hash.clone()),
754 new_title: "New Anchored".to_string(),
755 },
756 actor,
757 Some(&client),
758 None,
759 )
760 .unwrap();
761
762 assert!(engine.entity_anchors(&old_id).is_empty());
764 assert_eq!(engine.entity_anchors(&outcome.new_id).len(), 1);
765 assert_eq!(
766 engine.anchors_referencing_artifact("src/lib.rs"),
767 vec![(
768 outcome.new_id.clone(),
769 engine.entity_anchors(&outcome.new_id)[0].clone()
770 )]
771 );
772 }
773
774 #[test]
775 fn rename_entity_renames_file_and_id_persists_across_restart() {
776 let tmp = TempDir::new().unwrap();
777 let mem_dir = tmp.path().to_path_buf();
778
779 let (old_id, new_id, new_file) = {
780 let writer = FilesystemMemWriter::new(mem_dir.clone());
781 let mut engine = Engine::from_mounts(vec![(
782 folder_mount("specs", mem_dir.clone()),
783 Box::new(writer) as Box<dyn MemBackend>,
784 )])
785 .unwrap();
786 let (actor, client) = cli_actor();
787 let seeded = engine
788 .create_entity(
789 empty_create_args("specs", "Old Name"),
790 actor,
791 Some(&client),
792 None,
793 )
794 .unwrap();
795 let outcome = engine
796 .rename_entity(
797 RenameEntityArgs {
798 id: seeded.id.clone(),
799 expected_hash: Some(seeded.content_hash.clone()),
800 new_title: "New Name".to_string(),
801 },
802 actor,
803 Some(&client),
804 None,
805 )
806 .unwrap();
807 assert_eq!(outcome.old_id.to_string(), "specs--old-name");
808 assert_eq!(outcome.new_id.to_string(), "specs--new-name");
809 assert_eq!(outcome.new_path, "new-name.md");
810 assert!(!mem_dir.join(&outcome.old_path).exists());
812 assert!(mem_dir.join(&outcome.new_path).exists());
813 (outcome.old_id, outcome.new_id, outcome.new_path)
814 };
815
816 let writer2 = FilesystemMemWriter::new(mem_dir.clone());
818 let engine2 = Engine::from_mounts(vec![(
819 folder_mount("specs", mem_dir),
820 Box::new(writer2) as Box<dyn MemBackend>,
821 )])
822 .unwrap();
823 assert!(engine2.get_entity(&old_id).is_none());
824 let new_entity = engine2.get_entity(&new_id).expect("new id must persist");
825 assert_eq!(new_entity.title, "New Name");
826 assert_eq!(new_entity.file_path, new_file);
827 }
828
829 #[test]
830 fn rename_entity_returns_typed_warning_on_slug_noop() {
831 let tmp = TempDir::new().unwrap();
832 let (mut engine, seeded) = engine_with_seed(&tmp, "Same Slug");
833 let (actor, client) = cli_actor();
834 let outcome = engine
835 .rename_entity(
836 RenameEntityArgs {
837 id: seeded.id.clone(),
838 expected_hash: Some(seeded.content_hash.clone()),
839 new_title: "Same Slug".to_string(), },
841 actor,
842 Some(&client),
843 None,
844 )
845 .unwrap();
846 assert_eq!(outcome.old_id, outcome.new_id);
851 assert_eq!(outcome.old_path, outcome.new_path);
852 assert!(outcome.write_id.is_empty());
853 assert_eq!(outcome.warnings.len(), 1);
854 assert!(matches!(
855 outcome.warnings[0],
856 WarningHint::TitleNormalizedToSlugNoop { .. }
857 ));
858 }
859
860 #[test]
861 fn rename_entity_returns_write_id_on_real_rename() {
862 let tmp = TempDir::new().unwrap();
863 let (mut engine, seeded) = engine_with_seed(&tmp, "Old Name");
864 let (actor, client) = cli_actor();
865 let outcome = engine
866 .rename_entity(
867 RenameEntityArgs {
868 id: seeded.id.clone(),
869 expected_hash: Some(seeded.content_hash.clone()),
870 new_title: "Brand New Name".to_string(),
871 },
872 actor,
873 Some(&client),
874 None,
875 )
876 .unwrap();
877 assert_ne!(outcome.old_id, outcome.new_id);
880 assert!(
881 !outcome.write_id.is_empty(),
882 "write_id must be populated on a real rename"
883 );
884 assert!(outcome.warnings.is_empty());
885 }
886
887 #[test]
888 fn rename_entity_rewrites_self_references_in_body_and_relationships() {
889 use crate::entity::EntityId;
890 use indexmap::IndexMap;
891
892 let tmp = TempDir::new().unwrap();
893 let mem_dir = tmp.path().to_path_buf();
894 let writer = FilesystemMemWriter::new(mem_dir.clone());
895 let mut engine = Engine::from_mounts(vec![(
896 folder_mount("specs", mem_dir.clone()),
897 Box::new(writer) as Box<dyn MemBackend>,
898 )])
899 .unwrap();
900 let (actor, client) = cli_actor();
901
902 let mut sections: IndexMap<String, String> = IndexMap::new();
908 sections.insert("identity".to_string(), "the seed identity".to_string());
909 sections.insert(
910 "purpose".to_string(),
911 "see also [[old-name]] for prior context".to_string(),
912 );
913 let seeded = engine
920 .create_entity(
921 crate::engine::CreateEntityArgs {
922 anchors: Vec::new(),
923 mem: "specs".to_string(),
924 title: "Old Name".to_string(),
925 entity_type: "spec".to_string(),
926 sections,
927 metadata: IndexMap::new(),
928 relations: Vec::new(),
929 dry_run: false,
930 },
931 actor,
932 Some(&client),
933 None,
934 )
935 .unwrap();
936 assert_eq!(seeded.id.to_string(), "specs--old-name");
937 let related = seeded.clone();
938
939 let outcome = engine
940 .rename_entity(
941 RenameEntityArgs {
942 id: seeded.id.clone(),
943 expected_hash: Some(related.content_hash.clone()),
944 new_title: "Brand New Name".to_string(),
945 },
946 actor,
947 Some(&client),
948 None,
949 )
950 .unwrap();
951 assert_eq!(outcome.new_id.to_string(), "specs--brand-new-name");
952
953 let new_bytes = std::fs::read_to_string(mem_dir.join(&outcome.new_path)).unwrap();
956 assert!(
957 new_bytes.contains("[[brand-new-name]]"),
958 "expected new slug in body, got:\n{new_bytes}"
959 );
960 assert!(
961 !new_bytes.contains("[[old-name]]"),
962 "old slug must not survive in the rewritten file, got:\n{new_bytes}"
963 );
964
965 let in_mem = engine.get_entity(&outcome.new_id).unwrap();
968 assert!(
969 in_mem
970 .sections
971 .get("purpose")
972 .map(|s| s.contains("[[brand-new-name]]"))
973 .unwrap_or(false),
974 "section body must be rewritten in-memory; got {:?}",
975 in_mem.sections.get("purpose")
976 );
977 let new_self_target = EntityId::new("specs", "brand-new-name");
981 assert!(
982 in_mem
983 .relationships
984 .iter()
985 .all(|r| r.target != seeded.id && r.target != new_self_target),
986 "a self-referential body link must produce no self-relation (F11), got: {:?}",
987 in_mem.relationships
988 );
989 }
990
991 #[test]
1000 fn rename_preserves_referrer_last_modified_but_rewrites_link() {
1001 let tmp = TempDir::new().unwrap();
1002 let mem_dir = tmp.path().to_path_buf();
1003
1004 std::fs::write(
1005 mem_dir.join("target.md"),
1006 "---\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",
1007 )
1008 .unwrap();
1009 std::fs::write(
1010 mem_dir.join("referrer.md"),
1011 "---\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",
1012 )
1013 .unwrap();
1014
1015 let writer = FilesystemMemWriter::new(mem_dir.clone());
1016 let mut engine = Engine::from_mounts(vec![(
1017 folder_mount("specs", mem_dir.clone()),
1018 Box::new(writer) as Box<dyn MemBackend>,
1019 )])
1020 .unwrap();
1021 let (actor, client) = cli_actor();
1022
1023 let target_id = crate::entity::EntityId::new("specs", "target");
1024 let target_hash = engine
1025 .store()
1026 .get(&target_id)
1027 .expect("target loaded from disk")
1028 .content_hash
1029 .clone();
1030
1031 engine
1032 .rename_entity(
1033 RenameEntityArgs {
1034 id: target_id,
1035 expected_hash: Some(target_hash),
1036 new_title: "Target Renamed".to_string(),
1037 },
1038 actor,
1039 Some(&client),
1040 None,
1041 )
1042 .expect("rename succeeds");
1043
1044 let referrer_md = std::fs::read_to_string(mem_dir.join("referrer.md")).unwrap();
1045 assert!(
1047 referrer_md.contains("last_modified: 2020-01-01"),
1048 "referrer's last_modified must be preserved across a rename-driven slug rewrite; got:\n{referrer_md}"
1049 );
1050 assert!(
1052 referrer_md.contains("[[target-renamed]]"),
1053 "referrer's body wiki-link must be rewritten to the new slug; got:\n{referrer_md}"
1054 );
1055 assert!(
1056 !referrer_md.contains("[[target]]"),
1057 "old slug must not survive in the referrer body; got:\n{referrer_md}"
1058 );
1059 }
1060
1061 #[test]
1070 fn rename_rewrites_prose_only_referrer_without_relationships_row() {
1071 let tmp = TempDir::new().unwrap();
1072 let mem_dir = tmp.path().to_path_buf();
1073
1074 std::fs::write(
1075 mem_dir.join("target.md"),
1076 "---\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",
1077 )
1078 .unwrap();
1079 std::fs::write(
1081 mem_dir.join("referrer.md"),
1082 "---\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",
1083 )
1084 .unwrap();
1085
1086 let writer = FilesystemMemWriter::new(mem_dir.clone());
1087 let mut engine = Engine::from_mounts(vec![(
1088 folder_mount("specs", mem_dir.clone()),
1089 Box::new(writer) as Box<dyn MemBackend>,
1090 )])
1091 .unwrap();
1092 let (actor, client) = cli_actor();
1093
1094 let target_id = crate::entity::EntityId::new("specs", "target");
1095 let target_hash = engine
1096 .store()
1097 .get(&target_id)
1098 .expect("target loaded from disk")
1099 .content_hash
1100 .clone();
1101
1102 engine
1103 .rename_entity(
1104 RenameEntityArgs {
1105 id: target_id,
1106 expected_hash: Some(target_hash),
1107 new_title: "Target Renamed".to_string(),
1108 },
1109 actor,
1110 Some(&client),
1111 None,
1112 )
1113 .expect("rename succeeds");
1114
1115 let referrer_md = std::fs::read_to_string(mem_dir.join("referrer.md")).unwrap();
1116 assert!(
1117 referrer_md.contains("[[target-renamed]]"),
1118 "the prose-only wiki-link must be rewritten to the new slug; got:\n{referrer_md}"
1119 );
1120 assert!(
1121 !referrer_md.contains("[[target]]"),
1122 "the old slug must not survive in the prose-only referrer; got:\n{referrer_md}"
1123 );
1124 }
1125
1126 #[test]
1127 fn rename_entity_rewrites_same_mem_referrers_atomically() {
1128 use crate::engine::CreateEntityArgs;
1129 use crate::entity::EntityId;
1130 use indexmap::IndexMap;
1131
1132 let tmp = TempDir::new().unwrap();
1133 let mem_dir = tmp.path().to_path_buf();
1134 let writer = FilesystemMemWriter::new(mem_dir.clone());
1135 let mut engine = Engine::from_mounts(vec![(
1136 folder_mount("specs", mem_dir.clone()),
1137 Box::new(writer) as Box<dyn MemBackend>,
1138 )])
1139 .unwrap();
1140 let (actor, client) = cli_actor();
1141
1142 let target = engine
1144 .create_entity(
1145 empty_create_args("specs", "Target Spec"),
1146 actor,
1147 Some(&client),
1148 None,
1149 )
1150 .unwrap();
1151 assert_eq!(target.id.to_string(), "specs--target-spec");
1152
1153 let mut sections_a: IndexMap<String, String> = IndexMap::new();
1156 sections_a.insert(
1157 "identity".to_string(),
1158 "referrer alpha identity".to_string(),
1159 );
1160 sections_a.insert(
1161 "purpose".to_string(),
1162 "rationale relies on [[target-spec]] for context".to_string(),
1163 );
1164 let referrer_a = engine
1165 .create_entity(
1166 CreateEntityArgs {
1167 anchors: Vec::new(),
1168 mem: "specs".to_string(),
1169 title: "Referrer Alpha".to_string(),
1170 entity_type: "spec".to_string(),
1171 sections: sections_a,
1172 metadata: IndexMap::new(),
1173 relations: Vec::new(),
1177 dry_run: false,
1178 },
1179 actor,
1180 Some(&client),
1181 None,
1182 )
1183 .unwrap();
1184
1185 let mut sections_b: IndexMap<String, String> = IndexMap::new();
1188 sections_b.insert("identity".to_string(), "referrer beta identity".to_string());
1189 sections_b.insert(
1190 "purpose".to_string(),
1191 "consult [[target-spec]] for the canonical phrasing".to_string(),
1192 );
1193 let referrer_b = engine
1194 .create_entity(
1195 CreateEntityArgs {
1196 anchors: Vec::new(),
1197 mem: "specs".to_string(),
1198 title: "Referrer Bravo".to_string(),
1199 entity_type: "spec".to_string(),
1200 sections: sections_b,
1201 metadata: IndexMap::new(),
1202 relations: Vec::new(),
1206 dry_run: false,
1207 },
1208 actor,
1209 Some(&client),
1210 None,
1211 )
1212 .unwrap();
1213
1214 let bystander = engine
1217 .create_entity(
1218 empty_create_args("specs", "Bystander"),
1219 actor,
1220 Some(&client),
1221 None,
1222 )
1223 .unwrap();
1224 let bystander_bytes_before =
1225 std::fs::read_to_string(mem_dir.join(&bystander.file_path)).unwrap();
1226
1227 let renamed = engine
1228 .rename_entity(
1229 RenameEntityArgs {
1230 id: target.id.clone(),
1231 expected_hash: Some(target.content_hash.clone()),
1232 new_title: "Renamed Spec".to_string(),
1233 },
1234 actor,
1235 Some(&client),
1236 None,
1237 )
1238 .unwrap();
1239 assert_eq!(renamed.new_id.to_string(), "specs--renamed-spec");
1240
1241 for path in std::fs::read_dir(&mem_dir).unwrap().flatten() {
1244 let p = path.path();
1245 if p.extension().and_then(|s| s.to_str()) != Some("md") {
1246 continue;
1247 }
1248 let body = std::fs::read_to_string(&p).unwrap();
1249 assert!(
1250 !body.contains("[[target-spec]]"),
1251 "old slug must not survive in {}, got:\n{body}",
1252 p.display()
1253 );
1254 }
1255
1256 let in_mem_a = engine.get_entity(&referrer_a.id).unwrap();
1258 assert!(
1259 in_mem_a
1260 .relationships
1261 .iter()
1262 .any(|r| r.rel_type == "REFERENCES"
1263 && r.target == EntityId::new("specs", "renamed-spec")),
1264 "expected referrer A's relation to point at renamed-spec, got {:?}",
1265 in_mem_a.relationships
1266 );
1267 assert!(
1268 in_mem_a
1269 .sections
1270 .get("purpose")
1271 .map(|s| s.contains("[[renamed-spec]]"))
1272 .unwrap_or(false),
1273 "referrer A's body must be rewritten"
1274 );
1275
1276 let in_mem_b = engine.get_entity(&referrer_b.id).unwrap();
1279 assert!(
1280 in_mem_b
1281 .sections
1282 .get("purpose")
1283 .map(|s| s.contains("[[renamed-spec]]"))
1284 .unwrap_or(false),
1285 "referrer B's body must be rewritten"
1286 );
1287
1288 let bystander_bytes_after =
1290 std::fs::read_to_string(mem_dir.join(&bystander.file_path)).unwrap();
1291 assert_eq!(
1292 bystander_bytes_before, bystander_bytes_after,
1293 "bystander must not be rewritten"
1294 );
1295 }
1296
1297 fn engine_with_two_mems_and_bidirectional_policy(
1303 specs_dir: PathBuf,
1304 memos_dir: PathBuf,
1305 ) -> Engine {
1306 use memstead_schema::workspace_config::CrossLinkValue;
1307 let writer_specs = FilesystemMemWriter::new(specs_dir.clone());
1308 let writer_memos = FilesystemMemWriter::new(memos_dir.clone());
1309 let mut engine = Engine::from_mounts(vec![
1310 (
1311 folder_mount("specs", specs_dir),
1312 Box::new(writer_specs) as Box<dyn MemBackend>,
1313 ),
1314 (
1315 folder_mount("memos", memos_dir),
1316 Box::new(writer_memos) as Box<dyn MemBackend>,
1317 ),
1318 ])
1319 .unwrap();
1320 let mut settings = crate::workspace::WorkspaceSettings::default();
1321 settings.cross_mem_links.insert(
1322 "memos".to_string(),
1323 CrossLinkValue::List(vec!["specs".to_string()]),
1324 );
1325 settings.cross_mem_links.insert(
1326 "specs".to_string(),
1327 CrossLinkValue::List(vec!["memos".to_string()]),
1328 );
1329 engine.set_settings(settings);
1330 engine
1331 }
1332
1333 #[test]
1334 fn rename_entity_rewrites_cross_mem_write_referrer() {
1335 use crate::engine::CreateEntityArgs;
1336 use crate::entity::EntityId;
1337 use indexmap::IndexMap;
1338
1339 let tmp_specs = TempDir::new().unwrap();
1340 let tmp_memos = TempDir::new().unwrap();
1341 let specs_dir = tmp_specs.path().to_path_buf();
1342 let memos_dir = tmp_memos.path().to_path_buf();
1343 let mut engine =
1344 engine_with_two_mems_and_bidirectional_policy(specs_dir.clone(), memos_dir.clone());
1345 let (actor, client) = cli_actor();
1346
1347 let target = engine
1349 .create_entity(
1350 empty_create_args("specs", "Target Spec"),
1351 actor,
1352 Some(&client),
1353 None,
1354 )
1355 .unwrap();
1356
1357 let mut sections: IndexMap<String, String> = IndexMap::new();
1364 sections.insert("claim".to_string(), "the claim".to_string());
1365 sections.insert(
1366 "context".to_string(),
1367 "discussion stems from [[specs:target-spec]]".to_string(),
1368 );
1369 let referrer = engine
1370 .create_entity(
1371 CreateEntityArgs {
1372 anchors: Vec::new(),
1373 mem: "memos".to_string(),
1374 title: "Cross Note".to_string(),
1375 entity_type: "memo".to_string(),
1376 sections,
1377 metadata: IndexMap::new(),
1378 relations: Vec::new(),
1382 dry_run: false,
1383 },
1384 actor,
1385 Some(&client),
1386 None,
1387 )
1388 .unwrap();
1389
1390 let renamed = engine
1392 .rename_entity(
1393 RenameEntityArgs {
1394 id: target.id.clone(),
1395 expected_hash: Some(target.content_hash.clone()),
1396 new_title: "Renamed Spec".to_string(),
1397 },
1398 actor,
1399 Some(&client),
1400 None,
1401 )
1402 .unwrap();
1403 assert_eq!(renamed.new_id.to_string(), "specs--renamed-spec");
1404
1405 let referrer_path = memos_dir.join(&referrer.file_path);
1408 let referrer_bytes = std::fs::read_to_string(&referrer_path).unwrap();
1409 assert!(
1410 referrer_bytes.contains("[[specs:renamed-spec]]"),
1411 "expected colon-form rewrite in referrer body, got:\n{referrer_bytes}"
1412 );
1413 assert!(
1414 !referrer_bytes.contains("target-spec"),
1415 "old slug must not survive in referrer file, got:\n{referrer_bytes}"
1416 );
1417
1418 let in_mem = engine.get_entity(&referrer.id).unwrap();
1420 assert!(
1421 in_mem
1422 .relationships
1423 .iter()
1424 .any(|r| r.rel_type == "REFERENCES"
1425 && r.target == EntityId::new("specs", "renamed-spec")),
1426 "expected cross-mem relation to point at renamed-spec, got {:?}",
1427 in_mem.relationships
1428 );
1429 assert!(
1430 in_mem.relationships.iter().all(|r| r.target != target.id),
1431 "no relationship may still target the old id, got: {:?}",
1432 in_mem.relationships
1433 );
1434 }
1435
1436 struct DriftingBackend {
1443 inner: Box<dyn MemBackend>,
1444 }
1445 impl DriftingBackend {
1446 fn new(inner: Box<dyn MemBackend>) -> Self {
1447 Self { inner }
1448 }
1449 }
1450 impl crate::backend::MemBackend for DriftingBackend {
1451 fn list_entities(&self) -> Result<Vec<PathBuf>, crate::backend::BackendError> {
1452 self.inner.list_entities()
1453 }
1454 fn read_entity(
1455 &self,
1456 rel: &std::path::Path,
1457 ) -> Result<Option<Vec<u8>>, crate::backend::BackendError> {
1458 self.inner.read_entity(rel)
1459 }
1460 fn write_entity(
1461 &self,
1462 rel: &std::path::Path,
1463 b: &[u8],
1464 ) -> Result<(), crate::backend::BackendError> {
1465 self.inner.write_entity(rel, b)
1466 }
1467 fn delete_entity(&self, rel: &std::path::Path) -> Result<(), crate::backend::BackendError> {
1468 self.inner.delete_entity(rel)
1469 }
1470 fn move_entity(
1471 &self,
1472 f: &std::path::Path,
1473 t: &std::path::Path,
1474 ) -> Result<(), crate::backend::BackendError> {
1475 self.inner.move_entity(f, t)
1476 }
1477 fn commit(
1478 &self,
1479 m: &str,
1480 c: &crate::vcs::CommitContext<'_>,
1481 ) -> Result<crate::storage::CommitId, crate::backend::BackendError> {
1482 self.inner.commit(m, c)
1483 }
1484 fn commit_with_expected_parent(
1485 &self,
1486 m: &str,
1487 c: &crate::vcs::CommitContext<'_>,
1488 expected_parent: Option<&str>,
1489 ) -> Result<crate::storage::CommitId, crate::backend::BackendError> {
1490 if let Some(expected) = expected_parent {
1491 Err(crate::backend::BackendError::ParentMismatch {
1492 expected: expected.to_string(),
1493 actual: "drifted-by-sibling-writer".to_string(),
1494 })
1495 } else {
1496 self.inner.commit(m, c)
1497 }
1498 }
1499 fn append_provenance(
1500 &self,
1501 r: &crate::Provenance,
1502 ) -> Result<(), crate::backend::BackendError> {
1503 self.inner.append_provenance(r)
1504 }
1505 fn read_provenance(
1506 &self,
1507 c: Option<&str>,
1508 ) -> Result<Vec<crate::Provenance>, crate::backend::BackendError> {
1509 self.inner.read_provenance(c)
1510 }
1511 fn current_head(&self) -> Result<Option<String>, crate::backend::BackendError> {
1512 Ok(Some("snapshot-head-sha".to_string()))
1515 }
1516 }
1517
1518 #[test]
1519 fn rename_entity_surfaces_partial_failure_when_peer_mem_drifts() {
1520 use crate::engine::CreateEntityArgs;
1521 use indexmap::IndexMap;
1522 use memstead_schema::workspace_config::CrossLinkValue;
1523
1524 let tmp_specs = TempDir::new().unwrap();
1525 let tmp_memos = TempDir::new().unwrap();
1526 let specs_dir = tmp_specs.path().to_path_buf();
1527 let memos_dir = tmp_memos.path().to_path_buf();
1528
1529 let writer_specs = FilesystemMemWriter::new(specs_dir.clone());
1534 let writer_memos_inner: Box<dyn MemBackend> =
1535 Box::new(FilesystemMemWriter::new(memos_dir.clone()));
1536 let writer_memos = DriftingBackend::new(writer_memos_inner);
1537
1538 let mut engine = Engine::from_mounts(vec![
1539 (
1540 folder_mount("specs", specs_dir.clone()),
1541 Box::new(writer_specs) as Box<dyn MemBackend>,
1542 ),
1543 (
1544 folder_mount("memos", memos_dir.clone()),
1545 Box::new(writer_memos) as Box<dyn MemBackend>,
1546 ),
1547 ])
1548 .unwrap();
1549 let mut settings = crate::workspace::WorkspaceSettings::default();
1550 settings.cross_mem_links.insert(
1551 "memos".to_string(),
1552 CrossLinkValue::List(vec!["specs".to_string()]),
1553 );
1554 settings.cross_mem_links.insert(
1555 "specs".to_string(),
1556 CrossLinkValue::List(vec!["memos".to_string()]),
1557 );
1558 engine.set_settings(settings);
1559
1560 let (actor, client) = cli_actor();
1561 let target = engine
1562 .create_entity(
1563 empty_create_args("specs", "Target Spec"),
1564 actor,
1565 Some(&client),
1566 None,
1567 )
1568 .unwrap();
1569 let mut sections: IndexMap<String, String> = IndexMap::new();
1570 sections.insert("claim".to_string(), "the claim".to_string());
1571 sections.insert(
1572 "context".to_string(),
1573 "see [[specs:target-spec]]".to_string(),
1574 );
1575 let _referrer = engine
1576 .create_entity(
1577 CreateEntityArgs {
1578 anchors: Vec::new(),
1579 mem: "memos".to_string(),
1580 title: "Cross Note".to_string(),
1581 entity_type: "memo".to_string(),
1582 sections,
1583 metadata: IndexMap::new(),
1584 relations: Vec::new(),
1588 dry_run: false,
1589 },
1590 actor,
1591 Some(&client),
1592 None,
1593 )
1594 .unwrap();
1595
1596 let err = engine
1597 .rename_entity(
1598 RenameEntityArgs {
1599 id: target.id.clone(),
1600 expected_hash: Some(target.content_hash.clone()),
1601 new_title: "Renamed Spec".to_string(),
1602 },
1603 actor,
1604 Some(&client),
1605 None,
1606 )
1607 .unwrap_err();
1608 match err {
1609 EngineError::RenamePartialFailure {
1610 committed_mems,
1611 failed_mem,
1612 failure_cause,
1613 } => {
1614 assert_eq!(committed_mems, vec!["specs".to_string()]);
1618 assert_eq!(failed_mem, "memos");
1619 assert_eq!(failure_cause, "drift");
1620 }
1621 other => panic!("expected RenamePartialFailure, got {other:?}"),
1622 }
1623 assert!(specs_dir.join("renamed-spec.md").exists());
1627 assert!(!specs_dir.join(&target.file_path).exists());
1628 }
1629
1630 #[test]
1631 fn rename_entity_tags_every_per_mem_commit_with_same_logical_operation_id() {
1632 use crate::backend::MemBackend;
1633 use crate::engine::CreateEntityArgs;
1634 use indexmap::IndexMap;
1635
1636 let tmp_specs = TempDir::new().unwrap();
1637 let tmp_memos = TempDir::new().unwrap();
1638 let specs_dir = tmp_specs.path().to_path_buf();
1639 let memos_dir = tmp_memos.path().to_path_buf();
1640 let mut engine =
1641 engine_with_two_mems_and_bidirectional_policy(specs_dir.clone(), memos_dir.clone());
1642 let (actor, client) = cli_actor();
1643
1644 let target = engine
1645 .create_entity(
1646 empty_create_args("specs", "Target Spec"),
1647 actor,
1648 Some(&client),
1649 None,
1650 )
1651 .unwrap();
1652 let mut sections: IndexMap<String, String> = IndexMap::new();
1653 sections.insert("claim".to_string(), "the claim".to_string());
1654 sections.insert(
1655 "context".to_string(),
1656 "discussion stems from [[specs:target-spec]]".to_string(),
1657 );
1658 let _referrer = engine
1659 .create_entity(
1660 CreateEntityArgs {
1661 anchors: Vec::new(),
1662 mem: "memos".to_string(),
1663 title: "Cross Note".to_string(),
1664 entity_type: "memo".to_string(),
1665 sections,
1666 metadata: IndexMap::new(),
1667 relations: Vec::new(),
1671 dry_run: false,
1672 },
1673 actor,
1674 Some(&client),
1675 None,
1676 )
1677 .unwrap();
1678
1679 let _ = engine
1680 .rename_entity(
1681 RenameEntityArgs {
1682 id: target.id.clone(),
1683 expected_hash: Some(target.content_hash.clone()),
1684 new_title: "Renamed Spec".to_string(),
1685 },
1686 actor,
1687 Some(&client),
1688 None,
1689 )
1690 .unwrap();
1691
1692 let specs_backend: Box<dyn MemBackend> =
1696 Box::new(FilesystemMemWriter::new(specs_dir.clone()));
1697 let memos_backend: Box<dyn MemBackend> =
1698 Box::new(FilesystemMemWriter::new(memos_dir.clone()));
1699 let specs_provenance = specs_backend.read_provenance(None).unwrap();
1700 let memos_provenance = memos_backend.read_provenance(None).unwrap();
1701
1702 let specs_rename = specs_provenance
1703 .iter()
1704 .find(|p| matches!(p.kind, crate::provenance::ProvenanceKind::Rename))
1705 .expect("specs mem must have a rename provenance entry");
1706 let memos_rename = memos_provenance
1707 .iter()
1708 .find(|p| matches!(p.kind, crate::provenance::ProvenanceKind::Rename))
1709 .expect("memos mem must have a rename provenance entry");
1710
1711 let specs_id = specs_rename
1712 .logical_operation_id
1713 .as_deref()
1714 .expect("specs rename entry must carry a logical_operation_id");
1715 let memos_id = memos_rename
1716 .logical_operation_id
1717 .as_deref()
1718 .expect("memos rename entry must carry a logical_operation_id");
1719 assert_eq!(
1720 specs_id, memos_id,
1721 "both per-mem rename commits must share the same logical_operation_id"
1722 );
1723 assert!(
1724 specs_id.starts_with("logop-"),
1725 "logical_operation_id must use the `logop-` prefix the engine mints; got {specs_id}"
1726 );
1727 }
1728
1729 #[test]
1730 fn rename_entity_refuses_when_cross_mem_referrer_blocked_by_policy() {
1731 use crate::engine::CreateEntityArgs;
1732 use indexmap::IndexMap;
1733 use memstead_schema::workspace_config::CrossLinkValue;
1734
1735 let tmp_specs = TempDir::new().unwrap();
1736 let tmp_memos = TempDir::new().unwrap();
1737 let specs_dir = tmp_specs.path().to_path_buf();
1738 let memos_dir = tmp_memos.path().to_path_buf();
1739
1740 let mut engine =
1743 engine_with_two_mems_and_bidirectional_policy(specs_dir.clone(), memos_dir.clone());
1744 let (actor, client) = cli_actor();
1745
1746 let target = engine
1747 .create_entity(
1748 empty_create_args("specs", "Target Spec"),
1749 actor,
1750 Some(&client),
1751 None,
1752 )
1753 .unwrap();
1754 let mut sections: IndexMap<String, String> = IndexMap::new();
1755 sections.insert("claim".to_string(), "the claim".to_string());
1756 sections.insert(
1757 "context".to_string(),
1758 "see [[specs:target-spec]]".to_string(),
1759 );
1760 let _referrer = engine
1761 .create_entity(
1762 CreateEntityArgs {
1763 anchors: Vec::new(),
1764 mem: "memos".to_string(),
1765 title: "Cross Note".to_string(),
1766 entity_type: "memo".to_string(),
1767 sections,
1768 metadata: IndexMap::new(),
1769 relations: Vec::new(),
1773 dry_run: false,
1774 },
1775 actor,
1776 Some(&client),
1777 None,
1778 )
1779 .unwrap();
1780
1781 let mut settings = crate::workspace::WorkspaceSettings::default();
1787 settings.cross_mem_links.insert(
1788 "specs".to_string(),
1789 CrossLinkValue::List(vec!["memos".to_string()]),
1790 );
1791 engine.set_settings(settings);
1793
1794 let err = engine
1795 .rename_entity(
1796 RenameEntityArgs {
1797 id: target.id.clone(),
1798 expected_hash: Some(target.content_hash.clone()),
1799 new_title: "Renamed Spec".to_string(),
1800 },
1801 actor,
1802 Some(&client),
1803 None,
1804 )
1805 .unwrap_err();
1806 match err {
1807 EngineError::RenameBlockedByCrossMemPolicy {
1808 from_mem,
1809 blocked_referrers,
1810 } => {
1811 assert_eq!(from_mem, "specs");
1812 assert_eq!(blocked_referrers.len(), 1);
1813 assert_eq!(blocked_referrers[0].from_mem, "memos");
1814 assert_eq!(blocked_referrers[0].to_mem, "specs");
1815 assert_eq!(blocked_referrers[0].count, 1);
1816 }
1817 other => panic!("expected RenameBlockedByCrossMemPolicy, got {other:?}"),
1818 }
1819 assert!(specs_dir.join(&target.file_path).exists());
1822 assert!(!specs_dir.join("renamed-spec.md").exists());
1823 }
1824
1825 #[test]
1836 fn rename_entity_demotes_to_stub_when_only_readonly_cross_mem_referrers_remain() {
1837 use crate::engine::test_helpers::{archive_mount, build_archive};
1838 use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
1839
1840 let tmp = TempDir::new().unwrap();
1841 let writable_dir = tmp.path().join("writable");
1842 std::fs::create_dir_all(&writable_dir).unwrap();
1843 let writer = FilesystemMemWriter::new(writable_dir.clone());
1844
1845 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";
1849 let archive_path = build_archive(
1850 tmp.path(),
1851 "archive",
1852 &[("archived-source.md", archive_md.as_bytes())],
1853 );
1854
1855 let folder_mount = Mount {
1856 mem: "specs".to_string(),
1857 schema: Some(crate::engine::test_helpers::pin("default")),
1858 storage: MountStorage::Folder {
1859 path: writable_dir.clone(),
1860 },
1861 capability: MountCapability::Write,
1862 lifecycle: MountLifecycle::Eager,
1863 cross_linkable: true,
1864 migration_target: None,
1865 };
1866 let archive_reader = crate::storage::ArchiveBackend::new(archive_path.clone());
1867 let mut engine = Engine::from_mounts(vec![
1868 (folder_mount, Box::new(writer) as Box<dyn MemBackend>),
1869 (
1870 archive_mount("archive", archive_path.clone()),
1871 Box::new(archive_reader) as Box<dyn MemBackend>,
1872 ),
1873 ])
1874 .unwrap();
1875
1876 let (actor, client) = cli_actor();
1877 let target = engine
1878 .create_entity(
1879 empty_create_args("specs", "Target"),
1880 actor,
1881 Some(&client),
1882 None,
1883 )
1884 .unwrap();
1885
1886 let archived_source_id = crate::EntityId::new("archive", "archived-source");
1889 let incoming_pre: Vec<_> = engine
1890 .store()
1891 .incoming(&target.id)
1892 .iter()
1893 .map(|e| e.from.clone())
1894 .collect();
1895 assert!(
1896 incoming_pre.contains(&archived_source_id),
1897 "archive wiki-link must produce an incoming edge on target; got {incoming_pre:?}"
1898 );
1899
1900 let outcome = engine
1903 .rename_entity(
1904 RenameEntityArgs {
1905 id: target.id.clone(),
1906 expected_hash: Some(target.content_hash.clone()),
1907 new_title: "Renamed Target".to_string(),
1908 },
1909 actor,
1910 Some(&client),
1911 None,
1912 )
1913 .unwrap();
1914 assert_eq!(outcome.new_id.to_string(), "specs--renamed-target");
1915
1916 assert!(writable_dir.join(&outcome.new_path).exists());
1918 let demoted = engine
1920 .get_entity(&target.id)
1921 .expect("residual stub must remain at old id");
1922 assert!(demoted.stub, "demoted entity must be flagged as stub");
1923 assert!(demoted.entity_type.is_empty());
1924 let incoming_old: Vec<_> = engine
1927 .store()
1928 .incoming(&target.id)
1929 .iter()
1930 .map(|e| e.from.clone())
1931 .collect();
1932 assert!(
1933 incoming_old.contains(&archived_source_id),
1934 "archive incoming edge must survive demotion at old id; got {incoming_old:?}"
1935 );
1936 let incoming_new: Vec<_> = engine
1939 .store()
1940 .incoming(&outcome.new_id)
1941 .iter()
1942 .map(|e| e.from.clone())
1943 .collect();
1944 assert!(
1945 !incoming_new.contains(&archived_source_id),
1946 "archive must not be wired to new id (markdown still references old slug); got {incoming_new:?}"
1947 );
1948 let referrers = outcome
1950 .warnings
1951 .iter()
1952 .find_map(|w| match w {
1953 WarningHint::ResidualStubForReadOnlyReferrers {
1954 id: warn_id,
1955 referrers,
1956 } => {
1957 assert_eq!(warn_id, &target.id);
1958 Some(referrers.clone())
1959 }
1960 _ => None,
1961 })
1962 .expect("ResidualStubForReadOnlyReferrers warning must surface");
1963 assert_eq!(referrers, vec![archived_source_id]);
1964 }
1965
1966 #[test]
1974 fn rename_entity_rewrites_full_id_form_body_link_on_same_mem_referrer() {
1975 use crate::engine::CreateEntityArgs;
1976 use indexmap::IndexMap;
1977 let tmp = TempDir::new().unwrap();
1978 let mem_dir = tmp.path().to_path_buf();
1979 let writer = FilesystemMemWriter::new(mem_dir.clone());
1980 let mut engine = Engine::from_mounts(vec![(
1981 folder_mount("specs", mem_dir.clone()),
1982 Box::new(writer) as Box<dyn MemBackend>,
1983 )])
1984 .unwrap();
1985 let (actor, client) = cli_actor();
1986
1987 let target = engine
1989 .create_entity(
1990 empty_create_args("specs", "Target Spec"),
1991 actor,
1992 Some(&client),
1993 None,
1994 )
1995 .unwrap();
1996 assert_eq!(target.id.to_string(), "specs--target-spec");
1997
1998 let mut sections: IndexMap<String, String> = IndexMap::new();
2003 sections.insert("identity".to_string(), "referrer identity".to_string());
2004 sections.insert(
2005 "purpose".to_string(),
2006 "see also [[specs--target-spec]] for context".to_string(),
2007 );
2008 let referrer = engine
2009 .create_entity(
2010 CreateEntityArgs {
2011 anchors: Vec::new(),
2012 mem: "specs".to_string(),
2013 title: "Referrer Full".to_string(),
2014 entity_type: "spec".to_string(),
2015 sections,
2016 metadata: IndexMap::new(),
2017 relations: Vec::new(),
2018 dry_run: false,
2019 },
2020 actor,
2021 Some(&client),
2022 None,
2023 )
2024 .unwrap();
2025
2026 let renamed = engine
2027 .rename_entity(
2028 RenameEntityArgs {
2029 id: target.id.clone(),
2030 expected_hash: Some(target.content_hash.clone()),
2031 new_title: "Renamed Spec".to_string(),
2032 },
2033 actor,
2034 Some(&client),
2035 None,
2036 )
2037 .unwrap();
2038 assert_eq!(renamed.new_id.to_string(), "specs--renamed-spec");
2039
2040 let referrer_path = mem_dir.join(&referrer.file_path);
2044 let body = std::fs::read_to_string(&referrer_path).unwrap();
2045 assert!(
2046 body.contains("[[specs--renamed-spec]]"),
2047 "expected full-id form retargeted to new slug, got:\n{body}"
2048 );
2049 assert!(
2050 !body.contains("target-spec"),
2051 "old slug must not survive in any form, got:\n{body}"
2052 );
2053 }
2054
2055 #[test]
2056 fn rename_entity_rejects_collision_with_existing_id() {
2057 let tmp = TempDir::new().unwrap();
2058 let (mut engine, first) = engine_with_seed(&tmp, "First");
2059 let (actor, client) = cli_actor();
2060 let _ = engine
2061 .create_entity(
2062 empty_create_args("specs", "Second"),
2063 actor,
2064 Some(&client),
2065 None,
2066 )
2067 .unwrap();
2068 let err = engine
2070 .rename_entity(
2071 RenameEntityArgs {
2072 id: first.id.clone(),
2073 expected_hash: Some(first.content_hash.clone()),
2074 new_title: "Second".to_string(),
2075 },
2076 actor,
2077 Some(&client),
2078 None,
2079 )
2080 .unwrap_err();
2081 assert!(matches!(
2082 err,
2083 EngineError::AlreadyExists { ref id, ref existing_title, existing_is_stub: false }
2084 if id == "specs--second" && existing_title == "Second"
2085 ));
2086 }
2087
2088 #[test]
2095 fn rename_propagation_gate_checks_actual_edge_direction() {
2096 use crate::engine::error::BlockedReferrer;
2097 use crate::engine::{CreateEntityArgs, RelateEntityArgs};
2098 use indexmap::IndexMap;
2099 use memstead_schema::workspace_config::CrossLinkValue;
2100
2101 let tmp_test = TempDir::new().unwrap();
2102 let tmp_other = TempDir::new().unwrap();
2103 let test_dir = tmp_test.path().to_path_buf();
2104 let other_dir = tmp_other.path().to_path_buf();
2105
2106 let writer_test = FilesystemMemWriter::new(test_dir.clone());
2110 let writer_other = FilesystemMemWriter::new(other_dir.clone());
2111 let mut engine = Engine::from_mounts(vec![
2112 (
2113 folder_mount("test", test_dir.clone()),
2114 Box::new(writer_test) as Box<dyn MemBackend>,
2115 ),
2116 (
2117 folder_mount("other", other_dir.clone()),
2118 Box::new(writer_other) as Box<dyn MemBackend>,
2119 ),
2120 ])
2121 .unwrap();
2122 let (actor, client) = cli_actor();
2123
2124 let mut settings = crate::workspace::WorkspaceSettings::default();
2127 settings.cross_mem_links.insert(
2128 "test".to_string(),
2129 CrossLinkValue::List(vec!["other".to_string()]),
2130 );
2131 engine.set_settings(settings);
2132
2133 let target = engine
2134 .create_entity(
2135 empty_create_args("other", "Target"),
2136 actor,
2137 Some(&client),
2138 None,
2139 )
2140 .unwrap();
2141 let mut src_sections: IndexMap<String, String> = IndexMap::new();
2142 src_sections.insert("identity".to_string(), "source identity".to_string());
2143 src_sections.insert("purpose".to_string(), "source purpose".to_string());
2144 let src = engine
2145 .create_entity(
2146 CreateEntityArgs {
2147 anchors: Vec::new(),
2148 mem: "test".to_string(),
2149 title: "Src".to_string(),
2150 entity_type: "spec".to_string(),
2151 sections: src_sections,
2152 metadata: IndexMap::new(),
2153 relations: Vec::new(),
2154 dry_run: false,
2155 },
2156 actor,
2157 Some(&client),
2158 None,
2159 )
2160 .unwrap();
2161 let src = engine
2162 .relate_entity(
2163 RelateEntityArgs {
2164 source: src.id.clone(),
2165 rel_type: "IMPLEMENTS".to_string(),
2166 target: target.id.clone(),
2167 expected_hash: Some(src.content_hash.clone()),
2168 remove: false,
2169 description: None,
2170 dry_run: false,
2171 },
2172 actor,
2173 Some(&client),
2174 None,
2175 )
2176 .unwrap();
2177 let _ = src; engine.set_settings(crate::workspace::WorkspaceSettings::default());
2184
2185 let err = engine
2186 .rename_entity(
2187 RenameEntityArgs {
2188 id: target.id.clone(),
2189 expected_hash: Some(target.content_hash.clone()),
2190 new_title: "Renamed".to_string(),
2191 },
2192 actor,
2193 Some(&client),
2194 None,
2195 )
2196 .unwrap_err();
2197 match err {
2198 EngineError::RenameBlockedByCrossMemPolicy {
2199 from_mem,
2200 blocked_referrers,
2201 } => {
2202 assert_eq!(from_mem, "other");
2203 assert_eq!(
2204 blocked_referrers,
2205 vec![BlockedReferrer {
2206 from_mem: "test".to_string(),
2207 to_mem: "other".to_string(),
2208 count: 1,
2209 }],
2210 "blocked_referrers must name the actual edge direction (test → other)",
2211 );
2212 }
2213 other => panic!("expected RenameBlockedByCrossMemPolicy, got {other:?}"),
2214 }
2215 assert!(other_dir.join(&target.file_path).exists());
2218 assert!(!other_dir.join("renamed.md").exists());
2219 }
2220
2221 #[test]
2225 fn rename_propagation_succeeds_when_actual_edge_direction_granted() {
2226 use crate::engine::{CreateEntityArgs, RelateEntityArgs};
2227 use indexmap::IndexMap;
2228 use memstead_schema::workspace_config::CrossLinkValue;
2229
2230 let tmp_test = TempDir::new().unwrap();
2231 let tmp_other = TempDir::new().unwrap();
2232 let test_dir = tmp_test.path().to_path_buf();
2233 let other_dir = tmp_other.path().to_path_buf();
2234
2235 let writer_test = FilesystemMemWriter::new(test_dir.clone());
2236 let writer_other = FilesystemMemWriter::new(other_dir.clone());
2237 let mut engine = Engine::from_mounts(vec![
2238 (
2239 folder_mount("test", test_dir),
2240 Box::new(writer_test) as Box<dyn MemBackend>,
2241 ),
2242 (
2243 folder_mount("other", other_dir),
2244 Box::new(writer_other) as Box<dyn MemBackend>,
2245 ),
2246 ])
2247 .unwrap();
2248 let (actor, client) = cli_actor();
2249
2250 let mut settings = crate::workspace::WorkspaceSettings::default();
2251 settings.cross_mem_links.insert(
2252 "test".to_string(),
2253 CrossLinkValue::List(vec!["other".to_string()]),
2254 );
2255 engine.set_settings(settings);
2256
2257 let target = engine
2258 .create_entity(
2259 empty_create_args("other", "Target"),
2260 actor,
2261 Some(&client),
2262 None,
2263 )
2264 .unwrap();
2265 let mut src_sections: IndexMap<String, String> = IndexMap::new();
2266 src_sections.insert("identity".to_string(), "source identity".to_string());
2267 src_sections.insert("purpose".to_string(), "source purpose".to_string());
2268 let src = engine
2269 .create_entity(
2270 CreateEntityArgs {
2271 anchors: Vec::new(),
2272 mem: "test".to_string(),
2273 title: "Src".to_string(),
2274 entity_type: "spec".to_string(),
2275 sections: src_sections,
2276 metadata: IndexMap::new(),
2277 relations: Vec::new(),
2278 dry_run: false,
2279 },
2280 actor,
2281 Some(&client),
2282 None,
2283 )
2284 .unwrap();
2285 let _ = engine
2286 .relate_entity(
2287 RelateEntityArgs {
2288 source: src.id.clone(),
2289 rel_type: "IMPLEMENTS".to_string(),
2290 target: target.id.clone(),
2291 expected_hash: Some(src.content_hash.clone()),
2292 remove: false,
2293 description: None,
2294 dry_run: false,
2295 },
2296 actor,
2297 Some(&client),
2298 None,
2299 )
2300 .unwrap();
2301
2302 let outcome = engine
2305 .rename_entity(
2306 RenameEntityArgs {
2307 id: target.id.clone(),
2308 expected_hash: Some(target.content_hash.clone()),
2309 new_title: "Renamed".to_string(),
2310 },
2311 actor,
2312 Some(&client),
2313 None,
2314 )
2315 .unwrap();
2316 assert_ne!(outcome.old_id, outcome.new_id);
2317 let renamed = engine
2318 .get_entity(&outcome.new_id)
2319 .expect("renamed entity persists");
2320 assert_eq!(renamed.title, "Renamed");
2321 let updated_src = engine.get_entity(&src.id).expect("source persists");
2323 assert!(
2324 updated_src
2325 .relationships
2326 .iter()
2327 .any(|r| r.rel_type == "IMPLEMENTS" && r.target == outcome.new_id),
2328 "referrer's IMPLEMENTS edge must point at the new id after rewrite"
2329 );
2330 }
2331
2332 #[test]
2336 fn rename_with_no_cross_mem_referrers_succeeds_regardless_of_policy() {
2337 let tmp = TempDir::new().unwrap();
2338 let mem_dir = tmp.path().to_path_buf();
2339 let writer = FilesystemMemWriter::new(mem_dir.clone());
2340 let mut engine = Engine::from_mounts(vec![(
2341 folder_mount("specs", mem_dir),
2342 Box::new(writer) as Box<dyn MemBackend>,
2343 )])
2344 .unwrap();
2345 let (actor, client) = cli_actor();
2346 let target = engine
2350 .create_entity(
2351 empty_create_args("specs", "Target"),
2352 actor,
2353 Some(&client),
2354 None,
2355 )
2356 .unwrap();
2357 let outcome = engine
2358 .rename_entity(
2359 RenameEntityArgs {
2360 id: target.id.clone(),
2361 expected_hash: Some(target.content_hash.clone()),
2362 new_title: "Renamed Target".to_string(),
2363 },
2364 actor,
2365 Some(&client),
2366 None,
2367 )
2368 .unwrap();
2369 assert_ne!(outcome.old_id, outcome.new_id);
2370 }
2371}