Skip to main content

things3_cloud/commands/
mark.rs

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