aip-cli 0.10.5

AI profile manager for Claude Code and Codex CLI.
use chrono::{DateTime, Local, Utc};
use serde::{Deserialize, Serialize};

const BAR_WIDTH: usize = 20;
const RESET: &str = "\x1b[0m";

fn danger_color(used_percent: f64) -> &'static str {
    if used_percent > 80.0 {
        "\x1b[31m" // red
    } else if used_percent > 50.0 {
        "\x1b[33m" // yellow
    } else {
        "\x1b[32m" // green
    }
}

#[derive(Clone, Copy)]
pub enum DisplayMode {
    Used,
    Left,
}

#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DisplayPreference {
    #[default]
    Default,
    Used,
    Left,
}

impl DisplayPreference {
    pub fn next(self) -> Self {
        match self {
            DisplayPreference::Default => DisplayPreference::Used,
            DisplayPreference::Used => DisplayPreference::Left,
            DisplayPreference::Left => DisplayPreference::Default,
        }
    }

    pub fn resolve(self, tool_default: DisplayMode) -> DisplayMode {
        match self {
            DisplayPreference::Default => tool_default,
            DisplayPreference::Used => DisplayMode::Used,
            DisplayPreference::Left => DisplayMode::Left,
        }
    }
}

pub fn render_bar(percent: f64, color: &str) -> String {
    let percent = percent.clamp(0.0, 100.0);
    let filled = ((percent / 100.0) * BAR_WIDTH as f64).round() as usize;
    let filled = filled.min(BAR_WIDTH);
    let empty = BAR_WIDTH - filled;
    format!(
        "{}{}{}{}",
        color,
        "\u{2588}".repeat(filled),
        RESET,
        "\u{2591}".repeat(empty),
    )
}

pub fn format_usage_line(
    label: &str,
    percent: f64,
    resets_at: Option<DateTime<Utc>>,
    mode: &DisplayMode,
) -> String {
    // danger_color is always based on used% (not display_percent)
    // so color reflects how close to the limit regardless of display mode
    let color = danger_color(percent);
    let (display_percent, colored_mode_label) = match mode {
        DisplayMode::Used => (percent, format!("{color}used{RESET}")),
        DisplayMode::Left => (100.0 - percent, format!("{color}left{RESET}")),
    };
    let display_percent = display_percent.clamp(0.0, 100.0);
    let reset_label = match resets_at {
        Some(reset_at) => format!("resets at {}", format_reset_time(reset_at)),
        None => "session not started".to_string(),
    };
    format!(
        "{}  {}  {:>5.1}% {}  {}",
        label,
        render_bar(display_percent, color),
        display_percent,
        colored_mode_label,
        reset_label,
    )
}

pub fn format_reset_time(reset_utc: DateTime<Utc>) -> String {
    let local: DateTime<Local> = reset_utc.into();
    let now = Local::now();

    if local.date_naive() == now.date_naive() {
        local.format("%H:%M").to_string()
    } else {
        local.format("%b %d %H:%M").to_string()
    }
}

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

    #[test]
    fn format_usage_line_handles_session_not_started() {
        let line = format_usage_line("5-hour", 0.0, None, &DisplayMode::Used);

        assert!(line.contains("session not started"));
    }

    #[test]
    fn render_bar_zero_percent() {
        let bar = render_bar(0.0, "\x1b[32m");
        assert!(!bar.contains('\u{2588}'));
        assert_eq!(bar.matches('\u{2591}').count(), BAR_WIDTH);
    }

    #[test]
    fn render_bar_full_percent() {
        let bar = render_bar(100.0, "\x1b[32m");
        assert_eq!(bar.matches('\u{2588}').count(), BAR_WIDTH);
        assert!(!bar.contains('\u{2591}'));
    }

    #[test]
    fn render_bar_clamps_negative() {
        let bar = render_bar(-10.0, "\x1b[32m");
        assert!(!bar.contains('\u{2588}'));
        assert_eq!(bar.matches('\u{2591}').count(), BAR_WIDTH);
    }

    #[test]
    fn render_bar_clamps_over_100() {
        let bar = render_bar(150.0, "\x1b[32m");
        assert_eq!(bar.matches('\u{2588}').count(), BAR_WIDTH);
        assert!(!bar.contains('\u{2591}'));
    }

    #[test]
    fn danger_color_green_for_low() {
        assert_eq!(danger_color(0.0), "\x1b[32m");
        assert_eq!(danger_color(50.0), "\x1b[32m");
    }

    #[test]
    fn danger_color_yellow_for_medium() {
        assert_eq!(danger_color(51.0), "\x1b[33m");
        assert_eq!(danger_color(80.0), "\x1b[33m");
    }

    #[test]
    fn danger_color_red_for_high() {
        assert_eq!(danger_color(81.0), "\x1b[31m");
        assert_eq!(danger_color(100.0), "\x1b[31m");
    }

    #[test]
    fn format_reset_time_different_day() {
        use chrono::TimeZone;
        let far_future = Utc.with_ymd_and_hms(2099, 12, 31, 12, 0, 0).unwrap();
        let result = format_reset_time(far_future);
        assert!(result.contains("Dec 31"));
    }

    #[test]
    fn format_usage_line_left_mode_shows_remaining_percent() {
        let line = format_usage_line("5-hour", 70.0, None, &DisplayMode::Left);

        // 100.0 - 70.0 = 30.0% left
        assert!(line.contains("30.0%"));
        assert!(line.contains("left"));
    }

    #[test]
    fn format_usage_line_used_mode_shows_used_percent() {
        let line = format_usage_line("5-hour", 60.0, None, &DisplayMode::Used);

        assert!(line.contains("60.0%"));
        assert!(line.contains("used"));
    }

    #[test]
    fn display_preference_next_cycles_through_all_modes() {
        let pref = DisplayPreference::Default;
        let pref = pref.next();
        assert_eq!(pref, DisplayPreference::Used);
        let pref = pref.next();
        assert_eq!(pref, DisplayPreference::Left);
        let pref = pref.next();
        assert_eq!(pref, DisplayPreference::Default);
    }

    #[test]
    fn display_preference_resolve_default_returns_tool_default() {
        assert!(matches!(
            DisplayPreference::Default.resolve(DisplayMode::Used),
            DisplayMode::Used
        ));
        assert!(matches!(
            DisplayPreference::Default.resolve(DisplayMode::Left),
            DisplayMode::Left
        ));
    }

    #[test]
    fn display_preference_resolve_overrides_tool_default() {
        assert!(matches!(
            DisplayPreference::Used.resolve(DisplayMode::Left),
            DisplayMode::Used
        ));
        assert!(matches!(
            DisplayPreference::Left.resolve(DisplayMode::Used),
            DisplayMode::Left
        ));
    }

    #[test]
    fn format_usage_line_left_mode_clamps_over_100_to_zero() {
        // percent 120.0 means 120% used; in Left mode, 100 - 120 = -20 -> clamped to 0.0
        let line = format_usage_line("5-hour", 120.0, None, &DisplayMode::Left);
        assert!(line.contains("0.0%"));
    }

    #[test]
    fn format_usage_line_used_mode_clamps_negative_to_zero() {
        // percent -10.0 in Used mode -> clamped to 0.0
        let line = format_usage_line("5-hour", -10.0, None, &DisplayMode::Used);
        assert!(line.contains("0.0%"));
    }
}