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 mut args = args;
62 let (resolved, short_hint) = self.resolve_entity_id(&args.id)?;
63 args.id = resolved;
64 let id = &args.id;
65 let mem = id.mem().to_string();
66
67 let mount_idx = self
68 .mounts
69 .iter()
70 .position(|m| m.mount.mem == mem)
71 .ok_or_else(|| self.unknown_mem_error(&mem))?;
72 if self.mounts[mount_idx].mount.capability != MountCapability::Write {
73 return Err(EngineError::ReadOnlyMount(mem));
74 }
75
76 let mut drift_warnings: Vec<WarningHint> = short_hint.into_iter().collect();
81 drift_warnings.extend(self.reload_if_stale(Some(&mem)));
82
83 let entity = self
84 .store
85 .get(id)
86 .ok_or_else(|| EngineError::NotFound { id: id.to_string() })?;
87
88 if entity.stub {
94 return Err(EngineError::StubNotRenamable { id: id.to_string() });
95 }
96
97 if let Some(expected) = args.expected_hash.as_deref()
98 && entity.content_hash != expected
99 {
100 return Err(EngineError::HashMismatch {
101 id: id.to_string(),
102 current: entity.content_hash.clone(),
103 is_stub: entity.stub,
104 });
105 }
106
107 let derivation = validate_and_derive_slug(&args.new_title)?;
108 let new_slug = derivation.slug.clone();
109 let new_id = EntityId::new(&mem, &new_slug);
110 crate::entity::id::enforce_id_length(new_id.as_ref())?;
111
112 if new_id == *id {
113 return Ok(RenameEntityOutcome {
119 old_id: id.clone(),
120 new_id: new_id.clone(),
121 old_path: entity.file_path.clone(),
122 new_path: entity.file_path.clone(),
123 content_hash: entity.content_hash.clone(),
124 write_id: String::new(),
125 warnings: vec![WarningHint::TitleNormalizedToSlugNoop {
126 requested_title: args.new_title.clone(),
127 current_slug: id.name().to_string(),
128 }],
129 });
130 }
131 if let Some(existing) = self.store.get(&new_id) {
132 return Err(EngineError::AlreadyExists {
133 id: new_id.to_string(),
134 existing_title: existing.title.clone(),
135 existing_is_stub: existing.stub,
136 });
137 }
138
139 let schema = self
140 .schemas
141 .get(&mem)
142 .expect("schema present for every registered mount");
143 let type_def = schema
144 .get_type(&entity.entity_type)
145 .ok_or_else(|| unknown_type_error(schema, &entity.entity_type))?;
146
147 let old_file_path = entity.file_path.clone();
148 let new_file_path = format!("{new_slug}.md");
149
150 let mut next = entity.clone();
151 next.id = new_id.clone();
152 next.title = args.new_title.clone();
153 next.file_path = new_file_path.clone();
154
155 for rel in next.relationships.iter_mut() {
162 if rel.target == *id {
163 rel.target = new_id.clone();
164 }
165 }
166
167 let old_slug = id.name().to_string();
182 let new_slug_owned = new_slug.clone();
183 for body in next.sections.values_mut() {
184 let (rewritten, count) = crate::entity::wikilink_rewrite::rewrite_bare_slug(
185 body,
186 &old_slug,
187 &new_slug_owned,
188 );
189 if count > 0 {
190 *body = rewritten;
191 }
192 let (rewritten, count) = crate::entity::wikilink_rewrite::rewrite_cross_mem_slug(
193 body,
194 &mem,
195 &old_slug,
196 &new_slug_owned,
197 );
198 if count > 0 {
199 *body = rewritten;
200 }
201 }
202
203 let today = self.now_iso();
211 super::auto_stamp_timestamps(&mut next, type_def.as_ref(), &today);
212
213 let markdown = super::render_for_write(&next, type_def.as_ref())?;
214
215 let mut seen: std::collections::HashSet<EntityId> = std::collections::HashSet::new();
227 let mut same_mem_ids: Vec<EntityId> = Vec::new();
228 let mut cross_mem_ids: Vec<EntityId> = Vec::new();
229 for in_edge in self.store.incoming(id) {
230 if in_edge.from == *id || !seen.insert(in_edge.from.clone()) {
231 continue;
232 }
233 if in_edge.from.mem() == mem {
234 same_mem_ids.push(in_edge.from.clone());
235 } else {
236 cross_mem_ids.push(in_edge.from.clone());
237 }
238 }
239 for entity in self.store.all_entities() {
251 if entity.id == *id || entity.stub || seen.contains(&entity.id) {
252 continue;
253 }
254 let mentions = entity.sections.values().any(|body| {
255 body.contains(old_slug.as_str())
256 && crate::entity::parser::extract_inline_links_lenient(body, entity.id.mem())
257 .iter()
258 .any(|t| t == id)
259 });
260 if !mentions {
261 continue;
262 }
263 seen.insert(entity.id.clone());
264 if entity.id.mem() == mem {
265 same_mem_ids.push(entity.id.clone());
266 } else {
267 cross_mem_ids.push(entity.id.clone());
268 }
269 }
270
271 let mut cross_mem_write_ids: Vec<EntityId> = Vec::new();
279 let mut readonly_referrers: Vec<EntityId> = Vec::new();
280 for from_id in cross_mem_ids {
281 match self
282 .mount(from_id.mem())
283 .map(|m| m.capability)
284 .unwrap_or(MountCapability::Write)
285 {
286 MountCapability::Write => cross_mem_write_ids.push(from_id),
287 MountCapability::ReadOnly => readonly_referrers.push(from_id),
288 }
289 }
290 readonly_referrers.sort_by_key(|a| a.to_string());
291
292 let mut blocked_counts: std::collections::BTreeMap<String, usize> =
302 std::collections::BTreeMap::new();
303 for from_id in &cross_mem_write_ids {
304 let peer_mem = from_id.mem().to_string();
305 if !self.cross_mem_link_allowed(&peer_mem, &mem) {
306 *blocked_counts.entry(peer_mem).or_insert(0) += 1;
307 }
308 }
309 if !blocked_counts.is_empty() {
310 let blocked_referrers: Vec<crate::engine::error::BlockedReferrer> = blocked_counts
311 .into_iter()
312 .map(|(peer_mem, count)| crate::engine::error::BlockedReferrer {
313 from_mem: peer_mem,
314 to_mem: mem.clone(),
315 count,
316 })
317 .collect();
318 return Err(EngineError::RenameBlockedByCrossMemPolicy {
319 from_mem: mem.clone(),
320 blocked_referrers,
321 });
322 }
323
324 same_mem_ids.sort_by_key(|a| a.to_string());
328 cross_mem_write_ids.sort_by_key(|a| a.to_string());
329
330 let mut same_mem_writes: Vec<(
336 String,
337 String,
338 std::sync::Arc<memstead_schema::TypeDefinition>,
339 )> = Vec::with_capacity(same_mem_ids.len());
340 for from_id in &same_mem_ids {
341 let Some(referrer) = self.store.get(from_id) else {
342 continue;
343 };
344 if referrer.stub {
345 continue;
346 }
347 let referrer_type_def = schema
348 .get_type(&referrer.entity_type)
349 .ok_or_else(|| unknown_type_error(schema, &referrer.entity_type))?;
350 let mut next_ref = referrer.clone();
351 for rel in next_ref.relationships.iter_mut() {
352 if rel.target == *id {
353 rel.target = new_id.clone();
354 }
355 }
356 for body in next_ref.sections.values_mut() {
357 let (rewritten, count) = crate::entity::wikilink_rewrite::rewrite_bare_slug(
358 body,
359 &old_slug,
360 &new_slug_owned,
361 );
362 if count > 0 {
363 *body = rewritten;
364 }
365 let (rewritten, count) = crate::entity::wikilink_rewrite::rewrite_cross_mem_slug(
370 body,
371 &mem,
372 &old_slug,
373 &new_slug_owned,
374 );
375 if count > 0 {
376 *body = rewritten;
377 }
378 }
379 let ref_markdown = super::render_for_write(&next_ref, referrer_type_def.as_ref())?;
390 same_mem_writes.push((ref_markdown, next_ref.file_path.clone(), referrer_type_def));
391 }
392
393 struct PeerMemPlan {
399 mount_idx: usize,
400 mem: String,
401 writes: Vec<(
402 String,
403 String,
404 std::sync::Arc<memstead_schema::TypeDefinition>,
405 )>,
406 }
407 let mut peer_plans: std::collections::BTreeMap<String, PeerMemPlan> =
408 std::collections::BTreeMap::new();
409 for from_id in &cross_mem_write_ids {
410 let peer_mem = from_id.mem().to_string();
411 let peer_mount_idx = self
412 .mounts
413 .iter()
414 .position(|m| m.mount.mem == peer_mem)
415 .expect("peer mount present for collected referrer id");
416 let peer_schema = self
417 .schemas
418 .get(&peer_mem)
419 .expect("schema present for every registered mount");
420
421 let Some(referrer) = self.store.get(from_id) else {
422 continue;
423 };
424 if referrer.stub {
425 continue;
426 }
427 let referrer_type_def = peer_schema
428 .get_type(&referrer.entity_type)
429 .ok_or_else(|| unknown_type_error(peer_schema, &referrer.entity_type))?;
430 let mut next_ref = referrer.clone();
431 for rel in next_ref.relationships.iter_mut() {
432 if rel.target == *id {
433 rel.target = new_id.clone();
434 }
435 }
436 for body in next_ref.sections.values_mut() {
442 let (rewritten, count) = crate::entity::wikilink_rewrite::rewrite_cross_mem_slug(
443 body,
444 &mem,
445 &old_slug,
446 &new_slug_owned,
447 );
448 if count > 0 {
449 *body = rewritten;
450 }
451 }
452 let ref_markdown = super::render_for_write(&next_ref, referrer_type_def.as_ref())?;
458
459 peer_plans
460 .entry(peer_mem.clone())
461 .or_insert_with(|| PeerMemPlan {
462 mount_idx: peer_mount_idx,
463 mem: peer_mem.clone(),
464 writes: Vec::new(),
465 })
466 .writes
467 .push((ref_markdown, next_ref.file_path.clone(), referrer_type_def));
468 }
469
470 let logical_op_id = crate::provenance::mint_logical_operation_id();
478
479 let backend = self.mounts[mount_idx].backend.as_ref();
480 backend.write_entity(Path::new(&new_file_path), markdown.as_bytes())?;
481 backend.delete_entity(Path::new(&old_file_path))?;
482 for (ref_markdown, ref_file_path, _) in &same_mem_writes {
483 backend.write_entity(Path::new(ref_file_path), ref_markdown.as_bytes())?;
484 }
485 super::stage_anchors_rename(backend, id, &new_id)?;
490 let commit_subject = format!("memstead: rename {} → {new_id}", id);
491 let ctx = CommitContext {
492 actor,
493 client: client.cloned(),
494 tool: Some("rename_entity"),
495 note: note.map(String::from),
496 role: self.current_role,
497 identity: self.current_identity.clone(),
498 logical_operation_id: Some(logical_op_id.as_str()),
499 entity_ids: None,
500 };
501 let write_id = backend.commit(&commit_subject, &ctx)?;
502
503 backend.append_provenance(
504 &Provenance::new(
505 std::time::SystemTime::now(),
506 ProvenanceKind::Rename,
507 Some(new_id.to_string()),
508 actor,
509 client.cloned(),
510 note.map(String::from),
511 )
512 .with_role(self.current_role)
513 .with_identity(self.current_identity.clone())
514 .with_logical_operation_id(logical_op_id.clone()),
515 )?;
516
517 self.record_self_write(mount_idx, &write_id);
518 let stamp_warnings = self.stamp_mutation_versions(mount_idx);
519 let mut peer_stamp_warnings: Vec<WarningHint> = Vec::new();
520
521 let mut peer_snapshots: std::collections::BTreeMap<String, Option<String>> =
535 std::collections::BTreeMap::new();
536 for plan in peer_plans.values() {
537 let peer_backend = self.mounts[plan.mount_idx].backend.as_ref();
538 let snapshot = peer_backend.current_head()?;
539 peer_snapshots.insert(plan.mem.clone(), snapshot);
540 }
541
542 let mut committed_mems: Vec<String> = vec![mem.clone()];
547 for plan in peer_plans.values() {
548 let peer_backend = self.mounts[plan.mount_idx].backend.as_ref();
549 for (ref_markdown, ref_file_path, _) in &plan.writes {
550 peer_backend.write_entity(Path::new(ref_file_path), ref_markdown.as_bytes())?;
551 }
552 let peer_commit_subject = format!(
553 "memstead: rename {} → {new_id} (cross-mem rewrite in `{}`)",
554 id, plan.mem
555 );
556 let peer_ctx = CommitContext {
557 actor,
558 client: client.cloned(),
559 tool: Some("rename_entity"),
560 note: note.map(String::from),
561 role: self.current_role,
562 identity: self.current_identity.clone(),
563 logical_operation_id: Some(logical_op_id.as_str()),
564 entity_ids: None,
565 };
566 let expected = peer_snapshots.get(&plan.mem).cloned().unwrap_or(None);
567 let peer_commit_result = peer_backend.commit_with_expected_parent(
568 &peer_commit_subject,
569 &peer_ctx,
570 expected.as_deref(),
571 );
572 let peer_write_id = match peer_commit_result {
573 Ok(sha) => sha,
574 Err(crate::backend::BackendError::ParentMismatch { .. }) => {
575 return Err(EngineError::RenamePartialFailure {
576 committed_mems: std::mem::take(&mut committed_mems),
577 failed_mem: plan.mem.clone(),
578 failure_cause: "drift".to_string(),
579 });
580 }
581 Err(e) => return Err(e.into()),
582 };
583 peer_backend.append_provenance(
584 &Provenance::new(
585 std::time::SystemTime::now(),
586 ProvenanceKind::Rename,
587 Some(new_id.to_string()),
588 actor,
589 client.cloned(),
590 note.map(String::from),
591 )
592 .with_role(self.current_role)
593 .with_identity(self.current_identity.clone())
594 .with_logical_operation_id(logical_op_id.clone()),
595 )?;
596 self.record_self_write(plan.mount_idx, &peer_write_id);
597 peer_stamp_warnings.extend(self.stamp_mutation_versions(plan.mount_idx));
601 committed_mems.push(plan.mem.clone());
602 }
603
604 let parse_result = parse_markdown(&markdown, &new_file_path, type_def.as_ref(), &mem)
606 .map_err(|e| EngineError::ParseAfterWrite(e.to_string()))?;
607 let content_hash = parse_result.entity.content_hash.clone();
608
609 let mut parse_results = vec![parse_result];
610 for (ref_markdown, ref_file_path, ref_type_def) in &same_mem_writes {
611 let pr = parse_markdown(ref_markdown, ref_file_path, ref_type_def.as_ref(), &mem)
612 .map_err(|e| EngineError::ParseAfterWrite(e.to_string()))?;
613 parse_results.push(pr);
614 }
615 for plan in peer_plans.values() {
616 for (ref_markdown, ref_file_path, ref_type_def) in &plan.writes {
617 let pr = parse_markdown(
618 ref_markdown,
619 ref_file_path,
620 ref_type_def.as_ref(),
621 &plan.mem,
622 )
623 .map_err(|e| EngineError::ParseAfterWrite(e.to_string()))?;
624 parse_results.push(pr);
625 }
626 }
627
628 let mut outcome_warnings: Vec<WarningHint> = Vec::new();
641 outcome_warnings.extend(stamp_warnings);
642 outcome_warnings.extend(peer_stamp_warnings);
643 outcome_warnings.append(&mut drift_warnings);
645 if !derivation.dropped_chars.is_empty() {
648 outcome_warnings.push(WarningHint::TitleCharsDroppedFromSlug {
649 title: args.new_title.trim().to_string(),
650 dropped_chars: derivation.dropped_chars.clone(),
651 slug: new_slug.clone(),
652 });
653 }
654 if readonly_referrers.is_empty() {
655 self.store.remove(id);
656 } else {
657 self.store.remove_edges_from(id);
662 self.store.upsert(
663 id.clone(),
664 make_stub(
665 id,
666 crate::entity::StubKind::Residual {
667 since_commit: write_id.clone(),
668 readonly_referrers: readonly_referrers.clone(),
669 },
670 ),
671 );
672 outcome_warnings.push(WarningHint::ResidualStubForReadOnlyReferrers {
673 id: id.clone(),
674 referrers: readonly_referrers,
675 });
676 }
677
678 let fallback = engine_fallback_type();
679 let mut touched: Vec<crate::EntityId> = parse_results
684 .iter()
685 .map(|pr| pr.entity.id.clone())
686 .collect();
687 touched.push(id.clone());
688 push_entities_into_store(&mut self.store, parse_results, fallback.as_ref(), None);
689 crate::entity::store_builder::remap_alias_target_edge_sources(
690 &mut self.store,
691 &self.schemas,
692 );
693
694 self.invalidate_communities();
695 self.maintain_search_indexes(&touched);
696
697 if let Some(w) = self.note_missing_warning("rename_entity", note) {
702 outcome_warnings.push(w);
703 }
704
705 Ok(RenameEntityOutcome {
706 old_id: id.clone(),
707 new_id,
708 old_path: old_file_path,
709 new_path: new_file_path,
710 content_hash,
711 write_id,
712 warnings: outcome_warnings,
713 })
714 }
715}
716
717#[cfg(test)]
718mod tests {
719 use std::path::PathBuf;
720
721 use tempfile::TempDir;
722
723 use crate::backend::MemBackend;
724 use crate::engine::test_helpers::*;
725 use crate::engine::{Engine, EngineError, RenameEntityArgs};
726 use crate::ops::WarningHint;
727 use crate::storage::FilesystemMemWriter;
728
729 #[test]
730 fn rename_moves_entity_anchors_to_new_id() {
731 let tmp = TempDir::new().unwrap();
732 let mem_dir = tmp.path().to_path_buf();
733 let writer = FilesystemMemWriter::new(mem_dir.clone());
734 let mut engine = Engine::from_mounts(vec![(
735 folder_mount("specs", mem_dir.clone()),
736 Box::new(writer) as Box<dyn MemBackend>,
737 )])
738 .unwrap();
739 let (actor, client) = cli_actor();
740 let mut args = empty_create_args("specs", "Old Anchored");
741 args.anchors = vec![crate::anchor::AnchorInput {
742 artifact: Some("src/lib.rs".into()),
743 grain: Some("file".into()),
744 class: Some("anchored".into()),
745 hash: Some("h1".into()),
746 hash_stability: Some("stable".into()),
747 ..Default::default()
748 }];
749 let seeded = engine
750 .create_entity(args, actor, Some(&client), None)
751 .unwrap();
752 let old_id = seeded.id.clone();
753
754 let outcome = engine
755 .rename_entity(
756 RenameEntityArgs {
757 id: old_id.clone(),
758 expected_hash: Some(seeded.content_hash.clone()),
759 new_title: "New Anchored".to_string(),
760 },
761 actor,
762 Some(&client),
763 None,
764 )
765 .unwrap();
766
767 assert!(engine.entity_anchors(&old_id).is_empty());
769 assert_eq!(engine.entity_anchors(&outcome.new_id).len(), 1);
770 assert_eq!(
771 engine.anchors_referencing_artifact("src/lib.rs"),
772 vec![(
773 outcome.new_id.clone(),
774 engine.entity_anchors(&outcome.new_id)[0].clone()
775 )]
776 );
777 }
778
779 #[test]
780 fn rename_entity_renames_file_and_id_persists_across_restart() {
781 let tmp = TempDir::new().unwrap();
782 let mem_dir = tmp.path().to_path_buf();
783
784 let (old_id, new_id, new_file) = {
785 let writer = FilesystemMemWriter::new(mem_dir.clone());
786 let mut engine = Engine::from_mounts(vec![(
787 folder_mount("specs", mem_dir.clone()),
788 Box::new(writer) as Box<dyn MemBackend>,
789 )])
790 .unwrap();
791 let (actor, client) = cli_actor();
792 let seeded = engine
793 .create_entity(
794 empty_create_args("specs", "Old Name"),
795 actor,
796 Some(&client),
797 None,
798 )
799 .unwrap();
800 let outcome = engine
801 .rename_entity(
802 RenameEntityArgs {
803 id: seeded.id.clone(),
804 expected_hash: Some(seeded.content_hash.clone()),
805 new_title: "New Name".to_string(),
806 },
807 actor,
808 Some(&client),
809 None,
810 )
811 .unwrap();
812 assert_eq!(outcome.old_id.to_string(), "specs--old-name");
813 assert_eq!(outcome.new_id.to_string(), "specs--new-name");
814 assert_eq!(outcome.new_path, "new-name.md");
815 assert!(!mem_dir.join(&outcome.old_path).exists());
817 assert!(mem_dir.join(&outcome.new_path).exists());
818 (outcome.old_id, outcome.new_id, outcome.new_path)
819 };
820
821 let writer2 = FilesystemMemWriter::new(mem_dir.clone());
823 let engine2 = Engine::from_mounts(vec![(
824 folder_mount("specs", mem_dir),
825 Box::new(writer2) as Box<dyn MemBackend>,
826 )])
827 .unwrap();
828 assert!(engine2.get_entity(&old_id).is_none());
829 let new_entity = engine2.get_entity(&new_id).expect("new id must persist");
830 assert_eq!(new_entity.title, "New Name");
831 assert_eq!(new_entity.file_path, new_file);
832 }
833
834 #[test]
835 fn rename_entity_returns_typed_warning_on_slug_noop() {
836 let tmp = TempDir::new().unwrap();
837 let (mut engine, seeded) = engine_with_seed(&tmp, "Same Slug");
838 let (actor, client) = cli_actor();
839 let outcome = engine
840 .rename_entity(
841 RenameEntityArgs {
842 id: seeded.id.clone(),
843 expected_hash: Some(seeded.content_hash.clone()),
844 new_title: "Same Slug".to_string(), },
846 actor,
847 Some(&client),
848 None,
849 )
850 .unwrap();
851 assert_eq!(outcome.old_id, outcome.new_id);
856 assert_eq!(outcome.old_path, outcome.new_path);
857 assert!(outcome.write_id.is_empty());
858 assert_eq!(outcome.warnings.len(), 1);
859 assert!(matches!(
860 outcome.warnings[0],
861 WarningHint::TitleNormalizedToSlugNoop { .. }
862 ));
863 }
864
865 #[test]
866 fn rename_entity_returns_write_id_on_real_rename() {
867 let tmp = TempDir::new().unwrap();
868 let (mut engine, seeded) = engine_with_seed(&tmp, "Old Name");
869 let (actor, client) = cli_actor();
870 let outcome = engine
871 .rename_entity(
872 RenameEntityArgs {
873 id: seeded.id.clone(),
874 expected_hash: Some(seeded.content_hash.clone()),
875 new_title: "Brand New Name".to_string(),
876 },
877 actor,
878 Some(&client),
879 None,
880 )
881 .unwrap();
882 assert_ne!(outcome.old_id, outcome.new_id);
885 assert!(
886 !outcome.write_id.is_empty(),
887 "write_id must be populated on a real rename"
888 );
889 assert!(outcome.warnings.is_empty());
890 }
891
892 #[test]
893 fn rename_entity_rewrites_self_references_in_body_and_relationships() {
894 use crate::entity::EntityId;
895 use indexmap::IndexMap;
896
897 let tmp = TempDir::new().unwrap();
898 let mem_dir = tmp.path().to_path_buf();
899 let writer = FilesystemMemWriter::new(mem_dir.clone());
900 let mut engine = Engine::from_mounts(vec![(
901 folder_mount("specs", mem_dir.clone()),
902 Box::new(writer) as Box<dyn MemBackend>,
903 )])
904 .unwrap();
905 let (actor, client) = cli_actor();
906
907 let mut sections: IndexMap<String, String> = IndexMap::new();
913 sections.insert("identity".to_string(), "the seed identity".to_string());
914 sections.insert(
915 "purpose".to_string(),
916 "see also [[old-name]] for prior context".to_string(),
917 );
918 let seeded = engine
925 .create_entity(
926 crate::engine::CreateEntityArgs {
927 anchors: Vec::new(),
928 mem: "specs".to_string(),
929 title: "Old Name".to_string(),
930 entity_type: "spec".to_string(),
931 sections,
932 metadata: IndexMap::new(),
933 relations: Vec::new(),
934 dry_run: false,
935 },
936 actor,
937 Some(&client),
938 None,
939 )
940 .unwrap();
941 assert_eq!(seeded.id.to_string(), "specs--old-name");
942 let related = seeded.clone();
943
944 let outcome = engine
945 .rename_entity(
946 RenameEntityArgs {
947 id: seeded.id.clone(),
948 expected_hash: Some(related.content_hash.clone()),
949 new_title: "Brand New Name".to_string(),
950 },
951 actor,
952 Some(&client),
953 None,
954 )
955 .unwrap();
956 assert_eq!(outcome.new_id.to_string(), "specs--brand-new-name");
957
958 let new_bytes = std::fs::read_to_string(mem_dir.join(&outcome.new_path)).unwrap();
961 assert!(
962 new_bytes.contains("[[brand-new-name]]"),
963 "expected new slug in body, got:\n{new_bytes}"
964 );
965 assert!(
966 !new_bytes.contains("[[old-name]]"),
967 "old slug must not survive in the rewritten file, got:\n{new_bytes}"
968 );
969
970 let in_mem = engine.get_entity(&outcome.new_id).unwrap();
973 assert!(
974 in_mem
975 .sections
976 .get("purpose")
977 .map(|s| s.contains("[[brand-new-name]]"))
978 .unwrap_or(false),
979 "section body must be rewritten in-memory; got {:?}",
980 in_mem.sections.get("purpose")
981 );
982 let new_self_target = EntityId::new("specs", "brand-new-name");
986 assert!(
987 in_mem
988 .relationships
989 .iter()
990 .all(|r| r.target != seeded.id && r.target != new_self_target),
991 "a self-referential body link must produce no self-relation (F11), got: {:?}",
992 in_mem.relationships
993 );
994 }
995
996 #[test]
1005 fn rename_preserves_referrer_last_modified_but_rewrites_link() {
1006 let tmp = TempDir::new().unwrap();
1007 let mem_dir = tmp.path().to_path_buf();
1008
1009 std::fs::write(
1010 mem_dir.join("target.md"),
1011 "---\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",
1012 )
1013 .unwrap();
1014 std::fs::write(
1015 mem_dir.join("referrer.md"),
1016 "---\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",
1017 )
1018 .unwrap();
1019
1020 let writer = FilesystemMemWriter::new(mem_dir.clone());
1021 let mut engine = Engine::from_mounts(vec![(
1022 folder_mount("specs", mem_dir.clone()),
1023 Box::new(writer) as Box<dyn MemBackend>,
1024 )])
1025 .unwrap();
1026 let (actor, client) = cli_actor();
1027
1028 let target_id = crate::entity::EntityId::new("specs", "target");
1029 let target_hash = engine
1030 .store()
1031 .get(&target_id)
1032 .expect("target loaded from disk")
1033 .content_hash
1034 .clone();
1035
1036 engine
1037 .rename_entity(
1038 RenameEntityArgs {
1039 id: target_id,
1040 expected_hash: Some(target_hash),
1041 new_title: "Target Renamed".to_string(),
1042 },
1043 actor,
1044 Some(&client),
1045 None,
1046 )
1047 .expect("rename succeeds");
1048
1049 let referrer_md = std::fs::read_to_string(mem_dir.join("referrer.md")).unwrap();
1050 assert!(
1052 referrer_md.contains("last_modified: 2020-01-01"),
1053 "referrer's last_modified must be preserved across a rename-driven slug rewrite; got:\n{referrer_md}"
1054 );
1055 assert!(
1057 referrer_md.contains("[[target-renamed]]"),
1058 "referrer's body wiki-link must be rewritten to the new slug; got:\n{referrer_md}"
1059 );
1060 assert!(
1061 !referrer_md.contains("[[target]]"),
1062 "old slug must not survive in the referrer body; got:\n{referrer_md}"
1063 );
1064 }
1065
1066 #[test]
1075 fn rename_rewrites_prose_only_referrer_without_relationships_row() {
1076 let tmp = TempDir::new().unwrap();
1077 let mem_dir = tmp.path().to_path_buf();
1078
1079 std::fs::write(
1080 mem_dir.join("target.md"),
1081 "---\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",
1082 )
1083 .unwrap();
1084 std::fs::write(
1086 mem_dir.join("referrer.md"),
1087 "---\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",
1088 )
1089 .unwrap();
1090
1091 let writer = FilesystemMemWriter::new(mem_dir.clone());
1092 let mut engine = Engine::from_mounts(vec![(
1093 folder_mount("specs", mem_dir.clone()),
1094 Box::new(writer) as Box<dyn MemBackend>,
1095 )])
1096 .unwrap();
1097 let (actor, client) = cli_actor();
1098
1099 let target_id = crate::entity::EntityId::new("specs", "target");
1100 let target_hash = engine
1101 .store()
1102 .get(&target_id)
1103 .expect("target loaded from disk")
1104 .content_hash
1105 .clone();
1106
1107 engine
1108 .rename_entity(
1109 RenameEntityArgs {
1110 id: target_id,
1111 expected_hash: Some(target_hash),
1112 new_title: "Target Renamed".to_string(),
1113 },
1114 actor,
1115 Some(&client),
1116 None,
1117 )
1118 .expect("rename succeeds");
1119
1120 let referrer_md = std::fs::read_to_string(mem_dir.join("referrer.md")).unwrap();
1121 assert!(
1122 referrer_md.contains("[[target-renamed]]"),
1123 "the prose-only wiki-link must be rewritten to the new slug; got:\n{referrer_md}"
1124 );
1125 assert!(
1126 !referrer_md.contains("[[target]]"),
1127 "the old slug must not survive in the prose-only referrer; got:\n{referrer_md}"
1128 );
1129 }
1130
1131 #[test]
1132 fn rename_entity_rewrites_same_mem_referrers_atomically() {
1133 use crate::engine::CreateEntityArgs;
1134 use crate::entity::EntityId;
1135 use indexmap::IndexMap;
1136
1137 let tmp = TempDir::new().unwrap();
1138 let mem_dir = tmp.path().to_path_buf();
1139 let writer = FilesystemMemWriter::new(mem_dir.clone());
1140 let mut engine = Engine::from_mounts(vec![(
1141 folder_mount("specs", mem_dir.clone()),
1142 Box::new(writer) as Box<dyn MemBackend>,
1143 )])
1144 .unwrap();
1145 let (actor, client) = cli_actor();
1146
1147 let target = engine
1149 .create_entity(
1150 empty_create_args("specs", "Target Spec"),
1151 actor,
1152 Some(&client),
1153 None,
1154 )
1155 .unwrap();
1156 assert_eq!(target.id.to_string(), "specs--target-spec");
1157
1158 let mut sections_a: IndexMap<String, String> = IndexMap::new();
1161 sections_a.insert(
1162 "identity".to_string(),
1163 "referrer alpha identity".to_string(),
1164 );
1165 sections_a.insert(
1166 "purpose".to_string(),
1167 "rationale relies on [[target-spec]] for context".to_string(),
1168 );
1169 let referrer_a = engine
1170 .create_entity(
1171 CreateEntityArgs {
1172 anchors: Vec::new(),
1173 mem: "specs".to_string(),
1174 title: "Referrer Alpha".to_string(),
1175 entity_type: "spec".to_string(),
1176 sections: sections_a,
1177 metadata: IndexMap::new(),
1178 relations: Vec::new(),
1182 dry_run: false,
1183 },
1184 actor,
1185 Some(&client),
1186 None,
1187 )
1188 .unwrap();
1189
1190 let mut sections_b: IndexMap<String, String> = IndexMap::new();
1193 sections_b.insert("identity".to_string(), "referrer beta identity".to_string());
1194 sections_b.insert(
1195 "purpose".to_string(),
1196 "consult [[target-spec]] for the canonical phrasing".to_string(),
1197 );
1198 let referrer_b = engine
1199 .create_entity(
1200 CreateEntityArgs {
1201 anchors: Vec::new(),
1202 mem: "specs".to_string(),
1203 title: "Referrer Bravo".to_string(),
1204 entity_type: "spec".to_string(),
1205 sections: sections_b,
1206 metadata: IndexMap::new(),
1207 relations: Vec::new(),
1211 dry_run: false,
1212 },
1213 actor,
1214 Some(&client),
1215 None,
1216 )
1217 .unwrap();
1218
1219 let bystander = engine
1222 .create_entity(
1223 empty_create_args("specs", "Bystander"),
1224 actor,
1225 Some(&client),
1226 None,
1227 )
1228 .unwrap();
1229 let bystander_bytes_before =
1230 std::fs::read_to_string(mem_dir.join(&bystander.file_path)).unwrap();
1231
1232 let renamed = engine
1233 .rename_entity(
1234 RenameEntityArgs {
1235 id: target.id.clone(),
1236 expected_hash: Some(target.content_hash.clone()),
1237 new_title: "Renamed Spec".to_string(),
1238 },
1239 actor,
1240 Some(&client),
1241 None,
1242 )
1243 .unwrap();
1244 assert_eq!(renamed.new_id.to_string(), "specs--renamed-spec");
1245
1246 for path in std::fs::read_dir(&mem_dir).unwrap().flatten() {
1249 let p = path.path();
1250 if p.extension().and_then(|s| s.to_str()) != Some("md") {
1251 continue;
1252 }
1253 let body = std::fs::read_to_string(&p).unwrap();
1254 assert!(
1255 !body.contains("[[target-spec]]"),
1256 "old slug must not survive in {}, got:\n{body}",
1257 p.display()
1258 );
1259 }
1260
1261 let in_mem_a = engine.get_entity(&referrer_a.id).unwrap();
1263 assert!(
1264 in_mem_a
1265 .relationships
1266 .iter()
1267 .any(|r| r.rel_type == "REFERENCES"
1268 && r.target == EntityId::new("specs", "renamed-spec")),
1269 "expected referrer A's relation to point at renamed-spec, got {:?}",
1270 in_mem_a.relationships
1271 );
1272 assert!(
1273 in_mem_a
1274 .sections
1275 .get("purpose")
1276 .map(|s| s.contains("[[renamed-spec]]"))
1277 .unwrap_or(false),
1278 "referrer A's body must be rewritten"
1279 );
1280
1281 let in_mem_b = engine.get_entity(&referrer_b.id).unwrap();
1284 assert!(
1285 in_mem_b
1286 .sections
1287 .get("purpose")
1288 .map(|s| s.contains("[[renamed-spec]]"))
1289 .unwrap_or(false),
1290 "referrer B's body must be rewritten"
1291 );
1292
1293 let bystander_bytes_after =
1295 std::fs::read_to_string(mem_dir.join(&bystander.file_path)).unwrap();
1296 assert_eq!(
1297 bystander_bytes_before, bystander_bytes_after,
1298 "bystander must not be rewritten"
1299 );
1300 }
1301
1302 fn engine_with_two_mems_and_bidirectional_policy(
1308 specs_dir: PathBuf,
1309 memos_dir: PathBuf,
1310 ) -> Engine {
1311 use memstead_schema::workspace_config::CrossLinkValue;
1312 let writer_specs = FilesystemMemWriter::new(specs_dir.clone());
1313 let writer_memos = FilesystemMemWriter::new(memos_dir.clone());
1314 let mut engine = Engine::from_mounts(vec![
1315 (
1316 folder_mount("specs", specs_dir),
1317 Box::new(writer_specs) as Box<dyn MemBackend>,
1318 ),
1319 (
1320 folder_mount("memos", memos_dir),
1321 Box::new(writer_memos) as Box<dyn MemBackend>,
1322 ),
1323 ])
1324 .unwrap();
1325 let mut settings = crate::workspace::WorkspaceSettings::default();
1326 settings.cross_mem_links.insert(
1327 "memos".to_string(),
1328 CrossLinkValue::List(vec!["specs".to_string()]),
1329 );
1330 settings.cross_mem_links.insert(
1331 "specs".to_string(),
1332 CrossLinkValue::List(vec!["memos".to_string()]),
1333 );
1334 engine.set_settings(settings);
1335 engine
1336 }
1337
1338 #[test]
1339 fn rename_entity_rewrites_cross_mem_write_referrer() {
1340 use crate::engine::CreateEntityArgs;
1341 use crate::entity::EntityId;
1342 use indexmap::IndexMap;
1343
1344 let tmp_specs = TempDir::new().unwrap();
1345 let tmp_memos = TempDir::new().unwrap();
1346 let specs_dir = tmp_specs.path().to_path_buf();
1347 let memos_dir = tmp_memos.path().to_path_buf();
1348 let mut engine =
1349 engine_with_two_mems_and_bidirectional_policy(specs_dir.clone(), memos_dir.clone());
1350 let (actor, client) = cli_actor();
1351
1352 let target = engine
1354 .create_entity(
1355 empty_create_args("specs", "Target Spec"),
1356 actor,
1357 Some(&client),
1358 None,
1359 )
1360 .unwrap();
1361
1362 let mut sections: IndexMap<String, String> = IndexMap::new();
1369 sections.insert("claim".to_string(), "the claim".to_string());
1370 sections.insert(
1371 "context".to_string(),
1372 "discussion stems from [[specs:target-spec]]".to_string(),
1373 );
1374 let referrer = engine
1375 .create_entity(
1376 CreateEntityArgs {
1377 anchors: Vec::new(),
1378 mem: "memos".to_string(),
1379 title: "Cross Note".to_string(),
1380 entity_type: "memo".to_string(),
1381 sections,
1382 metadata: IndexMap::new(),
1383 relations: Vec::new(),
1387 dry_run: false,
1388 },
1389 actor,
1390 Some(&client),
1391 None,
1392 )
1393 .unwrap();
1394
1395 let renamed = engine
1397 .rename_entity(
1398 RenameEntityArgs {
1399 id: target.id.clone(),
1400 expected_hash: Some(target.content_hash.clone()),
1401 new_title: "Renamed Spec".to_string(),
1402 },
1403 actor,
1404 Some(&client),
1405 None,
1406 )
1407 .unwrap();
1408 assert_eq!(renamed.new_id.to_string(), "specs--renamed-spec");
1409
1410 let referrer_path = memos_dir.join(&referrer.file_path);
1413 let referrer_bytes = std::fs::read_to_string(&referrer_path).unwrap();
1414 assert!(
1415 referrer_bytes.contains("[[specs:renamed-spec]]"),
1416 "expected colon-form rewrite in referrer body, got:\n{referrer_bytes}"
1417 );
1418 assert!(
1419 !referrer_bytes.contains("target-spec"),
1420 "old slug must not survive in referrer file, got:\n{referrer_bytes}"
1421 );
1422
1423 let in_mem = engine.get_entity(&referrer.id).unwrap();
1425 assert!(
1426 in_mem
1427 .relationships
1428 .iter()
1429 .any(|r| r.rel_type == "REFERENCES"
1430 && r.target == EntityId::new("specs", "renamed-spec")),
1431 "expected cross-mem relation to point at renamed-spec, got {:?}",
1432 in_mem.relationships
1433 );
1434 assert!(
1435 in_mem.relationships.iter().all(|r| r.target != target.id),
1436 "no relationship may still target the old id, got: {:?}",
1437 in_mem.relationships
1438 );
1439 }
1440
1441 struct DriftingBackend {
1448 inner: Box<dyn MemBackend>,
1449 }
1450 impl DriftingBackend {
1451 fn new(inner: Box<dyn MemBackend>) -> Self {
1452 Self { inner }
1453 }
1454 }
1455 impl crate::backend::MemBackend for DriftingBackend {
1456 fn list_entities(&self) -> Result<Vec<PathBuf>, crate::backend::BackendError> {
1457 self.inner.list_entities()
1458 }
1459 fn read_entity(
1460 &self,
1461 rel: &std::path::Path,
1462 ) -> Result<Option<Vec<u8>>, crate::backend::BackendError> {
1463 self.inner.read_entity(rel)
1464 }
1465 fn write_entity(
1466 &self,
1467 rel: &std::path::Path,
1468 b: &[u8],
1469 ) -> Result<(), crate::backend::BackendError> {
1470 self.inner.write_entity(rel, b)
1471 }
1472 fn delete_entity(&self, rel: &std::path::Path) -> Result<(), crate::backend::BackendError> {
1473 self.inner.delete_entity(rel)
1474 }
1475 fn move_entity(
1476 &self,
1477 f: &std::path::Path,
1478 t: &std::path::Path,
1479 ) -> Result<(), crate::backend::BackendError> {
1480 self.inner.move_entity(f, t)
1481 }
1482 fn commit(
1483 &self,
1484 m: &str,
1485 c: &crate::vcs::CommitContext<'_>,
1486 ) -> Result<crate::storage::CommitId, crate::backend::BackendError> {
1487 self.inner.commit(m, c)
1488 }
1489 fn commit_with_expected_parent(
1490 &self,
1491 m: &str,
1492 c: &crate::vcs::CommitContext<'_>,
1493 expected_parent: Option<&str>,
1494 ) -> Result<crate::storage::CommitId, crate::backend::BackendError> {
1495 if let Some(expected) = expected_parent {
1496 Err(crate::backend::BackendError::ParentMismatch {
1497 expected: expected.to_string(),
1498 actual: "drifted-by-sibling-writer".to_string(),
1499 })
1500 } else {
1501 self.inner.commit(m, c)
1502 }
1503 }
1504 fn append_provenance(
1505 &self,
1506 r: &crate::Provenance,
1507 ) -> Result<(), crate::backend::BackendError> {
1508 self.inner.append_provenance(r)
1509 }
1510 fn read_provenance(
1511 &self,
1512 c: Option<&str>,
1513 ) -> Result<Vec<crate::Provenance>, crate::backend::BackendError> {
1514 self.inner.read_provenance(c)
1515 }
1516 fn current_head(&self) -> Result<Option<String>, crate::backend::BackendError> {
1517 Ok(Some("snapshot-head-sha".to_string()))
1520 }
1521 }
1522
1523 #[test]
1524 fn rename_entity_surfaces_partial_failure_when_peer_mem_drifts() {
1525 use crate::engine::CreateEntityArgs;
1526 use indexmap::IndexMap;
1527 use memstead_schema::workspace_config::CrossLinkValue;
1528
1529 let tmp_specs = TempDir::new().unwrap();
1530 let tmp_memos = TempDir::new().unwrap();
1531 let specs_dir = tmp_specs.path().to_path_buf();
1532 let memos_dir = tmp_memos.path().to_path_buf();
1533
1534 let writer_specs = FilesystemMemWriter::new(specs_dir.clone());
1539 let writer_memos_inner: Box<dyn MemBackend> =
1540 Box::new(FilesystemMemWriter::new(memos_dir.clone()));
1541 let writer_memos = DriftingBackend::new(writer_memos_inner);
1542
1543 let mut engine = Engine::from_mounts(vec![
1544 (
1545 folder_mount("specs", specs_dir.clone()),
1546 Box::new(writer_specs) as Box<dyn MemBackend>,
1547 ),
1548 (
1549 folder_mount("memos", memos_dir.clone()),
1550 Box::new(writer_memos) as Box<dyn MemBackend>,
1551 ),
1552 ])
1553 .unwrap();
1554 let mut settings = crate::workspace::WorkspaceSettings::default();
1555 settings.cross_mem_links.insert(
1556 "memos".to_string(),
1557 CrossLinkValue::List(vec!["specs".to_string()]),
1558 );
1559 settings.cross_mem_links.insert(
1560 "specs".to_string(),
1561 CrossLinkValue::List(vec!["memos".to_string()]),
1562 );
1563 engine.set_settings(settings);
1564
1565 let (actor, client) = cli_actor();
1566 let target = engine
1567 .create_entity(
1568 empty_create_args("specs", "Target Spec"),
1569 actor,
1570 Some(&client),
1571 None,
1572 )
1573 .unwrap();
1574 let mut sections: IndexMap<String, String> = IndexMap::new();
1575 sections.insert("claim".to_string(), "the claim".to_string());
1576 sections.insert(
1577 "context".to_string(),
1578 "see [[specs:target-spec]]".to_string(),
1579 );
1580 let _referrer = engine
1581 .create_entity(
1582 CreateEntityArgs {
1583 anchors: Vec::new(),
1584 mem: "memos".to_string(),
1585 title: "Cross Note".to_string(),
1586 entity_type: "memo".to_string(),
1587 sections,
1588 metadata: IndexMap::new(),
1589 relations: Vec::new(),
1593 dry_run: false,
1594 },
1595 actor,
1596 Some(&client),
1597 None,
1598 )
1599 .unwrap();
1600
1601 let err = engine
1602 .rename_entity(
1603 RenameEntityArgs {
1604 id: target.id.clone(),
1605 expected_hash: Some(target.content_hash.clone()),
1606 new_title: "Renamed Spec".to_string(),
1607 },
1608 actor,
1609 Some(&client),
1610 None,
1611 )
1612 .unwrap_err();
1613 match err {
1614 EngineError::RenamePartialFailure {
1615 committed_mems,
1616 failed_mem,
1617 failure_cause,
1618 } => {
1619 assert_eq!(committed_mems, vec!["specs".to_string()]);
1623 assert_eq!(failed_mem, "memos");
1624 assert_eq!(failure_cause, "drift");
1625 }
1626 other => panic!("expected RenamePartialFailure, got {other:?}"),
1627 }
1628 assert!(specs_dir.join("renamed-spec.md").exists());
1632 assert!(!specs_dir.join(&target.file_path).exists());
1633 }
1634
1635 #[test]
1636 fn rename_entity_tags_every_per_mem_commit_with_same_logical_operation_id() {
1637 use crate::backend::MemBackend;
1638 use crate::engine::CreateEntityArgs;
1639 use indexmap::IndexMap;
1640
1641 let tmp_specs = TempDir::new().unwrap();
1642 let tmp_memos = TempDir::new().unwrap();
1643 let specs_dir = tmp_specs.path().to_path_buf();
1644 let memos_dir = tmp_memos.path().to_path_buf();
1645 let mut engine =
1646 engine_with_two_mems_and_bidirectional_policy(specs_dir.clone(), memos_dir.clone());
1647 let (actor, client) = cli_actor();
1648
1649 let target = engine
1650 .create_entity(
1651 empty_create_args("specs", "Target Spec"),
1652 actor,
1653 Some(&client),
1654 None,
1655 )
1656 .unwrap();
1657 let mut sections: IndexMap<String, String> = IndexMap::new();
1658 sections.insert("claim".to_string(), "the claim".to_string());
1659 sections.insert(
1660 "context".to_string(),
1661 "discussion stems from [[specs:target-spec]]".to_string(),
1662 );
1663 let _referrer = engine
1664 .create_entity(
1665 CreateEntityArgs {
1666 anchors: Vec::new(),
1667 mem: "memos".to_string(),
1668 title: "Cross Note".to_string(),
1669 entity_type: "memo".to_string(),
1670 sections,
1671 metadata: IndexMap::new(),
1672 relations: Vec::new(),
1676 dry_run: false,
1677 },
1678 actor,
1679 Some(&client),
1680 None,
1681 )
1682 .unwrap();
1683
1684 let _ = engine
1685 .rename_entity(
1686 RenameEntityArgs {
1687 id: target.id.clone(),
1688 expected_hash: Some(target.content_hash.clone()),
1689 new_title: "Renamed Spec".to_string(),
1690 },
1691 actor,
1692 Some(&client),
1693 None,
1694 )
1695 .unwrap();
1696
1697 let specs_backend: Box<dyn MemBackend> =
1701 Box::new(FilesystemMemWriter::new(specs_dir.clone()));
1702 let memos_backend: Box<dyn MemBackend> =
1703 Box::new(FilesystemMemWriter::new(memos_dir.clone()));
1704 let specs_provenance = specs_backend.read_provenance(None).unwrap();
1705 let memos_provenance = memos_backend.read_provenance(None).unwrap();
1706
1707 let specs_rename = specs_provenance
1708 .iter()
1709 .find(|p| matches!(p.kind, crate::provenance::ProvenanceKind::Rename))
1710 .expect("specs mem must have a rename provenance entry");
1711 let memos_rename = memos_provenance
1712 .iter()
1713 .find(|p| matches!(p.kind, crate::provenance::ProvenanceKind::Rename))
1714 .expect("memos mem must have a rename provenance entry");
1715
1716 let specs_id = specs_rename
1717 .logical_operation_id
1718 .as_deref()
1719 .expect("specs rename entry must carry a logical_operation_id");
1720 let memos_id = memos_rename
1721 .logical_operation_id
1722 .as_deref()
1723 .expect("memos rename entry must carry a logical_operation_id");
1724 assert_eq!(
1725 specs_id, memos_id,
1726 "both per-mem rename commits must share the same logical_operation_id"
1727 );
1728 assert!(
1729 specs_id.starts_with("logop-"),
1730 "logical_operation_id must use the `logop-` prefix the engine mints; got {specs_id}"
1731 );
1732 }
1733
1734 #[test]
1735 fn rename_entity_refuses_when_cross_mem_referrer_blocked_by_policy() {
1736 use crate::engine::CreateEntityArgs;
1737 use indexmap::IndexMap;
1738 use memstead_schema::workspace_config::CrossLinkValue;
1739
1740 let tmp_specs = TempDir::new().unwrap();
1741 let tmp_memos = TempDir::new().unwrap();
1742 let specs_dir = tmp_specs.path().to_path_buf();
1743 let memos_dir = tmp_memos.path().to_path_buf();
1744
1745 let mut engine =
1748 engine_with_two_mems_and_bidirectional_policy(specs_dir.clone(), memos_dir.clone());
1749 let (actor, client) = cli_actor();
1750
1751 let target = engine
1752 .create_entity(
1753 empty_create_args("specs", "Target Spec"),
1754 actor,
1755 Some(&client),
1756 None,
1757 )
1758 .unwrap();
1759 let mut sections: IndexMap<String, String> = IndexMap::new();
1760 sections.insert("claim".to_string(), "the claim".to_string());
1761 sections.insert(
1762 "context".to_string(),
1763 "see [[specs:target-spec]]".to_string(),
1764 );
1765 let _referrer = engine
1766 .create_entity(
1767 CreateEntityArgs {
1768 anchors: Vec::new(),
1769 mem: "memos".to_string(),
1770 title: "Cross Note".to_string(),
1771 entity_type: "memo".to_string(),
1772 sections,
1773 metadata: IndexMap::new(),
1774 relations: Vec::new(),
1778 dry_run: false,
1779 },
1780 actor,
1781 Some(&client),
1782 None,
1783 )
1784 .unwrap();
1785
1786 let mut settings = crate::workspace::WorkspaceSettings::default();
1792 settings.cross_mem_links.insert(
1793 "specs".to_string(),
1794 CrossLinkValue::List(vec!["memos".to_string()]),
1795 );
1796 engine.set_settings(settings);
1798
1799 let err = engine
1800 .rename_entity(
1801 RenameEntityArgs {
1802 id: target.id.clone(),
1803 expected_hash: Some(target.content_hash.clone()),
1804 new_title: "Renamed Spec".to_string(),
1805 },
1806 actor,
1807 Some(&client),
1808 None,
1809 )
1810 .unwrap_err();
1811 match err {
1812 EngineError::RenameBlockedByCrossMemPolicy {
1813 from_mem,
1814 blocked_referrers,
1815 } => {
1816 assert_eq!(from_mem, "specs");
1817 assert_eq!(blocked_referrers.len(), 1);
1818 assert_eq!(blocked_referrers[0].from_mem, "memos");
1819 assert_eq!(blocked_referrers[0].to_mem, "specs");
1820 assert_eq!(blocked_referrers[0].count, 1);
1821 }
1822 other => panic!("expected RenameBlockedByCrossMemPolicy, got {other:?}"),
1823 }
1824 assert!(specs_dir.join(&target.file_path).exists());
1827 assert!(!specs_dir.join("renamed-spec.md").exists());
1828 }
1829
1830 #[test]
1841 fn rename_entity_demotes_to_stub_when_only_readonly_cross_mem_referrers_remain() {
1842 use crate::engine::test_helpers::{archive_mount, build_archive};
1843 use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
1844
1845 let tmp = TempDir::new().unwrap();
1846 let writable_dir = tmp.path().join("writable");
1847 std::fs::create_dir_all(&writable_dir).unwrap();
1848 let writer = FilesystemMemWriter::new(writable_dir.clone());
1849
1850 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";
1854 let archive_path = build_archive(
1855 tmp.path(),
1856 "archive",
1857 &[("archived-source.md", archive_md.as_bytes())],
1858 );
1859
1860 let folder_mount = Mount {
1861 mem: "specs".to_string(),
1862 schema: Some(crate::engine::test_helpers::pin("default")),
1863 storage: MountStorage::Folder {
1864 path: writable_dir.clone(),
1865 },
1866 capability: MountCapability::Write,
1867 lifecycle: MountLifecycle::Eager,
1868 cross_linkable: true,
1869 migration_target: None,
1870 };
1871 let archive_reader = crate::storage::ArchiveBackend::new(archive_path.clone());
1872 let mut engine = Engine::from_mounts(vec![
1873 (folder_mount, Box::new(writer) as Box<dyn MemBackend>),
1874 (
1875 archive_mount("archive", archive_path.clone()),
1876 Box::new(archive_reader) as Box<dyn MemBackend>,
1877 ),
1878 ])
1879 .unwrap();
1880
1881 let (actor, client) = cli_actor();
1882 let target = engine
1883 .create_entity(
1884 empty_create_args("specs", "Target"),
1885 actor,
1886 Some(&client),
1887 None,
1888 )
1889 .unwrap();
1890
1891 let archived_source_id = crate::EntityId::new("archive", "archived-source");
1894 let incoming_pre: Vec<_> = engine
1895 .store()
1896 .incoming(&target.id)
1897 .iter()
1898 .map(|e| e.from.clone())
1899 .collect();
1900 assert!(
1901 incoming_pre.contains(&archived_source_id),
1902 "archive wiki-link must produce an incoming edge on target; got {incoming_pre:?}"
1903 );
1904
1905 let outcome = engine
1908 .rename_entity(
1909 RenameEntityArgs {
1910 id: target.id.clone(),
1911 expected_hash: Some(target.content_hash.clone()),
1912 new_title: "Renamed Target".to_string(),
1913 },
1914 actor,
1915 Some(&client),
1916 None,
1917 )
1918 .unwrap();
1919 assert_eq!(outcome.new_id.to_string(), "specs--renamed-target");
1920
1921 assert!(writable_dir.join(&outcome.new_path).exists());
1923 let demoted = engine
1925 .get_entity(&target.id)
1926 .expect("residual stub must remain at old id");
1927 assert!(demoted.stub, "demoted entity must be flagged as stub");
1928 assert!(demoted.entity_type.is_empty());
1929 let incoming_old: Vec<_> = engine
1932 .store()
1933 .incoming(&target.id)
1934 .iter()
1935 .map(|e| e.from.clone())
1936 .collect();
1937 assert!(
1938 incoming_old.contains(&archived_source_id),
1939 "archive incoming edge must survive demotion at old id; got {incoming_old:?}"
1940 );
1941 let incoming_new: Vec<_> = engine
1944 .store()
1945 .incoming(&outcome.new_id)
1946 .iter()
1947 .map(|e| e.from.clone())
1948 .collect();
1949 assert!(
1950 !incoming_new.contains(&archived_source_id),
1951 "archive must not be wired to new id (markdown still references old slug); got {incoming_new:?}"
1952 );
1953 let referrers = outcome
1955 .warnings
1956 .iter()
1957 .find_map(|w| match w {
1958 WarningHint::ResidualStubForReadOnlyReferrers {
1959 id: warn_id,
1960 referrers,
1961 } => {
1962 assert_eq!(warn_id, &target.id);
1963 Some(referrers.clone())
1964 }
1965 _ => None,
1966 })
1967 .expect("ResidualStubForReadOnlyReferrers warning must surface");
1968 assert_eq!(referrers, vec![archived_source_id]);
1969 }
1970
1971 #[test]
1979 fn rename_entity_rewrites_full_id_form_body_link_on_same_mem_referrer() {
1980 use crate::engine::CreateEntityArgs;
1981 use indexmap::IndexMap;
1982 let tmp = TempDir::new().unwrap();
1983 let mem_dir = tmp.path().to_path_buf();
1984 let writer = FilesystemMemWriter::new(mem_dir.clone());
1985 let mut engine = Engine::from_mounts(vec![(
1986 folder_mount("specs", mem_dir.clone()),
1987 Box::new(writer) as Box<dyn MemBackend>,
1988 )])
1989 .unwrap();
1990 let (actor, client) = cli_actor();
1991
1992 let target = engine
1994 .create_entity(
1995 empty_create_args("specs", "Target Spec"),
1996 actor,
1997 Some(&client),
1998 None,
1999 )
2000 .unwrap();
2001 assert_eq!(target.id.to_string(), "specs--target-spec");
2002
2003 let mut sections: IndexMap<String, String> = IndexMap::new();
2008 sections.insert("identity".to_string(), "referrer identity".to_string());
2009 sections.insert(
2010 "purpose".to_string(),
2011 "see also [[specs--target-spec]] for context".to_string(),
2012 );
2013 let referrer = engine
2014 .create_entity(
2015 CreateEntityArgs {
2016 anchors: Vec::new(),
2017 mem: "specs".to_string(),
2018 title: "Referrer Full".to_string(),
2019 entity_type: "spec".to_string(),
2020 sections,
2021 metadata: IndexMap::new(),
2022 relations: Vec::new(),
2023 dry_run: false,
2024 },
2025 actor,
2026 Some(&client),
2027 None,
2028 )
2029 .unwrap();
2030
2031 let renamed = engine
2032 .rename_entity(
2033 RenameEntityArgs {
2034 id: target.id.clone(),
2035 expected_hash: Some(target.content_hash.clone()),
2036 new_title: "Renamed Spec".to_string(),
2037 },
2038 actor,
2039 Some(&client),
2040 None,
2041 )
2042 .unwrap();
2043 assert_eq!(renamed.new_id.to_string(), "specs--renamed-spec");
2044
2045 let referrer_path = mem_dir.join(&referrer.file_path);
2049 let body = std::fs::read_to_string(&referrer_path).unwrap();
2050 assert!(
2051 body.contains("[[specs--renamed-spec]]"),
2052 "expected full-id form retargeted to new slug, got:\n{body}"
2053 );
2054 assert!(
2055 !body.contains("target-spec"),
2056 "old slug must not survive in any form, got:\n{body}"
2057 );
2058 }
2059
2060 #[test]
2061 fn rename_entity_rejects_collision_with_existing_id() {
2062 let tmp = TempDir::new().unwrap();
2063 let (mut engine, first) = engine_with_seed(&tmp, "First");
2064 let (actor, client) = cli_actor();
2065 let _ = engine
2066 .create_entity(
2067 empty_create_args("specs", "Second"),
2068 actor,
2069 Some(&client),
2070 None,
2071 )
2072 .unwrap();
2073 let err = engine
2075 .rename_entity(
2076 RenameEntityArgs {
2077 id: first.id.clone(),
2078 expected_hash: Some(first.content_hash.clone()),
2079 new_title: "Second".to_string(),
2080 },
2081 actor,
2082 Some(&client),
2083 None,
2084 )
2085 .unwrap_err();
2086 assert!(matches!(
2087 err,
2088 EngineError::AlreadyExists { ref id, ref existing_title, existing_is_stub: false }
2089 if id == "specs--second" && existing_title == "Second"
2090 ));
2091 }
2092
2093 #[test]
2100 fn rename_propagation_gate_checks_actual_edge_direction() {
2101 use crate::engine::error::BlockedReferrer;
2102 use crate::engine::{CreateEntityArgs, RelateEntityArgs};
2103 use indexmap::IndexMap;
2104 use memstead_schema::workspace_config::CrossLinkValue;
2105
2106 let tmp_test = TempDir::new().unwrap();
2107 let tmp_other = TempDir::new().unwrap();
2108 let test_dir = tmp_test.path().to_path_buf();
2109 let other_dir = tmp_other.path().to_path_buf();
2110
2111 let writer_test = FilesystemMemWriter::new(test_dir.clone());
2115 let writer_other = FilesystemMemWriter::new(other_dir.clone());
2116 let mut engine = Engine::from_mounts(vec![
2117 (
2118 folder_mount("test", test_dir.clone()),
2119 Box::new(writer_test) as Box<dyn MemBackend>,
2120 ),
2121 (
2122 folder_mount("other", other_dir.clone()),
2123 Box::new(writer_other) as Box<dyn MemBackend>,
2124 ),
2125 ])
2126 .unwrap();
2127 let (actor, client) = cli_actor();
2128
2129 let mut settings = crate::workspace::WorkspaceSettings::default();
2132 settings.cross_mem_links.insert(
2133 "test".to_string(),
2134 CrossLinkValue::List(vec!["other".to_string()]),
2135 );
2136 engine.set_settings(settings);
2137
2138 let target = engine
2139 .create_entity(
2140 empty_create_args("other", "Target"),
2141 actor,
2142 Some(&client),
2143 None,
2144 )
2145 .unwrap();
2146 let mut src_sections: IndexMap<String, String> = IndexMap::new();
2147 src_sections.insert("identity".to_string(), "source identity".to_string());
2148 src_sections.insert("purpose".to_string(), "source purpose".to_string());
2149 let src = engine
2150 .create_entity(
2151 CreateEntityArgs {
2152 anchors: Vec::new(),
2153 mem: "test".to_string(),
2154 title: "Src".to_string(),
2155 entity_type: "spec".to_string(),
2156 sections: src_sections,
2157 metadata: IndexMap::new(),
2158 relations: Vec::new(),
2159 dry_run: false,
2160 },
2161 actor,
2162 Some(&client),
2163 None,
2164 )
2165 .unwrap();
2166 let src = engine
2167 .relate_entity(
2168 RelateEntityArgs {
2169 source: src.id.clone(),
2170 rel_type: "IMPLEMENTS".to_string(),
2171 target: target.id.clone(),
2172 expected_hash: Some(src.content_hash.clone()),
2173 remove: false,
2174 description: None,
2175 dry_run: false,
2176 },
2177 actor,
2178 Some(&client),
2179 None,
2180 )
2181 .unwrap();
2182 let _ = src; engine.set_settings(crate::workspace::WorkspaceSettings::default());
2189
2190 let err = engine
2191 .rename_entity(
2192 RenameEntityArgs {
2193 id: target.id.clone(),
2194 expected_hash: Some(target.content_hash.clone()),
2195 new_title: "Renamed".to_string(),
2196 },
2197 actor,
2198 Some(&client),
2199 None,
2200 )
2201 .unwrap_err();
2202 match err {
2203 EngineError::RenameBlockedByCrossMemPolicy {
2204 from_mem,
2205 blocked_referrers,
2206 } => {
2207 assert_eq!(from_mem, "other");
2208 assert_eq!(
2209 blocked_referrers,
2210 vec![BlockedReferrer {
2211 from_mem: "test".to_string(),
2212 to_mem: "other".to_string(),
2213 count: 1,
2214 }],
2215 "blocked_referrers must name the actual edge direction (test → other)",
2216 );
2217 }
2218 other => panic!("expected RenameBlockedByCrossMemPolicy, got {other:?}"),
2219 }
2220 assert!(other_dir.join(&target.file_path).exists());
2223 assert!(!other_dir.join("renamed.md").exists());
2224 }
2225
2226 #[test]
2230 fn rename_propagation_succeeds_when_actual_edge_direction_granted() {
2231 use crate::engine::{CreateEntityArgs, RelateEntityArgs};
2232 use indexmap::IndexMap;
2233 use memstead_schema::workspace_config::CrossLinkValue;
2234
2235 let tmp_test = TempDir::new().unwrap();
2236 let tmp_other = TempDir::new().unwrap();
2237 let test_dir = tmp_test.path().to_path_buf();
2238 let other_dir = tmp_other.path().to_path_buf();
2239
2240 let writer_test = FilesystemMemWriter::new(test_dir.clone());
2241 let writer_other = FilesystemMemWriter::new(other_dir.clone());
2242 let mut engine = Engine::from_mounts(vec![
2243 (
2244 folder_mount("test", test_dir),
2245 Box::new(writer_test) as Box<dyn MemBackend>,
2246 ),
2247 (
2248 folder_mount("other", other_dir),
2249 Box::new(writer_other) as Box<dyn MemBackend>,
2250 ),
2251 ])
2252 .unwrap();
2253 let (actor, client) = cli_actor();
2254
2255 let mut settings = crate::workspace::WorkspaceSettings::default();
2256 settings.cross_mem_links.insert(
2257 "test".to_string(),
2258 CrossLinkValue::List(vec!["other".to_string()]),
2259 );
2260 engine.set_settings(settings);
2261
2262 let target = engine
2263 .create_entity(
2264 empty_create_args("other", "Target"),
2265 actor,
2266 Some(&client),
2267 None,
2268 )
2269 .unwrap();
2270 let mut src_sections: IndexMap<String, String> = IndexMap::new();
2271 src_sections.insert("identity".to_string(), "source identity".to_string());
2272 src_sections.insert("purpose".to_string(), "source purpose".to_string());
2273 let src = engine
2274 .create_entity(
2275 CreateEntityArgs {
2276 anchors: Vec::new(),
2277 mem: "test".to_string(),
2278 title: "Src".to_string(),
2279 entity_type: "spec".to_string(),
2280 sections: src_sections,
2281 metadata: IndexMap::new(),
2282 relations: Vec::new(),
2283 dry_run: false,
2284 },
2285 actor,
2286 Some(&client),
2287 None,
2288 )
2289 .unwrap();
2290 let _ = engine
2291 .relate_entity(
2292 RelateEntityArgs {
2293 source: src.id.clone(),
2294 rel_type: "IMPLEMENTS".to_string(),
2295 target: target.id.clone(),
2296 expected_hash: Some(src.content_hash.clone()),
2297 remove: false,
2298 description: None,
2299 dry_run: false,
2300 },
2301 actor,
2302 Some(&client),
2303 None,
2304 )
2305 .unwrap();
2306
2307 let outcome = engine
2310 .rename_entity(
2311 RenameEntityArgs {
2312 id: target.id.clone(),
2313 expected_hash: Some(target.content_hash.clone()),
2314 new_title: "Renamed".to_string(),
2315 },
2316 actor,
2317 Some(&client),
2318 None,
2319 )
2320 .unwrap();
2321 assert_ne!(outcome.old_id, outcome.new_id);
2322 let renamed = engine
2323 .get_entity(&outcome.new_id)
2324 .expect("renamed entity persists");
2325 assert_eq!(renamed.title, "Renamed");
2326 let updated_src = engine.get_entity(&src.id).expect("source persists");
2328 assert!(
2329 updated_src
2330 .relationships
2331 .iter()
2332 .any(|r| r.rel_type == "IMPLEMENTS" && r.target == outcome.new_id),
2333 "referrer's IMPLEMENTS edge must point at the new id after rewrite"
2334 );
2335 }
2336
2337 #[test]
2341 fn rename_with_no_cross_mem_referrers_succeeds_regardless_of_policy() {
2342 let tmp = TempDir::new().unwrap();
2343 let mem_dir = tmp.path().to_path_buf();
2344 let writer = FilesystemMemWriter::new(mem_dir.clone());
2345 let mut engine = Engine::from_mounts(vec![(
2346 folder_mount("specs", mem_dir),
2347 Box::new(writer) as Box<dyn MemBackend>,
2348 )])
2349 .unwrap();
2350 let (actor, client) = cli_actor();
2351 let target = engine
2355 .create_entity(
2356 empty_create_args("specs", "Target"),
2357 actor,
2358 Some(&client),
2359 None,
2360 )
2361 .unwrap();
2362 let outcome = engine
2363 .rename_entity(
2364 RenameEntityArgs {
2365 id: target.id.clone(),
2366 expected_hash: Some(target.content_hash.clone()),
2367 new_title: "Renamed Target".to_string(),
2368 },
2369 actor,
2370 Some(&client),
2371 None,
2372 )
2373 .unwrap();
2374 assert_ne!(outcome.old_id, outcome.new_id);
2375 }
2376}