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<db::TaskId> = 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.clone())
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 let short: Vec<String> = unblocked_ids.iter().map(ToString::to_string).collect();
77 eprintln!("warning: removed blockers from {}", short.join(", "));
78 }
79
80 if json {
81 let out = RmResult {
82 requested_ids: ids.to_vec(),
83 deleted_ids: deleted_ids
84 .iter()
85 .map(|id| id.as_str().to_string())
86 .collect(),
87 unblocked_ids: unblocked_ids
88 .iter()
89 .map(|id| id.as_str().to_string())
90 .collect(),
91 };
92 println!("{}", serde_json::to_string(&out)?);
93 } else {
94 let c = crate::color::stdout_theme();
95 for id in deleted_ids {
96 println!("{}deleted{} {id}", c.green, c.reset);
97 }
98 }
99
100 Ok(())
101}
102
103fn collect_subtree(all: &[db::Task], root: &db::TaskId, out: &mut BTreeSet<db::TaskId>) {
104 if !out.insert(root.clone()) {
105 return;
106 }
107 for task in all {
108 if task.parent.as_ref() == Some(root) && task.deleted_at.is_none() {
109 collect_subtree(all, &task.id, out);
110 }
111 }
112}