1use std::ffi::OsString;
8use std::io::{self, Write};
9use std::path::PathBuf;
10
11use chrono::NaiveTime;
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 update check GitHub for a newer release
89 --install verify and install release binary to ~/.local/bin
90
91 (no command) open TUI
92 --json exactly one JSON document on stdout
93 --dir PATH data directory (global)
94
95Data: --dir PATH > $MACH_DIR > ~/.mach
96";
97
98#[derive(Parser)]
99#[command(
100 name = "mach",
101 about = concat!("mach v", env!("CARGO_PKG_VERSION")),
102 disable_version_flag = true,
103 color = clap::ColorChoice::Never,
104 after_help = HELP,
105)]
106struct Cli {
107 #[arg(short = 'v', long = "version")]
109 version: bool,
110
111 #[arg(long = "dir", value_name = "PATH", global = true)]
113 dir: Option<PathBuf>,
114
115 #[arg(long, global = true)]
117 json: bool,
118
119 #[command(subcommand)]
120 command: Option<Command>,
121}
122
123#[derive(Subcommand)]
124enum Command {
125 List {
127 #[arg(short = 'c', long = "category", value_name = "NAME")]
129 category: Option<String>,
130 #[arg(long, conflicts_with = "done")]
132 open: bool,
133 #[arg(long)]
135 done: bool,
136 },
137 Categories {
139 #[command(subcommand)]
140 action: Option<CatAction>,
141 },
142 Add(AddArgs),
144 Show {
146 id: String,
148 },
149 Done {
151 id: String,
153 },
154 Undone {
156 id: String,
158 },
159 Delete {
161 id: String,
163 },
164 Move {
166 id: String,
168 #[arg(
170 long,
171 value_name = "TARGET",
172 conflicts_with = "after",
173 required_unless_present = "after"
174 )]
175 before: Option<String>,
176 #[arg(
178 long,
179 value_name = "TARGET",
180 conflicts_with = "before",
181 required_unless_present = "before"
182 )]
183 after: Option<String>,
184 },
185 Purge {
187 #[arg(long, required = true)]
189 done: bool,
190 #[arg(short = 'c', long = "category", value_name = "NAME")]
192 category: Option<String>,
193 },
194 Edit(EditArgs),
196 Subtasks {
198 task: String,
200 #[command(subcommand)]
201 action: Option<SubAction>,
202 },
203 Update {
205 #[arg(long)]
207 install: bool,
208 },
209}
210
211#[derive(clap::Args)]
212struct AddArgs {
213 #[arg(value_name = "TITLE", conflicts_with = "title")]
215 title_pos: Option<String>,
216 #[arg(short = 't', long = "title")]
218 title: Option<String>,
219 #[arg(short = 'b', long = "body")]
221 body: Option<String>,
222 #[arg(short = 'd', long = "due", value_name = "DATE")]
224 due: Option<String>,
225 #[arg(long = "time", value_name = "HH:MM")]
227 time: Option<String>,
228 #[arg(short = 'c', long = "category", value_name = "NAME")]
230 category: Option<String>,
231 #[arg(
233 short = 'i',
234 long = "importance",
235 value_name = "N",
236 default_value_t = 0
237 )]
238 importance: u8,
239 #[arg(long = "subtask", value_name = "TEXT")]
241 subtasks: Vec<String>,
242}
243
244#[derive(clap::Args)]
245struct EditArgs {
246 id: String,
248 #[arg(short = 't', long = "title")]
250 title: Option<String>,
251 #[arg(short = 'b', long = "body")]
253 body: Option<String>,
254 #[arg(short = 'd', long = "due", value_name = "DATE")]
256 due: Option<String>,
257 #[arg(long = "time", value_name = "HH:MM")]
259 time: Option<String>,
260 #[arg(long)]
262 clear_due: bool,
263 #[arg(short = 'c', long = "category", value_name = "NAME")]
265 category: Option<String>,
266 #[arg(long = "clear-category")]
268 clear_cat: bool,
269 #[arg(short = 'i', long = "importance", value_name = "N")]
271 importance: Option<u8>,
272}
273
274#[derive(Subcommand)]
275enum CatAction {
276 List,
278 Add {
280 name: String,
282 #[arg(short = 'd', long = "description")]
284 description: Option<String>,
285 },
286 Edit {
288 name: String,
290 #[arg(short = 'n', long = "name", value_name = "NEW")]
292 new_name: Option<String>,
293 #[arg(short = 'd', long = "description")]
295 description: Option<String>,
296 #[arg(long = "clear-description")]
298 clear_description: bool,
299 },
300 Delete {
302 name: String,
304 },
305}
306
307#[derive(Subcommand)]
308enum SubAction {
309 List,
311 Add {
313 #[arg(value_name = "TEXT", conflicts_with = "text")]
315 text_pos: Option<String>,
316 #[arg(short = 't', long = "text")]
318 text: Option<String>,
319 #[arg(long)]
321 done: bool,
322 },
323 Done {
325 index: usize,
327 },
328 Undone {
330 index: usize,
332 },
333 Toggle {
335 index: usize,
337 },
338 Edit {
340 index: usize,
342 #[arg(value_name = "TEXT", conflicts_with = "text")]
344 text_pos: Option<String>,
345 #[arg(short = 't', long = "text")]
347 text: Option<String>,
348 },
349 Delete {
351 index: usize,
353 },
354}
355
356#[derive(Debug)]
357struct CliError {
358 kind: &'static str,
359 message: String,
360}
361
362impl CliError {
363 fn validation(message: impl Into<String>) -> Self {
364 Self {
365 kind: "validation",
366 message: message.into(),
367 }
368 }
369
370 fn update(message: impl Into<String>) -> Self {
371 Self {
372 kind: "update",
373 message: message.into(),
374 }
375 }
376}
377
378impl From<StoreError> for CliError {
379 fn from(error: StoreError) -> Self {
380 let kind = match &error {
381 StoreError::Io { .. } => "io",
382 StoreError::Json { .. } => "legacy_json",
383 StoreError::Database(_) => "database",
384 StoreError::UnsupportedLegacySchema { .. }
385 | StoreError::UnsupportedDatabaseSchema { .. } => "schema",
386 StoreError::Conflict { .. } | StoreError::StaleEntity { .. } => "conflict",
387 StoreError::NotFound { .. } => "not_found",
388 StoreError::Ambiguous { .. } => "ambiguous",
389 StoreError::Validation(_) => "validation",
390 StoreError::Corrupt(_) => "corrupt",
391 };
392 Self {
393 kind,
394 message: error.to_string(),
395 }
396 }
397}
398
399enum Rendered {
400 Json(Value),
401 Plain(String),
402}
403
404impl Rendered {
405 fn emit(self) -> io::Result<()> {
406 let stdout = io::stdout();
407 let mut output = stdout.lock();
408 match self {
409 Self::Json(value) => {
410 serde_json::to_writer_pretty(&mut output, &value).map_err(|error| {
411 if let Some(kind) = error.io_error_kind() {
412 io::Error::new(kind, error)
413 } else {
414 io::Error::other(error)
415 }
416 })?;
417 output.write_all(b"\n")
418 }
419 Self::Plain(text) => output.write_all(text.as_bytes()),
420 }
421 }
422}
423
424fn rendered(json_mode: bool, value: Value, plain: String) -> Rendered {
425 if json_mode {
426 Rendered::Json(value)
427 } else {
428 Rendered::Plain(plain)
429 }
430}
431
432pub fn run() {
433 let arguments: Vec<OsString> = std::env::args_os().collect();
434 let json_requested = requested_json(&arguments);
435 let cli = match Cli::try_parse_from(&arguments) {
436 Ok(cli) => cli,
437 Err(error) => emit_parse_error(error, json_requested),
438 };
439 let Cli {
440 version,
441 dir,
442 json,
443 command,
444 } = cli;
445
446 if version {
447 let output = if json {
448 Rendered::Json(json!({ "ok": true, "version": VERSION }))
449 } else {
450 Rendered::Plain(format!("mach v{VERSION}\n"))
451 };
452 emit_success(output);
453 return;
454 }
455
456 let result = match command {
457 Some(Command::Update { install }) => cmd_update(install, json),
458 None if json => Err(CliError::validation(
459 "--json requires a command or --version",
460 )),
461 None => crate::require_interactive_terminal()
462 .map_err(terminal_error)
463 .and_then(|()| Store::open_default(dir).map_err(CliError::from))
464 .and_then(|store| {
465 crate::run_tui(store).map_err(terminal_error)?;
466 Ok(Rendered::Plain(String::new()))
467 }),
468 Some(command) => Store::open_default(dir)
469 .map_err(CliError::from)
470 .and_then(|mut store| dispatch(&mut store, command, json)),
471 };
472
473 match result {
474 Ok(output) => emit_success(output),
475 Err(error) => emit_runtime_error(error, json),
476 }
477}
478
479fn terminal_error(error: io::Error) -> CliError {
480 CliError {
481 kind: "terminal",
482 message: error.to_string(),
483 }
484}
485
486fn requested_json(arguments: &[OsString]) -> bool {
487 arguments
488 .iter()
489 .skip(1)
490 .take_while(|argument| argument.as_os_str() != "--")
491 .any(|argument| argument.as_os_str() == "--json")
492}
493
494fn emit_parse_error(error: clap::Error, json_mode: bool) -> ! {
495 let help = matches!(
496 error.kind(),
497 clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion
498 );
499 let exit_code = if help { 0 } else { error.exit_code() };
500 if !json_mode {
501 let write = if help {
502 Rendered::Plain(error.to_string()).emit()
503 } else {
504 write_stderr(&format!("{}\n", terminal_text(&error.to_string())))
505 };
506 exit_after_write(write, exit_code);
507 }
508 let value = if help {
509 json!({ "ok": true, "kind": "help", "help": error.to_string() })
510 } else {
511 json!({ "ok": false, "kind": "usage", "error": error.to_string() })
512 };
513 exit_after_write(Rendered::Json(value).emit(), exit_code);
514}
515
516fn emit_runtime_error(error: CliError, json_mode: bool) -> ! {
517 let write = if json_mode {
518 Rendered::Json(json!({
519 "ok": false,
520 "kind": error.kind,
521 "error": error.message,
522 }))
523 .emit()
524 } else {
525 write_stderr(&format!("mach: {}\n", terminal_text(&error.message)))
526 };
527 exit_after_write(write, 1);
528}
529
530fn emit_success(output: Rendered) {
531 if let Err(error) = output.emit() {
532 if error.kind() == io::ErrorKind::BrokenPipe {
533 return;
534 }
535 let _ = write_stderr(&format!("mach: could not write output: {error}\n"));
536 std::process::exit(1);
537 }
538}
539
540fn write_stderr(text: &str) -> io::Result<()> {
541 let stderr = io::stderr();
542 stderr.lock().write_all(text.as_bytes())
543}
544
545fn exit_after_write(result: io::Result<()>, intended_code: i32) -> ! {
546 match result {
547 Ok(()) => std::process::exit(intended_code),
548 Err(error) if error.kind() == io::ErrorKind::BrokenPipe => std::process::exit(0),
549 Err(_) => std::process::exit(1),
550 }
551}
552
553fn dispatch(store: &mut Store, command: Command, json_mode: bool) -> Result<Rendered, CliError> {
554 match command {
555 Command::List {
556 category,
557 open,
558 done,
559 } => cmd_list(store, category.as_deref(), open, done, json_mode),
560 Command::Categories { action } => match action {
561 None | Some(CatAction::List) => cmd_categories_list(store, json_mode),
562 Some(CatAction::Add { name, description }) => {
563 cmd_category_add(store, &name, description.as_deref(), json_mode)
564 }
565 Some(CatAction::Edit {
566 name,
567 new_name,
568 description,
569 clear_description,
570 }) => cmd_category_edit(
571 store,
572 &name,
573 new_name.as_deref(),
574 description.as_deref(),
575 clear_description,
576 json_mode,
577 ),
578 Some(CatAction::Delete { name }) => cmd_category_delete(store, &name, json_mode),
579 },
580 Command::Add(arguments) => cmd_add(store, &arguments, json_mode),
581 Command::Show { id } => cmd_show(store, &id, json_mode),
582 Command::Done { id } => cmd_set_done(store, &id, true, json_mode),
583 Command::Undone { id } => cmd_set_done(store, &id, false, json_mode),
584 Command::Delete { id } => cmd_delete(store, &id, json_mode),
585 Command::Move { id, before, after } => {
586 cmd_move(store, &id, before.as_deref(), after.as_deref(), json_mode)
587 }
588 Command::Purge { done, category } => cmd_purge(store, done, category.as_deref(), json_mode),
589 Command::Edit(arguments) => cmd_edit(store, &arguments, json_mode),
590 Command::Subtasks { task, action } => match action {
591 None | Some(SubAction::List) => cmd_subtasks_list(store, &task, json_mode),
592 Some(SubAction::Add {
593 text_pos,
594 text,
595 done,
596 }) => cmd_subtask_add(
597 store,
598 &task,
599 &text.or(text_pos).unwrap_or_default(),
600 done,
601 json_mode,
602 ),
603 Some(SubAction::Done { index }) => {
604 cmd_subtask_set_done(store, &task, index, Some(true), json_mode)
605 }
606 Some(SubAction::Undone { index }) => {
607 cmd_subtask_set_done(store, &task, index, Some(false), json_mode)
608 }
609 Some(SubAction::Toggle { index }) => {
610 cmd_subtask_set_done(store, &task, index, None, json_mode)
611 }
612 Some(SubAction::Edit {
613 index,
614 text_pos,
615 text,
616 }) => cmd_subtask_edit(
617 store,
618 &task,
619 index,
620 &text.or(text_pos).unwrap_or_default(),
621 json_mode,
622 ),
623 Some(SubAction::Delete { index }) => cmd_subtask_delete(store, &task, index, json_mode),
624 },
625 Command::Update { .. } => Err(CliError {
626 kind: "internal",
627 message: "update command crossed the data-command boundary".into(),
628 }),
629 }
630}
631
632fn cmd_update(do_install: bool, json_mode: bool) -> Result<Rendered, CliError> {
633 let info = crate::update::check().map_err(CliError::update)?;
634 let install = if do_install && info.newer {
635 Some(crate::update::install(&info).map_err(CliError::update)?)
636 } else {
637 None
638 };
639
640 let value = json!({
641 "ok": true,
642 "current": info.current,
643 "latest": info.latest,
644 "newer": info.newer,
645 "prerelease": info.prerelease,
646 "url": info.release_url,
647 "installed": install.is_some(),
648 "destination": install
649 .as_ref()
650 .map(|result| result.destination.display().to_string()),
651 "tag": install.as_ref().map(|result| result.tag.as_str()).unwrap_or(&info.tag),
652 });
653 let mut plain = format!("{}\n", terminal_text(&info.summary()));
654 if info.newer && install.is_none() {
655 plain.push('\n');
656 for line in info.install_hint().lines() {
657 plain.push_str(&terminal_text(line));
658 plain.push('\n');
659 }
660 }
661 if do_install && install.is_none() {
662 plain.push_str("Already up to date.\n");
663 } else if let Some(result) = install {
664 plain.push_str(&format!(
665 "Installed {} to {}. Restart mach to use the new build.\n",
666 terminal_text(&result.tag),
667 terminal_text(&result.destination.display().to_string())
668 ));
669 }
670 Ok(rendered(json_mode, value, plain))
671}
672
673fn short_id(id: &str) -> String {
676 id.chars().take(8).collect()
677}
678
679fn terminal_text(text: &str) -> String {
680 let mut safe = String::with_capacity(text.len());
681 for character in text.chars() {
682 match character {
683 '\n' => safe.push_str("\\n"),
684 '\r' => safe.push_str("\\r"),
685 '\t' => safe.push_str("\\t"),
686 character if character.is_control() => {
687 safe.push_str(&format!("\\u{{{:x}}}", character as u32));
688 }
689 character => safe.push(character),
690 }
691 }
692 safe
693}
694
695fn body_from_text(text: &str) -> Vec<Block> {
696 if text.is_empty() {
697 return Vec::new();
698 }
699 text.lines().map(line_to_block).collect()
700}
701
702fn line_to_block(line: &str) -> Block {
703 let text = line.trim_end();
704 if let Some(rest) = text.strip_prefix("[ ] ") {
705 return Block::todo(rest, false);
706 }
707 if let Some(rest) = text
708 .strip_prefix("[x] ")
709 .or_else(|| text.strip_prefix("[X] "))
710 .or_else(|| text.strip_prefix("[✓] "))
711 {
712 return Block::todo(rest, true);
713 }
714 if let Some(rest) = text.strip_prefix("- ").or_else(|| text.strip_prefix("• ")) {
715 return Block::bullet(rest);
716 }
717 if let Some(rest) = strip_number_prefix(text) {
718 return Block::number(rest);
719 }
720 if let Some(path) = text
721 .strip_prefix("[image:")
722 .and_then(|value| value.strip_suffix(']'))
723 .filter(|path| !path.is_empty())
724 {
725 return Block::image(path);
726 }
727 if text.starts_with("http://") || text.starts_with("https://") {
728 return Block::link(text);
729 }
730 Block::text(text)
731}
732
733fn strip_number_prefix(line: &str) -> Option<&str> {
734 let bytes = line.as_bytes();
735 let mut index = 0;
736 while index < bytes.len() && bytes[index].is_ascii_digit() {
737 index += 1;
738 }
739 if index == 0 {
740 return None;
741 }
742 line.get(index..)?.strip_prefix(". ")
743}
744
745fn body_to_text(body: &[Block]) -> String {
746 let mut numbered = 0usize;
747 body.iter()
748 .map(|block| match block {
749 Block::Text { text } => {
750 numbered = 0;
751 text.clone()
752 }
753 Block::Todo { text, done } => {
754 numbered = 0;
755 format!("[{}] {text}", if *done { "x" } else { " " })
756 }
757 Block::Bullet { text } => {
758 numbered = 0;
759 format!("- {text}")
760 }
761 Block::Number { text } => {
762 numbered += 1;
763 format!("{numbered}. {text}")
764 }
765 Block::Link { url } => {
766 numbered = 0;
767 url.clone()
768 }
769 Block::Image { attachment_id } => {
770 numbered = 0;
771 format!("[image:{attachment_id}]")
772 }
773 })
774 .collect::<Vec<_>>()
775 .join("\n")
776}
777
778fn collect_subtasks(body: &[Block]) -> Vec<(usize, &str, bool)> {
779 body.iter()
780 .filter_map(|block| match block {
781 Block::Todo { text, done } => Some((text.as_str(), *done)),
782 _ => None,
783 })
784 .enumerate()
785 .map(|(index, (text, done))| (index + 1, text, done))
786 .collect()
787}
788
789fn subtask_body_index(body: &[Block], one_based: usize) -> Result<usize, CliError> {
790 if one_based == 0 {
791 return Err(CliError::validation(
792 "subtask index is 1-based (use 1 for the first subtask)",
793 ));
794 }
795 let mut count = 0usize;
796 for (body_index, block) in body.iter().enumerate() {
797 if matches!(block, Block::Todo { .. }) {
798 count += 1;
799 if count == one_based {
800 return Ok(body_index);
801 }
802 }
803 }
804 Err(CliError::validation(format!(
805 "no subtask at index {one_based} (task has {count} subtask(s))"
806 )))
807}
808
809fn subtasks_json(body: &[Block]) -> Vec<Value> {
810 subtasks_to_json(&collect_subtasks(body))
811}
812
813fn subtasks_to_json(subtasks: &[(usize, &str, bool)]) -> Vec<Value> {
814 subtasks
815 .iter()
816 .map(|(index, text, done)| json!({ "index": index, "text": text, "done": done }))
817 .collect()
818}
819
820fn category_name<'a>(categories: &'a [Category], task: &Task) -> Option<&'a str> {
821 task.category_id
822 .as_ref()
823 .and_then(|id| categories.iter().find(|category| category.id == *id))
824 .map(|category| category.name.as_str())
825}
826
827fn task_json(categories: &[Category], task: &Task) -> Value {
828 let subtasks = collect_subtasks(&task.body);
829 let subtasks_json = subtasks_to_json(&subtasks);
830 json!({
831 "id": task.id,
832 "title": task.title,
833 "body": body_to_text(&task.body),
834 "subtasks": subtasks_json,
835 "subtasks_done": subtasks.iter().filter(|(_, _, done)| *done).count(),
836 "subtasks_total": subtasks.len(),
837 "due": task.due,
838 "done": task.done,
839 "importance": task.importance,
840 "category": {
841 "id": task.category_id,
842 "name": category_name(categories, task),
843 },
844 "created": task.created,
845 })
846}
847
848fn category_json(category: &Category) -> Value {
849 json!({
850 "id": category.id,
851 "name": category.name,
852 "description": category.description,
853 })
854}
855
856fn validate_time(raw: &str) -> Result<String, CliError> {
857 let value = raw.trim();
858 if value.len() == 5 && NaiveTime::parse_from_str(value, "%H:%M").is_ok() {
859 Ok(value.to_string())
860 } else {
861 Err(CliError::validation(format!(
862 "invalid time {raw:?}; use HH:MM (24h), e.g. 14:30"
863 )))
864 }
865}
866
867fn due_for_add(due: Option<&str>, time: Option<&str>) -> Result<String, CliError> {
868 let due = due.map(str::trim).filter(|value| !value.is_empty());
869 match (due, time) {
870 (None, None) => Ok(String::new()),
871 (None, Some(time)) => validate_time(time),
872 (Some(due), None) => Ok(due.to_string()),
873 (Some(due), Some(time)) => {
874 if due.contains(':') {
875 return Err(CliError::validation(format!(
876 "due already includes a time ({due}); omit --time or pass date-only --due"
877 )));
878 }
879 Ok(format!("{due} {}", validate_time(time)?))
880 }
881 }
882}
883
884fn split_inline_title(raw: &str) -> Result<(String, String), CliError> {
885 let (inline_due, title) = crate::due::parse(raw.trim());
886 if !inline_due.is_empty() {
887 crate::due::normalize_for_write(&inline_due)
888 .map_err(|error| CliError::validation(error.to_string()))?;
889 }
890 Ok((title, inline_due))
891}
892
893fn due_for_edit(
896 current: &str,
897 due: Option<&str>,
898 time: Option<&str>,
899) -> Result<Option<String>, CliError> {
900 if due.is_none() && time.is_none() {
901 return Ok(None);
902 }
903 let existing_time = current.split_once(' ').map(|(_, time)| time.to_string());
904 let existing_date = current
905 .split_once(' ')
906 .map(|(date, _)| date.to_string())
907 .or_else(|| (!current.is_empty() && !current.contains(':')).then(|| current.to_string()));
908
909 if let Some(raw_due) = due {
910 let raw_due = raw_due.trim();
911 if raw_due.contains(':') {
912 if time.is_some() {
913 return Err(CliError::validation(
914 "pass either a full --due datetime or --due date + --time, not both",
915 ));
916 }
917 return crate::due::normalize_for_write(raw_due)
918 .map(Some)
919 .map_err(|error| CliError::validation(error.to_string()));
920 }
921 let chosen_time = match time {
922 Some(time) => Some(validate_time(time)?),
923 None => existing_time,
924 };
925 let combined = match chosen_time {
926 Some(time) => format!("{raw_due} {time}"),
927 None => raw_due.to_string(),
928 };
929 return crate::due::normalize_for_write(&combined)
930 .map(Some)
931 .map_err(|error| CliError::validation(error.to_string()));
932 }
933
934 let Some(time) = time else {
935 return Ok(None);
936 };
937 let time = validate_time(time)?;
938 let combined = match existing_date {
939 Some(date) => format!("{date} {time}"),
940 None => time,
941 };
942 crate::due::normalize_for_write(&combined)
943 .map(Some)
944 .map_err(|error| CliError::validation(error.to_string()))
945}
946
947fn task_line(
948 categories: &[Category],
949 task: &Task,
950 show_category: bool,
951 date_format: &str,
952) -> String {
953 let check = if task.done { "[✓]" } else { "[ ]" };
954 let title = terminal_text(&task.title);
955 let due = crate::due::display(&task.due, date_format);
956 let due = if due.is_empty() {
957 String::new()
958 } else {
959 format!(" {}", terminal_text(&due))
960 };
961 let flag = if task.importance > 0 {
962 format!(" {}", crate::model::importance_marks(task.importance))
963 } else {
964 String::new()
965 };
966 let category = if show_category {
967 format!(
968 " [{}]",
969 terminal_text(category_name(categories, task).unwrap_or("—"))
970 )
971 } else {
972 String::new()
973 };
974 let progress = crate::model::todo_progress(task)
975 .map(|(done, total)| format!(" ({done}/{total})"))
976 .unwrap_or_default();
977 format!(
978 "{} {check} {title}{category}{due}{flag}{progress}\n",
979 terminal_text(&short_id(&task.id))
980 )
981}
982
983fn cmd_list(
986 store: &Store,
987 category: Option<&str>,
988 open_only: bool,
989 done_only: bool,
990 json_mode: bool,
991) -> Result<Rendered, CliError> {
992 let data = store.snapshot()?;
993 let category_id = category
994 .map(|query| data.resolve_category_id(query))
995 .transpose()?;
996 let show_category = category_id.is_none();
997 let tasks: Vec<_> = data
998 .tasks
999 .iter()
1000 .filter(|task| {
1001 category_id
1002 .as_ref()
1003 .is_none_or(|id| task.category_id.as_deref() == Some(id.as_str()))
1004 })
1005 .filter(|task| {
1006 if open_only {
1007 !task.done
1008 } else if done_only {
1009 task.done
1010 } else {
1011 true
1012 }
1013 })
1014 .collect();
1015 let value = Value::Array(
1016 tasks
1017 .iter()
1018 .map(|task| task_json(&data.categories, task))
1019 .collect(),
1020 );
1021 let mut plain = String::new();
1022 if tasks.is_empty() {
1023 plain.push_str("(no tasks)\n");
1024 } else {
1025 for task in &tasks {
1026 plain.push_str(&task_line(
1027 &data.categories,
1028 task,
1029 show_category,
1030 &data.settings.date_format,
1031 ));
1032 }
1033 let done = tasks.iter().filter(|task| task.done).count();
1034 plain.push_str(&format!("— {} task(s), {done} done\n", tasks.len()));
1035 }
1036 Ok(rendered(json_mode, value, plain))
1037}
1038
1039fn cmd_categories_list(store: &Store, json_mode: bool) -> Result<Rendered, CliError> {
1040 let data = store.snapshot()?;
1041 let stats: Vec<_> = data
1042 .categories
1043 .iter()
1044 .map(|category| {
1045 let (done, total) = data
1046 .tasks
1047 .iter()
1048 .filter(|task| task.category_id.as_deref() == Some(category.id.as_str()))
1049 .fold((0, 0), |(done, total), task| {
1050 (done + usize::from(task.done), total + 1)
1051 });
1052 (category, done, total)
1053 })
1054 .collect();
1055 let categories: Vec<_> = stats
1056 .iter()
1057 .map(|(category, done, total)| {
1058 json!({
1059 "id": category.id,
1060 "name": category.name,
1061 "description": category.description,
1062 "total": total,
1063 "done": done,
1064 })
1065 })
1066 .collect();
1067 let (uncategorized_done, uncategorized_total) = data
1068 .tasks
1069 .iter()
1070 .filter(|task| task.category_id.is_none())
1071 .fold((0, 0), |(done, total), task| {
1072 (done + usize::from(task.done), total + 1)
1073 });
1074 let value = json!({
1075 "categories": categories,
1076 "uncategorized": {
1077 "total": uncategorized_total,
1078 "done": uncategorized_done,
1079 },
1080 });
1081 let mut plain = String::new();
1082 if data.categories.is_empty() {
1083 plain.push_str("(no categories)\n");
1084 } else {
1085 for (category, done, total) in &stats {
1086 plain.push_str(&format!(
1087 "{} {}/{}\n",
1088 terminal_text(&category.name),
1089 done,
1090 total
1091 ));
1092 }
1093 }
1094 if uncategorized_total > 0 {
1095 plain.push_str(&format!(
1096 "— uncategorized {}/{}\n",
1097 uncategorized_done, uncategorized_total
1098 ));
1099 }
1100 Ok(rendered(json_mode, value, plain))
1101}
1102
1103fn cmd_category_add(
1104 store: &mut Store,
1105 name: &str,
1106 description: Option<&str>,
1107 json_mode: bool,
1108) -> Result<Rendered, CliError> {
1109 let name = name.trim().to_string();
1110 let description = description.unwrap_or_default().to_string();
1111 let category = store.update(|data| data.create_category(name, description))?;
1112 Ok(rendered(
1113 json_mode,
1114 category_json(&category),
1115 format!("created category {}\n", terminal_text(&category.name)),
1116 ))
1117}
1118
1119fn cmd_category_edit(
1120 store: &mut Store,
1121 query: &str,
1122 new_name: Option<&str>,
1123 description: Option<&str>,
1124 clear_description: bool,
1125 json_mode: bool,
1126) -> Result<Rendered, CliError> {
1127 if new_name.is_none() && description.is_none() && !clear_description {
1128 return Err(CliError::validation(
1129 "nothing to edit; pass --name / --description / --clear-description",
1130 ));
1131 }
1132 if clear_description && description.is_some() {
1133 return Err(CliError::validation(
1134 "--clear-description cannot be combined with --description",
1135 ));
1136 }
1137 let patch = CategoryPatch {
1138 name: new_name.map(|name| name.trim().to_string()),
1139 description: if clear_description {
1140 Some(String::new())
1141 } else {
1142 description.map(str::to_string)
1143 },
1144 };
1145 let query = query.to_string();
1146 let category = store.update(|data| {
1147 let id = data.resolve_category_id(&query)?;
1148 data.edit_category(&id, patch)
1149 })?;
1150 Ok(rendered(
1151 json_mode,
1152 category_json(&category),
1153 format!("updated category {}\n", terminal_text(&category.name)),
1154 ))
1155}
1156
1157fn cmd_category_delete(
1158 store: &mut Store,
1159 query: &str,
1160 json_mode: bool,
1161) -> Result<Rendered, CliError> {
1162 let query = query.to_string();
1163 let category = store.update(|data| {
1164 let id = data.resolve_category_id(&query)?;
1165 data.delete_category(&id)
1166 })?;
1167 Ok(rendered(
1168 json_mode,
1169 json!({ "deleted": category.name, "id": category.id }),
1170 format!(
1171 "deleted category {} (tasks uncategorized)\n",
1172 terminal_text(&category.name)
1173 ),
1174 ))
1175}
1176
1177fn cmd_add(store: &mut Store, arguments: &AddArgs, json_mode: bool) -> Result<Rendered, CliError> {
1178 let raw_title = arguments
1179 .title
1180 .as_deref()
1181 .or(arguments.title_pos.as_deref())
1182 .unwrap_or_default()
1183 .trim();
1184 let (title, inline_due) = split_inline_title(raw_title)?;
1185 if title.is_empty() {
1186 return Err(CliError::validation(
1187 "title required (positional or --title)",
1188 ));
1189 }
1190 let mut body = arguments
1191 .body
1192 .as_deref()
1193 .map(body_from_text)
1194 .unwrap_or_default();
1195 for subtask in &arguments.subtasks {
1196 let text = subtask.trim();
1197 if text.is_empty() {
1198 return Err(CliError::validation("--subtask text cannot be empty"));
1199 }
1200 body.push(Block::todo(text, false));
1201 }
1202 let due = if arguments.due.is_none() && arguments.time.is_none() {
1203 inline_due
1204 } else {
1205 due_for_add(arguments.due.as_deref(), arguments.time.as_deref())?
1206 };
1207 let category_query = arguments.category.clone();
1208 let importance = arguments.importance;
1209 let (task_id, snapshot) = store.update_with_snapshot(|data| {
1210 let category_id = category_query
1211 .as_deref()
1212 .map(|query| data.resolve_category_id(query))
1213 .transpose()?;
1214 let task = data.create_task(title, body, due, importance, category_id)?;
1215 Ok(task.id)
1216 })?;
1217 let task = snapshot.task(&task_id)?.clone();
1218 let categories = snapshot.categories;
1219 let subtasks = collect_subtasks(&task.body).len();
1220 let plain = if subtasks == 0 {
1221 format!(
1222 "added {} {}\n",
1223 terminal_text(&short_id(&task.id)),
1224 terminal_text(&task.title)
1225 )
1226 } else {
1227 format!(
1228 "added {} {} ({} subtask{})\n",
1229 terminal_text(&short_id(&task.id)),
1230 terminal_text(&task.title),
1231 subtasks,
1232 if subtasks == 1 { "" } else { "s" }
1233 )
1234 };
1235 Ok(rendered(json_mode, task_json(&categories, &task), plain))
1236}
1237
1238fn cmd_show(store: &Store, query: &str, json_mode: bool) -> Result<Rendered, CliError> {
1239 let data = store.snapshot()?;
1240 let id = data.resolve_task_id(query)?;
1241 let task = data.task(&id)?;
1242 let mut plain = format!(
1243 "id: {}\ntitle: {}\ndone: {}\ncategory: {}\ndue: {}\nimportance: {} ({})\ncreated: {}\n",
1244 terminal_text(&task.id),
1245 terminal_text(&task.title),
1246 task.done,
1247 terminal_text(category_name(&data.categories, task).unwrap_or("—")),
1248 if task.due.is_empty() {
1249 "—".into()
1250 } else {
1251 terminal_text(&task.due)
1252 },
1253 task.importance,
1254 crate::model::importance_marks(task.importance),
1255 terminal_text(&task.created),
1256 );
1257 let subtasks = collect_subtasks(&task.body);
1258 if subtasks.is_empty() {
1259 plain.push_str("subtasks: —\n");
1260 } else {
1261 plain.push_str(&format!(
1262 "subtasks: {}/{}\n",
1263 subtasks.iter().filter(|(_, _, done)| *done).count(),
1264 subtasks.len()
1265 ));
1266 for (index, text, done) in &subtasks {
1267 plain.push_str(&format!(
1268 " {index}. {} {}\n",
1269 if *done { "[✓]" } else { "[ ]" },
1270 terminal_text(text)
1271 ));
1272 }
1273 }
1274 let notes = body_note_lines(&task.body);
1275 if notes.is_empty() {
1276 plain.push_str("body: —\n");
1277 } else {
1278 plain.push_str("body:\n");
1279 for note in notes {
1280 plain.push_str(&format!(" {}\n", terminal_text(¬e)));
1281 }
1282 }
1283 Ok(rendered(
1284 json_mode,
1285 task_json(&data.categories, task),
1286 plain,
1287 ))
1288}
1289
1290fn body_note_lines(body: &[Block]) -> Vec<String> {
1291 let mut numbered = 0usize;
1292 let mut notes = Vec::new();
1293 for block in body {
1294 match block {
1295 Block::Todo { .. } => numbered = 0,
1296 Block::Text { text } => {
1297 numbered = 0;
1298 if !text.trim().is_empty() {
1299 notes.push(text.clone());
1300 }
1301 }
1302 Block::Bullet { text } => {
1303 numbered = 0;
1304 notes.push(format!("- {text}"));
1305 }
1306 Block::Number { text } => {
1307 numbered += 1;
1308 notes.push(format!("{numbered}. {text}"));
1309 }
1310 Block::Link { url } => {
1311 numbered = 0;
1312 notes.push(url.clone());
1313 }
1314 Block::Image { attachment_id } => {
1315 numbered = 0;
1316 notes.push(format!("[image:{attachment_id}]"));
1317 }
1318 }
1319 }
1320 notes
1321}
1322
1323fn cmd_set_done(
1324 store: &mut Store,
1325 query: &str,
1326 done: bool,
1327 json_mode: bool,
1328) -> Result<Rendered, CliError> {
1329 let query = query.to_string();
1330 let (task, snapshot) = store.update_with_snapshot(|data| {
1331 let id = data.resolve_task_id(&query)?;
1332 data.set_task_done(&id, done)
1333 })?;
1334 let categories = snapshot.categories;
1335 Ok(rendered(
1336 json_mode,
1337 task_json(&categories, &task),
1338 format!(
1339 "{} {} {}\n",
1340 if done { "done" } else { "undone" },
1341 terminal_text(&short_id(&task.id)),
1342 terminal_text(&task.title)
1343 ),
1344 ))
1345}
1346
1347fn cmd_delete(store: &mut Store, query: &str, json_mode: bool) -> Result<Rendered, CliError> {
1348 let query = query.to_string();
1349 let (task, snapshot) = store.update_with_snapshot(|data| {
1350 let id = data.resolve_task_id(&query)?;
1351 data.delete_task(&id)
1352 })?;
1353 let categories = snapshot.categories;
1354 Ok(rendered(
1355 json_mode,
1356 task_json(&categories, &task),
1357 format!(
1358 "deleted {} {}\n",
1359 terminal_text(&short_id(&task.id)),
1360 terminal_text(&task.title)
1361 ),
1362 ))
1363}
1364
1365fn cmd_move(
1366 store: &mut Store,
1367 query: &str,
1368 before: Option<&str>,
1369 after: Option<&str>,
1370 json_mode: bool,
1371) -> Result<Rendered, CliError> {
1372 let (relation, position, target_query) = match (before, after) {
1373 (Some(target), None) => ("before", RelativePosition::Before, target),
1374 (None, Some(target)) => ("after", RelativePosition::After, target),
1375 _ => {
1376 return Err(CliError::validation(
1377 "pass exactly one of --before or --after",
1378 ));
1379 }
1380 };
1381 let query = query.to_string();
1382 let target_query = target_query.to_string();
1383 let ((task, target), snapshot) = store.update_with_snapshot(|data| {
1384 let id = data.resolve_task_id(&query)?;
1385 let target_id = data.resolve_task_id(&target_query)?;
1386 let target = data.task(&target_id)?.clone();
1387 let task = data.move_task_relative(&id, &target_id, position)?;
1388 Ok((task, target))
1389 })?;
1390 let categories = snapshot.categories;
1391 let value = json!({
1392 "moved": task_json(&categories, &task),
1393 "relation": relation,
1394 "target": { "id": target.id, "title": target.title },
1395 });
1396 let plain = format!(
1397 "moved {} {relation} {}\n",
1398 terminal_text(&short_id(&task.id)),
1399 terminal_text(&short_id(&target.id))
1400 );
1401 Ok(rendered(json_mode, value, plain))
1402}
1403
1404fn cmd_purge(
1405 store: &mut Store,
1406 confirmed_done: bool,
1407 category: Option<&str>,
1408 json_mode: bool,
1409) -> Result<Rendered, CliError> {
1410 if !confirmed_done {
1411 return Err(CliError::validation(
1412 "refusing to purge without the explicit --done flag",
1413 ));
1414 }
1415 let category = category.map(str::to_string);
1416 let (removed, snapshot) = store.update_with_snapshot(|data| {
1417 let scope = match category.as_deref() {
1418 Some(query) => PurgeScope::Category(data.resolve_category_id(query)?),
1419 None => PurgeScope::All,
1420 };
1421 data.purge_completed(&scope)
1422 })?;
1423 let categories = snapshot.categories;
1424 let value = json!({
1425 "purged": removed
1426 .iter()
1427 .map(|task| task_json(&categories, task))
1428 .collect::<Vec<_>>(),
1429 "count": removed.len(),
1430 });
1431 let plain = format!("purged {} completed task(s)\n", removed.len());
1432 Ok(rendered(json_mode, value, plain))
1433}
1434
1435fn cmd_edit(
1436 store: &mut Store,
1437 arguments: &EditArgs,
1438 json_mode: bool,
1439) -> Result<Rendered, CliError> {
1440 if arguments.title.is_none()
1441 && arguments.body.is_none()
1442 && arguments.due.is_none()
1443 && arguments.time.is_none()
1444 && !arguments.clear_due
1445 && arguments.category.is_none()
1446 && !arguments.clear_cat
1447 && arguments.importance.is_none()
1448 {
1449 return Err(CliError::validation(
1450 "nothing to edit; pass --title / --body / --due / --time / --clear-due / --category / --clear-category / --importance",
1451 ));
1452 }
1453 if arguments.clear_due && (arguments.due.is_some() || arguments.time.is_some()) {
1454 return Err(CliError::validation(
1455 "--clear-due cannot be combined with --due / --time",
1456 ));
1457 }
1458 if arguments.clear_cat && arguments.category.is_some() {
1459 return Err(CliError::validation(
1460 "--clear-category cannot be combined with --category",
1461 ));
1462 }
1463 let query = arguments.id.clone();
1464 let (title, inline_due) = match arguments.title.as_deref() {
1465 Some(title) => {
1466 let (title, inline_due) = split_inline_title(title)?;
1467 (Some(title), inline_due)
1468 }
1469 None => (None, String::new()),
1470 };
1471 let body = arguments.body.as_deref().map(body_from_text);
1472 let due_argument = arguments.due.clone();
1473 let time_argument = arguments.time.clone();
1474 let clear_due = arguments.clear_due;
1475 let category_query = arguments.category.clone();
1476 let clear_category = arguments.clear_cat;
1477 let importance = arguments.importance;
1478 let (task_id, snapshot) = store.update_with_snapshot(|data| {
1479 let id = data.resolve_task_id(&query)?;
1480 let due = if clear_due {
1481 Some(String::new())
1482 } else if due_argument.is_none() && time_argument.is_none() && !inline_due.is_empty() {
1483 Some(inline_due)
1484 } else {
1485 due_for_edit(
1486 &data.task(&id)?.due,
1487 due_argument.as_deref(),
1488 time_argument.as_deref(),
1489 )
1490 .map_err(|error| StoreError::validation(error.message))?
1491 };
1492 let category_id = if clear_category {
1493 Some(None)
1494 } else {
1495 category_query
1496 .as_deref()
1497 .map(|query| data.resolve_category_id(query).map(Some))
1498 .transpose()?
1499 };
1500 let task = data.edit_task(
1501 &id,
1502 TaskPatch {
1503 title,
1504 body,
1505 due,
1506 importance,
1507 category_id,
1508 ..TaskPatch::default()
1509 },
1510 )?;
1511 Ok(task.id)
1512 })?;
1513 let task = snapshot.task(&task_id)?.clone();
1514 let categories = snapshot.categories;
1515 Ok(rendered(
1516 json_mode,
1517 task_json(&categories, &task),
1518 format!(
1519 "updated {} {}\n",
1520 terminal_text(&short_id(&task.id)),
1521 terminal_text(&task.title)
1522 ),
1523 ))
1524}
1525
1526fn cmd_subtasks_list(store: &Store, query: &str, json_mode: bool) -> Result<Rendered, CliError> {
1529 let data = store.snapshot()?;
1530 let id = data.resolve_task_id(query)?;
1531 let task = data.task(&id)?;
1532 let subtasks = collect_subtasks(&task.body);
1533 let value = json!({
1534 "task_id": task.id,
1535 "title": task.title,
1536 "subtasks": subtasks_json(&task.body),
1537 "done": subtasks.iter().filter(|(_, _, done)| *done).count(),
1538 "total": subtasks.len(),
1539 });
1540 let mut plain = format!(
1541 "{} {}",
1542 terminal_text(&short_id(&task.id)),
1543 terminal_text(&task.title)
1544 );
1545 if subtasks.is_empty() {
1546 plain.push_str(" (no subtasks)\n");
1547 } else {
1548 plain.push('\n');
1549 for (index, text, done) in &subtasks {
1550 let check = if *done { "[✓]" } else { "[ ]" };
1551 plain.push_str(&format!(" {index}. {check} {}\n", terminal_text(text)));
1552 }
1553 plain.push_str(&format!(
1554 "— {}/{} done\n",
1555 subtasks.iter().filter(|(_, _, done)| *done).count(),
1556 subtasks.len()
1557 ));
1558 }
1559 Ok(rendered(json_mode, value, plain))
1560}
1561
1562fn cmd_subtask_add(
1563 store: &mut Store,
1564 query: &str,
1565 text: &str,
1566 done: bool,
1567 json_mode: bool,
1568) -> Result<Rendered, CliError> {
1569 let text = text.trim().to_string();
1570 if text.is_empty() {
1571 return Err(CliError::validation(
1572 "subtask text required (positional or --text)",
1573 ));
1574 }
1575 let query = query.to_string();
1576 let (task, index) = store.update(|data| {
1577 let id = data.resolve_task_id(&query)?;
1578 let mut body = data.task(&id)?.body.clone();
1579 body.push(Block::todo(&text, done));
1580 let task = data.edit_task(
1581 &id,
1582 TaskPatch {
1583 body: Some(body),
1584 ..TaskPatch::default()
1585 },
1586 )?;
1587 Ok((task, collect_subtasks(&data.task(&id)?.body).len()))
1588 })?;
1589 Ok(rendered(
1590 json_mode,
1591 json!({
1592 "task_id": task.id,
1593 "index": index,
1594 "text": text,
1595 "done": done,
1596 "subtasks": subtasks_json(&task.body),
1597 }),
1598 format!(
1599 "added subtask {index} on {} {}\n",
1600 terminal_text(&short_id(&task.id)),
1601 terminal_text(&text)
1602 ),
1603 ))
1604}
1605
1606fn cmd_subtask_set_done(
1607 store: &mut Store,
1608 query: &str,
1609 index: usize,
1610 done: Option<bool>,
1611 json_mode: bool,
1612) -> Result<Rendered, CliError> {
1613 let query = query.to_string();
1614 let (task, text, new_done) = store.update(|data| {
1615 let id = data.resolve_task_id(&query)?;
1616 let mut body = data.task(&id)?.body.clone();
1617 let body_index = subtask_body_index(&body, index)
1618 .map_err(|error| StoreError::validation(error.message))?;
1619 let Block::Todo { text, done: value } = &mut body[body_index] else {
1620 return Err(StoreError::Corrupt(
1621 "resolved subtask index does not point to a subtask".into(),
1622 ));
1623 };
1624 let new_done = done.unwrap_or(!*value);
1625 *value = new_done;
1626 let text = text.clone();
1627 let task = data.edit_task(
1628 &id,
1629 TaskPatch {
1630 body: Some(body),
1631 ..TaskPatch::default()
1632 },
1633 )?;
1634 Ok((task, text, new_done))
1635 })?;
1636 Ok(rendered(
1637 json_mode,
1638 json!({
1639 "task_id": task.id,
1640 "index": index,
1641 "text": text,
1642 "done": new_done,
1643 "subtasks": subtasks_json(&task.body),
1644 }),
1645 format!(
1646 "{} subtask {index} on {} {}\n",
1647 if new_done { "done" } else { "undone" },
1648 terminal_text(&short_id(&task.id)),
1649 terminal_text(&text)
1650 ),
1651 ))
1652}
1653
1654fn cmd_subtask_edit(
1655 store: &mut Store,
1656 query: &str,
1657 index: usize,
1658 text: &str,
1659 json_mode: bool,
1660) -> Result<Rendered, CliError> {
1661 let text = text.trim().to_string();
1662 if text.is_empty() {
1663 return Err(CliError::validation(
1664 "subtask text required (positional or --text)",
1665 ));
1666 }
1667 let query = query.to_string();
1668 let (task, done) = store.update(|data| {
1669 let id = data.resolve_task_id(&query)?;
1670 let mut body = data.task(&id)?.body.clone();
1671 let body_index = subtask_body_index(&body, index)
1672 .map_err(|error| StoreError::validation(error.message))?;
1673 let Block::Todo {
1674 text: current,
1675 done,
1676 } = &mut body[body_index]
1677 else {
1678 return Err(StoreError::Corrupt(
1679 "resolved subtask index does not point to a subtask".into(),
1680 ));
1681 };
1682 *current = text.clone();
1683 let done = *done;
1684 let task = data.edit_task(
1685 &id,
1686 TaskPatch {
1687 body: Some(body),
1688 ..TaskPatch::default()
1689 },
1690 )?;
1691 Ok((task, done))
1692 })?;
1693 Ok(rendered(
1694 json_mode,
1695 json!({
1696 "task_id": task.id,
1697 "index": index,
1698 "text": text,
1699 "done": done,
1700 "subtasks": subtasks_json(&task.body),
1701 }),
1702 format!(
1703 "updated subtask {index} on {} {}\n",
1704 terminal_text(&short_id(&task.id)),
1705 terminal_text(&text)
1706 ),
1707 ))
1708}
1709
1710fn cmd_subtask_delete(
1711 store: &mut Store,
1712 query: &str,
1713 index: usize,
1714 json_mode: bool,
1715) -> Result<Rendered, CliError> {
1716 let query = query.to_string();
1717 let (task, text, done) = store.update(|data| {
1718 let id = data.resolve_task_id(&query)?;
1719 let mut body = data.task(&id)?.body.clone();
1720 let body_index = subtask_body_index(&body, index)
1721 .map_err(|error| StoreError::validation(error.message))?;
1722 let Block::Todo { text, done } = body.remove(body_index) else {
1723 return Err(StoreError::Corrupt(
1724 "resolved subtask index does not point to a subtask".into(),
1725 ));
1726 };
1727 let task = data.edit_task(
1728 &id,
1729 TaskPatch {
1730 body: Some(body),
1731 ..TaskPatch::default()
1732 },
1733 )?;
1734 Ok((task, text, done))
1735 })?;
1736 Ok(rendered(
1737 json_mode,
1738 json!({
1739 "task_id": task.id,
1740 "deleted": { "index": index, "text": text, "done": done },
1741 "subtasks": subtasks_json(&task.body),
1742 }),
1743 format!(
1744 "deleted subtask {index} on {} {}\n",
1745 terminal_text(&short_id(&task.id)),
1746 terminal_text(&text)
1747 ),
1748 ))
1749}