limitdeck 0.1.8

A privacy-safe terminal dashboard for AI subscription limits
use std::{
    env, fs,
    io::{self, Read},
    path::{Path, PathBuf},
    time::{Duration, SystemTime, UNIX_EPOCH},
};

use serde::{Deserialize, Serialize};

use crate::{
    adapter::{AdapterError, AdapterErrorKind, PlanAdapter},
    domain::{CodingPlan, PlanIdentity, UsageStatus, UsageWindow},
};

const MAX_INPUT_BYTES: usize = 1024 * 1024;
const SOURCE: &str = "Claude statusline";

fn adapter_error(kind: AdapterErrorKind) -> AdapterError {
    AdapterError::new(kind, SOURCE)
}

fn protocol_error() -> AdapterError {
    adapter_error(AdapterErrorKind::ProtocolChanged)
}

fn snapshot_open_error(error: &io::Error) -> AdapterError {
    let kind = if error.kind() == io::ErrorKind::NotFound {
        AdapterErrorKind::SnapshotMissing
    } else {
        AdapterErrorKind::ProtocolChanged
    };
    adapter_error(kind)
}

pub struct ClaudeStatuslineAdapter {
    cache_path: PathBuf,
}

impl ClaudeStatuslineAdapter {
    pub fn discover() -> Option<Self> {
        let cache_path = cache_path()?;
        cache_path.is_file().then_some(Self { cache_path })
    }

    #[cfg(test)]
    fn at_path(cache_path: PathBuf) -> Self {
        Self { cache_path }
    }
}

impl PlanAdapter for ClaudeStatuslineAdapter {
    fn identity(&self) -> PlanIdentity {
        PlanIdentity::new("anthropic-claude", "anthropic", "Claude")
    }

    fn fetch(&self) -> Result<CodingPlan, AdapterError> {
        read_cache(&self.cache_path)
    }
}

pub fn ingest_stdin() -> Result<String, AdapterError> {
    let mut input = Vec::new();
    io::stdin()
        .take((MAX_INPUT_BYTES + 1) as u64)
        .read_to_end(&mut input)
        .map_err(|_| protocol_error())?;
    if input.len() > MAX_INPUT_BYTES {
        return Err(protocol_error());
    }
    let plan = parse_statusline(&input, SystemTime::now())?;
    let path = cache_path().ok_or_else(|| adapter_error(AdapterErrorKind::SnapshotMissing))?;
    write_cache(&path, &plan)?;
    Ok(compact_statusline(&plan))
}

#[derive(Deserialize)]
struct StatuslineInput {
    rate_limits: Option<RateLimits>,
}

#[derive(Deserialize)]
struct RateLimits {
    five_hour: Option<RateLimitWindow>,
    seven_day: Option<RateLimitWindow>,
    spend_limit: Option<RateLimitWindow>,
}

#[derive(Deserialize)]
struct RateLimitWindow {
    used_percentage: f64,
    resets_at: u64,
}

fn parse_statusline(input: &[u8], fetched_at: SystemTime) -> Result<CodingPlan, AdapterError> {
    let input: StatuslineInput = serde_json::from_slice(input).map_err(|_| protocol_error())?;
    let limits = input
        .rate_limits
        .ok_or_else(|| adapter_error(AdapterErrorKind::NotAuthenticated))?;
    let mut windows = Vec::with_capacity(4);
    push_window(
        &mut windows,
        "anthropic:five-hour",
        "Claude · 5 hours",
        Some(Duration::from_secs(5 * 60 * 60)),
        limits.five_hour,
    );
    push_window(
        &mut windows,
        "anthropic:seven-day",
        "Claude · 7 days",
        Some(Duration::from_secs(7 * 24 * 60 * 60)),
        limits.seven_day,
    );
    push_window(
        &mut windows,
        "anthropic:spend-limit",
        "Claude · Spend limit",
        None,
        limits.spend_limit,
    );
    if windows.is_empty() {
        return Err(protocol_error());
    }

    Ok(CodingPlan {
        id: "anthropic-claude".to_owned(),
        provider_id: "anthropic".to_owned(),
        display_name: "Claude".to_owned(),
        fetched_at,
        windows,
    })
}

