Skip to main content

things3_cloud/commands/
edit.rs

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