Skip to main content

things3_cloud/commands/
edit.rs

1use std::collections::{BTreeMap, HashMap, HashSet};
2
3use anyhow::Result;
4use clap::Args;
5
6use crate::{
7    app::Cli,
8    arg_types::IdentifierToken,
9    commands::{Command, TagDeltaArgs},
10    common::{DIM, GREEN, ICONS, colored, resolve_tag_ids, task6_note},
11    ids::ThingsId,
12    wire::{
13        checklist::{ChecklistItemPatch, ChecklistItemProps},
14        notes::{StructuredTaskNotes, TaskNotes},
15        task::{TaskPatch, TaskStart, TaskStatus},
16        wire_object::{EntityType, WireObject},
17    },
18};
19
20#[derive(Debug, Args)]
21#[command(about = "Edit a task title, container, notes, tags, or checklist items")]
22pub struct EditArgs {
23    #[arg(help = "Task UUID(s) (or unique UUID prefixes)")]
24    pub task_ids: Vec<IdentifierToken>,
25    #[arg(long, help = "Replace title (single task only)")]
26    pub title: Option<String>,
27    #[arg(
28        long,
29        help = "Replace notes (single task only; use empty string to clear)"
30    )]
31    pub notes: Option<String>,
32    #[arg(
33        long = "move",
34        help = "Move to Inbox, clear, project UUID/prefix, or area UUID/prefix"
35    )]
36    pub move_target: Option<String>,
37    #[command(flatten)]
38    pub tag_delta: TagDeltaArgs,
39    #[arg(
40        long = "add-checklist",
41        value_name = "TITLE",
42        help = "Add a checklist item (repeatable, single task only)"
43    )]
44    pub add_checklist: Vec<String>,
45    #[arg(
46        long = "remove-checklist",
47        value_name = "IDS",
48        help = "Remove checklist items by comma-separated short IDs (single task only)"
49    )]
50    pub remove_checklist: Option<String>,
51    #[arg(
52        long = "rename-checklist",
53        value_name = "ID:TITLE",
54        help = "Rename a checklist item: short-id:new title (repeatable, single task only)"
55    )]
56    pub rename_checklist: Vec<String>,
57}
58
59fn resolve_checklist_items(
60    task: &crate::store::Task,
61    raw_ids: &str,
62) -> (Vec<crate::store::ChecklistItem>, String) {
63    let tokens = raw_ids
64        .split(',')
65        .map(str::trim)
66        .filter(|t| !t.is_empty())
67        .collect::<Vec<_>>();
68    if tokens.is_empty() {
69        return (Vec::new(), "No checklist item IDs provided.".to_string());
70    }
71
72    let mut resolved = Vec::new();
73    let mut seen = HashSet::new();
74    for token in tokens {
75        let matches = task
76            .checklist_items
77            .iter()
78            .filter(|item| item.uuid.starts_with(token))
79            .cloned()
80            .collect::<Vec<_>>();
81        if matches.is_empty() {
82            return (Vec::new(), format!("Checklist item not found: '{token}'"));
83        }
84        if matches.len() > 1 {
85            return (
86                Vec::new(),
87                format!("Ambiguous checklist item prefix: '{token}'"),
88            );
89        }
90        let item = matches[0].clone();
91        if seen.insert(item.uuid.clone()) {
92            resolved.push(item);
93        }
94    }
95
96    (resolved, String::new())
97}
98
99#[derive(Debug, Clone)]
100struct EditPlan {
101    tasks: Vec<crate::store::Task>,
102    changes: BTreeMap<String, WireObject>,
103    labels: Vec<String>,
104}
105
106impl Command for EditArgs {
107    fn run_with_ctx(
108        &self,
109        cli: &Cli,
110        out: &mut dyn std::io::Write,
111        ctx: &mut dyn crate::cmd_ctx::CmdCtx,
112    ) -> Result<()> {
113        let store = cli.load_store()?;
114        let now = ctx.now_timestamp();
115        let mut id_gen = || ctx.next_id();
116        let plan = match build_edit_plan(self, &store, now, &mut id_gen) {
117            Ok(plan) => plan,
118            Err(err) => {
119                eprintln!("{err}");
120                return Ok(());
121            }
122        };
123
124        if let Err(e) = ctx.commit_changes(plan.changes.clone(), None) {
125            eprintln!("Failed to edit item: {e}");
126            return Ok(());
127        }
128
129        let label_str = colored(
130            &format!("({})", plan.labels.join(", ")),
131            &[DIM],
132            cli.no_color,
133        );
134        for task in plan.tasks {
135            let title_display = plan
136                .changes
137                .get(&task.uuid.to_string())
138                .and_then(|obj| obj.properties_map().get("tt").cloned())
139                .and_then(|v| v.as_str().map(ToString::to_string))
140                .unwrap_or(task.title);
141            writeln!(
142                out,
143                "{} {}  {} {}",
144                colored(&format!("{} Edited", ICONS.done), &[GREEN], cli.no_color),
145                title_display,
146                colored(&task.uuid, &[DIM], cli.no_color),
147                label_str
148            )?;
149        }
150
151        Ok(())
152    }
153}
154
155fn build_edit_plan(
156    args: &EditArgs,
157    store: &crate::store::ThingsStore,
158    now: f64,
159    next_id: &mut dyn FnMut() -> String,
160) -> std::result::Result<EditPlan, String> {
161    let multiple = args.task_ids.len() > 1;
162    if multiple && args.title.is_some() {
163        return Err("--title requires a single task ID.".to_string());
164    }
165    if multiple && args.notes.is_some() {
166        return Err("--notes requires a single task ID.".to_string());
167    }
168    if multiple
169        && (!args.add_checklist.is_empty()
170            || args.remove_checklist.is_some()
171            || !args.rename_checklist.is_empty())
172    {
173        return Err(
174            "--add-checklist/--remove-checklist/--rename-checklist require a single task ID."
175                .to_string(),
176        );
177    }
178
179    let mut tasks = Vec::new();
180    for identifier in &args.task_ids {
181        let (task_opt, err, _) = store.resolve_mark_identifier(identifier.as_str());
182        let Some(task) = task_opt else {
183            return Err(err);
184        };
185        if task.is_project() {
186            return Err("Use 'projects edit' to edit a project.".to_string());
187        }
188        tasks.push(task);
189    }
190
191    let mut shared_update = TaskPatch::default();
192    let mut move_from_inbox_st: Option<TaskStart> = None;
193    let mut labels: Vec<String> = Vec::new();
194    let move_raw = args.move_target.clone().unwrap_or_default();
195    let move_l = move_raw.to_lowercase();
196
197    if !move_raw.trim().is_empty() {
198        if move_l == "inbox" {
199            shared_update.parent_project_ids = Some(vec![]);
200            shared_update.area_ids = Some(vec![]);
201            shared_update.action_group_ids = Some(vec![]);
202            shared_update.start_location = Some(TaskStart::Inbox);
203            shared_update.scheduled_date = Some(None);
204            shared_update.today_index_reference = Some(None);
205            shared_update.evening_bit = Some(0);
206            labels.push("move=inbox".to_string());
207        } else if move_l == "clear" {
208            labels.push("move=clear".to_string());
209        } else {
210            let (project_opt, _, _) = store.resolve_mark_identifier(&move_raw);
211            let (area_opt, _, _) = store.resolve_area_identifier(&move_raw);
212
213            let project_uuid = project_opt.as_ref().and_then(|p| {
214                if p.is_project() {
215                    Some(p.uuid.clone())
216                } else {
217                    None
218                }
219            });
220            let area_uuid = area_opt.as_ref().map(|a| a.uuid.clone());
221
222            if project_uuid.is_some() && area_uuid.is_some() {
223                return Err(format!(
224                    "Ambiguous --move target '{}' (matches project and area).",
225                    move_raw
226                ));
227            }
228            if project_opt.is_some() && project_uuid.is_none() {
229                return Err(
230                    "--move target must be Inbox, clear, a project ID, or an area ID.".to_string(),
231                );
232            }
233
234            if let Some(project_uuid) = project_uuid {
235                let project_id = ThingsId::from(project_uuid);
236                shared_update.parent_project_ids = Some(vec![project_id]);
237                shared_update.area_ids = Some(vec![]);
238                shared_update.action_group_ids = Some(vec![]);
239                move_from_inbox_st = Some(TaskStart::Anytime);
240                labels.push(format!("move={move_raw}"));
241            } else if let Some(area_uuid) = area_uuid {
242                let area_id = ThingsId::from(area_uuid);
243                shared_update.area_ids = Some(vec![area_id]);
244                shared_update.parent_project_ids = Some(vec![]);
245                shared_update.action_group_ids = Some(vec![]);
246                move_from_inbox_st = Some(TaskStart::Anytime);
247                labels.push(format!("move={move_raw}"));
248            } else {
249                return Err(format!("Container not found: {move_raw}"));
250            }
251        }
252    }
253
254    let mut add_tag_ids = Vec::new();
255    let mut remove_tag_ids = Vec::new();
256    if let Some(raw) = &args.tag_delta.add_tags {
257        let (ids, err) = resolve_tag_ids(store, raw);
258        if !err.is_empty() {
259            return Err(err);
260        }
261        add_tag_ids = ids;
262        labels.push("add-tags".to_string());
263    }
264    if let Some(raw) = &args.tag_delta.remove_tags {
265        let (ids, err) = resolve_tag_ids(store, raw);
266        if !err.is_empty() {
267            return Err(err);
268        }
269        remove_tag_ids = ids;
270        if !labels.iter().any(|l| l == "remove-tags") {
271            labels.push("remove-tags".to_string());
272        }
273    }
274
275    let mut rename_map: HashMap<String, String> = HashMap::new();
276    for token in &args.rename_checklist {
277        let Some((short_id, new_title)) = token.split_once(':') else {
278            return Err(format!(
279                "--rename-checklist requires 'id:new title' format, got: {token:?}"
280            ));
281        };
282        let short_id = short_id.trim();
283        let new_title = new_title.trim();
284        if short_id.is_empty() || new_title.is_empty() {
285            return Err(format!(
286                "--rename-checklist requires 'id:new title' format, got: {token:?}"
287            ));
288        }
289        rename_map.insert(short_id.to_string(), new_title.to_string());
290    }
291
292    let mut changes: BTreeMap<String, WireObject> = BTreeMap::new();
293
294    for task in &tasks {
295        let mut update = shared_update.clone();
296
297        if let Some(title) = &args.title {
298            let title = title.trim();
299            if title.is_empty() {
300                return Err("Task title cannot be empty.".to_string());
301            }
302            update.title = Some(title.to_string());
303            if !labels.iter().any(|l| l == "title") {
304                labels.push("title".to_string());
305            }
306        }
307
308        if let Some(notes) = &args.notes {
309            if notes.is_empty() {
310                update.notes = Some(TaskNotes::Structured(StructuredTaskNotes {
311                    object_type: Some("tx".to_string()),
312                    format_type: 1,
313                    ch: Some(0),
314                    v: Some(String::new()),
315                    ps: Vec::new(),
316                    unknown_fields: Default::default(),
317                }));
318            } else {
319                update.notes = Some(task6_note(notes));
320            }
321            if !labels.iter().any(|l| l == "notes") {
322                labels.push("notes".to_string());
323            }
324        }
325
326        if move_l == "clear" {
327            update.parent_project_ids = Some(vec![]);
328            update.area_ids = Some(vec![]);
329            update.action_group_ids = Some(vec![]);
330            if task.start == TaskStart::Inbox {
331                update.start_location = Some(TaskStart::Anytime);
332            }
333        }
334
335        if let Some(move_from_inbox_st) = move_from_inbox_st
336            && task.start == TaskStart::Inbox
337        {
338            update.start_location = Some(move_from_inbox_st);
339        }
340
341        if !add_tag_ids.is_empty() || !remove_tag_ids.is_empty() {
342            let mut current = task.tags.clone();
343            for uuid in &add_tag_ids {
344                if !current.iter().any(|c| c == uuid) {
345                    current.push(uuid.clone());
346                }
347            }
348            current.retain(|uuid| !remove_tag_ids.iter().any(|r| r == uuid));
349            update.tag_ids = Some(current);
350        }
351
352        if let Some(remove_raw) = &args.remove_checklist {
353            let (items, err) = resolve_checklist_items(task, remove_raw);
354            if !err.is_empty() {
355                return Err(err);
356            }
357            for uuid in items.into_iter().map(|i| i.uuid).collect::<HashSet<_>>() {
358                changes.insert(
359                    uuid.to_string(),
360                    WireObject::delete(EntityType::ChecklistItem3),
361                );
362            }
363            if !labels.iter().any(|l| l == "remove-checklist") {
364                labels.push("remove-checklist".to_string());
365            }
366        }
367
368        if !rename_map.is_empty() {
369            for (short_id, new_title) in &rename_map {
370                let matches = task
371                    .checklist_items
372                    .iter()
373                    .filter(|i| i.uuid.starts_with(short_id))
374                    .cloned()
375                    .collect::<Vec<_>>();
376                if matches.is_empty() {
377                    return Err(format!("Checklist item not found: '{short_id}'"));
378                }
379                if matches.len() > 1 {
380                    return Err(format!("Ambiguous checklist item prefix: '{short_id}'"));
381                }
382                changes.insert(
383                    matches[0].uuid.to_string(),
384                    WireObject::update(
385                        EntityType::ChecklistItem3,
386                        ChecklistItemPatch {
387                            title: Some(new_title.to_string()),
388                            modification_date: Some(now),
389                            ..Default::default()
390                        },
391                    ),
392                );
393            }
394            if !labels.iter().any(|l| l == "rename-checklist") {
395                labels.push("rename-checklist".to_string());
396            }
397        }
398
399        if !args.add_checklist.is_empty() {
400            let max_ix = task
401                .checklist_items
402                .iter()
403                .map(|i| i.index)
404                .max()
405                .unwrap_or(0);
406            for (idx, title) in args.add_checklist.iter().enumerate() {
407                let title = title.trim();
408                if title.is_empty() {
409                    return Err("Checklist item title cannot be empty.".to_string());
410                }
411                changes.insert(
412                    next_id(),
413                    WireObject::create(
414                        EntityType::ChecklistItem3,
415                        ChecklistItemProps {
416                            title: title.to_string(),
417                            task_ids: vec![task.uuid.clone()],
418                            status: TaskStatus::Incomplete,
419                            sort_index: max_ix + idx as i32 + 1,
420                            creation_date: Some(now),
421                            modification_date: Some(now),
422                            ..Default::default()
423                        },
424                    ),
425                );
426            }
427            if !labels.iter().any(|l| l == "add-checklist") {
428                labels.push("add-checklist".to_string());
429            }
430        }
431
432        let has_checklist_changes = !args.add_checklist.is_empty()
433            || args.remove_checklist.is_some()
434            || !rename_map.is_empty();
435        if update.is_empty() && !has_checklist_changes {
436            return Err("No edit changes requested.".to_string());
437        }
438
439        if !update.is_empty() {
440            update.modification_date = Some(now);
441            changes.insert(
442                task.uuid.to_string(),
443                WireObject::update(EntityType::from(task.entity.clone()), update),
444            );
445        }
446    }
447
448    Ok(EditPlan {
449        tasks,
450        changes,
451        labels,
452    })
453}
454
455#[cfg(test)]
456mod tests {
457    use std::collections::BTreeMap;
458
459    use serde_json::json;
460
461    use super::*;
462    use crate::{
463        ids::ThingsId,
464        store::{ThingsStore, fold_items},
465        wire::{
466            area::AreaProps,
467            checklist::ChecklistItemProps,
468            tags::TagProps,
469            task::{TaskProps, TaskStart, TaskStatus, TaskType},
470            wire_object::{EntityType, OperationType, WireItem, WireObject},
471        },
472    };
473
474    const NOW: f64 = 1_700_000_222.0;
475    const TASK_UUID: &str = "A7h5eCi24RvAWKC3Hv3muf";
476    const TASK_UUID2: &str = "3H9jsMx3kYMrQ4M7DReSRn";
477    const PROJECT_UUID: &str = "KGvAPpMrzHAKMdgMiERP1V";
478    const AREA_UUID: &str = "MpkEei6ybkFS2n6SXvwfLf";
479    const CHECK_A: &str = "5uwoHPi5m5i8QJa6Rae6Cn";
480    const CHECK_B: &str = "CwhFwmHxjHkR7AFn9aJH9Q";
481
482    fn build_store(entries: Vec<(String, WireObject)>) -> ThingsStore {
483        let mut item: WireItem = BTreeMap::new();
484        for (uuid, obj) in entries {
485            item.insert(uuid, obj);
486        }
487        let raw = fold_items([item]);
488        ThingsStore::from_raw_state(&raw)
489    }
490
491    fn task(uuid: &str, title: &str) -> (String, WireObject) {
492        (
493            uuid.to_string(),
494            WireObject::create(
495                EntityType::Task6,
496                TaskProps {
497                    title: title.to_string(),
498                    item_type: TaskType::Todo,
499                    status: TaskStatus::Incomplete,
500                    start_location: TaskStart::Inbox,
501                    sort_index: 0,
502                    creation_date: Some(1.0),
503                    modification_date: Some(1.0),
504                    ..Default::default()
505                },
506            ),
507        )
508    }
509
510    fn task_with(uuid: &str, title: &str, tag_ids: Vec<&str>) -> (String, WireObject) {
511        (
512            uuid.to_string(),
513            WireObject::create(
514                EntityType::Task6,
515                TaskProps {
516                    title: title.to_string(),
517                    item_type: TaskType::Todo,
518                    status: TaskStatus::Incomplete,
519                    start_location: TaskStart::Inbox,
520                    sort_index: 0,
521                    tag_ids: tag_ids.iter().map(|t| ThingsId::from(*t)).collect(),
522                    creation_date: Some(1.0),
523                    modification_date: Some(1.0),
524                    ..Default::default()
525                },
526            ),
527        )
528    }
529
530    fn project(uuid: &str, title: &str) -> (String, WireObject) {
531        (
532            uuid.to_string(),
533            WireObject::create(
534                EntityType::Task6,
535                TaskProps {
536                    title: title.to_string(),
537                    item_type: TaskType::Project,
538                    status: TaskStatus::Incomplete,
539                    start_location: TaskStart::Anytime,
540                    sort_index: 0,
541                    creation_date: Some(1.0),
542                    modification_date: Some(1.0),
543                    ..Default::default()
544                },
545            ),
546        )
547    }
548
549    fn area(uuid: &str, title: &str) -> (String, WireObject) {
550        (
551            uuid.to_string(),
552            WireObject::create(
553                EntityType::Area3,
554                AreaProps {
555                    title: title.to_string(),
556                    sort_index: 0,
557                    ..Default::default()
558                },
559            ),
560        )
561    }
562
563    fn tag(uuid: &str, title: &str) -> (String, WireObject) {
564        (
565            uuid.to_string(),
566            WireObject::create(
567                EntityType::Tag4,
568                TagProps {
569                    title: title.to_string(),
570                    sort_index: 0,
571                    ..Default::default()
572                },
573            ),
574        )
575    }
576
577    fn checklist(uuid: &str, task_uuid: &str, title: &str, ix: i32) -> (String, WireObject) {
578        (
579            uuid.to_string(),
580            WireObject::create(
581                EntityType::ChecklistItem3,
582                ChecklistItemProps {
583                    title: title.to_string(),
584                    task_ids: vec![ThingsId::from(task_uuid)],
585                    status: TaskStatus::Incomplete,
586                    sort_index: ix,
587                    creation_date: Some(1.0),
588                    modification_date: Some(1.0),
589                    ..Default::default()
590                },
591            ),
592        )
593    }
594
595    fn assert_task_update(plan: &EditPlan, uuid: &str) -> BTreeMap<String, serde_json::Value> {
596        let obj = plan.changes.get(uuid).expect("missing task change");
597        assert_eq!(obj.operation_type, OperationType::Update);
598        assert_eq!(obj.entity_type, Some(EntityType::Task6));
599        obj.properties_map()
600    }
601
602    #[test]
603    fn edit_title_and_notes_payloads() {
604        let store = build_store(vec![task(TASK_UUID, "Old title")]);
605        let args = EditArgs {
606            task_ids: vec![IdentifierToken::from(TASK_UUID)],
607            title: Some("New title".to_string()),
608            notes: Some("new notes".to_string()),
609            move_target: None,
610            tag_delta: TagDeltaArgs {
611                add_tags: None,
612                remove_tags: None,
613            },
614            add_checklist: vec![],
615            remove_checklist: None,
616            rename_checklist: vec![],
617        };
618        let mut id_gen = || "X".to_string();
619        let plan = build_edit_plan(&args, &store, NOW, &mut id_gen).expect("plan");
620        let p = assert_task_update(&plan, TASK_UUID);
621        assert_eq!(p.get("tt"), Some(&json!("New title")));
622        assert_eq!(p.get("md"), Some(&json!(NOW)));
623        assert!(p.get("nt").is_some());
624    }
625
626    #[test]
627    fn edit_move_targets_payload() {
628        let store = build_store(vec![
629            task(TASK_UUID, "Movable"),
630            project(PROJECT_UUID, "Roadmap"),
631            area(AREA_UUID, "Work"),
632        ]);
633
634        let mut id_gen = || "X".to_string();
635        let inbox = build_edit_plan(
636            &EditArgs {
637                task_ids: vec![IdentifierToken::from(TASK_UUID)],
638                title: None,
639                notes: None,
640                move_target: Some("inbox".to_string()),
641                tag_delta: TagDeltaArgs {
642                    add_tags: None,
643                    remove_tags: None,
644                },
645                add_checklist: vec![],
646                remove_checklist: None,
647                rename_checklist: vec![],
648            },
649            &store,
650            NOW,
651            &mut id_gen,
652        )
653        .expect("inbox plan");
654        let p = assert_task_update(&inbox, TASK_UUID);
655        assert_eq!(p.get("st"), Some(&json!(0)));
656        assert_eq!(p.get("pr"), Some(&json!([])));
657        assert_eq!(p.get("ar"), Some(&json!([])));
658
659        let clear = build_edit_plan(
660            &EditArgs {
661                task_ids: vec![IdentifierToken::from(TASK_UUID)],
662                title: None,
663                notes: None,
664                move_target: Some("clear".to_string()),
665                tag_delta: TagDeltaArgs {
666                    add_tags: None,
667                    remove_tags: None,
668                },
669                add_checklist: vec![],
670                remove_checklist: None,
671                rename_checklist: vec![],
672            },
673            &store,
674            NOW,
675            &mut id_gen,
676        )
677        .expect("clear plan");
678        let p = assert_task_update(&clear, TASK_UUID);
679        assert_eq!(p.get("st"), Some(&json!(1)));
680
681        let project_move = build_edit_plan(
682            &EditArgs {
683                task_ids: vec![IdentifierToken::from(TASK_UUID)],
684                title: None,
685                notes: None,
686                move_target: Some(PROJECT_UUID.to_string()),
687                tag_delta: TagDeltaArgs {
688                    add_tags: None,
689                    remove_tags: None,
690                },
691                add_checklist: vec![],
692                remove_checklist: None,
693                rename_checklist: vec![],
694            },
695            &store,
696            NOW,
697            &mut id_gen,
698        )
699        .expect("project move plan");
700        let p = assert_task_update(&project_move, TASK_UUID);
701        assert_eq!(p.get("pr"), Some(&json!([PROJECT_UUID])));
702        assert_eq!(p.get("st"), Some(&json!(1)));
703    }
704
705    #[test]
706    fn edit_multi_id_move_and_rejections() {
707        let store = build_store(vec![
708            task(TASK_UUID, "Task One"),
709            task(TASK_UUID2, "Task Two"),
710            project(PROJECT_UUID, "Roadmap"),
711        ]);
712
713        let mut id_gen = || "X".to_string();
714        let plan = build_edit_plan(
715            &EditArgs {
716                task_ids: vec![
717                    IdentifierToken::from(TASK_UUID),
718                    IdentifierToken::from(TASK_UUID2),
719                ],
720                title: None,
721                notes: None,
722                move_target: Some(PROJECT_UUID.to_string()),
723                tag_delta: TagDeltaArgs {
724                    add_tags: None,
725                    remove_tags: None,
726                },
727                add_checklist: vec![],
728                remove_checklist: None,
729                rename_checklist: vec![],
730            },
731            &store,
732            NOW,
733            &mut id_gen,
734        )
735        .expect("multi move");
736        assert_eq!(plan.changes.len(), 2);
737
738        let err = build_edit_plan(
739            &EditArgs {
740                task_ids: vec![
741                    IdentifierToken::from(TASK_UUID),
742                    IdentifierToken::from(TASK_UUID2),
743                ],
744                title: Some("New".to_string()),
745                notes: None,
746                move_target: None,
747                tag_delta: TagDeltaArgs {
748                    add_tags: None,
749                    remove_tags: None,
750                },
751                add_checklist: vec![],
752                remove_checklist: None,
753                rename_checklist: vec![],
754            },
755            &store,
756            NOW,
757            &mut id_gen,
758        )
759        .expect_err("title should reject");
760        assert_eq!(err, "--title requires a single task ID.");
761    }
762
763    #[test]
764    fn edit_tag_payloads() {
765        let tag1 = "WukwpDdL5Z88nX3okGMKTC";
766        let tag2 = "JiqwiDaS3CAyjCmHihBDnB";
767        let store = build_store(vec![
768            task_with(TASK_UUID, "A", vec![tag1]),
769            tag(tag1, "Work"),
770            tag(tag2, "Focus"),
771        ]);
772
773        let mut id_gen = || "X".to_string();
774        let plan = build_edit_plan(
775            &EditArgs {
776                task_ids: vec![IdentifierToken::from(TASK_UUID)],
777                title: None,
778                notes: None,
779                move_target: None,
780                tag_delta: TagDeltaArgs {
781                    add_tags: Some("Focus".to_string()),
782                    remove_tags: Some("Work".to_string()),
783                },
784                add_checklist: vec![],
785                remove_checklist: None,
786                rename_checklist: vec![],
787            },
788            &store,
789            NOW,
790            &mut id_gen,
791        )
792        .expect("tag plan");
793
794        let p = assert_task_update(&plan, TASK_UUID);
795        assert_eq!(p.get("tg"), Some(&json!([tag2])));
796    }
797
798    #[test]
799    fn edit_checklist_mutations() {
800        let store = build_store(vec![
801            task(TASK_UUID, "A"),
802            checklist(CHECK_A, TASK_UUID, "Step one", 1),
803            checklist(CHECK_B, TASK_UUID, "Step two", 2),
804        ]);
805
806        let mut ids = vec!["NEW_CHECK_1".to_string(), "NEW_CHECK_2".to_string()].into_iter();
807        let mut id_gen = || ids.next().expect("next id");
808        let plan = build_edit_plan(
809            &EditArgs {
810                task_ids: vec![IdentifierToken::from(TASK_UUID)],
811                title: None,
812                notes: None,
813                move_target: None,
814                tag_delta: TagDeltaArgs {
815                    add_tags: None,
816                    remove_tags: None,
817                },
818                add_checklist: vec!["Step three".to_string(), "Step four".to_string()],
819                remove_checklist: Some(format!("{},{}", &CHECK_A[..6], &CHECK_B[..6])),
820                rename_checklist: vec![format!("{}:Renamed", &CHECK_A[..6])],
821            },
822            &store,
823            NOW,
824            &mut id_gen,
825        )
826        .expect("checklist plan");
827
828        assert!(matches!(
829            plan.changes.get(CHECK_A).map(|o| o.operation_type),
830            Some(OperationType::Update)
831        ));
832        assert!(matches!(
833            plan.changes.get(CHECK_B).map(|o| o.operation_type),
834            Some(OperationType::Delete)
835        ));
836        assert!(plan.changes.contains_key("NEW_CHECK_1"));
837        assert!(plan.changes.contains_key("NEW_CHECK_2"));
838    }
839
840    #[test]
841    fn edit_no_changes_project_and_move_errors() {
842        let store = build_store(vec![task(TASK_UUID, "A")]);
843        let mut id_gen = || "X".to_string();
844        let err = build_edit_plan(
845            &EditArgs {
846                task_ids: vec![IdentifierToken::from(TASK_UUID)],
847                title: None,
848                notes: None,
849                move_target: None,
850                tag_delta: TagDeltaArgs {
851                    add_tags: None,
852                    remove_tags: None,
853                },
854                add_checklist: vec![],
855                remove_checklist: None,
856                rename_checklist: vec![],
857            },
858            &store,
859            NOW,
860            &mut id_gen,
861        )
862        .expect_err("no changes");
863        assert_eq!(err, "No edit changes requested.");
864
865        let store = build_store(vec![task(TASK_UUID, "A"), project(PROJECT_UUID, "Roadmap")]);
866        let err = build_edit_plan(
867            &EditArgs {
868                task_ids: vec![IdentifierToken::from(PROJECT_UUID)],
869                title: Some("New".to_string()),
870                notes: None,
871                move_target: None,
872                tag_delta: TagDeltaArgs {
873                    add_tags: None,
874                    remove_tags: None,
875                },
876                add_checklist: vec![],
877                remove_checklist: None,
878                rename_checklist: vec![],
879            },
880            &store,
881            NOW,
882            &mut id_gen,
883        )
884        .expect_err("project edit reject");
885        assert_eq!(err, "Use 'projects edit' to edit a project.");
886
887        let store = build_store(vec![
888            task(TASK_UUID, "Movable"),
889            task(PROJECT_UUID, "Not a project"),
890        ]);
891        let err = build_edit_plan(
892            &EditArgs {
893                task_ids: vec![IdentifierToken::from(TASK_UUID)],
894                title: None,
895                notes: None,
896                move_target: Some(PROJECT_UUID.to_string()),
897                tag_delta: TagDeltaArgs {
898                    add_tags: None,
899                    remove_tags: None,
900                },
901                add_checklist: vec![],
902                remove_checklist: None,
903                rename_checklist: vec![],
904            },
905            &store,
906            NOW,
907            &mut id_gen,
908        )
909        .expect_err("invalid move target kind");
910        assert_eq!(
911            err,
912            "--move target must be Inbox, clear, a project ID, or an area ID."
913        );
914    }
915
916    #[test]
917    fn edit_move_target_ambiguous() {
918        let ambiguous_project = "ABCD1234efgh5678JKLMno";
919        let ambiguous_area = "ABCD1234pqrs9123TUVWxy";
920        let store = build_store(vec![
921            task(TASK_UUID, "Movable"),
922            project(ambiguous_project, "Project match"),
923            area(ambiguous_area, "Area match"),
924        ]);
925        let mut id_gen = || "X".to_string();
926        let err = build_edit_plan(
927            &EditArgs {
928                task_ids: vec![IdentifierToken::from(TASK_UUID)],
929                title: None,
930                notes: None,
931                move_target: Some("ABCD1234".to_string()),
932                tag_delta: TagDeltaArgs {
933                    add_tags: None,
934                    remove_tags: None,
935                },
936                add_checklist: vec![],
937                remove_checklist: None,
938                rename_checklist: vec![],
939            },
940            &store,
941            NOW,
942            &mut id_gen,
943        )
944        .expect_err("ambiguous move target");
945        assert_eq!(
946            err,
947            "Ambiguous --move target 'ABCD1234' (matches project and area)."
948        );
949    }
950
951    #[test]
952    fn checklist_single_task_constraint_and_empty_title() {
953        let store = build_store(vec![task(TASK_UUID, "A"), task(TASK_UUID2, "B")]);
954        let mut id_gen = || "X".to_string();
955
956        let err = build_edit_plan(
957            &EditArgs {
958                task_ids: vec![
959                    IdentifierToken::from(TASK_UUID),
960                    IdentifierToken::from(TASK_UUID2),
961                ],
962                title: None,
963                notes: None,
964                move_target: None,
965                tag_delta: TagDeltaArgs {
966                    add_tags: None,
967                    remove_tags: None,
968                },
969                add_checklist: vec!["Step".to_string()],
970                remove_checklist: None,
971                rename_checklist: vec![],
972            },
973            &store,
974            NOW,
975            &mut id_gen,
976        )
977        .expect_err("single task constraint");
978        assert_eq!(
979            err,
980            "--add-checklist/--remove-checklist/--rename-checklist require a single task ID."
981        );
982
983        let store = build_store(vec![task(TASK_UUID, "A")]);
984        let err = build_edit_plan(
985            &EditArgs {
986                task_ids: vec![IdentifierToken::from(TASK_UUID)],
987                title: Some("   ".to_string()),
988                notes: None,
989                move_target: None,
990                tag_delta: TagDeltaArgs {
991                    add_tags: None,
992                    remove_tags: None,
993                },
994                add_checklist: vec![],
995                remove_checklist: None,
996                rename_checklist: vec![],
997            },
998            &store,
999            NOW,
1000            &mut id_gen,
1001        )
1002        .expect_err("empty title");
1003        assert_eq!(err, "Task title cannot be empty.");
1004    }
1005
1006    #[test]
1007    fn checklist_patch_has_expected_fields() {
1008        let patch = ChecklistItemPatch {
1009            title: Some("Step".to_string()),
1010            status: Some(TaskStatus::Incomplete),
1011            task_ids: Some(vec![crate::ids::ThingsId::from(TASK_UUID)]),
1012            sort_index: Some(3),
1013            creation_date: Some(NOW),
1014            modification_date: Some(NOW),
1015        };
1016        let props = patch.into_properties();
1017        assert_eq!(props.get("tt"), Some(&json!("Step")));
1018        assert_eq!(props.get("ss"), Some(&json!(0)));
1019        assert_eq!(props.get("ix"), Some(&json!(3)));
1020    }
1021}