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