1use 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 pub id: String,
24
25 #[arg(long)]
27 pub dry_run: bool,
28
29 #[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 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 ¤t_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 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 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 "write_id": outcome.write_id,
191 "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
209fn 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 "referrers": blocking_json,
249 "readonly_referrers": readonly_json,
250 "relations_incoming": incoming,
251 "relations_outgoing": outgoing,
252 "relations_total": relations_total,
253 "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}