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 self.ensure_mems_loaded(None);
132
133 let mut drift_warnings = self.reload_if_stale(Some(&mem));
138
139 let entity = self
140 .store
141 .get(id)
142 .ok_or_else(|| EngineError::NotFound { id: id.to_string() })?;
143
144 if let Some(expected) = args.expected_hash.as_deref()
145 && entity.content_hash != expected
146 {
147 return Err(EngineError::HashMismatch {
155 id: id.to_string(),
156 current: entity.content_hash.clone(),
157 is_stub: entity.stub,
158 });
159 }
160
161 let file_path = entity.file_path.clone();
162 let entity_is_stub = entity.stub;
163
164 let DeleteReferrers {
170 write_referrers,
171 readonly_referrers,
172 } = self.classify_delete_referrers(id);
173 if !write_referrers.is_empty() {
174 return Err(EngineError::HasIncomingRefs {
175 id: id.to_string(),
176 referrers: write_referrers,
177 });
178 }
179
180 let demote_to_stub = !readonly_referrers.is_empty();
181 let removed_incoming: Vec<String> =
182 readonly_referrers.iter().map(|id| id.to_string()).collect();
183
184 let relations_removed = if demote_to_stub {
190 self.store.outgoing(id).len()
191 } else {
192 self.store.outgoing(id).len() + self.store.incoming(id).len()
193 };
194
195 let backend = self.mounts[mount_idx].backend.as_ref();
204 let commit_sha = if entity_is_stub {
205 String::new()
206 } else {
207 backend.delete_entity(Path::new(&file_path))?;
208 super::stage_anchors_removal(backend, id)?;
212 let commit_subject = format!("memstead: delete {id}");
213 let ctx = CommitContext {
214 actor,
215 client: client.cloned(),
216 tool: Some("delete_entity"),
217 note: note.map(String::from),
218 role: self.current_role,
219 logical_operation_id: None,
220 entity_ids: None,
221 };
222 backend.commit(&commit_subject, &ctx)?
223 };
224
225 backend.append_provenance(
226 &Provenance::new(
227 std::time::SystemTime::now(),
228 ProvenanceKind::Delete,
229 Some(id.to_string()),
230 actor,
231 client.cloned(),
232 note.map(String::from),
233 )
234 .with_role(self.current_role),
235 )?;
236
237 if !commit_sha.is_empty() {
238 self.record_self_write(mount_idx, &commit_sha);
239 self.stamp_mutation_versions(mount_idx);
240 }
241
242 let mut warnings: Vec<WarningHint> = Vec::new();
243 warnings.append(&mut drift_warnings);
245 let orphan_stubs_removed = if demote_to_stub {
246 self.store.remove_edges_from(id);
253 self.store.upsert(
254 id.clone(),
255 make_stub(
256 id,
257 crate::entity::StubKind::Residual {
258 since_commit: commit_sha.clone(),
259 readonly_referrers: readonly_referrers.clone(),
260 },
261 ),
262 );
263 warnings.push(WarningHint::ResidualStubForReadOnlyReferrers {
264 id: id.clone(),
265 referrers: readonly_referrers,
266 });
267 Vec::new()
273 } else {
274 self.store.remove(id);
275 gc_orphan_stubs(&mut self.store)
279 };
280
281 self.invalidate_communities();
282 self.maintain_search_indexes(std::slice::from_ref(id));
287
288 if !commit_sha.is_empty()
293 && let Some(w) = self.note_missing_warning("delete_entity", note)
294 {
295 warnings.push(w);
296 }
297
298 Ok(DeleteEntityOutcome {
299 id: id.clone(),
300 file_path,
301 removed_incoming,
302 relations_removed,
303 commit_sha,
304 orphan_stubs_removed,
305 warnings,
306 })
307 }
308
309 pub fn delete_entity_with_ctx(
313 &mut self,
314 id: &EntityId,
315 expected_hash: &str,
316 ctx: &CommitContext<'_>,
317 ) -> Result<DeleteEntityOutcome, EngineError> {
318 let args = DeleteEntityArgs {
319 id: id.clone(),
320 expected_hash: Some(expected_hash.to_string()),
321 };
322 self.delete_entity(args, ctx.actor, ctx.client.as_ref(), ctx.note.as_deref())
323 }
324}
325
326#[cfg(test)]
327mod tests {
328
329 use tempfile::TempDir;
330
331 use crate::backend::MemBackend;
332 use crate::engine::test_helpers::*;
333 use crate::engine::{
334 CreateEntityArgs, DeleteEntityArgs, Engine, EngineError, RelateEntityArgs,
335 };
336 use crate::ops::WarningHint;
337 use crate::storage::FilesystemMemWriter;
338
339 fn engine_with_anchored(
342 tmp: &TempDir,
343 title: &str,
344 ) -> (Engine, crate::engine::CreateEntityOutcome) {
345 let mem_dir = tmp.path().to_path_buf();
346 let writer = FilesystemMemWriter::new(mem_dir.clone());
347 let mut engine = Engine::from_mounts(vec![(
348 folder_mount("specs", mem_dir),
349 Box::new(writer) as Box<dyn MemBackend>,
350 )])
351 .unwrap();
352 let (actor, client) = cli_actor();
353 let mut args: CreateEntityArgs = empty_create_args("specs", title);
354 args.anchors = vec![crate::anchor::AnchorInput {
355 artifact: Some("src/lib.rs".into()),
356 grain: Some("file".into()),
357 class: Some("anchored".into()),
358 hash: Some("h1".into()),
359 hash_stability: Some("stable".into()),
360 ..Default::default()
361 }];
362 let outcome = engine
363 .create_entity(args, actor, Some(&client), None)
364 .unwrap();
365 (engine, outcome)
366 }
367
368 #[test]
369 fn delete_removes_entity_anchors_no_orphan() {
370 let tmp = TempDir::new().unwrap();
371 let (mut engine, seeded) = engine_with_anchored(&tmp, "Anchored Doomed");
372 assert_eq!(engine.entity_anchors(&seeded.id).len(), 1);
373 let (actor, client) = cli_actor();
374 engine
375 .delete_entity(
376 DeleteEntityArgs {
377 id: seeded.id.clone(),
378 expected_hash: Some(seeded.content_hash.clone()),
379 },
380 actor,
381 Some(&client),
382 None,
383 )
384 .unwrap();
385 assert!(engine.entity_anchors(&seeded.id).is_empty());
388 assert!(engine.anchors_referencing_artifact("src/lib.rs").is_empty());
389 }
390
391 #[test]
392 fn delete_entity_removes_file_and_store_entry() {
393 let tmp = TempDir::new().unwrap();
394 let (mut engine, seeded) = engine_with_seed(&tmp, "Doomed");
395 let (actor, client) = cli_actor();
396 let outcome = engine
397 .delete_entity(
398 DeleteEntityArgs {
399 id: seeded.id.clone(),
400 expected_hash: Some(seeded.content_hash.clone()),
401 },
402 actor,
403 Some(&client),
404 None,
405 )
406 .unwrap();
407 assert_eq!(outcome.id, seeded.id);
408 assert_eq!(outcome.removed_incoming, Vec::<String>::new());
409 assert!(engine.get_entity(&seeded.id).is_none());
411 assert!(!tmp.path().join(&seeded.file_path).exists());
413 let log = std::fs::read_to_string(tmp.path().join(".memstead/changes.jsonl")).unwrap();
415 assert!(log.contains("\"kind\":\"delete\""));
416 }
417
418 #[test]
419 fn delete_entity_rejects_hash_mismatch() {
420 let tmp = TempDir::new().unwrap();
421 let (mut engine, seeded) = engine_with_seed(&tmp, "Locked");
422 let (actor, client) = cli_actor();
423 let err = engine
424 .delete_entity(
425 DeleteEntityArgs {
426 id: seeded.id.clone(),
427 expected_hash: Some("nope".to_string()),
428 },
429 actor,
430 Some(&client),
431 None,
432 )
433 .unwrap_err();
434 match err {
435 EngineError::HashMismatch {
436 id,
437 current,
438 is_stub,
439 } => {
440 assert_eq!(id, seeded.id.to_string());
441 assert_eq!(current, seeded.content_hash);
442 assert!(!is_stub, "real entity must not flag as stub");
443 }
444 other => panic!("expected HashMismatch, got {other:?}"),
445 }
446 }
447
448 #[test]
455 fn delete_entity_on_stub_with_non_empty_hash_surfaces_is_stub_flag() {
456 let tmp = TempDir::new().unwrap();
457 let (mut engine, _seed) = engine_with_seed(&tmp, "Anchor");
458 let (actor, client) = cli_actor();
459 let stub_id = crate::EntityId::new("specs", "stub-target");
468 let source = engine
469 .create_entity(
470 empty_create_args("specs", "Source With Link"),
471 actor,
472 Some(&client),
473 None,
474 )
475 .unwrap();
476 engine
477 .relate_entity(
478 RelateEntityArgs {
479 source: source.id.clone(),
480 expected_hash: Some(source.content_hash.clone()),
481 rel_type: "USES".to_string(),
482 target: stub_id.clone(),
483 remove: false,
484 description: None,
485 dry_run: false,
486 },
487 actor,
488 Some(&client),
489 None,
490 )
491 .unwrap();
492 assert!(
493 engine.store().contains(&stub_id),
494 "forward-reference relate must materialise stub"
495 );
496
497 let err = engine
501 .delete_entity(
502 DeleteEntityArgs {
503 id: stub_id.clone(),
504 expected_hash: Some("definitely-non-empty".to_string()),
505 },
506 actor,
507 Some(&client),
508 None,
509 )
510 .unwrap_err();
511 match err {
512 EngineError::HashMismatch {
513 id,
514 current,
515 is_stub,
516 } => {
517 assert_eq!(id, stub_id.to_string());
518 assert!(current.is_empty(), "stub has no content hash");
519 assert!(is_stub, "is_stub must be true on a stub mismatch");
520 let msg = format!(
521 "{}",
522 EngineError::HashMismatch {
523 id,
524 current,
525 is_stub
526 }
527 );
528 assert!(
529 msg.contains("stub") && msg.contains("expected_hash: \"\""),
530 "stub message must name the corrective action; got: {msg}",
531 );
532 }
533 other => panic!("expected HashMismatch, got {other:?}"),
534 }
535 }
536
537 #[test]
544 fn delete_entity_on_stub_with_empty_hash_succeeds_via_in_memory_route() {
545 let tmp = TempDir::new().unwrap();
546 let (mut engine, _anchor) = engine_with_seed(&tmp, "Anchor");
547 let (actor, client) = cli_actor();
548
549 let stub_id = crate::EntityId::new("specs", "ghost-target");
557 engine.store.upsert(
558 stub_id.clone(),
559 super::make_stub(&stub_id, crate::entity::StubKind::ForwardReference),
560 );
561 assert!(
562 engine.store().contains(&stub_id),
563 "injected orphan stub must be in store"
564 );
565
566 let outcome = engine
567 .delete_entity(
568 DeleteEntityArgs {
569 id: stub_id.clone(),
570 expected_hash: Some(String::new()),
571 },
572 actor,
573 Some(&client),
574 None,
575 )
576 .expect("stub delete with expected_hash=\"\" must succeed");
577
578 assert_eq!(outcome.id, stub_id);
579 assert!(
581 outcome.commit_sha.is_empty(),
582 "stub deletes skip the backend write; commit_sha is empty"
583 );
584 assert!(
586 !engine.store().contains(&stub_id),
587 "stub must be gone from the in-memory store"
588 );
589 assert!(engine.get_entity(&stub_id).is_none());
591 let log = std::fs::read_to_string(tmp.path().join(".memstead/changes.jsonl")).unwrap();
594 assert!(log.contains("\"kind\":\"delete\""));
595 }
596
597 #[test]
598 fn delete_entity_refuses_on_write_mem_referrers_with_typed_payload() {
599 let tmp = TempDir::new().unwrap();
600 let (mut engine, target) = engine_with_seed(&tmp, "Target");
601 let (actor, client) = cli_actor();
602 let source = engine
604 .create_entity(
605 empty_create_args("specs", "Source"),
606 actor,
607 Some(&client),
608 None,
609 )
610 .unwrap();
611 engine
612 .relate_entity(
613 RelateEntityArgs {
614 source: source.id.clone(),
615 expected_hash: Some(source.content_hash.clone()),
616 rel_type: "USES".to_string(),
617 target: target.id.clone(),
618 remove: false,
619 description: None,
620 dry_run: false,
621 },
622 actor,
623 Some(&client),
624 None,
625 )
626 .unwrap();
627
628 let err = engine
631 .delete_entity(
632 DeleteEntityArgs {
633 id: target.id.clone(),
634 expected_hash: None,
635 },
636 actor,
637 Some(&client),
638 None,
639 )
640 .unwrap_err();
641 match err {
642 EngineError::HasIncomingRefs { id, referrers } => {
643 assert_eq!(id, target.id.to_string());
644 assert_eq!(referrers.len(), 1);
645 let r = &referrers[0];
646 assert_eq!(r.from_id, source.id.to_string());
647 assert_eq!(r.rel_types, vec!["USES".to_string()]);
648 assert_eq!(r.mem, "specs");
649 }
650 other => panic!("expected HasIncomingRefs, got {other:?}"),
651 }
652
653 assert!(engine.get_entity(&target.id).is_some());
656 assert!(tmp.path().join(&target.file_path).exists());
657 }
658
659 #[test]
660 fn delete_entity_returns_commit_sha_and_relations_removed() {
661 let tmp = TempDir::new().unwrap();
662 let (mut engine, target) = engine_with_seed(&tmp, "Target");
663 let (actor, client) = cli_actor();
664
665 let source = engine
669 .create_entity(
670 empty_create_args("specs", "Source"),
671 actor,
672 Some(&client),
673 None,
674 )
675 .unwrap();
676 let related = engine
677 .relate_entity(
678 RelateEntityArgs {
679 source: source.id.clone(),
680 expected_hash: Some(source.content_hash.clone()),
681 rel_type: "USES".to_string(),
682 target: target.id.clone(),
683 remove: false,
684 description: None,
685 dry_run: false,
686 },
687 actor,
688 Some(&client),
689 None,
690 )
691 .unwrap();
692
693 let outcome = engine
694 .delete_entity(
695 DeleteEntityArgs {
696 id: source.id.clone(),
697 expected_hash: Some(related.content_hash.clone()),
698 },
699 actor,
700 Some(&client),
701 None,
702 )
703 .unwrap();
704
705 assert!(
707 !outcome.commit_sha.is_empty(),
708 "commit_sha must be populated on a real delete"
709 );
710 assert_eq!(outcome.relations_removed, 1);
712 assert!(outcome.orphan_stubs_removed.is_empty());
714 assert!(outcome.warnings.is_empty());
716 }
717
718 #[test]
719 fn delete_entity_garbage_collects_orphaned_stubs() {
720 let tmp = TempDir::new().unwrap();
721 let (mut engine, source) = engine_with_seed(&tmp, "Source");
722 let (actor, client) = cli_actor();
723 let stub_id = crate::EntityId::new("specs", "ghost-stub");
724
725 let related = engine
728 .relate_entity(
729 RelateEntityArgs {
730 source: source.id.clone(),
731 expected_hash: Some(source.content_hash.clone()),
732 rel_type: "USES".to_string(),
733 target: stub_id.clone(),
734 remove: false,
735 description: None,
736 dry_run: false,
737 },
738 actor,
739 Some(&client),
740 None,
741 )
742 .unwrap();
743 assert!(engine.store().contains(&stub_id), "stub must be in store");
744
745 let outcome = engine
748 .delete_entity(
749 DeleteEntityArgs {
750 id: source.id.clone(),
751 expected_hash: Some(related.content_hash.clone()),
752 },
753 actor,
754 Some(&client),
755 None,
756 )
757 .unwrap();
758
759 assert_eq!(outcome.orphan_stubs_removed, vec![stub_id.clone()]);
760 assert!(
761 !engine.store().contains(&stub_id),
762 "GC must drop the orphaned stub"
763 );
764 }
765
766 #[test]
775 fn delete_entity_demotes_to_stub_when_only_readonly_referrers_remain() {
776 use crate::engine::test_helpers::{archive_mount, build_archive};
777 use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
778
779 let tmp = TempDir::new().unwrap();
780 let writable_dir = tmp.path().join("writable");
781 std::fs::create_dir_all(&writable_dir).unwrap();
782 let writer = FilesystemMemWriter::new(writable_dir.clone());
783
784 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";
789 let archive_path = build_archive(
790 tmp.path(),
791 "archive",
792 &[("archived-source.md", archive_md.as_bytes())],
793 );
794
795 let folder_mount = Mount {
796 mem: "specs".to_string(),
797 schema: Some(crate::engine::test_helpers::pin("default")),
798 storage: MountStorage::Folder {
799 path: writable_dir.clone(),
800 },
801 capability: MountCapability::Write,
802 lifecycle: MountLifecycle::Eager,
803 cross_linkable: true,
804 migration_target: None,
805 };
806 let archive_reader = crate::storage::ArchiveBackend::new(archive_path.clone());
807 let mut engine = Engine::from_mounts(vec![
808 (folder_mount, Box::new(writer) as Box<dyn MemBackend>),
809 (
810 archive_mount("archive", archive_path.clone()),
811 Box::new(archive_reader) as Box<dyn MemBackend>,
812 ),
813 ])
814 .unwrap();
815
816 let (actor, client) = cli_actor();
817 let target = engine
818 .create_entity(
819 empty_create_args("specs", "Target"),
820 actor,
821 Some(&client),
822 None,
823 )
824 .unwrap();
825
826 let archived_source_id = crate::EntityId::new("archive", "archived-source");
829 assert!(
830 engine.store().contains(&archived_source_id),
831 "archive entity must load into the store"
832 );
833 let incoming_pre: Vec<_> = engine
834 .store()
835 .incoming(&target.id)
836 .iter()
837 .map(|e| e.from.clone())
838 .collect();
839 assert!(
840 incoming_pre.contains(&archived_source_id),
841 "archive wiki-link must produce an incoming edge on target; got {incoming_pre:?}"
842 );
843
844 let outcome = engine
847 .delete_entity(
848 DeleteEntityArgs {
849 id: target.id.clone(),
850 expected_hash: Some(target.content_hash.clone()),
851 },
852 actor,
853 Some(&client),
854 None,
855 )
856 .unwrap();
857
858 assert!(!writable_dir.join(&target.file_path).exists());
860 let demoted = engine
862 .get_entity(&target.id)
863 .expect("residual stub must remain in store");
864 assert!(demoted.stub, "demoted entity must be flagged as stub");
865 assert!(demoted.entity_type.is_empty());
866 match &demoted.stub_kind {
870 Some(crate::entity::StubKind::Residual {
871 since_commit: _,
872 readonly_referrers,
873 }) => {
874 assert_eq!(
875 readonly_referrers,
876 &vec![archived_source_id.clone()],
877 "Residual.readonly_referrers must snapshot the surviving referrers at mutation time"
878 );
879 }
880 other => panic!("demoted stub must be tagged Residual; got {other:?}"),
881 }
882 let incoming_post: Vec<_> = engine
884 .store()
885 .incoming(&target.id)
886 .iter()
887 .map(|e| e.from.clone())
888 .collect();
889 assert!(
890 incoming_post.contains(&archived_source_id),
891 "archive incoming edge must survive demotion"
892 );
893 let referrers = outcome
895 .warnings
896 .iter()
897 .find_map(|w| match w {
898 WarningHint::ResidualStubForReadOnlyReferrers { referrers, .. } => {
899 Some(referrers.clone())
900 }
901 _ => None,
902 })
903 .expect("ResidualStubForReadOnlyReferrers warning must surface");
904 assert_eq!(referrers, vec![archived_source_id]);
905 assert_eq!(outcome.removed_incoming.len(), 1);
906 }
907
908 }