use clap::Parser;
use memstead_base::vcs::Actor;
use memstead_base::{DeleteEntityArgs, EntityId};
use crate::CliError;
use crate::output::{ExitKind, print_json, print_markdown};
use crate::setup::{CliContext, CliEngine};
#[derive(Parser, Debug)]
pub struct Args {
pub id: String,
#[arg(long)]
pub dry_run: bool,
#[arg(long)]
pub note: Option<String>,
}
pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
let id = EntityId::canonical(&args.id);
match ctx.cli_engine()? {
#[cfg(feature = "mem-repo")]
CliEngine::MemRepo(engine) => run_mem_repo(ctx, engine, id, args),
CliEngine::Filesystem(engine) => run_filesystem(ctx, engine, id, args),
}
}
#[cfg(feature = "mem-repo")]
fn run_mem_repo(
ctx: &CliContext,
mut engine: memstead_base::Engine,
id: EntityId,
args: Args,
) -> anyhow::Result<()> {
if args.dry_run {
let entity = engine
.get_entity(&id)
.ok_or_else(|| {
CliError::new(
ExitKind::NotFound,
"ENTITY_NOT_FOUND",
format!("entity not found: {}", id),
)
.with_details(serde_json::json!({ "id": id.to_string() }))
})?
.clone();
let referrers = engine.classify_delete_referrers(&id);
let outgoing = engine.store().outgoing(&id).len();
return print_dry_run(
ctx,
&id,
&entity.title,
&entity.file_path,
&referrers,
outgoing,
);
}
let current_hash = engine
.get_entity(&id)
.ok_or_else(|| {
CliError::new(
ExitKind::NotFound,
"ENTITY_NOT_FOUND",
format!("entity not found: {}", id),
)
.with_details(serde_json::json!({ "id": id.to_string() }))
})?
.content_hash
.clone();
let result = engine
.delete_entity_with_ctx(
&id,
¤t_hash,
&crate::setup::cli_ctx_with_note(args.note.clone()),
)
.map_err(CliError::from_engine_op)?;
let mem_changed = engine.take_mem_changed_notices();
if ctx.json {
let mut body = serde_json::to_value(&result).unwrap_or(serde_json::Value::Null);
super::merge_mem_changed_json(&mut body, &mem_changed);
print_json(&body)?;
} else {
print_markdown(&format!(
"# Deleted `{}`\n\n- Relations removed: {}{}",
result.id,
result.relations_removed,
super::render_mem_changed_block(&mem_changed),
));
}
Ok(())
}
fn run_filesystem(
ctx: &CliContext,
mut engine: memstead_base::Engine,
id: EntityId,
args: Args,
) -> anyhow::Result<()> {
if args.dry_run {
let entity = engine
.get_entity(&id)
.ok_or_else(|| {
CliError::new(
ExitKind::NotFound,
"ENTITY_NOT_FOUND",
format!("entity not found: {}", id),
)
.with_details(serde_json::json!({ "id": id.to_string() }))
})?
.clone();
let referrers = engine.classify_delete_referrers(&id);
let outgoing = engine.store().outgoing(&id).len();
return print_dry_run(
ctx,
&id,
&entity.title,
&entity.file_path,
&referrers,
outgoing,
);
}
let current_hash = engine
.get_entity(&id)
.ok_or_else(|| {
CliError::new(
ExitKind::NotFound,
"ENTITY_NOT_FOUND",
format!("entity not found: {}", id),
)
.with_details(serde_json::json!({ "id": id.to_string() }))
})?
.content_hash
.clone();
let outcome = engine
.delete_entity(
DeleteEntityArgs {
id: id.clone(),
expected_hash: Some(current_hash),
},
Actor::Cli,
None,
args.note.as_deref(),
)
.map_err(CliError::from_engine_op)?;
let relations_removed = outcome.removed_incoming.len();
if ctx.json {
print_json(&serde_json::json!({
"id": outcome.id.as_ref(),
"file_path": outcome.file_path,
"relations_removed": relations_removed,
"warnings": outcome.warnings,
}))?;
} else {
let mut body = format!(
"# Deleted `{}`\n\n- Relations removed: {}",
outcome.id, relations_removed,
);
if !outcome.warnings.is_empty() {
let parts: Vec<String> = outcome.warnings.iter().map(|w| w.to_string()).collect();
body.push_str(&format!("\n- Warnings: {}", parts.join("; ")));
}
print_markdown(&body);
}
Ok(())
}
fn print_dry_run(
ctx: &CliContext,
id: &EntityId,
title: &str,
file_path: &str,
referrers: &memstead_base::DeleteReferrers,
outgoing: usize,
) -> anyhow::Result<()> {
let blocking = &referrers.write_referrers;
let readonly = &referrers.readonly_referrers;
let incoming = blocking.len() + readonly.len();
let relations_total = incoming + outgoing;
let would_refuse = referrers.would_refuse();
if ctx.json {
let blocking_json: Vec<_> = blocking
.iter()
.map(|r| {
serde_json::json!({
"from_id": r.from_id,
"rel_types": r.rel_types,
"mem": r.mem,
})
})
.collect();
let readonly_json: Vec<String> = readonly.iter().map(|r| r.to_string()).collect();
print_json(&serde_json::json!({
"id": id.as_ref(),
"title": title,
"file_path": file_path,
"referrers": blocking_json,
"readonly_referrers": readonly_json,
"relations_incoming": incoming,
"relations_outgoing": outgoing,
"relations_total": relations_total,
"would_refuse": would_refuse,
"refusal_code": if would_refuse { Some("HAS_INCOMING_REFS") } else { None },
"blocking_referrers": blocking.len(),
"dry_run": true,
}))?;
} else {
let verdict = if would_refuse {
format!(
"would REFUSE — `HAS_INCOMING_REFS` ({} blocking referrer(s); remove them first)",
blocking.len()
)
} else if !readonly.is_empty() {
format!(
"would PROCEED — {} read-only referrer(s) keep a residual stub at this id",
readonly.len()
)
} else {
"would PROCEED — clean removal".to_string()
};
let mut lines = vec![
format!("# Dry-run `{}`", id),
String::new(),
format!("- Title: {title}"),
format!("- File: {file_path}"),
format!("- Relations in: {incoming}"),
format!("- Relations out: {outgoing}"),
format!("- Verdict: {verdict}"),
];
if !blocking.is_empty() {
lines.push(String::new());
lines.push("## Blocking referrers".to_string());
for r in blocking {
lines.push(format!(
"- `{}` [{}] ({})",
r.from_id,
r.rel_types.join(", "),
r.mem
));
}
}
if !readonly.is_empty() {
lines.push(String::new());
lines.push("## Read-only referrers (non-blocking)".to_string());
for r in readonly {
lines.push(format!("- `{r}`"));
}
}
print_markdown(&lines.join("\n"));
}
Ok(())
}