1use std::collections::{HashMap, HashSet};
8use std::ffi::OsString;
9use std::io::{self, Write};
10use std::path::PathBuf;
11
12use chrono::Utc;
13use clap::{Parser, Subcommand, ValueEnum, builder::PossibleValue};
14use serde_json::{Value, json};
15
16use crate::VERSION;
17use crate::model::{Block, Category, Label, LabelColor, Task, caseless_key, task_text_contains};
18use crate::store::{
19 CategoryPatch, LabelPatch, PurgeScope, RelativePosition, Store, StoreData, StoreError,
20 TaskPatch,
21};
22
23const HELP: &str = "\
25 list
26 --query QUERY search titles and descriptions
27 -c, --category NAME only this category
28 --label NAME require label (repeatable; all must match)
29 --open only incomplete
30 --done only completed
31
32 categories
33 (no args) list categories (done/total)
34 add NAME
35 -d, --description TEXT
36 ensure NAME
37 -d, --description TEXT create if missing; conflict if different
38 edit NAME rename / set description
39 -n, --name NEW
40 -d, --description TEXT
41 --clear-description
42 delete NAME delete category; tasks become uncategorized
43
44 labels
45 (no args) list labels (done/total)
46 add NAME [--color COLOR]
47 ensure NAME [--color COLOR]
48 create if missing; conflict if different
49 edit NAME [--name NEW] [--color COLOR]
50 edit label; assignments stay attached
51 delete NAME delete label; tasks stay in place
52
53 Label colors: red, orange, yellow, lime, green, teal, cyan, blue, indigo, purple, pink, brown
54
55 add [TITLE]
56 -t, --title TITLE title (required if no positional TITLE)
57 -d, --description TEXT description (newlines = lines; see DESCRIPTION MARKUP)
58 --due DATE YYYY-MM-DD | MM-DD | HH:MM | DATEThh:mm
59 --time HH:MM with --due, or alone = next occurrence
60 -c, --category NAME category (name or unique prefix)
61 --label NAME existing label (repeatable)
62 -i, --importance N 0–3 (default 0)
63 --subtask TEXT add subtask (repeatable)
64
65 show ID ID = uuid or unique prefix
66
67 done ID
68 undone ID
69
70 delete ID
71
72 move ID (--before | --after) TARGET
73 reorder within the task's current category
74
75 purge --done
76 -c, --category NAME only completed tasks in this category
77
78 edit ID only given flags change
79 -t, --title TITLE
80 -d, --description TEXT replace entire description (DESCRIPTION MARKUP; wipes old description)
81 --due DATE date-only keeps existing time
82 --time HH:MM keeps existing date if no --due
83 --clear-due remove due date/time
84 -c, --category NAME
85 --clear-category uncategorized
86 --add-label NAME assign existing label (repeatable)
87 --remove-label NAME unassign label (repeatable)
88 --clear-labels remove all labels; may combine with --add-label
89 -i, --importance N 0–3
90
91 DESCRIPTION MARKUP (add/edit --description, one block per line)
92 plain text
93 [ ] item / [x] item subtask
94 - item / • item bullet
95 1. item numbered (any leading N.)
96 https://… link
97 [image:PATH] import an absolute path, or one relative to images/
98
99 subtasks TASK
100 (no subcommand) list subtasks
101 add [TEXT]
102 -t, --text TEXT
103 --done create already checked
104 done INDEX INDEX = 1-based among checkboxes only
105 undone INDEX
106 toggle INDEX
107 edit INDEX [TEXT]
108 -t, --text TEXT
109 delete INDEX later indexes shift down
110
111 export [FILE] portable .mach archive (tasks, categories, labels, images)
112 default: ./mach-export-YYYYMMDD-HHMMSS.mach
113
114 import FILE safely merge a .mach archive
115 identical records are skipped; conflicts abort
116
117 update check GitHub for a newer release
118 --install verify SHA-256 and install release binary to ~/.local/bin
119
120 (no command) open TUI
121 --json exactly one JSON document on stdout
122 --dir PATH data directory (global)
123
124Data: --dir PATH > $MACH_DIR > ~/.mach
125";
126
127#[derive(Parser)]
128#[command(
129 name = "mach",
130 about = concat!("mach v", env!("CARGO_PKG_VERSION")),
131 disable_version_flag = true,
132 color = clap::ColorChoice::Never,
133 after_help = HELP,
134)]
135struct Cli {
136 #[arg(short = 'v', long = "version")]
138 version: bool,
139
140 #[arg(long = "dir", value_name = "PATH", global = true)]
142 dir: Option<PathBuf>,
143
144 #[arg(long, global = true)]
146 json: bool,
147
148 #[command(subcommand)]
149 command: Option<Command>,
150}
151
152#[derive(Subcommand)]
153enum Command {
154 List {
156 #[arg(long = "query", value_name = "QUERY")]
158 query: Option<String>,
159 #[arg(short = 'c', long = "category", value_name = "NAME")]
161 category: Option<String>,
162 #[arg(long = "label", value_name = "NAME")]
164 labels: Vec<String>,
165 #[arg(long, conflicts_with = "done")]
167 open: bool,
168 #[arg(long)]
170 done: bool,
171 },
172 Categories {
174 #[command(subcommand)]
175 action: Option<CatAction>,
176 },
177 Labels {
179 #[command(subcommand)]
180 action: Option<LabelAction>,
181 },
182 Add(AddArgs),
184 Show {
186 id: String,
188 },
189 Done {
191 id: String,
193 },
194 Undone {
196 id: String,
198 },
199 Delete {
201 id: String,
203 },
204 Move {
206 id: String,
208 #[arg(
210 long,
211 value_name = "TARGET",
212 conflicts_with = "after",
213 required_unless_present = "after"
214 )]
215 before: Option<String>,
216 #[arg(
218 long,
219 value_name = "TARGET",
220 conflicts_with = "before",
221 required_unless_present = "before"
222 )]
223 after: Option<String>,
224 },
225 Purge {
227 #[arg(long, required = true)]
229 done: bool,
230 #[arg(short = 'c', long = "category", value_name = "NAME")]
232 category: Option<String>,
233 },
234 Edit(EditArgs),
236 Subtasks {
238 task: String,
240 #[command(subcommand)]
241 action: Option<SubAction>,
242 },
243 Export {
245 #[arg(value_name = "FILE")]
247 file: Option<PathBuf>,
248 },
249 Import {
251 #[arg(value_name = "FILE")]
253 file: PathBuf,
254 },
255 Update {
257 #[arg(long)]
259 install: bool,
260 },
261}
262
263#[derive(clap::Args)]
264struct AddArgs {
265 #[arg(value_name = "TITLE", conflicts_with = "title")]
267 title_pos: Option<String>,
268 #[arg(short = 't', long = "title")]
270 title: Option<String>,
271 #[arg(short = 'd', long = "description")]
273 description: Option<String>,
274 #[arg(long = "due", value_name = "DATE")]
276 due: Option<String>,
277 #[arg(long = "time", value_name = "HH:MM")]
279 time: Option<String>,
280 #[arg(short = 'c', long = "category", value_name = "NAME")]
282 category: Option<String>,
283 #[arg(long = "label", value_name = "NAME")]
285 labels: Vec<String>,
286 #[arg(
288 short = 'i',
289 long = "importance",
290 value_name = "N",
291 default_value_t = 0
292 )]
293 importance: u8,
294 #[arg(long = "subtask", value_name = "TEXT")]
296 subtasks: Vec<String>,
297}
298
299#[derive(clap::Args)]
300struct EditArgs {
301 id: String,
303 #[arg(short = 't', long = "title")]
305 title: Option<String>,
306 #[arg(short = 'd', long = "description")]
308 description: Option<String>,
309 #[arg(long = "due", value_name = "DATE")]
311 due: Option<String>,
312 #[arg(long = "time", value_name = "HH:MM")]
314 time: Option<String>,
315 #[arg(long)]
317 clear_due: bool,
318 #[arg(short = 'c', long = "category", value_name = "NAME")]
320 category: Option<String>,
321 #[arg(long = "clear-category")]
323 clear_cat: bool,
324 #[arg(long = "add-label", value_name = "NAME")]
326 add_labels: Vec<String>,
327 #[arg(long = "remove-label", value_name = "NAME")]
329 remove_labels: Vec<String>,
330 #[arg(long = "clear-labels")]
332 clear_labels: bool,
333 #[arg(short = 'i', long = "importance", value_name = "N")]
335 importance: Option<u8>,
336}
337
338#[derive(Subcommand)]
339enum CatAction {
340 List,
342 Add {
344 name: String,
346 #[arg(short = 'd', long = "description")]
348 description: Option<String>,
349 },
350 Ensure {
352 name: String,
354 #[arg(short = 'd', long = "description")]
356 description: Option<String>,
357 },
358 Edit {
360 name: String,
362 #[arg(short = 'n', long = "name", value_name = "NEW")]
364 new_name: Option<String>,
365 #[arg(short = 'd', long = "description")]
367 description: Option<String>,
368 #[arg(long = "clear-description")]
370 clear_description: bool,
371 },
372 Delete {
374 name: String,
376 },
377}
378
379#[derive(Subcommand)]
380enum LabelAction {
381 List,
383 Add {
385 name: String,
387 #[arg(long, value_enum)]
389 color: Option<LabelColor>,
390 },
391 Ensure {
393 name: String,
395 #[arg(long, value_enum)]
397 color: Option<LabelColor>,
398 },
399 Edit {
401 name: String,
403 #[arg(
405 short = 'n',
406 long = "name",
407 value_name = "NEW",
408 required_unless_present = "color"
409 )]
410 new_name: Option<String>,
411 #[arg(long, value_enum)]
413 color: Option<LabelColor>,
414 },
415 Delete {
417 name: String,
419 },
420}
421
422impl ValueEnum for LabelColor {
423 fn value_variants<'a>() -> &'a [Self] {
424 &Self::SWATCHES
425 }
426
427 fn to_possible_value(&self) -> Option<PossibleValue> {
428 Some(PossibleValue::new(self.as_str()))
429 }
430}
431
432#[derive(Subcommand)]
433enum SubAction {
434 List,
436 Add {
438 #[arg(value_name = "TEXT", conflicts_with = "text")]
440 text_pos: Option<String>,
441 #[arg(short = 't', long = "text")]
443 text: Option<String>,
444 #[arg(long)]
446 done: bool,
447 },
448 Done {
450 index: usize,
452 },
453 Undone {
455 index: usize,
457 },
458 Toggle {
460 index: usize,
462 },
463 Edit {
465 index: usize,
467 #[arg(value_name = "TEXT", conflicts_with = "text")]
469 text_pos: Option<String>,
470 #[arg(short = 't', long = "text")]
472 text: Option<String>,
473 },
474 Delete {
476 index: usize,
478 },
479}
480
481#[derive(Debug)]
482struct CliError {
483 kind: &'static str,
484 message: String,
485}
486
487impl CliError {
488 fn validation(message: impl Into<String>) -> Self {
489 Self {
490 kind: "validation",
491 message: message.into(),
492 }
493 }
494
495 fn update(message: impl Into<String>) -> Self {
496 Self {
497 kind: "update",
498 message: message.into(),
499 }
500 }
501}
502
503impl From<StoreError> for CliError {
504 fn from(error: StoreError) -> Self {
505 let kind = match &error {
506 StoreError::Io { .. } => "io",
507 StoreError::Json { .. } => "legacy_json",
508 StoreError::Database(_) => "database",
509 StoreError::UnsupportedLegacySchema { .. }
510 | StoreError::UnsupportedDatabaseSchema { .. } => "schema",
511 StoreError::Conflict { .. }
512 | StoreError::MetadataConflict { .. }
513 | StoreError::StaleEntity { .. } => "conflict",
514 StoreError::NotFound { .. } => "not_found",
515 StoreError::Ambiguous { .. } => "ambiguous",
516 StoreError::Validation(_) => "validation",
517 StoreError::Corrupt(_) => "corrupt",
518 };
519 Self {
520 kind,
521 message: error.to_string(),
522 }
523 }
524}
525
526impl From<crate::archive::ArchiveError> for CliError {
527 fn from(error: crate::archive::ArchiveError) -> Self {
528 Self {
529 kind: error.kind(),
530 message: error.to_string(),
531 }
532 }
533}
534
535enum Rendered {
536 Json(Value),
537 Plain(String),
538}
539
540impl Rendered {
541 fn emit(self) -> io::Result<()> {
542 let stdout = io::stdout();
543 let mut output = stdout.lock();
544 match self {
545 Self::Json(value) => {
546 serde_json::to_writer_pretty(&mut output, &value).map_err(|error| {
547 if let Some(kind) = error.io_error_kind() {
548 io::Error::new(kind, error)
549 } else {
550 io::Error::other(error)
551 }
552 })?;
553 output.write_all(b"\n")
554 }
555 Self::Plain(text) => output.write_all(text.as_bytes()),
556 }
557 }
558}
559
560fn rendered(
561 json_mode: bool,
562 json: impl FnOnce() -> Value,
563 plain: impl FnOnce() -> String,
564) -> Rendered {
565 if json_mode {
566 Rendered::Json(json())
567 } else {
568 Rendered::Plain(plain())
569 }
570}
571
572pub fn run() {
573 let arguments = normalize_documented_description_values(std::env::args_os().collect());
574 let json_requested = requested_json(&arguments);
575 let cli = match Cli::try_parse_from(&arguments) {
576 Ok(cli) => cli,
577 Err(error) => emit_parse_error(error, json_requested),
578 };
579 let Cli {
580 version,
581 dir,
582 json,
583 command,
584 } = cli;
585
586 if version {
587 let output = if json {
588 Rendered::Json(json!({ "ok": true, "version": VERSION }))
589 } else {
590 Rendered::Plain(format!("mach v{VERSION}\n"))
591 };
592 emit_success(output);
593 return;
594 }
595
596 let result = match command {
597 Some(Command::Update { install }) => cmd_update(install, json),
598 None if json => Err(CliError::validation(
599 "--json requires a command or --version",
600 )),
601 None => crate::require_interactive_terminal()
602 .map_err(terminal_error)
603 .and_then(|()| Store::open_default(dir).map_err(CliError::from))
604 .and_then(|store| {
605 crate::run_tui(store).map_err(terminal_error)?;
606 Ok(Rendered::Plain(String::new()))
607 }),
608 Some(command) => Store::open_default(dir)
609 .map_err(CliError::from)
610 .and_then(|mut store| dispatch(&mut store, command, json)),
611 };
612
613 match result {
614 Ok(output) => emit_success(output),
615 Err(error) => emit_runtime_error(error, json),
616 }
617}
618
619fn normalize_documented_description_values(arguments: Vec<OsString>) -> Vec<OsString> {
623 let mut normalized = Vec::with_capacity(arguments.len());
624 let mut arguments = arguments.into_iter().peekable();
625 let mut options = true;
626 while let Some(argument) = arguments.next() {
627 if options && argument == "--" {
628 options = false;
629 normalized.push(argument);
630 continue;
631 }
632 let description_option = options && (argument == "--description" || argument == "-d");
633 let documented_bullet = description_option
634 && arguments
635 .peek()
636 .and_then(|value| value.to_str())
637 .is_some_and(|value| value.starts_with("- "));
638 if documented_bullet {
639 let value = arguments
640 .next()
641 .expect("peeked description value must exist");
642 let mut combined = OsString::from("--description=");
643 combined.push(value);
644 normalized.push(combined);
645 } else {
646 normalized.push(argument);
647 }
648 }
649 normalized
650}
651
652fn terminal_error(error: io::Error) -> CliError {
653 CliError {
654 kind: "terminal",
655 message: error.to_string(),
656 }
657}
658
659fn requested_json(arguments: &[OsString]) -> bool {
660 arguments
661 .iter()
662 .skip(1)
663 .take_while(|argument| argument.as_os_str() != "--")
664 .any(|argument| argument.as_os_str() == "--json")
665}
666
667fn emit_parse_error(error: clap::Error, json_mode: bool) -> ! {
668 let help = matches!(
669 error.kind(),
670 clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion
671 );
672 let exit_code = if help { 0 } else { error.exit_code() };
673 if !json_mode {
674 let write = if help {
675 Rendered::Plain(error.to_string()).emit()
676 } else {
677 write_stderr(&format!("{}\n", terminal_text(&error.to_string())))
678 };
679 exit_after_write(write, exit_code);
680 }
681 let value = if help {
682 json!({ "ok": true, "kind": "help", "help": error.to_string() })
683 } else {
684 json!({ "ok": false, "kind": "usage", "error": error.to_string() })
685 };
686 exit_after_write(Rendered::Json(value).emit(), exit_code);
687}
688
689fn emit_runtime_error(error: CliError, json_mode: bool) -> ! {
690 let write = if json_mode {
691 Rendered::Json(json!({
692 "ok": false,
693 "kind": error.kind,
694 "error": error.message,
695 }))
696 .emit()
697 } else {
698 write_stderr(&format!("mach: {}\n", terminal_text(&error.message)))
699 };
700 exit_after_write(write, 1);
701}
702
703fn emit_success(output: Rendered) {
704 if let Err(error) = output.emit() {
705 if error.kind() == io::ErrorKind::BrokenPipe {
706 return;
707 }
708 let _ = write_stderr(&format!("mach: could not write output: {error}\n"));
709 std::process::exit(1);
710 }
711}
712
713fn write_stderr(text: &str) -> io::Result<()> {
714 let stderr = io::stderr();
715 stderr.lock().write_all(text.as_bytes())
716}
717
718fn exit_after_write(result: io::Result<()>, intended_code: i32) -> ! {
719 match result {
720 Ok(()) => std::process::exit(intended_code),
721 Err(error) if error.kind() == io::ErrorKind::BrokenPipe => std::process::exit(0),
722 Err(_) => std::process::exit(1),
723 }
724}
725
726fn dispatch(store: &mut Store, command: Command, json_mode: bool) -> Result<Rendered, CliError> {
727 match command {
728 Command::List {
729 query,
730 category,
731 labels,
732 open,
733 done,
734 } => cmd_list(
735 store,
736 query.as_deref(),
737 category.as_deref(),
738 &labels,
739 open,
740 done,
741 json_mode,
742 ),
743 Command::Categories { action } => match action {
744 None | Some(CatAction::List) => cmd_categories_list(store, json_mode),
745 Some(CatAction::Add { name, description }) => {
746 cmd_category_add(store, &name, description.as_deref(), json_mode)
747 }
748 Some(CatAction::Ensure { name, description }) => {
749 cmd_category_ensure(store, &name, description.as_deref(), json_mode)
750 }
751 Some(CatAction::Edit {
752 name,
753 new_name,
754 description,
755 clear_description,
756 }) => cmd_category_edit(
757 store,
758 &name,
759 new_name.as_deref(),
760 description.as_deref(),
761 clear_description,
762 json_mode,
763 ),
764 Some(CatAction::Delete { name }) => cmd_category_delete(store, &name, json_mode),
765 },
766 Command::Labels { action } => match action {
767 None | Some(LabelAction::List) => cmd_labels_list(store, json_mode),
768 Some(LabelAction::Add { name, color }) => cmd_label_add(store, &name, color, json_mode),
769 Some(LabelAction::Ensure { name, color }) => {
770 cmd_label_ensure(store, &name, color, json_mode)
771 }
772 Some(LabelAction::Edit {
773 name,
774 new_name,
775 color,
776 }) => cmd_label_edit(store, &name, new_name.as_deref(), color, json_mode),
777 Some(LabelAction::Delete { name }) => cmd_label_delete(store, &name, json_mode),
778 },
779 Command::Add(arguments) => cmd_add(store, &arguments, json_mode),
780 Command::Show { id } => cmd_show(store, &id, json_mode),
781 Command::Done { id } => cmd_set_done(store, &id, true, json_mode),
782 Command::Undone { id } => cmd_set_done(store, &id, false, json_mode),
783 Command::Delete { id } => cmd_delete(store, &id, json_mode),
784 Command::Move { id, before, after } => {
785 cmd_move(store, &id, before.as_deref(), after.as_deref(), json_mode)
786 }
787 Command::Purge { done: _, category } => cmd_purge(store, category.as_deref(), json_mode),
788 Command::Edit(arguments) => cmd_edit(store, &arguments, json_mode),
789 Command::Subtasks { task, action } => match action {
790 None | Some(SubAction::List) => cmd_subtasks_list(store, &task, json_mode),
791 Some(SubAction::Add {
792 text_pos,
793 text,
794 done,
795 }) => cmd_subtask_add(
796 store,
797 &task,
798 &text.or(text_pos).unwrap_or_default(),
799 done,
800 json_mode,
801 ),
802 Some(SubAction::Done { index }) => {
803 cmd_subtask_set_done(store, &task, index, Some(true), json_mode)
804 }
805 Some(SubAction::Undone { index }) => {
806 cmd_subtask_set_done(store, &task, index, Some(false), json_mode)
807 }
808 Some(SubAction::Toggle { index }) => {
809 cmd_subtask_set_done(store, &task, index, None, json_mode)
810 }
811 Some(SubAction::Edit {
812 index,
813 text_pos,
814 text,
815 }) => cmd_subtask_edit(
816 store,
817 &task,
818 index,
819 &text.or(text_pos).unwrap_or_default(),
820 json_mode,
821 ),
822 Some(SubAction::Delete { index }) => cmd_subtask_delete(store, &task, index, json_mode),
823 },
824 Command::Export { file } => cmd_export(store, file.as_deref(), json_mode),
825 Command::Import { file } => cmd_import(store, &file, json_mode),
826 Command::Update { .. } => Err(CliError {
827 kind: "internal",
828 message: "update command crossed the data-command boundary".into(),
829 }),
830 }
831}
832
833fn cmd_export(
834 store: &Store,
835 path: Option<&std::path::Path>,
836 json_mode: bool,
837) -> Result<Rendered, CliError> {
838 let summary = crate::archive::export(store, path)?;
839 Ok(rendered(
840 json_mode,
841 || {
842 json!({
843 "ok": true,
844 "archive": summary.path.display().to_string(),
845 "tasks": summary.tasks,
846 "categories": summary.categories,
847 "labels": summary.labels,
848 "images": summary.images,
849 })
850 },
851 || {
852 let contents = crate::archive::content_count_text(
853 summary.tasks,
854 summary.categories,
855 summary.labels,
856 summary.images,
857 );
858 format!(
859 "exported {contents} to {}\n",
860 terminal_text(&summary.path.display().to_string())
861 )
862 },
863 ))
864}
865
866fn cmd_import(
867 store: &mut Store,
868 path: &std::path::Path,
869 json_mode: bool,
870) -> Result<Rendered, CliError> {
871 let summary = crate::archive::import(store, path)?;
872 Ok(rendered(
873 json_mode,
874 || {
875 json!({
876 "ok": true,
877 "archive": summary.path.display().to_string(),
878 "tasks_added": summary.tasks_added,
879 "tasks_unchanged": summary.tasks_unchanged,
880 "categories_added": summary.categories_added,
881 "categories_unchanged": summary.categories_unchanged,
882 "labels_added": summary.labels_added,
883 "labels_unchanged": summary.labels_unchanged,
884 "images_added": summary.images_added,
885 "images_unchanged": summary.images_unchanged,
886 })
887 },
888 || {
889 let added = crate::archive::content_count_text(
890 summary.tasks_added,
891 summary.categories_added,
892 summary.labels_added,
893 summary.images_added,
894 );
895 let unchanged = crate::archive::content_count_text(
896 summary.tasks_unchanged,
897 summary.categories_unchanged,
898 summary.labels_unchanged,
899 summary.images_unchanged,
900 );
901 if summary.changed() {
902 format!("imported {added}; {unchanged} already present\n")
903 } else {
904 format!("nothing imported; {unchanged} already present\n")
905 }
906 },
907 ))
908}
909
910fn cmd_update(do_install: bool, json_mode: bool) -> Result<Rendered, CliError> {
911 let now = Utc::now().timestamp();
912 let mut update_state = crate::update_state::UpdateStateStore::open_default().ok();
913 let lease = update_state
914 .as_mut()
915 .and_then(|store| store.claim_manual(now).ok());
916 let checked = match crate::update::check_with_etag(None) {
917 Ok(crate::update::CheckResponse::Modified { value: info, etag }) => (info, etag),
918 Ok(crate::update::CheckResponse::NotModified) => {
919 if let (Some(store), Some(lease)) = (update_state.as_mut(), lease.as_ref()) {
920 let _ = store.finish_failure(lease, Utc::now().timestamp(), None);
921 }
922 return Err(CliError::update(
923 "GitHub returned 304 without a conditional request",
924 ));
925 }
926 Err(error) => {
927 if let (Some(store), Some(lease)) = (update_state.as_mut(), lease.as_ref()) {
928 let _ = store.finish_failure(lease, Utc::now().timestamp(), error.retry_at);
929 }
930 return Err(CliError::update(error.message));
931 }
932 };
933 let (info, etag) = checked;
934 let install = if do_install && info.newer {
935 crate::update::install(&info).map(Some)
936 } else {
937 Ok(None)
938 };
939 if let (Some(store), Some(lease)) = (update_state.as_mut(), lease.as_ref()) {
940 let _ = store.finish_modified(lease, Utc::now().timestamp(), etag.as_deref(), &info.latest);
941 }
942 let install = install.map_err(CliError::update)?;
943 let install_disposition = install.as_ref().map(|result| match result.disposition {
944 crate::update::InstallDisposition::Installed => "installed",
945 crate::update::InstallDisposition::AlreadyCurrent => "already_current",
946 });
947
948 Ok(rendered(
949 json_mode,
950 || {
951 json!({
952 "ok": true,
953 "current": info.current,
954 "latest": info.latest,
955 "newer": info.newer,
956 "prerelease": info.prerelease,
957 "url": info.release_url,
958 "installed": install_disposition == Some("installed"),
959 "install_disposition": install_disposition,
960 "destination": install
961 .as_ref()
962 .map(|result| result.destination.display().to_string()),
963 "tag": install.as_ref().map(|result| result.tag.as_str()).unwrap_or(&info.tag),
964 })
965 },
966 || {
967 let mut plain = format!("{}\n", terminal_text(&info.summary()));
968 if info.newer && install.is_none() {
969 plain.push('\n');
970 for line in info.install_hint().lines() {
971 plain.push_str(&terminal_text(line));
972 plain.push('\n');
973 }
974 }
975 if do_install && install.is_none() {
976 plain.push_str("Already up to date.\n");
977 } else if let Some(result) = &install {
978 let message = match result.disposition {
979 crate::update::InstallDisposition::Installed => format!(
980 "Installed {} to {}. Restart mach to use the new build.\n",
981 terminal_text(&result.tag),
982 terminal_text(&result.destination.display().to_string())
983 ),
984 crate::update::InstallDisposition::AlreadyCurrent => format!(
985 "Already installed {} at {}. Restart mach to use the new build.\n",
986 terminal_text(&result.tag),
987 terminal_text(&result.destination.display().to_string())
988 ),
989 };
990 plain.push_str(&message);
991 }
992 plain
993 },
994 ))
995}
996
997fn short_id(id: &str) -> String {
1000 id.chars().take(8).collect()
1001}
1002
1003fn terminal_text(text: &str) -> String {
1004 let mut safe = String::with_capacity(text.len());
1005 for character in text.chars() {
1006 match character {
1007 '\n' => safe.push_str("\\n"),
1008 '\r' => safe.push_str("\\r"),
1009 '\t' => safe.push_str("\\t"),
1010 character if character.is_control() => {
1011 safe.push_str(&format!("\\u{{{:x}}}", character as u32));
1012 }
1013 character => safe.push(character),
1014 }
1015 }
1016 safe
1017}
1018
1019fn description_from_text(text: &str) -> Vec<Block> {
1020 if text.is_empty() {
1021 return Vec::new();
1022 }
1023 text.lines().map(line_to_block).collect()
1024}
1025
1026fn line_to_block(line: &str) -> Block {
1027 let text = line.trim_end();
1028 if let Some(rest) = text.strip_prefix("[ ] ") {
1029 return Block::todo(rest, false);
1030 }
1031 if let Some(rest) = text
1032 .strip_prefix("[x] ")
1033 .or_else(|| text.strip_prefix("[X] "))
1034 .or_else(|| text.strip_prefix("[✓] "))
1035 {
1036 return Block::todo(rest, true);
1037 }
1038 if let Some(rest) = text.strip_prefix("- ").or_else(|| text.strip_prefix("• ")) {
1039 return Block::bullet(rest);
1040 }
1041 if let Some(rest) = strip_number_prefix(text) {
1042 return Block::number(rest);
1043 }
1044 if let Some(path) = text
1045 .strip_prefix("[image:")
1046 .and_then(|value| value.strip_suffix(']'))
1047 .filter(|path| !path.is_empty())
1048 {
1049 return Block::image(path);
1050 }
1051 if text.starts_with("http://") || text.starts_with("https://") {
1052 return Block::link(text);
1053 }
1054 Block::text(text)
1055}
1056
1057fn strip_number_prefix(line: &str) -> Option<&str> {
1058 let bytes = line.as_bytes();
1059 let mut index = 0;
1060 while index < bytes.len() && bytes[index].is_ascii_digit() {
1061 index += 1;
1062 }
1063 if index == 0 {
1064 return None;
1065 }
1066 line.get(index..)?.strip_prefix(". ")
1067}
1068
1069fn description_to_text(description: &[Block]) -> String {
1070 let mut numbered = 0usize;
1071 description
1072 .iter()
1073 .map(|block| match block {
1074 Block::Text { text } => {
1075 numbered = 0;
1076 text.clone()
1077 }
1078 Block::Todo { text, done } => {
1079 numbered = 0;
1080 format!("[{}] {text}", if *done { "x" } else { " " })
1081 }
1082 Block::Bullet { text } => {
1083 numbered = 0;
1084 format!("- {text}")
1085 }
1086 Block::Number { text } => {
1087 numbered += 1;
1088 format!("{numbered}. {text}")
1089 }
1090 Block::Link { url } => {
1091 numbered = 0;
1092 url.clone()
1093 }
1094 Block::Image { attachment_id } => {
1095 numbered = 0;
1096 format!("[image:{attachment_id}]")
1097 }
1098 })
1099 .collect::<Vec<_>>()
1100 .join("\n")
1101}
1102
1103fn collect_subtasks(description: &[Block]) -> Vec<(usize, &str, bool)> {
1104 description
1105 .iter()
1106 .filter_map(|block| match block {
1107 Block::Todo { text, done } => Some((text.as_str(), *done)),
1108 _ => None,
1109 })
1110 .enumerate()
1111 .map(|(index, (text, done))| (index + 1, text, done))
1112 .collect()
1113}
1114
1115fn subtask_description_index(description: &[Block], one_based: usize) -> Result<usize, CliError> {
1116 if one_based == 0 {
1117 return Err(CliError::validation(
1118 "subtask index is 1-based (use 1 for the first subtask)",
1119 ));
1120 }
1121 let mut count = 0usize;
1122 for (description_index, block) in description.iter().enumerate() {
1123 if matches!(block, Block::Todo { .. }) {
1124 count += 1;
1125 if count == one_based {
1126 return Ok(description_index);
1127 }
1128 }
1129 }
1130 Err(CliError::validation(format!(
1131 "no subtask at index {one_based} (task has {count} subtask(s))"
1132 )))
1133}
1134
1135fn subtasks_json(description: &[Block]) -> Vec<Value> {
1136 subtasks_to_json(&collect_subtasks(description))
1137}
1138
1139fn subtasks_to_json(subtasks: &[(usize, &str, bool)]) -> Vec<Value> {
1140 subtasks
1141 .iter()
1142 .map(|(index, text, done)| json!({ "index": index, "text": text, "done": done }))
1143 .collect()
1144}
1145
1146fn category_name<'a>(categories: &'a [Category], task: &Task) -> Option<&'a str> {
1147 task.category_id
1148 .as_ref()
1149 .and_then(|id| categories.iter().find(|category| category.id == *id))
1150 .map(|category| category.name.as_str())
1151}
1152
1153fn label_json(label: &Label) -> Value {
1154 json!({
1155 "id": label.id,
1156 "name": label.name,
1157 "color": label.color,
1158 })
1159}
1160
1161fn task_labels<'a>(labels: &'a [Label], task: &Task) -> Vec<&'a Label> {
1162 labels
1163 .iter()
1164 .filter(|label| task.label_ids.contains(&label.id))
1165 .collect()
1166}
1167
1168fn task_label_text(labels: &[Label], task: &Task) -> String {
1169 task_labels(labels, task)
1170 .into_iter()
1171 .map(|label| terminal_text(&label.name))
1172 .collect::<Vec<_>>()
1173 .join(" ")
1174}
1175
1176fn resolve_label_ids(data: &StoreData, queries: &[String]) -> Result<Vec<String>, StoreError> {
1177 let mut selected = HashSet::with_capacity(queries.len());
1178 for query in queries {
1179 selected.insert(data.resolve_label_id(query)?);
1180 }
1181 Ok(data
1182 .labels
1183 .iter()
1184 .filter(|label| selected.contains(&label.id))
1185 .map(|label| label.id.clone())
1186 .collect())
1187}
1188
1189fn task_json(categories: &[Category], labels: &[Label], task: &Task) -> Value {
1190 task_json_with_category(labels, task, category_name(categories, task))
1191}
1192
1193fn task_json_with_category(labels: &[Label], task: &Task, category_name: Option<&str>) -> Value {
1194 let subtasks = collect_subtasks(&task.description);
1195 let subtasks_json = subtasks_to_json(&subtasks);
1196 json!({
1197 "id": task.id,
1198 "title": task.title,
1199 "description": description_to_text(&task.description),
1200 "subtasks": subtasks_json,
1201 "subtasks_done": subtasks.iter().filter(|(_, _, done)| *done).count(),
1202 "subtasks_total": subtasks.len(),
1203 "due": task.due,
1204 "done": task.done,
1205 "importance": task.importance,
1206 "category": {
1207 "id": task.category_id,
1208 "name": category_name,
1209 },
1210 "labels": task_labels(labels, task)
1211 .into_iter()
1212 .map(label_json)
1213 .collect::<Vec<_>>(),
1214 "created": task.created,
1215 })
1216}
1217
1218fn category_json(category: &Category) -> Value {
1219 json!({
1220 "id": category.id,
1221 "name": category.name,
1222 "description": category.description,
1223 })
1224}
1225
1226fn validate_time(raw: &str) -> Result<String, CliError> {
1227 let value = raw.trim();
1228 if crate::due::parse_time(value).is_some() {
1229 Ok(value.to_string())
1230 } else {
1231 Err(CliError::validation(format!(
1232 "invalid time {raw:?}; use HH:MM (24h), e.g. 14:30"
1233 )))
1234 }
1235}
1236
1237fn due_for_add(due: Option<&str>, time: Option<&str>) -> Result<String, CliError> {
1238 let due = due.map(str::trim).filter(|value| !value.is_empty());
1239 match (due, time) {
1240 (None, None) => Ok(String::new()),
1241 (None, Some(time)) => validate_time(time),
1242 (Some(due), None) => Ok(due.to_string()),
1243 (Some(due), Some(time)) => {
1244 if due.contains(':') {
1245 return Err(CliError::validation(format!(
1246 "due already includes a time ({due}); omit --time or pass date-only --due"
1247 )));
1248 }
1249 Ok(format!("{due} {}", validate_time(time)?))
1250 }
1251 }
1252}
1253
1254fn split_inline_title(raw: &str) -> Result<(String, String), CliError> {
1255 let (inline_due, title) = crate::due::parse(raw.trim());
1256 if !inline_due.is_empty() {
1257 crate::due::normalize_for_write(&inline_due)
1258 .map_err(|error| CliError::validation(error.to_string()))?;
1259 }
1260 Ok((title, inline_due))
1261}
1262
1263fn due_for_edit(
1266 current: &str,
1267 due: Option<&str>,
1268 time: Option<&str>,
1269) -> Result<Option<String>, CliError> {
1270 if due.is_none() && time.is_none() {
1271 return Ok(None);
1272 }
1273 let existing_time = current.split_once(' ').map(|(_, time)| time.to_string());
1274 let existing_date = current
1275 .split_once(' ')
1276 .map(|(date, _)| date.to_string())
1277 .or_else(|| (!current.is_empty() && !current.contains(':')).then(|| current.to_string()));
1278
1279 if let Some(raw_due) = due {
1280 let raw_due = raw_due.trim();
1281 if raw_due.contains(':') {
1282 if time.is_some() {
1283 return Err(CliError::validation(
1284 "pass either a full --due datetime or --due date + --time, not both",
1285 ));
1286 }
1287 return crate::due::normalize_for_write(raw_due)
1288 .map(Some)
1289 .map_err(|error| CliError::validation(error.to_string()));
1290 }
1291 let chosen_time = match time {
1292 Some(time) => Some(validate_time(time)?),
1293 None => existing_time,
1294 };
1295 let combined = match chosen_time {
1296 Some(time) => format!("{raw_due} {time}"),
1297 None => raw_due.to_string(),
1298 };
1299 return crate::due::normalize_for_write(&combined)
1300 .map(Some)
1301 .map_err(|error| CliError::validation(error.to_string()));
1302 }
1303
1304 let Some(time) = time else {
1305 return Ok(None);
1306 };
1307 let time = validate_time(time)?;
1308 let combined = match existing_date {
1309 Some(date) => format!("{date} {time}"),
1310 None => time,
1311 };
1312 crate::due::normalize_for_write(&combined)
1313 .map(Some)
1314 .map_err(|error| CliError::validation(error.to_string()))
1315}
1316
1317fn task_line(
1318 task: &Task,
1319 category_name: Option<&str>,
1320 labels: &[Label],
1321 show_category: bool,
1322 date_format: &str,
1323) -> String {
1324 let check = if task.done { "[✓]" } else { "[ ]" };
1325 let title = terminal_text(&task.title);
1326 let due = crate::due::display(&task.due, date_format);
1327 let due = if due.is_empty() {
1328 String::new()
1329 } else {
1330 format!(" {}", terminal_text(&due))
1331 };
1332 let flag = if task.importance > 0 {
1333 format!(" {}", crate::model::importance_marks(task.importance))
1334 } else {
1335 String::new()
1336 };
1337 let category = if show_category {
1338 format!(" [{}]", terminal_text(category_name.unwrap_or("—")))
1339 } else {
1340 String::new()
1341 };
1342 let label_text = task_label_text(labels, task);
1343 let labels = if label_text.is_empty() {
1344 String::new()
1345 } else {
1346 format!(" {label_text}")
1347 };
1348 let progress = crate::model::todo_progress(task)
1349 .map(|(done, total)| format!(" ({done}/{total})"))
1350 .unwrap_or_default();
1351 format!(
1352 "{} {check} {title}{category}{labels}{due}{flag}{progress}\n",
1353 terminal_text(&short_id(&task.id))
1354 )
1355}
1356
1357fn cmd_list(
1360 store: &Store,
1361 query: Option<&str>,
1362 category: Option<&str>,
1363 label_queries: &[String],
1364 open_only: bool,
1365 done_only: bool,
1366 json_mode: bool,
1367) -> Result<Rendered, CliError> {
1368 let query = query.map(str::trim);
1369 if query.is_some_and(str::is_empty) {
1370 return Err(CliError::validation("search query cannot be empty"));
1371 }
1372 let query_key = query.map(caseless_key);
1373 let data = store.snapshot()?;
1374 let category_id = category
1375 .map(|query| data.resolve_category_id(query))
1376 .transpose()?;
1377 let label_ids = resolve_label_ids(&data, label_queries)?;
1378 let show_category = category_id.is_none();
1379 let tasks: Vec<_> = data
1380 .tasks
1381 .iter()
1382 .filter(|task| {
1383 category_id
1384 .as_ref()
1385 .is_none_or(|id| task.category_id.as_deref() == Some(id.as_str()))
1386 })
1387 .filter(|task| {
1388 label_ids
1389 .iter()
1390 .all(|label_id| task.label_ids.contains(label_id))
1391 })
1392 .filter(|task| {
1393 if open_only {
1394 !task.done
1395 } else if done_only {
1396 task.done
1397 } else {
1398 true
1399 }
1400 })
1401 .filter(|task| {
1402 query_key
1403 .as_deref()
1404 .is_none_or(|query| task_text_contains(task, query))
1405 })
1406 .collect();
1407 let category_names: HashMap<_, _> = data
1408 .categories
1409 .iter()
1410 .map(|category| (category.id.as_str(), category.name.as_str()))
1411 .collect();
1412 let name_for = |task: &Task| {
1413 task.category_id
1414 .as_deref()
1415 .and_then(|id| category_names.get(id).copied())
1416 };
1417 Ok(rendered(
1418 json_mode,
1419 || {
1420 Value::Array(
1421 tasks
1422 .iter()
1423 .map(|task| task_json_with_category(&data.labels, task, name_for(task)))
1424 .collect(),
1425 )
1426 },
1427 || {
1428 let mut plain = String::new();
1429 if tasks.is_empty() {
1430 plain.push_str("(no tasks)\n");
1431 } else {
1432 for task in &tasks {
1433 plain.push_str(&task_line(
1434 task,
1435 name_for(task),
1436 &data.labels,
1437 show_category,
1438 &data.settings.date_format,
1439 ));
1440 }
1441 let done = tasks.iter().filter(|task| task.done).count();
1442 plain.push_str(&format!("— {} task(s), {done} done\n", tasks.len()));
1443 }
1444 plain
1445 },
1446 ))
1447}
1448
1449fn cmd_categories_list(store: &Store, json_mode: bool) -> Result<Rendered, CliError> {
1450 let data = store.snapshot()?;
1451 let category_indices: HashMap<_, _> = data
1452 .categories
1453 .iter()
1454 .enumerate()
1455 .map(|(index, category)| (category.id.as_str(), index))
1456 .collect();
1457 let mut counts = vec![(0usize, 0usize); data.categories.len()];
1458 let mut uncategorized = (0usize, 0usize);
1459 for task in &data.tasks {
1460 let count = match task.category_id.as_deref() {
1461 Some(id) => category_indices.get(id).map(|index| &mut counts[*index]),
1462 None => Some(&mut uncategorized),
1463 };
1464 if let Some((done, total)) = count {
1465 *done += usize::from(task.done);
1466 *total += 1;
1467 }
1468 }
1469 Ok(rendered(
1470 json_mode,
1471 || {
1472 let categories: Vec<_> = data
1473 .categories
1474 .iter()
1475 .zip(&counts)
1476 .map(|(category, (done, total))| {
1477 json!({
1478 "id": category.id,
1479 "name": category.name,
1480 "description": category.description,
1481 "total": total,
1482 "done": done,
1483 })
1484 })
1485 .collect();
1486 json!({
1487 "categories": categories,
1488 "uncategorized": {
1489 "total": uncategorized.1,
1490 "done": uncategorized.0,
1491 },
1492 })
1493 },
1494 || {
1495 let mut plain = String::new();
1496 if data.categories.is_empty() {
1497 plain.push_str("(no categories)\n");
1498 } else {
1499 for (category, (done, total)) in data.categories.iter().zip(&counts) {
1500 plain.push_str(&format!(
1501 "{} {done}/{total}\n",
1502 terminal_text(&category.name),
1503 ));
1504 }
1505 }
1506 if uncategorized.1 > 0 {
1507 plain.push_str(&format!(
1508 "— uncategorized {}/{}\n",
1509 uncategorized.0, uncategorized.1
1510 ));
1511 }
1512 plain
1513 },
1514 ))
1515}
1516
1517fn cmd_category_add(
1518 store: &mut Store,
1519 name: &str,
1520 description: Option<&str>,
1521 json_mode: bool,
1522) -> Result<Rendered, CliError> {
1523 let name = name.trim().to_string();
1524 let description = description.unwrap_or_default().to_string();
1525 let category = store.update(|data| data.create_category(name, description))?;
1526 Ok(rendered(
1527 json_mode,
1528 || category_json(&category),
1529 || format!("created category {}\n", terminal_text(&category.name)),
1530 ))
1531}
1532
1533fn cmd_category_ensure(
1534 store: &mut Store,
1535 name: &str,
1536 description: Option<&str>,
1537 json_mode: bool,
1538) -> Result<Rendered, CliError> {
1539 let (category, created) = store.ensure_category(name, description.map(str::to_string))?;
1540 Ok(rendered(
1541 json_mode,
1542 || {
1543 json!({
1544 "created": created,
1545 "category": category_json(&category),
1546 })
1547 },
1548 || {
1549 if created {
1550 format!("created category {}\n", terminal_text(&category.name))
1551 } else {
1552 format!(
1553 "category {} already exists\n",
1554 terminal_text(&category.name)
1555 )
1556 }
1557 },
1558 ))
1559}
1560
1561fn cmd_category_edit(
1562 store: &mut Store,
1563 query: &str,
1564 new_name: Option<&str>,
1565 description: Option<&str>,
1566 clear_description: bool,
1567 json_mode: bool,
1568) -> Result<Rendered, CliError> {
1569 if new_name.is_none() && description.is_none() && !clear_description {
1570 return Err(CliError::validation(
1571 "nothing to edit; pass --name / --description / --clear-description",
1572 ));
1573 }
1574 if clear_description && description.is_some() {
1575 return Err(CliError::validation(
1576 "--clear-description cannot be combined with --description",
1577 ));
1578 }
1579 let patch = CategoryPatch {
1580 name: new_name.map(|name| name.trim().to_string()),
1581 description: if clear_description {
1582 Some(String::new())
1583 } else {
1584 description.map(str::to_string)
1585 },
1586 };
1587 let category = store.update(|data| {
1588 let id = data.resolve_category_id(query)?;
1589 data.edit_category(&id, patch)
1590 })?;
1591 Ok(rendered(
1592 json_mode,
1593 || category_json(&category),
1594 || format!("updated category {}\n", terminal_text(&category.name)),
1595 ))
1596}
1597
1598fn cmd_category_delete(
1599 store: &mut Store,
1600 query: &str,
1601 json_mode: bool,
1602) -> Result<Rendered, CliError> {
1603 let category = store.update(|data| {
1604 let id = data.resolve_category_id(query)?;
1605 data.delete_category(&id)
1606 })?;
1607 Ok(rendered(
1608 json_mode,
1609 || json!({ "deleted": category.name, "id": category.id }),
1610 || {
1611 format!(
1612 "deleted category {} (tasks uncategorized)\n",
1613 terminal_text(&category.name)
1614 )
1615 },
1616 ))
1617}
1618
1619fn cmd_labels_list(store: &Store, json_mode: bool) -> Result<Rendered, CliError> {
1620 let data = store.snapshot()?;
1621 let label_indices: HashMap<_, _> = data
1622 .labels
1623 .iter()
1624 .enumerate()
1625 .map(|(index, label)| (label.id.as_str(), index))
1626 .collect();
1627 let mut counts = vec![(0usize, 0usize); data.labels.len()];
1628 for task in &data.tasks {
1629 for label_id in &task.label_ids {
1630 if let Some(index) = label_indices.get(label_id.as_str()) {
1631 counts[*index].0 += usize::from(task.done);
1632 counts[*index].1 += 1;
1633 }
1634 }
1635 }
1636 Ok(rendered(
1637 json_mode,
1638 || {
1639 json!({
1640 "labels": data.labels.iter().zip(&counts).map(|(label, (done, total))| {
1641 json!({
1642 "id": label.id,
1643 "name": label.name,
1644 "color": label.color,
1645 "total": total,
1646 "done": done,
1647 })
1648 }).collect::<Vec<_>>(),
1649 })
1650 },
1651 || {
1652 if data.labels.is_empty() {
1653 return "(no labels)\n".to_string();
1654 }
1655 data.labels
1656 .iter()
1657 .zip(&counts)
1658 .map(|(label, (done, total))| {
1659 format!(
1660 "{} {} {done}/{total}\n",
1661 terminal_text(&label.name),
1662 label.color
1663 )
1664 })
1665 .collect()
1666 },
1667 ))
1668}
1669
1670fn cmd_label_add(
1671 store: &mut Store,
1672 name: &str,
1673 color: Option<LabelColor>,
1674 json_mode: bool,
1675) -> Result<Rendered, CliError> {
1676 let label = store.update(|data| match color {
1677 Some(color) => data.create_label_with_color(name, color),
1678 None => data.create_label(name),
1679 })?;
1680 Ok(rendered(
1681 json_mode,
1682 || label_json(&label),
1683 || {
1684 format!(
1685 "created label {} ({})\n",
1686 terminal_text(&label.name),
1687 label.color
1688 )
1689 },
1690 ))
1691}
1692
1693fn cmd_label_ensure(
1694 store: &mut Store,
1695 name: &str,
1696 color: Option<LabelColor>,
1697 json_mode: bool,
1698) -> Result<Rendered, CliError> {
1699 let (label, created) = store.ensure_label(name, color)?;
1700 Ok(rendered(
1701 json_mode,
1702 || {
1703 json!({
1704 "created": created,
1705 "label": label_json(&label),
1706 })
1707 },
1708 || {
1709 if created {
1710 format!(
1711 "created label {} ({})\n",
1712 terminal_text(&label.name),
1713 label.color
1714 )
1715 } else {
1716 format!(
1717 "label {} ({}) already exists\n",
1718 terminal_text(&label.name),
1719 label.color
1720 )
1721 }
1722 },
1723 ))
1724}
1725
1726fn cmd_label_edit(
1727 store: &mut Store,
1728 query: &str,
1729 new_name: Option<&str>,
1730 color: Option<LabelColor>,
1731 json_mode: bool,
1732) -> Result<Rendered, CliError> {
1733 let label = store.update(|data| {
1734 let id = data.resolve_label_id(query)?;
1735 data.edit_label(
1736 &id,
1737 LabelPatch {
1738 name: new_name.map(str::to_string),
1739 color,
1740 },
1741 )
1742 })?;
1743 Ok(rendered(
1744 json_mode,
1745 || label_json(&label),
1746 || {
1747 format!(
1748 "updated label {} ({})\n",
1749 terminal_text(&label.name),
1750 label.color
1751 )
1752 },
1753 ))
1754}
1755
1756fn cmd_label_delete(store: &mut Store, query: &str, json_mode: bool) -> Result<Rendered, CliError> {
1757 let (label, tasks_unassigned) = store.update(|data| {
1758 let id = data.resolve_label_id(query)?;
1759 let tasks_unassigned = data
1760 .tasks
1761 .iter()
1762 .filter(|task| task.label_ids.contains(&id))
1763 .count();
1764 let label = data.delete_label(&id)?;
1765 Ok((label, tasks_unassigned))
1766 })?;
1767 Ok(rendered(
1768 json_mode,
1769 || {
1770 json!({
1771 "deleted": label.name,
1772 "id": label.id,
1773 "tasks_unassigned": tasks_unassigned,
1774 })
1775 },
1776 || {
1777 format!(
1778 "deleted label {} (unassigned from {tasks_unassigned} task{})\n",
1779 terminal_text(&label.name),
1780 if tasks_unassigned == 1 { "" } else { "s" }
1781 )
1782 },
1783 ))
1784}
1785
1786fn cmd_add(store: &mut Store, arguments: &AddArgs, json_mode: bool) -> Result<Rendered, CliError> {
1787 let raw_title = arguments
1788 .title
1789 .as_deref()
1790 .or(arguments.title_pos.as_deref())
1791 .unwrap_or_default()
1792 .trim();
1793 let (title, inline_due) = split_inline_title(raw_title)?;
1794 if title.is_empty() {
1795 return Err(CliError::validation(
1796 "title required (positional or --title)",
1797 ));
1798 }
1799 let mut description = arguments
1800 .description
1801 .as_deref()
1802 .map(description_from_text)
1803 .unwrap_or_default();
1804 for subtask in &arguments.subtasks {
1805 let text = subtask.trim();
1806 if text.is_empty() {
1807 return Err(CliError::validation("--subtask text cannot be empty"));
1808 }
1809 description.push(Block::todo(text, false));
1810 }
1811 let due = if arguments.due.is_none() && arguments.time.is_none() {
1812 inline_due
1813 } else {
1814 due_for_add(arguments.due.as_deref(), arguments.time.as_deref())?
1815 };
1816 let category_query = arguments.category.as_deref();
1817 let label_queries = &arguments.labels;
1818 let importance = arguments.importance;
1819 let (task_id, snapshot) = store.update_with_snapshot(move |data| {
1820 let category_id = category_query
1821 .map(|query| data.resolve_category_id(query))
1822 .transpose()?;
1823 let label_ids = resolve_label_ids(data, label_queries)?;
1824 let task = data.create_task(title, description, due, importance, category_id)?;
1825 let task_id = task.id;
1826 data.set_task_labels(&task_id, label_ids)?;
1827 Ok(task_id)
1828 })?;
1829 let task = snapshot.task(&task_id)?.clone();
1830 let categories = snapshot.categories;
1831 let labels = snapshot.labels;
1832 Ok(rendered(
1833 json_mode,
1834 || task_json(&categories, &labels, &task),
1835 || {
1836 let subtasks = collect_subtasks(&task.description).len();
1837 if subtasks == 0 {
1838 format!(
1839 "added {} {}\n",
1840 terminal_text(&short_id(&task.id)),
1841 terminal_text(&task.title)
1842 )
1843 } else {
1844 format!(
1845 "added {} {} ({} subtask{})\n",
1846 terminal_text(&short_id(&task.id)),
1847 terminal_text(&task.title),
1848 subtasks,
1849 if subtasks == 1 { "" } else { "s" }
1850 )
1851 }
1852 },
1853 ))
1854}
1855
1856fn cmd_show(store: &Store, query: &str, json_mode: bool) -> Result<Rendered, CliError> {
1857 let data = store.snapshot()?;
1858 let id = data.resolve_task_id(query)?;
1859 let task = data.task(&id)?;
1860 Ok(rendered(
1861 json_mode,
1862 || task_json(&data.categories, &data.labels, task),
1863 || {
1864 let labels = task_label_text(&data.labels, task);
1865 let mut plain = format!(
1866 "id: {}\ntitle: {}\ndone: {}\ncategory: {}\nlabels: {}\ndue: {}\nimportance: {} ({})\ncreated: {}\n",
1867 terminal_text(&task.id),
1868 terminal_text(&task.title),
1869 task.done,
1870 terminal_text(category_name(&data.categories, task).unwrap_or("—")),
1871 if labels.is_empty() { "—" } else { &labels },
1872 if task.due.is_empty() {
1873 "—".into()
1874 } else {
1875 terminal_text(&task.due)
1876 },
1877 task.importance,
1878 crate::model::importance_marks(task.importance),
1879 terminal_text(&task.created),
1880 );
1881 let subtasks = collect_subtasks(&task.description);
1882 if subtasks.is_empty() {
1883 plain.push_str("subtasks: —\n");
1884 } else {
1885 plain.push_str(&format!(
1886 "subtasks: {}/{}\n",
1887 subtasks.iter().filter(|(_, _, done)| *done).count(),
1888 subtasks.len()
1889 ));
1890 for (index, text, done) in &subtasks {
1891 plain.push_str(&format!(
1892 " {index}. {} {}\n",
1893 if *done { "[✓]" } else { "[ ]" },
1894 terminal_text(text)
1895 ));
1896 }
1897 }
1898 let notes = description_note_lines(&task.description);
1899 if notes.is_empty() {
1900 plain.push_str("description: —\n");
1901 } else {
1902 plain.push_str("description:\n");
1903 for note in notes {
1904 plain.push_str(&format!(" {}\n", terminal_text(¬e)));
1905 }
1906 }
1907 plain
1908 },
1909 ))
1910}
1911
1912fn description_note_lines(description: &[Block]) -> Vec<String> {
1913 let mut numbered = 0usize;
1914 let mut notes = Vec::new();
1915 for block in description {
1916 match block {
1917 Block::Todo { .. } => numbered = 0,
1918 Block::Text { text } => {
1919 numbered = 0;
1920 if !text.trim().is_empty() {
1921 notes.push(text.clone());
1922 }
1923 }
1924 Block::Bullet { text } => {
1925 numbered = 0;
1926 notes.push(format!("- {text}"));
1927 }
1928 Block::Number { text } => {
1929 numbered += 1;
1930 notes.push(format!("{numbered}. {text}"));
1931 }
1932 Block::Link { url } => {
1933 numbered = 0;
1934 notes.push(url.clone());
1935 }
1936 Block::Image { attachment_id } => {
1937 numbered = 0;
1938 notes.push(format!("[image:{attachment_id}]"));
1939 }
1940 }
1941 }
1942 notes
1943}
1944
1945fn cmd_set_done(
1946 store: &mut Store,
1947 query: &str,
1948 done: bool,
1949 json_mode: bool,
1950) -> Result<Rendered, CliError> {
1951 let (task, snapshot) = store.update_with_snapshot(|data| {
1952 let id = data.resolve_task_id(query)?;
1953 data.set_task_done(&id, done)
1954 })?;
1955 let categories = snapshot.categories;
1956 let labels = snapshot.labels;
1957 Ok(rendered(
1958 json_mode,
1959 || task_json(&categories, &labels, &task),
1960 || {
1961 format!(
1962 "{} {} {}\n",
1963 if done { "done" } else { "undone" },
1964 terminal_text(&short_id(&task.id)),
1965 terminal_text(&task.title)
1966 )
1967 },
1968 ))
1969}
1970
1971fn cmd_delete(store: &mut Store, query: &str, json_mode: bool) -> Result<Rendered, CliError> {
1972 let (task, snapshot) = store.update_with_snapshot(|data| {
1973 let id = data.resolve_task_id(query)?;
1974 data.delete_task(&id)
1975 })?;
1976 let categories = snapshot.categories;
1977 let labels = snapshot.labels;
1978 Ok(rendered(
1979 json_mode,
1980 || task_json(&categories, &labels, &task),
1981 || {
1982 format!(
1983 "deleted {} {}\n",
1984 terminal_text(&short_id(&task.id)),
1985 terminal_text(&task.title)
1986 )
1987 },
1988 ))
1989}
1990
1991fn cmd_move(
1992 store: &mut Store,
1993 query: &str,
1994 before: Option<&str>,
1995 after: Option<&str>,
1996 json_mode: bool,
1997) -> Result<Rendered, CliError> {
1998 let (relation, position, target_query) = match (before, after) {
1999 (Some(target), None) => ("before", RelativePosition::Before, target),
2000 (None, Some(target)) => ("after", RelativePosition::After, target),
2001 _ => {
2002 return Err(CliError::validation(
2003 "pass exactly one of --before or --after",
2004 ));
2005 }
2006 };
2007 let ((task, target), snapshot) = store.update_with_snapshot(|data| {
2008 let id = data.resolve_task_id(query)?;
2009 let target_id = data.resolve_task_id(target_query)?;
2010 let target = data.task(&target_id)?.clone();
2011 let task = data.move_task_relative(&id, &target_id, position)?;
2012 Ok((task, target))
2013 })?;
2014 let categories = snapshot.categories;
2015 let labels = snapshot.labels;
2016 Ok(rendered(
2017 json_mode,
2018 || {
2019 json!({
2020 "moved": task_json(&categories, &labels, &task),
2021 "relation": relation,
2022 "target": { "id": target.id, "title": target.title },
2023 })
2024 },
2025 || {
2026 format!(
2027 "moved {} {relation} {}\n",
2028 terminal_text(&short_id(&task.id)),
2029 terminal_text(&short_id(&target.id))
2030 )
2031 },
2032 ))
2033}
2034
2035fn cmd_purge(
2036 store: &mut Store,
2037 category: Option<&str>,
2038 json_mode: bool,
2039) -> Result<Rendered, CliError> {
2040 let (removed, snapshot) = store.update_with_snapshot(|data| {
2041 let scope = match category {
2042 Some(query) => PurgeScope::Category(data.resolve_category_id(query)?),
2043 None => PurgeScope::All,
2044 };
2045 data.purge_completed(&scope)
2046 })?;
2047 let categories = snapshot.categories;
2048 let labels = snapshot.labels;
2049 Ok(rendered(
2050 json_mode,
2051 || {
2052 json!({
2053 "purged": removed
2054 .iter()
2055 .map(|task| task_json(&categories, &labels, task))
2056 .collect::<Vec<_>>(),
2057 "count": removed.len(),
2058 })
2059 },
2060 || format!("purged {} completed task(s)\n", removed.len()),
2061 ))
2062}
2063
2064fn cmd_edit(
2065 store: &mut Store,
2066 arguments: &EditArgs,
2067 json_mode: bool,
2068) -> Result<Rendered, CliError> {
2069 if arguments.title.is_none()
2070 && arguments.description.is_none()
2071 && arguments.due.is_none()
2072 && arguments.time.is_none()
2073 && !arguments.clear_due
2074 && arguments.category.is_none()
2075 && !arguments.clear_cat
2076 && arguments.add_labels.is_empty()
2077 && arguments.remove_labels.is_empty()
2078 && !arguments.clear_labels
2079 && arguments.importance.is_none()
2080 {
2081 return Err(CliError::validation(
2082 "nothing to edit; pass --title / --description / --due / --time / --clear-due / --category / --clear-category / --add-label / --remove-label / --clear-labels / --importance",
2083 ));
2084 }
2085 if arguments.clear_due && (arguments.due.is_some() || arguments.time.is_some()) {
2086 return Err(CliError::validation(
2087 "--clear-due cannot be combined with --due / --time",
2088 ));
2089 }
2090 if arguments.clear_cat && arguments.category.is_some() {
2091 return Err(CliError::validation(
2092 "--clear-category cannot be combined with --category",
2093 ));
2094 }
2095 if arguments.clear_labels && !arguments.remove_labels.is_empty() {
2096 return Err(CliError::validation(
2097 "--clear-labels cannot be combined with --remove-label",
2098 ));
2099 }
2100 let query = arguments.id.as_str();
2101 let (title, inline_due) = match arguments.title.as_deref() {
2102 Some(title) => {
2103 let (title, inline_due) = split_inline_title(title)?;
2104 (Some(title), inline_due)
2105 }
2106 None => (None, String::new()),
2107 };
2108 let description = arguments.description.as_deref().map(description_from_text);
2109 let due_argument = arguments.due.as_deref();
2110 let time_argument = arguments.time.as_deref();
2111 let clear_due = arguments.clear_due;
2112 let category_query = arguments.category.as_deref();
2113 let clear_category = arguments.clear_cat;
2114 let add_label_queries = &arguments.add_labels;
2115 let remove_label_queries = &arguments.remove_labels;
2116 let clear_labels = arguments.clear_labels;
2117 let importance = arguments.importance;
2118 let (task_id, snapshot) = store.update_with_snapshot(|data| {
2119 let id = data.resolve_task_id(query)?;
2120 let add_label_ids = resolve_label_ids(data, add_label_queries)?;
2121 let remove_label_ids = resolve_label_ids(data, remove_label_queries)?;
2122 if let Some(label_id) = add_label_ids
2123 .iter()
2124 .find(|label_id| remove_label_ids.contains(label_id))
2125 {
2126 let label = data.label(label_id)?;
2127 return Err(StoreError::validation(format!(
2128 "label {:?} cannot be both added and removed",
2129 label.name
2130 )));
2131 }
2132 let due = if clear_due {
2133 Some(String::new())
2134 } else if due_argument.is_none() && time_argument.is_none() && !inline_due.is_empty() {
2135 Some(inline_due)
2136 } else {
2137 due_for_edit(&data.task(&id)?.due, due_argument, time_argument)
2138 .map_err(|error| StoreError::validation(error.message))?
2139 };
2140 let category_id = if clear_category {
2141 Some(None)
2142 } else {
2143 category_query
2144 .map(|query| data.resolve_category_id(query).map(Some))
2145 .transpose()?
2146 };
2147 let label_ids = if clear_labels || !add_label_ids.is_empty() || !remove_label_ids.is_empty()
2148 {
2149 let mut selected: HashSet<_> = if clear_labels {
2150 HashSet::new()
2151 } else {
2152 data.task(&id)?.label_ids.iter().cloned().collect()
2153 };
2154 for label_id in remove_label_ids {
2155 selected.remove(&label_id);
2156 }
2157 selected.extend(add_label_ids);
2158 Some(
2159 data.labels
2160 .iter()
2161 .filter(|label| selected.contains(&label.id))
2162 .map(|label| label.id.clone())
2163 .collect(),
2164 )
2165 } else {
2166 None
2167 };
2168 let task = data.edit_task(
2169 &id,
2170 TaskPatch {
2171 title,
2172 description,
2173 due,
2174 importance,
2175 category_id,
2176 label_ids,
2177 ..TaskPatch::default()
2178 },
2179 )?;
2180 Ok(task.id)
2181 })?;
2182 let task = snapshot.task(&task_id)?.clone();
2183 let categories = snapshot.categories;
2184 let labels = snapshot.labels;
2185 Ok(rendered(
2186 json_mode,
2187 || task_json(&categories, &labels, &task),
2188 || {
2189 format!(
2190 "updated {} {}\n",
2191 terminal_text(&short_id(&task.id)),
2192 terminal_text(&task.title)
2193 )
2194 },
2195 ))
2196}
2197
2198fn cmd_subtasks_list(store: &Store, query: &str, json_mode: bool) -> Result<Rendered, CliError> {
2201 let data = store.snapshot()?;
2202 let id = data.resolve_task_id(query)?;
2203 let task = data.task(&id)?;
2204 let subtasks = collect_subtasks(&task.description);
2205 let done_count = subtasks.iter().filter(|(_, _, done)| *done).count();
2206 Ok(rendered(
2207 json_mode,
2208 || {
2209 json!({
2210 "task_id": task.id,
2211 "title": task.title,
2212 "subtasks": subtasks_to_json(&subtasks),
2213 "done": done_count,
2214 "total": subtasks.len(),
2215 })
2216 },
2217 || {
2218 let mut plain = format!(
2219 "{} {}",
2220 terminal_text(&short_id(&task.id)),
2221 terminal_text(&task.title)
2222 );
2223 if subtasks.is_empty() {
2224 plain.push_str(" (no subtasks)\n");
2225 } else {
2226 plain.push('\n');
2227 for (index, text, done) in &subtasks {
2228 let check = if *done { "[✓]" } else { "[ ]" };
2229 plain.push_str(&format!(" {index}. {check} {}\n", terminal_text(text)));
2230 }
2231 plain.push_str(&format!("— {done_count}/{} done\n", subtasks.len()));
2232 }
2233 plain
2234 },
2235 ))
2236}
2237
2238fn cmd_subtask_add(
2239 store: &mut Store,
2240 query: &str,
2241 text: &str,
2242 done: bool,
2243 json_mode: bool,
2244) -> Result<Rendered, CliError> {
2245 let text = text.trim().to_string();
2246 if text.is_empty() {
2247 return Err(CliError::validation(
2248 "subtask text required (positional or --text)",
2249 ));
2250 }
2251 let (task, index) = store.update(|data| {
2252 let id = data.resolve_task_id(query)?;
2253 let mut description = data.task(&id)?.description.clone();
2254 description.push(Block::todo(&text, done));
2255 let task = data.edit_task(
2256 &id,
2257 TaskPatch {
2258 description: Some(description),
2259 ..TaskPatch::default()
2260 },
2261 )?;
2262 let index = collect_subtasks(&task.description).len();
2263 Ok((task, index))
2264 })?;
2265 Ok(rendered(
2266 json_mode,
2267 || {
2268 json!({
2269 "task_id": task.id,
2270 "index": index,
2271 "text": text,
2272 "done": done,
2273 "subtasks": subtasks_json(&task.description),
2274 })
2275 },
2276 || {
2277 format!(
2278 "added subtask {index} on {} {}\n",
2279 terminal_text(&short_id(&task.id)),
2280 terminal_text(&text)
2281 )
2282 },
2283 ))
2284}
2285
2286enum SubtaskMutation<'a> {
2287 SetDone(Option<bool>),
2288 Edit(&'a str),
2289 Delete,
2290}
2291
2292fn mutate_subtask(
2293 store: &mut Store,
2294 query: &str,
2295 index: usize,
2296 mutation: SubtaskMutation<'_>,
2297) -> Result<(Task, String, bool), CliError> {
2298 store
2299 .update(|data| {
2300 let id = data.resolve_task_id(query)?;
2301 let mut description = data.task(&id)?.description.clone();
2302 let description_index = subtask_description_index(&description, index)
2303 .map_err(|error| StoreError::validation(error.message))?;
2304 let (text, done) = match mutation {
2305 SubtaskMutation::SetDone(requested) => {
2306 let Block::Todo { text, done } = &mut description[description_index] else {
2307 unreachable!("subtask index resolved to a non-subtask block")
2308 };
2309 *done = requested.unwrap_or(!*done);
2310 (text.clone(), *done)
2311 }
2312 SubtaskMutation::Edit(replacement) => {
2313 let Block::Todo { text, done } = &mut description[description_index] else {
2314 unreachable!("subtask index resolved to a non-subtask block")
2315 };
2316 *text = replacement.to_string();
2317 (text.clone(), *done)
2318 }
2319 SubtaskMutation::Delete => {
2320 let Block::Todo { text, done } = description.remove(description_index) else {
2321 unreachable!("subtask index resolved to a non-subtask block")
2322 };
2323 (text, done)
2324 }
2325 };
2326 let task = data.edit_task(
2327 &id,
2328 TaskPatch {
2329 description: Some(description),
2330 ..TaskPatch::default()
2331 },
2332 )?;
2333 Ok((task, text, done))
2334 })
2335 .map_err(Into::into)
2336}
2337
2338fn cmd_subtask_set_done(
2339 store: &mut Store,
2340 query: &str,
2341 index: usize,
2342 done: Option<bool>,
2343 json_mode: bool,
2344) -> Result<Rendered, CliError> {
2345 let (task, text, new_done) =
2346 mutate_subtask(store, query, index, SubtaskMutation::SetDone(done))?;
2347 Ok(rendered(
2348 json_mode,
2349 || {
2350 json!({
2351 "task_id": task.id,
2352 "index": index,
2353 "text": text,
2354 "done": new_done,
2355 "subtasks": subtasks_json(&task.description),
2356 })
2357 },
2358 || {
2359 format!(
2360 "{} subtask {index} on {} {}\n",
2361 if new_done { "done" } else { "undone" },
2362 terminal_text(&short_id(&task.id)),
2363 terminal_text(&text)
2364 )
2365 },
2366 ))
2367}
2368
2369fn cmd_subtask_edit(
2370 store: &mut Store,
2371 query: &str,
2372 index: usize,
2373 text: &str,
2374 json_mode: bool,
2375) -> Result<Rendered, CliError> {
2376 let text = text.trim().to_string();
2377 if text.is_empty() {
2378 return Err(CliError::validation(
2379 "subtask text required (positional or --text)",
2380 ));
2381 }
2382 let (task, _, done) = mutate_subtask(store, query, index, SubtaskMutation::Edit(&text))?;
2383 Ok(rendered(
2384 json_mode,
2385 || {
2386 json!({
2387 "task_id": task.id,
2388 "index": index,
2389 "text": text,
2390 "done": done,
2391 "subtasks": subtasks_json(&task.description),
2392 })
2393 },
2394 || {
2395 format!(
2396 "updated subtask {index} on {} {}\n",
2397 terminal_text(&short_id(&task.id)),
2398 terminal_text(&text)
2399 )
2400 },
2401 ))
2402}
2403
2404fn cmd_subtask_delete(
2405 store: &mut Store,
2406 query: &str,
2407 index: usize,
2408 json_mode: bool,
2409) -> Result<Rendered, CliError> {
2410 let (task, text, done) = mutate_subtask(store, query, index, SubtaskMutation::Delete)?;
2411 Ok(rendered(
2412 json_mode,
2413 || {
2414 json!({
2415 "task_id": task.id,
2416 "deleted": { "index": index, "text": text, "done": done },
2417 "subtasks": subtasks_json(&task.description),
2418 })
2419 },
2420 || {
2421 format!(
2422 "deleted subtask {index} on {} {}\n",
2423 terminal_text(&short_id(&task.id)),
2424 terminal_text(&text)
2425 )
2426 },
2427 ))
2428}