1use anyhow::{anyhow, bail, Result};
2use serde::Serialize;
3use std::collections::{BTreeSet, HashSet};
4use std::path::Path;
5
6use crate::db;
7
8#[derive(Serialize)]
9struct RmResult {
10 requested_ids: Vec<String>,
11 deleted_ids: Vec<String>,
12 unblocked_ids: Vec<String>,
13}
14
15pub fn run(root: &Path, ids: &[String], recursive: bool, force: bool, json: bool) -> Result<()> {
16 let store = db::open(root)?;
17 let all = store.list_tasks_unfiltered()?;
18
19 let mut to_delete = BTreeSet::new();
20 for raw in ids {
21 let id = db::resolve_task_id(&store, raw, false)?;
22 if recursive {
23 collect_subtree(&all, &id, &mut to_delete);
24 } else {
25 if all
26 .iter()
27 .any(|t| t.parent.as_ref() == Some(&id) && t.deleted_at.is_none())
28 {
29 bail!("task '{id}' has children; use --recursive to delete subtree");
30 }
31 to_delete.insert(id);
32 }
33 }
34
35 let deleted_ids: Vec<db::TaskId> = to_delete.into_iter().collect();
36 let deleted_set: HashSet<String> = deleted_ids
37 .iter()
38 .map(|id| id.as_str().to_string())
39 .collect();
40
41 let unblocked_ids: Vec<String> = all
42 .iter()
43 .filter(|t| !deleted_set.contains(t.id.as_str()))
44 .filter(|t| t.blockers.iter().any(|b| deleted_set.contains(b.as_str())))
45 .map(|t| t.id.as_str().to_string())
46 .collect();
47
48 let ts = db::now_utc();
49 store.apply_and_persist(|doc| {
50 let tasks = doc.get_map("tasks");
51
52 for task_id in &deleted_ids {
53 let task =
54 db::get_task_map(&tasks, task_id)?.ok_or_else(|| anyhow!("task not found"))?;
55 task.insert("deleted_at", ts.clone())?;
56 task.insert("updated_at", ts.clone())?;
57 task.insert("status", db::status_label(db::Status::Closed))?;
58 }
59
60 for task in store.list_tasks_unfiltered()? {
61 if deleted_set.contains(task.id.as_str()) {
62 continue;
63 }
64 if let Some(task_map) = db::get_task_map(&tasks, &task.id)? {
65 let blockers = db::get_or_create_child_map(&task_map, "blockers")?;
66 for deleted in &deleted_ids {
67 blockers.delete(deleted.as_str())?;
68 }
69 }
70 }
71
72 Ok(())
73 })?;
74
75 if !force && !unblocked_ids.is_empty() {
76 eprintln!(
77 "warning: removed blockers from {}",
78 unblocked_ids.join(", ")
79 );
80 }
81
82 if json {
83 let out = RmResult {
84 requested_ids: ids.to_vec(),
85 deleted_ids: deleted_ids
86 .iter()
87 .map(|id| id.as_str().to_string())
88 .collect(),
89 unblocked_ids,
90 };
91 println!("{}", serde_json::to_string(&out)?);
92 } else {
93 let c = crate::color::stdout_theme();
94 for id in deleted_ids {
95 println!("{}deleted{} {id}", c.green, c.reset);
96 }
97 }
98
99 Ok(())
100}
101
102fn collect_subtree(all: &[db::Task], root: &db::TaskId, out: &mut BTreeSet<db::TaskId>) {
103 if !out.insert(root.clone()) {
104 return;
105 }
106 for task in all {
107 if task.parent.as_ref() == Some(root) && task.deleted_at.is_none() {
108 collect_subtree(all, &task.id, out);
109 }
110 }
111}