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