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 let commit_subject = format!("memstead: delete {id}");
200 let ctx = CommitContext {
201 actor,
202 client: client.cloned(),
203 tool: Some("delete_entity"),
204 note: note.map(String::from),
205 logical_operation_id: None,
206 entity_ids: None,
207 };
208 backend.commit(&commit_subject, &ctx)?
209 };
210
211 backend.append_provenance(&Provenance::new(
212 std::time::SystemTime::now(),
213 ProvenanceKind::Delete,
214 Some(id.to_string()),
215 actor,
216 client.cloned(),
217 note.map(String::from),
218 ))?;
219
220 if !commit_sha.is_empty() {
221 self.record_self_write(mount_idx, &commit_sha);
222 }
223
224 let mut warnings: Vec<WarningHint> = Vec::new();
225 warnings.append(&mut drift_warnings);
227 let orphan_stubs_removed = if demote_to_stub {
228 self.store.remove_edges_from(id);
235 self.store.upsert(
236 id.clone(),
237 make_stub(
238 id,
239 crate::entity::StubKind::Residual {
240 since_commit: commit_sha.clone(),
241 readonly_referrers: readonly_referrers.clone(),
242 },
243 ),
244 );
245 warnings.push(WarningHint::ResidualStubForReadOnlyReferrers {
246 id: id.clone(),
247 referrers: readonly_referrers,
248 });
249 Vec::new()
255 } else {
256 self.store.remove(id);
257 gc_orphan_stubs(&mut self.store)
261 };
262
263 self.invalidate_communities();
264 self.invalidate_search_indexes();
265
266 if !commit_sha.is_empty()
271 && let Some(w) = self.note_missing_warning("delete_entity", note)
272 {
273 warnings.push(w);
274 }
275
276 Ok(DeleteEntityOutcome {
277 id: id.clone(),
278 file_path,
279 removed_incoming,
280 relations_removed,
281 commit_sha,
282 orphan_stubs_removed,
283 warnings,
284 })
285 }
286
287 pub fn delete_entity_with_ctx(
291 &mut self,
292 id: &EntityId,
293 expected_hash: &str,
294 ctx: &CommitContext<'_>,
295 ) -> Result<DeleteEntityOutcome, EngineError> {
296 let args = DeleteEntityArgs {
297 id: id.clone(),
298 expected_hash: Some(expected_hash.to_string()),
299 };
300 self.delete_entity(args, ctx.actor, ctx.client.as_ref(), ctx.note.as_deref())
301 }
302}
303
304#[cfg(test)]
305mod tests {
306
307 use tempfile::TempDir;
308
309 use crate::backend::MemBackend;
310 use crate::engine::test_helpers::*;
311 use crate::engine::{DeleteEntityArgs, Engine, EngineError, RelateEntityArgs};
312 use crate::ops::WarningHint;
313 use crate::storage::FilesystemMemWriter;
314
315 #[test]
316 fn delete_entity_removes_file_and_store_entry() {
317 let tmp = TempDir::new().unwrap();
318 let (mut engine, seeded) = engine_with_seed(&tmp, "Doomed");
319 let (actor, client) = cli_actor();
320 let outcome = engine
321 .delete_entity(
322 DeleteEntityArgs {
323 id: seeded.id.clone(),
324 expected_hash: Some(seeded.content_hash.clone()),
325 },
326 actor,
327 Some(&client),
328 None,
329 )
330 .unwrap();
331 assert_eq!(outcome.id, seeded.id);
332 assert_eq!(outcome.removed_incoming, Vec::<String>::new());
333 assert!(engine.get_entity(&seeded.id).is_none());
335 assert!(!tmp.path().join(&seeded.file_path).exists());
337 let log = std::fs::read_to_string(tmp.path().join(".memstead/changes.jsonl")).unwrap();
339 assert!(log.contains("\"kind\":\"delete\""));
340 }
341
342 #[test]
343 fn delete_entity_rejects_hash_mismatch() {
344 let tmp = TempDir::new().unwrap();
345 let (mut engine, seeded) = engine_with_seed(&tmp, "Locked");
346 let (actor, client) = cli_actor();
347 let err = engine
348 .delete_entity(
349 DeleteEntityArgs {
350 id: seeded.id.clone(),
351 expected_hash: Some("nope".to_string()),
352 },
353 actor,
354 Some(&client),
355 None,
356 )
357 .unwrap_err();
358 match err {
359 EngineError::HashMismatch {
360 id,
361 current,
362 is_stub,
363 } => {
364 assert_eq!(id, seeded.id.to_string());
365 assert_eq!(current, seeded.content_hash);
366 assert!(!is_stub, "real entity must not flag as stub");
367 }
368 other => panic!("expected HashMismatch, got {other:?}"),
369 }
370 }
371
372 #[test]
379 fn delete_entity_on_stub_with_non_empty_hash_surfaces_is_stub_flag() {
380 let tmp = TempDir::new().unwrap();
381 let (mut engine, _seed) = engine_with_seed(&tmp, "Anchor");
382 let (actor, client) = cli_actor();
383 let stub_id = crate::EntityId::new("specs", "stub-target");
392 let source = engine
393 .create_entity(
394 empty_create_args("specs", "Source With Link"),
395 actor,
396 Some(&client),
397 None,
398 )
399 .unwrap();
400 engine
401 .relate_entity(
402 RelateEntityArgs {
403 source: source.id.clone(),
404 expected_hash: Some(source.content_hash.clone()),
405 rel_type: "USES".to_string(),
406 target: stub_id.clone(),
407 remove: false,
408 description: None,
409 },
410 actor,
411 Some(&client),
412 None,
413 )
414 .unwrap();
415 assert!(
416 engine.store().contains(&stub_id),
417 "forward-reference relate must materialise stub"
418 );
419
420 let err = engine
424 .delete_entity(
425 DeleteEntityArgs {
426 id: stub_id.clone(),
427 expected_hash: Some("definitely-non-empty".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, stub_id.to_string());
441 assert!(current.is_empty(), "stub has no content hash");
442 assert!(is_stub, "is_stub must be true on a stub mismatch");
443 let msg = format!(
444 "{}",
445 EngineError::HashMismatch {
446 id,
447 current,
448 is_stub
449 }
450 );
451 assert!(
452 msg.contains("stub") && msg.contains("expected_hash: \"\""),
453 "stub message must name the corrective action; got: {msg}",
454 );
455 }
456 other => panic!("expected HashMismatch, got {other:?}"),
457 }
458 }
459
460 #[test]
467 fn delete_entity_on_stub_with_empty_hash_succeeds_via_in_memory_route() {
468 let tmp = TempDir::new().unwrap();
469 let (mut engine, _anchor) = engine_with_seed(&tmp, "Anchor");
470 let (actor, client) = cli_actor();
471
472 let stub_id = crate::EntityId::new("specs", "ghost-target");
480 engine.store.upsert(
481 stub_id.clone(),
482 super::make_stub(&stub_id, crate::entity::StubKind::ForwardReference),
483 );
484 assert!(
485 engine.store().contains(&stub_id),
486 "injected orphan stub must be in store"
487 );
488
489 let outcome = engine
490 .delete_entity(
491 DeleteEntityArgs {
492 id: stub_id.clone(),
493 expected_hash: Some(String::new()),
494 },
495 actor,
496 Some(&client),
497 None,
498 )
499 .expect("stub delete with expected_hash=\"\" must succeed");
500
501 assert_eq!(outcome.id, stub_id);
502 assert!(
504 outcome.commit_sha.is_empty(),
505 "stub deletes skip the backend write; commit_sha is empty"
506 );
507 assert!(
509 !engine.store().contains(&stub_id),
510 "stub must be gone from the in-memory store"
511 );
512 assert!(engine.get_entity(&stub_id).is_none());
514 let log = std::fs::read_to_string(tmp.path().join(".memstead/changes.jsonl")).unwrap();
517 assert!(log.contains("\"kind\":\"delete\""));
518 }
519
520 #[test]
521 fn delete_entity_refuses_on_write_mem_referrers_with_typed_payload() {
522 let tmp = TempDir::new().unwrap();
523 let (mut engine, target) = engine_with_seed(&tmp, "Target");
524 let (actor, client) = cli_actor();
525 let source = engine
527 .create_entity(
528 empty_create_args("specs", "Source"),
529 actor,
530 Some(&client),
531 None,
532 )
533 .unwrap();
534 engine
535 .relate_entity(
536 RelateEntityArgs {
537 source: source.id.clone(),
538 expected_hash: Some(source.content_hash.clone()),
539 rel_type: "USES".to_string(),
540 target: target.id.clone(),
541 remove: false,
542 description: None,
543 },
544 actor,
545 Some(&client),
546 None,
547 )
548 .unwrap();
549
550 let err = engine
553 .delete_entity(
554 DeleteEntityArgs {
555 id: target.id.clone(),
556 expected_hash: None,
557 },
558 actor,
559 Some(&client),
560 None,
561 )
562 .unwrap_err();
563 match err {
564 EngineError::HasIncomingRefs { id, referrers } => {
565 assert_eq!(id, target.id.to_string());
566 assert_eq!(referrers.len(), 1);
567 let r = &referrers[0];
568 assert_eq!(r.from_id, source.id.to_string());
569 assert_eq!(r.rel_types, vec!["USES".to_string()]);
570 assert_eq!(r.mem, "specs");
571 }
572 other => panic!("expected HasIncomingRefs, got {other:?}"),
573 }
574
575 assert!(engine.get_entity(&target.id).is_some());
578 assert!(tmp.path().join(&target.file_path).exists());
579 }
580
581 #[test]
582 fn delete_entity_returns_commit_sha_and_relations_removed() {
583 let tmp = TempDir::new().unwrap();
584 let (mut engine, target) = engine_with_seed(&tmp, "Target");
585 let (actor, client) = cli_actor();
586
587 let source = engine
591 .create_entity(
592 empty_create_args("specs", "Source"),
593 actor,
594 Some(&client),
595 None,
596 )
597 .unwrap();
598 let related = 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 },
608 actor,
609 Some(&client),
610 None,
611 )
612 .unwrap();
613
614 let outcome = engine
615 .delete_entity(
616 DeleteEntityArgs {
617 id: source.id.clone(),
618 expected_hash: Some(related.content_hash.clone()),
619 },
620 actor,
621 Some(&client),
622 None,
623 )
624 .unwrap();
625
626 assert!(
628 !outcome.commit_sha.is_empty(),
629 "commit_sha must be populated on a real delete"
630 );
631 assert_eq!(outcome.relations_removed, 1);
633 assert!(outcome.orphan_stubs_removed.is_empty());
635 assert!(outcome.warnings.is_empty());
637 }
638
639 #[test]
640 fn delete_entity_garbage_collects_orphaned_stubs() {
641 let tmp = TempDir::new().unwrap();
642 let (mut engine, source) = engine_with_seed(&tmp, "Source");
643 let (actor, client) = cli_actor();
644 let stub_id = crate::EntityId::new("specs", "ghost-stub");
645
646 let related = engine
649 .relate_entity(
650 RelateEntityArgs {
651 source: source.id.clone(),
652 expected_hash: Some(source.content_hash.clone()),
653 rel_type: "USES".to_string(),
654 target: stub_id.clone(),
655 remove: false,
656 description: None,
657 },
658 actor,
659 Some(&client),
660 None,
661 )
662 .unwrap();
663 assert!(engine.store().contains(&stub_id), "stub must be in store");
664
665 let outcome = engine
668 .delete_entity(
669 DeleteEntityArgs {
670 id: source.id.clone(),
671 expected_hash: Some(related.content_hash.clone()),
672 },
673 actor,
674 Some(&client),
675 None,
676 )
677 .unwrap();
678
679 assert_eq!(outcome.orphan_stubs_removed, vec![stub_id.clone()]);
680 assert!(
681 !engine.store().contains(&stub_id),
682 "GC must drop the orphaned stub"
683 );
684 }
685
686 #[test]
695 fn delete_entity_demotes_to_stub_when_only_readonly_referrers_remain() {
696 use crate::engine::test_helpers::{archive_mount, build_archive};
697 use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
698
699 let tmp = TempDir::new().unwrap();
700 let writable_dir = tmp.path().join("writable");
701 std::fs::create_dir_all(&writable_dir).unwrap();
702 let writer = FilesystemMemWriter::new(writable_dir.clone());
703
704 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";
709 let archive_path = build_archive(
710 tmp.path(),
711 "archive",
712 &[("archived-source.md", archive_md.as_bytes())],
713 );
714
715 let folder_mount = Mount {
716 mem: "specs".to_string(),
717 schema: Some(crate::engine::test_helpers::pin("default")),
718 storage: MountStorage::Folder {
719 path: writable_dir.clone(),
720 },
721 capability: MountCapability::Write,
722 lifecycle: MountLifecycle::Eager,
723 cross_linkable: true,
724 migration_target: None,
725 };
726 let archive_reader = crate::storage::ArchiveBackend::new(archive_path.clone());
727 let mut engine = Engine::from_mounts(vec![
728 (folder_mount, Box::new(writer) as Box<dyn MemBackend>),
729 (
730 archive_mount("archive", archive_path.clone()),
731 Box::new(archive_reader) as Box<dyn MemBackend>,
732 ),
733 ])
734 .unwrap();
735
736 let (actor, client) = cli_actor();
737 let target = engine
738 .create_entity(
739 empty_create_args("specs", "Target"),
740 actor,
741 Some(&client),
742 None,
743 )
744 .unwrap();
745
746 let archived_source_id = crate::EntityId::new("archive", "archived-source");
749 assert!(
750 engine.store().contains(&archived_source_id),
751 "archive entity must load into the store"
752 );
753 let incoming_pre: Vec<_> = engine
754 .store()
755 .incoming(&target.id)
756 .iter()
757 .map(|e| e.from.clone())
758 .collect();
759 assert!(
760 incoming_pre.contains(&archived_source_id),
761 "archive wiki-link must produce an incoming edge on target; got {incoming_pre:?}"
762 );
763
764 let outcome = engine
767 .delete_entity(
768 DeleteEntityArgs {
769 id: target.id.clone(),
770 expected_hash: Some(target.content_hash.clone()),
771 },
772 actor,
773 Some(&client),
774 None,
775 )
776 .unwrap();
777
778 assert!(!writable_dir.join(&target.file_path).exists());
780 let demoted = engine
782 .get_entity(&target.id)
783 .expect("residual stub must remain in store");
784 assert!(demoted.stub, "demoted entity must be flagged as stub");
785 assert!(demoted.entity_type.is_empty());
786 match &demoted.stub_kind {
790 Some(crate::entity::StubKind::Residual {
791 since_commit: _,
792 readonly_referrers,
793 }) => {
794 assert_eq!(
795 readonly_referrers,
796 &vec![archived_source_id.clone()],
797 "Residual.readonly_referrers must snapshot the surviving referrers at mutation time"
798 );
799 }
800 other => panic!("demoted stub must be tagged Residual; got {other:?}"),
801 }
802 let incoming_post: Vec<_> = engine
804 .store()
805 .incoming(&target.id)
806 .iter()
807 .map(|e| e.from.clone())
808 .collect();
809 assert!(
810 incoming_post.contains(&archived_source_id),
811 "archive incoming edge must survive demotion"
812 );
813 let referrers = outcome
815 .warnings
816 .iter()
817 .find_map(|w| match w {
818 WarningHint::ResidualStubForReadOnlyReferrers { referrers, .. } => {
819 Some(referrers.clone())
820 }
821 _ => None,
822 })
823 .expect("ResidualStubForReadOnlyReferrers warning must surface");
824 assert_eq!(referrers, vec![archived_source_id]);
825 assert_eq!(outcome.removed_incoming.len(), 1);
826 }
827
828 }