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