tasks-cli-rs 0.14.2

Markdown-based TODO task management CLI: each task is a Markdown file with YAML front matter
use crate::error::{Error, Result};
use crate::model::Status;
use crate::storage::StoredTask;

const COL_WIDTH: usize = 24;

/// Display width where CJK chars count as 2 columns.
pub fn width(s: &str) -> usize {
    s.chars().map(|c| if (c as u32) > 0x2E7F { 2 } else { 1 }).sum()
}

pub fn truncate(s: &str, max: usize) -> String {
    if width(s) <= max {
        return s.to_string();
    }
    let mut out = String::new();
    let mut w = 0;
    for c in s.chars() {
        let cw = if (c as u32) > 0x2E7F { 2 } else { 1 };
        // reserve one column for the ellipsis
        if w + cw > max - 1 {
            break;
        }
        out.push(c);
        w += cw;
    }
    out.push('');
    out
}

pub fn pad(s: &str, to: usize) -> String {
    let mut out = s.to_string();
    for _ in width(s)..to {
        out.push(' ');
    }
    out
}

pub fn render(tasks: &[StoredTask], columns: Option<String>) -> Result<String> {
    let columns: Vec<Status> = match columns {
        Some(cs) => cs
            .split(',')
            .map(|s| s.trim().parse::<Status>().map_err(Error::InvalidTaskFile))
            .collect::<Result<_>>()?,
        None => vec![Status::Todo, Status::InProgress, Status::Done],
    };

    let mut cells: Vec<Vec<String>> = Vec::new();
    let mut headers = Vec::new();
    for col in &columns {
        let mut items: Vec<String> = Vec::new();
        for st in tasks.iter().filter(|t| t.task.meta.status == *col) {
            let m = &st.task.meta;
            items.push(truncate(&format!("#{} {}", m.seq, m.title), COL_WIDTH - 2));
            let mut meta = format!("[{}]", m.priority);
            if let Some(d) = m.due_date {
                meta.push_str(&format!(" due {}", d.date_naive()));
            }
            items.push(truncate(&meta, COL_WIDTH - 2));
            items.push(String::new());
        }
        if items.last().is_some_and(|s| s.is_empty()) {
            items.pop();
        }
        headers.push(format!(
            "{} ({})",
            col.to_string().to_uppercase(),
            tasks.iter().filter(|t| t.task.meta.status == *col).count()
        ));
        cells.push(items);
    }

    let rows = cells.iter().map(|c| c.len()).max().unwrap_or(0);
    let mut out = String::new();
    for h in &headers {
        out.push_str(&format!("| {} ", pad(&truncate(h, COL_WIDTH - 2), COL_WIDTH - 2)));
    }
    out.push_str("|\n");
    for _ in &headers {
        out.push_str(&format!("|{}", "-".repeat(COL_WIDTH)));
    }
    out.push_str("|\n");
    for r in 0..rows {
        for c in &cells {
            let text = c.get(r).map(String::as_str).unwrap_or("");
            out.push_str(&format!("| {} ", pad(&truncate(text, COL_WIDTH - 2), COL_WIDTH - 2)));
        }
        out.push_str("|\n");
    }
    Ok(out)
}

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

    fn stored(seq: u64, title: &str, status: Status) -> StoredTask {
        let mut task = Task::new(seq, title);
        task.set_status(status);
        StoredTask { path: std::path::PathBuf::from("x.md"), task }
    }

    #[test]
    fn renders_default_columns_with_counts() {
        let tasks = vec![
            stored(1, "Alpha", Status::Todo),
            stored(2, "Beta", Status::InProgress),
            stored(3, "Gamma", Status::Done),
        ];
        let out = render(&tasks, None).unwrap();
        assert!(out.contains("TODO (1)"));
        assert!(out.contains("IN_PROGRESS (1)"));
        assert!(out.contains("DONE (1)"));
        assert!(out.contains("#1 Alpha"));
        assert!(out.contains("#3 Gamma"));
    }

    #[test]
    fn custom_columns_and_bad_column() {
        let tasks = vec![stored(1, "Alpha", Status::Blocked)];
        let out = render(&tasks, Some("blocked".into())).unwrap();
        assert!(out.contains("BLOCKED (1)"));
        assert!(render(&tasks, Some("nope".into())).is_err());
    }

    #[test]
    fn cjk_titles_do_not_break_layout() {
        let tasks = vec![stored(1, "修复登录超时问题的一个很长的标题", Status::Todo)];
        let out = render(&tasks, None).unwrap();
        let lines: Vec<&str> = out.lines().collect();
        let w = width(lines[0]);
        assert!(lines.iter().all(|l| width(l) == w), "{out}");
    }
}