Skip to main content

mach/
cli.rs

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