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            // Backend write identity — response-shape parity with the
177            // MCP filesystem flavour.
178            "write_id": outcome.write_id,
179            // Engine-emitted warnings (e.g. `NOTE_MISSING` under
180            // `[mutations].require_notes`).
181            "warnings": outcome.warnings,
182        }))?;
183    } else {
184        let mut body = format!(
185            "# Deleted `{}`\n\n- Relations removed: {}",
186            outcome.id, relations_removed,
187        );
188        if !outcome.warnings.is_empty() {
189            let parts: Vec<String> = outcome.warnings.iter().map(|w| w.to_string()).collect();
190            body.push_str(&format!("\n- Warnings: {}", parts.join("; ")));
191        }
192        print_markdown(&body);
193    }
194    Ok(())
195}
196
197/// Render the dry-run preview, including the would-be verdict. The
198/// referrer classification comes straight from the engine's delete guard
199/// (`classify_delete_referrers`), so the preview's verdict matches what
200/// the real `memstead delete` would do: Write-Mem referrers would refuse
201/// with `HAS_INCOMING_REFS`; ReadOnly-only referrers would proceed via
202/// the residual-stub demotion; none would remove cleanly. The agent can
203/// branch on the preview alone without re-encoding the deletion ruleset.
204fn print_dry_run(
205    ctx: &CliContext,
206    id: &EntityId,
207    title: &str,
208    file_path: &str,
209    referrers: &memstead_base::DeleteReferrers,
210    outgoing: usize,
211) -> anyhow::Result<()> {
212    let blocking = &referrers.write_referrers;
213    let readonly = &referrers.readonly_referrers;
214    let incoming = blocking.len() + readonly.len();
215    let relations_total = incoming + outgoing;
216    let would_refuse = referrers.would_refuse();
217    if ctx.json {
218        let blocking_json: Vec<_> = blocking
219            .iter()
220            .map(|r| {
221                serde_json::json!({
222                    "from_id": r.from_id,
223                    "rel_types": r.rel_types,
224                    "mem": r.mem,
225                })
226            })
227            .collect();
228        let readonly_json: Vec<String> = readonly.iter().map(|r| r.to_string()).collect();
229        print_json(&serde_json::json!({
230            "id": id.as_ref(),
231            "title": title,
232            "file_path": file_path,
233            // Same `referrers` key the failure-path payload uses, holding
234            // the blocking (Write-Mem) sources only — these are what the
235            // agent must clear before the real delete can proceed.
236            "referrers": blocking_json,
237            "readonly_referrers": readonly_json,
238            "relations_incoming": incoming,
239            "relations_outgoing": outgoing,
240            "relations_total": relations_total,
241            // The would-be verdict. `would_refuse` lets the agent branch
242            // without applying the ruleset; `refusal_code` mirrors the real
243            // error code so the preview and the failure share a vocabulary.
244            "would_refuse": would_refuse,
245            "refusal_code": if would_refuse { Some("HAS_INCOMING_REFS") } else { None },
246            "blocking_referrers": blocking.len(),
247            "dry_run": true,
248        }))?;
249    } else {
250        let verdict = if would_refuse {
251            format!(
252                "would REFUSE — `HAS_INCOMING_REFS` ({} blocking referrer(s); remove them first)",
253                blocking.len()
254            )
255        } else if !readonly.is_empty() {
256            format!(
257                "would PROCEED — {} read-only referrer(s) keep a residual stub at this id",
258                readonly.len()
259            )
260        } else {
261            "would PROCEED — clean removal".to_string()
262        };
263        let mut lines = vec![
264            format!("# Dry-run `{}`", id),
265            String::new(),
266            format!("- Title: {title}"),
267            format!("- File: {file_path}"),
268            format!("- Relations in: {incoming}"),
269            format!("- Relations out: {outgoing}"),
270            format!("- Verdict: {verdict}"),
271        ];
272        if !blocking.is_empty() {
273            lines.push(String::new());
274            lines.push("## Blocking referrers".to_string());
275            for r in blocking {
276                lines.push(format!(
277                    "- `{}` [{}] ({})",
278                    r.from_id,
279                    r.rel_types.join(", "),
280                    r.mem
281                ));
282            }
283        }
284        if !readonly.is_empty() {
285            lines.push(String::new());
286            lines.push("## Read-only referrers (non-blocking)".to_string());
287            for r in readonly {
288                lines.push(format!("- `{r}`"));
289            }
290        }
291        print_markdown(&lines.join("\n"));
292    }
293    Ok(())
294}