Skip to main content

mach/
cli.rs

1//! Command-line front end for humans and agents.
2//!
3//! Every invocation owns one [`Store`]. Read commands use one snapshot and
4//! mutations execute one fresh read-modify-write transaction. JSON mode emits
5//! exactly one document on stdout, including usage and runtime errors.
6
7use std::collections::{HashMap, HashSet};
8use std::ffi::OsString;
9use std::io::{self, Write};
10use std::path::PathBuf;
11
12use chrono::Utc;
13use clap::{Parser, Subcommand, ValueEnum, builder::PossibleValue};
14use serde_json::{Value, json};
15
16use crate::VERSION;
17use crate::model::{Block, Category, Label, LabelColor, Task};
18use crate::store::{
19    CategoryPatch, LabelPatch, PurgeScope, RelativePosition, Store, StoreData, StoreError,
20    TaskPatch,
21};
22
23/// Full CLI reference under `mach --help`.
24const HELP: &str = "\
25  list
26    -c, --category NAME  only this category
27    --label NAME         require label (repeatable; all must match)
28    --open               only incomplete
29    --done               only completed
30
31  categories
32    (no args)            list categories (done/total)
33    add NAME
34      -d, --description TEXT
35    edit NAME            rename / set description
36      -n, --name NEW
37      -d, --description TEXT
38      --clear-description
39    delete NAME          delete category; tasks become uncategorized
40
41  labels
42    (no args)            list labels (done/total)
43    add NAME [--color COLOR]
44    edit NAME [--name NEW] [--color COLOR]
45                         edit label; assignments stay attached
46    delete NAME          delete label; tasks stay in place
47
48  Label colors: red, orange, yellow, lime, green, teal, cyan, blue, indigo, purple, pink, brown
49
50  add [TITLE]
51    -t, --title TITLE    title (required if no positional TITLE)
52    -d, --description TEXT  description (newlines = lines; see DESCRIPTION MARKUP)
53    --due DATE            YYYY-MM-DD | MM-DD | HH:MM | DATEThh:mm
54    --time HH:MM         with --due, or alone = next occurrence
55    -c, --category NAME  category (name or unique prefix)
56    --label NAME         existing label (repeatable)
57    -i, --importance N   0–3 (default 0)
58    --subtask TEXT       add subtask (repeatable)
59
60  show ID                ID = uuid or unique prefix
61
62  done ID
63  undone ID
64
65  delete ID
66
67  move ID (--before | --after) TARGET
68    reorder within the task's current category
69
70  purge --done
71    -c, --category NAME  only completed tasks in this category
72
73  edit ID                only given flags change
74    -t, --title TITLE
75    -d, --description TEXT  replace entire description (DESCRIPTION MARKUP; wipes old description)
76    --due DATE            date-only keeps existing time
77    --time HH:MM         keeps existing date if no --due
78    --clear-due          remove due date/time
79    -c, --category NAME
80    --clear-category     uncategorized
81    --add-label NAME     assign existing label (repeatable)
82    --remove-label NAME  unassign label (repeatable)
83    --clear-labels       remove all labels; may combine with --add-label
84    -i, --importance N   0–3
85
86  DESCRIPTION MARKUP (add/edit --description, one block per line)
87    plain text
88    [ ] item / [x] item  subtask
89    - item / • item      bullet
90    1. item              numbered (any leading N.)
91    https://…            link
92    [image:PATH]         import an absolute path, or one relative to images/
93
94  subtasks TASK
95    (no subcommand)      list subtasks
96    add [TEXT]
97      -t, --text TEXT
98      --done             create already checked
99    done INDEX           INDEX = 1-based among checkboxes only
100    undone INDEX
101    toggle INDEX
102    edit INDEX [TEXT]
103      -t, --text TEXT
104    delete INDEX         later indexes shift down
105
106  export [FILE]          portable .mach archive (tasks, categories, labels, images)
107                         default: ./mach-export-YYYYMMDD-HHMMSS.mach
108
109  import FILE            safely merge a .mach archive
110                         identical records are skipped; conflicts abort
111
112  update                 check GitHub for a newer release
113    --install            verify SHA-256 and install release binary to ~/.local/bin
114
115  (no command)           open TUI
116  --json                 exactly one JSON document on stdout
117  --dir PATH             data directory (global)
118
119Data: --dir PATH  >  $MACH_DIR  >  ~/.mach
120";
121
122#[derive(Parser)]
123#[command(
124    name = "mach",
125    about = concat!("mach v", env!("CARGO_PKG_VERSION")),
126    disable_version_flag = true,
127    color = clap::ColorChoice::Never,
128    after_help = HELP,
129)]
130struct Cli {
131    /// Show version
132    #[arg(short = 'v', long = "version")]
133    version: bool,
134
135    /// Data directory (default ~/.mach; overrides $MACH_DIR)
136    #[arg(long = "dir", value_name = "PATH", global = true)]
137    dir: Option<PathBuf>,
138
139    /// JSON stdout
140    #[arg(long, global = true)]
141    json: bool,
142
143    #[command(subcommand)]
144    command: Option<Command>,
145}
146
147#[derive(Subcommand)]
148enum Command {
149    /// List tasks
150    List {
151        /// Category name / prefix
152        #[arg(short = 'c', long = "category", value_name = "NAME")]
153        category: Option<String>,
154        /// Require label (repeatable; all must match)
155        #[arg(long = "label", value_name = "NAME")]
156        labels: Vec<String>,
157        /// Incomplete only
158        #[arg(long, conflicts_with = "done")]
159        open: bool,
160        /// Done only
161        #[arg(long)]
162        done: bool,
163    },
164    /// List / add / edit / delete categories
165    Categories {
166        #[command(subcommand)]
167        action: Option<CatAction>,
168    },
169    /// List / add / edit / delete labels
170    Labels {
171        #[command(subcommand)]
172        action: Option<LabelAction>,
173    },
174    /// Add a task
175    Add(AddArgs),
176    /// Show task
177    Show {
178        /// Task id / prefix
179        id: String,
180    },
181    /// Mark task done
182    Done {
183        /// Task id / prefix
184        id: String,
185    },
186    /// Mark task not done
187    Undone {
188        /// Task id / prefix
189        id: String,
190    },
191    /// Delete task
192    Delete {
193        /// Task id / prefix
194        id: String,
195    },
196    /// Reorder a task within its category
197    Move {
198        /// Task id / prefix
199        id: String,
200        /// Place before this task id / prefix
201        #[arg(
202            long,
203            value_name = "TARGET",
204            conflicts_with = "after",
205            required_unless_present = "after"
206        )]
207        before: Option<String>,
208        /// Place after this task id / prefix
209        #[arg(
210            long,
211            value_name = "TARGET",
212            conflicts_with = "before",
213            required_unless_present = "before"
214        )]
215        after: Option<String>,
216    },
217    /// Permanently remove completed tasks
218    Purge {
219        /// Required safety interlock: purge completed tasks only
220        #[arg(long, required = true)]
221        done: bool,
222        /// Limit to one category
223        #[arg(short = 'c', long = "category", value_name = "NAME")]
224        category: Option<String>,
225    },
226    /// Edit task fields
227    Edit(EditArgs),
228    /// Subtasks on a task
229    Subtasks {
230        /// Parent task id / prefix
231        task: String,
232        #[command(subcommand)]
233        action: Option<SubAction>,
234    },
235    /// Export tasks, categories, labels, and images to a portable archive
236    Export {
237        /// Output file (default ./mach-export-YYYYMMDD-HHMMSS.mach)
238        #[arg(value_name = "FILE")]
239        file: Option<PathBuf>,
240    },
241    /// Safely merge a portable archive
242    Import {
243        /// Archive file
244        #[arg(value_name = "FILE")]
245        file: PathBuf,
246    },
247    /// Check GitHub for a newer release (optional install)
248    Update {
249        /// Verify SHA-256 and install the release binary to ~/.local/bin
250        #[arg(long)]
251        install: bool,
252    },
253}
254
255#[derive(clap::Args)]
256struct AddArgs {
257    /// Title (or use --title)
258    #[arg(value_name = "TITLE", conflicts_with = "title")]
259    title_pos: Option<String>,
260    /// Title
261    #[arg(short = 't', long = "title")]
262    title: Option<String>,
263    /// Description text (newlines → lines)
264    #[arg(short = 'd', long = "description")]
265    description: Option<String>,
266    /// Due date
267    #[arg(long = "due", value_name = "DATE")]
268    due: Option<String>,
269    /// Due time HH:MM (with --due, or alone = next occurrence)
270    #[arg(long = "time", value_name = "HH:MM")]
271    time: Option<String>,
272    /// Category
273    #[arg(short = 'c', long = "category", value_name = "NAME")]
274    category: Option<String>,
275    /// Existing label (repeatable)
276    #[arg(long = "label", value_name = "NAME")]
277    labels: Vec<String>,
278    /// Importance 0–3
279    #[arg(
280        short = 'i',
281        long = "importance",
282        value_name = "N",
283        default_value_t = 0
284    )]
285    importance: u8,
286    /// Subtask (repeatable)
287    #[arg(long = "subtask", value_name = "TEXT")]
288    subtasks: Vec<String>,
289}
290
291#[derive(clap::Args)]
292struct EditArgs {
293    /// Task id / prefix
294    id: String,
295    /// New title
296    #[arg(short = 't', long = "title")]
297    title: Option<String>,
298    /// Replace description
299    #[arg(short = 'd', long = "description")]
300    description: Option<String>,
301    /// Due date
302    #[arg(long = "due", value_name = "DATE")]
303    due: Option<String>,
304    /// Due time HH:MM
305    #[arg(long = "time", value_name = "HH:MM")]
306    time: Option<String>,
307    /// Clear due
308    #[arg(long)]
309    clear_due: bool,
310    /// Set category
311    #[arg(short = 'c', long = "category", value_name = "NAME")]
312    category: Option<String>,
313    /// Uncategorized
314    #[arg(long = "clear-category")]
315    clear_cat: bool,
316    /// Assign existing label (repeatable)
317    #[arg(long = "add-label", value_name = "NAME")]
318    add_labels: Vec<String>,
319    /// Unassign label (repeatable)
320    #[arg(long = "remove-label", value_name = "NAME")]
321    remove_labels: Vec<String>,
322    /// Remove all labels; may be combined with --add-label
323    #[arg(long = "clear-labels")]
324    clear_labels: bool,
325    /// Importance 0–3
326    #[arg(short = 'i', long = "importance", value_name = "N")]
327    importance: Option<u8>,
328}
329
330#[derive(Subcommand)]
331enum CatAction {
332    /// List categories (default)
333    List,
334    /// Create category
335    Add {
336        /// Name
337        name: String,
338        /// Description
339        #[arg(short = 'd', long = "description")]
340        description: Option<String>,
341    },
342    /// Rename / set description
343    Edit {
344        /// Current name / prefix
345        name: String,
346        /// New name
347        #[arg(short = 'n', long = "name", value_name = "NEW")]
348        new_name: Option<String>,
349        /// Description
350        #[arg(short = 'd', long = "description")]
351        description: Option<String>,
352        /// Clear description
353        #[arg(long = "clear-description")]
354        clear_description: bool,
355    },
356    /// Delete category (tasks become uncategorized)
357    Delete {
358        /// Name / prefix
359        name: String,
360    },
361}
362
363#[derive(Subcommand)]
364enum LabelAction {
365    /// List labels (default)
366    List,
367    /// Create label
368    Add {
369        /// Name
370        name: String,
371        /// Logical color (automatically balanced when omitted)
372        #[arg(long, value_enum)]
373        color: Option<LabelColor>,
374    },
375    /// Edit label name or color
376    Edit {
377        /// Current name / prefix
378        name: String,
379        /// New name
380        #[arg(
381            short = 'n',
382            long = "name",
383            value_name = "NEW",
384            required_unless_present = "color"
385        )]
386        new_name: Option<String>,
387        /// Logical color
388        #[arg(long, value_enum)]
389        color: Option<LabelColor>,
390    },
391    /// Delete label (tasks remain in place)
392    Delete {
393        /// Name / prefix
394        name: String,
395    },
396}
397
398impl ValueEnum for LabelColor {
399    fn value_variants<'a>() -> &'a [Self] {
400        &Self::SWATCHES
401    }
402
403    fn to_possible_value(&self) -> Option<PossibleValue> {
404        Some(PossibleValue::new(self.as_str()))
405    }
406}
407
408#[derive(Subcommand)]
409enum SubAction {
410    /// List subtasks (default)
411    List,
412    /// Add subtask
413    Add {
414        /// Text (or --text)
415        #[arg(value_name = "TEXT", conflicts_with = "text")]
416        text_pos: Option<String>,
417        /// Text
418        #[arg(short = 't', long = "text")]
419        text: Option<String>,
420        /// Start done
421        #[arg(long)]
422        done: bool,
423    },
424    /// Mark subtask done
425    Done {
426        /// 1-based index
427        index: usize,
428    },
429    /// Mark subtask not done
430    Undone {
431        /// 1-based index
432        index: usize,
433    },
434    /// Toggle subtask
435    Toggle {
436        /// 1-based index
437        index: usize,
438    },
439    /// Edit subtask text
440    Edit {
441        /// 1-based index
442        index: usize,
443        /// Text (or --text)
444        #[arg(value_name = "TEXT", conflicts_with = "text")]
445        text_pos: Option<String>,
446        /// Text
447        #[arg(short = 't', long = "text")]
448        text: Option<String>,
449    },
450    /// Delete subtask
451    Delete {
452        /// 1-based index
453        index: usize,
454    },
455}
456
457#[derive(Debug)]
458struct CliError {
459    kind: &'static str,
460    message: String,
461}
462
463impl CliError {
464    fn validation(message: impl Into<String>) -> Self {
465        Self {
466            kind: "validation",
467            message: message.into(),
468        }
469    }
470
471    fn update(message: impl Into<String>) -> Self {
472        Self {
473            kind: "update",
474            message: message.into(),
475        }
476    }
477}
478
479impl From<StoreError> for CliError {
480    fn from(error: StoreError) -> Self {
481        let kind = match &error {
482            StoreError::Io { .. } => "io",
483            StoreError::Json { .. } => "legacy_json",
484            StoreError::Database(_) => "database",
485            StoreError::UnsupportedLegacySchema { .. }
486            | StoreError::UnsupportedDatabaseSchema { .. } => "schema",
487            StoreError::Conflict { .. } | StoreError::StaleEntity { .. } => "conflict",
488            StoreError::NotFound { .. } => "not_found",
489            StoreError::Ambiguous { .. } => "ambiguous",
490            StoreError::Validation(_) => "validation",
491            StoreError::Corrupt(_) => "corrupt",
492        };
493        Self {
494            kind,
495            message: error.to_string(),
496        }
497    }
498}
499
500impl From<crate::archive::ArchiveError> for CliError {
501    fn from(error: crate::archive::ArchiveError) -> Self {
502        Self {
503            kind: error.kind(),
504            message: error.to_string(),
505        }
506    }
507}
508
509enum Rendered {
510    Json(Value),
511    Plain(String),
512}
513
514impl Rendered {
515    fn emit(self) -> io::Result<()> {
516        let stdout = io::stdout();
517        let mut output = stdout.lock();
518        match self {
519            Self::Json(value) => {
520                serde_json::to_writer_pretty(&mut output, &value).map_err(|error| {
521                    if let Some(kind) = error.io_error_kind() {
522                        io::Error::new(kind, error)
523                    } else {
524                        io::Error::other(error)
525                    }
526                })?;
527                output.write_all(b"\n")
528            }
529            Self::Plain(text) => output.write_all(text.as_bytes()),
530        }
531    }
532}
533
534fn rendered(
535    json_mode: bool,
536    json: impl FnOnce() -> Value,
537    plain: impl FnOnce() -> String,
538) -> Rendered {
539    if json_mode {
540        Rendered::Json(json())
541    } else {
542        Rendered::Plain(plain())
543    }
544}
545
546pub fn run() {
547    let arguments = normalize_documented_description_values(std::env::args_os().collect());
548    let json_requested = requested_json(&arguments);
549    let cli = match Cli::try_parse_from(&arguments) {
550        Ok(cli) => cli,
551        Err(error) => emit_parse_error(error, json_requested),
552    };
553    let Cli {
554        version,
555        dir,
556        json,
557        command,
558    } = cli;
559
560    if version {
561        let output = if json {
562            Rendered::Json(json!({ "ok": true, "version": VERSION }))
563        } else {
564            Rendered::Plain(format!("mach v{VERSION}\n"))
565        };
566        emit_success(output);
567        return;
568    }
569
570    let result = match command {
571        Some(Command::Update { install }) => cmd_update(install, json),
572        None if json => Err(CliError::validation(
573            "--json requires a command or --version",
574        )),
575        None => crate::require_interactive_terminal()
576            .map_err(terminal_error)
577            .and_then(|()| Store::open_default(dir).map_err(CliError::from))
578            .and_then(|store| {
579                crate::run_tui(store).map_err(terminal_error)?;
580                Ok(Rendered::Plain(String::new()))
581            }),
582        Some(command) => Store::open_default(dir)
583            .map_err(CliError::from)
584            .and_then(|mut store| dispatch(&mut store, command, json)),
585    };
586
587    match result {
588        Ok(output) => emit_success(output),
589        Err(error) => emit_runtime_error(error, json),
590    }
591}
592
593/// Clap normally treats a separate leading-hyphen value as another option.
594/// Preserve that unambiguous behavior except for the documented `- ` description
595/// bullet; explicit `--description=...` remains the escape hatch for all other text.
596fn normalize_documented_description_values(arguments: Vec<OsString>) -> Vec<OsString> {
597    let mut normalized = Vec::with_capacity(arguments.len());
598    let mut arguments = arguments.into_iter().peekable();
599    let mut options = true;
600    while let Some(argument) = arguments.next() {
601        if options && argument == "--" {
602            options = false;
603            normalized.push(argument);
604            continue;
605        }
606        let description_option = options && (argument == "--description" || argument == "-d");
607        let documented_bullet = description_option
608            && arguments
609                .peek()
610                .and_then(|value| value.to_str())
611                .is_some_and(|value| value.starts_with("- "));
612        if documented_bullet {
613            let value = arguments
614                .next()
615                .expect("peeked description value must exist");
616            let mut combined = OsString::from("--description=");
617            combined.push(value);
618            normalized.push(combined);
619        } else {
620            normalized.push(argument);
621        }
622    }
623    normalized
624}
625
626fn terminal_error(error: io::Error) -> CliError {
627    CliError {
628        kind: "terminal",
629        message: error.to_string(),
630    }
631}
632
633fn requested_json(arguments: &[OsString]) -> bool {
634    arguments
635        .iter()
636        .skip(1)
637        .take_while(|argument| argument.as_os_str() != "--")
638        .any(|argument| argument.as_os_str() == "--json")
639}
640
641fn emit_parse_error(error: clap::Error, json_mode: bool) -> ! {
642    let help = matches!(
643        error.kind(),
644        clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion
645    );
646    let exit_code = if help { 0 } else { error.exit_code() };
647    if !json_mode {
648        let write = if help {
649            Rendered::Plain(error.to_string()).emit()
650        } else {
651            write_stderr(&format!("{}\n", terminal_text(&error.to_string())))
652        };
653        exit_after_write(write, exit_code);
654    }
655    let value = if help {
656        json!({ "ok": true, "kind": "help", "help": error.to_string() })
657    } else {
658        json!({ "ok": false, "kind": "usage", "error": error.to_string() })
659    };
660    exit_after_write(Rendered::Json(value).emit(), exit_code);
661}
662
663fn emit_runtime_error(error: CliError, json_mode: bool) -> ! {
664    let write = if json_mode {
665        Rendered::Json(json!({
666            "ok": false,
667            "kind": error.kind,
668            "error": error.message,
669        }))
670        .emit()
671    } else {
672        write_stderr(&format!("mach: {}\n", terminal_text(&error.message)))
673    };
674    exit_after_write(write, 1);
675}
676
677fn emit_success(output: Rendered) {
678    if let Err(error) = output.emit() {
679        if error.kind() == io::ErrorKind::BrokenPipe {
680            return;
681        }
682        let _ = write_stderr(&format!("mach: could not write output: {error}\n"));
683        std::process::exit(1);
684    }
685}
686
687fn write_stderr(text: &str) -> io::Result<()> {
688    let stderr = io::stderr();
689    stderr.lock().write_all(text.as_bytes())
690}
691
692fn exit_after_write(result: io::Result<()>, intended_code: i32) -> ! {
693    match result {
694        Ok(()) => std::process::exit(intended_code),
695        Err(error) if error.kind() == io::ErrorKind::BrokenPipe => std::process::exit(0),
696        Err(_) => std::process::exit(1),
697    }
698}
699
700fn dispatch(store: &mut Store, command: Command, json_mode: bool) -> Result<Rendered, CliError> {
701    match command {
702        Command::List {
703            category,
704            labels,
705            open,
706            done,
707        } => cmd_list(store, category.as_deref(), &labels, open, done, json_mode),
708        Command::Categories { action } => match action {
709            None | Some(CatAction::List) => cmd_categories_list(store, json_mode),
710            Some(CatAction::Add { name, description }) => {
711                cmd_category_add(store, &name, description.as_deref(), json_mode)
712            }
713            Some(CatAction::Edit {
714                name,
715                new_name,
716                description,
717                clear_description,
718            }) => cmd_category_edit(
719                store,
720                &name,
721                new_name.as_deref(),
722                description.as_deref(),
723                clear_description,
724                json_mode,
725            ),
726            Some(CatAction::Delete { name }) => cmd_category_delete(store, &name, json_mode),
727        },
728        Command::Labels { action } => match action {
729            None | Some(LabelAction::List) => cmd_labels_list(store, json_mode),
730            Some(LabelAction::Add { name, color }) => cmd_label_add(store, &name, color, json_mode),
731            Some(LabelAction::Edit {
732                name,
733                new_name,
734                color,
735            }) => cmd_label_edit(store, &name, new_name.as_deref(), color, json_mode),
736            Some(LabelAction::Delete { name }) => cmd_label_delete(store, &name, json_mode),
737        },
738        Command::Add(arguments) => cmd_add(store, &arguments, json_mode),
739        Command::Show { id } => cmd_show(store, &id, json_mode),
740        Command::Done { id } => cmd_set_done(store, &id, true, json_mode),
741        Command::Undone { id } => cmd_set_done(store, &id, false, json_mode),
742        Command::Delete { id } => cmd_delete(store, &id, json_mode),
743        Command::Move { id, before, after } => {
744            cmd_move(store, &id, before.as_deref(), after.as_deref(), json_mode)
745        }
746        Command::Purge { done: _, category } => cmd_purge(store, category.as_deref(), json_mode),
747        Command::Edit(arguments) => cmd_edit(store, &arguments, json_mode),
748        Command::Subtasks { task, action } => match action {
749            None | Some(SubAction::List) => cmd_subtasks_list(store, &task, json_mode),
750            Some(SubAction::Add {
751                text_pos,
752                text,
753                done,
754            }) => cmd_subtask_add(
755                store,
756                &task,
757                &text.or(text_pos).unwrap_or_default(),
758                done,
759                json_mode,
760            ),
761            Some(SubAction::Done { index }) => {
762                cmd_subtask_set_done(store, &task, index, Some(true), json_mode)
763            }
764            Some(SubAction::Undone { index }) => {
765                cmd_subtask_set_done(store, &task, index, Some(false), json_mode)
766            }
767            Some(SubAction::Toggle { index }) => {
768                cmd_subtask_set_done(store, &task, index, None, json_mode)
769            }
770            Some(SubAction::Edit {
771                index,
772                text_pos,
773                text,
774            }) => cmd_subtask_edit(
775                store,
776                &task,
777                index,
778                &text.or(text_pos).unwrap_or_default(),
779                json_mode,
780            ),
781            Some(SubAction::Delete { index }) => cmd_subtask_delete(store, &task, index, json_mode),
782        },
783        Command::Export { file } => cmd_export(store, file.as_deref(), json_mode),
784        Command::Import { file } => cmd_import(store, &file, json_mode),
785        Command::Update { .. } => Err(CliError {
786            kind: "internal",
787            message: "update command crossed the data-command boundary".into(),
788        }),
789    }
790}
791
792fn cmd_export(
793    store: &Store,
794    path: Option<&std::path::Path>,
795    json_mode: bool,
796) -> Result<Rendered, CliError> {
797    let summary = crate::archive::export(store, path)?;
798    Ok(rendered(
799        json_mode,
800        || {
801            json!({
802                "ok": true,
803                "archive": summary.path.display().to_string(),
804                "tasks": summary.tasks,
805                "categories": summary.categories,
806                "labels": summary.labels,
807                "images": summary.images,
808            })
809        },
810        || {
811            let contents = crate::archive::content_count_text(
812                summary.tasks,
813                summary.categories,
814                summary.labels,
815                summary.images,
816            );
817            format!(
818                "exported {contents} to {}\n",
819                terminal_text(&summary.path.display().to_string())
820            )
821        },
822    ))
823}
824
825fn cmd_import(
826    store: &mut Store,
827    path: &std::path::Path,
828    json_mode: bool,
829) -> Result<Rendered, CliError> {
830    let summary = crate::archive::import(store, path)?;
831    Ok(rendered(
832        json_mode,
833        || {
834            json!({
835                "ok": true,
836                "archive": summary.path.display().to_string(),
837                "tasks_added": summary.tasks_added,
838                "tasks_unchanged": summary.tasks_unchanged,
839                "categories_added": summary.categories_added,
840                "categories_unchanged": summary.categories_unchanged,
841                "labels_added": summary.labels_added,
842                "labels_unchanged": summary.labels_unchanged,
843                "images_added": summary.images_added,
844                "images_unchanged": summary.images_unchanged,
845            })
846        },
847        || {
848            let added = crate::archive::content_count_text(
849                summary.tasks_added,
850                summary.categories_added,
851                summary.labels_added,
852                summary.images_added,
853            );
854            let unchanged = crate::archive::content_count_text(
855                summary.tasks_unchanged,
856                summary.categories_unchanged,
857                summary.labels_unchanged,
858                summary.images_unchanged,
859            );
860            if summary.changed() {
861                format!("imported {added}; {unchanged} already present\n")
862            } else {
863                format!("nothing imported; {unchanged} already present\n")
864            }
865        },
866    ))
867}
868
869fn cmd_update(do_install: bool, json_mode: bool) -> Result<Rendered, CliError> {
870    let now = Utc::now().timestamp();
871    let mut update_state = crate::update_state::UpdateStateStore::open_default().ok();
872    let lease = update_state
873        .as_mut()
874        .and_then(|store| store.claim_manual(now).ok());
875    let checked = match crate::update::check_with_etag(None) {
876        Ok(crate::update::CheckResponse::Modified { value: info, etag }) => (info, etag),
877        Ok(crate::update::CheckResponse::NotModified) => {
878            if let (Some(store), Some(lease)) = (update_state.as_mut(), lease.as_ref()) {
879                let _ = store.finish_failure(lease, Utc::now().timestamp(), None);
880            }
881            return Err(CliError::update(
882                "GitHub returned 304 without a conditional request",
883            ));
884        }
885        Err(error) => {
886            if let (Some(store), Some(lease)) = (update_state.as_mut(), lease.as_ref()) {
887                let _ = store.finish_failure(lease, Utc::now().timestamp(), error.retry_at);
888            }
889            return Err(CliError::update(error.message));
890        }
891    };
892    let (info, etag) = checked;
893    let install = if do_install && info.newer {
894        crate::update::install(&info).map(Some)
895    } else {
896        Ok(None)
897    };
898    if let (Some(store), Some(lease)) = (update_state.as_mut(), lease.as_ref()) {
899        let _ = store.finish_modified(lease, Utc::now().timestamp(), etag.as_deref(), &info.latest);
900    }
901    let install = install.map_err(CliError::update)?;
902    let install_disposition = install.as_ref().map(|result| match result.disposition {
903        crate::update::InstallDisposition::Installed => "installed",
904        crate::update::InstallDisposition::AlreadyCurrent => "already_current",
905    });
906
907    Ok(rendered(
908        json_mode,
909        || {
910            json!({
911                "ok": true,
912                "current": info.current,
913                "latest": info.latest,
914                "newer": info.newer,
915                "prerelease": info.prerelease,
916                "url": info.release_url,
917                "installed": install_disposition == Some("installed"),
918                "install_disposition": install_disposition,
919                "destination": install
920                    .as_ref()
921                    .map(|result| result.destination.display().to_string()),
922                "tag": install.as_ref().map(|result| result.tag.as_str()).unwrap_or(&info.tag),
923            })
924        },
925        || {
926            let mut plain = format!("{}\n", terminal_text(&info.summary()));
927            if info.newer && install.is_none() {
928                plain.push('\n');
929                for line in info.install_hint().lines() {
930                    plain.push_str(&terminal_text(line));
931                    plain.push('\n');
932                }
933            }
934            if do_install && install.is_none() {
935                plain.push_str("Already up to date.\n");
936            } else if let Some(result) = &install {
937                let message = match result.disposition {
938                    crate::update::InstallDisposition::Installed => format!(
939                        "Installed {} to {}. Restart mach to use the new build.\n",
940                        terminal_text(&result.tag),
941                        terminal_text(&result.destination.display().to_string())
942                    ),
943                    crate::update::InstallDisposition::AlreadyCurrent => format!(
944                        "Already installed {} at {}. Restart mach to use the new build.\n",
945                        terminal_text(&result.tag),
946                        terminal_text(&result.destination.display().to_string())
947                    ),
948                };
949                plain.push_str(&message);
950            }
951            plain
952        },
953    ))
954}
955
956// ---------------------------------------------------------------- helpers
957
958fn short_id(id: &str) -> String {
959    id.chars().take(8).collect()
960}
961
962fn terminal_text(text: &str) -> String {
963    let mut safe = String::with_capacity(text.len());
964    for character in text.chars() {
965        match character {
966            '\n' => safe.push_str("\\n"),
967            '\r' => safe.push_str("\\r"),
968            '\t' => safe.push_str("\\t"),
969            character if character.is_control() => {
970                safe.push_str(&format!("\\u{{{:x}}}", character as u32));
971            }
972            character => safe.push(character),
973        }
974    }
975    safe
976}
977
978fn description_from_text(text: &str) -> Vec<Block> {
979    if text.is_empty() {
980        return Vec::new();
981    }
982    text.lines().map(line_to_block).collect()
983}
984
985fn line_to_block(line: &str) -> Block {
986    let text = line.trim_end();
987    if let Some(rest) = text.strip_prefix("[ ] ") {
988        return Block::todo(rest, false);
989    }
990    if let Some(rest) = text
991        .strip_prefix("[x] ")
992        .or_else(|| text.strip_prefix("[X] "))
993        .or_else(|| text.strip_prefix("[✓] "))
994    {
995        return Block::todo(rest, true);
996    }
997    if let Some(rest) = text.strip_prefix("- ").or_else(|| text.strip_prefix("• ")) {
998        return Block::bullet(rest);
999    }
1000    if let Some(rest) = strip_number_prefix(text) {
1001        return Block::number(rest);
1002    }
1003    if let Some(path) = text
1004        .strip_prefix("[image:")
1005        .and_then(|value| value.strip_suffix(']'))
1006        .filter(|path| !path.is_empty())
1007    {
1008        return Block::image(path);
1009    }
1010    if text.starts_with("http://") || text.starts_with("https://") {
1011        return Block::link(text);
1012    }
1013    Block::text(text)
1014}
1015
1016fn strip_number_prefix(line: &str) -> Option<&str> {
1017    let bytes = line.as_bytes();
1018    let mut index = 0;
1019    while index < bytes.len() && bytes[index].is_ascii_digit() {
1020        index += 1;
1021    }
1022    if index == 0 {
1023        return None;
1024    }
1025    line.get(index..)?.strip_prefix(". ")
1026}
1027
1028fn description_to_text(description: &[Block]) -> String {
1029    let mut numbered = 0usize;
1030    description
1031        .iter()
1032        .map(|block| match block {
1033            Block::Text { text } => {
1034                numbered = 0;
1035                text.clone()
1036            }
1037            Block::Todo { text, done } => {
1038                numbered = 0;
1039                format!("[{}] {text}", if *done { "x" } else { " " })
1040            }
1041            Block::Bullet { text } => {
1042                numbered = 0;
1043                format!("- {text}")
1044            }
1045            Block::Number { text } => {
1046                numbered += 1;
1047                format!("{numbered}. {text}")
1048            }
1049            Block::Link { url } => {
1050                numbered = 0;
1051                url.clone()
1052            }
1053            Block::Image { attachment_id } => {
1054                numbered = 0;
1055                format!("[image:{attachment_id}]")
1056            }
1057        })
1058        .collect::<Vec<_>>()
1059        .join("\n")
1060}
1061
1062fn collect_subtasks(description: &[Block]) -> Vec<(usize, &str, bool)> {
1063    description
1064        .iter()
1065        .filter_map(|block| match block {
1066            Block::Todo { text, done } => Some((text.as_str(), *done)),
1067            _ => None,
1068        })
1069        .enumerate()
1070        .map(|(index, (text, done))| (index + 1, text, done))
1071        .collect()
1072}
1073
1074fn subtask_description_index(description: &[Block], one_based: usize) -> Result<usize, CliError> {
1075    if one_based == 0 {
1076        return Err(CliError::validation(
1077            "subtask index is 1-based (use 1 for the first subtask)",
1078        ));
1079    }
1080    let mut count = 0usize;
1081    for (description_index, block) in description.iter().enumerate() {
1082        if matches!(block, Block::Todo { .. }) {
1083            count += 1;
1084            if count == one_based {
1085                return Ok(description_index);
1086            }
1087        }
1088    }
1089    Err(CliError::validation(format!(
1090        "no subtask at index {one_based} (task has {count} subtask(s))"
1091    )))
1092}
1093
1094fn subtasks_json(description: &[Block]) -> Vec<Value> {
1095    subtasks_to_json(&collect_subtasks(description))
1096}
1097
1098fn subtasks_to_json(subtasks: &[(usize, &str, bool)]) -> Vec<Value> {
1099    subtasks
1100        .iter()
1101        .map(|(index, text, done)| json!({ "index": index, "text": text, "done": done }))
1102        .collect()
1103}
1104
1105fn category_name<'a>(categories: &'a [Category], task: &Task) -> Option<&'a str> {
1106    task.category_id
1107        .as_ref()
1108        .and_then(|id| categories.iter().find(|category| category.id == *id))
1109        .map(|category| category.name.as_str())
1110}
1111
1112fn label_json(label: &Label) -> Value {
1113    json!({
1114        "id": label.id,
1115        "name": label.name,
1116        "color": label.color,
1117    })
1118}
1119
1120fn task_labels<'a>(labels: &'a [Label], task: &Task) -> Vec<&'a Label> {
1121    labels
1122        .iter()
1123        .filter(|label| task.label_ids.contains(&label.id))
1124        .collect()
1125}
1126
1127fn task_label_text(labels: &[Label], task: &Task) -> String {
1128    task_labels(labels, task)
1129        .into_iter()
1130        .map(|label| format!("#{}", terminal_text(&label.name)))
1131        .collect::<Vec<_>>()
1132        .join(" ")
1133}
1134
1135fn resolve_label_ids(data: &StoreData, queries: &[String]) -> Result<Vec<String>, StoreError> {
1136    let mut selected = HashSet::with_capacity(queries.len());
1137    for query in queries {
1138        selected.insert(data.resolve_label_id(query)?);
1139    }
1140    Ok(data
1141        .labels
1142        .iter()
1143        .filter(|label| selected.contains(&label.id))
1144        .map(|label| label.id.clone())
1145        .collect())
1146}
1147
1148fn task_json(categories: &[Category], labels: &[Label], task: &Task) -> Value {
1149    task_json_with_category(labels, task, category_name(categories, task))
1150}
1151
1152fn task_json_with_category(labels: &[Label], task: &Task, category_name: Option<&str>) -> Value {
1153    let subtasks = collect_subtasks(&task.description);
1154    let subtasks_json = subtasks_to_json(&subtasks);
1155    json!({
1156        "id": task.id,
1157        "title": task.title,
1158        "description": description_to_text(&task.description),
1159        "subtasks": subtasks_json,
1160        "subtasks_done": subtasks.iter().filter(|(_, _, done)| *done).count(),
1161        "subtasks_total": subtasks.len(),
1162        "due": task.due,
1163        "done": task.done,
1164        "importance": task.importance,
1165        "category": {
1166            "id": task.category_id,
1167            "name": category_name,
1168        },
1169        "labels": task_labels(labels, task)
1170            .into_iter()
1171            .map(label_json)
1172            .collect::<Vec<_>>(),
1173        "created": task.created,
1174    })
1175}
1176
1177fn category_json(category: &Category) -> Value {
1178    json!({
1179        "id": category.id,
1180        "name": category.name,
1181        "description": category.description,
1182    })
1183}
1184
1185fn validate_time(raw: &str) -> Result<String, CliError> {
1186    let value = raw.trim();
1187    if crate::due::parse_time(value).is_some() {
1188        Ok(value.to_string())
1189    } else {
1190        Err(CliError::validation(format!(
1191            "invalid time {raw:?}; use HH:MM (24h), e.g. 14:30"
1192        )))
1193    }
1194}
1195
1196fn due_for_add(due: Option<&str>, time: Option<&str>) -> Result<String, CliError> {
1197    let due = due.map(str::trim).filter(|value| !value.is_empty());
1198    match (due, time) {
1199        (None, None) => Ok(String::new()),
1200        (None, Some(time)) => validate_time(time),
1201        (Some(due), None) => Ok(due.to_string()),
1202        (Some(due), Some(time)) => {
1203            if due.contains(':') {
1204                return Err(CliError::validation(format!(
1205                    "due already includes a time ({due}); omit --time or pass date-only --due"
1206                )));
1207            }
1208            Ok(format!("{due} {}", validate_time(time)?))
1209        }
1210    }
1211}
1212
1213fn split_inline_title(raw: &str) -> Result<(String, String), CliError> {
1214    let (inline_due, title) = crate::due::parse(raw.trim());
1215    if !inline_due.is_empty() {
1216        crate::due::normalize_for_write(&inline_due)
1217            .map_err(|error| CliError::validation(error.to_string()))?;
1218    }
1219    Ok((title, inline_due))
1220}
1221
1222/// Resolve edit due semantics inside the write transaction so shorthand is
1223/// anchored at the actual write time.
1224fn due_for_edit(
1225    current: &str,
1226    due: Option<&str>,
1227    time: Option<&str>,
1228) -> Result<Option<String>, CliError> {
1229    if due.is_none() && time.is_none() {
1230        return Ok(None);
1231    }
1232    let existing_time = current.split_once(' ').map(|(_, time)| time.to_string());
1233    let existing_date = current
1234        .split_once(' ')
1235        .map(|(date, _)| date.to_string())
1236        .or_else(|| (!current.is_empty() && !current.contains(':')).then(|| current.to_string()));
1237
1238    if let Some(raw_due) = due {
1239        let raw_due = raw_due.trim();
1240        if raw_due.contains(':') {
1241            if time.is_some() {
1242                return Err(CliError::validation(
1243                    "pass either a full --due datetime or --due date + --time, not both",
1244                ));
1245            }
1246            return crate::due::normalize_for_write(raw_due)
1247                .map(Some)
1248                .map_err(|error| CliError::validation(error.to_string()));
1249        }
1250        let chosen_time = match time {
1251            Some(time) => Some(validate_time(time)?),
1252            None => existing_time,
1253        };
1254        let combined = match chosen_time {
1255            Some(time) => format!("{raw_due} {time}"),
1256            None => raw_due.to_string(),
1257        };
1258        return crate::due::normalize_for_write(&combined)
1259            .map(Some)
1260            .map_err(|error| CliError::validation(error.to_string()));
1261    }
1262
1263    let Some(time) = time else {
1264        return Ok(None);
1265    };
1266    let time = validate_time(time)?;
1267    let combined = match existing_date {
1268        Some(date) => format!("{date} {time}"),
1269        None => time,
1270    };
1271    crate::due::normalize_for_write(&combined)
1272        .map(Some)
1273        .map_err(|error| CliError::validation(error.to_string()))
1274}
1275
1276fn task_line(
1277    task: &Task,
1278    category_name: Option<&str>,
1279    labels: &[Label],
1280    show_category: bool,
1281    date_format: &str,
1282) -> String {
1283    let check = if task.done { "[✓]" } else { "[ ]" };
1284    let title = terminal_text(&task.title);
1285    let due = crate::due::display(&task.due, date_format);
1286    let due = if due.is_empty() {
1287        String::new()
1288    } else {
1289        format!("  {}", terminal_text(&due))
1290    };
1291    let flag = if task.importance > 0 {
1292        format!("  {}", crate::model::importance_marks(task.importance))
1293    } else {
1294        String::new()
1295    };
1296    let category = if show_category {
1297        format!("  [{}]", terminal_text(category_name.unwrap_or("—")))
1298    } else {
1299        String::new()
1300    };
1301    let label_text = task_label_text(labels, task);
1302    let labels = if label_text.is_empty() {
1303        String::new()
1304    } else {
1305        format!("  {label_text}")
1306    };
1307    let progress = crate::model::todo_progress(task)
1308        .map(|(done, total)| format!("  ({done}/{total})"))
1309        .unwrap_or_default();
1310    format!(
1311        "{} {check} {title}{category}{labels}{due}{flag}{progress}\n",
1312        terminal_text(&short_id(&task.id))
1313    )
1314}
1315
1316// ---------------------------------------------------------------- commands
1317
1318fn cmd_list(
1319    store: &Store,
1320    category: Option<&str>,
1321    label_queries: &[String],
1322    open_only: bool,
1323    done_only: bool,
1324    json_mode: bool,
1325) -> Result<Rendered, CliError> {
1326    let data = store.snapshot()?;
1327    let category_id = category
1328        .map(|query| data.resolve_category_id(query))
1329        .transpose()?;
1330    let label_ids = resolve_label_ids(&data, label_queries)?;
1331    let show_category = category_id.is_none();
1332    let tasks: Vec<_> = data
1333        .tasks
1334        .iter()
1335        .filter(|task| {
1336            category_id
1337                .as_ref()
1338                .is_none_or(|id| task.category_id.as_deref() == Some(id.as_str()))
1339        })
1340        .filter(|task| {
1341            label_ids
1342                .iter()
1343                .all(|label_id| task.label_ids.contains(label_id))
1344        })
1345        .filter(|task| {
1346            if open_only {
1347                !task.done
1348            } else if done_only {
1349                task.done
1350            } else {
1351                true
1352            }
1353        })
1354        .collect();
1355    let category_names: HashMap<_, _> = data
1356        .categories
1357        .iter()
1358        .map(|category| (category.id.as_str(), category.name.as_str()))
1359        .collect();
1360    let name_for = |task: &Task| {
1361        task.category_id
1362            .as_deref()
1363            .and_then(|id| category_names.get(id).copied())
1364    };
1365    Ok(rendered(
1366        json_mode,
1367        || {
1368            Value::Array(
1369                tasks
1370                    .iter()
1371                    .map(|task| task_json_with_category(&data.labels, task, name_for(task)))
1372                    .collect(),
1373            )
1374        },
1375        || {
1376            let mut plain = String::new();
1377            if tasks.is_empty() {
1378                plain.push_str("(no tasks)\n");
1379            } else {
1380                for task in &tasks {
1381                    plain.push_str(&task_line(
1382                        task,
1383                        name_for(task),
1384                        &data.labels,
1385                        show_category,
1386                        &data.settings.date_format,
1387                    ));
1388                }
1389                let done = tasks.iter().filter(|task| task.done).count();
1390                plain.push_str(&format!("— {} task(s), {done} done\n", tasks.len()));
1391            }
1392            plain
1393        },
1394    ))
1395}
1396
1397fn cmd_categories_list(store: &Store, json_mode: bool) -> Result<Rendered, CliError> {
1398    let data = store.snapshot()?;
1399    let category_indices: HashMap<_, _> = data
1400        .categories
1401        .iter()
1402        .enumerate()
1403        .map(|(index, category)| (category.id.as_str(), index))
1404        .collect();
1405    let mut counts = vec![(0usize, 0usize); data.categories.len()];
1406    let mut uncategorized = (0usize, 0usize);
1407    for task in &data.tasks {
1408        let count = match task.category_id.as_deref() {
1409            Some(id) => category_indices.get(id).map(|index| &mut counts[*index]),
1410            None => Some(&mut uncategorized),
1411        };
1412        if let Some((done, total)) = count {
1413            *done += usize::from(task.done);
1414            *total += 1;
1415        }
1416    }
1417    Ok(rendered(
1418        json_mode,
1419        || {
1420            let categories: Vec<_> = data
1421                .categories
1422                .iter()
1423                .zip(&counts)
1424                .map(|(category, (done, total))| {
1425                    json!({
1426                        "id": category.id,
1427                        "name": category.name,
1428                        "description": category.description,
1429                        "total": total,
1430                        "done": done,
1431                    })
1432                })
1433                .collect();
1434            json!({
1435                "categories": categories,
1436                "uncategorized": {
1437                    "total": uncategorized.1,
1438                    "done": uncategorized.0,
1439                },
1440            })
1441        },
1442        || {
1443            let mut plain = String::new();
1444            if data.categories.is_empty() {
1445                plain.push_str("(no categories)\n");
1446            } else {
1447                for (category, (done, total)) in data.categories.iter().zip(&counts) {
1448                    plain.push_str(&format!(
1449                        "{}  {done}/{total}\n",
1450                        terminal_text(&category.name),
1451                    ));
1452                }
1453            }
1454            if uncategorized.1 > 0 {
1455                plain.push_str(&format!(
1456                    "— uncategorized  {}/{}\n",
1457                    uncategorized.0, uncategorized.1
1458                ));
1459            }
1460            plain
1461        },
1462    ))
1463}
1464
1465fn cmd_category_add(
1466    store: &mut Store,
1467    name: &str,
1468    description: Option<&str>,
1469    json_mode: bool,
1470) -> Result<Rendered, CliError> {
1471    let name = name.trim().to_string();
1472    let description = description.unwrap_or_default().to_string();
1473    let category = store.update(|data| data.create_category(name, description))?;
1474    Ok(rendered(
1475        json_mode,
1476        || category_json(&category),
1477        || format!("created category {}\n", terminal_text(&category.name)),
1478    ))
1479}
1480
1481fn cmd_category_edit(
1482    store: &mut Store,
1483    query: &str,
1484    new_name: Option<&str>,
1485    description: Option<&str>,
1486    clear_description: bool,
1487    json_mode: bool,
1488) -> Result<Rendered, CliError> {
1489    if new_name.is_none() && description.is_none() && !clear_description {
1490        return Err(CliError::validation(
1491            "nothing to edit; pass --name / --description / --clear-description",
1492        ));
1493    }
1494    if clear_description && description.is_some() {
1495        return Err(CliError::validation(
1496            "--clear-description cannot be combined with --description",
1497        ));
1498    }
1499    let patch = CategoryPatch {
1500        name: new_name.map(|name| name.trim().to_string()),
1501        description: if clear_description {
1502            Some(String::new())
1503        } else {
1504            description.map(str::to_string)
1505        },
1506    };
1507    let category = store.update(|data| {
1508        let id = data.resolve_category_id(query)?;
1509        data.edit_category(&id, patch)
1510    })?;
1511    Ok(rendered(
1512        json_mode,
1513        || category_json(&category),
1514        || format!("updated category {}\n", terminal_text(&category.name)),
1515    ))
1516}
1517
1518fn cmd_category_delete(
1519    store: &mut Store,
1520    query: &str,
1521    json_mode: bool,
1522) -> Result<Rendered, CliError> {
1523    let category = store.update(|data| {
1524        let id = data.resolve_category_id(query)?;
1525        data.delete_category(&id)
1526    })?;
1527    Ok(rendered(
1528        json_mode,
1529        || json!({ "deleted": category.name, "id": category.id }),
1530        || {
1531            format!(
1532                "deleted category {} (tasks uncategorized)\n",
1533                terminal_text(&category.name)
1534            )
1535        },
1536    ))
1537}
1538
1539fn cmd_labels_list(store: &Store, json_mode: bool) -> Result<Rendered, CliError> {
1540    let data = store.snapshot()?;
1541    let label_indices: HashMap<_, _> = data
1542        .labels
1543        .iter()
1544        .enumerate()
1545        .map(|(index, label)| (label.id.as_str(), index))
1546        .collect();
1547    let mut counts = vec![(0usize, 0usize); data.labels.len()];
1548    for task in &data.tasks {
1549        for label_id in &task.label_ids {
1550            if let Some(index) = label_indices.get(label_id.as_str()) {
1551                counts[*index].0 += usize::from(task.done);
1552                counts[*index].1 += 1;
1553            }
1554        }
1555    }
1556    Ok(rendered(
1557        json_mode,
1558        || {
1559            json!({
1560                "labels": data.labels.iter().zip(&counts).map(|(label, (done, total))| {
1561                    json!({
1562                        "id": label.id,
1563                        "name": label.name,
1564                        "color": label.color,
1565                        "total": total,
1566                        "done": done,
1567                    })
1568                }).collect::<Vec<_>>(),
1569            })
1570        },
1571        || {
1572            if data.labels.is_empty() {
1573                return "(no labels)\n".to_string();
1574            }
1575            data.labels
1576                .iter()
1577                .zip(&counts)
1578                .map(|(label, (done, total))| {
1579                    format!(
1580                        "#{}  {}  {done}/{total}\n",
1581                        terminal_text(&label.name),
1582                        label.color
1583                    )
1584                })
1585                .collect()
1586        },
1587    ))
1588}
1589
1590fn cmd_label_add(
1591    store: &mut Store,
1592    name: &str,
1593    color: Option<LabelColor>,
1594    json_mode: bool,
1595) -> Result<Rendered, CliError> {
1596    let label = store.update(|data| match color {
1597        Some(color) => data.create_label_with_color(name, color),
1598        None => data.create_label(name),
1599    })?;
1600    Ok(rendered(
1601        json_mode,
1602        || label_json(&label),
1603        || {
1604            format!(
1605                "created label #{} ({})\n",
1606                terminal_text(&label.name),
1607                label.color
1608            )
1609        },
1610    ))
1611}
1612
1613fn cmd_label_edit(
1614    store: &mut Store,
1615    query: &str,
1616    new_name: Option<&str>,
1617    color: Option<LabelColor>,
1618    json_mode: bool,
1619) -> Result<Rendered, CliError> {
1620    let label = store.update(|data| {
1621        let id = data.resolve_label_id(query)?;
1622        data.edit_label(
1623            &id,
1624            LabelPatch {
1625                name: new_name.map(str::to_string),
1626                color,
1627            },
1628        )
1629    })?;
1630    Ok(rendered(
1631        json_mode,
1632        || label_json(&label),
1633        || {
1634            format!(
1635                "updated label #{} ({})\n",
1636                terminal_text(&label.name),
1637                label.color
1638            )
1639        },
1640    ))
1641}
1642
1643fn cmd_label_delete(store: &mut Store, query: &str, json_mode: bool) -> Result<Rendered, CliError> {
1644    let (label, tasks_unassigned) = store.update(|data| {
1645        let id = data.resolve_label_id(query)?;
1646        let tasks_unassigned = data
1647            .tasks
1648            .iter()
1649            .filter(|task| task.label_ids.contains(&id))
1650            .count();
1651        let label = data.delete_label(&id)?;
1652        Ok((label, tasks_unassigned))
1653    })?;
1654    Ok(rendered(
1655        json_mode,
1656        || {
1657            json!({
1658                "deleted": label.name,
1659                "id": label.id,
1660                "tasks_unassigned": tasks_unassigned,
1661            })
1662        },
1663        || {
1664            format!(
1665                "deleted label #{} (unassigned from {tasks_unassigned} task{})\n",
1666                terminal_text(&label.name),
1667                if tasks_unassigned == 1 { "" } else { "s" }
1668            )
1669        },
1670    ))
1671}
1672
1673fn cmd_add(store: &mut Store, arguments: &AddArgs, json_mode: bool) -> Result<Rendered, CliError> {
1674    let raw_title = arguments
1675        .title
1676        .as_deref()
1677        .or(arguments.title_pos.as_deref())
1678        .unwrap_or_default()
1679        .trim();
1680    let (title, inline_due) = split_inline_title(raw_title)?;
1681    if title.is_empty() {
1682        return Err(CliError::validation(
1683            "title required (positional or --title)",
1684        ));
1685    }
1686    let mut description = arguments
1687        .description
1688        .as_deref()
1689        .map(description_from_text)
1690        .unwrap_or_default();
1691    for subtask in &arguments.subtasks {
1692        let text = subtask.trim();
1693        if text.is_empty() {
1694            return Err(CliError::validation("--subtask text cannot be empty"));
1695        }
1696        description.push(Block::todo(text, false));
1697    }
1698    let due = if arguments.due.is_none() && arguments.time.is_none() {
1699        inline_due
1700    } else {
1701        due_for_add(arguments.due.as_deref(), arguments.time.as_deref())?
1702    };
1703    let category_query = arguments.category.as_deref();
1704    let label_queries = &arguments.labels;
1705    let importance = arguments.importance;
1706    let (task_id, snapshot) = store.update_with_snapshot(move |data| {
1707        let category_id = category_query
1708            .map(|query| data.resolve_category_id(query))
1709            .transpose()?;
1710        let label_ids = resolve_label_ids(data, label_queries)?;
1711        let task = data.create_task(title, description, due, importance, category_id)?;
1712        let task_id = task.id;
1713        data.set_task_labels(&task_id, label_ids)?;
1714        Ok(task_id)
1715    })?;
1716    let task = snapshot.task(&task_id)?.clone();
1717    let categories = snapshot.categories;
1718    let labels = snapshot.labels;
1719    Ok(rendered(
1720        json_mode,
1721        || task_json(&categories, &labels, &task),
1722        || {
1723            let subtasks = collect_subtasks(&task.description).len();
1724            if subtasks == 0 {
1725                format!(
1726                    "added {}  {}\n",
1727                    terminal_text(&short_id(&task.id)),
1728                    terminal_text(&task.title)
1729                )
1730            } else {
1731                format!(
1732                    "added {}  {}  ({} subtask{})\n",
1733                    terminal_text(&short_id(&task.id)),
1734                    terminal_text(&task.title),
1735                    subtasks,
1736                    if subtasks == 1 { "" } else { "s" }
1737                )
1738            }
1739        },
1740    ))
1741}
1742
1743fn cmd_show(store: &Store, query: &str, json_mode: bool) -> Result<Rendered, CliError> {
1744    let data = store.snapshot()?;
1745    let id = data.resolve_task_id(query)?;
1746    let task = data.task(&id)?;
1747    Ok(rendered(
1748        json_mode,
1749        || task_json(&data.categories, &data.labels, task),
1750        || {
1751            let labels = task_label_text(&data.labels, task);
1752            let mut plain = format!(
1753                "id:         {}\ntitle:      {}\ndone:       {}\ncategory:   {}\nlabels:     {}\ndue:        {}\nimportance: {} ({})\ncreated:    {}\n",
1754                terminal_text(&task.id),
1755                terminal_text(&task.title),
1756                task.done,
1757                terminal_text(category_name(&data.categories, task).unwrap_or("—")),
1758                if labels.is_empty() { "—" } else { &labels },
1759                if task.due.is_empty() {
1760                    "—".into()
1761                } else {
1762                    terminal_text(&task.due)
1763                },
1764                task.importance,
1765                crate::model::importance_marks(task.importance),
1766                terminal_text(&task.created),
1767            );
1768            let subtasks = collect_subtasks(&task.description);
1769            if subtasks.is_empty() {
1770                plain.push_str("subtasks:   —\n");
1771            } else {
1772                plain.push_str(&format!(
1773                    "subtasks:   {}/{}\n",
1774                    subtasks.iter().filter(|(_, _, done)| *done).count(),
1775                    subtasks.len()
1776                ));
1777                for (index, text, done) in &subtasks {
1778                    plain.push_str(&format!(
1779                        "  {index}. {} {}\n",
1780                        if *done { "[✓]" } else { "[ ]" },
1781                        terminal_text(text)
1782                    ));
1783                }
1784            }
1785            let notes = description_note_lines(&task.description);
1786            if notes.is_empty() {
1787                plain.push_str("description:       —\n");
1788            } else {
1789                plain.push_str("description:\n");
1790                for note in notes {
1791                    plain.push_str(&format!("  {}\n", terminal_text(&note)));
1792                }
1793            }
1794            plain
1795        },
1796    ))
1797}
1798
1799fn description_note_lines(description: &[Block]) -> Vec<String> {
1800    let mut numbered = 0usize;
1801    let mut notes = Vec::new();
1802    for block in description {
1803        match block {
1804            Block::Todo { .. } => numbered = 0,
1805            Block::Text { text } => {
1806                numbered = 0;
1807                if !text.trim().is_empty() {
1808                    notes.push(text.clone());
1809                }
1810            }
1811            Block::Bullet { text } => {
1812                numbered = 0;
1813                notes.push(format!("- {text}"));
1814            }
1815            Block::Number { text } => {
1816                numbered += 1;
1817                notes.push(format!("{numbered}. {text}"));
1818            }
1819            Block::Link { url } => {
1820                numbered = 0;
1821                notes.push(url.clone());
1822            }
1823            Block::Image { attachment_id } => {
1824                numbered = 0;
1825                notes.push(format!("[image:{attachment_id}]"));
1826            }
1827        }
1828    }
1829    notes
1830}
1831
1832fn cmd_set_done(
1833    store: &mut Store,
1834    query: &str,
1835    done: bool,
1836    json_mode: bool,
1837) -> Result<Rendered, CliError> {
1838    let (task, snapshot) = store.update_with_snapshot(|data| {
1839        let id = data.resolve_task_id(query)?;
1840        data.set_task_done(&id, done)
1841    })?;
1842    let categories = snapshot.categories;
1843    let labels = snapshot.labels;
1844    Ok(rendered(
1845        json_mode,
1846        || task_json(&categories, &labels, &task),
1847        || {
1848            format!(
1849                "{} {}  {}\n",
1850                if done { "done" } else { "undone" },
1851                terminal_text(&short_id(&task.id)),
1852                terminal_text(&task.title)
1853            )
1854        },
1855    ))
1856}
1857
1858fn cmd_delete(store: &mut Store, query: &str, json_mode: bool) -> Result<Rendered, CliError> {
1859    let (task, snapshot) = store.update_with_snapshot(|data| {
1860        let id = data.resolve_task_id(query)?;
1861        data.delete_task(&id)
1862    })?;
1863    let categories = snapshot.categories;
1864    let labels = snapshot.labels;
1865    Ok(rendered(
1866        json_mode,
1867        || task_json(&categories, &labels, &task),
1868        || {
1869            format!(
1870                "deleted {}  {}\n",
1871                terminal_text(&short_id(&task.id)),
1872                terminal_text(&task.title)
1873            )
1874        },
1875    ))
1876}
1877
1878fn cmd_move(
1879    store: &mut Store,
1880    query: &str,
1881    before: Option<&str>,
1882    after: Option<&str>,
1883    json_mode: bool,
1884) -> Result<Rendered, CliError> {
1885    let (relation, position, target_query) = match (before, after) {
1886        (Some(target), None) => ("before", RelativePosition::Before, target),
1887        (None, Some(target)) => ("after", RelativePosition::After, target),
1888        _ => {
1889            return Err(CliError::validation(
1890                "pass exactly one of --before or --after",
1891            ));
1892        }
1893    };
1894    let ((task, target), snapshot) = store.update_with_snapshot(|data| {
1895        let id = data.resolve_task_id(query)?;
1896        let target_id = data.resolve_task_id(target_query)?;
1897        let target = data.task(&target_id)?.clone();
1898        let task = data.move_task_relative(&id, &target_id, position)?;
1899        Ok((task, target))
1900    })?;
1901    let categories = snapshot.categories;
1902    let labels = snapshot.labels;
1903    Ok(rendered(
1904        json_mode,
1905        || {
1906            json!({
1907                "moved": task_json(&categories, &labels, &task),
1908                "relation": relation,
1909                "target": { "id": target.id, "title": target.title },
1910            })
1911        },
1912        || {
1913            format!(
1914                "moved {} {relation} {}\n",
1915                terminal_text(&short_id(&task.id)),
1916                terminal_text(&short_id(&target.id))
1917            )
1918        },
1919    ))
1920}
1921
1922fn cmd_purge(
1923    store: &mut Store,
1924    category: Option<&str>,
1925    json_mode: bool,
1926) -> Result<Rendered, CliError> {
1927    let (removed, snapshot) = store.update_with_snapshot(|data| {
1928        let scope = match category {
1929            Some(query) => PurgeScope::Category(data.resolve_category_id(query)?),
1930            None => PurgeScope::All,
1931        };
1932        data.purge_completed(&scope)
1933    })?;
1934    let categories = snapshot.categories;
1935    let labels = snapshot.labels;
1936    Ok(rendered(
1937        json_mode,
1938        || {
1939            json!({
1940                "purged": removed
1941                    .iter()
1942                    .map(|task| task_json(&categories, &labels, task))
1943                    .collect::<Vec<_>>(),
1944                "count": removed.len(),
1945            })
1946        },
1947        || format!("purged {} completed task(s)\n", removed.len()),
1948    ))
1949}
1950
1951fn cmd_edit(
1952    store: &mut Store,
1953    arguments: &EditArgs,
1954    json_mode: bool,
1955) -> Result<Rendered, CliError> {
1956    if arguments.title.is_none()
1957        && arguments.description.is_none()
1958        && arguments.due.is_none()
1959        && arguments.time.is_none()
1960        && !arguments.clear_due
1961        && arguments.category.is_none()
1962        && !arguments.clear_cat
1963        && arguments.add_labels.is_empty()
1964        && arguments.remove_labels.is_empty()
1965        && !arguments.clear_labels
1966        && arguments.importance.is_none()
1967    {
1968        return Err(CliError::validation(
1969            "nothing to edit; pass --title / --description / --due / --time / --clear-due / --category / --clear-category / --add-label / --remove-label / --clear-labels / --importance",
1970        ));
1971    }
1972    if arguments.clear_due && (arguments.due.is_some() || arguments.time.is_some()) {
1973        return Err(CliError::validation(
1974            "--clear-due cannot be combined with --due / --time",
1975        ));
1976    }
1977    if arguments.clear_cat && arguments.category.is_some() {
1978        return Err(CliError::validation(
1979            "--clear-category cannot be combined with --category",
1980        ));
1981    }
1982    if arguments.clear_labels && !arguments.remove_labels.is_empty() {
1983        return Err(CliError::validation(
1984            "--clear-labels cannot be combined with --remove-label",
1985        ));
1986    }
1987    let query = arguments.id.as_str();
1988    let (title, inline_due) = match arguments.title.as_deref() {
1989        Some(title) => {
1990            let (title, inline_due) = split_inline_title(title)?;
1991            (Some(title), inline_due)
1992        }
1993        None => (None, String::new()),
1994    };
1995    let description = arguments.description.as_deref().map(description_from_text);
1996    let due_argument = arguments.due.as_deref();
1997    let time_argument = arguments.time.as_deref();
1998    let clear_due = arguments.clear_due;
1999    let category_query = arguments.category.as_deref();
2000    let clear_category = arguments.clear_cat;
2001    let add_label_queries = &arguments.add_labels;
2002    let remove_label_queries = &arguments.remove_labels;
2003    let clear_labels = arguments.clear_labels;
2004    let importance = arguments.importance;
2005    let (task_id, snapshot) = store.update_with_snapshot(|data| {
2006        let id = data.resolve_task_id(query)?;
2007        let add_label_ids = resolve_label_ids(data, add_label_queries)?;
2008        let remove_label_ids = resolve_label_ids(data, remove_label_queries)?;
2009        if let Some(label_id) = add_label_ids
2010            .iter()
2011            .find(|label_id| remove_label_ids.contains(label_id))
2012        {
2013            let label = data.label(label_id)?;
2014            return Err(StoreError::validation(format!(
2015                "label {:?} cannot be both added and removed",
2016                label.name
2017            )));
2018        }
2019        let due = if clear_due {
2020            Some(String::new())
2021        } else if due_argument.is_none() && time_argument.is_none() && !inline_due.is_empty() {
2022            Some(inline_due)
2023        } else {
2024            due_for_edit(&data.task(&id)?.due, due_argument, time_argument)
2025                .map_err(|error| StoreError::validation(error.message))?
2026        };
2027        let category_id = if clear_category {
2028            Some(None)
2029        } else {
2030            category_query
2031                .map(|query| data.resolve_category_id(query).map(Some))
2032                .transpose()?
2033        };
2034        let label_ids = if clear_labels || !add_label_ids.is_empty() || !remove_label_ids.is_empty()
2035        {
2036            let mut selected: HashSet<_> = if clear_labels {
2037                HashSet::new()
2038            } else {
2039                data.task(&id)?.label_ids.iter().cloned().collect()
2040            };
2041            for label_id in remove_label_ids {
2042                selected.remove(&label_id);
2043            }
2044            selected.extend(add_label_ids);
2045            Some(
2046                data.labels
2047                    .iter()
2048                    .filter(|label| selected.contains(&label.id))
2049                    .map(|label| label.id.clone())
2050                    .collect(),
2051            )
2052        } else {
2053            None
2054        };
2055        let task = data.edit_task(
2056            &id,
2057            TaskPatch {
2058                title,
2059                description,
2060                due,
2061                importance,
2062                category_id,
2063                label_ids,
2064                ..TaskPatch::default()
2065            },
2066        )?;
2067        Ok(task.id)
2068    })?;
2069    let task = snapshot.task(&task_id)?.clone();
2070    let categories = snapshot.categories;
2071    let labels = snapshot.labels;
2072    Ok(rendered(
2073        json_mode,
2074        || task_json(&categories, &labels, &task),
2075        || {
2076            format!(
2077                "updated {}  {}\n",
2078                terminal_text(&short_id(&task.id)),
2079                terminal_text(&task.title)
2080            )
2081        },
2082    ))
2083}
2084
2085// --------------------------------------------------------------- subtasks
2086
2087fn cmd_subtasks_list(store: &Store, query: &str, json_mode: bool) -> Result<Rendered, CliError> {
2088    let data = store.snapshot()?;
2089    let id = data.resolve_task_id(query)?;
2090    let task = data.task(&id)?;
2091    let subtasks = collect_subtasks(&task.description);
2092    let done_count = subtasks.iter().filter(|(_, _, done)| *done).count();
2093    Ok(rendered(
2094        json_mode,
2095        || {
2096            json!({
2097                "task_id": task.id,
2098                "title": task.title,
2099                "subtasks": subtasks_to_json(&subtasks),
2100                "done": done_count,
2101                "total": subtasks.len(),
2102            })
2103        },
2104        || {
2105            let mut plain = format!(
2106                "{}  {}",
2107                terminal_text(&short_id(&task.id)),
2108                terminal_text(&task.title)
2109            );
2110            if subtasks.is_empty() {
2111                plain.push_str("  (no subtasks)\n");
2112            } else {
2113                plain.push('\n');
2114                for (index, text, done) in &subtasks {
2115                    let check = if *done { "[✓]" } else { "[ ]" };
2116                    plain.push_str(&format!("  {index}. {check} {}\n", terminal_text(text)));
2117                }
2118                plain.push_str(&format!("— {done_count}/{} done\n", subtasks.len()));
2119            }
2120            plain
2121        },
2122    ))
2123}
2124
2125fn cmd_subtask_add(
2126    store: &mut Store,
2127    query: &str,
2128    text: &str,
2129    done: bool,
2130    json_mode: bool,
2131) -> Result<Rendered, CliError> {
2132    let text = text.trim().to_string();
2133    if text.is_empty() {
2134        return Err(CliError::validation(
2135            "subtask text required (positional or --text)",
2136        ));
2137    }
2138    let (task, index) = store.update(|data| {
2139        let id = data.resolve_task_id(query)?;
2140        let mut description = data.task(&id)?.description.clone();
2141        description.push(Block::todo(&text, done));
2142        let task = data.edit_task(
2143            &id,
2144            TaskPatch {
2145                description: Some(description),
2146                ..TaskPatch::default()
2147            },
2148        )?;
2149        let index = collect_subtasks(&task.description).len();
2150        Ok((task, index))
2151    })?;
2152    Ok(rendered(
2153        json_mode,
2154        || {
2155            json!({
2156                "task_id": task.id,
2157                "index": index,
2158                "text": text,
2159                "done": done,
2160                "subtasks": subtasks_json(&task.description),
2161            })
2162        },
2163        || {
2164            format!(
2165                "added subtask {index} on {}  {}\n",
2166                terminal_text(&short_id(&task.id)),
2167                terminal_text(&text)
2168            )
2169        },
2170    ))
2171}
2172
2173enum SubtaskMutation<'a> {
2174    SetDone(Option<bool>),
2175    Edit(&'a str),
2176    Delete,
2177}
2178
2179fn mutate_subtask(
2180    store: &mut Store,
2181    query: &str,
2182    index: usize,
2183    mutation: SubtaskMutation<'_>,
2184) -> Result<(Task, String, bool), CliError> {
2185    store
2186        .update(|data| {
2187            let id = data.resolve_task_id(query)?;
2188            let mut description = data.task(&id)?.description.clone();
2189            let description_index = subtask_description_index(&description, index)
2190                .map_err(|error| StoreError::validation(error.message))?;
2191            let (text, done) = match mutation {
2192                SubtaskMutation::SetDone(requested) => {
2193                    let Block::Todo { text, done } = &mut description[description_index] else {
2194                        unreachable!("subtask index resolved to a non-subtask block")
2195                    };
2196                    *done = requested.unwrap_or(!*done);
2197                    (text.clone(), *done)
2198                }
2199                SubtaskMutation::Edit(replacement) => {
2200                    let Block::Todo { text, done } = &mut description[description_index] else {
2201                        unreachable!("subtask index resolved to a non-subtask block")
2202                    };
2203                    *text = replacement.to_string();
2204                    (text.clone(), *done)
2205                }
2206                SubtaskMutation::Delete => {
2207                    let Block::Todo { text, done } = description.remove(description_index) else {
2208                        unreachable!("subtask index resolved to a non-subtask block")
2209                    };
2210                    (text, done)
2211                }
2212            };
2213            let task = data.edit_task(
2214                &id,
2215                TaskPatch {
2216                    description: Some(description),
2217                    ..TaskPatch::default()
2218                },
2219            )?;
2220            Ok((task, text, done))
2221        })
2222        .map_err(Into::into)
2223}
2224
2225fn cmd_subtask_set_done(
2226    store: &mut Store,
2227    query: &str,
2228    index: usize,
2229    done: Option<bool>,
2230    json_mode: bool,
2231) -> Result<Rendered, CliError> {
2232    let (task, text, new_done) =
2233        mutate_subtask(store, query, index, SubtaskMutation::SetDone(done))?;
2234    Ok(rendered(
2235        json_mode,
2236        || {
2237            json!({
2238                "task_id": task.id,
2239                "index": index,
2240                "text": text,
2241                "done": new_done,
2242                "subtasks": subtasks_json(&task.description),
2243            })
2244        },
2245        || {
2246            format!(
2247                "{} subtask {index} on {}  {}\n",
2248                if new_done { "done" } else { "undone" },
2249                terminal_text(&short_id(&task.id)),
2250                terminal_text(&text)
2251            )
2252        },
2253    ))
2254}
2255
2256fn cmd_subtask_edit(
2257    store: &mut Store,
2258    query: &str,
2259    index: usize,
2260    text: &str,
2261    json_mode: bool,
2262) -> Result<Rendered, CliError> {
2263    let text = text.trim().to_string();
2264    if text.is_empty() {
2265        return Err(CliError::validation(
2266            "subtask text required (positional or --text)",
2267        ));
2268    }
2269    let (task, _, done) = mutate_subtask(store, query, index, SubtaskMutation::Edit(&text))?;
2270    Ok(rendered(
2271        json_mode,
2272        || {
2273            json!({
2274                "task_id": task.id,
2275                "index": index,
2276                "text": text,
2277                "done": done,
2278                "subtasks": subtasks_json(&task.description),
2279            })
2280        },
2281        || {
2282            format!(
2283                "updated subtask {index} on {}  {}\n",
2284                terminal_text(&short_id(&task.id)),
2285                terminal_text(&text)
2286            )
2287        },
2288    ))
2289}
2290
2291fn cmd_subtask_delete(
2292    store: &mut Store,
2293    query: &str,
2294    index: usize,
2295    json_mode: bool,
2296) -> Result<Rendered, CliError> {
2297    let (task, text, done) = mutate_subtask(store, query, index, SubtaskMutation::Delete)?;
2298    Ok(rendered(
2299        json_mode,
2300        || {
2301            json!({
2302                "task_id": task.id,
2303                "deleted": { "index": index, "text": text, "done": done },
2304                "subtasks": subtasks_json(&task.description),
2305            })
2306        },
2307        || {
2308            format!(
2309                "deleted subtask {index} on {}  {}\n",
2310                terminal_text(&short_id(&task.id)),
2311                terminal_text(&text)
2312            )
2313        },
2314    ))
2315}