Skip to main content

mach/
cli.rs

1//! Command line front end for humans and agents.
2//!
3//! No subcommand → open the TUI. Subcommands read/write the data dir
4//! (`--dir` > `$MACH_DIR` > `~/.mach`).
5
6use 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
16/// Full CLI reference under `mach --help`.
17const 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    /// Show version
99    #[arg(short = 'v', long = "version")]
100    version: bool,
101
102    /// Data directory (default ~/.mach; overrides $MACH_DIR)
103    #[arg(long = "dir", value_name = "PATH", global = true)]
104    dir: Option<PathBuf>,
105
106    /// JSON stdout
107    #[arg(long, global = true)]
108    json: bool,
109
110    #[command(subcommand)]
111    command: Option<Command>,
112}
113
114#[derive(Subcommand)]
115enum Command {
116    /// List tasks
117    List {
118        /// Category name / prefix
119        #[arg(short = 'c', long = "category", value_name = "NAME")]
120        category: Option<String>,
121        /// Incomplete only
122        #[arg(long, conflicts_with = "done")]
123        open: bool,
124        /// Done only
125        #[arg(long)]
126        done: bool,
127    },
128    /// List / add / delete categories
129    Categories {
130        #[command(subcommand)]
131        action: Option<CatAction>,
132    },
133    /// Add a task
134    Add(AddArgs),
135    /// Show task
136    Show {
137        /// Task id / prefix
138        id: String,
139    },
140    /// Mark task done
141    Done {
142        /// Task id / prefix
143        id: String,
144    },
145    /// Mark task not done
146    Undone {
147        /// Task id / prefix
148        id: String,
149    },
150    /// Delete task
151    Delete {
152        /// Task id / prefix
153        id: String,
154    },
155    /// Edit task fields
156    Edit(EditArgs),
157    /// Subtasks on a task
158    Subtasks {
159        /// Parent task id / prefix
160        task: String,
161        #[command(subcommand)]
162        action: Option<SubAction>,
163    },
164    /// Check GitHub for a newer release (optional install)
165    Update {
166        /// Run install.sh (release binary → ~/.local/bin) when an update is available
167        #[arg(long)]
168        install: bool,
169        /// Install even if this binary is already the latest
170        #[arg(long)]
171        force: bool,
172    },
173}
174
175#[derive(clap::Args)]
176struct AddArgs {
177    /// Title (or use --title)
178    #[arg(value_name = "TITLE")]
179    title_pos: Option<String>,
180    /// Title
181    #[arg(short = 't', long = "title")]
182    title: Option<String>,
183    /// Body text (newlines → lines)
184    #[arg(short = 'b', long = "body")]
185    body: Option<String>,
186    /// Due date
187    #[arg(short = 'd', long = "due", value_name = "DATE")]
188    due: Option<String>,
189    /// Due time HH:MM (with --due, or alone = today)
190    #[arg(long = "time", value_name = "HH:MM")]
191    time: Option<String>,
192    /// Category
193    #[arg(short = 'c', long = "category", value_name = "NAME")]
194    category: Option<String>,
195    /// Importance 0–3
196    #[arg(
197        short = 'i',
198        long = "importance",
199        value_name = "N",
200        default_value_t = 0
201    )]
202    importance: u8,
203    /// Subtask (repeatable)
204    #[arg(long = "subtask", value_name = "TEXT")]
205    subtasks: Vec<String>,
206}
207
208#[derive(clap::Args)]
209struct EditArgs {
210    /// Task id / prefix
211    id: String,
212    /// New title
213    #[arg(short = 't', long = "title")]
214    title: Option<String>,
215    /// Replace body
216    #[arg(short = 'b', long = "body")]
217    body: Option<String>,
218    /// Due date
219    #[arg(short = 'd', long = "due", value_name = "DATE")]
220    due: Option<String>,
221    /// Due time HH:MM
222    #[arg(long = "time", value_name = "HH:MM")]
223    time: Option<String>,
224    /// Clear due
225    #[arg(long)]
226    clear_due: bool,
227    /// Set category
228    #[arg(short = 'c', long = "category", value_name = "NAME")]
229    category: Option<String>,
230    /// Uncategorized
231    #[arg(long = "clear-category")]
232    clear_cat: bool,
233    /// Importance 0–3
234    #[arg(short = 'i', long = "importance", value_name = "N")]
235    importance: Option<u8>,
236}
237
238#[derive(Subcommand)]
239enum CatAction {
240    /// List categories (default)
241    List,
242    /// Create category
243    Add {
244        /// Name
245        name: String,
246        /// Description
247        #[arg(short = 'd', long = "description")]
248        description: Option<String>,
249    },
250    /// Rename / set description
251    Edit {
252        /// Current name / prefix
253        name: String,
254        /// New name
255        #[arg(short = 'n', long = "name", value_name = "NEW")]
256        new_name: Option<String>,
257        /// Description
258        #[arg(short = 'd', long = "description")]
259        description: Option<String>,
260        /// Clear description
261        #[arg(long = "clear-description")]
262        clear_description: bool,
263    },
264    /// Delete category (tasks uncategorized)
265    Delete {
266        /// Name / prefix
267        name: String,
268    },
269}
270
271#[derive(Subcommand)]
272enum SubAction {
273    /// List subtasks (default)
274    List,
275    /// Add subtask
276    Add {
277        /// Text (or --text)
278        #[arg(value_name = "TEXT")]
279        text_pos: Option<String>,
280        /// Text
281        #[arg(short = 't', long = "text")]
282        text: Option<String>,
283        /// Start done
284        #[arg(long)]
285        done: bool,
286    },
287    /// Mark subtask done
288    Done {
289        /// 1-based index
290        index: usize,
291    },
292    /// Mark subtask not done
293    Undone {
294        /// 1-based index
295        index: usize,
296    },
297    /// Toggle subtask
298    Toggle {
299        /// 1-based index
300        index: usize,
301    },
302    /// Edit subtask text
303    Edit {
304        /// 1-based index
305        index: usize,
306        /// Text (or --text)
307        #[arg(value_name = "TEXT")]
308        text_pos: Option<String>,
309        /// Text
310        #[arg(short = 't', long = "text")]
311        text: Option<String>,
312    },
313    /// Delete subtask
314    Delete {
315        /// 1-based index
316        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            // Second line for agents that already consumed the check object.
445            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
459// ---------------------------------------------------------------- helpers
460
461fn 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
470/// Parse CLI body text into blocks (see HELP BODY MARKUP).
471fn 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
509/// `1. rest` / `12. rest` → `rest`.
510fn 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
674/// 1-based index → body index of the Nth `Block::Todo`.
675fn 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    // Accept ISO-ish "2026-08-10T14:30" as well as "2026-08-10 14:30".
741    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    // Require HH:MM (exactly), which is already a valid due form.
760    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
766/// Combine optional date (`--due`) and time (`--time`) into a stored due string.
767///
768/// - neither → empty  
769/// - time only → `HH:MM` (today)  
770/// - date only → date as given  
771/// - both → `DATE HH:MM` (date must not already include a time)
772fn 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
790/// For `edit`: apply --due / --time on top of the current value.
791fn 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(), // bare time
802            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        // "YYYY-MM-DD" with no time, or "MM-DD"
816        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                // Full datetime in --due; --time must not also be set
824                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                // bare time via --due
831                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
861// ---------------------------------------------------------------- commands
862
863fn 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        // uncategorized bucket
926        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    // Non-todo body lines (notes / bullets / links / images), with markers.
1196    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
1235// ---------------------------------------------------------------- subtasks
1236
1237fn 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
1304/// `done = Some(true/false)` sets; `None` toggles.
1305fn 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        // Full replace — use BODY MARKUP (or `show` / `--json` export) to keep structure.
1462        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}