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, 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}