use crate::error::{Error, Result};
use crate::model::Status;
use crate::storage::StoredTask;
const COL_WIDTH: usize = 24;
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 };
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}");
}
}