use chrono::{DateTime, Datelike, Utc};
use std::sync::OnceLock;
use super::todos::{ProjectStats, UserTodoStats};
static CONTEXT_REGEX: OnceLock<regex::Regex> = OnceLock::new();
fn get_context_regex() -> &'static regex::Regex {
CONTEXT_REGEX.get_or_init(|| regex::Regex::new(r"@(\w+)").unwrap())
}
use super::types::{Project, ProjectStatus, Todo, TodoStatus};
const LINE_WIDTH: usize = 70;
pub fn format_todo_line(todo: &Todo, project_name: Option<&str>, show_meta: bool) -> String {
let status = todo.status.icon();
let priority = format!("{:3}", todo.priority.indicator());
let short_id = todo.short_id();
let mut line = format!(" {} {} {} {}", status, priority, short_id, todo.content);
if let Some(proj) = project_name {
let content_width = line.chars().count();
if content_width < LINE_WIDTH - proj.len() - 2 {
let padding = LINE_WIDTH - content_width - proj.len() - 2;
line.push_str(&" ".repeat(padding));
} else {
line.push_str(" ");
}
line.push_str(proj);
}
if show_meta {
let mut meta = Vec::new();
if !todo.contexts.is_empty() {
meta.push(todo.contexts.join(" "));
}
if let Some(ref due) = todo.due_date {
meta.push(format_due_date(due));
}
if todo.status == TodoStatus::Blocked {
if let Some(ref blocked_on) = todo.blocked_on {
meta.push(format!("Blocked on {}", blocked_on));
}
}
if !meta.is_empty() {
line.push_str(&format!("\n {}", meta.join(" · ")));
}
}
line
}
pub fn format_due_date(due: &DateTime<Utc>) -> String {
let now = Utc::now();
let diff_secs = (due.timestamp() - now.timestamp()) as i64;
let diff_hours = diff_secs / 3600;
let diff_days = diff_secs / 86400;
if diff_secs < 0 {
let hours = (-diff_hours) as u32;
if hours < 24 {
format!("⚠ Overdue {}h", hours)
} else {
format!("⚠ Overdue {}d", hours / 24)
}
} else if diff_hours < 24 {
format!("Due {}", due.format("%I:%M %p"))
} else if diff_days < 7 {
format!("Due {}", due.format("%a"))
} else {
format!("Due {}", due.format("%b %d"))
}
}
fn format_section_header(label: &str) -> String {
let dashes = LINE_WIDTH.saturating_sub(label.len() + 1);
format!("{} {}", label, "─".repeat(dashes))
}
pub fn format_subtask_line(todo: &Todo, project_name: Option<&str>) -> String {
let status = todo.status.icon();
let priority = format!("{:3}", todo.priority.indicator());
let short_id = todo.short_id();
let mut line = format!(
" {} {} {} {}",
status, priority, short_id, todo.content
);
if let Some(proj) = project_name {
let content_width = line.chars().count();
if content_width < LINE_WIDTH - proj.len() - 2 {
let padding = LINE_WIDTH - content_width - proj.len() - 2;
line.push_str(&" ".repeat(padding));
} else {
line.push_str(" ");
}
line.push_str(proj);
}
line
}
pub fn format_todo_list(todos: &[Todo], projects: &[Project]) -> String {
format_todo_list_with_total(todos, projects, todos.len())
}
pub fn format_todo_list_with_total(
todos: &[Todo],
projects: &[Project],
total_count: usize,
) -> String {
if todos.is_empty() {
return "No todos found.".to_string();
}
let shown = todos.len();
let header = if total_count > shown {
format!(
"SHO · My Todos{:>width$} items (showing {}, {} more)\n\n",
total_count,
shown,
total_count - shown,
width = LINE_WIDTH - 14 - 20 )
} else {
format!(
"SHO · My Todos{:>width$} items\n\n",
shown,
width = LINE_WIDTH - 14
)
};
let mut output = header;
let parent_todos: Vec<_> = todos.iter().filter(|t| t.parent_id.is_none()).collect();
let subtasks: Vec<_> = todos.iter().filter(|t| t.parent_id.is_some()).collect();
let status_order = [
(TodoStatus::InProgress, "In Progress ◐"),
(TodoStatus::Todo, "Todo ○"),
(TodoStatus::Blocked, "Blocked ⊘"),
(TodoStatus::Backlog, "Backlog ◌"),
(TodoStatus::Done, "Done ●"),
(TodoStatus::Cancelled, "Cancelled ⊗"),
];
for (status, label) in status_order {
let items: Vec<_> = parent_todos.iter().filter(|t| t.status == status).collect();
if !items.is_empty() {
output.push_str(&format_section_header(label));
output.push('\n');
for todo in items {
let project_name = todo
.project_id
.as_ref()
.and_then(|pid| projects.iter().find(|p| p.id == *pid))
.map(|p| p.name.as_str());
output.push_str(&format_todo_line(todo, project_name, true));
output.push('\n');
let todo_subtasks: Vec<_> = subtasks
.iter()
.filter(|st| st.parent_id.as_ref() == Some(&todo.id))
.collect();
for subtask in todo_subtasks {
let subtask_project = subtask
.project_id
.as_ref()
.and_then(|pid| projects.iter().find(|p| p.id == *pid))
.map(|p| p.name.as_str());
output.push_str(&format_subtask_line(subtask, subtask_project));
output.push('\n');
}
}
output.push('\n');
}
}
output
}
pub fn format_project_todos(project: &Project, todos: &[Todo], stats: &ProjectStats) -> String {
let mut output = format!(
"{}{:>width$} items\n",
project.name,
stats.total,
width = LINE_WIDTH - project.name.len()
);
output.push_str(&"─".repeat(LINE_WIDTH));
output.push('\n');
let mut status_parts = Vec::new();
if stats.in_progress > 0 {
status_parts.push(format!("In Progress ({})", stats.in_progress));
}
if stats.todo > 0 {
status_parts.push(format!("Todo ({})", stats.todo));
}
if stats.blocked > 0 {
status_parts.push(format!("Blocked ({})", stats.blocked));
}
if stats.backlog > 0 {
status_parts.push(format!("Backlog ({})", stats.backlog));
}
if !status_parts.is_empty() {
output.push_str(&format!(" {}\n\n", status_parts.join(" ")));
}
for todo in todos {
output.push_str(&format_todo_line(todo, None, true));
output.push('\n');
}
output
}
pub fn format_due_todos(todos: &[Todo]) -> String {
if todos.is_empty() {
return String::new();
}
let mut output = format!(
"📋 Due Today ({})\n{}\n",
todos.len(),
"─".repeat(LINE_WIDTH)
);
for todo in todos {
let status = todo.status.icon();
let priority = format!("{:3}", todo.priority.indicator());
let short_id = todo.short_id();
let due_text = todo
.due_date
.as_ref()
.map(format_due_date)
.unwrap_or_default();
output.push_str(&format!(
" {} {} {} {:<40} {}\n",
status, priority, short_id, todo.content, due_text
));
}
output
}
pub fn format_todo_created(todo: &Todo, project_name: Option<&str>) -> String {
let mut output = format!("✓ Created {}\n\n", todo.short_id());
output.push_str(&format_todo_line(todo, project_name, true));
output
}
pub fn format_todo_completed(todo: &Todo, next: Option<&Todo>) -> String {
let duration = todo
.completed_at
.and_then(|completed| {
let created = todo.created_at;
let diff = completed - created;
let hours = diff.num_hours();
let mins = diff.num_minutes() % 60;
if hours > 0 {
Some(format!("{}h {}m", hours, mins))
} else if mins > 0 {
Some(format!("{}m", mins))
} else {
Some("< 1m".to_string())
}
})
.unwrap_or_default();
let mut output = format!("✓ Completed {}\n\n", todo.short_id());
output.push_str(&format!(
" {} {} {}",
todo.status.icon(),
todo.content,
if !duration.is_empty() {
format!("✓ {}", duration)
} else {
String::new()
}
));
if let Some(next_todo) = next {
output.push_str(&format!(
"\n\n → Next occurrence: {} ({})",
next_todo.short_id(),
next_todo
.due_date
.map(|d| format_due_date(&d))
.unwrap_or_default()
));
}
output
}
pub fn format_todo_updated(todo: &Todo, project_name: Option<&str>) -> String {
let mut output = format!("✓ Updated {}\n\n", todo.short_id());
output.push_str(&format_todo_line(todo, project_name, true));
output
}
pub fn format_todo_deleted(todo_id: &str) -> String {
format!("✓ Deleted {}", todo_id)
}
pub fn format_project_list(projects: &[(Project, ProjectStats)]) -> String {
if projects.is_empty() {
return "No projects found.".to_string();
}
let mut output = format!(
"SHO · Projects{:>width$} total\n\n",
projects.len(),
width = LINE_WIDTH - 14
);
let root_projects: Vec<_> = projects
.iter()
.filter(|(p, _)| p.parent_id.is_none())
.collect();
let sub_projects: Vec<_> = projects
.iter()
.filter(|(p, _)| p.parent_id.is_some())
.collect();
for (project, stats) in root_projects {
output.push_str(&format_project_line(project, stats, 0));
let children: Vec<_> = sub_projects
.iter()
.filter(|(p, _)| p.parent_id.as_ref() == Some(&project.id))
.collect();
for (subproject, substats) in children {
output.push_str(&format_project_line(subproject, substats, 1));
}
}
output
}
fn format_project_line(project: &Project, stats: &ProjectStats, indent_level: usize) -> String {
let indent = " ".repeat(indent_level);
let icon = if indent_level > 0 { "📂" } else { "📁" };
let active = stats.in_progress + stats.todo;
let mut output = format!(
" {}{} {:<40} {} active / {} total\n",
indent, icon, project.name, active, stats.total
);
let mut parts = Vec::new();
if stats.in_progress > 0 {
parts.push(format!("◐ {}", stats.in_progress));
}
if stats.todo > 0 {
parts.push(format!("○ {}", stats.todo));
}
if stats.blocked > 0 {
parts.push(format!("⊘ {}", stats.blocked));
}
if stats.done > 0 {
parts.push(format!("● {}", stats.done));
}
if !parts.is_empty() {
output.push_str(&format!(" {}{}\n", indent, parts.join(" ")));
}
output.push('\n');
output
}
pub fn format_project_created(project: &Project) -> String {
if project.parent_id.is_some() {
format!(
"✓ Created sub-project '{}' (ID: {})",
project.name,
&project.id.0.to_string()[..8]
)
} else {
format!(
"✓ Created project '{}' (ID: {})",
project.name,
&project.id.0.to_string()[..8]
)
}
}
pub fn format_project_updated(project: &Project) -> String {
let status_str = match project.status {
ProjectStatus::Active => "Active",
ProjectStatus::OnHold => "On Hold",
ProjectStatus::Completed => "Completed",
ProjectStatus::Archived => "Archived",
};
format!(
"✓ Updated project '{}' (Status: {})",
project.name, status_str
)
}
pub fn format_project_deleted(project: &Project, todos_deleted: usize) -> String {
if todos_deleted > 0 {
format!(
"✓ Deleted project '{}' and {} todos",
project.name, todos_deleted
)
} else {
format!("✓ Deleted project '{}'", project.name)
}
}
pub fn format_user_stats(stats: &UserTodoStats) -> String {
let mut output = format!(
"SHO · Stats{:>width$} todos\n",
stats.total,
width = LINE_WIDTH - 11
);
output.push_str(&"─".repeat(LINE_WIDTH));
output.push('\n');
output.push_str(&format!("\n ◐ In Progress {:>4}\n", stats.in_progress));
output.push_str(&format!(" ○ Todo {:>4}\n", stats.todo));
output.push_str(&format!(" ⊘ Blocked {:>4}\n", stats.blocked));
output.push_str(&format!(" ◌ Backlog {:>4}\n", stats.backlog));
output.push_str(&format!(" ● Done {:>4}\n", stats.done));
output.push_str(&format!(" ⊗ Cancelled {:>4}\n", stats.cancelled));
output.push_str(&format!("\n ⚠ Overdue {:>4}\n", stats.overdue));
output.push_str(&format!(" 📅 Due Today {:>4}\n", stats.due_today));
output.push_str(&format!(" 📁 Projects {:>4}\n", stats.projects));
output
}
pub fn parse_due_date(input: &str) -> Option<DateTime<Utc>> {
use chrono::Duration;
let input_lower = input.to_lowercase();
let now = Utc::now();
let end_of_day = |dt: DateTime<Utc>| {
dt.date_naive()
.and_hms_opt(23, 59, 59)
.map(|t| t.and_utc())
.unwrap_or(dt)
};
if input_lower.starts_with("next ") {
let day = input_lower.strip_prefix("next ").unwrap_or("");
return match day {
"monday" | "mon" => Some(next_weekday(now + Duration::days(7), 1)),
"tuesday" | "tue" => Some(next_weekday(now + Duration::days(7), 2)),
"wednesday" | "wed" => Some(next_weekday(now + Duration::days(7), 3)),
"thursday" | "thu" => Some(next_weekday(now + Duration::days(7), 4)),
"friday" | "fri" => Some(next_weekday(now + Duration::days(7), 5)),
"saturday" | "sat" => Some(next_weekday(now + Duration::days(7), 6)),
"sunday" | "sun" => Some(next_weekday(now + Duration::days(7), 0)),
"week" => Some(end_of_day(now + Duration::weeks(1))),
"month" => Some(end_of_day(now + Duration::days(30))),
_ => None,
};
}
if input_lower.starts_with("in ") {
let rest = input_lower.strip_prefix("in ").unwrap_or("");
let parts: Vec<&str> = rest.split_whitespace().collect();
if parts.len() == 2 {
if let Ok(num) = parts[0].parse::<i64>() {
return match parts[1] {
"day" | "days" => Some(end_of_day(now + Duration::days(num))),
"week" | "weeks" => Some(end_of_day(now + Duration::weeks(num))),
"month" | "months" => Some(end_of_day(now + Duration::days(num * 30))),
_ => None,
};
}
}
}
match input_lower.as_str() {
"today" => Some(end_of_day(now)),
"tomorrow" => Some(end_of_day(now + Duration::days(1))),
"next week" => Some(end_of_day(now + Duration::weeks(1))),
"next month" => Some(end_of_day(now + Duration::days(30))),
"monday" | "mon" => Some(next_weekday(now, 1)),
"tuesday" | "tue" => Some(next_weekday(now, 2)),
"wednesday" | "wed" => Some(next_weekday(now, 3)),
"thursday" | "thu" => Some(next_weekday(now, 4)),
"friday" | "fri" => Some(next_weekday(now, 5)),
"saturday" | "sat" => Some(next_weekday(now, 6)),
"sunday" | "sun" => Some(next_weekday(now, 0)),
"eod" | "end of day" => Some(end_of_day(now)),
"eow" | "end of week" => Some(next_weekday(now, 5)), "eom" | "end of month" => {
let next_month = now.with_day(1).unwrap() + Duration::days(32);
let first_of_next = next_month.with_day(1).unwrap();
Some(end_of_day(first_of_next - Duration::days(1)))
}
_ => {
chrono::DateTime::parse_from_rfc3339(input)
.ok()
.map(|d| d.with_timezone(&Utc))
.or_else(|| {
chrono::NaiveDate::parse_from_str(input, "%Y-%m-%d")
.ok()
.and_then(|d| d.and_hms_opt(23, 59, 59))
.map(|dt| dt.and_utc())
})
}
}
}
fn next_weekday(from: DateTime<Utc>, target_dow: u32) -> DateTime<Utc> {
use chrono::Duration;
let current_dow = from.weekday().num_days_from_sunday();
let days_until = if target_dow > current_dow {
target_dow - current_dow
} else if target_dow < current_dow {
7 - current_dow + target_dow
} else {
7 };
let target = from + Duration::days(days_until as i64);
target
.date_naive()
.and_hms_opt(23, 59, 59)
.map(|t| t.and_utc())
.unwrap_or(target)
}
pub fn extract_contexts(content: &str) -> Vec<String> {
let re = get_context_regex();
re.captures_iter(content)
.map(|c| format!("@{}", &c[1].to_lowercase()))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_due_date() {
assert!(parse_due_date("today").is_some());
assert!(parse_due_date("tomorrow").is_some());
assert!(parse_due_date("next week").is_some());
assert!(parse_due_date("monday").is_some());
assert!(parse_due_date("2024-12-25").is_some());
}
#[test]
fn test_extract_contexts() {
let contexts = extract_contexts("Buy groceries @errands @home");
assert_eq!(contexts.len(), 2);
assert!(contexts.contains(&"@errands".to_string()));
assert!(contexts.contains(&"@home".to_string()));
}
#[test]
fn test_format_due_date() {
use chrono::Duration;
let now = Utc::now();
let overdue = now - Duration::hours(2);
let text = format_due_date(&overdue);
assert!(text.contains("Overdue"));
let future = now + Duration::days(3);
let text = format_due_date(&future);
assert!(text.contains("Due"));
}
}