tasks-cli-rs 0.6.0

Markdown-based TODO task management CLI: each task is a Markdown file with YAML front matter
use std::io::Write;

use clap::Parser;

use crate::cli::Cli;
use crate::error::Result;
use crate::model::Status;
use crate::storage;

use super::task::active_library_root;

/// Commands whose `<id>` argument comes after a subcommand, so the current
/// task id must be injected one position later.
const NESTED: [&str; 2] = ["step", "body"];

const HELP: &str = "\
Commands (prefix with '/'):
  /add <title>            create a task (alias of /new)
  /list [filters]         list tasks, e.g. /list --status todo --tag backend
  /show [id]              task details
  /start /done /cancel [id]
  /status [id] <status>
  /set [id] --title ... --description ...
  /step get|add|done|remove [id] ...
  /body append|replace|delete [id] ...
  /edit [id]              open in $EDITOR
  /delete [id]            asks for confirmation
  /board /stats /remind /overdue /search <text>
  /archive /batch /tag /template /lib /fix-names

Workbench:
  /use <id>               select a task; later commands may omit the id
  /unuse                  clear the selection
  /refresh                redraw the dashboard
  /help                   this help
  /quit                   leave (also: /exit, Ctrl-D)
";

pub fn run() -> Result<()> {
    let mut current: Option<u64> = None;
    println!("tasks workbench — /help for commands, /quit to leave");
    dashboard(current)?;

    let stdin = std::io::stdin();
    loop {
        print!("{}> ", current.map(|s| format!("[#{s}] ")).unwrap_or_default());
        std::io::stdout().flush()?;
        let mut line = String::new();
        if stdin.read_line(&mut line)? == 0 {
            println!();
            return Ok(());
        }
        let line = line.trim();
        if line.is_empty() {
            continue;
        }
        let Some(rest) = line.strip_prefix('/') else {
            eprintln!("commands must start with '/' — try /help");
            continue;
        };
        let words: Vec<String> = rest.split_whitespace().map(String::from).collect();
        if words.is_empty() {
            continue;
        }

        match words[0].as_str() {
            "quit" | "exit" | "q" => return Ok(()),
            "help" | "h" => {
                print!("{HELP}");
                continue;
            }
            "refresh" => {
                dashboard(current)?;
                continue;
            }
            "unuse" => {
                current = None;
                continue;
            }
            "use" => {
                match select(words.get(1).map(String::as_str)) {
                    Ok(seq) => current = Some(seq),
                    Err(err) => eprintln!("error: {err}"),
                }
                continue;
            }
            _ => {}
        }

        if let Err(err) = execute(&words, current) {
            eprintln!("error: {err}");
            continue;
        }
        // the selected task may be gone (deleted/archived) after a command
        if let Some(seq) = current
            && select(Some(&seq.to_string())).is_err()
        {
            current = None;
        }
        dashboard(current)?;
    }
}

fn select(arg: Option<&str>) -> Result<u64> {
    let id = arg.ok_or_else(|| {
        crate::error::Error::InvalidTaskFile("usage: /use <id>".into())
    })?;
    let root = active_library_root()?;
    Ok(storage::resolve_task(&root, id)?.task.meta.seq)
}

/// Parses the words as a CLI invocation, injecting the current task id when
/// the command needs one and none was supplied.
fn execute(words: &[String], current: Option<u64>) -> Result<()> {
    let build = |args: &[String]| -> Vec<String> {
        let mut v = vec!["tasks".to_string()];
        v.extend(args.iter().cloned());
        v
    };
    let parsed = Cli::try_parse_from(build(words));
    let cli = match (parsed, current) {
        (Ok(cli), _) => cli,
        (Err(first_err), Some(seq)) => {
            let at = if NESTED.contains(&words[0].as_str()) { 2 } else { 1 };
            if at > words.len() {
                return Err(crate::error::Error::InvalidTaskFile(first_err.to_string()));
            }
            let mut with_id = words.to_vec();
            with_id.insert(at, seq.to_string());
            Cli::try_parse_from(build(&with_id))
                .map_err(|_| crate::error::Error::InvalidTaskFile(first_err.to_string()))?
        }
        (Err(err), None) => {
            return Err(crate::error::Error::InvalidTaskFile(err.to_string()));
        }
    };
    if matches!(cli.command, crate::cli::Command::Repl) {
        return Err(crate::error::Error::InvalidTaskFile(
            "already in the workbench".into(),
        ));
    }
    crate::dispatch(cli)
}

/// In-progress tasks in full, everything else as counts.
fn dashboard(current: Option<u64>) -> Result<()> {
    let root = active_library_root()?;
    let tasks = storage::load_library(&root)?.tasks;

    println!("\n=== in progress ===");
    let active: Vec<_> = tasks
        .iter()
        .filter(|t| t.task.meta.status == Status::InProgress)
        .collect();
    if active.is_empty() {
        println!("(none)");
    }
    for st in &active {
        let m = &st.task.meta;
        let marker = if current == Some(m.seq) { "*" } else { " " };
        println!("{marker} #{} [{}] {}", m.seq, m.priority, m.title);
        if let Some(d) = &m.description {
            println!("    {d}");
        }
        if let Some(due) = m.due_date {
            println!("    due {}", due.date_naive());
        }
        if !st.task.steps.is_empty() {
            let done = st.task.steps.iter().filter(|s| s.done).count();
            println!("    steps {done}/{}", st.task.steps.len());
            for (i, step) in st.task.steps.iter().enumerate() {
                let mark = if step.done { "x" } else { " " };
                println!("      s{} [{mark}] {}", i + 1, step.title);
            }
        }
    }

    let count = |s: Status| tasks.iter().filter(|t| t.task.meta.status == s).count();
    let now = chrono::Utc::now();
    let overdue = tasks
        .iter()
        .filter(|t| {
            !matches!(t.task.meta.status, Status::Done | Status::Cancelled)
                && t.task.meta.due_date.is_some_and(|d| d < now)
        })
        .count();
    println!(
        "=== todo {} | blocked {} | in_review {} | done {} | cancelled {} | overdue {} ===",
        count(Status::Todo),
        count(Status::Blocked),
        count(Status::InReview),
        count(Status::Done),
        count(Status::Cancelled),
        overdue
    );
    Ok(())
}