1use std::path::Path;
5
6use crate::entity::EntityId;
7use crate::ops::WarningHint;
8use crate::provenance::{Provenance, ProvenanceKind};
9use crate::vcs::{Actor, ClientId, CommitContext};
10use crate::workspace::MountCapability;
11
12use super::super::{DeleteEntityArgs, DeleteEntityOutcome, Engine, EngineError, ReferrerInfo};
13use super::{gc_orphan_stubs, make_stub};
14
15#[derive(Debug, Clone)]
23pub struct DeleteReferrers {
24 pub write_referrers: Vec<ReferrerInfo>,
25 pub readonly_referrers: Vec<EntityId>,
26}
27
28impl DeleteReferrers {
29 pub fn would_refuse(&self) -> bool {
31 !self.write_referrers.is_empty()
32 }
33}
34
35impl Engine {
36 pub fn classify_delete_referrers(&self, id: &EntityId) -> DeleteReferrers {
48 let mut write_referrers: Vec<ReferrerInfo> = Vec::new();
49 let mut readonly_referrers: Vec<EntityId> = Vec::new();
50 for edge in self.store.incoming(id) {
51 let from_mem = edge.from.mem().to_string();
52 let cap = self
53 .mounts
54 .iter()
55 .find(|m| m.mount.mem == from_mem)
56 .map(|m| m.mount.capability)
57 .unwrap_or(MountCapability::Write);
58 match cap {
59 MountCapability::Write => {
60 let from_id = edge.from.to_string();
61 if let Some(existing) =
62 write_referrers.iter_mut().find(|r| r.from_id == from_id)
63 {
64 if !existing.rel_types.contains(&edge.rel_type) {
65 existing.rel_types.push(edge.rel_type.clone());
66 }
67 } else {
68 write_referrers.push(ReferrerInfo {
69 from_id,
70 rel_types: vec![edge.rel_type.clone()],
71 mem: from_mem,
72 });
73 }
74 }
75 MountCapability::ReadOnly => readonly_referrers.push(edge.from.clone()),
76 }
77 }
78 DeleteReferrers {
79 write_referrers,
80 readonly_referrers,
81 }
82 }
83
84 pub fn delete_entity(
106 &mut self,
107 args: DeleteEntityArgs,
108 actor: Actor,
109 client: Option<&ClientId>,
110 note: Option<&str>,
111 ) -> Result<DeleteEntityOutcome, EngineError> {
112 let mut args = args;
114 let (resolved, short_hint) = self.resolve_entity_id(&args.id)?;
115 args.id = resolved;
116 let id = &args.id;
117 let mem = id.mem().to_string();
118
119 let mount_idx = self
120 .mounts
121 .iter()
122 .position(|m| m.mount.mem == mem)
123 .ok_or_else(|| self.unknown_mem_error(&mem))?;
124 if self.mounts[mount_idx].mount.capability != MountCapability::Write {
125 return Err(EngineError::ReadOnlyMount(mem));
126 }
127
128 self.ensure_mems_loaded(None);
136
137 let mut drift_warnings: Vec<WarningHint> = short_hint.into_iter().collect();
142 drift_warnings.extend(self.reload_if_stale(Some(&mem)));
143
144 let entity = self
145 .store
146 .get(id)
147 .ok_or_else(|| EngineError::NotFound { id: id.to_string() })?;
148
149 if let Some(expected) = args.expected_hash.as_deref()
150 && entity.content_hash != expected
151 {
152 return Err(EngineError::HashMismatch {
160 id: id.to_string(),
161 current: entity.content_hash.clone(),
162 is_stub: entity.stub,
163 });
164 }
165
166 let file_path = entity.file_path.clone();
167 let entity_is_stub = entity.stub;
168
169 let DeleteReferrers {
175 write_referrers,
176 readonly_referrers,
177 } = self.classify_delete_referrers(id);
178 if !write_referrers.is_empty() {
179 return Err(EngineError::HasIncomingRefs {
180 id: id.to_string(),
181 referrers: write_referrers,
182 });
183 }
184
185 let demote_to_stub = !readonly_referrers.is_empty();
186 let removed_incoming: Vec<String> =
187 readonly_referrers.iter().map(|id| id.to_string()).collect();
188
189 let relations_removed = if demote_to_stub {
195 self.store.outgoing(id).len()
196 } else {
197 self.store.outgoing(id).len() + self.store.incoming(id).len()
198 };
199
200 let backend = self.mounts[mount_idx].backend.as_ref();
209 let write_id = if entity_is_stub {
210 String::new()
211 } else {
212 backend.delete_entity(Path::new(&file_path))?;
213 super::stage_anchors_removal(backend, id)?;
217 let commit_subject = format!("memstead: delete {id}");
218 let ctx = CommitContext {
219 actor,
220 client: client.cloned(),
221 tool: Some("delete_entity"),
222 note: note.map(String::from),
223 role: self.current_role,
224 identity: self.current_identity.clone(),
225 logical_operation_id: None,
226 entity_ids: None,
227 };
228 backend.commit(&commit_subject, &ctx)?
229 };
230
231 backend.append_provenance(
232 &Provenance::new(
233 std::time::SystemTime::now(),
234 ProvenanceKind::Delete,
235 Some(id.to_string()),
236 actor,
237 client.cloned(),
238 note.map(String::from),
239 )
240 .with_role(self.current_role)
241 .with_identity(self.current_identity.clone()),
242 )?;
243
244 let mut stamp_warnings: Vec<WarningHint> = Vec::new();
245 if !write_id.is_empty() {
246 self.record_self_write(mount_idx, &write_id);
247 stamp_warnings = self.stamp_mutation_versions(mount_idx);
248 }
249
250 let mut warnings: Vec<WarningHint> = Vec::new();
251 warnings.extend(std::mem::take(&mut stamp_warnings));
252 warnings.append(&mut drift_warnings);
254 let orphan_stubs_removed = if demote_to_stub {
255 self.store.remove_edges_from(id);
262 self.store.upsert(
263 id.clone(),
264 make_stub(
265 id,
266 crate::entity::StubKind::Residual {
267 since_commit: write_id.clone(),
268 readonly_referrers: readonly_referrers.clone(),
269 },
270 ),
271 );
272 warnings.push(WarningHint::ResidualStubForReadOnlyReferrers {
273 id: id.clone(),
274 referrers: readonly_referrers,
275 });
276 Vec::new()
282 } else {
283 self.store.remove(id);
284 gc_orphan_stubs(&mut self.store)
288 };
289
290 self.invalidate_communities();
291 self.maintain_search_indexes(std::slice::from_ref(id));
296
297 if !write_id.is_empty()
302 && let Some(w) = self.note_missing_warning("delete_entity", note)
303 {
304 warnings.push(w);
305 }
306
307 Ok(DeleteEntityOutcome {
308 id: id.clone(),
309 file_path,
310 removed_incoming,
311 relations_removed,
312 write_id,
313 orphan_stubs_removed,
314 warnings,
315 })
316 }
317
318 pub fn delete_entity_with_ctx(
322 &mut self,
323 id: &EntityId,
324 expected_hash: &str,
325 ctx: &CommitContext<'_>,
326 ) -> Result<DeleteEntityOutcome, EngineError> {
327 let args = DeleteEntityArgs {
328 id: id.clone(),
329 expected_hash: Some(expected_hash.to_string()),
330 };
331 self.delete_entity(args, ctx.actor, ctx.client.as_ref(), ctx.note.as_deref())
332 }
333}
334
335#[cfg(test)]
336mod tests {
337
338 use tempfile::TempDir;
339
340 use crate::backend::MemBackend;
341 use crate::engine::test_helpers::*;
342 use crate::engine::{
343 CreateEntityArgs, DeleteEntityArgs, Engine, EngineError, RelateEntityArgs,
344 };
345 use crate::ops::WarningHint;
346 use crate::storage::FilesystemMemWriter;
347
348 fn engine_with_anchored(
351 tmp: &TempDir,
352 title: &str,
353 ) -> (Engine, crate::engine::CreateEntityOutcome) {
354 let mem_dir = tmp.path().to_path_buf();
355 let writer = FilesystemMemWriter::new(mem_dir.clone());
356 let mut engine = Engine::from_mounts(vec![(
357 folder_mount("specs", mem_dir),
358 Box::new(writer) as Box<dyn MemBackend>,
359 )])
360 .unwrap();
361 let (actor, client) = cli_actor();
362 let mut args: CreateEntityArgs = empty_create_args("specs", title);
363 args.anchors = vec![crate::anchor::AnchorInput {
364 artifact: Some("src/lib.rs".into()),
365 grain: Some("file".into()),
366 class: Some("anchored".into()),
367 hash: Some("h1".into()),
368 hash_stability: Some("stable".into()),
369 ..Default::default()
370 }];
371 let outcome = engine
372 .create_entity(args, actor, Some(&client), None)
373 .unwrap();
374 (engine, outcome)
375 }
376
377 #[test]
378 fn delete_removes_entity_anchors_no_orphan() {
379 let tmp = TempDir::new().unwrap();
380 let (mut engine, seeded) = engine_with_anchored(&tmp, "Anchored Doomed");
381 assert_eq!(engine.entity_anchors(&seeded.id).len(), 1);
382 let (actor, client) = cli_actor();
383 engine
384 .delete_entity(
385 DeleteEntityArgs {
386 id: seeded.id.clone(),
387 expected_hash: Some(seeded.content_hash.clone()),
388 },
389 actor,
390 Some(&client),
391 None,
392 )
393 .unwrap();
394 assert!(engine.entity_anchors(&seeded.id).is_empty());
397 assert!(engine.anchors_referencing_artifact("src/lib.rs").is_empty());
398 }
399
400 #[test]
405 fn delete_through_the_engine_leaves_no_dangling_row() {
406 let tmp = TempDir::new().unwrap();
407 let (mut engine, seeded) = engine_with_anchored(&tmp, "Anchored Doomed");
408 let (actor, client) = cli_actor();
409 engine
410 .delete_entity(
411 DeleteEntityArgs {
412 id: seeded.id.clone(),
413 expected_hash: Some(seeded.content_hash.clone()),
414 },
415 actor,
416 Some(&client),
417 None,
418 )
419 .unwrap();
420 let report = engine.verify_mem_anchors("specs").unwrap();
421 assert_eq!(report.unreconciled, None, "the entity end was examined");
422 assert_eq!(
423 report.dangling, 0,
424 "the delete path took the row with it, so there is nothing to report"
425 );
426 }
427
428 #[test]
432 fn a_row_whose_entity_vanished_reads_dangling_not_resolved() {
433 let tmp = TempDir::new().unwrap();
434 let (_engine, seeded) = engine_with_anchored(&tmp, "Anchored Vanishing");
435 std::fs::remove_file(tmp.path().join(format!(
438 "{}.md",
439 seeded.id.as_ref().split_once("--").unwrap().1
440 )))
441 .unwrap();
442 let engine = Engine::from_mounts(vec![(
443 folder_mount("specs", tmp.path().to_path_buf()),
444 Box::new(FilesystemMemWriter::new(tmp.path().to_path_buf())) as Box<dyn MemBackend>,
445 )])
446 .unwrap();
447 drop(seeded);
448 let report = engine.verify_mem_anchors("specs").unwrap();
449 assert_eq!(report.dangling, 1);
450 assert_eq!(report.resolves, 0, "never evidence of health");
451 assert_eq!(
452 report.unresolvable, 0,
453 "and never folded into the artifact-end bucket, whose repair is the opposite"
454 );
455 assert_eq!(report.anchors[0].state, "dangling");
456 }
457
458 #[test]
459 fn delete_entity_removes_file_and_store_entry() {
460 let tmp = TempDir::new().unwrap();
461 let (mut engine, seeded) = engine_with_seed(&tmp, "Doomed");
462 let (actor, client) = cli_actor();
463 let outcome = engine
464 .delete_entity(
465 DeleteEntityArgs {
466 id: seeded.id.clone(),
467 expected_hash: Some(seeded.content_hash.clone()),
468 },
469 actor,
470 Some(&client),
471 None,
472 )
473 .unwrap();
474 assert_eq!(outcome.id, seeded.id);
475 assert_eq!(outcome.removed_incoming, Vec::<String>::new());
476 assert!(engine.get_entity(&seeded.id).is_none());
478 assert!(!tmp.path().join(&seeded.file_path).exists());
480 let log = std::fs::read_to_string(tmp.path().join(".memstead/changes.jsonl")).unwrap();
482 assert!(log.contains("\"kind\":\"delete\""));
483 }
484
485 #[test]
486 fn delete_entity_rejects_hash_mismatch() {
487 let tmp = TempDir::new().unwrap();
488 let (mut engine, seeded) = engine_with_seed(&tmp, "Locked");
489 let (actor, client) = cli_actor();
490 let err = engine
491 .delete_entity(
492 DeleteEntityArgs {
493 id: seeded.id.clone(),
494 expected_hash: Some("nope".to_string()),
495 },
496 actor,
497 Some(&client),
498 None,
499 )
500 .unwrap_err();
501 match err {
502 EngineError::HashMismatch {
503 id,
504 current,
505 is_stub,
506 } => {
507 assert_eq!(id, seeded.id.to_string());
508 assert_eq!(current, seeded.content_hash);
509 assert!(!is_stub, "real entity must not flag as stub");
510 }
511 other => panic!("expected HashMismatch, got {other:?}"),
512 }
513 }
514
515 #[test]
522 fn delete_entity_on_stub_with_non_empty_hash_surfaces_is_stub_flag() {
523 let tmp = TempDir::new().unwrap();
524 let (mut engine, _seed) = engine_with_seed(&tmp, "Anchor");
525 let (actor, client) = cli_actor();
526 let stub_id = crate::EntityId::new("specs", "stub-target");
535 let source = engine
536 .create_entity(
537 empty_create_args("specs", "Source With Link"),
538 actor,
539 Some(&client),
540 None,
541 )
542 .unwrap();
543 engine
544 .relate_entity(
545 RelateEntityArgs {
546 source: source.id.clone(),
547 expected_hash: Some(source.content_hash.clone()),
548 rel_type: "USES".to_string(),
549 target: stub_id.clone(),
550 remove: false,
551 description: None,
552 dry_run: false,
553 },
554 actor,
555 Some(&client),
556 None,
557 )
558 .unwrap();
559 assert!(
560 engine.store().contains(&stub_id),
561 "forward-reference relate must materialise stub"
562 );
563
564 let err = engine
568 .delete_entity(
569 DeleteEntityArgs {
570 id: stub_id.clone(),
571 expected_hash: Some("definitely-non-empty".to_string()),
572 },
573 actor,
574 Some(&client),
575 None,
576 )
577 .unwrap_err();
578 match err {
579 EngineError::HashMismatch {
580 id,
581 current,
582 is_stub,
583 } => {
584 assert_eq!(id, stub_id.to_string());
585 assert!(current.is_empty(), "stub has no content hash");
586 assert!(is_stub, "is_stub must be true on a stub mismatch");
587 let msg = format!(
588 "{}",
589 EngineError::HashMismatch {
590 id,
591 current,
592 is_stub
593 }
594 );
595 assert!(
596 msg.contains("stub") && msg.contains("expected_hash: \"\""),
597 "stub message must name the corrective action; got: {msg}",
598 );
599 }
600 other => panic!("expected HashMismatch, got {other:?}"),
601 }
602 }
603
604 #[test]
611 fn delete_entity_on_stub_with_empty_hash_succeeds_via_in_memory_route() {
612 let tmp = TempDir::new().unwrap();
613 let (mut engine, _anchor) = engine_with_seed(&tmp, "Anchor");
614 let (actor, client) = cli_actor();
615
616 let stub_id = crate::EntityId::new("specs", "ghost-target");
624 engine.store.upsert(
625 stub_id.clone(),
626 super::make_stub(&stub_id, crate::entity::StubKind::ForwardReference),
627 );
628 assert!(
629 engine.store().contains(&stub_id),
630 "injected orphan stub must be in store"
631 );
632
633 let outcome = engine
634 .delete_entity(
635 DeleteEntityArgs {
636 id: stub_id.clone(),
637 expected_hash: Some(String::new()),
638 },
639 actor,
640 Some(&client),
641 None,
642 )
643 .expect("stub delete with expected_hash=\"\" must succeed");
644
645 assert_eq!(outcome.id, stub_id);
646 assert!(
648 outcome.write_id.is_empty(),
649 "stub deletes skip the backend write; write_id is empty"
650 );
651 assert!(
653 !engine.store().contains(&stub_id),
654 "stub must be gone from the in-memory store"
655 );
656 assert!(engine.get_entity(&stub_id).is_none());
658 let log = std::fs::read_to_string(tmp.path().join(".memstead/changes.jsonl")).unwrap();
661 assert!(log.contains("\"kind\":\"delete\""));
662 }
663
664 #[test]
665 fn delete_entity_refuses_on_write_mem_referrers_with_typed_payload() {
666 let tmp = TempDir::new().unwrap();
667 let (mut engine, target) = engine_with_seed(&tmp, "Target");
668 let (actor, client) = cli_actor();
669 let source = engine
671 .create_entity(
672 empty_create_args("specs", "Source"),
673 actor,
674 Some(&client),
675 None,
676 )
677 .unwrap();
678 engine
679 .relate_entity(
680 RelateEntityArgs {
681 source: source.id.clone(),
682 expected_hash: Some(source.content_hash.clone()),
683 rel_type: "USES".to_string(),
684 target: target.id.clone(),
685 remove: false,
686 description: None,
687 dry_run: false,
688 },
689 actor,
690 Some(&client),
691 None,
692 )
693 .unwrap();
694
695 let err = engine
698 .delete_entity(
699 DeleteEntityArgs {
700 id: target.id.clone(),
701 expected_hash: None,
702 },
703 actor,
704 Some(&client),
705 None,
706 )
707 .unwrap_err();
708 match err {
709 EngineError::HasIncomingRefs { id, referrers } => {
710 assert_eq!(id, target.id.to_string());
711 assert_eq!(referrers.len(), 1);
712 let r = &referrers[0];
713 assert_eq!(r.from_id, source.id.to_string());
714 assert_eq!(r.rel_types, vec!["USES".to_string()]);
715 assert_eq!(r.mem, "specs");
716 }
717 other => panic!("expected HasIncomingRefs, got {other:?}"),
718 }
719
720 assert!(engine.get_entity(&target.id).is_some());
723 assert!(tmp.path().join(&target.file_path).exists());
724 }
725
726 #[test]
727 fn delete_entity_returns_write_id_and_relations_removed() {
728 let tmp = TempDir::new().unwrap();
729 let (mut engine, target) = engine_with_seed(&tmp, "Target");
730 let (actor, client) = cli_actor();
731
732 let source = engine
736 .create_entity(
737 empty_create_args("specs", "Source"),
738 actor,
739 Some(&client),
740 None,
741 )
742 .unwrap();
743 let related = engine
744 .relate_entity(
745 RelateEntityArgs {
746 source: source.id.clone(),
747 expected_hash: Some(source.content_hash.clone()),
748 rel_type: "USES".to_string(),
749 target: target.id.clone(),
750 remove: false,
751 description: None,
752 dry_run: false,
753 },
754 actor,
755 Some(&client),
756 None,
757 )
758 .unwrap();
759
760 let outcome = engine
761 .delete_entity(
762 DeleteEntityArgs {
763 id: source.id.clone(),
764 expected_hash: Some(related.content_hash.clone()),
765 },
766 actor,
767 Some(&client),
768 None,
769 )
770 .unwrap();
771
772 assert!(
774 !outcome.write_id.is_empty(),
775 "write_id must be populated on a real delete"
776 );
777 assert_eq!(outcome.relations_removed, 1);
779 assert!(outcome.orphan_stubs_removed.is_empty());
781 assert!(outcome.warnings.is_empty());
783 }
784
785 #[test]
786 fn delete_entity_garbage_collects_orphaned_stubs() {
787 let tmp = TempDir::new().unwrap();
788 let (mut engine, source) = engine_with_seed(&tmp, "Source");
789 let (actor, client) = cli_actor();
790 let stub_id = crate::EntityId::new("specs", "ghost-stub");
791
792 let related = engine
795 .relate_entity(
796 RelateEntityArgs {
797 source: source.id.clone(),
798 expected_hash: Some(source.content_hash.clone()),
799 rel_type: "USES".to_string(),
800 target: stub_id.clone(),
801 remove: false,
802 description: None,
803 dry_run: false,
804 },
805 actor,
806 Some(&client),
807 None,
808 )
809 .unwrap();
810 assert!(engine.store().contains(&stub_id), "stub must be in store");
811
812 let outcome = engine
815 .delete_entity(
816 DeleteEntityArgs {
817 id: source.id.clone(),
818 expected_hash: Some(related.content_hash.clone()),
819 },
820 actor,
821 Some(&client),
822 None,
823 )
824 .unwrap();
825
826 assert_eq!(outcome.orphan_stubs_removed, vec![stub_id.clone()]);
827 assert!(
828 !engine.store().contains(&stub_id),
829 "GC must drop the orphaned stub"
830 );
831 }
832
833 #[test]
842 fn delete_entity_demotes_to_stub_when_only_readonly_referrers_remain() {
843 use crate::engine::test_helpers::{archive_mount, build_archive};
844 use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
845
846 let tmp = TempDir::new().unwrap();
847 let writable_dir = tmp.path().join("writable");
848 std::fs::create_dir_all(&writable_dir).unwrap();
849 let writer = FilesystemMemWriter::new(writable_dir.clone());
850
851 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 residual-stub demotion.\n\n## Relationships\n\n- **REFERENCES**: [[specs:target]]\n";
856 let archive_path = build_archive(
857 tmp.path(),
858 "archive",
859 &[("archived-source.md", archive_md.as_bytes())],
860 );
861
862 let folder_mount = Mount {
863 mem: "specs".to_string(),
864 schema: Some(crate::engine::test_helpers::pin("default")),
865 storage: MountStorage::Folder {
866 path: writable_dir.clone(),
867 },
868 capability: MountCapability::Write,
869 lifecycle: MountLifecycle::Eager,
870 cross_linkable: true,
871 migration_target: None,
872 };
873 let archive_reader = crate::storage::ArchiveBackend::new(archive_path.clone());
874 let mut engine = Engine::from_mounts(vec![
875 (folder_mount, Box::new(writer) as Box<dyn MemBackend>),
876 (
877 archive_mount("archive", archive_path.clone()),
878 Box::new(archive_reader) as Box<dyn MemBackend>,
879 ),
880 ])
881 .unwrap();
882
883 let (actor, client) = cli_actor();
884 let target = engine
885 .create_entity(
886 empty_create_args("specs", "Target"),
887 actor,
888 Some(&client),
889 None,
890 )
891 .unwrap();
892
893 let archived_source_id = crate::EntityId::new("archive", "archived-source");
896 assert!(
897 engine.store().contains(&archived_source_id),
898 "archive entity must load into the store"
899 );
900 let incoming_pre: Vec<_> = engine
901 .store()
902 .incoming(&target.id)
903 .iter()
904 .map(|e| e.from.clone())
905 .collect();
906 assert!(
907 incoming_pre.contains(&archived_source_id),
908 "archive wiki-link must produce an incoming edge on target; got {incoming_pre:?}"
909 );
910
911 let outcome = engine
914 .delete_entity(
915 DeleteEntityArgs {
916 id: target.id.clone(),
917 expected_hash: Some(target.content_hash.clone()),
918 },
919 actor,
920 Some(&client),
921 None,
922 )
923 .unwrap();
924
925 assert!(!writable_dir.join(&target.file_path).exists());
927 let demoted = engine
929 .get_entity(&target.id)
930 .expect("residual stub must remain in store");
931 assert!(demoted.stub, "demoted entity must be flagged as stub");
932 assert!(demoted.entity_type.is_empty());
933 match &demoted.stub_kind {
937 Some(crate::entity::StubKind::Residual {
938 since_commit: _,
939 readonly_referrers,
940 }) => {
941 assert_eq!(
942 readonly_referrers,
943 &vec![archived_source_id.clone()],
944 "Residual.readonly_referrers must snapshot the surviving referrers at mutation time"
945 );
946 }
947 other => panic!("demoted stub must be tagged Residual; got {other:?}"),
948 }
949 let incoming_post: Vec<_> = engine
951 .store()
952 .incoming(&target.id)
953 .iter()
954 .map(|e| e.from.clone())
955 .collect();
956 assert!(
957 incoming_post.contains(&archived_source_id),
958 "archive incoming edge must survive demotion"
959 );
960 let referrers = outcome
962 .warnings
963 .iter()
964 .find_map(|w| match w {
965 WarningHint::ResidualStubForReadOnlyReferrers { referrers, .. } => {
966 Some(referrers.clone())
967 }
968 _ => None,
969 })
970 .expect("ResidualStubForReadOnlyReferrers warning must surface");
971 assert_eq!(referrers, vec![archived_source_id]);
972 assert_eq!(outcome.removed_incoming.len(), 1);
973 }
974
975 }