Skip to main content

things3_cloud/commands/
delete.rs

1use crate::app::Cli;
2use crate::arg_types::IdentifierToken;
3use crate::commands::Command;
4use crate::common::{DIM, GREEN, ICONS, colored};
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    /// Item UUID(s) (or unique UUID prefixes)
14    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::{ThingsStore, fold_items};
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}