1use std::collections::{BTreeMap, HashSet};
2
3use anyhow::Result;
4use clap::{ArgGroup, Args};
5
6use crate::{
7 app::Cli,
8 arg_types::IdentifierToken,
9 commands::Command,
10 common::{DIM, GREEN, ICONS, colored},
11 wire::{
12 checklist::ChecklistItemPatch,
13 recurrence::RecurrenceType,
14 task::{TaskPatch, TaskStatus},
15 wire_object::{EntityType, WireObject},
16 },
17};
18
19#[derive(Debug, Args)]
20#[command(about = "Mark a task done, incomplete, or canceled")]
21#[command(group(ArgGroup::new("status").args(["done", "incomplete", "canceled", "check_ids", "uncheck_ids", "check_cancel_ids"]).required(true).multiple(false)))]
22pub struct MarkArgs {
23 pub task_ids: Vec<IdentifierToken>,
25 #[arg(long, short = 'd', help = "Mark task(s) as completed")]
26 pub done: bool,
27 #[arg(long, short = 'i', help = "Mark task(s) as incomplete")]
28 pub incomplete: bool,
29 #[arg(long, short = 'c', help = "Mark task(s) as canceled")]
30 pub canceled: bool,
31 #[arg(
32 long = "check",
33 short = 'k',
34 help = "Mark checklist items done by comma-separated short IDs"
35 )]
36 pub check_ids: Option<String>,
37 #[arg(
38 long = "uncheck",
39 short = 'u',
40 help = "Mark checklist items incomplete by comma-separated short IDs"
41 )]
42 pub uncheck_ids: Option<String>,
43 #[arg(
44 long = "check-cancel",
45 short = 'x',
46 help = "Mark checklist items canceled by comma-separated short IDs"
47 )]
48 pub check_cancel_ids: Option<String>,
49}
50
51fn resolve_checklist_items(
52 task: &crate::store::Task,
53 raw_ids: &str,
54) -> (Vec<crate::store::ChecklistItem>, String) {
55 let tokens = raw_ids
56 .split(',')
57 .map(str::trim)
58 .filter(|t| !t.is_empty())
59 .collect::<Vec<_>>();
60 if tokens.is_empty() {
61 return (Vec::new(), "No checklist item IDs provided.".to_string());
62 }
63
64 let mut resolved = Vec::new();
65 let mut seen = HashSet::new();
66 for token in tokens {
67 let matches = task
68 .checklist_items
69 .iter()
70 .filter(|item| item.uuid.starts_with(token))
71 .cloned()
72 .collect::<Vec<_>>();
73 if matches.is_empty() {
74 return (Vec::new(), format!("Checklist item not found: '{token}'"));
75 }
76 if matches.len() > 1 {
77 return (
78 Vec::new(),
79 format!("Ambiguous checklist item prefix: '{token}'"),
80 );
81 }
82 let item = matches[0].clone();
83 if seen.insert(item.uuid.clone()) {
84 resolved.push(item);
85 }
86 }
87
88 (resolved, String::new())
89}
90
91fn validate_recurring_done(
92 task: &crate::store::Task,
93 store: &crate::store::ThingsStore,
94) -> (bool, String) {
95 if task.is_recurrence_template() {
96 return (
97 false,
98 "Recurring template tasks are blocked for done (template progression bookkeeping is not implemented).".to_string(),
99 );
100 }
101
102 if !task.is_recurrence_instance() {
103 return (
104 false,
105 "Recurring task shape is unsupported (expected an instance with rt set and rr unset)."
106 .to_string(),
107 );
108 }
109
110 if task.recurrence_templates.len() != 1 {
111 return (
112 false,
113 format!(
114 "Recurring instance has {} template references; expected exactly 1.",
115 task.recurrence_templates.len()
116 ),
117 );
118 }
119
120 let template_uuid = &task.recurrence_templates[0];
121 let Some(template) = store.get_task(&template_uuid.to_string()) else {
122 return (
123 false,
124 format!(
125 "Recurring instance template {} is missing from current state.",
126 template_uuid
127 ),
128 );
129 };
130
131 let Some(rr) = template.recurrence_rule else {
132 return (
133 false,
134 "Recurring instance template has unsupported recurrence rule shape (expected dict)."
135 .to_string(),
136 );
137 };
138
139 match rr.repeat_type {
140 RecurrenceType::FixedSchedule => (true, String::new()),
141 RecurrenceType::AfterCompletion => (
142 false,
143 "Recurring 'after completion' templates (rr.tp=1) are blocked: completion requires coupled template writes (acrd/tir) not implemented yet.".to_string(),
144 ),
145 RecurrenceType::Unknown(v) => (
146 false,
147 format!("Recurring template type rr.tp={v:?} is unsupported for safe completion."),
148 ),
149 }
150}
151
152fn validate_mark_target(
153 task: &crate::store::Task,
154 action: &str,
155 store: &crate::store::ThingsStore,
156) -> String {
157 if task.entity != "Task6" {
158 return "Only Task6 tasks are supported by mark right now.".to_string();
159 }
160 if task.is_heading() {
161 return "Headings cannot be marked.".to_string();
162 }
163 if task.trashed {
164 return "Task is in Trash and cannot be completed.".to_string();
165 }
166 if action == "done" && task.status == TaskStatus::Completed {
167 return "Task is already completed.".to_string();
168 }
169 if action == "incomplete" && task.status == TaskStatus::Incomplete {
170 return "Task is already incomplete/open.".to_string();
171 }
172 if action == "canceled" && task.status == TaskStatus::Canceled {
173 return "Task is already canceled.".to_string();
174 }
175 if action == "done" && (task.is_recurrence_instance() || task.is_recurrence_template()) {
176 let (ok, reason) = validate_recurring_done(task, store);
177 if !ok {
178 return reason;
179 }
180 }
181 String::new()
182}
183
184#[derive(Debug, Clone)]
185struct MarkCommitPlan {
186 changes: BTreeMap<String, WireObject>,
187}
188
189fn build_mark_status_plan(
190 args: &MarkArgs,
191 store: &crate::store::ThingsStore,
192 now: f64,
193) -> (MarkCommitPlan, Vec<crate::store::Task>, Vec<String>) {
194 let action = if args.done {
195 "done"
196 } else if args.incomplete {
197 "incomplete"
198 } else {
199 "canceled"
200 };
201
202 let mut targets = Vec::new();
203 let mut seen = HashSet::new();
204 for identifier in &args.task_ids {
205 let (task_opt, err, _) = store.resolve_mark_identifier(identifier.as_str());
206 let Some(task) = task_opt else {
207 eprintln!("{err}");
208 continue;
209 };
210 if !seen.insert(task.uuid.clone()) {
211 continue;
212 }
213 targets.push(task);
214 }
215
216 let mut updates = Vec::new();
217 let mut successes = Vec::new();
218 let mut errors = Vec::new();
219
220 for task in targets {
221 let validation_error = validate_mark_target(&task, action, store);
222 if !validation_error.is_empty() {
223 errors.push(format!("{} ({})", validation_error, task.title));
224 continue;
225 }
226
227 let (task_status, stop_date) = if action == "done" {
228 (TaskStatus::Completed, Some(now))
229 } else if action == "incomplete" {
230 (TaskStatus::Incomplete, None)
231 } else {
232 (TaskStatus::Canceled, Some(now))
233 };
234
235 updates.push((
236 task.uuid.clone(),
237 task_status,
238 task.entity.clone(),
239 stop_date,
240 ));
241 successes.push(task);
242 }
243
244 let mut changes = BTreeMap::new();
245 for (uuid, status, entity, stop_date) in updates {
246 changes.insert(
247 uuid.to_string(),
248 WireObject::update(
249 EntityType::from(entity),
250 TaskPatch {
251 status: Some(status),
252 stop_date: Some(stop_date),
253 modification_date: Some(now),
254 ..Default::default()
255 },
256 ),
257 );
258 }
259
260 (MarkCommitPlan { changes }, successes, errors)
261}
262
263fn build_mark_checklist_plan(
264 args: &MarkArgs,
265 task: &crate::store::Task,
266 checklist_raw: &str,
267 now: f64,
268) -> std::result::Result<(MarkCommitPlan, Vec<crate::store::ChecklistItem>, String), String> {
269 let (items, err) = resolve_checklist_items(task, checklist_raw);
270 if !err.is_empty() {
271 return Err(err);
272 }
273
274 let (label, status): (&str, TaskStatus) = if args.check_ids.is_some() {
275 ("checked", TaskStatus::Completed)
276 } else if args.uncheck_ids.is_some() {
277 ("unchecked", TaskStatus::Incomplete)
278 } else {
279 ("canceled", TaskStatus::Canceled)
280 };
281
282 let mut changes = BTreeMap::new();
283 for item in &items {
284 changes.insert(
285 item.uuid.to_string(),
286 WireObject::update(
287 EntityType::ChecklistItem3,
288 ChecklistItemPatch {
289 status: Some(status),
290 modification_date: Some(now),
291 ..Default::default()
292 },
293 ),
294 );
295 }
296
297 Ok((MarkCommitPlan { changes }, items, label.to_string()))
298}
299
300impl Command for MarkArgs {
301 fn run_with_ctx(
302 &self,
303 cli: &Cli,
304 out: &mut dyn std::io::Write,
305 ctx: &mut dyn crate::cmd_ctx::CmdCtx,
306 ) -> Result<()> {
307 let store = cli.load_store()?;
308 let checklist_raw = self
309 .check_ids
310 .as_ref()
311 .or(self.uncheck_ids.as_ref())
312 .or(self.check_cancel_ids.as_ref());
313
314 if let Some(checklist_raw) = checklist_raw {
315 if self.task_ids.len() != 1 {
316 eprintln!(
317 "Checklist flags (--check, --uncheck, --check-cancel) require exactly one task ID."
318 );
319 return Ok(());
320 }
321
322 let (task_opt, err, _) = store.resolve_mark_identifier(self.task_ids[0].as_str());
323 let Some(task) = task_opt else {
324 eprintln!("{err}");
325 return Ok(());
326 };
327
328 if task.checklist_items.is_empty() {
329 eprintln!("Task has no checklist items: {}", task.title);
330 return Ok(());
331 }
332
333 let (plan, items, label) =
334 match build_mark_checklist_plan(self, &task, checklist_raw, ctx.now_timestamp()) {
335 Ok(v) => v,
336 Err(err) => {
337 eprintln!("{err}");
338 return Ok(());
339 }
340 };
341
342 if let Err(e) = ctx.commit_changes(plan.changes, None) {
343 eprintln!("Failed to mark checklist items: {e}");
344 return Ok(());
345 }
346
347 let title = match label.as_str() {
348 "checked" => format!("{} Checked", ICONS.checklist_done),
349 "unchecked" => format!("{} Unchecked", ICONS.checklist_open),
350 _ => format!("{} Canceled", ICONS.checklist_canceled),
351 };
352
353 for item in items {
354 writeln!(
355 out,
356 "{} {} {}",
357 colored(&title, &[GREEN], cli.no_color),
358 item.title,
359 colored(&item.uuid, &[DIM], cli.no_color)
360 )?;
361 }
362 return Ok(());
363 }
364
365 let action = if self.done {
366 "done"
367 } else if self.incomplete {
368 "incomplete"
369 } else {
370 "canceled"
371 };
372
373 let (plan, successes, errors) = build_mark_status_plan(self, &store, ctx.now_timestamp());
374 for err in errors {
375 eprintln!("{err}");
376 }
377
378 if plan.changes.is_empty() {
379 return Ok(());
380 }
381
382 if let Err(e) = ctx.commit_changes(plan.changes, None) {
383 eprintln!("Failed to mark items {}: {}", action, e);
384 return Ok(());
385 }
386
387 let label = match action {
388 "done" => format!("{} Done", ICONS.done),
389 "incomplete" => format!("{} Incomplete", ICONS.incomplete),
390 _ => format!("{} Canceled", ICONS.canceled),
391 };
392 for task in successes {
393 writeln!(
394 out,
395 "{} {} {}",
396 colored(&label, &[GREEN], cli.no_color),
397 task.title,
398 colored(&task.uuid, &[DIM], cli.no_color)
399 )?;
400 }
401
402 Ok(())
403 }
404}
405
406#[cfg(test)]
407mod tests {
408 use serde_json::json;
409
410 use super::*;
411 use crate::{
412 ids::ThingsId,
413 store::{ThingsStore, fold_items},
414 wire::{
415 checklist::ChecklistItemProps,
416 recurrence::{RecurrenceRule, RecurrenceType},
417 task::{TaskProps, TaskStart, TaskStatus, TaskType},
418 },
419 };
420
421 const NOW: f64 = 1_700_000_111.0;
422 const TASK_A: &str = "A7h5eCi24RvAWKC3Hv3muf";
423 const CHECK_A: &str = "MpkEei6ybkFS2n6SXvwfLf";
424 const CHECK_B: &str = "JFdhhhp37fpryAKu8UXwzK";
425 const TPL_A: &str = "MpkEei6ybkFS2n6SXvwfLf";
426 const TPL_B: &str = "JFdhhhp37fpryAKu8UXwzK";
427
428 fn build_store(entries: Vec<(String, WireObject)>) -> ThingsStore {
429 let mut item = BTreeMap::new();
430 for (uuid, obj) in entries {
431 item.insert(uuid, obj);
432 }
433 ThingsStore::from_raw_state(&fold_items([item]))
434 }
435
436 fn task(uuid: &str, title: &str, status: i32) -> (String, WireObject) {
437 (
438 uuid.to_string(),
439 WireObject::create(
440 EntityType::Task6,
441 TaskProps {
442 title: title.to_string(),
443 item_type: TaskType::Todo,
444 status: TaskStatus::from(status),
445 start_location: TaskStart::Inbox,
446 sort_index: 0,
447 creation_date: Some(1.0),
448 modification_date: Some(1.0),
449 ..Default::default()
450 },
451 ),
452 )
453 }
454
455 fn task_with_props(
456 uuid: &str,
457 title: &str,
458 recurrence_rule: Option<RecurrenceRule>,
459 recurrence_templates: Vec<&str>,
460 ) -> (String, WireObject) {
461 (
462 uuid.to_string(),
463 WireObject::create(
464 EntityType::Task6,
465 TaskProps {
466 title: title.to_string(),
467 item_type: TaskType::Todo,
468 status: TaskStatus::Incomplete,
469 start_location: TaskStart::Inbox,
470 sort_index: 0,
471 recurrence_rule,
472 recurrence_template_ids: recurrence_templates
473 .iter()
474 .map(|t| {
475 t.parse::<ThingsId>()
476 .expect("test recurrence template id should parse")
477 })
478 .collect(),
479 creation_date: Some(1.0),
480 modification_date: Some(1.0),
481 ..Default::default()
482 },
483 ),
484 )
485 }
486
487 fn checklist(uuid: &str, task_uuid: &str, title: &str, ix: i32) -> (String, WireObject) {
488 (
489 uuid.to_string(),
490 WireObject::create(
491 EntityType::ChecklistItem3,
492 ChecklistItemProps {
493 title: title.to_string(),
494 task_ids: vec![
495 task_uuid
496 .parse::<ThingsId>()
497 .expect("test task id should parse as ThingsId"),
498 ],
499 status: TaskStatus::Incomplete,
500 sort_index: ix,
501 creation_date: Some(1.0),
502 modification_date: Some(1.0),
503 ..Default::default()
504 },
505 ),
506 )
507 }
508
509 #[test]
510 fn mark_status_payloads() {
511 let done_store = build_store(vec![task(TASK_A, "Alpha", 0)]);
512 let (done_plan, _, errs) = build_mark_status_plan(
513 &MarkArgs {
514 task_ids: vec![IdentifierToken::from(TASK_A)],
515 done: true,
516 incomplete: false,
517 canceled: false,
518 check_ids: None,
519 uncheck_ids: None,
520 check_cancel_ids: None,
521 },
522 &done_store,
523 NOW,
524 );
525 assert!(errs.is_empty());
526 assert_eq!(
527 serde_json::to_value(done_plan.changes).expect("to value"),
528 json!({ TASK_A: {"t":1,"e":"Task6","p":{"ss":3,"sp":NOW,"md":NOW}} })
529 );
530
531 let incomplete_store = build_store(vec![task(TASK_A, "Alpha", 3)]);
532 let (incomplete_plan, _, _) = build_mark_status_plan(
533 &MarkArgs {
534 task_ids: vec![IdentifierToken::from(TASK_A)],
535 done: false,
536 incomplete: true,
537 canceled: false,
538 check_ids: None,
539 uncheck_ids: None,
540 check_cancel_ids: None,
541 },
542 &incomplete_store,
543 NOW,
544 );
545 assert_eq!(
546 serde_json::to_value(incomplete_plan.changes).expect("to value"),
547 json!({ TASK_A: {"t":1,"e":"Task6","p":{"ss":0,"sp":null,"md":NOW}} })
548 );
549 }
550
551 #[test]
552 fn mark_checklist_payloads() {
553 let store = build_store(vec![
554 task(TASK_A, "Task with checklist", 0),
555 checklist(CHECK_A, TASK_A, "One", 1),
556 checklist(CHECK_B, TASK_A, "Two", 2),
557 ]);
558 let task = store.get_task(TASK_A).expect("task");
559
560 let (checked_plan, _, _) = build_mark_checklist_plan(
561 &MarkArgs {
562 task_ids: vec![IdentifierToken::from(TASK_A)],
563 done: false,
564 incomplete: false,
565 canceled: false,
566 check_ids: Some(format!("{},{}", &CHECK_A[..6], &CHECK_B[..6])),
567 uncheck_ids: None,
568 check_cancel_ids: None,
569 },
570 &task,
571 &format!("{},{}", &CHECK_A[..6], &CHECK_B[..6]),
572 NOW,
573 )
574 .expect("checked plan");
575 assert_eq!(
576 serde_json::to_value(checked_plan.changes).expect("to value"),
577 json!({
578 CHECK_A: {"t":1,"e":"ChecklistItem3","p":{"ss":3,"md":NOW}},
579 CHECK_B: {"t":1,"e":"ChecklistItem3","p":{"ss":3,"md":NOW}}
580 })
581 );
582 }
583
584 #[test]
585 fn mark_recurring_rejection_cases() {
586 let store = build_store(vec![task_with_props(
587 TASK_A,
588 "Recurring template",
589 Some(RecurrenceRule {
590 repeat_type: RecurrenceType::FixedSchedule,
591 ..Default::default()
592 }),
593 vec![],
594 )]);
595 let (plan, _, errs) = build_mark_status_plan(
596 &MarkArgs {
597 task_ids: vec![IdentifierToken::from(TASK_A)],
598 done: true,
599 incomplete: false,
600 canceled: false,
601 check_ids: None,
602 uncheck_ids: None,
603 check_cancel_ids: None,
604 },
605 &store,
606 NOW,
607 );
608 assert!(plan.changes.is_empty());
609 assert_eq!(
610 errs,
611 vec![
612 "Recurring template tasks are blocked for done (template progression bookkeeping is not implemented). (Recurring template)"
613 ]
614 );
615
616 let store = build_store(vec![task_with_props(
617 TASK_A,
618 "Recurring instance",
619 None,
620 vec![TPL_A, TPL_B],
621 )]);
622 let (_, _, errs) = build_mark_status_plan(
623 &MarkArgs {
624 task_ids: vec![IdentifierToken::from(TASK_A)],
625 done: true,
626 incomplete: false,
627 canceled: false,
628 check_ids: None,
629 uncheck_ids: None,
630 check_cancel_ids: None,
631 },
632 &store,
633 NOW,
634 );
635 assert_eq!(
636 errs,
637 vec![
638 "Recurring instance has 2 template references; expected exactly 1. (Recurring instance)"
639 ]
640 );
641 }
642}