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