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