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 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: Vec<OsString> = 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 terminal_error(error: io::Error) -> CliError {
507 CliError {
508 kind: "terminal",
509 message: error.to_string(),
510 }
511}
512
513fn requested_json(arguments: &[OsString]) -> bool {
514 arguments
515 .iter()
516 .skip(1)
517 .take_while(|argument| argument.as_os_str() != "--")
518 .any(|argument| argument.as_os_str() == "--json")
519}
520
521fn emit_parse_error(error: clap::Error, json_mode: bool) -> ! {
522 let help = matches!(
523 error.kind(),
524 clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion
525 );
526 let exit_code = if help { 0 } else { error.exit_code() };
527 if !json_mode {
528 let write = if help {
529 Rendered::Plain(error.to_string()).emit()
530 } else {
531 write_stderr(&format!("{}\n", terminal_text(&error.to_string())))
532 };
533 exit_after_write(write, exit_code);
534 }
535 let value = if help {
536 json!({ "ok": true, "kind": "help", "help": error.to_string() })
537 } else {
538 json!({ "ok": false, "kind": "usage", "error": error.to_string() })
539 };
540 exit_after_write(Rendered::Json(value).emit(), exit_code);
541}
542
543fn emit_runtime_error(error: CliError, json_mode: bool) -> ! {
544 let write = if json_mode {
545 Rendered::Json(json!({
546 "ok": false,
547 "kind": error.kind,
548 "error": error.message,
549 }))
550 .emit()
551 } else {
552 write_stderr(&format!("mach: {}\n", terminal_text(&error.message)))
553 };
554 exit_after_write(write, 1);
555}
556
557fn emit_success(output: Rendered) {
558 if let Err(error) = output.emit() {
559 if error.kind() == io::ErrorKind::BrokenPipe {
560 return;
561 }
562 let _ = write_stderr(&format!("mach: could not write output: {error}\n"));
563 std::process::exit(1);
564 }
565}
566
567fn write_stderr(text: &str) -> io::Result<()> {
568 let stderr = io::stderr();
569 stderr.lock().write_all(text.as_bytes())
570}
571
572fn exit_after_write(result: io::Result<()>, intended_code: i32) -> ! {
573 match result {
574 Ok(()) => std::process::exit(intended_code),
575 Err(error) if error.kind() == io::ErrorKind::BrokenPipe => std::process::exit(0),
576 Err(_) => std::process::exit(1),
577 }
578}
579
580fn dispatch(store: &mut Store, command: Command, json_mode: bool) -> Result<Rendered, CliError> {
581 match command {
582 Command::List {
583 category,
584 open,
585 done,
586 } => cmd_list(store, category.as_deref(), open, done, json_mode),
587 Command::Categories { action } => match action {
588 None | Some(CatAction::List) => cmd_categories_list(store, json_mode),
589 Some(CatAction::Add { name, description }) => {
590 cmd_category_add(store, &name, description.as_deref(), json_mode)
591 }
592 Some(CatAction::Edit {
593 name,
594 new_name,
595 description,
596 clear_description,
597 }) => cmd_category_edit(
598 store,
599 &name,
600 new_name.as_deref(),
601 description.as_deref(),
602 clear_description,
603 json_mode,
604 ),
605 Some(CatAction::Delete { name }) => cmd_category_delete(store, &name, json_mode),
606 },
607 Command::Add(arguments) => cmd_add(store, &arguments, json_mode),
608 Command::Show { id } => cmd_show(store, &id, json_mode),
609 Command::Done { id } => cmd_set_done(store, &id, true, json_mode),
610 Command::Undone { id } => cmd_set_done(store, &id, false, json_mode),
611 Command::Delete { id } => cmd_delete(store, &id, json_mode),
612 Command::Move { id, before, after } => {
613 cmd_move(store, &id, before.as_deref(), after.as_deref(), json_mode)
614 }
615 Command::Purge { done, category } => cmd_purge(store, done, category.as_deref(), json_mode),
616 Command::Edit(arguments) => cmd_edit(store, &arguments, json_mode),
617 Command::Subtasks { task, action } => match action {
618 None | Some(SubAction::List) => cmd_subtasks_list(store, &task, json_mode),
619 Some(SubAction::Add {
620 text_pos,
621 text,
622 done,
623 }) => cmd_subtask_add(
624 store,
625 &task,
626 &text.or(text_pos).unwrap_or_default(),
627 done,
628 json_mode,
629 ),
630 Some(SubAction::Done { index }) => {
631 cmd_subtask_set_done(store, &task, index, Some(true), json_mode)
632 }
633 Some(SubAction::Undone { index }) => {
634 cmd_subtask_set_done(store, &task, index, Some(false), json_mode)
635 }
636 Some(SubAction::Toggle { index }) => {
637 cmd_subtask_set_done(store, &task, index, None, json_mode)
638 }
639 Some(SubAction::Edit {
640 index,
641 text_pos,
642 text,
643 }) => cmd_subtask_edit(
644 store,
645 &task,
646 index,
647 &text.or(text_pos).unwrap_or_default(),
648 json_mode,
649 ),
650 Some(SubAction::Delete { index }) => cmd_subtask_delete(store, &task, index, json_mode),
651 },
652 Command::Export { file } => cmd_export(store, file.as_deref(), json_mode),
653 Command::Import { file } => cmd_import(store, &file, json_mode),
654 Command::Update { .. } => Err(CliError {
655 kind: "internal",
656 message: "update command crossed the data-command boundary".into(),
657 }),
658 }
659}
660
661fn cmd_export(
662 store: &Store,
663 path: Option<&std::path::Path>,
664 json_mode: bool,
665) -> Result<Rendered, CliError> {
666 let summary = crate::archive::export(store, path)?;
667 let value = json!({
668 "ok": true,
669 "archive": summary.path.display().to_string(),
670 "tasks": summary.tasks,
671 "categories": summary.categories,
672 "images": summary.images,
673 });
674 let contents =
675 crate::archive::content_count_text(summary.tasks, summary.categories, summary.images);
676 let plain = format!(
677 "exported {contents} to {}\n",
678 terminal_text(&summary.path.display().to_string())
679 );
680 Ok(rendered(json_mode, value, plain))
681}
682
683fn cmd_import(
684 store: &mut Store,
685 path: &std::path::Path,
686 json_mode: bool,
687) -> Result<Rendered, CliError> {
688 let outcome = crate::archive::import(store, path)?;
689 let summary = outcome.summary;
690 let value = json!({
691 "ok": true,
692 "archive": summary.path.display().to_string(),
693 "tasks_added": summary.tasks_added,
694 "tasks_unchanged": summary.tasks_unchanged,
695 "categories_added": summary.categories_added,
696 "categories_unchanged": summary.categories_unchanged,
697 "images_added": summary.images_added,
698 "images_unchanged": summary.images_unchanged,
699 });
700 let added = crate::archive::content_count_text(
701 summary.tasks_added,
702 summary.categories_added,
703 summary.images_added,
704 );
705 let unchanged = crate::archive::content_count_text(
706 summary.tasks_unchanged,
707 summary.categories_unchanged,
708 summary.images_unchanged,
709 );
710 let plain = if summary.tasks_added + summary.categories_added + summary.images_added == 0 {
711 format!("nothing imported; {unchanged} already present\n")
712 } else {
713 format!("imported {added}; {unchanged} already present\n")
714 };
715 Ok(rendered(json_mode, value, plain))
716}
717
718fn cmd_update(do_install: bool, json_mode: bool) -> Result<Rendered, CliError> {
719 let now = Utc::now().timestamp();
720 let mut update_state = crate::update_state::UpdateStateStore::open_default().ok();
721 let lease = update_state
722 .as_mut()
723 .and_then(|store| store.claim_manual(now).ok());
724 let checked = match crate::update::check_with_etag(None) {
725 Ok(crate::update::CheckResponse::Modified { info, etag }) => (info, etag),
726 Ok(crate::update::CheckResponse::NotModified) => {
727 if let (Some(store), Some(lease)) = (update_state.as_mut(), lease.as_ref()) {
728 let _ = store.finish_failure(lease, Utc::now().timestamp(), None);
729 }
730 return Err(CliError::update(
731 "GitHub returned 304 without a conditional request",
732 ));
733 }
734 Err(error) => {
735 if let (Some(store), Some(lease)) = (update_state.as_mut(), lease.as_ref()) {
736 let _ = store.finish_failure(lease, Utc::now().timestamp(), error.retry_at);
737 }
738 return Err(CliError::update(error.message));
739 }
740 };
741 let (info, etag) = checked;
742 let install = if do_install && info.newer {
743 crate::update::install(&info).map(Some)
744 } else {
745 Ok(None)
746 };
747 if let (Some(store), Some(lease)) = (update_state.as_mut(), lease.as_ref()) {
748 let _ = store.finish_modified(lease, Utc::now().timestamp(), etag.as_deref(), &info.latest);
749 }
750 let install = install.map_err(CliError::update)?;
751
752 let value = json!({
753 "ok": true,
754 "current": info.current,
755 "latest": info.latest,
756 "newer": info.newer,
757 "prerelease": info.prerelease,
758 "url": info.release_url,
759 "installed": install.is_some(),
760 "destination": install
761 .as_ref()
762 .map(|result| result.destination.display().to_string()),
763 "tag": install.as_ref().map(|result| result.tag.as_str()).unwrap_or(&info.tag),
764 });
765 let mut plain = format!("{}\n", terminal_text(&info.summary()));
766 if info.newer && install.is_none() {
767 plain.push('\n');
768 for line in info.install_hint().lines() {
769 plain.push_str(&terminal_text(line));
770 plain.push('\n');
771 }
772 }
773 if do_install && install.is_none() {
774 plain.push_str("Already up to date.\n");
775 } else if let Some(result) = install {
776 plain.push_str(&format!(
777 "Installed {} to {}. Restart mach to use the new build.\n",
778 terminal_text(&result.tag),
779 terminal_text(&result.destination.display().to_string())
780 ));
781 }
782 Ok(rendered(json_mode, value, plain))
783}
784
785fn short_id(id: &str) -> String {
788 id.chars().take(8).collect()
789}
790
791fn terminal_text(text: &str) -> String {
792 let mut safe = String::with_capacity(text.len());
793 for character in text.chars() {
794 match character {
795 '\n' => safe.push_str("\\n"),
796 '\r' => safe.push_str("\\r"),
797 '\t' => safe.push_str("\\t"),
798 character if character.is_control() => {
799 safe.push_str(&format!("\\u{{{:x}}}", character as u32));
800 }
801 character => safe.push(character),
802 }
803 }
804 safe
805}
806
807fn body_from_text(text: &str) -> Vec<Block> {
808 if text.is_empty() {
809 return Vec::new();
810 }
811 text.lines().map(line_to_block).collect()
812}
813
814fn line_to_block(line: &str) -> Block {
815 let text = line.trim_end();
816 if let Some(rest) = text.strip_prefix("[ ] ") {
817 return Block::todo(rest, false);
818 }
819 if let Some(rest) = text
820 .strip_prefix("[x] ")
821 .or_else(|| text.strip_prefix("[X] "))
822 .or_else(|| text.strip_prefix("[✓] "))
823 {
824 return Block::todo(rest, true);
825 }
826 if let Some(rest) = text.strip_prefix("- ").or_else(|| text.strip_prefix("• ")) {
827 return Block::bullet(rest);
828 }
829 if let Some(rest) = strip_number_prefix(text) {
830 return Block::number(rest);
831 }
832 if let Some(path) = text
833 .strip_prefix("[image:")
834 .and_then(|value| value.strip_suffix(']'))
835 .filter(|path| !path.is_empty())
836 {
837 return Block::image(path);
838 }
839 if text.starts_with("http://") || text.starts_with("https://") {
840 return Block::link(text);
841 }
842 Block::text(text)
843}
844
845fn strip_number_prefix(line: &str) -> Option<&str> {
846 let bytes = line.as_bytes();
847 let mut index = 0;
848 while index < bytes.len() && bytes[index].is_ascii_digit() {
849 index += 1;
850 }
851 if index == 0 {
852 return None;
853 }
854 line.get(index..)?.strip_prefix(". ")
855}
856
857fn body_to_text(body: &[Block]) -> String {
858 let mut numbered = 0usize;
859 body.iter()
860 .map(|block| match block {
861 Block::Text { text } => {
862 numbered = 0;
863 text.clone()
864 }
865 Block::Todo { text, done } => {
866 numbered = 0;
867 format!("[{}] {text}", if *done { "x" } else { " " })
868 }
869 Block::Bullet { text } => {
870 numbered = 0;
871 format!("- {text}")
872 }
873 Block::Number { text } => {
874 numbered += 1;
875 format!("{numbered}. {text}")
876 }
877 Block::Link { url } => {
878 numbered = 0;
879 url.clone()
880 }
881 Block::Image { attachment_id } => {
882 numbered = 0;
883 format!("[image:{attachment_id}]")
884 }
885 })
886 .collect::<Vec<_>>()
887 .join("\n")
888}
889
890fn collect_subtasks(body: &[Block]) -> Vec<(usize, &str, bool)> {
891 body.iter()
892 .filter_map(|block| match block {
893 Block::Todo { text, done } => Some((text.as_str(), *done)),
894 _ => None,
895 })
896 .enumerate()
897 .map(|(index, (text, done))| (index + 1, text, done))
898 .collect()
899}
900
901fn subtask_body_index(body: &[Block], one_based: usize) -> Result<usize, CliError> {
902 if one_based == 0 {
903 return Err(CliError::validation(
904 "subtask index is 1-based (use 1 for the first subtask)",
905 ));
906 }
907 let mut count = 0usize;
908 for (body_index, block) in body.iter().enumerate() {
909 if matches!(block, Block::Todo { .. }) {
910 count += 1;
911 if count == one_based {
912 return Ok(body_index);
913 }
914 }
915 }
916 Err(CliError::validation(format!(
917 "no subtask at index {one_based} (task has {count} subtask(s))"
918 )))
919}
920
921fn subtasks_json(body: &[Block]) -> Vec<Value> {
922 subtasks_to_json(&collect_subtasks(body))
923}
924
925fn subtasks_to_json(subtasks: &[(usize, &str, bool)]) -> Vec<Value> {
926 subtasks
927 .iter()
928 .map(|(index, text, done)| json!({ "index": index, "text": text, "done": done }))
929 .collect()
930}
931
932fn category_name<'a>(categories: &'a [Category], task: &Task) -> Option<&'a str> {
933 task.category_id
934 .as_ref()
935 .and_then(|id| categories.iter().find(|category| category.id == *id))
936 .map(|category| category.name.as_str())
937}
938
939fn task_json(categories: &[Category], task: &Task) -> Value {
940 let subtasks = collect_subtasks(&task.body);
941 let subtasks_json = subtasks_to_json(&subtasks);
942 json!({
943 "id": task.id,
944 "title": task.title,
945 "body": body_to_text(&task.body),
946 "subtasks": subtasks_json,
947 "subtasks_done": subtasks.iter().filter(|(_, _, done)| *done).count(),
948 "subtasks_total": subtasks.len(),
949 "due": task.due,
950 "done": task.done,
951 "importance": task.importance,
952 "category": {
953 "id": task.category_id,
954 "name": category_name(categories, task),
955 },
956 "created": task.created,
957 })
958}
959
960fn category_json(category: &Category) -> Value {
961 json!({
962 "id": category.id,
963 "name": category.name,
964 "description": category.description,
965 })
966}
967
968fn validate_time(raw: &str) -> Result<String, CliError> {
969 let value = raw.trim();
970 if value.len() == 5 && NaiveTime::parse_from_str(value, "%H:%M").is_ok() {
971 Ok(value.to_string())
972 } else {
973 Err(CliError::validation(format!(
974 "invalid time {raw:?}; use HH:MM (24h), e.g. 14:30"
975 )))
976 }
977}
978
979fn due_for_add(due: Option<&str>, time: Option<&str>) -> Result<String, CliError> {
980 let due = due.map(str::trim).filter(|value| !value.is_empty());
981 match (due, time) {
982 (None, None) => Ok(String::new()),
983 (None, Some(time)) => validate_time(time),
984 (Some(due), None) => Ok(due.to_string()),
985 (Some(due), Some(time)) => {
986 if due.contains(':') {
987 return Err(CliError::validation(format!(
988 "due already includes a time ({due}); omit --time or pass date-only --due"
989 )));
990 }
991 Ok(format!("{due} {}", validate_time(time)?))
992 }
993 }
994}
995
996fn split_inline_title(raw: &str) -> Result<(String, String), CliError> {
997 let (inline_due, title) = crate::due::parse(raw.trim());
998 if !inline_due.is_empty() {
999 crate::due::normalize_for_write(&inline_due)
1000 .map_err(|error| CliError::validation(error.to_string()))?;
1001 }
1002 Ok((title, inline_due))
1003}
1004
1005fn due_for_edit(
1008 current: &str,
1009 due: Option<&str>,
1010 time: Option<&str>,
1011) -> Result<Option<String>, CliError> {
1012 if due.is_none() && time.is_none() {
1013 return Ok(None);
1014 }
1015 let existing_time = current.split_once(' ').map(|(_, time)| time.to_string());
1016 let existing_date = current
1017 .split_once(' ')
1018 .map(|(date, _)| date.to_string())
1019 .or_else(|| (!current.is_empty() && !current.contains(':')).then(|| current.to_string()));
1020
1021 if let Some(raw_due) = due {
1022 let raw_due = raw_due.trim();
1023 if raw_due.contains(':') {
1024 if time.is_some() {
1025 return Err(CliError::validation(
1026 "pass either a full --due datetime or --due date + --time, not both",
1027 ));
1028 }
1029 return crate::due::normalize_for_write(raw_due)
1030 .map(Some)
1031 .map_err(|error| CliError::validation(error.to_string()));
1032 }
1033 let chosen_time = match time {
1034 Some(time) => Some(validate_time(time)?),
1035 None => existing_time,
1036 };
1037 let combined = match chosen_time {
1038 Some(time) => format!("{raw_due} {time}"),
1039 None => raw_due.to_string(),
1040 };
1041 return crate::due::normalize_for_write(&combined)
1042 .map(Some)
1043 .map_err(|error| CliError::validation(error.to_string()));
1044 }
1045
1046 let Some(time) = time else {
1047 return Ok(None);
1048 };
1049 let time = validate_time(time)?;
1050 let combined = match existing_date {
1051 Some(date) => format!("{date} {time}"),
1052 None => time,
1053 };
1054 crate::due::normalize_for_write(&combined)
1055 .map(Some)
1056 .map_err(|error| CliError::validation(error.to_string()))
1057}
1058
1059fn task_line(
1060 categories: &[Category],
1061 task: &Task,
1062 show_category: bool,
1063 date_format: &str,
1064) -> String {
1065 let check = if task.done { "[✓]" } else { "[ ]" };
1066 let title = terminal_text(&task.title);
1067 let due = crate::due::display(&task.due, date_format);
1068 let due = if due.is_empty() {
1069 String::new()
1070 } else {
1071 format!(" {}", terminal_text(&due))
1072 };
1073 let flag = if task.importance > 0 {
1074 format!(" {}", crate::model::importance_marks(task.importance))
1075 } else {
1076 String::new()
1077 };
1078 let category = if show_category {
1079 format!(
1080 " [{}]",
1081 terminal_text(category_name(categories, task).unwrap_or("—"))
1082 )
1083 } else {
1084 String::new()
1085 };
1086 let progress = crate::model::todo_progress(task)
1087 .map(|(done, total)| format!(" ({done}/{total})"))
1088 .unwrap_or_default();
1089 format!(
1090 "{} {check} {title}{category}{due}{flag}{progress}\n",
1091 terminal_text(&short_id(&task.id))
1092 )
1093}
1094
1095fn cmd_list(
1098 store: &Store,
1099 category: Option<&str>,
1100 open_only: bool,
1101 done_only: bool,
1102 json_mode: bool,
1103) -> Result<Rendered, CliError> {
1104 let data = store.snapshot()?;
1105 let category_id = category
1106 .map(|query| data.resolve_category_id(query))
1107 .transpose()?;
1108 let show_category = category_id.is_none();
1109 let tasks: Vec<_> = data
1110 .tasks
1111 .iter()
1112 .filter(|task| {
1113 category_id
1114 .as_ref()
1115 .is_none_or(|id| task.category_id.as_deref() == Some(id.as_str()))
1116 })
1117 .filter(|task| {
1118 if open_only {
1119 !task.done
1120 } else if done_only {
1121 task.done
1122 } else {
1123 true
1124 }
1125 })
1126 .collect();
1127 let value = Value::Array(
1128 tasks
1129 .iter()
1130 .map(|task| task_json(&data.categories, task))
1131 .collect(),
1132 );
1133 let mut plain = String::new();
1134 if tasks.is_empty() {
1135 plain.push_str("(no tasks)\n");
1136 } else {
1137 for task in &tasks {
1138 plain.push_str(&task_line(
1139 &data.categories,
1140 task,
1141 show_category,
1142 &data.settings.date_format,
1143 ));
1144 }
1145 let done = tasks.iter().filter(|task| task.done).count();
1146 plain.push_str(&format!("— {} task(s), {done} done\n", tasks.len()));
1147 }
1148 Ok(rendered(json_mode, value, plain))
1149}
1150
1151fn cmd_categories_list(store: &Store, json_mode: bool) -> Result<Rendered, CliError> {
1152 let data = store.snapshot()?;
1153 let stats: Vec<_> = data
1154 .categories
1155 .iter()
1156 .map(|category| {
1157 let (done, total) = data
1158 .tasks
1159 .iter()
1160 .filter(|task| task.category_id.as_deref() == Some(category.id.as_str()))
1161 .fold((0, 0), |(done, total), task| {
1162 (done + usize::from(task.done), total + 1)
1163 });
1164 (category, done, total)
1165 })
1166 .collect();
1167 let categories: Vec<_> = stats
1168 .iter()
1169 .map(|(category, done, total)| {
1170 json!({
1171 "id": category.id,
1172 "name": category.name,
1173 "description": category.description,
1174 "total": total,
1175 "done": done,
1176 })
1177 })
1178 .collect();
1179 let (uncategorized_done, uncategorized_total) = data
1180 .tasks
1181 .iter()
1182 .filter(|task| task.category_id.is_none())
1183 .fold((0, 0), |(done, total), task| {
1184 (done + usize::from(task.done), total + 1)
1185 });
1186 let value = json!({
1187 "categories": categories,
1188 "uncategorized": {
1189 "total": uncategorized_total,
1190 "done": uncategorized_done,
1191 },
1192 });
1193 let mut plain = String::new();
1194 if data.categories.is_empty() {
1195 plain.push_str("(no categories)\n");
1196 } else {
1197 for (category, done, total) in &stats {
1198 plain.push_str(&format!(
1199 "{} {}/{}\n",
1200 terminal_text(&category.name),
1201 done,
1202 total
1203 ));
1204 }
1205 }
1206 if uncategorized_total > 0 {
1207 plain.push_str(&format!(
1208 "— uncategorized {}/{}\n",
1209 uncategorized_done, uncategorized_total
1210 ));
1211 }
1212 Ok(rendered(json_mode, value, plain))
1213}
1214
1215fn cmd_category_add(
1216 store: &mut Store,
1217 name: &str,
1218 description: Option<&str>,
1219 json_mode: bool,
1220) -> Result<Rendered, CliError> {
1221 let name = name.trim().to_string();
1222 let description = description.unwrap_or_default().to_string();
1223 let category = store.update(|data| data.create_category(name, description))?;
1224 Ok(rendered(
1225 json_mode,
1226 category_json(&category),
1227 format!("created category {}\n", terminal_text(&category.name)),
1228 ))
1229}
1230
1231fn cmd_category_edit(
1232 store: &mut Store,
1233 query: &str,
1234 new_name: Option<&str>,
1235 description: Option<&str>,
1236 clear_description: bool,
1237 json_mode: bool,
1238) -> Result<Rendered, CliError> {
1239 if new_name.is_none() && description.is_none() && !clear_description {
1240 return Err(CliError::validation(
1241 "nothing to edit; pass --name / --description / --clear-description",
1242 ));
1243 }
1244 if clear_description && description.is_some() {
1245 return Err(CliError::validation(
1246 "--clear-description cannot be combined with --description",
1247 ));
1248 }
1249 let patch = CategoryPatch {
1250 name: new_name.map(|name| name.trim().to_string()),
1251 description: if clear_description {
1252 Some(String::new())
1253 } else {
1254 description.map(str::to_string)
1255 },
1256 };
1257 let query = query.to_string();
1258 let category = store.update(|data| {
1259 let id = data.resolve_category_id(&query)?;
1260 data.edit_category(&id, patch)
1261 })?;
1262 Ok(rendered(
1263 json_mode,
1264 category_json(&category),
1265 format!("updated category {}\n", terminal_text(&category.name)),
1266 ))
1267}
1268
1269fn cmd_category_delete(
1270 store: &mut Store,
1271 query: &str,
1272 json_mode: bool,
1273) -> Result<Rendered, CliError> {
1274 let query = query.to_string();
1275 let category = store.update(|data| {
1276 let id = data.resolve_category_id(&query)?;
1277 data.delete_category(&id)
1278 })?;
1279 Ok(rendered(
1280 json_mode,
1281 json!({ "deleted": category.name, "id": category.id }),
1282 format!(
1283 "deleted category {} (tasks uncategorized)\n",
1284 terminal_text(&category.name)
1285 ),
1286 ))
1287}
1288
1289fn cmd_add(store: &mut Store, arguments: &AddArgs, json_mode: bool) -> Result<Rendered, CliError> {
1290 let raw_title = arguments
1291 .title
1292 .as_deref()
1293 .or(arguments.title_pos.as_deref())
1294 .unwrap_or_default()
1295 .trim();
1296 let (title, inline_due) = split_inline_title(raw_title)?;
1297 if title.is_empty() {
1298 return Err(CliError::validation(
1299 "title required (positional or --title)",
1300 ));
1301 }
1302 let mut body = arguments
1303 .body
1304 .as_deref()
1305 .map(body_from_text)
1306 .unwrap_or_default();
1307 for subtask in &arguments.subtasks {
1308 let text = subtask.trim();
1309 if text.is_empty() {
1310 return Err(CliError::validation("--subtask text cannot be empty"));
1311 }
1312 body.push(Block::todo(text, false));
1313 }
1314 let due = if arguments.due.is_none() && arguments.time.is_none() {
1315 inline_due
1316 } else {
1317 due_for_add(arguments.due.as_deref(), arguments.time.as_deref())?
1318 };
1319 let category_query = arguments.category.clone();
1320 let importance = arguments.importance;
1321 let (task_id, snapshot) = store.update_with_snapshot(|data| {
1322 let category_id = category_query
1323 .as_deref()
1324 .map(|query| data.resolve_category_id(query))
1325 .transpose()?;
1326 let task = data.create_task(title, body, due, importance, category_id)?;
1327 Ok(task.id)
1328 })?;
1329 let task = snapshot.task(&task_id)?.clone();
1330 let categories = snapshot.categories;
1331 let subtasks = collect_subtasks(&task.body).len();
1332 let plain = if subtasks == 0 {
1333 format!(
1334 "added {} {}\n",
1335 terminal_text(&short_id(&task.id)),
1336 terminal_text(&task.title)
1337 )
1338 } else {
1339 format!(
1340 "added {} {} ({} subtask{})\n",
1341 terminal_text(&short_id(&task.id)),
1342 terminal_text(&task.title),
1343 subtasks,
1344 if subtasks == 1 { "" } else { "s" }
1345 )
1346 };
1347 Ok(rendered(json_mode, task_json(&categories, &task), plain))
1348}
1349
1350fn cmd_show(store: &Store, query: &str, json_mode: bool) -> Result<Rendered, CliError> {
1351 let data = store.snapshot()?;
1352 let id = data.resolve_task_id(query)?;
1353 let task = data.task(&id)?;
1354 let mut plain = format!(
1355 "id: {}\ntitle: {}\ndone: {}\ncategory: {}\ndue: {}\nimportance: {} ({})\ncreated: {}\n",
1356 terminal_text(&task.id),
1357 terminal_text(&task.title),
1358 task.done,
1359 terminal_text(category_name(&data.categories, task).unwrap_or("—")),
1360 if task.due.is_empty() {
1361 "—".into()
1362 } else {
1363 terminal_text(&task.due)
1364 },
1365 task.importance,
1366 crate::model::importance_marks(task.importance),
1367 terminal_text(&task.created),
1368 );
1369 let subtasks = collect_subtasks(&task.body);
1370 if subtasks.is_empty() {
1371 plain.push_str("subtasks: —\n");
1372 } else {
1373 plain.push_str(&format!(
1374 "subtasks: {}/{}\n",
1375 subtasks.iter().filter(|(_, _, done)| *done).count(),
1376 subtasks.len()
1377 ));
1378 for (index, text, done) in &subtasks {
1379 plain.push_str(&format!(
1380 " {index}. {} {}\n",
1381 if *done { "[✓]" } else { "[ ]" },
1382 terminal_text(text)
1383 ));
1384 }
1385 }
1386 let notes = body_note_lines(&task.body);
1387 if notes.is_empty() {
1388 plain.push_str("body: —\n");
1389 } else {
1390 plain.push_str("body:\n");
1391 for note in notes {
1392 plain.push_str(&format!(" {}\n", terminal_text(¬e)));
1393 }
1394 }
1395 Ok(rendered(
1396 json_mode,
1397 task_json(&data.categories, task),
1398 plain,
1399 ))
1400}
1401
1402fn body_note_lines(body: &[Block]) -> Vec<String> {
1403 let mut numbered = 0usize;
1404 let mut notes = Vec::new();
1405 for block in body {
1406 match block {
1407 Block::Todo { .. } => numbered = 0,
1408 Block::Text { text } => {
1409 numbered = 0;
1410 if !text.trim().is_empty() {
1411 notes.push(text.clone());
1412 }
1413 }
1414 Block::Bullet { text } => {
1415 numbered = 0;
1416 notes.push(format!("- {text}"));
1417 }
1418 Block::Number { text } => {
1419 numbered += 1;
1420 notes.push(format!("{numbered}. {text}"));
1421 }
1422 Block::Link { url } => {
1423 numbered = 0;
1424 notes.push(url.clone());
1425 }
1426 Block::Image { attachment_id } => {
1427 numbered = 0;
1428 notes.push(format!("[image:{attachment_id}]"));
1429 }
1430 }
1431 }
1432 notes
1433}
1434
1435fn cmd_set_done(
1436 store: &mut Store,
1437 query: &str,
1438 done: bool,
1439 json_mode: bool,
1440) -> Result<Rendered, CliError> {
1441 let query = query.to_string();
1442 let (task, snapshot) = store.update_with_snapshot(|data| {
1443 let id = data.resolve_task_id(&query)?;
1444 data.set_task_done(&id, done)
1445 })?;
1446 let categories = snapshot.categories;
1447 Ok(rendered(
1448 json_mode,
1449 task_json(&categories, &task),
1450 format!(
1451 "{} {} {}\n",
1452 if done { "done" } else { "undone" },
1453 terminal_text(&short_id(&task.id)),
1454 terminal_text(&task.title)
1455 ),
1456 ))
1457}
1458
1459fn cmd_delete(store: &mut Store, query: &str, json_mode: bool) -> Result<Rendered, CliError> {
1460 let query = query.to_string();
1461 let (task, snapshot) = store.update_with_snapshot(|data| {
1462 let id = data.resolve_task_id(&query)?;
1463 data.delete_task(&id)
1464 })?;
1465 let categories = snapshot.categories;
1466 Ok(rendered(
1467 json_mode,
1468 task_json(&categories, &task),
1469 format!(
1470 "deleted {} {}\n",
1471 terminal_text(&short_id(&task.id)),
1472 terminal_text(&task.title)
1473 ),
1474 ))
1475}
1476
1477fn cmd_move(
1478 store: &mut Store,
1479 query: &str,
1480 before: Option<&str>,
1481 after: Option<&str>,
1482 json_mode: bool,
1483) -> Result<Rendered, CliError> {
1484 let (relation, position, target_query) = match (before, after) {
1485 (Some(target), None) => ("before", RelativePosition::Before, target),
1486 (None, Some(target)) => ("after", RelativePosition::After, target),
1487 _ => {
1488 return Err(CliError::validation(
1489 "pass exactly one of --before or --after",
1490 ));
1491 }
1492 };
1493 let query = query.to_string();
1494 let target_query = target_query.to_string();
1495 let ((task, target), snapshot) = store.update_with_snapshot(|data| {
1496 let id = data.resolve_task_id(&query)?;
1497 let target_id = data.resolve_task_id(&target_query)?;
1498 let target = data.task(&target_id)?.clone();
1499 let task = data.move_task_relative(&id, &target_id, position)?;
1500 Ok((task, target))
1501 })?;
1502 let categories = snapshot.categories;
1503 let value = json!({
1504 "moved": task_json(&categories, &task),
1505 "relation": relation,
1506 "target": { "id": target.id, "title": target.title },
1507 });
1508 let plain = format!(
1509 "moved {} {relation} {}\n",
1510 terminal_text(&short_id(&task.id)),
1511 terminal_text(&short_id(&target.id))
1512 );
1513 Ok(rendered(json_mode, value, plain))
1514}
1515
1516fn cmd_purge(
1517 store: &mut Store,
1518 confirmed_done: bool,
1519 category: Option<&str>,
1520 json_mode: bool,
1521) -> Result<Rendered, CliError> {
1522 if !confirmed_done {
1523 return Err(CliError::validation(
1524 "refusing to purge without the explicit --done flag",
1525 ));
1526 }
1527 let category = category.map(str::to_string);
1528 let (removed, snapshot) = store.update_with_snapshot(|data| {
1529 let scope = match category.as_deref() {
1530 Some(query) => PurgeScope::Category(data.resolve_category_id(query)?),
1531 None => PurgeScope::All,
1532 };
1533 data.purge_completed(&scope)
1534 })?;
1535 let categories = snapshot.categories;
1536 let value = json!({
1537 "purged": removed
1538 .iter()
1539 .map(|task| task_json(&categories, task))
1540 .collect::<Vec<_>>(),
1541 "count": removed.len(),
1542 });
1543 let plain = format!("purged {} completed task(s)\n", removed.len());
1544 Ok(rendered(json_mode, value, plain))
1545}
1546
1547fn cmd_edit(
1548 store: &mut Store,
1549 arguments: &EditArgs,
1550 json_mode: bool,
1551) -> Result<Rendered, CliError> {
1552 if arguments.title.is_none()
1553 && arguments.body.is_none()
1554 && arguments.due.is_none()
1555 && arguments.time.is_none()
1556 && !arguments.clear_due
1557 && arguments.category.is_none()
1558 && !arguments.clear_cat
1559 && arguments.importance.is_none()
1560 {
1561 return Err(CliError::validation(
1562 "nothing to edit; pass --title / --body / --due / --time / --clear-due / --category / --clear-category / --importance",
1563 ));
1564 }
1565 if arguments.clear_due && (arguments.due.is_some() || arguments.time.is_some()) {
1566 return Err(CliError::validation(
1567 "--clear-due cannot be combined with --due / --time",
1568 ));
1569 }
1570 if arguments.clear_cat && arguments.category.is_some() {
1571 return Err(CliError::validation(
1572 "--clear-category cannot be combined with --category",
1573 ));
1574 }
1575 let query = arguments.id.clone();
1576 let (title, inline_due) = match arguments.title.as_deref() {
1577 Some(title) => {
1578 let (title, inline_due) = split_inline_title(title)?;
1579 (Some(title), inline_due)
1580 }
1581 None => (None, String::new()),
1582 };
1583 let body = arguments.body.as_deref().map(body_from_text);
1584 let due_argument = arguments.due.clone();
1585 let time_argument = arguments.time.clone();
1586 let clear_due = arguments.clear_due;
1587 let category_query = arguments.category.clone();
1588 let clear_category = arguments.clear_cat;
1589 let importance = arguments.importance;
1590 let (task_id, snapshot) = store.update_with_snapshot(|data| {
1591 let id = data.resolve_task_id(&query)?;
1592 let due = if clear_due {
1593 Some(String::new())
1594 } else if due_argument.is_none() && time_argument.is_none() && !inline_due.is_empty() {
1595 Some(inline_due)
1596 } else {
1597 due_for_edit(
1598 &data.task(&id)?.due,
1599 due_argument.as_deref(),
1600 time_argument.as_deref(),
1601 )
1602 .map_err(|error| StoreError::validation(error.message))?
1603 };
1604 let category_id = if clear_category {
1605 Some(None)
1606 } else {
1607 category_query
1608 .as_deref()
1609 .map(|query| data.resolve_category_id(query).map(Some))
1610 .transpose()?
1611 };
1612 let task = data.edit_task(
1613 &id,
1614 TaskPatch {
1615 title,
1616 body,
1617 due,
1618 importance,
1619 category_id,
1620 ..TaskPatch::default()
1621 },
1622 )?;
1623 Ok(task.id)
1624 })?;
1625 let task = snapshot.task(&task_id)?.clone();
1626 let categories = snapshot.categories;
1627 Ok(rendered(
1628 json_mode,
1629 task_json(&categories, &task),
1630 format!(
1631 "updated {} {}\n",
1632 terminal_text(&short_id(&task.id)),
1633 terminal_text(&task.title)
1634 ),
1635 ))
1636}
1637
1638fn cmd_subtasks_list(store: &Store, query: &str, json_mode: bool) -> Result<Rendered, CliError> {
1641 let data = store.snapshot()?;
1642 let id = data.resolve_task_id(query)?;
1643 let task = data.task(&id)?;
1644 let subtasks = collect_subtasks(&task.body);
1645 let value = json!({
1646 "task_id": task.id,
1647 "title": task.title,
1648 "subtasks": subtasks_json(&task.body),
1649 "done": subtasks.iter().filter(|(_, _, done)| *done).count(),
1650 "total": subtasks.len(),
1651 });
1652 let mut plain = format!(
1653 "{} {}",
1654 terminal_text(&short_id(&task.id)),
1655 terminal_text(&task.title)
1656 );
1657 if subtasks.is_empty() {
1658 plain.push_str(" (no subtasks)\n");
1659 } else {
1660 plain.push('\n');
1661 for (index, text, done) in &subtasks {
1662 let check = if *done { "[✓]" } else { "[ ]" };
1663 plain.push_str(&format!(" {index}. {check} {}\n", terminal_text(text)));
1664 }
1665 plain.push_str(&format!(
1666 "— {}/{} done\n",
1667 subtasks.iter().filter(|(_, _, done)| *done).count(),
1668 subtasks.len()
1669 ));
1670 }
1671 Ok(rendered(json_mode, value, plain))
1672}
1673
1674fn cmd_subtask_add(
1675 store: &mut Store,
1676 query: &str,
1677 text: &str,
1678 done: bool,
1679 json_mode: bool,
1680) -> Result<Rendered, CliError> {
1681 let text = text.trim().to_string();
1682 if text.is_empty() {
1683 return Err(CliError::validation(
1684 "subtask text required (positional or --text)",
1685 ));
1686 }
1687 let query = query.to_string();
1688 let (task, index) = store.update(|data| {
1689 let id = data.resolve_task_id(&query)?;
1690 let mut body = data.task(&id)?.body.clone();
1691 body.push(Block::todo(&text, done));
1692 let task = data.edit_task(
1693 &id,
1694 TaskPatch {
1695 body: Some(body),
1696 ..TaskPatch::default()
1697 },
1698 )?;
1699 Ok((task, collect_subtasks(&data.task(&id)?.body).len()))
1700 })?;
1701 Ok(rendered(
1702 json_mode,
1703 json!({
1704 "task_id": task.id,
1705 "index": index,
1706 "text": text,
1707 "done": done,
1708 "subtasks": subtasks_json(&task.body),
1709 }),
1710 format!(
1711 "added subtask {index} on {} {}\n",
1712 terminal_text(&short_id(&task.id)),
1713 terminal_text(&text)
1714 ),
1715 ))
1716}
1717
1718fn cmd_subtask_set_done(
1719 store: &mut Store,
1720 query: &str,
1721 index: usize,
1722 done: Option<bool>,
1723 json_mode: bool,
1724) -> Result<Rendered, CliError> {
1725 let query = query.to_string();
1726 let (task, text, new_done) = store.update(|data| {
1727 let id = data.resolve_task_id(&query)?;
1728 let mut body = data.task(&id)?.body.clone();
1729 let body_index = subtask_body_index(&body, index)
1730 .map_err(|error| StoreError::validation(error.message))?;
1731 let Block::Todo { text, done: value } = &mut body[body_index] else {
1732 return Err(StoreError::Corrupt(
1733 "resolved subtask index does not point to a subtask".into(),
1734 ));
1735 };
1736 let new_done = done.unwrap_or(!*value);
1737 *value = new_done;
1738 let text = text.clone();
1739 let task = data.edit_task(
1740 &id,
1741 TaskPatch {
1742 body: Some(body),
1743 ..TaskPatch::default()
1744 },
1745 )?;
1746 Ok((task, text, new_done))
1747 })?;
1748 Ok(rendered(
1749 json_mode,
1750 json!({
1751 "task_id": task.id,
1752 "index": index,
1753 "text": text,
1754 "done": new_done,
1755 "subtasks": subtasks_json(&task.body),
1756 }),
1757 format!(
1758 "{} subtask {index} on {} {}\n",
1759 if new_done { "done" } else { "undone" },
1760 terminal_text(&short_id(&task.id)),
1761 terminal_text(&text)
1762 ),
1763 ))
1764}
1765
1766fn cmd_subtask_edit(
1767 store: &mut Store,
1768 query: &str,
1769 index: usize,
1770 text: &str,
1771 json_mode: bool,
1772) -> Result<Rendered, CliError> {
1773 let text = text.trim().to_string();
1774 if text.is_empty() {
1775 return Err(CliError::validation(
1776 "subtask text required (positional or --text)",
1777 ));
1778 }
1779 let query = query.to_string();
1780 let (task, done) = store.update(|data| {
1781 let id = data.resolve_task_id(&query)?;
1782 let mut body = data.task(&id)?.body.clone();
1783 let body_index = subtask_body_index(&body, index)
1784 .map_err(|error| StoreError::validation(error.message))?;
1785 let Block::Todo {
1786 text: current,
1787 done,
1788 } = &mut body[body_index]
1789 else {
1790 return Err(StoreError::Corrupt(
1791 "resolved subtask index does not point to a subtask".into(),
1792 ));
1793 };
1794 *current = text.clone();
1795 let done = *done;
1796 let task = data.edit_task(
1797 &id,
1798 TaskPatch {
1799 body: Some(body),
1800 ..TaskPatch::default()
1801 },
1802 )?;
1803 Ok((task, done))
1804 })?;
1805 Ok(rendered(
1806 json_mode,
1807 json!({
1808 "task_id": task.id,
1809 "index": index,
1810 "text": text,
1811 "done": done,
1812 "subtasks": subtasks_json(&task.body),
1813 }),
1814 format!(
1815 "updated subtask {index} on {} {}\n",
1816 terminal_text(&short_id(&task.id)),
1817 terminal_text(&text)
1818 ),
1819 ))
1820}
1821
1822fn cmd_subtask_delete(
1823 store: &mut Store,
1824 query: &str,
1825 index: usize,
1826 json_mode: bool,
1827) -> Result<Rendered, CliError> {
1828 let query = query.to_string();
1829 let (task, text, done) = store.update(|data| {
1830 let id = data.resolve_task_id(&query)?;
1831 let mut body = data.task(&id)?.body.clone();
1832 let body_index = subtask_body_index(&body, index)
1833 .map_err(|error| StoreError::validation(error.message))?;
1834 let Block::Todo { text, done } = body.remove(body_index) else {
1835 return Err(StoreError::Corrupt(
1836 "resolved subtask index does not point to a subtask".into(),
1837 ));
1838 };
1839 let task = data.edit_task(
1840 &id,
1841 TaskPatch {
1842 body: Some(body),
1843 ..TaskPatch::default()
1844 },
1845 )?;
1846 Ok((task, text, done))
1847 })?;
1848 Ok(rendered(
1849 json_mode,
1850 json!({
1851 "task_id": task.id,
1852 "deleted": { "index": index, "text": text, "done": done },
1853 "subtasks": subtasks_json(&task.body),
1854 }),
1855 format!(
1856 "deleted subtask {index} on {} {}\n",
1857 terminal_text(&short_id(&task.id)),
1858 terminal_text(&text)
1859 ),
1860 ))
1861}