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 "write_id": outcome.write_id,
179 "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
197fn 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 "referrers": blocking_json,
237 "readonly_referrers": readonly_json,
238 "relations_incoming": incoming,
239 "relations_outgoing": outgoing,
240 "relations_total": relations_total,
241 "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}