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