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,
21
22 #[arg(long)]
24 pub dry_run: bool,
25
26 #[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 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 ¤t_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 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 "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
194fn 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 "referrers": blocking_json,
234 "readonly_referrers": readonly_json,
235 "relations_incoming": incoming,
236 "relations_outgoing": outgoing,
237 "relations_total": relations_total,
238 "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}