tokidex 0.1.2

macOS terminal UI for inspecting local Codex token usage
use chrono::{DateTime, Datelike, Duration, Local, TimeZone, Utc};

use crate::model::{RateLimit, SessionRecord, TokenUsage};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DateRange {
    Today,
    Week,
    All,
}

impl DateRange {
    pub fn label(self) -> &'static str {
        match self {
            DateRange::Today => "today",
            DateRange::Week => "week",
            DateRange::All => "all",
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PrivacyMode {
    Off,
    On,
}

impl PrivacyMode {
    pub fn enabled(self) -> bool {
        matches!(self, PrivacyMode::On)
    }
}

#[derive(Debug, Clone)]
pub struct App {
    pub all_records: Vec<SessionRecord>,
    pub visible: Vec<SessionRecord>,
    pub selected: usize,
    pub range: DateRange,
    pub search: String,
    pub search_mode: bool,
    pub status: String,
    pub privacy: PrivacyMode,
}

impl App {
    pub fn new(records: Vec<SessionRecord>, range: DateRange, privacy: PrivacyMode) -> Self {
        let mut app = App {
            all_records: records,
            visible: Vec::new(),
            selected: 0,
            range,
            search: String::new(),
            search_mode: false,
            status: String::new(),
            privacy,
        };
        app.recompute();
        app
    }

    pub fn replace_records(&mut self, records: Vec<SessionRecord>) {
        self.all_records = records;
        self.recompute();
    }

    pub fn recompute(&mut self) {
        let now = Utc::now();
        self.visible = filter_records(&self.all_records, self.range, &self.search, now)
            .into_iter()
            .cloned()
            .collect();
        if self.visible.is_empty() {
            self.selected = 0;
        } else if self.selected >= self.visible.len() {
            self.selected = self.visible.len() - 1;
        }
    }

    pub fn selected_record(&self) -> Option<&SessionRecord> {
        self.visible.get(self.selected)
    }

    pub fn move_next(&mut self) {
        if !self.visible.is_empty() {
            self.selected = (self.selected + 1).min(self.visible.len() - 1);
        }
    }

    pub fn move_previous(&mut self) {
        self.selected = self.selected.saturating_sub(1);
    }

    pub fn totals(&self) -> TokenUsage {
        self.visible
            .iter()
            .fold(TokenUsage::default(), |total, record| {
                total.combined_with(record.effective_usage())
            })
    }

    pub fn latest_rate_limit(&self) -> Option<RateLimit> {
        if self.privacy.enabled() {
            return None;
        }
        self.visible.iter().find_map(|record| record.rate_limit)
    }
}

pub fn display_title(record: &SessionRecord, index: usize, privacy: PrivacyMode) -> String {
    if privacy.enabled() {
        format!("Session {}", index + 1)
    } else {
        record.summary.title.clone()
    }
}

pub fn display_id(record: &SessionRecord, privacy: PrivacyMode) -> String {
    if !privacy.enabled() {
        return record.summary.id.clone();
    }

    let id = &record.summary.id;
    if id.len() <= 10 {
        return "redacted".to_string();
    }
    format!("{}...{}", &id[..4], &id[id.len() - 4..])
}

pub fn display_cwd(record: &SessionRecord, privacy: PrivacyMode) -> String {
    if privacy.enabled() {
        last_path_component(&record.summary.cwd).unwrap_or_else(|| "redacted".to_string())
    } else {
        record.summary.cwd.clone()
    }
}

pub fn display_rollout(record: &SessionRecord, privacy: PrivacyMode) -> String {
    if privacy.enabled() {
        "hidden in privacy mode".to_string()
    } else {
        record.summary.rollout_path.display().to_string()
    }
}

fn last_path_component(path: &str) -> Option<String> {
    std::path::Path::new(path)
        .file_name()
        .map(|name| name.to_string_lossy().to_string())
        .filter(|name| !name.is_empty())
}

pub fn filter_records<'a>(
    records: &'a [SessionRecord],
    range: DateRange,
    query: &str,
    now: DateTime<Utc>,
) -> Vec<&'a SessionRecord> {
    let query = query.trim().to_lowercase();
    records
        .iter()
        .filter(|record| record_in_range(record.summary.updated_at, range, now))
        .filter(|record| {
            if query.is_empty() {
                return true;
            }
            let haystack = format!(
                "{} {} {} {}",
                record.summary.title,
                record.summary.cwd,
                record.summary.model.as_deref().unwrap_or(""),
                record.summary.id
            )
            .to_lowercase();
            haystack.contains(&query)
        })
        .collect()
}

fn record_in_range(updated_at: i64, range: DateRange, now: DateTime<Utc>) -> bool {
    match range {
        DateRange::All => true,
        DateRange::Today => {
            let local_now = now.with_timezone(&Local);
            let start = Local
                .with_ymd_and_hms(
                    local_now.year(),
                    local_now.month(),
                    local_now.day(),
                    0,
                    0,
                    0,
                )
                .single()
                .unwrap_or(local_now);
            updated_at >= start.timestamp()
        }
        DateRange::Week => updated_at >= (now - Duration::days(7)).timestamp(),
    }
}