use crate::cli::DeleteArgs;
use crate::cli::commands::{
auto_import_storage_ctx_if_stale, resolve_issue_ids, retry_mutation_with_jsonl_recovery,
};
use crate::config;
use crate::error::{BeadsError, Result};
use crate::output::OutputContext;
use crate::storage::SqliteStorage;
use crate::util::id::{IdResolver, ResolverConfig};
use rich_rust::prelude::*;
use serde::Serialize;
use std::collections::HashSet;
use std::fs;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
#[derive(Debug, Serialize)]
pub struct DeleteResult {
pub deleted: Vec<String>,
pub deleted_count: usize,
pub dependencies_removed: usize,
pub labels_removed: usize,
pub events_removed: usize,
pub references_updated: usize,
pub orphaned_issues: Vec<String>,
}
impl DeleteResult {
const fn new() -> Self {
Self {
deleted: Vec::new(),
deleted_count: 0,
dependencies_removed: 0,
labels_removed: 0,
events_removed: 0,
references_updated: 0,
orphaned_issues: Vec::new(),
}
}
}
#[derive(Debug, Serialize)]
struct DeletePreviewResult {
preview: bool,
would_delete: Vec<String>,
cascade_delete: Vec<String>,
blocked_dependents: Vec<String>,
orphaned_issues: Vec<String>,
}
struct PreparedDeleteRoute {
beads_dir: PathBuf,
route_cli: config::CliOverrides,
resolved_ids: Vec<String>,
blocked_dependents: Vec<String>,
cascade_delete: Vec<String>,
final_delete_ids: Vec<String>,
auto_flush_external: bool,
}
#[allow(clippy::too_many_lines)]
pub fn execute(
args: &DeleteArgs,
_json: bool,
cli: &config::CliOverrides,
ctx: &OutputContext,
) -> Result<()> {
let mut ids: Vec<String> = args.ids.clone();
if let Some(ref file_path) = args.from_file {
let file_ids = read_ids_from_file(file_path)?;
ids.extend(file_ids);
}
if ids.is_empty() {
return Err(BeadsError::validation("ids", "no issue IDs provided"));
}
let mut ids: Vec<String> = ids
.into_iter()
.collect::<HashSet<_>>()
.into_iter()
.collect();
ids.sort();
let beads_dir = config::discover_beads_dir_with_cli(cli)?;
let routed_batches = config::routing::group_issue_inputs_by_route(&ids, &beads_dir)?;
if routed_batches.iter().any(|batch| batch.is_external) {
return execute_routed(args, cli, ctx, &beads_dir, routed_batches);
}
let mut storage_ctx = config::open_storage_with_cli(&beads_dir, cli)?;
let config_layer = storage_ctx.load_config(cli)?;
let id_config = config::id_config_from_layer(&config_layer);
let resolver = IdResolver::new(ResolverConfig::with_prefix(id_config.prefix));
let ids = sorted_unique_strings(resolve_issue_ids(&storage_ctx.storage, &resolver, &ids)?);
let result = {
let delete_set: HashSet<String> = ids.iter().cloned().collect();
let blocked_dependents = collect_direct_dependents(&storage_ctx.storage, &ids)?;
let cascade_dependents = if args.cascade {
Some(collect_sorted_cascade_dependents(
&storage_ctx.storage,
&ids,
)?)
} else {
None
};
if !blocked_dependents.is_empty() && !args.force && !args.cascade {
let full_cascade: Vec<String> =
collect_sorted_cascade_dependents(&storage_ctx.storage, &ids)?;
if ctx.is_json() || ctx.is_toon() {
let preview = DeletePreviewResult {
preview: true,
would_delete: ids,
cascade_delete: full_cascade,
blocked_dependents,
orphaned_issues: Vec::new(),
};
if ctx.is_toon() {
ctx.toon(&preview);
} else {
ctx.json_pretty(&preview);
}
return Ok(());
}
if ctx.is_quiet() {
return Ok(());
}
if ctx.is_rich() {
render_dependents_warning_rich(
&blocked_dependents,
&full_cascade,
&storage_ctx.storage,
ctx,
);
} else {
println!("The following issues depend on issues being deleted:");
for dep in &blocked_dependents {
println!(" - {dep}");
}
if full_cascade.len() > blocked_dependents.len() {
println!(
"\n{} additional issue(s) would be transitively affected by --cascade:",
full_cascade.len() - blocked_dependents.len()
);
let direct_set: HashSet<&String> = blocked_dependents.iter().collect();
for dep in &full_cascade {
if !direct_set.contains(dep) {
println!(" - {dep}");
}
}
}
println!();
println!(
"Use --force to orphan these dependents, or --cascade to delete them recursively."
);
if !full_cascade.is_empty() {
println!(
"--cascade would delete {} total dependent(s).",
full_cascade.len()
);
}
println!("No changes made (preview mode).");
}
return Ok(());
}
if args.dry_run {
let cascade_ids = cascade_dependents.clone().unwrap_or_default();
if ctx.is_json() || ctx.is_toon() {
let orphaned_issues = if args.force && !args.cascade {
blocked_dependents
} else {
Vec::new()
};
let preview = DeletePreviewResult {
preview: true,
would_delete: ids,
cascade_delete: cascade_ids,
blocked_dependents: Vec::new(),
orphaned_issues,
};
if ctx.is_toon() {
ctx.toon(&preview);
} else {
ctx.json_pretty(&preview);
}
return Ok(());
}
if ctx.is_quiet() {
return Ok(());
}
if ctx.is_rich() {
let orphan_ids: Vec<String> = if args.force && !args.cascade {
blocked_dependents
} else {
vec![]
};
render_dry_run_rich(&ids, &cascade_ids, &orphan_ids, &storage_ctx.storage, ctx);
return Ok(());
}
println!("Dry-run: Would delete {} issue(s):", ids.len());
for id in &ids {
let issue = storage_ctx
.storage
.get_issue(id)?
.ok_or_else(|| BeadsError::IssueNotFound { id: id.clone() })?;
println!(" - {}: {}", id, issue.title);
}
if !cascade_ids.is_empty() {
println!(
"Would also cascade delete {} dependent(s):",
cascade_ids.len()
);
for dep in &cascade_ids {
println!(" - {dep}");
}
}
if args.force && !blocked_dependents.is_empty() {
println!("Would orphan {} dependent(s):", blocked_dependents.len());
for dep in &blocked_dependents {
println!(" - {dep}");
}
}
return Ok(());
}
let mut final_delete_set: HashSet<String> = delete_set;
if let Some(cascade_ids) = &cascade_dependents {
final_delete_set.extend(cascade_ids.iter().cloned());
}
let actor = config::resolve_actor(&config_layer);
let mut result = DeleteResult::new();
let mut batch_has_mutated = false;
for id in &final_delete_set {
let deps_removed = retry_mutation_with_jsonl_recovery(
&mut storage_ctx,
!batch_has_mutated,
"delete remove dependencies",
Some(id.as_str()),
|storage| storage.remove_all_dependencies(id, &actor),
)?;
result.dependencies_removed += deps_removed;
if deps_removed > 0 {
batch_has_mutated = true;
}
}
if args.force && !args.cascade {
result.orphaned_issues.clone_from(&blocked_dependents);
}
let mut final_ids: Vec<String> = final_delete_set.into_iter().collect();
final_ids.sort();
for id in &final_ids {
if args.hard {
result.labels_removed += storage_ctx.storage.get_labels(id)?.len();
result.events_removed += storage_ctx.storage.count_issue_events(id)?;
retry_mutation_with_jsonl_recovery(
&mut storage_ctx,
!batch_has_mutated,
"delete purge",
Some(id.as_str()),
|storage| storage.purge_issue(id, &actor),
)?;
} else {
retry_mutation_with_jsonl_recovery(
&mut storage_ctx,
!batch_has_mutated,
"delete tombstone",
Some(id.as_str()),
|storage| storage.delete_issue(id, &actor, &args.reason, None),
)?;
}
batch_has_mutated = true;
result.deleted.push(id.clone());
}
result.deleted_count = result.deleted.len();
result
};
let deleted_ids: HashSet<String> = result.deleted.iter().cloned().collect();
storage_ctx.flush_no_db_then(|ctx| {
let last_touched = crate::util::get_last_touched_id(&ctx.paths.beads_dir);
if !last_touched.is_empty() && deleted_ids.contains(&last_touched) {
crate::util::clear_last_touched(&ctx.paths.beads_dir);
}
Ok(())
})?;
if ctx.is_json() || ctx.is_toon() {
if ctx.is_toon() {
ctx.toon(&result);
} else {
ctx.json_pretty(&result);
}
return Ok(());
}
if ctx.is_quiet() {
return Ok(());
}
if ctx.is_rich() {
render_delete_result_rich(&result, &storage_ctx.storage, ctx);
} else {
println!("Deleted {} issue(s):", result.deleted_count);
for id in &result.deleted {
println!(" - {id}");
}
if result.dependencies_removed > 0 {
println!("Removed {} dependency link(s)", result.dependencies_removed);
}
if !result.orphaned_issues.is_empty() {
println!("Orphaned {} issue(s):", result.orphaned_issues.len());
for id in &result.orphaned_issues {
println!(" - {id}");
}
}
}
Ok(())
}
#[allow(clippy::too_many_lines)]
fn execute_routed(
args: &DeleteArgs,
cli: &config::CliOverrides,
ctx: &OutputContext,
local_beads_dir: &Path,
routed_batches: Vec<config::routing::RoutedIssueBatch>,
) -> Result<()> {
let normalized_local_beads_dir =
dunce::canonicalize(local_beads_dir).unwrap_or_else(|_| local_beads_dir.to_path_buf());
let mut prepared_routes = Vec::with_capacity(routed_batches.len());
for batch in routed_batches {
let normalized_batch_beads_dir =
dunce::canonicalize(&batch.beads_dir).unwrap_or_else(|_| batch.beads_dir.clone());
let mut batch_cli = cli.clone();
batch_cli.db = if normalized_batch_beads_dir == normalized_local_beads_dir {
cli.db.clone()
} else {
None
};
prepared_routes.push(prepare_delete_route(
args,
&batch.issue_inputs,
&batch.beads_dir,
&batch_cli,
batch.is_external,
)?);
}
let would_delete = sorted_unique_strings(
prepared_routes
.iter()
.flat_map(|route| route.resolved_ids.iter().cloned())
.collect(),
);
let blocked_dependents = sorted_unique_strings(
prepared_routes
.iter()
.flat_map(|route| route.blocked_dependents.iter().cloned())
.collect(),
);
let cascade_delete = sorted_unique_strings(
prepared_routes
.iter()
.flat_map(|route| route.cascade_delete.iter().cloned())
.collect(),
);
if !blocked_dependents.is_empty() && !args.force && !args.cascade {
render_routed_delete_preview(
ctx,
&DeletePreviewResult {
preview: true,
would_delete,
cascade_delete,
blocked_dependents,
orphaned_issues: Vec::new(),
},
);
return Ok(());
}
if args.dry_run {
let orphaned_issues = if args.force && !args.cascade {
blocked_dependents
} else {
Vec::new()
};
render_routed_delete_preview(
ctx,
&DeletePreviewResult {
preview: true,
would_delete,
cascade_delete,
blocked_dependents: Vec::new(),
orphaned_issues,
},
);
return Ok(());
}
let mut result = DeleteResult::new();
for route in &prepared_routes {
let batch_result = apply_delete_route(args, route)?;
merge_delete_result(&mut result, batch_result);
}
finalize_delete_result(&mut result);
clear_last_touched_if_deleted(local_beads_dir, &result.deleted);
if ctx.is_json() || ctx.is_toon() {
if ctx.is_toon() {
ctx.toon(&result);
} else {
ctx.json_pretty(&result);
}
return Ok(());
}
if ctx.is_quiet() {
return Ok(());
}
ctx.success(&format!("Deleted {} issue(s)", result.deleted_count));
for id in &result.deleted {
ctx.print_line(&format!(" - {id}"));
}
if result.dependencies_removed > 0 {
ctx.info(&format!(
"Removed {} dependency link(s)",
result.dependencies_removed
));
}
if !result.orphaned_issues.is_empty() {
ctx.warning(&format!(
"Orphaned {} issue(s):",
result.orphaned_issues.len()
));
for id in &result.orphaned_issues {
ctx.print_line(&format!(" - {id}"));
}
}
Ok(())
}
fn prepare_delete_route(
args: &DeleteArgs,
issue_inputs: &[String],
beads_dir: &Path,
cli: &config::CliOverrides,
auto_flush_external: bool,
) -> Result<PreparedDeleteRoute> {
let mut storage_ctx = config::open_storage_with_cli(beads_dir, cli)?;
auto_import_storage_ctx_if_stale(&mut storage_ctx, cli)?;
let config_layer = storage_ctx.load_config(cli)?;
let id_config = config::id_config_from_layer(&config_layer);
let resolver = IdResolver::new(ResolverConfig::with_prefix(id_config.prefix));
let resolved_ids = sorted_unique_strings(resolve_issue_ids(
&storage_ctx.storage,
&resolver,
issue_inputs,
)?);
let blocked_dependents = collect_direct_dependents(&storage_ctx.storage, &resolved_ids)?;
let cascade_delete = collect_sorted_cascade_dependents(&storage_ctx.storage, &resolved_ids)?;
let final_delete_ids = if args.cascade {
sorted_unique_strings(
resolved_ids
.iter()
.chain(cascade_delete.iter())
.cloned()
.collect(),
)
} else {
resolved_ids.clone()
};
Ok(PreparedDeleteRoute {
beads_dir: beads_dir.to_path_buf(),
route_cli: cli.clone(),
resolved_ids,
blocked_dependents,
cascade_delete,
final_delete_ids,
auto_flush_external,
})
}
fn apply_delete_route(args: &DeleteArgs, route: &PreparedDeleteRoute) -> Result<DeleteResult> {
let mut storage_ctx = config::open_storage_with_cli(&route.beads_dir, &route.route_cli)?;
auto_import_storage_ctx_if_stale(&mut storage_ctx, &route.route_cli)?;
let config_layer = storage_ctx.load_config(&route.route_cli)?;
let actor = config::resolve_actor(&config_layer);
let mut result = DeleteResult::new();
let mut batch_has_mutated = false;
for id in &route.final_delete_ids {
let deps_removed = retry_mutation_with_jsonl_recovery(
&mut storage_ctx,
!batch_has_mutated,
"delete remove dependencies",
Some(id.as_str()),
|storage| storage.remove_all_dependencies(id, &actor),
)?;
result.dependencies_removed += deps_removed;
if deps_removed > 0 {
batch_has_mutated = true;
}
}
if args.force && !args.cascade {
result.orphaned_issues.clone_from(&route.blocked_dependents);
}
for id in &route.final_delete_ids {
if args.hard {
result.labels_removed += storage_ctx.storage.get_labels(id)?.len();
result.events_removed += storage_ctx.storage.count_issue_events(id)?;
retry_mutation_with_jsonl_recovery(
&mut storage_ctx,
!batch_has_mutated,
"delete purge",
Some(id.as_str()),
|storage| storage.purge_issue(id, &actor),
)?;
} else {
retry_mutation_with_jsonl_recovery(
&mut storage_ctx,
!batch_has_mutated,
"delete tombstone",
Some(id.as_str()),
|storage| storage.delete_issue(id, &actor, &args.reason, None),
)?;
}
batch_has_mutated = true;
result.deleted.push(id.clone());
}
result.deleted_count = result.deleted.len();
let deleted_ids: HashSet<String> = result.deleted.iter().cloned().collect();
storage_ctx.flush_no_db_then(|ctx| {
let last_touched = crate::util::get_last_touched_id(&ctx.paths.beads_dir);
if !last_touched.is_empty() && deleted_ids.contains(&last_touched) {
crate::util::clear_last_touched(&ctx.paths.beads_dir);
}
Ok(())
})?;
if route.auto_flush_external
&& let Err(error) = storage_ctx.auto_flush_if_enabled()
{
tracing::debug!(
beads_dir = %storage_ctx.paths.beads_dir.display(),
error = %error,
"Routed auto-flush failed (non-fatal)"
);
}
Ok(result)
}
fn render_routed_delete_preview(ctx: &OutputContext, preview: &DeletePreviewResult) {
if ctx.is_json() || ctx.is_toon() {
if ctx.is_toon() {
ctx.toon(&preview);
} else {
ctx.json_pretty(&preview);
}
return;
}
if ctx.is_quiet() {
return;
}
if !preview.blocked_dependents.is_empty() {
ctx.warning("The following issues depend on issues being deleted:");
for dep in &preview.blocked_dependents {
ctx.print_line(&format!(" - {dep}"));
}
if preview.cascade_delete.len() > preview.blocked_dependents.len() {
ctx.newline();
ctx.info(&format!(
"{} additional issue(s) would be transitively affected by --cascade:",
preview.cascade_delete.len() - preview.blocked_dependents.len()
));
let direct_set: HashSet<&String> = preview.blocked_dependents.iter().collect();
for dep in &preview.cascade_delete {
if !direct_set.contains(dep) {
ctx.print_line(&format!(" - {dep}"));
}
}
}
ctx.newline();
ctx.info(
"Use --force to orphan these dependents, or --cascade to delete them recursively.",
);
if !preview.cascade_delete.is_empty() {
ctx.info(&format!(
"--cascade would delete {} total dependent(s).",
preview.cascade_delete.len()
));
}
ctx.info("No changes made (preview mode).");
return;
}
ctx.info(&format!(
"Dry-run: Would delete {} issue(s):",
preview.would_delete.len()
));
for id in &preview.would_delete {
ctx.print_line(&format!(" - {id}"));
}
if !preview.cascade_delete.is_empty() {
ctx.info(&format!(
"Would also cascade delete {} dependent(s):",
preview.cascade_delete.len()
));
for dep in &preview.cascade_delete {
ctx.print_line(&format!(" - {dep}"));
}
}
if !preview.orphaned_issues.is_empty() {
ctx.warning(&format!(
"Would orphan {} dependent(s):",
preview.orphaned_issues.len()
));
for dep in &preview.orphaned_issues {
ctx.print_line(&format!(" - {dep}"));
}
}
}
fn merge_delete_result(result: &mut DeleteResult, mut batch_result: DeleteResult) {
result.deleted.append(&mut batch_result.deleted);
result.dependencies_removed += batch_result.dependencies_removed;
result.labels_removed += batch_result.labels_removed;
result.events_removed += batch_result.events_removed;
result.references_updated += batch_result.references_updated;
result
.orphaned_issues
.append(&mut batch_result.orphaned_issues);
}
fn finalize_delete_result(result: &mut DeleteResult) {
result.deleted = sorted_unique_strings(std::mem::take(&mut result.deleted));
result.orphaned_issues = sorted_unique_strings(std::mem::take(&mut result.orphaned_issues));
result.deleted_count = result.deleted.len();
}
fn clear_last_touched_if_deleted(beads_dir: &Path, deleted_ids: &[String]) {
let deleted_ids: HashSet<&str> = deleted_ids.iter().map(String::as_str).collect();
let last_touched = crate::util::get_last_touched_id(beads_dir);
if !last_touched.is_empty() && deleted_ids.contains(last_touched.as_str()) {
crate::util::clear_last_touched(beads_dir);
}
}
fn sorted_unique_strings(mut values: Vec<String>) -> Vec<String> {
values.sort();
values.dedup();
values
}
fn read_ids_from_file(path: &Path) -> Result<Vec<String>> {
let file = fs::File::open(path)?;
let reader = BufReader::new(file);
let mut ids = Vec::new();
for line in reader.lines() {
let line = line?;
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
ids.push(trimmed.to_string());
}
Ok(ids)
}
fn collect_cascade_dependents(
storage: &SqliteStorage,
initial_ids: &[String],
) -> Result<HashSet<String>> {
let mut all_ids: HashSet<String> = initial_ids.iter().cloned().collect();
let mut to_process: Vec<String> = initial_ids.to_vec();
while let Some(id) = to_process.pop() {
let dependents = storage.get_blocked_issue_ids(&id)?;
for dep_id in dependents {
if all_ids.insert(dep_id.clone()) {
to_process.push(dep_id);
}
}
}
for id in initial_ids {
all_ids.remove(id);
}
Ok(all_ids)
}
fn collect_direct_dependents(
storage: &SqliteStorage,
initial_ids: &[String],
) -> Result<Vec<String>> {
let delete_set: HashSet<String> = initial_ids.iter().cloned().collect();
let mut dependents = Vec::new();
for id in initial_ids {
let direct_dependents = storage.get_blocked_issue_ids(id)?;
for dep_id in direct_dependents {
if !delete_set.contains(&dep_id) {
dependents.push(dep_id);
}
}
}
dependents.sort();
dependents.dedup();
Ok(dependents)
}
fn collect_sorted_cascade_dependents(
storage: &SqliteStorage,
initial_ids: &[String],
) -> Result<Vec<String>> {
let mut cascade_ids: Vec<String> = collect_cascade_dependents(storage, initial_ids)?
.into_iter()
.collect();
cascade_ids.sort();
Ok(cascade_ids)
}
fn render_dependents_warning_rich(
direct_dependents: &[String],
full_cascade: &[String],
storage: &SqliteStorage,
ctx: &OutputContext,
) {
let console = Console::default();
let theme = ctx.theme();
let width = ctx.width();
let mut all_ids: Vec<&String> = direct_dependents
.iter()
.chain(full_cascade.iter())
.collect();
all_ids.sort();
all_ids.dedup();
let all_ids_owned: Vec<String> = all_ids.into_iter().cloned().collect();
let mut issues_by_id = std::collections::HashMap::new();
if let Ok(issues) = storage.get_issues_by_ids(&all_ids_owned) {
for issue in issues {
issues_by_id.insert(issue.id.clone(), issue);
}
}
let mut content = Text::new("");
content.append_styled(
"The following issues depend on issues being deleted:\n\n",
theme.warning.clone(),
);
for dep_id in direct_dependents {
content.append_styled(" \u{2022} ", theme.dimmed.clone());
content.append_styled(dep_id, theme.issue_id.clone());
if let Some(issue) = issues_by_id.get(dep_id) {
content.append_styled(": ", theme.dimmed.clone());
content.append(&issue.title);
}
content.append("\n");
}
let direct_set: HashSet<&String> = direct_dependents.iter().collect();
let transitive: Vec<&String> = full_cascade
.iter()
.filter(|id| !direct_set.contains(id))
.collect();
if !transitive.is_empty() {
content.append("\n");
content.append_styled(
&format!(
"{} additional issue(s) transitively affected by --cascade:\n\n",
transitive.len()
),
theme.warning.clone(),
);
for dep_id in &transitive {
content.append_styled(" \u{21b3} ", theme.warning.clone());
content.append_styled(dep_id, theme.issue_id.clone());
if let Some(issue) = issues_by_id.get(*dep_id) {
content.append_styled(": ", theme.dimmed.clone());
content.append(&issue.title);
}
content.append("\n");
}
}
content.append("\n");
content.append_styled(
"Use --force to orphan these dependents, or --cascade to delete them recursively.\n",
theme.dimmed.clone(),
);
if !full_cascade.is_empty() {
content.append_styled(
&format!(
"--cascade would delete {} total dependent(s).\n",
full_cascade.len()
),
theme.dimmed.clone(),
);
}
content.append_styled("No changes made (preview mode).", theme.muted.clone());
let panel = Panel::from_rich_text(&content, width)
.title(Text::styled(
"\u{26a0} Blocked by Dependents",
theme.warning.clone(),
))
.box_style(theme.box_style);
console.print_renderable(&panel);
}
fn render_dry_run_rich(
ids: &[String],
cascade_ids: &[String],
orphan_ids: &[String],
storage: &SqliteStorage,
ctx: &OutputContext,
) {
let console = Console::default();
let theme = ctx.theme();
let width = ctx.width();
let mut all_ids = Vec::new();
all_ids.extend_from_slice(ids);
all_ids.extend_from_slice(cascade_ids);
all_ids.extend_from_slice(orphan_ids);
let mut issues_by_id = std::collections::HashMap::new();
if let Ok(issues) = storage.get_issues_by_ids(&all_ids) {
for issue in issues {
issues_by_id.insert(issue.id.clone(), issue);
}
}
let mut content = Text::new("");
content.append_styled("Would delete ", theme.dimmed.clone());
content.append_styled(&format!("{}", ids.len()), theme.emphasis.clone());
content.append_styled(" issue(s):\n\n", theme.dimmed.clone());
for id in ids {
content.append_styled(" \u{2717} ", theme.error.clone());
content.append_styled(id, theme.issue_id.clone());
if let Some(issue) = issues_by_id.get(id) {
content.append_styled(": ", theme.dimmed.clone());
content.append(&issue.title);
}
content.append("\n");
}
if !cascade_ids.is_empty() {
content.append("\n");
content.append_styled("Would cascade delete ", theme.dimmed.clone());
content.append_styled(&format!("{}", cascade_ids.len()), theme.emphasis.clone());
content.append_styled(" dependent(s):\n\n", theme.dimmed.clone());
for id in cascade_ids {
content.append_styled(" \u{21b3} ", theme.warning.clone());
content.append_styled(id, theme.issue_id.clone());
if let Some(issue) = issues_by_id.get(id) {
content.append_styled(": ", theme.dimmed.clone());
content.append(&issue.title);
}
content.append("\n");
}
}
if !orphan_ids.is_empty() {
content.append("\n");
content.append_styled("Would orphan ", theme.dimmed.clone());
content.append_styled(&format!("{}", orphan_ids.len()), theme.emphasis.clone());
content.append_styled(" dependent(s):\n\n", theme.dimmed.clone());
for id in orphan_ids {
content.append_styled(" \u{26a0} ", theme.warning.clone());
content.append_styled(id, theme.issue_id.clone());
if let Some(issue) = issues_by_id.get(id) {
content.append_styled(": ", theme.dimmed.clone());
content.append(&issue.title);
}
content.append("\n");
}
}
let panel = Panel::from_rich_text(&content, width)
.title(Text::styled(
"\u{1f4cb} Dry Run Preview",
theme.info.clone(),
))
.box_style(theme.box_style);
console.print_renderable(&panel);
}
fn render_delete_result_rich(result: &DeleteResult, storage: &SqliteStorage, ctx: &OutputContext) {
let console = Console::default();
let theme = ctx.theme();
let width = ctx.width();
let mut all_ids = Vec::new();
all_ids.extend_from_slice(&result.deleted);
all_ids.extend_from_slice(&result.orphaned_issues);
let mut issues_by_id = std::collections::HashMap::new();
if let Ok(issues) = storage.get_issues_by_ids(&all_ids) {
for issue in issues {
issues_by_id.insert(issue.id.clone(), issue);
}
}
let mut content = Text::new("");
content.append_styled("Deleted ", theme.success.clone());
content.append_styled(&format!("{}", result.deleted_count), theme.emphasis.clone());
content.append_styled(" issue(s):\n\n", theme.success.clone());
for id in &result.deleted {
content.append_styled(" \u{2713} ", theme.success.clone());
content.append_styled(id, theme.issue_id.clone());
if let Some(issue) = issues_by_id.get(id) {
content.append_styled(": ", theme.dimmed.clone());
content.append(&issue.title);
}
content.append("\n");
}
if result.dependencies_removed > 0 {
content.append("\n");
content.append_styled("Removed ", theme.dimmed.clone());
content.append_styled(
&format!("{}", result.dependencies_removed),
theme.emphasis.clone(),
);
content.append_styled(" dependency link(s)", theme.dimmed.clone());
}
if !result.orphaned_issues.is_empty() {
content.append("\n\n");
content.append_styled("Orphaned ", theme.warning.clone());
content.append_styled(
&format!("{}", result.orphaned_issues.len()),
theme.emphasis.clone(),
);
content.append_styled(" issue(s):\n\n", theme.warning.clone());
for id in &result.orphaned_issues {
content.append_styled(" \u{26a0} ", theme.warning.clone());
content.append_styled(id, theme.issue_id.clone());
if let Some(issue) = issues_by_id.get(id) {
content.append_styled(": ", theme.dimmed.clone());
content.append(&issue.title);
}
content.append("\n");
}
}
let panel = Panel::from_rich_text(&content, width)
.title(Text::styled(
"\u{1f5d1} Delete Complete",
theme.success.clone(),
))
.box_style(theme.box_style);
console.print_renderable(&panel);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{Issue, IssueType, Priority, Status};
use chrono::Utc;
use std::io::Write;
use tempfile::NamedTempFile;
use tracing::info;
fn init_logging() {
crate::logging::init_test_logging();
}
fn create_test_issue(id: &str, title: &str) -> Issue {
Issue {
id: id.to_string(),
title: title.to_string(),
status: Status::Open,
priority: Priority::MEDIUM,
issue_type: IssueType::Task,
created_at: Utc::now(),
updated_at: Utc::now(),
content_hash: None,
description: None,
design: None,
acceptance_criteria: None,
notes: None,
assignee: None,
owner: None,
estimated_minutes: None,
created_by: None,
closed_at: None,
close_reason: None,
closed_by_session: None,
due_at: None,
defer_until: None,
external_ref: None,
source_system: None,
source_repo: None,
deleted_at: None,
deleted_by: None,
delete_reason: None,
original_type: None,
compaction_level: None,
compacted_at: None,
compacted_at_commit: None,
original_size: None,
sender: None,
ephemeral: false,
pinned: false,
is_template: false,
labels: vec![],
dependencies: vec![],
comments: vec![],
}
}
#[test]
fn test_read_ids_from_file() {
init_logging();
info!("test_read_ids_from_file: starting");
let mut file = NamedTempFile::new().unwrap();
writeln!(file, "bd-1").unwrap();
writeln!(file, "# comment").unwrap();
writeln!(file).unwrap();
writeln!(file, "bd-2").unwrap();
writeln!(file, " bd-3 ").unwrap();
file.flush().unwrap();
let ids = read_ids_from_file(file.path()).unwrap();
assert_eq!(ids, vec!["bd-1", "bd-2", "bd-3"]);
info!("test_read_ids_from_file: assertions passed");
}
#[test]
fn test_delete_creates_tombstone() {
init_logging();
info!("test_delete_creates_tombstone: starting");
let mut storage = SqliteStorage::open_memory().unwrap();
let issue = create_test_issue("bd-del1", "Test Delete");
storage.create_issue(&issue, "tester").unwrap();
let before = storage.get_issue("bd-del1").unwrap().unwrap();
assert_eq!(before.status, Status::Open);
let deleted = storage
.delete_issue("bd-del1", "tester", "test deletion", None)
.unwrap();
assert_eq!(deleted.status, Status::Tombstone);
assert!(deleted.deleted_at.is_some());
assert_eq!(deleted.deleted_by.as_deref(), Some("tester"));
assert_eq!(deleted.delete_reason.as_deref(), Some("test deletion"));
assert_eq!(deleted.original_type.as_deref(), Some("task"));
info!("test_delete_creates_tombstone: assertions passed");
}
#[test]
fn test_delete_nonexistent_fails() {
init_logging();
info!("test_delete_nonexistent_fails: starting");
let mut storage = SqliteStorage::open_memory().unwrap();
let result = storage.delete_issue("bd-nope", "tester", "reason", None);
assert!(result.is_err());
info!("test_delete_nonexistent_fails: assertions passed");
}
#[test]
fn test_cascade_dependents_collection() {
init_logging();
info!("test_cascade_dependents_collection: starting");
let mut storage = SqliteStorage::open_memory().unwrap();
let a = create_test_issue("bd-a", "Issue A");
let b = create_test_issue("bd-b", "Issue B");
let c = create_test_issue("bd-c", "Issue C");
storage.create_issue(&a, "tester").unwrap();
storage.create_issue(&b, "tester").unwrap();
storage.create_issue(&c, "tester").unwrap();
storage
.mutate("test_add_deps", "tester", |tx, _ctx| {
tx.execute_with_params(
"INSERT INTO dependencies (issue_id, depends_on_id, type, created_at) VALUES (?, ?, ?, ?)",
&[fsqlite_types::SqliteValue::from("bd-b"), fsqlite_types::SqliteValue::from("bd-a"), fsqlite_types::SqliteValue::from("blocks"), fsqlite_types::SqliteValue::from(chrono::Utc::now().to_rfc3339().as_str())],
)?;
tx.execute_with_params(
"INSERT INTO dependencies (issue_id, depends_on_id, type, created_at) VALUES (?, ?, ?, ?)",
&[fsqlite_types::SqliteValue::from("bd-c"), fsqlite_types::SqliteValue::from("bd-b"), fsqlite_types::SqliteValue::from("blocks"), fsqlite_types::SqliteValue::from(chrono::Utc::now().to_rfc3339().as_str())],
)?;
Ok(())
})
.unwrap();
let cascade = collect_cascade_dependents(&storage, &["bd-a".to_string()]).unwrap();
assert!(cascade.contains("bd-b"));
assert!(cascade.contains("bd-c"));
assert!(!cascade.contains("bd-a")); info!("test_cascade_dependents_collection: assertions passed");
}
#[test]
fn test_direct_dependents_collection_is_shallow() {
init_logging();
info!("test_direct_dependents_collection_is_shallow: starting");
let mut storage = SqliteStorage::open_memory().unwrap();
let a = create_test_issue("bd-a", "Issue A");
let b = create_test_issue("bd-b", "Issue B");
let c = create_test_issue("bd-c", "Issue C");
storage.create_issue(&a, "tester").unwrap();
storage.create_issue(&b, "tester").unwrap();
storage.create_issue(&c, "tester").unwrap();
storage
.mutate("test_add_direct_deps", "tester", |tx, _ctx| {
tx.execute_with_params(
"INSERT INTO dependencies (issue_id, depends_on_id, type, created_at) VALUES (?, ?, ?, ?)",
&[fsqlite_types::SqliteValue::from("bd-b"), fsqlite_types::SqliteValue::from("bd-a"), fsqlite_types::SqliteValue::from("blocks"), fsqlite_types::SqliteValue::from(chrono::Utc::now().to_rfc3339().as_str())],
)?;
tx.execute_with_params(
"INSERT INTO dependencies (issue_id, depends_on_id, type, created_at) VALUES (?, ?, ?, ?)",
&[fsqlite_types::SqliteValue::from("bd-c"), fsqlite_types::SqliteValue::from("bd-b"), fsqlite_types::SqliteValue::from("blocks"), fsqlite_types::SqliteValue::from(chrono::Utc::now().to_rfc3339().as_str())],
)?;
Ok(())
})
.unwrap();
let direct = collect_direct_dependents(&storage, &["bd-a".to_string()]).unwrap();
assert_eq!(direct, vec!["bd-b".to_string()]);
info!("test_direct_dependents_collection_is_shallow: assertions passed");
}
#[test]
fn test_sorted_cascade_dependents_include_transitive_dependents() {
init_logging();
info!("test_sorted_cascade_dependents_include_transitive_dependents: starting");
let mut storage = SqliteStorage::open_memory().unwrap();
let a = create_test_issue("bd-a", "Issue A");
let b = create_test_issue("bd-b", "Issue B");
let c = create_test_issue("bd-c", "Issue C");
storage.create_issue(&a, "tester").unwrap();
storage.create_issue(&b, "tester").unwrap();
storage.create_issue(&c, "tester").unwrap();
storage
.mutate("test_add_transitive_deps", "tester", |tx, _ctx| {
tx.execute_with_params(
"INSERT INTO dependencies (issue_id, depends_on_id, type, created_at) VALUES (?, ?, ?, ?)",
&[fsqlite_types::SqliteValue::from("bd-b"), fsqlite_types::SqliteValue::from("bd-a"), fsqlite_types::SqliteValue::from("blocks"), fsqlite_types::SqliteValue::from(chrono::Utc::now().to_rfc3339().as_str())],
)?;
tx.execute_with_params(
"INSERT INTO dependencies (issue_id, depends_on_id, type, created_at) VALUES (?, ?, ?, ?)",
&[fsqlite_types::SqliteValue::from("bd-c"), fsqlite_types::SqliteValue::from("bd-b"), fsqlite_types::SqliteValue::from("blocks"), fsqlite_types::SqliteValue::from(chrono::Utc::now().to_rfc3339().as_str())],
)?;
Ok(())
})
.unwrap();
let cascade = collect_sorted_cascade_dependents(&storage, &["bd-a".to_string()]).unwrap();
assert_eq!(cascade, vec!["bd-b".to_string(), "bd-c".to_string()]);
info!("test_sorted_cascade_dependents_include_transitive_dependents: assertions passed");
}
#[test]
fn test_preview_cascade_closure_exceeds_direct_dependents() {
init_logging();
info!("test_preview_cascade_closure_exceeds_direct_dependents: starting");
let mut storage = SqliteStorage::open_memory().unwrap();
let a = create_test_issue("bd-a", "Issue A");
let b = create_test_issue("bd-b", "Issue B");
let c = create_test_issue("bd-c", "Issue C");
let d = create_test_issue("bd-d", "Issue D");
storage.create_issue(&a, "tester").unwrap();
storage.create_issue(&b, "tester").unwrap();
storage.create_issue(&c, "tester").unwrap();
storage.create_issue(&d, "tester").unwrap();
storage
.mutate("test_add_chain_deps", "tester", |tx, _ctx| {
tx.execute_with_params(
"INSERT INTO dependencies (issue_id, depends_on_id, type, created_at) VALUES (?, ?, ?, ?)",
&[fsqlite_types::SqliteValue::from("bd-b"), fsqlite_types::SqliteValue::from("bd-a"), fsqlite_types::SqliteValue::from("blocks"), fsqlite_types::SqliteValue::from(chrono::Utc::now().to_rfc3339().as_str())],
)?;
tx.execute_with_params(
"INSERT INTO dependencies (issue_id, depends_on_id, type, created_at) VALUES (?, ?, ?, ?)",
&[fsqlite_types::SqliteValue::from("bd-c"), fsqlite_types::SqliteValue::from("bd-b"), fsqlite_types::SqliteValue::from("blocks"), fsqlite_types::SqliteValue::from(chrono::Utc::now().to_rfc3339().as_str())],
)?;
tx.execute_with_params(
"INSERT INTO dependencies (issue_id, depends_on_id, type, created_at) VALUES (?, ?, ?, ?)",
&[fsqlite_types::SqliteValue::from("bd-d"), fsqlite_types::SqliteValue::from("bd-c"), fsqlite_types::SqliteValue::from("blocks"), fsqlite_types::SqliteValue::from(chrono::Utc::now().to_rfc3339().as_str())],
)?;
Ok(())
})
.unwrap();
let direct = collect_direct_dependents(&storage, &["bd-a".to_string()]).unwrap();
assert_eq!(direct, vec!["bd-b".to_string()]);
let cascade = collect_sorted_cascade_dependents(&storage, &["bd-a".to_string()]).unwrap();
assert_eq!(
cascade,
vec!["bd-b".to_string(), "bd-c".to_string(), "bd-d".to_string()]
);
assert!(cascade.len() > direct.len());
for dep in &direct {
assert!(cascade.contains(dep));
}
info!("test_preview_cascade_closure_exceeds_direct_dependents: assertions passed");
}
}