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