tasks-cli-rs 0.9.0

Markdown-based TODO task management CLI: each task is a Markdown file with YAML front matter
use chrono::{Duration, Utc};

use crate::error::{Error, Result};
use crate::model::Status;
use crate::storage::{self, StoredTask};

use super::task::{active_library_root, print_task_table};

fn is_open(t: &StoredTask) -> bool {
    !matches!(t.task.meta.status, Status::Done | Status::Cancelled)
}

/// Parses "7d" or "7" as days.
pub fn parse_within(s: &str) -> Result<Duration> {
    let num = s.trim().trim_end_matches(['d', 'D']);
    num.parse::<i64>()
        .map(Duration::days)
        .map_err(|_| Error::InvalidTaskFile(format!("invalid window '{s}', expected e.g. 7d")))
}

pub fn remind(within: Option<String>, overdue_only: bool, summary: bool) -> Result<()> {
    let root = active_library_root()?;
    let tasks = storage::load_library(&root)?.tasks;
    let now = Utc::now();
    let window = within.map(|w| parse_within(&w)).transpose()?.unwrap_or(Duration::days(3));

    let overdue: Vec<&StoredTask> = tasks
        .iter()
        .filter(|t| is_open(t) && t.task.meta.due_date.is_some_and(|d| d < now))
        .collect();
    let due_soon: Vec<&StoredTask> = tasks
        .iter()
        .filter(|t| {
            is_open(t) && t.task.meta.due_date.is_some_and(|d| d >= now && d <= now + window)
        })
        .collect();

    if summary {
        if overdue.is_empty() && due_soon.is_empty() {
            return Ok(());
        }
        println!("tasks: {} overdue, {} due soon", overdue.len(), due_soon.len());
        return Ok(());
    }

    if !overdue.is_empty() {
        println!("OVERDUE:");
        for t in &overdue {
            let m = &t.task.meta;
            println!(
                "  #{} {} (due {})",
                m.seq,
                m.title,
                m.due_date.unwrap().date_naive()
            );
        }
    }
    if !overdue_only {
        if !due_soon.is_empty() {
            println!("DUE SOON (within {} day(s)):", window.num_days());
            for t in &due_soon {
                let m = &t.task.meta;
                println!(
                    "  #{} {} (due {})",
                    m.seq,
                    m.title,
                    m.due_date.unwrap().date_naive()
                );
            }
        }
        if overdue.is_empty() && due_soon.is_empty() {
            println!("nothing due within {} day(s)", window.num_days());
        }
    } else if overdue.is_empty() {
        println!("no overdue tasks");
    }
    Ok(())
}

pub fn overdue() -> Result<()> {
    let root = active_library_root()?;
    let now = Utc::now();
    let tasks: Vec<StoredTask> = storage::load_library(&root)?
        .tasks
        .into_iter()
        .filter(|t| is_open(t) && t.task.meta.due_date.is_some_and(|d| d < now))
        .collect();
    print_task_table(&tasks);
    Ok(())
}

pub fn stats() -> Result<()> {
    let root = active_library_root()?;
    let tasks = storage::load_library(&root)?.tasks;
    let now = Utc::now();
    let total = tasks.len();
    let count = |s: Status| tasks.iter().filter(|t| t.task.meta.status == s).count();
    let done = count(Status::Done);
    let cancelled = count(Status::Cancelled);
    let overdue = tasks
        .iter()
        .filter(|t| is_open(t) && t.task.meta.due_date.is_some_and(|d| d < now))
        .count();

    println!("Task Statistics");
    println!("────────────────────────────");
    println!("Total tasks:      {total}");
    for s in [Status::Todo, Status::InProgress, Status::Blocked, Status::InReview, Status::Done, Status::Cancelled] {
        let n = count(s);
        if n > 0 {
            println!("  {:<13} {n}", format!("{s}:"));
        }
    }
    let closed = total.saturating_sub(cancelled);
    if closed > 0 {
        println!("Completion rate:  {:.1}%", done as f64 * 100.0 / closed as f64);
    }
    println!("Overdue:          {overdue}");

    let mut tag_counts = std::collections::HashMap::<&str, usize>::new();
    for t in &tasks {
        for tag in &t.task.meta.tags {
            *tag_counts.entry(tag.as_str()).or_default() += 1;
        }
    }
    if !tag_counts.is_empty() {
        let mut sorted: Vec<_> = tag_counts.into_iter().collect();
        sorted.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));
        println!("Top tags:");
        for (tag, n) in sorted.into_iter().take(5) {
            println!("  {:<12} {n}", tag);
        }
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parse_within_accepts_days() {
        assert_eq!(parse_within("7d").unwrap(), Duration::days(7));
        assert_eq!(parse_within("3").unwrap(), Duration::days(3));
        assert!(parse_within("soon").is_err());
    }
}