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