1use crate::app::Cli;
2use crate::arg_types::IdentifierToken;
3use crate::commands::Command;
4use crate::common::{colored, DIM, GREEN, ICONS};
5use crate::wire::wire_object::{EntityType, WireObject};
6use anyhow::Result;
7use clap::Args;
8use std::collections::{BTreeMap, HashSet};
9
10#[derive(Debug, Args)]
11#[command(about = "Delete tasks/projects/headings/areas")]
12pub struct DeleteArgs {
13 pub item_ids: Vec<IdentifierToken>,
15}
16
17#[derive(Debug, Clone)]
18struct DeletePlan {
19 targets: Vec<(String, String, String)>,
20 changes: BTreeMap<String, WireObject>,
21}
22
23fn build_delete_plan(args: &DeleteArgs, store: &crate::store::ThingsStore) -> DeletePlan {
24 let mut targets: Vec<(String, String, String)> = Vec::new();
25 let mut seen = HashSet::new();
26
27 for identifier in &args.item_ids {
28 let (task, task_err, task_ambiguous) = store.resolve_task_identifier(identifier.as_str());
29 let (area, area_err, area_ambiguous) = store.resolve_area_identifier(identifier.as_str());
30
31 let task_match = task.is_some();
32 let area_match = area.is_some();
33
34 if task_match && area_match {
35 eprintln!(
36 "Ambiguous identifier '{}' (matches task and area).",
37 identifier.as_str()
38 );
39 continue;
40 }
41
42 if !task_match && !area_match {
43 if !task_ambiguous.is_empty() && !area_ambiguous.is_empty() {
44 eprintln!(
45 "Ambiguous identifier '{}' (matches multiple tasks and areas).",
46 identifier.as_str()
47 );
48 } else if !task_ambiguous.is_empty() {
49 eprintln!("{task_err}");
50 } else if !area_ambiguous.is_empty() {
51 eprintln!("{area_err}");
52 } else {
53 eprintln!("Item not found: {}", identifier.as_str());
54 }
55 continue;
56 }
57
58 if let Some(task) = task {
59 if task.trashed {
60 eprintln!("Item already deleted: {}", task.title);
61 continue;
62 }
63 if !seen.insert(task.uuid.clone()) {
64 continue;
65 }
66 targets.push((
67 task.uuid.to_string(),
68 task.entity.clone(),
69 task.title.clone(),
70 ));
71 continue;
72 }
73
74 if let Some(area) = area {
75 if !seen.insert(area.uuid.clone()) {
76 continue;
77 }
78 targets.push((
79 area.uuid.to_string(),
80 "Area3".to_string(),
81 area.title.clone(),
82 ));
83 }
84 }
85
86 let mut changes = BTreeMap::new();
87 for (uuid, entity, _title) in &targets {
88 changes.insert(
89 uuid.clone(),
90 WireObject::delete(EntityType::from(entity.clone())),
91 );
92 }
93
94 DeletePlan { targets, changes }
95}
96
97impl Command for DeleteArgs {
98 fn run_with_ctx(
99 &self,
100 cli: &Cli,
101 out: &mut dyn std::io::Write,
102 ctx: &mut dyn crate::cmd_ctx::CmdCtx,
103 ) -> Result<()> {
104 let store = cli.load_store()?;
105 let plan = build_delete_plan(self, &store);
106
107 if plan.targets.is_empty() {
108 return Ok(());
109 }
110
111 if let Err(e) = ctx.commit_changes(plan.changes, None) {
112 eprintln!("Failed to delete items: {e}");
113 return Ok(());
114 }
115
116 for (uuid, _entity, title) in plan.targets {
117 writeln!(
118 out,
119 "{} {} {}",
120 colored(
121 &format!("{} Deleted", ICONS.deleted),
122 &[GREEN],
123 cli.no_color
124 ),
125 title,
126 colored(&uuid, &[DIM], cli.no_color)
127 )?;
128 }
129
130 Ok(())
131 }
132}
133
134#[cfg(test)]
135mod tests {
136 use super::*;
137 use crate::store::{fold_items, ThingsStore};
138 use crate::wire::area::AreaProps;
139 use crate::wire::task::{TaskProps, TaskStart, TaskStatus, TaskType};
140
141 const TASK_A: &str = "A7h5eCi24RvAWKC3Hv3muf";
142 const TASK_B: &str = "KGvAPpMrzHAKMdgMiERP1V";
143 const AREA_A: &str = "MpkEei6ybkFS2n6SXvwfLf";
144
145 fn build_store(entries: Vec<(String, WireObject)>) -> ThingsStore {
146 let mut item = BTreeMap::new();
147 for (uuid, obj) in entries {
148 item.insert(uuid, obj);
149 }
150 ThingsStore::from_raw_state(&fold_items([item]))
151 }
152
153 fn task(uuid: &str, title: &str, trashed: bool) -> (String, WireObject) {
154 (
155 uuid.to_string(),
156 WireObject::create(
157 EntityType::Task6,
158 TaskProps {
159 title: title.to_string(),
160 item_type: TaskType::Todo,
161 status: TaskStatus::Incomplete,
162 start_location: TaskStart::Inbox,
163 sort_index: 0,
164 trashed,
165 creation_date: Some(1.0),
166 modification_date: Some(1.0),
167 ..Default::default()
168 },
169 ),
170 )
171 }
172
173 fn area(uuid: &str, title: &str) -> (String, WireObject) {
174 (
175 uuid.to_string(),
176 WireObject::create(
177 EntityType::Area3,
178 AreaProps {
179 title: title.to_string(),
180 sort_index: 0,
181 ..Default::default()
182 },
183 ),
184 )
185 }
186
187 #[test]
188 fn delete_payloads_match_snapshot_cases() {
189 let single = build_delete_plan(
190 &DeleteArgs {
191 item_ids: vec![IdentifierToken::from(TASK_A)],
192 },
193 &build_store(vec![task(TASK_A, "Alpha", false)]),
194 );
195 assert_eq!(
196 serde_json::to_value(single.changes).expect("to value"),
197 serde_json::json!({ TASK_A: {"t":2,"e":"Task6","p":{}} })
198 );
199
200 let multi = build_delete_plan(
201 &DeleteArgs {
202 item_ids: vec![IdentifierToken::from(TASK_A), IdentifierToken::from(AREA_A)],
203 },
204 &build_store(vec![task(TASK_A, "Alpha", false), area(AREA_A, "Work")]),
205 );
206 assert_eq!(
207 serde_json::to_value(multi.changes).expect("to value"),
208 serde_json::json!({
209 TASK_A: {"t":2,"e":"Task6","p":{}},
210 AREA_A: {"t":2,"e":"Area3","p":{}}
211 })
212 );
213
214 let skip_trashed = build_delete_plan(
215 &DeleteArgs {
216 item_ids: vec![IdentifierToken::from(TASK_A), IdentifierToken::from(TASK_B)],
217 },
218 &build_store(vec![
219 task(TASK_A, "Active", false),
220 task(TASK_B, "Trashed", true),
221 ]),
222 );
223 assert_eq!(
224 serde_json::to_value(skip_trashed.changes).expect("to value"),
225 serde_json::json!({ TASK_A: {"t":2,"e":"Task6","p":{}} })
226 );
227 }
228}