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