1use std::path::PathBuf;
7
8use clap::{Parser, Subcommand};
9
10use crate::model::{
11 Block, Category, MAX_BODY_LINES, MAX_CATEGORY_COUNT, MAX_CATEGORY_NAME_LEN, MAX_IMPORTANCE,
12 MAX_TASK_COUNT, Task,
13};
14use crate::{VERSION, banner, store};
15
16const HELP: &str = "\
18 list
19 -c, --category NAME only this category
20 --open only incomplete
21 --done only completed
22
23 categories
24 (no args) list categories (done/total)
25 add NAME
26 -d, --description TEXT
27 edit NAME rename / set description
28 -n, --name NEW
29 -d, --description TEXT
30 --clear-description
31 delete NAME delete category; tasks become uncategorized
32
33 add [TITLE]
34 -t, --title TITLE title (required if no positional TITLE)
35 -b, --body TEXT body (newlines = lines; see BODY MARKUP)
36 -d, --due DATE YYYY-MM-DD | MM-DD | HH:MM | DATEThh:mm
37 --time HH:MM with --due, or alone = today
38 -c, --category NAME category (name or unique prefix)
39 -i, --importance N 0–3 (default 0)
40 --subtask TEXT add subtask (repeatable)
41
42 show ID ID = uuid or unique prefix
43
44 done ID
45 undone ID
46
47 delete ID
48
49 edit ID only given flags change
50 -t, --title TITLE
51 -b, --body TEXT replace entire body (BODY MARKUP; wipes old body)
52 -d, --due DATE date-only keeps existing time
53 --time HH:MM keeps existing date if no --due
54 --clear-due remove due date/time
55 -c, --category NAME
56 --clear-category uncategorized
57 -i, --importance N 0–3
58
59 BODY MARKUP (add/edit --body, one block per line)
60 plain text
61 [ ] item / [x] item subtask
62 - item / • item bullet
63 1. item numbered (any leading N.)
64 https://… link
65 [image:PATH] image path under data dir
66
67 subtasks TASK
68 (no subcommand) list subtasks
69 add [TEXT]
70 -t, --text TEXT
71 --done create already checked
72 done INDEX INDEX = 1-based among checkboxes only
73 undone INDEX
74 toggle INDEX
75 edit INDEX [TEXT]
76 -t, --text TEXT
77 delete INDEX later indexes shift down
78
79 update check GitHub for a newer release
80 --install run install.sh (release binary) if newer (or always with --force)
81 --force install even when already up to date
82
83 (no command) open TUI
84 --json JSON stdout (global)
85 --dir PATH data directory (global)
86
87Data: --dir PATH > $MACH_DIR > ~/.mach
88";
89
90#[derive(Parser)]
91#[command(
92 name = "mach",
93 about = concat!("mach v", env!("CARGO_PKG_VERSION")),
94 disable_version_flag = true,
95 after_help = HELP,
96)]
97struct Cli {
98 #[arg(short = 'v', long = "version")]
100 version: bool,
101
102 #[arg(long = "dir", value_name = "PATH", global = true)]
104 dir: Option<PathBuf>,
105
106 #[arg(long, global = true)]
108 json: bool,
109
110 #[command(subcommand)]
111 command: Option<Command>,
112}
113
114#[derive(Subcommand)]
115enum Command {
116 List {
118 #[arg(short = 'c', long = "category", value_name = "NAME")]
120 category: Option<String>,
121 #[arg(long, conflicts_with = "done")]
123 open: bool,
124 #[arg(long)]
126 done: bool,
127 },
128 Categories {
130 #[command(subcommand)]
131 action: Option<CatAction>,
132 },
133 Add(AddArgs),
135 Show {
137 id: String,
139 },
140 Done {
142 id: String,
144 },
145 Undone {
147 id: String,
149 },
150 Delete {
152 id: String,
154 },
155 Edit(EditArgs),
157 Subtasks {
159 task: String,
161 #[command(subcommand)]
162 action: Option<SubAction>,
163 },
164 Update {
166 #[arg(long)]
168 install: bool,
169 #[arg(long)]
171 force: bool,
172 },
173}
174
175#[derive(clap::Args)]
176struct AddArgs {
177 #[arg(value_name = "TITLE")]
179 title_pos: Option<String>,
180 #[arg(short = 't', long = "title")]
182 title: Option<String>,
183 #[arg(short = 'b', long = "body")]
185 body: Option<String>,
186 #[arg(short = 'd', long = "due", value_name = "DATE")]
188 due: Option<String>,
189 #[arg(long = "time", value_name = "HH:MM")]
191 time: Option<String>,
192 #[arg(short = 'c', long = "category", value_name = "NAME")]
194 category: Option<String>,
195 #[arg(
197 short = 'i',
198 long = "importance",
199 value_name = "N",
200 default_value_t = 0
201 )]
202 importance: u8,
203 #[arg(long = "subtask", value_name = "TEXT")]
205 subtasks: Vec<String>,
206}
207
208#[derive(clap::Args)]
209struct EditArgs {
210 id: String,
212 #[arg(short = 't', long = "title")]
214 title: Option<String>,
215 #[arg(short = 'b', long = "body")]
217 body: Option<String>,
218 #[arg(short = 'd', long = "due", value_name = "DATE")]
220 due: Option<String>,
221 #[arg(long = "time", value_name = "HH:MM")]
223 time: Option<String>,
224 #[arg(long)]
226 clear_due: bool,
227 #[arg(short = 'c', long = "category", value_name = "NAME")]
229 category: Option<String>,
230 #[arg(long = "clear-category")]
232 clear_cat: bool,
233 #[arg(short = 'i', long = "importance", value_name = "N")]
235 importance: Option<u8>,
236}
237
238#[derive(Subcommand)]
239enum CatAction {
240 List,
242 Add {
244 name: String,
246 #[arg(short = 'd', long = "description")]
248 description: Option<String>,
249 },
250 Edit {
252 name: String,
254 #[arg(short = 'n', long = "name", value_name = "NEW")]
256 new_name: Option<String>,
257 #[arg(short = 'd', long = "description")]
259 description: Option<String>,
260 #[arg(long = "clear-description")]
262 clear_description: bool,
263 },
264 Delete {
266 name: String,
268 },
269}
270
271#[derive(Subcommand)]
272enum SubAction {
273 List,
275 Add {
277 #[arg(value_name = "TEXT")]
279 text_pos: Option<String>,
280 #[arg(short = 't', long = "text")]
282 text: Option<String>,
283 #[arg(long)]
285 done: bool,
286 },
287 Done {
289 index: usize,
291 },
292 Undone {
294 index: usize,
296 },
297 Toggle {
299 index: usize,
301 },
302 Edit {
304 index: usize,
306 #[arg(value_name = "TEXT")]
308 text_pos: Option<String>,
309 #[arg(short = 't', long = "text")]
311 text: Option<String>,
312 },
313 Delete {
315 index: usize,
317 },
318}
319
320pub fn run() {
321 let cli = Cli::parse();
322 if let Some(dir) = cli.dir {
323 store::set_data_dir(dir);
324 }
325
326 if cli.version {
327 print_version();
328 return;
329 }
330
331 let json = cli.json;
332 match cli.command {
333 Some(Command::List {
334 category,
335 open,
336 done,
337 }) => cmd_list(category.as_deref(), open, done, json),
338 Some(Command::Categories { action }) => match action {
339 None | Some(CatAction::List) => cmd_cats_list(json),
340 Some(CatAction::Add { name, description }) => {
341 cmd_cat_add(&name, description.as_deref(), json)
342 }
343 Some(CatAction::Edit {
344 name,
345 new_name,
346 description,
347 clear_description,
348 }) => cmd_cat_edit(
349 &name,
350 new_name.as_deref(),
351 description.as_deref(),
352 clear_description,
353 json,
354 ),
355 Some(CatAction::Delete { name }) => cmd_cat_delete(&name, json),
356 },
357 Some(Command::Add(args)) => cmd_add(&args, json),
358 Some(Command::Show { id }) => cmd_show(&id, json),
359 Some(Command::Done { id }) => cmd_set_done(&id, true, json),
360 Some(Command::Undone { id }) => cmd_set_done(&id, false, json),
361 Some(Command::Delete { id }) => cmd_delete(&id, json),
362 Some(Command::Edit(args)) => cmd_edit(&args, json),
363 Some(Command::Subtasks { task, action }) => match action {
364 None | Some(SubAction::List) => cmd_subs_list(&task, json),
365 Some(SubAction::Add {
366 text_pos,
367 text,
368 done,
369 }) => {
370 let text = text.or(text_pos).unwrap_or_default();
371 cmd_subs_add(&task, &text, done, json);
372 }
373 Some(SubAction::Done { index }) => cmd_subs_set_done(&task, index, Some(true), json),
374 Some(SubAction::Undone { index }) => cmd_subs_set_done(&task, index, Some(false), json),
375 Some(SubAction::Toggle { index }) => cmd_subs_set_done(&task, index, None, json),
376 Some(SubAction::Edit {
377 index,
378 text_pos,
379 text,
380 }) => {
381 let text = text.or(text_pos).unwrap_or_default();
382 cmd_subs_edit(&task, index, &text, json);
383 }
384 Some(SubAction::Delete { index }) => cmd_subs_delete(&task, index, json),
385 },
386 Some(Command::Update { install, force }) => cmd_update(install, force, json),
387 None => {
388 if let Err(err) = crate::run_tui() {
389 eprintln!("mach: {err}");
390 std::process::exit(1);
391 }
392 }
393 }
394}
395
396fn cmd_update(do_install: bool, force: bool, json: bool) {
397 let info = match crate::update::check() {
398 Ok(info) => info,
399 Err(err) => {
400 if json {
401 println!("{}", serde_json::json!({ "ok": false, "error": err }));
402 } else {
403 die(err);
404 }
405 return;
406 }
407 };
408
409 if json {
410 println!(
411 "{}",
412 serde_json::json!({
413 "ok": true,
414 "current": info.current,
415 "latest": info.latest,
416 "newer": info.newer,
417 "prerelease": info.prerelease,
418 "url": info.release_url,
419 })
420 );
421 } else {
422 println!("{}", info.summary());
423 if info.newer {
424 println!();
425 println!("{}", info.install_hint());
426 }
427 }
428
429 if do_install {
430 if !info.newer && !force {
431 if !json {
432 println!("Already up to date — pass --force to reinstall.");
433 }
434 return;
435 }
436 if !json {
437 println!();
438 println!("Installing from {} …", crate::update::GIT_URL);
439 }
440 if let Err(err) = crate::update::install() {
441 die(err);
442 }
443 if json {
444 println!("{}", serde_json::json!({ "installed": true }));
446 } else {
447 println!("Installed. Restart mach to use the new build.");
448 }
449 }
450}
451
452fn print_version() {
453 for line in banner::BANNER {
454 println!("{line}");
455 }
456 println!("\nmach v{VERSION}");
457}
458
459fn die(msg: impl std::fmt::Display) -> ! {
462 eprintln!("mach: {msg}");
463 std::process::exit(1);
464}
465
466fn short_id(id: &str) -> &str {
467 if id.len() >= 8 { &id[..8] } else { id }
468}
469
470fn body_from_text(text: &str) -> Vec<Block> {
472 if text.is_empty() {
473 return Vec::new();
474 }
475 text.lines().map(line_to_block).collect()
476}
477
478fn line_to_block(line: &str) -> Block {
479 let t = line.trim_end();
480 if let Some(rest) = t.strip_prefix("[ ] ") {
481 return Block::todo(rest, false);
482 }
483 if let Some(rest) = t
484 .strip_prefix("[x] ")
485 .or_else(|| t.strip_prefix("[X] "))
486 .or_else(|| t.strip_prefix("[✓] "))
487 {
488 return Block::todo(rest, true);
489 }
490 if let Some(rest) = t.strip_prefix("- ").or_else(|| t.strip_prefix("• ")) {
491 return Block::bullet(rest);
492 }
493 if let Some(rest) = strip_number_prefix(t) {
494 return Block::number(rest);
495 }
496 if let Some(path) = t
497 .strip_prefix("[image:")
498 .and_then(|s| s.strip_suffix(']'))
499 .filter(|p| !p.is_empty())
500 {
501 return Block::image(path);
502 }
503 if t.starts_with("http://") || t.starts_with("https://") {
504 return Block::link(t);
505 }
506 Block::text(t)
507}
508
509fn strip_number_prefix(line: &str) -> Option<&str> {
511 let bytes = line.as_bytes();
512 let mut i = 0;
513 while i < bytes.len() && bytes[i].is_ascii_digit() {
514 i += 1;
515 }
516 if i == 0 {
517 return None;
518 }
519 line.get(i..)?.strip_prefix(". ")
520}
521
522fn body_to_text(body: &[Block]) -> String {
523 let mut run = 0usize;
524 let mut lines = Vec::with_capacity(body.len());
525 for b in body {
526 let line = match b {
527 Block::Text { text } => {
528 run = 0;
529 text.clone()
530 }
531 Block::Todo { text, done } => {
532 run = 0;
533 if *done {
534 format!("[x] {text}")
535 } else {
536 format!("[ ] {text}")
537 }
538 }
539 Block::Bullet { text } => {
540 run = 0;
541 format!("- {text}")
542 }
543 Block::Number { text } => {
544 run += 1;
545 format!("{run}. {text}")
546 }
547 Block::Link { url } => {
548 run = 0;
549 url.clone()
550 }
551 Block::Image { path } => {
552 run = 0;
553 format!("[image:{path}]")
554 }
555 };
556 lines.push(line);
557 }
558 lines.join("\n")
559}
560
561fn find_category<'a>(cats: &'a [Category], name: &str) -> Option<&'a Category> {
562 let q = name.trim();
563 if q.is_empty() {
564 return None;
565 }
566 let lower = q.to_lowercase();
567 if let Some(c) = cats.iter().find(|c| c.name.eq_ignore_ascii_case(q)) {
568 return Some(c);
569 }
570 let hits: Vec<_> = cats
571 .iter()
572 .filter(|c| c.name.to_lowercase().starts_with(&lower))
573 .collect();
574 if hits.len() == 1 { Some(hits[0]) } else { None }
575}
576
577fn require_category<'a>(cats: &'a [Category], name: &str) -> &'a Category {
578 if let Some(c) = find_category(cats, name) {
579 return c;
580 }
581 let lower = name.trim().to_lowercase();
582 let hits: Vec<_> = cats
583 .iter()
584 .filter(|c| c.name.to_lowercase().starts_with(&lower))
585 .map(|c| c.name.as_str())
586 .collect();
587 if hits.is_empty() {
588 die(format!("no category matching {name:?}"));
589 }
590 die(format!(
591 "ambiguous category {name:?}; matches: {}",
592 hits.join(", ")
593 ));
594}
595
596fn find_task_index(tasks: &[Task], key: &str) -> usize {
597 let key = key.trim();
598 if key.is_empty() {
599 die("empty task id");
600 }
601 if let Some(i) = tasks.iter().position(|t| t.id == key) {
602 return i;
603 }
604 let hits: Vec<usize> = tasks
605 .iter()
606 .enumerate()
607 .filter(|(_, t)| t.id.starts_with(key))
608 .map(|(i, _)| i)
609 .collect();
610 match hits.as_slice() {
611 [i] => *i,
612 [] => die(format!("no task matching id {key:?}")),
613 many => {
614 let ids: Vec<_> = many.iter().map(|&i| short_id(&tasks[i].id)).collect();
615 die(format!("ambiguous id {key:?}; matches: {}", ids.join(", ")));
616 }
617 }
618}
619
620fn cat_name_of(cats: &[Category], task: &Task) -> String {
621 task.category_id
622 .as_ref()
623 .and_then(|id| cats.iter().find(|c| c.id == *id))
624 .map(|c| c.name.clone())
625 .unwrap_or_else(|| "—".into())
626}
627
628fn print_task_line(cats: &[Category], task: &Task, show_cat: bool) {
629 let check = if task.done {
630 "\x1b[32m[✓]\x1b[0m"
631 } else {
632 "[ ]"
633 };
634 let title = if task.done {
635 format!("\x1b[9m{}\x1b[0m", task.title)
636 } else {
637 task.title.clone()
638 };
639 let fmt = crate::settings::Settings::load().date_format;
640 let due = crate::due::display(&task.due, &fmt);
641 let due = if due.is_empty() {
642 String::new()
643 } else {
644 format!(" {due}")
645 };
646 let flag = if task.importance > 0 {
647 format!(
648 " \x1b[31m{}\x1b[0m",
649 crate::model::importance_marks(task.importance)
650 )
651 } else {
652 String::new()
653 };
654 let cat = if show_cat {
655 format!(" [{}]", cat_name_of(cats, task))
656 } else {
657 String::new()
658 };
659 let progress = crate::model::todo_progress(task)
660 .map(|(d, t)| format!(" ({d}/{t})"))
661 .unwrap_or_default();
662 println!(
663 "{} {} {}{}{}{}{}",
664 short_id(&task.id),
665 check,
666 title,
667 cat,
668 due,
669 flag,
670 progress
671 );
672}
673
674fn subtask_body_index(body: &[Block], one_based: usize) -> usize {
676 if one_based == 0 {
677 die("subtask index is 1-based (use 1 for the first subtask)");
678 }
679 let mut n = 0usize;
680 for (i, b) in body.iter().enumerate() {
681 if matches!(b, Block::Todo { .. }) {
682 n += 1;
683 if n == one_based {
684 return i;
685 }
686 }
687 }
688 die(format!(
689 "no subtask at index {one_based} (task has {n} subtask(s))"
690 ));
691}
692
693fn collect_subtasks(body: &[Block]) -> Vec<(usize, &str, bool)> {
694 let mut out = Vec::new();
695 let mut n = 0usize;
696 for b in body {
697 if let Block::Todo { text, done } = b {
698 n += 1;
699 out.push((n, text.as_str(), *done));
700 }
701 }
702 out
703}
704
705fn subtasks_json(body: &[Block]) -> Vec<serde_json::Value> {
706 collect_subtasks(body)
707 .into_iter()
708 .map(|(index, text, done)| {
709 serde_json::json!({
710 "index": index,
711 "text": text,
712 "done": done,
713 })
714 })
715 .collect()
716}
717
718fn task_json(cats: &[Category], task: &Task) -> serde_json::Value {
719 let subs = collect_subtasks(&task.body);
720 let done_n = subs.iter().filter(|(_, _, d)| *d).count();
721 serde_json::json!({
722 "id": task.id,
723 "title": task.title,
724 "body": body_to_text(&task.body),
725 "subtasks": subtasks_json(&task.body),
726 "subtasks_done": done_n,
727 "subtasks_total": subs.len(),
728 "due": task.due,
729 "done": task.done,
730 "importance": task.importance,
731 "category": {
732 "id": task.category_id,
733 "name": cat_name_of(cats, task),
734 },
735 "created": task.created,
736 })
737}
738
739fn normalize_due(raw: &str) -> String {
740 let t = raw.trim().replace('T', " ");
742 if t.is_empty() {
743 return String::new();
744 }
745 if !crate::due::is_valid(&t) {
746 die(format!(
747 "invalid due {raw:?}; try YYYY-MM-DD, YYYY-MM-DD HH:MM, MM-DD, or HH:MM"
748 ));
749 }
750 let (due, _) = crate::due::parse(&format!("[{t}]"));
751 if due.is_empty() {
752 die(format!("invalid due {raw:?}"));
753 }
754 due
755}
756
757fn normalize_time(raw: &str) -> String {
758 let t = raw.trim();
759 if t.len() == 5 && t.as_bytes().get(2) == Some(&b':') && crate::due::is_valid(t) {
761 return t.to_string();
762 }
763 die(format!("invalid time {raw:?}; use HH:MM (24h), e.g. 14:30"));
764}
765
766fn resolve_due(due: Option<&str>, time: Option<&str>) -> String {
773 match (due.map(str::trim).filter(|s| !s.is_empty()), time) {
774 (None, None) => String::new(),
775 (None, Some(t)) => normalize_time(t),
776 (Some(d), None) => normalize_due(d),
777 (Some(d), Some(t)) => {
778 let d = normalize_due(d);
779 let t = normalize_time(t);
780 if d.contains(':') {
781 die(format!(
782 "due already includes a time ({d}); omit --time or pass date-only --due"
783 ));
784 }
785 normalize_due(&format!("{d} {t}"))
786 }
787 }
788}
789
790fn resolve_due_edit(current: &str, due: Option<&str>, time: Option<&str>) -> Option<String> {
792 if due.is_none() && time.is_none() {
793 return None;
794 }
795 let date_part = |s: &str| -> String {
796 if s.is_empty() {
797 return String::new();
798 }
799 match s.split_once(' ') {
800 Some((d, _)) => d.to_string(),
801 None if s.contains(':') => String::new(), None => s.to_string(),
803 }
804 };
805 let time_part = |s: &str| -> Option<String> {
806 if s.is_empty() {
807 return None;
808 }
809 if let Some((_, t)) = s.split_once(' ') {
810 return Some(t.to_string());
811 }
812 if s.contains(':') && !s.contains('-') {
813 return Some(s.to_string());
814 }
815 None
817 };
818
819 let new_date = match due {
820 Some(d) => {
821 let d = normalize_due(d);
822 if d.contains(':') && d.contains('-') {
823 if time.is_some() {
825 die("pass either a full --due datetime or --due date + --time, not both");
826 }
827 return Some(d);
828 }
829 if d.contains(':') && !d.contains('-') {
830 if time.is_some() {
832 die("pass time via --time or --due, not both");
833 }
834 return Some(d);
835 }
836 d
837 }
838 None => date_part(current),
839 };
840 let new_time = match time {
841 Some(t) => Some(normalize_time(t)),
842 None => time_part(current),
843 };
844 Some(match (new_date.as_str(), new_time) {
845 ("", None) => String::new(),
846 ("", Some(t)) => t,
847 (d, None) => d.to_string(),
848 (d, Some(t)) => normalize_due(&format!("{d} {t}")),
849 })
850}
851
852fn save_or_die(tasks: &[Task], cats: &[Category]) {
853 if let Err(e) = store::save_tasks(tasks) {
854 die(format!("failed to save tasks: {e}"));
855 }
856 if let Err(e) = store::save_categories(cats) {
857 die(format!("failed to save categories: {e}"));
858 }
859}
860
861fn cmd_list(category: Option<&str>, open_only: bool, done_only: bool, json: bool) {
864 let (cats, tasks) = store::load_all();
865 let cat_filter: Option<String> = category.map(|n| require_category(&cats, n).id.clone());
866 let show_cat = cat_filter.is_none();
867
868 let filtered: Vec<&Task> = tasks
869 .iter()
870 .filter(|t| match &cat_filter {
871 Some(cid) => t.category_id.as_deref() == Some(cid.as_str()),
872 None => true,
873 })
874 .filter(|t| {
875 if open_only {
876 !t.done
877 } else if done_only {
878 t.done
879 } else {
880 true
881 }
882 })
883 .collect();
884
885 if json {
886 let arr: Vec<_> = filtered.iter().map(|t| task_json(&cats, t)).collect();
887 println!("{}", serde_json::to_string_pretty(&arr).unwrap_or_default());
888 return;
889 }
890
891 if filtered.is_empty() {
892 println!("(no tasks)");
893 return;
894 }
895 for t in &filtered {
896 print_task_line(&cats, t, show_cat);
897 }
898 let done_n = filtered.iter().filter(|t| t.done).count();
899 println!("— {} task(s), {} done", filtered.len(), done_n);
900}
901
902fn cmd_cats_list(json: bool) {
903 let (cats, tasks) = store::load_all();
904 if json {
905 let arr: Vec<_> = cats
906 .iter()
907 .map(|c| {
908 let total = tasks
909 .iter()
910 .filter(|t| t.category_id.as_deref() == Some(c.id.as_str()))
911 .count();
912 let done = tasks
913 .iter()
914 .filter(|t| t.category_id.as_deref() == Some(c.id.as_str()) && t.done)
915 .count();
916 serde_json::json!({
917 "id": c.id,
918 "name": c.name,
919 "description": c.description,
920 "total": total,
921 "done": done,
922 })
923 })
924 .collect();
925 let unc_total = tasks.iter().filter(|t| t.category_id.is_none()).count();
927 let unc_done = tasks
928 .iter()
929 .filter(|t| t.category_id.is_none() && t.done)
930 .count();
931 let out = serde_json::json!({
932 "categories": arr,
933 "uncategorized": { "total": unc_total, "done": unc_done },
934 });
935 println!("{}", serde_json::to_string_pretty(&out).unwrap_or_default());
936 return;
937 }
938
939 if cats.is_empty() {
940 println!("(no categories)");
941 } else {
942 for c in &cats {
943 let total = tasks
944 .iter()
945 .filter(|t| t.category_id.as_deref() == Some(c.id.as_str()))
946 .count();
947 let done = tasks
948 .iter()
949 .filter(|t| t.category_id.as_deref() == Some(c.id.as_str()) && t.done)
950 .count();
951 println!("{} {}/{}", c.name, done, total);
952 }
953 }
954 let unc_total = tasks.iter().filter(|t| t.category_id.is_none()).count();
955 if unc_total > 0 {
956 let unc_done = tasks
957 .iter()
958 .filter(|t| t.category_id.is_none() && t.done)
959 .count();
960 println!("— uncategorized {}/{}", unc_done, unc_total);
961 }
962}
963
964fn cmd_cat_add(name: &str, description: Option<&str>, json: bool) {
965 let name = name.trim();
966 if name.is_empty() {
967 die("category name required");
968 }
969 if name.chars().count() > MAX_CATEGORY_NAME_LEN {
970 die(format!(
971 "category name too long (max {MAX_CATEGORY_NAME_LEN})"
972 ));
973 }
974 let mut cats = store::load_categories();
975 if cats.len() >= MAX_CATEGORY_COUNT {
976 die(format!("category limit reached ({MAX_CATEGORY_COUNT})"));
977 }
978 if cats.iter().any(|c| c.name.eq_ignore_ascii_case(name)) {
979 die(format!("category {name:?} already exists"));
980 }
981 let mut cat = Category::new(name);
982 if let Some(d) = description {
983 cat.description = d.to_string();
984 }
985 cats.push(cat);
986 if let Err(e) = store::save_categories(&cats) {
987 die(format!("failed to save categories: {e}"));
988 }
989 let cat = cats.last().expect("just pushed");
990 if json {
991 println!(
992 "{}",
993 serde_json::to_string_pretty(&serde_json::json!({
994 "id": cat.id,
995 "name": cat.name,
996 "description": cat.description,
997 }))
998 .unwrap_or_default()
999 );
1000 } else {
1001 println!("created category {}", cat.name);
1002 }
1003}
1004
1005fn cmd_cat_edit(
1006 name: &str,
1007 new_name: Option<&str>,
1008 description: Option<&str>,
1009 clear_description: bool,
1010 json: bool,
1011) {
1012 if new_name.is_none() && description.is_none() && !clear_description {
1013 die("nothing to edit; pass --name / --description / --clear-description");
1014 }
1015 if clear_description && description.is_some() {
1016 die("--clear-description cannot be combined with --description");
1017 }
1018 let mut cats = store::load_categories();
1019 let i = {
1020 let cat = require_category(&cats, name);
1021 cats.iter().position(|c| c.id == cat.id).expect("found")
1022 };
1023 if let Some(n) = new_name {
1024 let n = n.trim();
1025 if n.is_empty() {
1026 die("category name cannot be empty");
1027 }
1028 if n.chars().count() > MAX_CATEGORY_NAME_LEN {
1029 die(format!(
1030 "category name too long (max {MAX_CATEGORY_NAME_LEN})"
1031 ));
1032 }
1033 if cats
1034 .iter()
1035 .enumerate()
1036 .any(|(j, c)| j != i && c.name.eq_ignore_ascii_case(n))
1037 {
1038 die(format!("category {n:?} already exists"));
1039 }
1040 cats[i].name = n.to_string();
1041 }
1042 if clear_description {
1043 cats[i].description.clear();
1044 } else if let Some(d) = description {
1045 cats[i].description = d.to_string();
1046 }
1047 let cat = cats[i].clone();
1048 if let Err(e) = store::save_categories(&cats) {
1049 die(format!("failed to save categories: {e}"));
1050 }
1051 if json {
1052 println!(
1053 "{}",
1054 serde_json::to_string_pretty(&serde_json::json!({
1055 "id": cat.id,
1056 "name": cat.name,
1057 "description": cat.description,
1058 }))
1059 .unwrap_or_default()
1060 );
1061 } else {
1062 println!("updated category {}", cat.name);
1063 }
1064}
1065
1066fn cmd_cat_delete(name: &str, json: bool) {
1067 let (mut cats, mut tasks) = store::load_all();
1068 let cat = require_category(&cats, name);
1069 let id = cat.id.clone();
1070 let cat_name = cat.name.clone();
1071 cats.retain(|c| c.id != id);
1072 for t in &mut tasks {
1073 if t.category_id.as_deref() == Some(id.as_str()) {
1074 t.category_id = None;
1075 }
1076 }
1077 save_or_die(&tasks, &cats);
1078 if json {
1079 println!(
1080 "{}",
1081 serde_json::to_string_pretty(&serde_json::json!({
1082 "deleted": cat_name,
1083 "id": id,
1084 }))
1085 .unwrap_or_default()
1086 );
1087 } else {
1088 println!("deleted category {cat_name} (tasks uncategorized)");
1089 }
1090}
1091
1092fn cmd_add(args: &AddArgs, json: bool) {
1093 let title = args
1094 .title
1095 .as_deref()
1096 .or(args.title_pos.as_deref())
1097 .unwrap_or_default()
1098 .trim();
1099 if title.is_empty() {
1100 die("title required (positional or --title)");
1101 }
1102 let importance = args.importance;
1103 if importance > MAX_IMPORTANCE {
1104 die(format!("importance must be 0–{MAX_IMPORTANCE}"));
1105 }
1106 let (cats, mut tasks) = store::load_all();
1107 if tasks.len() >= MAX_TASK_COUNT {
1108 die(format!("task limit reached ({MAX_TASK_COUNT})"));
1109 }
1110 let cat_id = args
1111 .category
1112 .as_deref()
1113 .map(|n| require_category(&cats, n).id.clone());
1114 let due_s = resolve_due(args.due.as_deref(), args.time.as_deref());
1115 let mut task = Task::new(title, importance, cat_id, &due_s);
1116 if let Some(b) = args.body.as_deref() {
1117 task.body = body_from_text(b);
1118 }
1119 for s in &args.subtasks {
1120 let t = s.trim();
1121 if t.is_empty() {
1122 continue;
1123 }
1124 task.body.push(Block::todo(t, false));
1125 }
1126 if task.body.len() > MAX_BODY_LINES {
1127 die(format!("body line limit reached ({MAX_BODY_LINES})"));
1128 }
1129 tasks.push(task);
1130 if let Err(e) = store::save_tasks(&tasks) {
1131 die(format!("failed to save tasks: {e}"));
1132 }
1133 let task = tasks.last().expect("just pushed");
1134 if json {
1135 println!(
1136 "{}",
1137 serde_json::to_string_pretty(&task_json(&cats, task)).unwrap_or_default()
1138 );
1139 } else {
1140 let n = collect_subtasks(&task.body).len();
1141 if n > 0 {
1142 println!(
1143 "added {} {} ({} subtask{})",
1144 short_id(&task.id),
1145 task.title,
1146 n,
1147 if n == 1 { "" } else { "s" }
1148 );
1149 } else {
1150 println!("added {} {}", short_id(&task.id), task.title);
1151 }
1152 }
1153}
1154
1155fn cmd_show(id: &str, json: bool) {
1156 let (cats, tasks) = store::load_all();
1157 let i = find_task_index(&tasks, id);
1158 let task = &tasks[i];
1159 if json {
1160 println!(
1161 "{}",
1162 serde_json::to_string_pretty(&task_json(&cats, task)).unwrap_or_default()
1163 );
1164 return;
1165 }
1166 println!("id: {}", task.id);
1167 println!("title: {}", task.title);
1168 println!("done: {}", task.done);
1169 println!("category: {}", cat_name_of(&cats, task));
1170 println!(
1171 "due: {}",
1172 if task.due.is_empty() {
1173 "—"
1174 } else {
1175 &task.due
1176 }
1177 );
1178 println!(
1179 "importance: {} ({})",
1180 task.importance,
1181 crate::model::importance_marks(task.importance)
1182 );
1183 println!("created: {}", task.created);
1184 let subs = collect_subtasks(&task.body);
1185 if subs.is_empty() {
1186 println!("subtasks: —");
1187 } else {
1188 let done_n = subs.iter().filter(|(_, _, d)| *d).count();
1189 println!("subtasks: {}/{}", done_n, subs.len());
1190 for (idx, text, done) in &subs {
1191 let check = if *done { "[✓]" } else { "[ ]" };
1192 println!(" {idx}. {check} {text}");
1193 }
1194 }
1195 let mut run = 0usize;
1197 let mut notes = Vec::new();
1198 for b in &task.body {
1199 match b {
1200 Block::Todo { .. } => run = 0,
1201 Block::Text { text } => {
1202 run = 0;
1203 if !text.trim().is_empty() {
1204 notes.push(text.clone());
1205 }
1206 }
1207 Block::Bullet { text } => {
1208 run = 0;
1209 notes.push(format!("- {text}"));
1210 }
1211 Block::Number { text } => {
1212 run += 1;
1213 notes.push(format!("{run}. {text}"));
1214 }
1215 Block::Link { url } => {
1216 run = 0;
1217 notes.push(url.clone());
1218 }
1219 Block::Image { path } => {
1220 run = 0;
1221 notes.push(format!("[image:{path}]"));
1222 }
1223 }
1224 }
1225 if notes.is_empty() {
1226 println!("body: —");
1227 } else {
1228 println!("body:");
1229 for line in notes {
1230 println!(" {line}");
1231 }
1232 }
1233}
1234
1235fn cmd_subs_list(task_key: &str, json: bool) {
1238 let tasks = store::load_tasks();
1239 let i = find_task_index(&tasks, task_key);
1240 let task = &tasks[i];
1241 let subs = collect_subtasks(&task.body);
1242 if json {
1243 let out = serde_json::json!({
1244 "task_id": task.id,
1245 "title": task.title,
1246 "subtasks": subtasks_json(&task.body),
1247 "done": subs.iter().filter(|(_, _, d)| *d).count(),
1248 "total": subs.len(),
1249 });
1250 println!("{}", serde_json::to_string_pretty(&out).unwrap_or_default());
1251 return;
1252 }
1253 if subs.is_empty() {
1254 println!("{} {} (no subtasks)", short_id(&task.id), task.title);
1255 return;
1256 }
1257 println!("{} {}", short_id(&task.id), task.title);
1258 for (idx, text, done) in &subs {
1259 let check = if *done { "\x1b[32m[✓]\x1b[0m" } else { "[ ]" };
1260 let text = if *done {
1261 format!("\x1b[9m{text}\x1b[0m")
1262 } else {
1263 (*text).to_string()
1264 };
1265 println!(" {idx}. {check} {text}");
1266 }
1267 let done_n = subs.iter().filter(|(_, _, d)| *d).count();
1268 println!("— {}/{} done", done_n, subs.len());
1269}
1270
1271fn cmd_subs_add(task_key: &str, text: &str, done: bool, json: bool) {
1272 let text = text.trim();
1273 if text.is_empty() {
1274 die("subtask text required (positional or --text)");
1275 }
1276 let mut tasks = store::load_tasks();
1277 let i = find_task_index(&tasks, task_key);
1278 if tasks[i].body.len() >= MAX_BODY_LINES {
1279 die(format!("body line limit reached ({MAX_BODY_LINES})"));
1280 }
1281 tasks[i].body.push(Block::todo(text, done));
1282 let index = collect_subtasks(&tasks[i].body).len();
1283 let task = tasks[i].clone();
1284 if let Err(e) = store::save_tasks(&tasks) {
1285 die(format!("failed to save tasks: {e}"));
1286 }
1287 if json {
1288 println!(
1289 "{}",
1290 serde_json::to_string_pretty(&serde_json::json!({
1291 "task_id": task.id,
1292 "index": index,
1293 "text": text,
1294 "done": done,
1295 "subtasks": subtasks_json(&task.body),
1296 }))
1297 .unwrap_or_default()
1298 );
1299 } else {
1300 println!("added subtask {index} on {} {text}", short_id(&task.id));
1301 }
1302}
1303
1304fn cmd_subs_set_done(task_key: &str, index: usize, done: Option<bool>, json: bool) {
1306 let mut tasks = store::load_tasks();
1307 let i = find_task_index(&tasks, task_key);
1308 let bi = subtask_body_index(&tasks[i].body, index);
1309 let (new_done, text) = match &mut tasks[i].body[bi] {
1310 Block::Todo { text, done: d } => {
1311 let nd = done.unwrap_or(!*d);
1312 *d = nd;
1313 (nd, text.clone())
1314 }
1315 _ => die("internal: body index is not a subtask"),
1316 };
1317 let task = tasks[i].clone();
1318 if let Err(e) = store::save_tasks(&tasks) {
1319 die(format!("failed to save tasks: {e}"));
1320 }
1321 if json {
1322 println!(
1323 "{}",
1324 serde_json::to_string_pretty(&serde_json::json!({
1325 "task_id": task.id,
1326 "index": index,
1327 "text": text,
1328 "done": new_done,
1329 "subtasks": subtasks_json(&task.body),
1330 }))
1331 .unwrap_or_default()
1332 );
1333 } else {
1334 let verb = if new_done { "done" } else { "undone" };
1335 println!("{verb} subtask {index} on {} {text}", short_id(&task.id));
1336 }
1337}
1338
1339fn cmd_subs_edit(task_key: &str, index: usize, text: &str, json: bool) {
1340 let text = text.trim();
1341 if text.is_empty() {
1342 die("subtask text required (positional or --text)");
1343 }
1344 let mut tasks = store::load_tasks();
1345 let i = find_task_index(&tasks, task_key);
1346 let bi = subtask_body_index(&tasks[i].body, index);
1347 match &mut tasks[i].body[bi] {
1348 Block::Todo { text: t, .. } => *t = text.to_string(),
1349 _ => die("internal: body index is not a subtask"),
1350 }
1351 let task = tasks[i].clone();
1352 let done = matches!(&task.body[bi], Block::Todo { done: true, .. });
1353 if let Err(e) = store::save_tasks(&tasks) {
1354 die(format!("failed to save tasks: {e}"));
1355 }
1356 if json {
1357 println!(
1358 "{}",
1359 serde_json::to_string_pretty(&serde_json::json!({
1360 "task_id": task.id,
1361 "index": index,
1362 "text": text,
1363 "done": done,
1364 "subtasks": subtasks_json(&task.body),
1365 }))
1366 .unwrap_or_default()
1367 );
1368 } else {
1369 println!("updated subtask {index} on {} {text}", short_id(&task.id));
1370 }
1371}
1372
1373fn cmd_subs_delete(task_key: &str, index: usize, json: bool) {
1374 let mut tasks = store::load_tasks();
1375 let i = find_task_index(&tasks, task_key);
1376 let bi = subtask_body_index(&tasks[i].body, index);
1377 let removed = tasks[i].body.remove(bi);
1378 let (text, done) = match removed {
1379 Block::Todo { text, done } => (text, done),
1380 _ => die("internal: body index is not a subtask"),
1381 };
1382 let task = tasks[i].clone();
1383 if let Err(e) = store::save_tasks(&tasks) {
1384 die(format!("failed to save tasks: {e}"));
1385 }
1386 if json {
1387 println!(
1388 "{}",
1389 serde_json::to_string_pretty(&serde_json::json!({
1390 "task_id": task.id,
1391 "deleted": { "index": index, "text": text, "done": done },
1392 "subtasks": subtasks_json(&task.body),
1393 }))
1394 .unwrap_or_default()
1395 );
1396 } else {
1397 println!("deleted subtask {index} on {} {text}", short_id(&task.id));
1398 }
1399}
1400
1401fn cmd_set_done(id: &str, done: bool, json: bool) {
1402 let (cats, mut tasks) = store::load_all();
1403 let i = find_task_index(&tasks, id);
1404 tasks[i].done = done;
1405 let task = tasks[i].clone();
1406 if let Err(e) = store::save_tasks(&tasks) {
1407 die(format!("failed to save tasks: {e}"));
1408 }
1409 if json {
1410 println!(
1411 "{}",
1412 serde_json::to_string_pretty(&task_json(&cats, &task)).unwrap_or_default()
1413 );
1414 } else {
1415 let verb = if done { "done" } else { "undone" };
1416 println!("{verb} {} {}", short_id(&task.id), task.title);
1417 }
1418}
1419
1420fn cmd_delete(id: &str, json: bool) {
1421 let (cats, mut tasks) = store::load_all();
1422 let i = find_task_index(&tasks, id);
1423 let removed = tasks.remove(i);
1424 if let Err(e) = store::save_tasks(&tasks) {
1425 die(format!("failed to save tasks: {e}"));
1426 }
1427 if json {
1428 println!(
1429 "{}",
1430 serde_json::to_string_pretty(&task_json(&cats, &removed)).unwrap_or_default()
1431 );
1432 } else {
1433 println!("deleted {} {}", short_id(&removed.id), removed.title);
1434 }
1435}
1436
1437fn cmd_edit(args: &EditArgs, json: bool) {
1438 if args.title.is_none()
1439 && args.body.is_none()
1440 && args.due.is_none()
1441 && args.time.is_none()
1442 && !args.clear_due
1443 && args.category.is_none()
1444 && !args.clear_cat
1445 && args.importance.is_none()
1446 {
1447 die(
1448 "nothing to edit; pass --title / --body / --due / --time / --clear-due / --category / --clear-category / --importance",
1449 );
1450 }
1451 let (cats, mut tasks) = store::load_all();
1452 let i = find_task_index(&tasks, &args.id);
1453 if let Some(t) = args.title.as_deref() {
1454 let t = t.trim();
1455 if t.is_empty() {
1456 die("title cannot be empty");
1457 }
1458 tasks[i].title = t.to_string();
1459 }
1460 if let Some(b) = args.body.as_deref() {
1461 let body = body_from_text(b);
1463 if body.len() > MAX_BODY_LINES {
1464 die(format!("body line limit reached ({MAX_BODY_LINES})"));
1465 }
1466 tasks[i].body = body;
1467 }
1468 if args.clear_due {
1469 if args.due.is_some() || args.time.is_some() {
1470 die("--clear-due cannot be combined with --due / --time");
1471 }
1472 tasks[i].due.clear();
1473 } else if let Some(d) =
1474 resolve_due_edit(&tasks[i].due, args.due.as_deref(), args.time.as_deref())
1475 {
1476 tasks[i].due = d;
1477 }
1478 if args.clear_cat {
1479 tasks[i].category_id = None;
1480 } else if let Some(n) = args.category.as_deref() {
1481 tasks[i].category_id = Some(require_category(&cats, n).id.clone());
1482 }
1483 if let Some(imp) = args.importance {
1484 if imp > MAX_IMPORTANCE {
1485 die(format!("importance must be 0–{MAX_IMPORTANCE}"));
1486 }
1487 tasks[i].importance = imp;
1488 }
1489 let task = tasks[i].clone();
1490 if let Err(e) = store::save_tasks(&tasks) {
1491 die(format!("failed to save tasks: {e}"));
1492 }
1493 if json {
1494 println!(
1495 "{}",
1496 serde_json::to_string_pretty(&task_json(&cats, &task)).unwrap_or_default()
1497 );
1498 } else {
1499 println!("updated {} {}", short_id(&task.id), task.title);
1500 }
1501}