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 id = &args.id;
113 let mem = id.mem().to_string();
114
115 let mount_idx = self
116 .mounts
117 .iter()
118 .position(|m| m.mount.mem == mem)
119 .ok_or_else(|| self.unknown_mem_error(&mem))?;
120 if self.mounts[mount_idx].mount.capability != MountCapability::Write {
121 return Err(EngineError::ReadOnlyMount(mem));
122 }
123
124 let mut drift_warnings = self.reload_if_stale(Some(&mem));
129
130 let entity = self
131 .store
132 .get(id)
133 .ok_or_else(|| EngineError::NotFound { id: id.to_string() })?;
134
135 if let Some(expected) = args.expected_hash.as_deref()
136 && entity.content_hash != expected
137 {
138 return Err(EngineError::HashMismatch {
146 id: id.to_string(),
147 current: entity.content_hash.clone(),
148 is_stub: entity.stub,
149 });
150 }
151
152 let file_path = entity.file_path.clone();
153 let entity_is_stub = entity.stub;
154
155 let DeleteReferrers {
161 write_referrers,
162 readonly_referrers,
163 } = self.classify_delete_referrers(id);
164 if !write_referrers.is_empty() {
165 return Err(EngineError::HasIncomingRefs {
166 id: id.to_string(),
167 referrers: write_referrers,
168 });
169 }
170
171 let demote_to_stub = !readonly_referrers.is_empty();
172 let removed_incoming: Vec<String> =
173 readonly_referrers.iter().map(|id| id.to_string()).collect();
174
175 let relations_removed = if demote_to_stub {
181 self.store.outgoing(id).len()
182 } else {
183 self.store.outgoing(id).len() + self.store.incoming(id).len()
184 };
185
186 let backend = self.mounts[mount_idx].backend.as_ref();
195 let commit_sha = if entity_is_stub {
196 String::new()
197 } else {
198 backend.delete_entity(Path::new(&file_path))?;
199 super::stage_anchors_removal(backend, id)?;
203 let commit_subject = format!("memstead: delete {id}");
204 let ctx = CommitContext {
205 actor,
206 client: client.cloned(),
207 tool: Some("delete_entity"),
208 note: note.map(String::from),
209 role: self.current_role,
210 logical_operation_id: None,
211 entity_ids: None,
212 };
213 backend.commit(&commit_subject, &ctx)?
214 };
215
216 backend.append_provenance(
217 &Provenance::new(
218 std::time::SystemTime::now(),
219 ProvenanceKind::Delete,
220 Some(id.to_string()),
221 actor,
222 client.cloned(),
223 note.map(String::from),
224 )
225 .with_role(self.current_role),
226 )?;
227
228 if !commit_sha.is_empty() {
229 self.record_self_write(mount_idx, &commit_sha);
230 self.stamp_mutation_versions(mount_idx);
231 }
232
233 let mut warnings: Vec<WarningHint> = Vec::new();
234 warnings.append(&mut drift_warnings);
236 let orphan_stubs_removed = if demote_to_stub {
237 self.store.remove_edges_from(id);
244 self.store.upsert(
245 id.clone(),
246 make_stub(
247 id,
248 crate::entity::StubKind::Residual {
249 since_commit: commit_sha.clone(),
250 readonly_referrers: readonly_referrers.clone(),
251 },
252 ),
253 );
254 warnings.push(WarningHint::ResidualStubForReadOnlyReferrers {
255 id: id.clone(),
256 referrers: readonly_referrers,
257 });
258 Vec::new()
264 } else {
265 self.store.remove(id);
266 gc_orphan_stubs(&mut self.store)
270 };
271
272 self.invalidate_communities();
273 self.invalidate_search_indexes();
274
275 if !commit_sha.is_empty()
280 && let Some(w) = self.note_missing_warning("delete_entity", note)
281 {
282 warnings.push(w);
283 }
284
285 Ok(DeleteEntityOutcome {
286 id: id.clone(),
287 file_path,
288 removed_incoming,
289 relations_removed,
290 commit_sha,
291 orphan_stubs_removed,
292 warnings,
293 })
294 }
295
296 pub fn delete_entity_with_ctx(
300 &mut self,
301 id: &EntityId,
302 expected_hash: &str,
303 ctx: &CommitContext<'_>,
304 ) -> Result<DeleteEntityOutcome, EngineError> {
305 let args = DeleteEntityArgs {
306 id: id.clone(),
307 expected_hash: Some(expected_hash.to_string()),
308 };
309 self.delete_entity(args, ctx.actor, ctx.client.as_ref(), ctx.note.as_deref())
310 }
311}
312
313#[cfg(test)]
314mod tests {
315
316 use tempfile::TempDir;
317
318 use crate::backend::MemBackend;
319 use crate::engine::test_helpers::*;
320 use crate::engine::{
321 CreateEntityArgs, DeleteEntityArgs, Engine, EngineError, RelateEntityArgs,
322 };
323 use crate::ops::WarningHint;
324 use crate::storage::FilesystemMemWriter;
325
326 fn engine_with_anchored(
329 tmp: &TempDir,
330 title: &str,
331 ) -> (Engine, crate::engine::CreateEntityOutcome) {
332 let mem_dir = tmp.path().to_path_buf();
333 let writer = FilesystemMemWriter::new(mem_dir.clone());
334 let mut engine = Engine::from_mounts(vec![(
335 folder_mount("specs", mem_dir),
336 Box::new(writer) as Box<dyn MemBackend>,
337 )])
338 .unwrap();
339 let (actor, client) = cli_actor();
340 let mut args: CreateEntityArgs = empty_create_args("specs", title);
341 args.anchors = vec![crate::anchor::AnchorInput {
342 artifact: Some("src/lib.rs".into()),
343 grain: Some("file".into()),
344 class: Some("anchored".into()),
345 hash: Some("h1".into()),
346 hash_stability: Some("stable".into()),
347 ..Default::default()
348 }];
349 let outcome = engine
350 .create_entity(args, actor, Some(&client), None)
351 .unwrap();
352 (engine, outcome)
353 }
354
355 #[test]
356 fn delete_removes_entity_anchors_no_orphan() {
357 let tmp = TempDir::new().unwrap();
358 let (mut engine, seeded) = engine_with_anchored(&tmp, "Anchored Doomed");
359 assert_eq!(engine.entity_anchors(&seeded.id).len(), 1);
360 let (actor, client) = cli_actor();
361 engine
362 .delete_entity(
363 DeleteEntityArgs {
364 id: seeded.id.clone(),
365 expected_hash: Some(seeded.content_hash.clone()),
366 },
367 actor,
368 Some(&client),
369 None,
370 )
371 .unwrap();
372 assert!(engine.entity_anchors(&seeded.id).is_empty());
375 assert!(engine.anchors_referencing_artifact("src/lib.rs").is_empty());
376 }
377
378 #[test]
379 fn delete_entity_removes_file_and_store_entry() {
380 let tmp = TempDir::new().unwrap();
381 let (mut engine, seeded) = engine_with_seed(&tmp, "Doomed");
382 let (actor, client) = cli_actor();
383 let outcome = 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_eq!(outcome.id, seeded.id);
395 assert_eq!(outcome.removed_incoming, Vec::<String>::new());
396 assert!(engine.get_entity(&seeded.id).is_none());
398 assert!(!tmp.path().join(&seeded.file_path).exists());
400 let log = std::fs::read_to_string(tmp.path().join(".memstead/changes.jsonl")).unwrap();
402 assert!(log.contains("\"kind\":\"delete\""));
403 }
404
405 #[test]
406 fn delete_entity_rejects_hash_mismatch() {
407 let tmp = TempDir::new().unwrap();
408 let (mut engine, seeded) = engine_with_seed(&tmp, "Locked");
409 let (actor, client) = cli_actor();
410 let err = engine
411 .delete_entity(
412 DeleteEntityArgs {
413 id: seeded.id.clone(),
414 expected_hash: Some("nope".to_string()),
415 },
416 actor,
417 Some(&client),
418 None,
419 )
420 .unwrap_err();
421 match err {
422 EngineError::HashMismatch {
423 id,
424 current,
425 is_stub,
426 } => {
427 assert_eq!(id, seeded.id.to_string());
428 assert_eq!(current, seeded.content_hash);
429 assert!(!is_stub, "real entity must not flag as stub");
430 }
431 other => panic!("expected HashMismatch, got {other:?}"),
432 }
433 }
434
435 #[test]
442 fn delete_entity_on_stub_with_non_empty_hash_surfaces_is_stub_flag() {
443 let tmp = TempDir::new().unwrap();
444 let (mut engine, _seed) = engine_with_seed(&tmp, "Anchor");
445 let (actor, client) = cli_actor();
446 let stub_id = crate::EntityId::new("specs", "stub-target");
455 let source = engine
456 .create_entity(
457 empty_create_args("specs", "Source With Link"),
458 actor,
459 Some(&client),
460 None,
461 )
462 .unwrap();
463 engine
464 .relate_entity(
465 RelateEntityArgs {
466 source: source.id.clone(),
467 expected_hash: Some(source.content_hash.clone()),
468 rel_type: "USES".to_string(),
469 target: stub_id.clone(),
470 remove: false,
471 description: None,
472 dry_run: false,
473 },
474 actor,
475 Some(&client),
476 None,
477 )
478 .unwrap();
479 assert!(
480 engine.store().contains(&stub_id),
481 "forward-reference relate must materialise stub"
482 );
483
484 let err = engine
488 .delete_entity(
489 DeleteEntityArgs {
490 id: stub_id.clone(),
491 expected_hash: Some("definitely-non-empty".to_string()),
492 },
493 actor,
494 Some(&client),
495 None,
496 )
497 .unwrap_err();
498 match err {
499 EngineError::HashMismatch {
500 id,
501 current,
502 is_stub,
503 } => {
504 assert_eq!(id, stub_id.to_string());
505 assert!(current.is_empty(), "stub has no content hash");
506 assert!(is_stub, "is_stub must be true on a stub mismatch");
507 let msg = format!(
508 "{}",
509 EngineError::HashMismatch {
510 id,
511 current,
512 is_stub
513 }
514 );
515 assert!(
516 msg.contains("stub") && msg.contains("expected_hash: \"\""),
517 "stub message must name the corrective action; got: {msg}",
518 );
519 }
520 other => panic!("expected HashMismatch, got {other:?}"),
521 }
522 }
523
524 #[test]
531 fn delete_entity_on_stub_with_empty_hash_succeeds_via_in_memory_route() {
532 let tmp = TempDir::new().unwrap();
533 let (mut engine, _anchor) = engine_with_seed(&tmp, "Anchor");
534 let (actor, client) = cli_actor();
535
536 let stub_id = crate::EntityId::new("specs", "ghost-target");
544 engine.store.upsert(
545 stub_id.clone(),
546 super::make_stub(&stub_id, crate::entity::StubKind::ForwardReference),
547 );
548 assert!(
549 engine.store().contains(&stub_id),
550 "injected orphan stub must be in store"
551 );
552
553 let outcome = engine
554 .delete_entity(
555 DeleteEntityArgs {
556 id: stub_id.clone(),
557 expected_hash: Some(String::new()),
558 },
559 actor,
560 Some(&client),
561 None,
562 )
563 .expect("stub delete with expected_hash=\"\" must succeed");
564
565 assert_eq!(outcome.id, stub_id);
566 assert!(
568 outcome.commit_sha.is_empty(),
569 "stub deletes skip the backend write; commit_sha is empty"
570 );
571 assert!(
573 !engine.store().contains(&stub_id),
574 "stub must be gone from the in-memory store"
575 );
576 assert!(engine.get_entity(&stub_id).is_none());
578 let log = std::fs::read_to_string(tmp.path().join(".memstead/changes.jsonl")).unwrap();
581 assert!(log.contains("\"kind\":\"delete\""));
582 }
583
584 #[test]
585 fn delete_entity_refuses_on_write_mem_referrers_with_typed_payload() {
586 let tmp = TempDir::new().unwrap();
587 let (mut engine, target) = engine_with_seed(&tmp, "Target");
588 let (actor, client) = cli_actor();
589 let source = engine
591 .create_entity(
592 empty_create_args("specs", "Source"),
593 actor,
594 Some(&client),
595 None,
596 )
597 .unwrap();
598 engine
599 .relate_entity(
600 RelateEntityArgs {
601 source: source.id.clone(),
602 expected_hash: Some(source.content_hash.clone()),
603 rel_type: "USES".to_string(),
604 target: target.id.clone(),
605 remove: false,
606 description: None,
607 dry_run: false,
608 },
609 actor,
610 Some(&client),
611 None,
612 )
613 .unwrap();
614
615 let err = engine
618 .delete_entity(
619 DeleteEntityArgs {
620 id: target.id.clone(),
621 expected_hash: None,
622 },
623 actor,
624 Some(&client),
625 None,
626 )
627 .unwrap_err();
628 match err {
629 EngineError::HasIncomingRefs { id, referrers } => {
630 assert_eq!(id, target.id.to_string());
631 assert_eq!(referrers.len(), 1);
632 let r = &referrers[0];
633 assert_eq!(r.from_id, source.id.to_string());
634 assert_eq!(r.rel_types, vec!["USES".to_string()]);
635 assert_eq!(r.mem, "specs");
636 }
637 other => panic!("expected HasIncomingRefs, got {other:?}"),
638 }
639
640 assert!(engine.get_entity(&target.id).is_some());
643 assert!(tmp.path().join(&target.file_path).exists());
644 }
645
646 #[test]
647 fn delete_entity_returns_commit_sha_and_relations_removed() {
648 let tmp = TempDir::new().unwrap();
649 let (mut engine, target) = engine_with_seed(&tmp, "Target");
650 let (actor, client) = cli_actor();
651
652 let source = engine
656 .create_entity(
657 empty_create_args("specs", "Source"),
658 actor,
659 Some(&client),
660 None,
661 )
662 .unwrap();
663 let related = engine
664 .relate_entity(
665 RelateEntityArgs {
666 source: source.id.clone(),
667 expected_hash: Some(source.content_hash.clone()),
668 rel_type: "USES".to_string(),
669 target: target.id.clone(),
670 remove: false,
671 description: None,
672 dry_run: false,
673 },
674 actor,
675 Some(&client),
676 None,
677 )
678 .unwrap();
679
680 let outcome = engine
681 .delete_entity(
682 DeleteEntityArgs {
683 id: source.id.clone(),
684 expected_hash: Some(related.content_hash.clone()),
685 },
686 actor,
687 Some(&client),
688 None,
689 )
690 .unwrap();
691
692 assert!(
694 !outcome.commit_sha.is_empty(),
695 "commit_sha must be populated on a real delete"
696 );
697 assert_eq!(outcome.relations_removed, 1);
699 assert!(outcome.orphan_stubs_removed.is_empty());
701 assert!(outcome.warnings.is_empty());
703 }
704
705 #[test]
706 fn delete_entity_garbage_collects_orphaned_stubs() {
707 let tmp = TempDir::new().unwrap();
708 let (mut engine, source) = engine_with_seed(&tmp, "Source");
709 let (actor, client) = cli_actor();
710 let stub_id = crate::EntityId::new("specs", "ghost-stub");
711
712 let related = engine
715 .relate_entity(
716 RelateEntityArgs {
717 source: source.id.clone(),
718 expected_hash: Some(source.content_hash.clone()),
719 rel_type: "USES".to_string(),
720 target: stub_id.clone(),
721 remove: false,
722 description: None,
723 dry_run: false,
724 },
725 actor,
726 Some(&client),
727 None,
728 )
729 .unwrap();
730 assert!(engine.store().contains(&stub_id), "stub must be in store");
731
732 let outcome = engine
735 .delete_entity(
736 DeleteEntityArgs {
737 id: source.id.clone(),
738 expected_hash: Some(related.content_hash.clone()),
739 },
740 actor,
741 Some(&client),
742 None,
743 )
744 .unwrap();
745
746 assert_eq!(outcome.orphan_stubs_removed, vec![stub_id.clone()]);
747 assert!(
748 !engine.store().contains(&stub_id),
749 "GC must drop the orphaned stub"
750 );
751 }
752
753 #[test]
762 fn delete_entity_demotes_to_stub_when_only_readonly_referrers_remain() {
763 use crate::engine::test_helpers::{archive_mount, build_archive};
764 use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
765
766 let tmp = TempDir::new().unwrap();
767 let writable_dir = tmp.path().join("writable");
768 std::fs::create_dir_all(&writable_dir).unwrap();
769 let writer = FilesystemMemWriter::new(writable_dir.clone());
770
771 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";
776 let archive_path = build_archive(
777 tmp.path(),
778 "archive",
779 &[("archived-source.md", archive_md.as_bytes())],
780 );
781
782 let folder_mount = Mount {
783 mem: "specs".to_string(),
784 schema: Some(crate::engine::test_helpers::pin("default")),
785 storage: MountStorage::Folder {
786 path: writable_dir.clone(),
787 },
788 capability: MountCapability::Write,
789 lifecycle: MountLifecycle::Eager,
790 cross_linkable: true,
791 migration_target: None,
792 };
793 let archive_reader = crate::storage::ArchiveBackend::new(archive_path.clone());
794 let mut engine = Engine::from_mounts(vec![
795 (folder_mount, Box::new(writer) as Box<dyn MemBackend>),
796 (
797 archive_mount("archive", archive_path.clone()),
798 Box::new(archive_reader) as Box<dyn MemBackend>,
799 ),
800 ])
801 .unwrap();
802
803 let (actor, client) = cli_actor();
804 let target = engine
805 .create_entity(
806 empty_create_args("specs", "Target"),
807 actor,
808 Some(&client),
809 None,
810 )
811 .unwrap();
812
813 let archived_source_id = crate::EntityId::new("archive", "archived-source");
816 assert!(
817 engine.store().contains(&archived_source_id),
818 "archive entity must load into the store"
819 );
820 let incoming_pre: Vec<_> = engine
821 .store()
822 .incoming(&target.id)
823 .iter()
824 .map(|e| e.from.clone())
825 .collect();
826 assert!(
827 incoming_pre.contains(&archived_source_id),
828 "archive wiki-link must produce an incoming edge on target; got {incoming_pre:?}"
829 );
830
831 let outcome = engine
834 .delete_entity(
835 DeleteEntityArgs {
836 id: target.id.clone(),
837 expected_hash: Some(target.content_hash.clone()),
838 },
839 actor,
840 Some(&client),
841 None,
842 )
843 .unwrap();
844
845 assert!(!writable_dir.join(&target.file_path).exists());
847 let demoted = engine
849 .get_entity(&target.id)
850 .expect("residual stub must remain in store");
851 assert!(demoted.stub, "demoted entity must be flagged as stub");
852 assert!(demoted.entity_type.is_empty());
853 match &demoted.stub_kind {
857 Some(crate::entity::StubKind::Residual {
858 since_commit: _,
859 readonly_referrers,
860 }) => {
861 assert_eq!(
862 readonly_referrers,
863 &vec![archived_source_id.clone()],
864 "Residual.readonly_referrers must snapshot the surviving referrers at mutation time"
865 );
866 }
867 other => panic!("demoted stub must be tagged Residual; got {other:?}"),
868 }
869 let incoming_post: Vec<_> = engine
871 .store()
872 .incoming(&target.id)
873 .iter()
874 .map(|e| e.from.clone())
875 .collect();
876 assert!(
877 incoming_post.contains(&archived_source_id),
878 "archive incoming edge must survive demotion"
879 );
880 let referrers = outcome
882 .warnings
883 .iter()
884 .find_map(|w| match w {
885 WarningHint::ResidualStubForReadOnlyReferrers { referrers, .. } => {
886 Some(referrers.clone())
887 }
888 _ => None,
889 })
890 .expect("ResidualStubForReadOnlyReferrers warning must surface");
891 assert_eq!(referrers, vec![archived_source_id]);
892 assert_eq!(outcome.removed_incoming.len(), 1);
893 }
894
895 }