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 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 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: Vec<OsString> = 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
506fn terminal_error(error: io::Error) -> CliError {
507    CliError {
508        kind: "terminal",
509        message: error.to_string(),
510    }
511}
512
513fn requested_json(arguments: &[OsString]) -> bool {
514    arguments
515        .iter()
516        .skip(1)
517        .take_while(|argument| argument.as_os_str() != "--")
518        .any(|argument| argument.as_os_str() == "--json")
519}
520
521fn emit_parse_error(error: clap::Error, json_mode: bool) -> ! {
522    let help = matches!(
523        error.kind(),
524        clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion
525    );
526    let exit_code = if help { 0 } else { error.exit_code() };
527    if !json_mode {
528        let write = if help {
529            Rendered::Plain(error.to_string()).emit()
530        } else {
531            write_stderr(&format!("{}\n", terminal_text(&error.to_string())))
532        };
533        exit_after_write(write, exit_code);
534    }
535    let value = if help {
536        json!({ "ok": true, "kind": "help", "help": error.to_string() })
537    } else {
538        json!({ "ok": false, "kind": "usage", "error": error.to_string() })
539    };
540    exit_after_write(Rendered::Json(value).emit(), exit_code);
541}
542
543fn emit_runtime_error(error: CliError, json_mode: bool) -> ! {
544    let write = if json_mode {
545        Rendered::Json(json!({
546            "ok": false,
547            "kind": error.kind,
548            "error": error.message,
549        }))
550        .emit()
551    } else {
552        write_stderr(&format!("mach: {}\n", terminal_text(&error.message)))
553    };
554    exit_after_write(write, 1);
555}
556
557fn emit_success(output: Rendered) {
558    if let Err(error) = output.emit() {
559        if error.kind() == io::ErrorKind::BrokenPipe {
560            return;
561        }
562        let _ = write_stderr(&format!("mach: could not write output: {error}\n"));
563        std::process::exit(1);
564    }
565}
566
567fn write_stderr(text: &str) -> io::Result<()> {
568    let stderr = io::stderr();
569    stderr.lock().write_all(text.as_bytes())
570}
571
572fn exit_after_write(result: io::Result<()>, intended_code: i32) -> ! {
573    match result {
574        Ok(()) => std::process::exit(intended_code),
575        Err(error) if error.kind() == io::ErrorKind::BrokenPipe => std::process::exit(0),
576        Err(_) => std::process::exit(1),
577    }
578}
579
580fn dispatch(store: &mut Store, command: Command, json_mode: bool) -> Result<Rendered, CliError> {
581    match command {
582        Command::List {
583            category,
584            open,
585            done,
586        } => cmd_list(store, category.as_deref(), open, done, json_mode),
587        Command::Categories { action } => match action {
588            None | Some(CatAction::List) => cmd_categories_list(store, json_mode),
589            Some(CatAction::Add { name, description }) => {
590                cmd_category_add(store, &name, description.as_deref(), json_mode)
591            }
592            Some(CatAction::Edit {
593                name,
594                new_name,
595                description,
596                clear_description,
597            }) => cmd_category_edit(
598                store,
599                &name,
600                new_name.as_deref(),
601                description.as_deref(),
602                clear_description,
603                json_mode,
604            ),
605            Some(CatAction::Delete { name }) => cmd_category_delete(store, &name, json_mode),
606        },
607        Command::Add(arguments) => cmd_add(store, &arguments, json_mode),
608        Command::Show { id } => cmd_show(store, &id, json_mode),
609        Command::Done { id } => cmd_set_done(store, &id, true, json_mode),
610        Command::Undone { id } => cmd_set_done(store, &id, false, json_mode),
611        Command::Delete { id } => cmd_delete(store, &id, json_mode),
612        Command::Move { id, before, after } => {
613            cmd_move(store, &id, before.as_deref(), after.as_deref(), json_mode)
614        }
615        Command::Purge { done, category } => cmd_purge(store, done, category.as_deref(), json_mode),
616        Command::Edit(arguments) => cmd_edit(store, &arguments, json_mode),
617        Command::Subtasks { task, action } => match action {
618            None | Some(SubAction::List) => cmd_subtasks_list(store, &task, json_mode),
619            Some(SubAction::Add {
620                text_pos,
621                text,
622                done,
623            }) => cmd_subtask_add(
624                store,
625                &task,
626                &text.or(text_pos).unwrap_or_default(),
627                done,
628                json_mode,
629            ),
630            Some(SubAction::Done { index }) => {
631                cmd_subtask_set_done(store, &task, index, Some(true), json_mode)
632            }
633            Some(SubAction::Undone { index }) => {
634                cmd_subtask_set_done(store, &task, index, Some(false), json_mode)
635            }
636            Some(SubAction::Toggle { index }) => {
637                cmd_subtask_set_done(store, &task, index, None, json_mode)
638            }
639            Some(SubAction::Edit {
640                index,
641                text_pos,
642                text,
643            }) => cmd_subtask_edit(
644                store,
645                &task,
646                index,
647                &text.or(text_pos).unwrap_or_default(),
648                json_mode,
649            ),
650            Some(SubAction::Delete { index }) => cmd_subtask_delete(store, &task, index, json_mode),
651        },
652        Command::Export { file } => cmd_export(store, file.as_deref(), json_mode),
653        Command::Import { file } => cmd_import(store, &file, json_mode),
654        Command::Update { .. } => Err(CliError {
655            kind: "internal",
656            message: "update command crossed the data-command boundary".into(),
657        }),
658    }
659}
660
661fn cmd_export(
662    store: &Store,
663    path: Option<&std::path::Path>,
664    json_mode: bool,
665) -> Result<Rendered, CliError> {
666    let summary = crate::archive::export(store, path)?;
667    let value = json!({
668        "ok": true,
669        "archive": summary.path.display().to_string(),
670        "tasks": summary.tasks,
671        "categories": summary.categories,
672        "images": summary.images,
673    });
674    let contents =
675        crate::archive::content_count_text(summary.tasks, summary.categories, summary.images);
676    let plain = format!(
677        "exported {contents} to {}\n",
678        terminal_text(&summary.path.display().to_string())
679    );
680    Ok(rendered(json_mode, value, plain))
681}
682
683fn cmd_import(
684    store: &mut Store,
685    path: &std::path::Path,
686    json_mode: bool,
687) -> Result<Rendered, CliError> {
688    let outcome = crate::archive::import(store, path)?;
689    let summary = outcome.summary;
690    let value = json!({
691        "ok": true,
692        "archive": summary.path.display().to_string(),
693        "tasks_added": summary.tasks_added,
694        "tasks_unchanged": summary.tasks_unchanged,
695        "categories_added": summary.categories_added,
696        "categories_unchanged": summary.categories_unchanged,
697        "images_added": summary.images_added,
698        "images_unchanged": summary.images_unchanged,
699    });
700    let added = crate::archive::content_count_text(
701        summary.tasks_added,
702        summary.categories_added,
703        summary.images_added,
704    );
705    let unchanged = crate::archive::content_count_text(
706        summary.tasks_unchanged,
707        summary.categories_unchanged,
708        summary.images_unchanged,
709    );
710    let plain = if summary.tasks_added + summary.categories_added + summary.images_added == 0 {
711        format!("nothing imported; {unchanged} already present\n")
712    } else {
713        format!("imported {added}; {unchanged} already present\n")
714    };
715    Ok(rendered(json_mode, value, plain))
716}
717
718fn cmd_update(do_install: bool, json_mode: bool) -> Result<Rendered, CliError> {
719    let now = Utc::now().timestamp();
720    let mut update_state = crate::update_state::UpdateStateStore::open_default().ok();
721    let lease = update_state
722        .as_mut()
723        .and_then(|store| store.claim_manual(now).ok());
724    let checked = match crate::update::check_with_etag(None) {
725        Ok(crate::update::CheckResponse::Modified { info, etag }) => (info, etag),
726        Ok(crate::update::CheckResponse::NotModified) => {
727            if let (Some(store), Some(lease)) = (update_state.as_mut(), lease.as_ref()) {
728                let _ = store.finish_failure(lease, Utc::now().timestamp(), None);
729            }
730            return Err(CliError::update(
731                "GitHub returned 304 without a conditional request",
732            ));
733        }
734        Err(error) => {
735            if let (Some(store), Some(lease)) = (update_state.as_mut(), lease.as_ref()) {
736                let _ = store.finish_failure(lease, Utc::now().timestamp(), error.retry_at);
737            }
738            return Err(CliError::update(error.message));
739        }
740    };
741    let (info, etag) = checked;
742    let install = if do_install && info.newer {
743        crate::update::install(&info).map(Some)
744    } else {
745        Ok(None)
746    };
747    if let (Some(store), Some(lease)) = (update_state.as_mut(), lease.as_ref()) {
748        let _ = store.finish_modified(lease, Utc::now().timestamp(), etag.as_deref(), &info.latest);
749    }
750    let install = install.map_err(CliError::update)?;
751
752    let value = json!({
753        "ok": true,
754        "current": info.current,
755        "latest": info.latest,
756        "newer": info.newer,
757        "prerelease": info.prerelease,
758        "url": info.release_url,
759        "installed": install.is_some(),
760        "destination": install
761            .as_ref()
762            .map(|result| result.destination.display().to_string()),
763        "tag": install.as_ref().map(|result| result.tag.as_str()).unwrap_or(&info.tag),
764    });
765    let mut plain = format!("{}\n", terminal_text(&info.summary()));
766    if info.newer && install.is_none() {
767        plain.push('\n');
768        for line in info.install_hint().lines() {
769            plain.push_str(&terminal_text(line));
770            plain.push('\n');
771        }
772    }
773    if do_install && install.is_none() {
774        plain.push_str("Already up to date.\n");
775    } else if let Some(result) = install {
776        plain.push_str(&format!(
777            "Installed {} to {}. Restart mach to use the new build.\n",
778            terminal_text(&result.tag),
779            terminal_text(&result.destination.display().to_string())
780        ));
781    }
782    Ok(rendered(json_mode, value, plain))
783}
784
785// ---------------------------------------------------------------- helpers
786
787fn short_id(id: &str) -> String {
788    id.chars().take(8).collect()
789}
790
791fn terminal_text(text: &str) -> String {
792    let mut safe = String::with_capacity(text.len());
793    for character in text.chars() {
794        match character {
795            '\n' => safe.push_str("\\n"),
796            '\r' => safe.push_str("\\r"),
797            '\t' => safe.push_str("\\t"),
798            character if character.is_control() => {
799                safe.push_str(&format!("\\u{{{:x}}}", character as u32));
800            }
801            character => safe.push(character),
802        }
803    }
804    safe
805}
806
807fn body_from_text(text: &str) -> Vec<Block> {
808    if text.is_empty() {
809        return Vec::new();
810    }
811    text.lines().map(line_to_block).collect()
812}
813
814fn line_to_block(line: &str) -> Block {
815    let text = line.trim_end();
816    if let Some(rest) = text.strip_prefix("[ ] ") {
817        return Block::todo(rest, false);
818    }
819    if let Some(rest) = text
820        .strip_prefix("[x] ")
821        .or_else(|| text.strip_prefix("[X] "))
822        .or_else(|| text.strip_prefix("[✓] "))
823    {
824        return Block::todo(rest, true);
825    }
826    if let Some(rest) = text.strip_prefix("- ").or_else(|| text.strip_prefix("• ")) {
827        return Block::bullet(rest);
828    }
829    if let Some(rest) = strip_number_prefix(text) {
830        return Block::number(rest);
831    }
832    if let Some(path) = text
833        .strip_prefix("[image:")
834        .and_then(|value| value.strip_suffix(']'))
835        .filter(|path| !path.is_empty())
836    {
837        return Block::image(path);
838    }
839    if text.starts_with("http://") || text.starts_with("https://") {
840        return Block::link(text);
841    }
842    Block::text(text)
843}
844
845fn strip_number_prefix(line: &str) -> Option<&str> {
846    let bytes = line.as_bytes();
847    let mut index = 0;
848    while index < bytes.len() && bytes[index].is_ascii_digit() {
849        index += 1;
850    }
851    if index == 0 {
852        return None;
853    }
854    line.get(index..)?.strip_prefix(". ")
855}
856
857fn body_to_text(body: &[Block]) -> String {
858    let mut numbered = 0usize;
859    body.iter()
860        .map(|block| match block {
861            Block::Text { text } => {
862                numbered = 0;
863                text.clone()
864            }
865            Block::Todo { text, done } => {
866                numbered = 0;
867                format!("[{}] {text}", if *done { "x" } else { " " })
868            }
869            Block::Bullet { text } => {
870                numbered = 0;
871                format!("- {text}")
872            }
873            Block::Number { text } => {
874                numbered += 1;
875                format!("{numbered}. {text}")
876            }
877            Block::Link { url } => {
878                numbered = 0;
879                url.clone()
880            }
881            Block::Image { attachment_id } => {
882                numbered = 0;
883                format!("[image:{attachment_id}]")
884            }
885        })
886        .collect::<Vec<_>>()
887        .join("\n")
888}
889
890fn collect_subtasks(body: &[Block]) -> Vec<(usize, &str, bool)> {
891    body.iter()
892        .filter_map(|block| match block {
893            Block::Todo { text, done } => Some((text.as_str(), *done)),
894            _ => None,
895        })
896        .enumerate()
897        .map(|(index, (text, done))| (index + 1, text, done))
898        .collect()
899}
900
901fn subtask_body_index(body: &[Block], one_based: usize) -> Result<usize, CliError> {
902    if one_based == 0 {
903        return Err(CliError::validation(
904            "subtask index is 1-based (use 1 for the first subtask)",
905        ));
906    }
907    let mut count = 0usize;
908    for (body_index, block) in body.iter().enumerate() {
909        if matches!(block, Block::Todo { .. }) {
910            count += 1;
911            if count == one_based {
912                return Ok(body_index);
913            }
914        }
915    }
916    Err(CliError::validation(format!(
917        "no subtask at index {one_based} (task has {count} subtask(s))"
918    )))
919}
920
921fn subtasks_json(body: &[Block]) -> Vec<Value> {
922    subtasks_to_json(&collect_subtasks(body))
923}
924
925fn subtasks_to_json(subtasks: &[(usize, &str, bool)]) -> Vec<Value> {
926    subtasks
927        .iter()
928        .map(|(index, text, done)| json!({ "index": index, "text": text, "done": done }))
929        .collect()
930}
931
932fn category_name<'a>(categories: &'a [Category], task: &Task) -> Option<&'a str> {
933    task.category_id
934        .as_ref()
935        .and_then(|id| categories.iter().find(|category| category.id == *id))
936        .map(|category| category.name.as_str())
937}
938
939fn task_json(categories: &[Category], task: &Task) -> Value {
940    let subtasks = collect_subtasks(&task.body);
941    let subtasks_json = subtasks_to_json(&subtasks);
942    json!({
943        "id": task.id,
944        "title": task.title,
945        "body": body_to_text(&task.body),
946        "subtasks": subtasks_json,
947        "subtasks_done": subtasks.iter().filter(|(_, _, done)| *done).count(),
948        "subtasks_total": subtasks.len(),
949        "due": task.due,
950        "done": task.done,
951        "importance": task.importance,
952        "category": {
953            "id": task.category_id,
954            "name": category_name(categories, task),
955        },
956        "created": task.created,
957    })
958}
959
960fn category_json(category: &Category) -> Value {
961    json!({
962        "id": category.id,
963        "name": category.name,
964        "description": category.description,
965    })
966}
967
968fn validate_time(raw: &str) -> Result<String, CliError> {
969    let value = raw.trim();
970    if value.len() == 5 && NaiveTime::parse_from_str(value, "%H:%M").is_ok() {
971        Ok(value.to_string())
972    } else {
973        Err(CliError::validation(format!(
974            "invalid time {raw:?}; use HH:MM (24h), e.g. 14:30"
975        )))
976    }
977}
978
979fn due_for_add(due: Option<&str>, time: Option<&str>) -> Result<String, CliError> {
980    let due = due.map(str::trim).filter(|value| !value.is_empty());
981    match (due, time) {
982        (None, None) => Ok(String::new()),
983        (None, Some(time)) => validate_time(time),
984        (Some(due), None) => Ok(due.to_string()),
985        (Some(due), Some(time)) => {
986            if due.contains(':') {
987                return Err(CliError::validation(format!(
988                    "due already includes a time ({due}); omit --time or pass date-only --due"
989                )));
990            }
991            Ok(format!("{due} {}", validate_time(time)?))
992        }
993    }
994}
995
996fn split_inline_title(raw: &str) -> Result<(String, String), CliError> {
997    let (inline_due, title) = crate::due::parse(raw.trim());
998    if !inline_due.is_empty() {
999        crate::due::normalize_for_write(&inline_due)
1000            .map_err(|error| CliError::validation(error.to_string()))?;
1001    }
1002    Ok((title, inline_due))
1003}
1004
1005/// Resolve edit due semantics inside the write transaction so shorthand is
1006/// anchored at the actual write time.
1007fn due_for_edit(
1008    current: &str,
1009    due: Option<&str>,
1010    time: Option<&str>,
1011) -> Result<Option<String>, CliError> {
1012    if due.is_none() && time.is_none() {
1013        return Ok(None);
1014    }
1015    let existing_time = current.split_once(' ').map(|(_, time)| time.to_string());
1016    let existing_date = current
1017        .split_once(' ')
1018        .map(|(date, _)| date.to_string())
1019        .or_else(|| (!current.is_empty() && !current.contains(':')).then(|| current.to_string()));
1020
1021    if let Some(raw_due) = due {
1022        let raw_due = raw_due.trim();
1023        if raw_due.contains(':') {
1024            if time.is_some() {
1025                return Err(CliError::validation(
1026                    "pass either a full --due datetime or --due date + --time, not both",
1027                ));
1028            }
1029            return crate::due::normalize_for_write(raw_due)
1030                .map(Some)
1031                .map_err(|error| CliError::validation(error.to_string()));
1032        }
1033        let chosen_time = match time {
1034            Some(time) => Some(validate_time(time)?),
1035            None => existing_time,
1036        };
1037        let combined = match chosen_time {
1038            Some(time) => format!("{raw_due} {time}"),
1039            None => raw_due.to_string(),
1040        };
1041        return crate::due::normalize_for_write(&combined)
1042            .map(Some)
1043            .map_err(|error| CliError::validation(error.to_string()));
1044    }
1045
1046    let Some(time) = time else {
1047        return Ok(None);
1048    };
1049    let time = validate_time(time)?;
1050    let combined = match existing_date {
1051        Some(date) => format!("{date} {time}"),
1052        None => time,
1053    };
1054    crate::due::normalize_for_write(&combined)
1055        .map(Some)
1056        .map_err(|error| CliError::validation(error.to_string()))
1057}
1058
1059fn task_line(
1060    categories: &[Category],
1061    task: &Task,
1062    show_category: bool,
1063    date_format: &str,
1064) -> String {
1065    let check = if task.done { "[✓]" } else { "[ ]" };
1066    let title = terminal_text(&task.title);
1067    let due = crate::due::display(&task.due, date_format);
1068    let due = if due.is_empty() {
1069        String::new()
1070    } else {
1071        format!("  {}", terminal_text(&due))
1072    };
1073    let flag = if task.importance > 0 {
1074        format!("  {}", crate::model::importance_marks(task.importance))
1075    } else {
1076        String::new()
1077    };
1078    let category = if show_category {
1079        format!(
1080            "  [{}]",
1081            terminal_text(category_name(categories, task).unwrap_or("—"))
1082        )
1083    } else {
1084        String::new()
1085    };
1086    let progress = crate::model::todo_progress(task)
1087        .map(|(done, total)| format!("  ({done}/{total})"))
1088        .unwrap_or_default();
1089    format!(
1090        "{} {check} {title}{category}{due}{flag}{progress}\n",
1091        terminal_text(&short_id(&task.id))
1092    )
1093}
1094
1095// ---------------------------------------------------------------- commands
1096
1097fn cmd_list(
1098    store: &Store,
1099    category: Option<&str>,
1100    open_only: bool,
1101    done_only: bool,
1102    json_mode: bool,
1103) -> Result<Rendered, CliError> {
1104    let data = store.snapshot()?;
1105    let category_id = category
1106        .map(|query| data.resolve_category_id(query))
1107        .transpose()?;
1108    let show_category = category_id.is_none();
1109    let tasks: Vec<_> = data
1110        .tasks
1111        .iter()
1112        .filter(|task| {
1113            category_id
1114                .as_ref()
1115                .is_none_or(|id| task.category_id.as_deref() == Some(id.as_str()))
1116        })
1117        .filter(|task| {
1118            if open_only {
1119                !task.done
1120            } else if done_only {
1121                task.done
1122            } else {
1123                true
1124            }
1125        })
1126        .collect();
1127    let value = Value::Array(
1128        tasks
1129            .iter()
1130            .map(|task| task_json(&data.categories, task))
1131            .collect(),
1132    );
1133    let mut plain = String::new();
1134    if tasks.is_empty() {
1135        plain.push_str("(no tasks)\n");
1136    } else {
1137        for task in &tasks {
1138            plain.push_str(&task_line(
1139                &data.categories,
1140                task,
1141                show_category,
1142                &data.settings.date_format,
1143            ));
1144        }
1145        let done = tasks.iter().filter(|task| task.done).count();
1146        plain.push_str(&format!("— {} task(s), {done} done\n", tasks.len()));
1147    }
1148    Ok(rendered(json_mode, value, plain))
1149}
1150
1151fn cmd_categories_list(store: &Store, json_mode: bool) -> Result<Rendered, CliError> {
1152    let data = store.snapshot()?;
1153    let stats: Vec<_> = data
1154        .categories
1155        .iter()
1156        .map(|category| {
1157            let (done, total) = data
1158                .tasks
1159                .iter()
1160                .filter(|task| task.category_id.as_deref() == Some(category.id.as_str()))
1161                .fold((0, 0), |(done, total), task| {
1162                    (done + usize::from(task.done), total + 1)
1163                });
1164            (category, done, total)
1165        })
1166        .collect();
1167    let categories: Vec<_> = stats
1168        .iter()
1169        .map(|(category, done, total)| {
1170            json!({
1171                "id": category.id,
1172                "name": category.name,
1173                "description": category.description,
1174                "total": total,
1175                "done": done,
1176            })
1177        })
1178        .collect();
1179    let (uncategorized_done, uncategorized_total) = data
1180        .tasks
1181        .iter()
1182        .filter(|task| task.category_id.is_none())
1183        .fold((0, 0), |(done, total), task| {
1184            (done + usize::from(task.done), total + 1)
1185        });
1186    let value = json!({
1187        "categories": categories,
1188        "uncategorized": {
1189            "total": uncategorized_total,
1190            "done": uncategorized_done,
1191        },
1192    });
1193    let mut plain = String::new();
1194    if data.categories.is_empty() {
1195        plain.push_str("(no categories)\n");
1196    } else {
1197        for (category, done, total) in &stats {
1198            plain.push_str(&format!(
1199                "{}  {}/{}\n",
1200                terminal_text(&category.name),
1201                done,
1202                total
1203            ));
1204        }
1205    }
1206    if uncategorized_total > 0 {
1207        plain.push_str(&format!(
1208            "— uncategorized  {}/{}\n",
1209            uncategorized_done, uncategorized_total
1210        ));
1211    }
1212    Ok(rendered(json_mode, value, plain))
1213}
1214
1215fn cmd_category_add(
1216    store: &mut Store,
1217    name: &str,
1218    description: Option<&str>,
1219    json_mode: bool,
1220) -> Result<Rendered, CliError> {
1221    let name = name.trim().to_string();
1222    let description = description.unwrap_or_default().to_string();
1223    let category = store.update(|data| data.create_category(name, description))?;
1224    Ok(rendered(
1225        json_mode,
1226        category_json(&category),
1227        format!("created category {}\n", terminal_text(&category.name)),
1228    ))
1229}
1230
1231fn cmd_category_edit(
1232    store: &mut Store,
1233    query: &str,
1234    new_name: Option<&str>,
1235    description: Option<&str>,
1236    clear_description: bool,
1237    json_mode: bool,
1238) -> Result<Rendered, CliError> {
1239    if new_name.is_none() && description.is_none() && !clear_description {
1240        return Err(CliError::validation(
1241            "nothing to edit; pass --name / --description / --clear-description",
1242        ));
1243    }
1244    if clear_description && description.is_some() {
1245        return Err(CliError::validation(
1246            "--clear-description cannot be combined with --description",
1247        ));
1248    }
1249    let patch = CategoryPatch {
1250        name: new_name.map(|name| name.trim().to_string()),
1251        description: if clear_description {
1252            Some(String::new())
1253        } else {
1254            description.map(str::to_string)
1255        },
1256    };
1257    let query = query.to_string();
1258    let category = store.update(|data| {
1259        let id = data.resolve_category_id(&query)?;
1260        data.edit_category(&id, patch)
1261    })?;
1262    Ok(rendered(
1263        json_mode,
1264        category_json(&category),
1265        format!("updated category {}\n", terminal_text(&category.name)),
1266    ))
1267}
1268
1269fn cmd_category_delete(
1270    store: &mut Store,
1271    query: &str,
1272    json_mode: bool,
1273) -> Result<Rendered, CliError> {
1274    let query = query.to_string();
1275    let category = store.update(|data| {
1276        let id = data.resolve_category_id(&query)?;
1277        data.delete_category(&id)
1278    })?;
1279    Ok(rendered(
1280        json_mode,
1281        json!({ "deleted": category.name, "id": category.id }),
1282        format!(
1283            "deleted category {} (tasks uncategorized)\n",
1284            terminal_text(&category.name)
1285        ),
1286    ))
1287}
1288
1289fn cmd_add(store: &mut Store, arguments: &AddArgs, json_mode: bool) -> Result<Rendered, CliError> {
1290    let raw_title = arguments
1291        .title
1292        .as_deref()
1293        .or(arguments.title_pos.as_deref())
1294        .unwrap_or_default()
1295        .trim();
1296    let (title, inline_due) = split_inline_title(raw_title)?;
1297    if title.is_empty() {
1298        return Err(CliError::validation(
1299            "title required (positional or --title)",
1300        ));
1301    }
1302    let mut body = arguments
1303        .body
1304        .as_deref()
1305        .map(body_from_text)
1306        .unwrap_or_default();
1307    for subtask in &arguments.subtasks {
1308        let text = subtask.trim();
1309        if text.is_empty() {
1310            return Err(CliError::validation("--subtask text cannot be empty"));
1311        }
1312        body.push(Block::todo(text, false));
1313    }
1314    let due = if arguments.due.is_none() && arguments.time.is_none() {
1315        inline_due
1316    } else {
1317        due_for_add(arguments.due.as_deref(), arguments.time.as_deref())?
1318    };
1319    let category_query = arguments.category.clone();
1320    let importance = arguments.importance;
1321    let (task_id, snapshot) = store.update_with_snapshot(|data| {
1322        let category_id = category_query
1323            .as_deref()
1324            .map(|query| data.resolve_category_id(query))
1325            .transpose()?;
1326        let task = data.create_task(title, body, due, importance, category_id)?;
1327        Ok(task.id)
1328    })?;
1329    let task = snapshot.task(&task_id)?.clone();
1330    let categories = snapshot.categories;
1331    let subtasks = collect_subtasks(&task.body).len();
1332    let plain = if subtasks == 0 {
1333        format!(
1334            "added {}  {}\n",
1335            terminal_text(&short_id(&task.id)),
1336            terminal_text(&task.title)
1337        )
1338    } else {
1339        format!(
1340            "added {}  {}  ({} subtask{})\n",
1341            terminal_text(&short_id(&task.id)),
1342            terminal_text(&task.title),
1343            subtasks,
1344            if subtasks == 1 { "" } else { "s" }
1345        )
1346    };
1347    Ok(rendered(json_mode, task_json(&categories, &task), plain))
1348}
1349
1350fn cmd_show(store: &Store, query: &str, json_mode: bool) -> Result<Rendered, CliError> {
1351    let data = store.snapshot()?;
1352    let id = data.resolve_task_id(query)?;
1353    let task = data.task(&id)?;
1354    let mut plain = format!(
1355        "id:         {}\ntitle:      {}\ndone:       {}\ncategory:   {}\ndue:        {}\nimportance: {} ({})\ncreated:    {}\n",
1356        terminal_text(&task.id),
1357        terminal_text(&task.title),
1358        task.done,
1359        terminal_text(category_name(&data.categories, task).unwrap_or("—")),
1360        if task.due.is_empty() {
1361            "—".into()
1362        } else {
1363            terminal_text(&task.due)
1364        },
1365        task.importance,
1366        crate::model::importance_marks(task.importance),
1367        terminal_text(&task.created),
1368    );
1369    let subtasks = collect_subtasks(&task.body);
1370    if subtasks.is_empty() {
1371        plain.push_str("subtasks:   —\n");
1372    } else {
1373        plain.push_str(&format!(
1374            "subtasks:   {}/{}\n",
1375            subtasks.iter().filter(|(_, _, done)| *done).count(),
1376            subtasks.len()
1377        ));
1378        for (index, text, done) in &subtasks {
1379            plain.push_str(&format!(
1380                "  {index}. {} {}\n",
1381                if *done { "[✓]" } else { "[ ]" },
1382                terminal_text(text)
1383            ));
1384        }
1385    }
1386    let notes = body_note_lines(&task.body);
1387    if notes.is_empty() {
1388        plain.push_str("body:       —\n");
1389    } else {
1390        plain.push_str("body:\n");
1391        for note in notes {
1392            plain.push_str(&format!("  {}\n", terminal_text(&note)));
1393        }
1394    }
1395    Ok(rendered(
1396        json_mode,
1397        task_json(&data.categories, task),
1398        plain,
1399    ))
1400}
1401
1402fn body_note_lines(body: &[Block]) -> Vec<String> {
1403    let mut numbered = 0usize;
1404    let mut notes = Vec::new();
1405    for block in body {
1406        match block {
1407            Block::Todo { .. } => numbered = 0,
1408            Block::Text { text } => {
1409                numbered = 0;
1410                if !text.trim().is_empty() {
1411                    notes.push(text.clone());
1412                }
1413            }
1414            Block::Bullet { text } => {
1415                numbered = 0;
1416                notes.push(format!("- {text}"));
1417            }
1418            Block::Number { text } => {
1419                numbered += 1;
1420                notes.push(format!("{numbered}. {text}"));
1421            }
1422            Block::Link { url } => {
1423                numbered = 0;
1424                notes.push(url.clone());
1425            }
1426            Block::Image { attachment_id } => {
1427                numbered = 0;
1428                notes.push(format!("[image:{attachment_id}]"));
1429            }
1430        }
1431    }
1432    notes
1433}
1434
1435fn cmd_set_done(
1436    store: &mut Store,
1437    query: &str,
1438    done: bool,
1439    json_mode: bool,
1440) -> Result<Rendered, CliError> {
1441    let query = query.to_string();
1442    let (task, snapshot) = store.update_with_snapshot(|data| {
1443        let id = data.resolve_task_id(&query)?;
1444        data.set_task_done(&id, done)
1445    })?;
1446    let categories = snapshot.categories;
1447    Ok(rendered(
1448        json_mode,
1449        task_json(&categories, &task),
1450        format!(
1451            "{} {}  {}\n",
1452            if done { "done" } else { "undone" },
1453            terminal_text(&short_id(&task.id)),
1454            terminal_text(&task.title)
1455        ),
1456    ))
1457}
1458
1459fn cmd_delete(store: &mut Store, query: &str, json_mode: bool) -> Result<Rendered, CliError> {
1460    let query = query.to_string();
1461    let (task, snapshot) = store.update_with_snapshot(|data| {
1462        let id = data.resolve_task_id(&query)?;
1463        data.delete_task(&id)
1464    })?;
1465    let categories = snapshot.categories;
1466    Ok(rendered(
1467        json_mode,
1468        task_json(&categories, &task),
1469        format!(
1470            "deleted {}  {}\n",
1471            terminal_text(&short_id(&task.id)),
1472            terminal_text(&task.title)
1473        ),
1474    ))
1475}
1476
1477fn cmd_move(
1478    store: &mut Store,
1479    query: &str,
1480    before: Option<&str>,
1481    after: Option<&str>,
1482    json_mode: bool,
1483) -> Result<Rendered, CliError> {
1484    let (relation, position, target_query) = match (before, after) {
1485        (Some(target), None) => ("before", RelativePosition::Before, target),
1486        (None, Some(target)) => ("after", RelativePosition::After, target),
1487        _ => {
1488            return Err(CliError::validation(
1489                "pass exactly one of --before or --after",
1490            ));
1491        }
1492    };
1493    let query = query.to_string();
1494    let target_query = target_query.to_string();
1495    let ((task, target), snapshot) = store.update_with_snapshot(|data| {
1496        let id = data.resolve_task_id(&query)?;
1497        let target_id = data.resolve_task_id(&target_query)?;
1498        let target = data.task(&target_id)?.clone();
1499        let task = data.move_task_relative(&id, &target_id, position)?;
1500        Ok((task, target))
1501    })?;
1502    let categories = snapshot.categories;
1503    let value = json!({
1504        "moved": task_json(&categories, &task),
1505        "relation": relation,
1506        "target": { "id": target.id, "title": target.title },
1507    });
1508    let plain = format!(
1509        "moved {} {relation} {}\n",
1510        terminal_text(&short_id(&task.id)),
1511        terminal_text(&short_id(&target.id))
1512    );
1513    Ok(rendered(json_mode, value, plain))
1514}
1515
1516fn cmd_purge(
1517    store: &mut Store,
1518    confirmed_done: bool,
1519    category: Option<&str>,
1520    json_mode: bool,
1521) -> Result<Rendered, CliError> {
1522    if !confirmed_done {
1523        return Err(CliError::validation(
1524            "refusing to purge without the explicit --done flag",
1525        ));
1526    }
1527    let category = category.map(str::to_string);
1528    let (removed, snapshot) = store.update_with_snapshot(|data| {
1529        let scope = match category.as_deref() {
1530            Some(query) => PurgeScope::Category(data.resolve_category_id(query)?),
1531            None => PurgeScope::All,
1532        };
1533        data.purge_completed(&scope)
1534    })?;
1535    let categories = snapshot.categories;
1536    let value = json!({
1537        "purged": removed
1538            .iter()
1539            .map(|task| task_json(&categories, task))
1540            .collect::<Vec<_>>(),
1541        "count": removed.len(),
1542    });
1543    let plain = format!("purged {} completed task(s)\n", removed.len());
1544    Ok(rendered(json_mode, value, plain))
1545}
1546
1547fn cmd_edit(
1548    store: &mut Store,
1549    arguments: &EditArgs,
1550    json_mode: bool,
1551) -> Result<Rendered, CliError> {
1552    if arguments.title.is_none()
1553        && arguments.body.is_none()
1554        && arguments.due.is_none()
1555        && arguments.time.is_none()
1556        && !arguments.clear_due
1557        && arguments.category.is_none()
1558        && !arguments.clear_cat
1559        && arguments.importance.is_none()
1560    {
1561        return Err(CliError::validation(
1562            "nothing to edit; pass --title / --body / --due / --time / --clear-due / --category / --clear-category / --importance",
1563        ));
1564    }
1565    if arguments.clear_due && (arguments.due.is_some() || arguments.time.is_some()) {
1566        return Err(CliError::validation(
1567            "--clear-due cannot be combined with --due / --time",
1568        ));
1569    }
1570    if arguments.clear_cat && arguments.category.is_some() {
1571        return Err(CliError::validation(
1572            "--clear-category cannot be combined with --category",
1573        ));
1574    }
1575    let query = arguments.id.clone();
1576    let (title, inline_due) = match arguments.title.as_deref() {
1577        Some(title) => {
1578            let (title, inline_due) = split_inline_title(title)?;
1579            (Some(title), inline_due)
1580        }
1581        None => (None, String::new()),
1582    };
1583    let body = arguments.body.as_deref().map(body_from_text);
1584    let due_argument = arguments.due.clone();
1585    let time_argument = arguments.time.clone();
1586    let clear_due = arguments.clear_due;
1587    let category_query = arguments.category.clone();
1588    let clear_category = arguments.clear_cat;
1589    let importance = arguments.importance;
1590    let (task_id, snapshot) = store.update_with_snapshot(|data| {
1591        let id = data.resolve_task_id(&query)?;
1592        let due = if clear_due {
1593            Some(String::new())
1594        } else if due_argument.is_none() && time_argument.is_none() && !inline_due.is_empty() {
1595            Some(inline_due)
1596        } else {
1597            due_for_edit(
1598                &data.task(&id)?.due,
1599                due_argument.as_deref(),
1600                time_argument.as_deref(),
1601            )
1602            .map_err(|error| StoreError::validation(error.message))?
1603        };
1604        let category_id = if clear_category {
1605            Some(None)
1606        } else {
1607            category_query
1608                .as_deref()
1609                .map(|query| data.resolve_category_id(query).map(Some))
1610                .transpose()?
1611        };
1612        let task = data.edit_task(
1613            &id,
1614            TaskPatch {
1615                title,
1616                body,
1617                due,
1618                importance,
1619                category_id,
1620                ..TaskPatch::default()
1621            },
1622        )?;
1623        Ok(task.id)
1624    })?;
1625    let task = snapshot.task(&task_id)?.clone();
1626    let categories = snapshot.categories;
1627    Ok(rendered(
1628        json_mode,
1629        task_json(&categories, &task),
1630        format!(
1631            "updated {}  {}\n",
1632            terminal_text(&short_id(&task.id)),
1633            terminal_text(&task.title)
1634        ),
1635    ))
1636}
1637
1638// --------------------------------------------------------------- subtasks
1639
1640fn cmd_subtasks_list(store: &Store, query: &str, json_mode: bool) -> Result<Rendered, CliError> {
1641    let data = store.snapshot()?;
1642    let id = data.resolve_task_id(query)?;
1643    let task = data.task(&id)?;
1644    let subtasks = collect_subtasks(&task.body);
1645    let value = json!({
1646        "task_id": task.id,
1647        "title": task.title,
1648        "subtasks": subtasks_json(&task.body),
1649        "done": subtasks.iter().filter(|(_, _, done)| *done).count(),
1650        "total": subtasks.len(),
1651    });
1652    let mut plain = format!(
1653        "{}  {}",
1654        terminal_text(&short_id(&task.id)),
1655        terminal_text(&task.title)
1656    );
1657    if subtasks.is_empty() {
1658        plain.push_str("  (no subtasks)\n");
1659    } else {
1660        plain.push('\n');
1661        for (index, text, done) in &subtasks {
1662            let check = if *done { "[✓]" } else { "[ ]" };
1663            plain.push_str(&format!("  {index}. {check} {}\n", terminal_text(text)));
1664        }
1665        plain.push_str(&format!(
1666            "— {}/{} done\n",
1667            subtasks.iter().filter(|(_, _, done)| *done).count(),
1668            subtasks.len()
1669        ));
1670    }
1671    Ok(rendered(json_mode, value, plain))
1672}
1673
1674fn cmd_subtask_add(
1675    store: &mut Store,
1676    query: &str,
1677    text: &str,
1678    done: bool,
1679    json_mode: bool,
1680) -> Result<Rendered, CliError> {
1681    let text = text.trim().to_string();
1682    if text.is_empty() {
1683        return Err(CliError::validation(
1684            "subtask text required (positional or --text)",
1685        ));
1686    }
1687    let query = query.to_string();
1688    let (task, index) = store.update(|data| {
1689        let id = data.resolve_task_id(&query)?;
1690        let mut body = data.task(&id)?.body.clone();
1691        body.push(Block::todo(&text, done));
1692        let task = data.edit_task(
1693            &id,
1694            TaskPatch {
1695                body: Some(body),
1696                ..TaskPatch::default()
1697            },
1698        )?;
1699        Ok((task, collect_subtasks(&data.task(&id)?.body).len()))
1700    })?;
1701    Ok(rendered(
1702        json_mode,
1703        json!({
1704            "task_id": task.id,
1705            "index": index,
1706            "text": text,
1707            "done": done,
1708            "subtasks": subtasks_json(&task.body),
1709        }),
1710        format!(
1711            "added subtask {index} on {}  {}\n",
1712            terminal_text(&short_id(&task.id)),
1713            terminal_text(&text)
1714        ),
1715    ))
1716}
1717
1718fn cmd_subtask_set_done(
1719    store: &mut Store,
1720    query: &str,
1721    index: usize,
1722    done: Option<bool>,
1723    json_mode: bool,
1724) -> Result<Rendered, CliError> {
1725    let query = query.to_string();
1726    let (task, text, new_done) = store.update(|data| {
1727        let id = data.resolve_task_id(&query)?;
1728        let mut body = data.task(&id)?.body.clone();
1729        let body_index = subtask_body_index(&body, index)
1730            .map_err(|error| StoreError::validation(error.message))?;
1731        let Block::Todo { text, done: value } = &mut body[body_index] else {
1732            return Err(StoreError::Corrupt(
1733                "resolved subtask index does not point to a subtask".into(),
1734            ));
1735        };
1736        let new_done = done.unwrap_or(!*value);
1737        *value = new_done;
1738        let text = text.clone();
1739        let task = data.edit_task(
1740            &id,
1741            TaskPatch {
1742                body: Some(body),
1743                ..TaskPatch::default()
1744            },
1745        )?;
1746        Ok((task, text, new_done))
1747    })?;
1748    Ok(rendered(
1749        json_mode,
1750        json!({
1751            "task_id": task.id,
1752            "index": index,
1753            "text": text,
1754            "done": new_done,
1755            "subtasks": subtasks_json(&task.body),
1756        }),
1757        format!(
1758            "{} subtask {index} on {}  {}\n",
1759            if new_done { "done" } else { "undone" },
1760            terminal_text(&short_id(&task.id)),
1761            terminal_text(&text)
1762        ),
1763    ))
1764}
1765
1766fn cmd_subtask_edit(
1767    store: &mut Store,
1768    query: &str,
1769    index: usize,
1770    text: &str,
1771    json_mode: bool,
1772) -> Result<Rendered, CliError> {
1773    let text = text.trim().to_string();
1774    if text.is_empty() {
1775        return Err(CliError::validation(
1776            "subtask text required (positional or --text)",
1777        ));
1778    }
1779    let query = query.to_string();
1780    let (task, done) = store.update(|data| {
1781        let id = data.resolve_task_id(&query)?;
1782        let mut body = data.task(&id)?.body.clone();
1783        let body_index = subtask_body_index(&body, index)
1784            .map_err(|error| StoreError::validation(error.message))?;
1785        let Block::Todo {
1786            text: current,
1787            done,
1788        } = &mut body[body_index]
1789        else {
1790            return Err(StoreError::Corrupt(
1791                "resolved subtask index does not point to a subtask".into(),
1792            ));
1793        };
1794        *current = text.clone();
1795        let done = *done;
1796        let task = data.edit_task(
1797            &id,
1798            TaskPatch {
1799                body: Some(body),
1800                ..TaskPatch::default()
1801            },
1802        )?;
1803        Ok((task, done))
1804    })?;
1805    Ok(rendered(
1806        json_mode,
1807        json!({
1808            "task_id": task.id,
1809            "index": index,
1810            "text": text,
1811            "done": done,
1812            "subtasks": subtasks_json(&task.body),
1813        }),
1814        format!(
1815            "updated subtask {index} on {}  {}\n",
1816            terminal_text(&short_id(&task.id)),
1817            terminal_text(&text)
1818        ),
1819    ))
1820}
1821
1822fn cmd_subtask_delete(
1823    store: &mut Store,
1824    query: &str,
1825    index: usize,
1826    json_mode: bool,
1827) -> Result<Rendered, CliError> {
1828    let query = query.to_string();
1829    let (task, text, done) = store.update(|data| {
1830        let id = data.resolve_task_id(&query)?;
1831        let mut body = data.task(&id)?.body.clone();
1832        let body_index = subtask_body_index(&body, index)
1833            .map_err(|error| StoreError::validation(error.message))?;
1834        let Block::Todo { text, done } = body.remove(body_index) else {
1835            return Err(StoreError::Corrupt(
1836                "resolved subtask index does not point to a subtask".into(),
1837            ));
1838        };
1839        let task = data.edit_task(
1840            &id,
1841            TaskPatch {
1842                body: Some(body),
1843                ..TaskPatch::default()
1844            },
1845        )?;
1846        Ok((task, text, done))
1847    })?;
1848    Ok(rendered(
1849        json_mode,
1850        json!({
1851            "task_id": task.id,
1852            "deleted": { "index": index, "text": text, "done": done },
1853            "subtasks": subtasks_json(&task.body),
1854        }),
1855        format!(
1856            "deleted subtask {index} on {}  {}\n",
1857            terminal_text(&short_id(&task.id)),
1858            terminal_text(&text)
1859        ),
1860    ))
1861}