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