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