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