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}