fn push_window(
    windows: &mut Vec<UsageWindow>,
    id: &str,
    label: &str,
    period: Option<Duration>,
    source: Option<RateLimitWindow>,
) {
    let Some(source) = source else {
        return;
    };
    if !source.used_percentage.is_finite() {
        return;
    }
    let used = source.used_percentage.clamp(0.0, 100.0).round() as u8;
    windows.push(UsageWindow {
        id: id.to_owned(),
        label: label.to_owned(),
        period,
        remaining_percent: 100u8.saturating_sub(used),
        resets_at: UNIX_EPOCH.checked_add(Duration::from_secs(source.resets_at)),
        status: UsageStatus::Available,
    });
}

#[derive(Serialize, Deserialize)]
struct CachedPlan {
    fetched_at_millis: u64,
    windows: Vec<CachedWindow>,
}

#[derive(Serialize, Deserialize)]
struct CachedWindow {
    id: String,
    label: String,
    period_seconds: Option<u64>,
    remaining_percent: u8,
    resets_at_millis: Option<u64>,
    available: bool,
}

fn write_cache(path: &Path, plan: &CodingPlan) -> Result<(), AdapterError> {
    let parent = path.parent().ok_or_else(protocol_error)?;
    fs::create_dir_all(parent).map_err(|_| protocol_error())?;
    let cached = CachedPlan {
        fetched_at_millis: system_time_millis(plan.fetched_at)?,
        windows: plan
            .windows
            .iter()
            .map(|window| {
                Ok(CachedWindow {
                    id: window.id.clone(),
                    label: window.label.clone(),
                    period_seconds: window.period.map(|period| period.as_secs()),
                    remaining_percent: window.remaining_percent,
                    resets_at_millis: window.resets_at.map(system_time_millis).transpose()?,
                    available: window.status == UsageStatus::Available,
                })
            })
            .collect::<Result<Vec<_>, AdapterError>>()?,
    };
    let encoded = serde_json::to_vec(&cached).map_err(|_| protocol_error())?;
    let temporary = path.with_extension(format!("tmp-{}", std::process::id()));
    fs::write(&temporary, encoded).map_err(|_| protocol_error())?;
    fs::rename(&temporary, path).map_err(|_| {
        let _ = fs::remove_file(&temporary);
        protocol_error()
    })
}

fn read_cache(path: &Path) -> Result<CodingPlan, AdapterError> {
    let mut encoded = Vec::new();
    fs::File::open(path)
        .map_err(|error| snapshot_open_error(&error))?
        .take((MAX_INPUT_BYTES + 1) as u64)
        .read_to_end(&mut encoded)
        .map_err(|_| protocol_error())?;
    if encoded.len() > MAX_INPUT_BYTES {
        return Err(protocol_error());
    }
    let cached: CachedPlan = serde_json::from_slice(&encoded).map_err(|_| protocol_error())?;
    if cached.windows.is_empty() {
        return Err(protocol_error());
    }
    Ok(CodingPlan {
        id: "anthropic-claude".to_owned(),
        provider_id: "anthropic".to_owned(),
        display_name: "Claude".to_owned(),
        fetched_at: timestamp_millis(cached.fetched_at_millis)?,
        windows: cached
            .windows
            .into_iter()
            .map(|window| UsageWindow {
                id: window.id,
                label: window.label,
                period: window.period_seconds.map(Duration::from_secs),
                remaining_percent: window.remaining_percent.min(100),
                resets_at: window
                    .resets_at_millis
                    .and_then(|value| timestamp_millis(value).ok()),
                status: if window.available {
                    UsageStatus::Available
                } else {
                    UsageStatus::Unavailable
                },
            })
            .collect(),
    })
}

