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