Skip to main content

things3_cloud/commands/
mark.rs

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    /// Task UUID(s) (or unique UUID prefixes)
24    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}