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