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}