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