fn compact_statusline(plan: &CodingPlan) -> String {
    let values = plan
        .windows
        .iter()
        .map(|window| {
            format!(
                "{} {}%",
                period_label(window.period),
                window.remaining_percent
            )
        })
        .collect::<Vec<_>>()
        .join(" · ");
    format!("Claude {values}")
}

fn period_label(period: Option<Duration>) -> String {
    let Some(period) = period else {
        return "quota".to_owned();
    };
    let hours = period.as_secs() / 3600;
    if hours >= 24 && hours % 24 == 0 {
        format!("{}d", hours / 24)
    } else {
        format!("{hours}h")
    }
}

fn cache_path() -> Option<PathBuf> {
    if let Some(directory) = env::var_os("XDG_CACHE_HOME") {
        return Some(PathBuf::from(directory).join("limitdeck/claude.json"));
    }
    env::var_os("HOME")
        .map(PathBuf::from)
        .map(|home| home.join(".cache/limitdeck/claude.json"))
}

fn system_time_millis(value: SystemTime) -> Result<u64, AdapterError> {
    let millis = value
        .duration_since(UNIX_EPOCH)
        .map_err(|_| protocol_error())?
        .as_millis();
    u64::try_from(millis).map_err(|_| protocol_error())
}

fn timestamp_millis(milliseconds: u64) -> Result<SystemTime, AdapterError> {
    UNIX_EPOCH
        .checked_add(Duration::from_millis(milliseconds))
        .ok_or_else(protocol_error)
}

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

    const STATUSLINE_FIXTURE: &[u8] = br#"{
        "session_id":"secret-session",
        "account":{"email":"private@example.com"},
        "rate_limits":{
            "five_hour":{"used_percentage":29.4,"resets_at":2000000000},
            "seven_day":{"used_percentage":48.6,"resets_at":2000500000},
            "spend_limit":{"used_percentage":62.8,"resets_at":2001000000}
        }
    }"#;

    #[test]
    fn statusline_parser_keeps_only_rate_limit_windows() {
        let plan = parse_statusline(STATUSLINE_FIXTURE, UNIX_EPOCH + Duration::from_secs(10))
            .expect("statusline fixture should parse");

        assert_eq!(plan.windows.len(), 3);
        assert_eq!(plan.windows[0].remaining_percent, 71);
        assert_eq!(plan.windows[1].remaining_percent, 51);
        assert_eq!(plan.windows[2].remaining_percent, 37);
        assert_eq!(plan.windows[2].period, None);
        let retained = format!("{plan:?}");
        assert!(!retained.contains("secret-session"));
        assert!(!retained.contains("private@example.com"));
    }
    #[test]
    fn cache_reader_rejects_oversized_files() {
        let unique = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let path = env::temp_dir().join(format!("limitdeck-oversized-{unique}.json"));
        fs::write(&path, vec![b'x'; MAX_INPUT_BYTES + 1]).unwrap();

        assert_eq!(read_cache(&path), Err(protocol_error()));
        let _ = fs::remove_file(path);
    }

    #[test]
    fn cache_round_trip_is_a_real_adapter_input() {
        let unique = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let directory = env::temp_dir().join(format!("limitdeck-{unique}"));
        let path = directory.join("claude.json");
        let plan = parse_statusline(STATUSLINE_FIXTURE, UNIX_EPOCH + Duration::from_secs(10))
            .expect("statusline fixture should parse");
        write_cache(&path, &plan).expect("cache should write atomically");

        let loaded = ClaudeStatuslineAdapter::at_path(path.clone())
            .fetch()
            .expect("adapter should load the cached snapshot");

        assert_eq!(loaded, plan);
        let _ = fs::remove_dir_all(directory);
    }

    #[test]
    fn missing_rate_limits_means_claude_is_not_authenticated() {
        let error = parse_statusline(br#"{"session_id":"secret"}"#, SystemTime::now())
            .expect_err("statusline without rate limits must not look healthy");

        assert_eq!(error.kind, AdapterErrorKind::NotAuthenticated);
        assert!(!format!("{error:?}").contains("secret"));
    }
}