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