aimcal-cli 0.12.1

AIM - Analyze. Interact. Manage Your Time, with calendar support
Documentation
// SPDX-FileCopyrightText: 2025-2026 Zexin Yuan <aim@yzx9.xyz>
//
// SPDX-License-Identifier: Apache-2.0

use std::{borrow::Cow, fmt};

use aimcal_core::{LooseDateTime, Priority, RangePosition, Todo, TodoStatus};
use colored::Color;
use jiff::{SignedDuration, Zoned};

use crate::table::{PaddingDirection, Table, TableColumn, TableStyleBasic, TableStyleJson};
use crate::util::{OutputFormat, format_datetime};

#[derive(Debug, Clone)]
pub struct TodoFormatter {
    now: Zoned,
    columns: Vec<TodoColumn>,
    format: OutputFormat,
}

impl TodoFormatter {
    pub fn new(now: Zoned, columns: Vec<TodoColumn>, format: OutputFormat) -> Self {
        Self {
            now,
            columns,
            format,
        }
    }

    pub fn format<'a, T: Todo>(&'a self, todos: &'a [T]) -> Display<'a, T> {
        Display {
            todos,
            formatter: self,
        }
    }
}

#[derive(Debug, Clone, Copy)]
pub struct Display<'a, T: Todo> {
    todos: &'a [T],
    formatter: &'a TodoFormatter,
}

impl<T: Todo> fmt::Display for Display<'_, T> {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let columns: Vec<_> = self
            .formatter
            .columns
            .iter()
            .map(|column| ColumnMeta {
                column,
                now: self.formatter.now.clone(),
            })
            .collect();

        match self.formatter.format {
            OutputFormat::Json => {
                let table = Table::new(TableStyleJson::new(), &columns, self.todos);
                write!(f, "{table}")
            }
            OutputFormat::Table => {
                let table = Table::new(TableStyleBasic::new(), &columns, self.todos);
                write!(f, "{table}")
            }
        }
    }
}

#[derive(Debug, Clone, Copy)]
pub enum TodoColumn {
    Due,
    Id,
    Priority,
    ShortId,
    Status,
    Summary,
    Uid,
}

#[derive(Debug, Clone)]
struct ColumnMeta<'a> {
    column: &'a TodoColumn,
    now: Zoned,
}

impl<T: Todo> TableColumn<T> for ColumnMeta<'_> {
    fn name(&self) -> Cow<'_, str> {
        match self.column {
            TodoColumn::Due => "Due",
            TodoColumn::Id => "ID",
            TodoColumn::Priority => "Priority",
            TodoColumn::ShortId => "Short ID",
            TodoColumn::Status => "Status",
            TodoColumn::Summary => "Summary",
            TodoColumn::Uid => "UID",
        }
        .into()
    }

    fn format<'b>(&self, data: &'b T) -> Cow<'b, str> {
        match self.column {
            TodoColumn::Due => format_due(data),
            TodoColumn::Id => format_id(data),
            TodoColumn::Priority => format_priority(data),
            TodoColumn::ShortId => format_short_id(data),
            TodoColumn::Status => format_status(data),
            TodoColumn::Summary => format_summary(data),
            TodoColumn::Uid => format_uid(data),
        }
    }

    fn padding_direction(&self) -> PaddingDirection {
        use TodoColumn::{Id, Priority, ShortId, Uid};
        match self.column {
            Id | Priority | Uid | ShortId => PaddingDirection::Right,
            _ => PaddingDirection::Left,
        }
    }

    fn get_color(&self, data: &T) -> Option<Color> {
        match self.column {
            TodoColumn::Due => get_color_due(data, &self.now),
            TodoColumn::Priority => get_color_priority(),
            _ => None,
        }
    }
}

fn format_id(todo: &impl Todo) -> Cow<'_, str> {
    if let Some(short_id) = todo.short_id() {
        short_id.to_string().into()
    } else {
        let uid = todo.uid(); // Fallback to the full UID if no short ID is available
        tracing::warn!(
            uid = uid.as_ref(),
            "todo does not have a short ID, using UID instead."
        );
        uid
    }
}

fn format_due(todo: &impl Todo) -> Cow<'_, str> {
    todo.due().map_or("".into(), |a| format_datetime(a).into())
}

fn get_color_due(todo: &impl Todo, now: &Zoned) -> Option<Color> {
    let due = todo.due()?; // Ensure due date is present
    get_color_due_impl(&due, now)
}

