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