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(|| EngineError::UnknownMem(mem.clone()))?;
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 logical_operation_id: None,
210 entity_ids: None,
211 };
212 backend.commit(&commit_subject, &ctx)?
213 };
214
215 backend.append_provenance(&Provenance::new(
216 std::time::SystemTime::now(),
217 ProvenanceKind::Delete,
218 Some(id.to_string()),
219 actor,
220 client.cloned(),
221 note.map(String::from),
222 ))?;
223
224 if !commit_sha.is_empty() {
225 self.record_self_write(mount_idx, &commit_sha);
226 }
227
228 let mut warnings: Vec<WarningHint> = Vec::new();
229 warnings.append(&mut drift_warnings);
231 let orphan_stubs_removed = if demote_to_stub {
232 self.store.remove_edges_from(id);
239 self.store.upsert(
240 id.clone(),
241 make_stub(
242 id,
243 crate::entity::StubKind::Residual {
244 since_commit: commit_sha.clone(),
245 readonly_referrers: readonly_referrers.clone(),
246 },
247 ),
248 );
249 warnings.push(WarningHint::ResidualStubForReadOnlyReferrers {
250 id: id.clone(),
251 referrers: readonly_referrers,
252 });
253 Vec::new()
259 } else {
260 self.store.remove(id);
261 gc_orphan_stubs(&mut self.store)
265 };
266
267 self.invalidate_communities();
268 self.invalidate_search_indexes();
269
270 if !commit_sha.is_empty()
275 && let Some(w) = self.note_missing_warning("delete_entity", note)
276 {
277 warnings.push(w);
278 }
279
280 Ok(DeleteEntityOutcome {
281 id: id.clone(),
282 file_path,
283 removed_incoming,
284 relations_removed,
285 commit_sha,
286 orphan_stubs_removed,
287 warnings,
288 })
289 }
290
291 pub fn delete_entity_with_ctx(
295 &mut self,
296 id: &EntityId,
297 expected_hash: &str,
298 ctx: &CommitContext<'_>,
299 ) -> Result<DeleteEntityOutcome, EngineError> {
300 let args = DeleteEntityArgs {
301 id: id.clone(),
302 expected_hash: Some(expected_hash.to_string()),
303 };
304 self.delete_entity(args, ctx.actor, ctx.client.as_ref(), ctx.note.as_deref())
305 }
306}
307
308#[cfg(test)]
309mod tests {
310
311 use tempfile::TempDir;
312
313 use crate::backend::MemBackend;
314 use crate::engine::test_helpers::*;
315 use crate::engine::{
316 CreateEntityArgs, DeleteEntityArgs, Engine, EngineError, RelateEntityArgs,
317 };
318 use crate::ops::WarningHint;
319 use crate::storage::FilesystemMemWriter;
320
321 fn engine_with_anchored(
324 tmp: &TempDir,
325 title: &str,
326 ) -> (Engine, crate::engine::CreateEntityOutcome) {
327 let mem_dir = tmp.path().to_path_buf();
328 let writer = FilesystemMemWriter::new(mem_dir.clone());
329 let mut engine = Engine::from_mounts(vec![(
330 folder_mount("specs", mem_dir),
331 Box::new(writer) as Box<dyn MemBackend>,
332 )])
333 .unwrap();
334 let (actor, client) = cli_actor();
335 let mut args: CreateEntityArgs = empty_create_args("specs", title);
336 args.anchors = vec![crate::anchor::AnchorInput {
337 artifact: Some("src/lib.rs".into()),
338 grain: Some("file".into()),
339 class: Some("anchored".into()),
340 hash: Some("h1".into()),
341 hash_stability: Some("stable".into()),
342 ..Default::default()
343 }];
344 let outcome = engine
345 .create_entity(args, actor, Some(&client), None)
346 .unwrap();
347 (engine, outcome)
348 }
349
350 #[test]
351 fn delete_removes_entity_anchors_no_orphan() {
352 let tmp = TempDir::new().unwrap();
353 let (mut engine, seeded) = engine_with_anchored(&tmp, "Anchored Doomed");
354 assert_eq!(engine.entity_anchors(&seeded.id).len(), 1);
355 let (actor, client) = cli_actor();
356 engine
357 .delete_entity(
358 DeleteEntityArgs {
359 id: seeded.id.clone(),
360 expected_hash: Some(seeded.content_hash.clone()),
361 },
362 actor,
363 Some(&client),
364 None,
365 )
366 .unwrap();
367 assert!(engine.entity_anchors(&seeded.id).is_empty());
370 assert!(engine.anchors_referencing_artifact("src/lib.rs").is_empty());
371 }
372
373 #[test]
374 fn delete_entity_removes_file_and_store_entry() {
375 let tmp = TempDir::new().unwrap();
376 let (mut engine, seeded) = engine_with_seed(&tmp, "Doomed");
377 let (actor, client) = cli_actor();
378 let outcome = engine
379 .delete_entity(
380 DeleteEntityArgs {
381 id: seeded.id.clone(),
382 expected_hash: Some(seeded.content_hash.clone()),
383 },
384 actor,
385 Some(&client),
386 None,
387 )
388 .unwrap();
389 assert_eq!(outcome.id, seeded.id);
390 assert_eq!(outcome.removed_incoming, Vec::<String>::new());
391 assert!(engine.get_entity(&seeded.id).is_none());
393 assert!(!tmp.path().join(&seeded.file_path).exists());
395 let log = std::fs::read_to_string(tmp.path().join(".memstead/changes.jsonl")).unwrap();
397 assert!(log.contains("\"kind\":\"delete\""));
398 }
399
400 #[test]
401 fn delete_entity_rejects_hash_mismatch() {
402 let tmp = TempDir::new().unwrap();
403 let (mut engine, seeded) = engine_with_seed(&tmp, "Locked");
404 let (actor, client) = cli_actor();
405 let err = engine
406 .delete_entity(
407 DeleteEntityArgs {
408 id: seeded.id.clone(),
409 expected_hash: Some("nope".to_string()),
410 },
411 actor,
412 Some(&client),
413 None,
414 )
415 .unwrap_err();
416 match err {
417 EngineError::HashMismatch {
418 id,
419 current,
420 is_stub,
421 } => {
422 assert_eq!(id, seeded.id.to_string());
423 assert_eq!(current, seeded.content_hash);
424 assert!(!is_stub, "real entity must not flag as stub");
425 }
426 other => panic!("expected HashMismatch, got {other:?}"),
427 }
428 }
429
430 #[test]
437 fn delete_entity_on_stub_with_non_empty_hash_surfaces_is_stub_flag() {
438 let tmp = TempDir::new().unwrap();
439 let (mut engine, _seed) = engine_with_seed(&tmp, "Anchor");
440 let (actor, client) = cli_actor();
441 let stub_id = crate::EntityId::new("specs", "stub-target");
450 let source = engine
451 .create_entity(
452 empty_create_args("specs", "Source With Link"),
453 actor,
454 Some(&client),
455 None,
456 )
457 .unwrap();
458 engine
459 .relate_entity(
460 RelateEntityArgs {
461 source: source.id.clone(),
462 expected_hash: Some(source.content_hash.clone()),
463 rel_type: "USES".to_string(),
464 target: stub_id.clone(),
465 remove: false,
466 description: None,
467 },
468 actor,
469 Some(&client),
470 None,
471 )
472 .unwrap();
473 assert!(
474 engine.store().contains(&stub_id),
475 "forward-reference relate must materialise stub"
476 );
477
478 let err = engine
482 .delete_entity(
483 DeleteEntityArgs {
484 id: stub_id.clone(),
485 expected_hash: Some("definitely-non-empty".to_string()),
486 },
487 actor,
488 Some(&client),
489 None,
490 )
491 .unwrap_err();
492 match err {
493 EngineError::HashMismatch {
494 id,
495 current,
496 is_stub,
497 } => {
498 assert_eq!(id, stub_id.to_string());
499 assert!(current.is_empty(), "stub has no content hash");
500 assert!(is_stub, "is_stub must be true on a stub mismatch");
501 let msg = format!(
502 "{}",
503 EngineError::HashMismatch {
504 id,
505 current,
506 is_stub
507 }
508 );
509 assert!(
510 msg.contains("stub") && msg.contains("expected_hash: \"\""),
511 "stub message must name the corrective action; got: {msg}",
512 );
513 }
514 other => panic!("expected HashMismatch, got {other:?}"),
515 }
516 }
517
518 #[test]
525 fn delete_entity_on_stub_with_empty_hash_succeeds_via_in_memory_route() {
526 let tmp = TempDir::new().unwrap();
527 let (mut engine, _anchor) = engine_with_seed(&tmp, "Anchor");
528 let (actor, client) = cli_actor();
529
530 let stub_id = crate::EntityId::new("specs", "ghost-target");
538 engine.store.upsert(
539 stub_id.clone(),
540 super::make_stub(&stub_id, crate::entity::StubKind::ForwardReference),
541 );
542 assert!(
543 engine.store().contains(&stub_id),
544 "injected orphan stub must be in store"
545 );
546
547 let outcome = engine
548 .delete_entity(
549 DeleteEntityArgs {
550 id: stub_id.clone(),
551 expected_hash: Some(String::new()),
552 },
553 actor,
554 Some(&client),
555 None,
556 )
557 .expect("stub delete with expected_hash=\"\" must succeed");
558
559 assert_eq!(outcome.id, stub_id);
560 assert!(
562 outcome.commit_sha.is_empty(),
563 "stub deletes skip the backend write; commit_sha is empty"
564 );
565 assert!(
567 !engine.store().contains(&stub_id),
568 "stub must be gone from the in-memory store"
569 );
570 assert!(engine.get_entity(&stub_id).is_none());
572 let log = std::fs::read_to_string(tmp.path().join(".memstead/changes.jsonl")).unwrap();
575 assert!(log.contains("\"kind\":\"delete\""));
576 }
577
578 #[test]
579 fn delete_entity_refuses_on_write_mem_referrers_with_typed_payload() {
580 let tmp = TempDir::new().unwrap();
581 let (mut engine, target) = engine_with_seed(&tmp, "Target");
582 let (actor, client) = cli_actor();
583 let source = engine
585 .create_entity(
586 empty_create_args("specs", "Source"),
587 actor,
588 Some(&client),
589 None,
590 )
591 .unwrap();
592 engine
593 .relate_entity(
594 RelateEntityArgs {
595 source: source.id.clone(),
596 expected_hash: Some(source.content_hash.clone()),
597 rel_type: "USES".to_string(),
598 target: target.id.clone(),
599 remove: false,
600 description: None,
601 },
602 actor,
603 Some(&client),
604 None,
605 )
606 .unwrap();
607
608 let err = engine
611 .delete_entity(
612 DeleteEntityArgs {
613 id: target.id.clone(),
614 expected_hash: None,
615 },
616 actor,
617 Some(&client),
618 None,
619 )
620 .unwrap_err();
621 match err {
622 EngineError::HasIncomingRefs { id, referrers } => {
623 assert_eq!(id, target.id.to_string());
624 assert_eq!(referrers.len(), 1);
625 let r = &referrers[0];
626 assert_eq!(r.from_id, source.id.to_string());
627 assert_eq!(r.rel_types, vec!["USES".to_string()]);
628 assert_eq!(r.mem, "specs");
629 }
630 other => panic!("expected HasIncomingRefs, got {other:?}"),
631 }
632
633 assert!(engine.get_entity(&target.id).is_some());
636 assert!(tmp.path().join(&target.file_path).exists());
637 }
638
639 #[test]
640 fn delete_entity_returns_commit_sha_and_relations_removed() {
641 let tmp = TempDir::new().unwrap();
642 let (mut engine, target) = engine_with_seed(&tmp, "Target");
643 let (actor, client) = cli_actor();
644
645 let source = engine
649 .create_entity(
650 empty_create_args("specs", "Source"),
651 actor,
652 Some(&client),
653 None,
654 )
655 .unwrap();
656 let related = engine
657 .relate_entity(
658 RelateEntityArgs {
659 source: source.id.clone(),
660 expected_hash: Some(source.content_hash.clone()),
661 rel_type: "USES".to_string(),
662 target: target.id.clone(),
663 remove: false,
664 description: None,
665 },
666 actor,
667 Some(&client),
668 None,
669 )
670 .unwrap();
671
672 let outcome = engine
673 .delete_entity(
674 DeleteEntityArgs {
675 id: source.id.clone(),
676 expected_hash: Some(related.content_hash.clone()),
677 },
678 actor,
679 Some(&client),
680 None,
681 )
682 .unwrap();
683
684 assert!(
686 !outcome.commit_sha.is_empty(),
687 "commit_sha must be populated on a real delete"
688 );
689 assert_eq!(outcome.relations_removed, 1);
691 assert!(outcome.orphan_stubs_removed.is_empty());
693 assert!(outcome.warnings.is_empty());
695 }
696
697 #[test]
698 fn delete_entity_garbage_collects_orphaned_stubs() {
699 let tmp = TempDir::new().unwrap();
700 let (mut engine, source) = engine_with_seed(&tmp, "Source");
701 let (actor, client) = cli_actor();
702 let stub_id = crate::EntityId::new("specs", "ghost-stub");
703
704 let related = engine
707 .relate_entity(
708 RelateEntityArgs {
709 source: source.id.clone(),
710 expected_hash: Some(source.content_hash.clone()),
711 rel_type: "USES".to_string(),
712 target: stub_id.clone(),
713 remove: false,
714 description: None,
715 },
716 actor,
717 Some(&client),
718 None,
719 )
720 .unwrap();
721 assert!(engine.store().contains(&stub_id), "stub must be in store");
722
723 let outcome = engine
726 .delete_entity(
727 DeleteEntityArgs {
728 id: source.id.clone(),
729 expected_hash: Some(related.content_hash.clone()),
730 },
731 actor,
732 Some(&client),
733 None,
734 )
735 .unwrap();
736
737 assert_eq!(outcome.orphan_stubs_removed, vec![stub_id.clone()]);
738 assert!(
739 !engine.store().contains(&stub_id),
740 "GC must drop the orphaned stub"
741 );
742 }
743
744 #[test]
753 fn delete_entity_demotes_to_stub_when_only_readonly_referrers_remain() {
754 use crate::engine::test_helpers::{archive_mount, build_archive};
755 use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
756
757 let tmp = TempDir::new().unwrap();
758 let writable_dir = tmp.path().join("writable");
759 std::fs::create_dir_all(&writable_dir).unwrap();
760 let writer = FilesystemMemWriter::new(writable_dir.clone());
761
762 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";
767 let archive_path = build_archive(
768 tmp.path(),
769 "archive",
770 &[("archived-source.md", archive_md.as_bytes())],
771 );
772
773 let folder_mount = Mount {
774 mem: "specs".to_string(),
775 schema: Some(crate::engine::test_helpers::pin("default")),
776 storage: MountStorage::Folder {
777 path: writable_dir.clone(),
778 },
779 capability: MountCapability::Write,
780 lifecycle: MountLifecycle::Eager,
781 cross_linkable: true,
782 migration_target: None,
783 };
784 let archive_reader = crate::storage::ArchiveBackend::new(archive_path.clone());
785 let mut engine = Engine::from_mounts(vec![
786 (folder_mount, Box::new(writer) as Box<dyn MemBackend>),
787 (
788 archive_mount("archive", archive_path.clone()),
789 Box::new(archive_reader) as Box<dyn MemBackend>,
790 ),
791 ])
792 .unwrap();
793
794 let (actor, client) = cli_actor();
795 let target = engine
796 .create_entity(
797 empty_create_args("specs", "Target"),
798 actor,
799 Some(&client),
800 None,
801 )
802 .unwrap();
803
804 let archived_source_id = crate::EntityId::new("archive", "archived-source");
807 assert!(
808 engine.store().contains(&archived_source_id),
809 "archive entity must load into the store"
810 );
811 let incoming_pre: Vec<_> = engine
812 .store()
813 .incoming(&target.id)
814 .iter()
815 .map(|e| e.from.clone())
816 .collect();
817 assert!(
818 incoming_pre.contains(&archived_source_id),
819 "archive wiki-link must produce an incoming edge on target; got {incoming_pre:?}"
820 );
821
822 let outcome = engine
825 .delete_entity(
826 DeleteEntityArgs {
827 id: target.id.clone(),
828 expected_hash: Some(target.content_hash.clone()),
829 },
830 actor,
831 Some(&client),
832 None,
833 )
834 .unwrap();
835
836 assert!(!writable_dir.join(&target.file_path).exists());
838 let demoted = engine
840 .get_entity(&target.id)
841 .expect("residual stub must remain in store");
842 assert!(demoted.stub, "demoted entity must be flagged as stub");
843 assert!(demoted.entity_type.is_empty());
844 match &demoted.stub_kind {
848 Some(crate::entity::StubKind::Residual {
849 since_commit: _,
850 readonly_referrers,
851 }) => {
852 assert_eq!(
853 readonly_referrers,
854 &vec![archived_source_id.clone()],
855 "Residual.readonly_referrers must snapshot the surviving referrers at mutation time"
856 );
857 }
858 other => panic!("demoted stub must be tagged Residual; got {other:?}"),
859 }
860 let incoming_post: Vec<_> = engine
862 .store()
863 .incoming(&target.id)
864 .iter()
865 .map(|e| e.from.clone())
866 .collect();
867 assert!(
868 incoming_post.contains(&archived_source_id),
869 "archive incoming edge must survive demotion"
870 );
871 let referrers = outcome
873 .warnings
874 .iter()
875 .find_map(|w| match w {
876 WarningHint::ResidualStubForReadOnlyReferrers { referrers, .. } => {
877 Some(referrers.clone())
878 }
879 _ => None,
880 })
881 .expect("ResidualStubForReadOnlyReferrers warning must surface");
882 assert_eq!(referrers, vec![archived_source_id]);
883 assert_eq!(outcome.removed_incoming.len(), 1);
884 }
885
886 }