fn get_color_due_impl(due: &LooseDateTime, now: &Zoned) -> Option<Color> {
    #[rustfmt::skip]
    const COLOR_OVERDUE_LT_24H: Color = Color::TrueColor { r: 255, g: 162, b: 162 };
    #[rustfmt::skip]
    const COLOR_OVERDUE_LT_48H: Color = Color::TrueColor { r: 251, g: 43, b: 55 };
    const COLOR_OVERDUE_LT_72H: Color = Color::TrueColor { r: 193, g: 2, b: 7 };
    #[rustfmt::skip]
    const COLOR_OVERDUE_GT_72H: Color = Color::TrueColor { r: 130, g: 24, b: 26 };
    const COLOR_COMING: Color = Color::Yellow;

    let now_dt = now.datetime();
    let due_dt = due.with_end_of_day();
    let same_day = due.date() == now.date();

    match LooseDateTime::position_in_range(&now_dt, &None, &Some(due.clone())) {
        RangePosition::InRange if same_day => Some(COLOR_COMING), // not due && due in today
        RangePosition::InRange => None,                           // not due
        RangePosition::After => {
            let overdue_lt_24h = due_dt
                .checked_add(SignedDuration::from_hours(24))
                .is_ok_and(|boundary| now_dt < boundary);
            let overdue_lt_48h = due_dt
                .checked_add(SignedDuration::from_hours(48))
                .is_ok_and(|boundary| now_dt < boundary);
            let overdue_lt_72h = due_dt
                .checked_add(SignedDuration::from_hours(72))
                .is_ok_and(|boundary| now_dt < boundary);

            if overdue_lt_24h {
                Some(COLOR_OVERDUE_LT_24H)
            } else if overdue_lt_48h {
                Some(COLOR_OVERDUE_LT_48H)
            } else if overdue_lt_72h {
                Some(COLOR_OVERDUE_LT_72H)
            } else {
                Some(COLOR_OVERDUE_GT_72H)
            }
        }
        pos => {
            tracing::error!(?due, now = ?now, ?pos, "Invalid state when computing due date color.");
            None
        }
    }
}

fn format_priority(todo: &impl Todo) -> Cow<'_, str> {
    match todo.priority() {
        Priority::P1 | Priority::P2 | Priority::P3 => "!!!",
        Priority::P4 | Priority::P5 | Priority::P6 => "!!",
        Priority::P7 | Priority::P8 | Priority::P9 => "!",
        Priority::None => "",
    }
    .into()
}

#[expect(clippy::unnecessary_wraps)]
fn get_color_priority() -> Option<Color> {
    Some(Color::Red)
}

fn format_status(todo: &impl Todo) -> Cow<'_, str> {
    match todo.status() {
        TodoStatus::NeedsAction => "[ ]".into(),
        TodoStatus::Completed => "[x]".into(),
        TodoStatus::Cancelled => "".into(),
        TodoStatus::InProcess => {
            let percent = todo.percent_complete().unwrap_or_default();
            match percent {
                0 => "[ ]".into(),
                100 => "[x]".into(),
                _ => format!("{percent}%").into(),
            }
        }
    }
}

fn format_summary(todo: &impl Todo) -> Cow<'_, str> {
    todo.summary().replace('\n', "").into()
}

fn format_short_id(todo: &impl Todo) -> Cow<'_, str> {
    todo.short_id()
        .map(|a| a.to_string())
        .unwrap_or_default()
        .into()
}

fn format_uid(todo: &impl Todo) -> Cow<'_, str> {
    todo.uid()
}

#[cfg(test)]
mod tests {
    use colored::Color;
    use jiff::civil::{DateTime, date, time};

    use super::*;

    #[test]
    fn computes_color_based_on_due_date() {
        let due_date = date(2025, 8, 5);
        let due_time = time(12, 0, 0, 0);
        let due = LooseDateTime::Floating(DateTime::from_parts(due_date, due_time));

        #[rustfmt::skip]
        let cases = [
            ("Today before due time", 2025, 8, 5, 12,  0, 0, Some(Color::Yellow)),
            ("Today after due time",  2025, 8, 5, 14,  0, 0, Some(Color::TrueColor { r: 255, g: 162, b: 162 })),
            ("Overdue by 23h59m",     2025, 8, 6, 11, 59, 0, Some(Color::TrueColor { r: 255, g: 162, b: 162 })),
            ("Overdue by 24h",        2025, 8, 6, 12,  0, 0, Some(Color::TrueColor { r: 251, g: 43, b: 55 })),
            ("Overdue by 48h",        2025, 8, 7, 12,  0, 0, Some(Color::TrueColor { r: 193, g: 2, b: 7 })),
            ("Overdue by 72h",        2025, 8, 8, 12,  0, 0, Some(Color::TrueColor { r: 130, g: 24, b: 26 })),
            ("Future date",           2025, 8, 4, 10,  0, 0, None),
        ];

        for (title, year, month, day, hour, minute, second, expected) in cases {
            let date = date(year, month, day);
            let time = time(hour, minute, second, 0);
            let now = DateTime::from_parts(date, time)
                .to_zoned(jiff::tz::TimeZone::system())
                .unwrap();
            let color = get_color_due_impl(&due, &now);
            assert_eq!(color, expected, "Failed for case: {title}");
        }
    }
}