Skip to main content

memstead_cli/commands/
delete.rs

1//! `memstead delete` — remove an entity, its file, and all its relationships.
2//!
3//! `--dry-run` does a non-destructive preview by reading the entity and
4//! counting its relations; no engine-side dry-run exists — the MCP tool
5//! carries no `dry_run` param, and optimistic locking via `expected_hash`
6//! is the shipping safety mechanism.
7
8use clap::Parser;
9
10use memstead_base::vcs::Actor;
11use memstead_base::{DeleteEntityArgs, EntityId};
12
13use crate::CliError;
14use crate::output::{ExitKind, print_json, print_markdown};
15use crate::setup::{CliContext, CliEngine};
16
17#[derive(Parser, Debug)]
18pub struct Args {
19    /// Entity ID to delete.
20    pub id: String,
21
22    /// Show what would be removed without deleting anything.
23    #[arg(long)]
24    pub dry_run: bool,
25
26    /// Agent-authored provenance note (≤280 chars). When
27    /// `[mutations].require_notes = true` a missing note adds a
28    /// `NOTE_MISSING` warning.
29    #[arg(long)]
30    pub note: Option<String>,
31}
32
33pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
34    let id = EntityId::canonical(&args.id);
35    match ctx.cli_engine()? {
36        #[cfg(feature = "mem-repo")]
37        CliEngine::MemRepo(engine) => run_mem_repo(ctx, engine, id, args),
38        CliEngine::Filesystem(engine) => run_filesystem(ctx, engine, id, args),
39    }
40}
41
42#[cfg(feature = "mem-repo")]
43fn run_mem_repo(
44    ctx: &CliContext,
45    mut engine: memstead_base::Engine,
46    id: EntityId,
47    args: Args,
48) -> anyhow::Result<()> {
49    if args.dry_run {
50        let entity = engine
51            .get_entity(&id)
52            .ok_or_else(|| {
53                CliError::new(
54                    ExitKind::NotFound,
55                    "ENTITY_NOT_FOUND",
56                    format!("entity not found: {}", id),
57                )
58                .with_details(serde_json::json!({ "id": id.to_string() }))
59            })?
60            .clone();
61        let referrers = engine.classify_delete_referrers(&id);
62        let outgoing = engine.store().outgoing(&id).len();
63        return print_dry_run(
64            ctx,
65            &id,
66            &entity.title,
67            &entity.file_path,
68            &referrers,
69            outgoing,
70        );
71    }
72
73    // `expected_hash` is mandatory on `Engine::delete_entity`. The CLI
74    // reads the current hash itself rather than exposing a flag — agents
75    // and humans invoking `memstead delete <id>` want one-shot semantics, and
76    // there's no meaningful external concurrency against a user-driven
77    // CLI process. MCP keeps the full read-then-lock pattern so a
78    // multi-agent workflow can't stomp itself.
79    let current_hash = engine
80        .get_entity(&id)
81        .ok_or_else(|| {
82            CliError::new(
83                ExitKind::NotFound,
84                "ENTITY_NOT_FOUND",
85                format!("entity not found: {}", id),
86            )
87            .with_details(serde_json::json!({ "id": id.to_string() }))
88        })?
89        .content_hash
90        .clone();
91    let result = engine
92        .delete_entity_with_ctx(
93            &id,
94            &current_hash,
95            &crate::setup::cli_ctx_with_note(args.note.clone()),
96        )
97        .map_err(CliError::from_engine_op)?;
98    let mem_changed = engine.take_mem_changed_notices();
99
100    if ctx.json {
101        let mut body = serde_json::to_value(&result).unwrap_or(serde_json::Value::Null);
102        super::merge_mem_changed_json(&mut body, &mem_changed);
103        print_json(&body)?;
104    } else {
105        print_markdown(&format!(
106            "# Deleted `{}`\n\n- Relations removed: {}{}",
107            result.id,
108            result.relations_removed,
109            super::render_mem_changed_block(&mem_changed),
110        ));
111    }
112    Ok(())
113}
114
115fn run_filesystem(
116    ctx: &CliContext,
117    mut engine: memstead_base::Engine,
118    id: EntityId,
119    args: Args,
120) -> anyhow::Result<()> {
121    if args.dry_run {
122        let entity = engine
123            .get_entity(&id)
124            .ok_or_else(|| {
125                CliError::new(
126                    ExitKind::NotFound,
127                    "ENTITY_NOT_FOUND",
128                    format!("entity not found: {}", id),
129                )
130                .with_details(serde_json::json!({ "id": id.to_string() }))
131            })?
132            .clone();
133        let referrers = engine.classify_delete_referrers(&id);
134        let outgoing = engine.store().outgoing(&id).len();
135        return print_dry_run(
136            ctx,
137            &id,
138            &entity.title,
139            &entity.file_path,
140            &referrers,
141            outgoing,
142        );
143    }
144
145    // Same hash-snapshot posture as the mem-repo path.
146    let current_hash = engine
147        .get_entity(&id)
148        .ok_or_else(|| {
149            CliError::new(
150                ExitKind::NotFound,
151                "ENTITY_NOT_FOUND",
152                format!("entity not found: {}", id),
153            )
154            .with_details(serde_json::json!({ "id": id.to_string() }))
155        })?
156        .content_hash
157        .clone();
158    let outcome = engine
159        .delete_entity(
160            DeleteEntityArgs {
161                id: id.clone(),
162                expected_hash: Some(current_hash),
163            },
164            Actor::Cli,
165            None,
166            args.note.as_deref(),
167        )
168        .map_err(CliError::from_engine_op)?;
169
170    let relations_removed = outcome.removed_incoming.len();
171    if ctx.json {
172        print_json(&serde_json::json!({
173            "id": outcome.id.as_ref(),
174            "file_path": outcome.file_path,
175            "relations_removed": relations_removed,
176            // Engine-emitted warnings (e.g. `NOTE_MISSING` under
177            // `[mutations].require_notes`).
178            "warnings": outcome.warnings,
179        }))?;
180    } else {
181        let mut body = format!(
182            "# Deleted `{}`\n\n- Relations removed: {}",
183            outcome.id, relations_removed,
184        );
185        if !outcome.warnings.is_empty() {
186            let parts: Vec<String> = outcome.warnings.iter().map(|w| w.to_string()).collect();
187            body.push_str(&format!("\n- Warnings: {}", parts.join("; ")));
188        }
189        print_markdown(&body);
190    }
191    Ok(())
192}
193
194/// Render the dry-run preview, including the would-be verdict. The
195/// referrer classification comes straight from the engine's delete guard
196/// (`classify_delete_referrers`), so the preview's verdict matches what
197/// the real `memstead delete` would do: Write-Mem referrers would refuse
198/// with `HAS_INCOMING_REFS`; ReadOnly-only referrers would proceed via
199/// the residual-stub demotion; none would remove cleanly. The agent can
200/// branch on the preview alone without re-encoding the deletion ruleset.
201fn print_dry_run(
202    ctx: &CliContext,
203    id: &EntityId,
204    title: &str,
205    file_path: &str,
206    referrers: &memstead_base::DeleteReferrers,
207    outgoing: usize,
208) -> anyhow::Result<()> {
209    let blocking = &referrers.write_referrers;
210    let readonly = &referrers.readonly_referrers;
211    let incoming = blocking.len() + readonly.len();
212    let relations_total = incoming + outgoing;
213    let would_refuse = referrers.would_refuse();
214    if ctx.json {
215        let blocking_json: Vec<_> = blocking
216            .iter()
217            .map(|r| {
218                serde_json::json!({
219                    "from_id": r.from_id,
220                    "rel_types": r.rel_types,
221                    "mem": r.mem,
222                })
223            })
224            .collect();
225        let readonly_json: Vec<String> = readonly.iter().map(|r| r.to_string()).collect();
226        print_json(&serde_json::json!({
227            "id": id.as_ref(),
228            "title": title,
229            "file_path": file_path,
230            // Same `referrers` key the failure-path payload uses, holding
231            // the blocking (Write-Mem) sources only — these are what the
232            // agent must clear before the real delete can proceed.
233            "referrers": blocking_json,
234            "readonly_referrers": readonly_json,
235            "relations_incoming": incoming,
236            "relations_outgoing": outgoing,
237            "relations_total": relations_total,
238            // The would-be verdict. `would_refuse` lets the agent branch
239            // without applying the ruleset; `refusal_code` mirrors the real
240            // error code so the preview and the failure share a vocabulary.
241            "would_refuse": would_refuse,
242            "refusal_code": if would_refuse { Some("HAS_INCOMING_REFS") } else { None },
243            "blocking_referrers": blocking.len(),
244            "dry_run": true,
245        }))?;
246    } else {
247        let verdict = if would_refuse {
248            format!(
249                "would REFUSE — `HAS_INCOMING_REFS` ({} blocking referrer(s); remove them first)",
250                blocking.len()
251            )
252        } else if !readonly.is_empty() {
253            format!(
254                "would PROCEED — {} read-only referrer(s) keep a residual stub at this id",
255                readonly.len()
256            )
257        } else {
258            "would PROCEED — clean removal".to_string()
259        };
260        let mut lines = vec![
261            format!("# Dry-run `{}`", id),
262            String::new(),
263            format!("- Title: {title}"),
264            format!("- File: {file_path}"),
265            format!("- Relations in: {incoming}"),
266            format!("- Relations out: {outgoing}"),
267            format!("- Verdict: {verdict}"),
268        ];
269        if !blocking.is_empty() {
270            lines.push(String::new());
271            lines.push("## Blocking referrers".to_string());
272            for r in blocking {
273                lines.push(format!(
274                    "- `{}` [{}] ({})",
275                    r.from_id,
276                    r.rel_types.join(", "),
277                    r.mem
278                ));
279            }
280        }
281        if !readonly.is_empty() {
282            lines.push(String::new());
283            lines.push("## Read-only referrers (non-blocking)".to_string());
284            for r in readonly {
285                lines.push(format!("- `{r}`"));
286            }
287        }
288        print_markdown(&lines.join("\n"));
289    }
290    Ok(())
291}