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