Skip to main content

things3_cloud/commands/
delete.rs

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    /// Item UUID(s) (or unique UUID prefixes)
18    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}