limitdeck 0.1.6

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

use serde::{Deserialize, Serialize};

use crate::domain::{CodingPlan, UsageStatus};

const FORMAT_VERSION: u8 = 1;
const MAX_INPUT_BYTES: usize = 1024 * 1024;
const MAX_SAMPLES_PER_SERIES: usize = 2048;
const MIN_UNCHANGED_INTERVAL: Duration = Duration::from_secs(15 * 60);
const RETENTION: Duration = Duration::from_secs(30 * 24 * 60 * 60);

#[derive(Debug, Deserialize)]
struct HistoryFile {
    version: u8,
    series: Vec<HistorySeries>,
}

#[derive(Serialize)]
struct HistoryFileRef<'a> {
    version: u8,
    series: &'a [HistorySeries],
}

#[derive(Clone, Debug, Deserialize, Serialize)]
struct HistorySeries {
    provider_id: String,
    plan_id: String,
    window_id: String,
    samples: Vec<HistorySample>,
}

#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub(crate) struct HistorySample {
    pub(crate) at_millis: u64,
    pub(crate) remaining_percent: u8,
}

#[derive(Debug, Default)]
pub(crate) struct UsageHistory {
    path: Option<PathBuf>,
    series: Vec<HistorySeries>,
}

impl UsageHistory {
    pub(crate) fn load_default() -> Self {
        let Some(path) = default_path() else {
            return Self::default();
        };
        let series = read_history(&path).unwrap_or_default();
        Self {
            path: Some(path),
            series,
        }
    }

    #[cfg(test)]
    pub(crate) fn empty() -> Self {
        Self::default()
    }

    pub(crate) fn record(&mut self, plan: &CodingPlan) -> io::Result<bool> {
        let at_millis = system_time_millis(plan.fetched_at)?;
        let cutoff = at_millis.saturating_sub(RETENTION.as_millis() as u64);
        let mut changed = false;

        self.series.retain_mut(|series| {
            let before = series.samples.len();
            series.samples.retain(|sample| sample.at_millis >= cutoff);
            changed |= series.samples.len() != before;
            !series.samples.is_empty()
        });

        for window in plan
            .windows
            .iter()
            .filter(|window| window.status == UsageStatus::Available)
        {
            let series = if let Some(series) = self.series.iter_mut().find(|series| {
                series.provider_id == plan.provider_id
                    && series.plan_id == plan.id
                    && series.window_id == window.id
            }) {
                series
            } else {
                self.series.push(HistorySeries {
                    provider_id: plan.provider_id.clone(),
                    plan_id: plan.id.clone(),
                    window_id: window.id.clone(),
                    samples: Vec::new(),
                });
                changed = true;
                self.series.last_mut().expect("history series was inserted")
            };

            let has_bootstrap_samples = series.samples.len() >= 2;
            if let Some(last) = series.samples.last_mut() {
                if at_millis < last.at_millis {
                    continue;
                }
                if at_millis == last.at_millis {
                    if last.remaining_percent != window.remaining_percent {
                        last.remaining_percent = window.remaining_percent;
                        changed = true;
                    }
                    continue;
                }
                if has_bootstrap_samples
                    && last.remaining_percent == window.remaining_percent
                    && at_millis - last.at_millis < MIN_UNCHANGED_INTERVAL.as_millis() as u64
                {
                    continue;
                }
            }

            series.samples.push(HistorySample {
                at_millis,
                remaining_percent: window.remaining_percent.min(100),
            });
            if series.samples.len() > MAX_SAMPLES_PER_SERIES {
                let excess = series.samples.len() - MAX_SAMPLES_PER_SERIES;
                series.samples.drain(..excess);
            }
            changed = true;
        }

        if changed {
            self.save()?;
        }
        Ok(changed)
    }

    pub(crate) fn samples(
        &self,
        provider_id: &str,
        plan_id: &str,
        window_id: &str,
    ) -> &[HistorySample] {
        self.series
            .iter()
            .find(|series| {
                series.provider_id == provider_id
                    && series.plan_id == plan_id
                    && series.window_id == window_id
            })
            .map_or(&[], |series| series.samples.as_slice())
    }

    fn save(&self) -> io::Result<()> {
        let Some(path) = &self.path else {
            return Ok(());
        };
        let parent = path.parent().ok_or_else(|| {
            io::Error::new(io::ErrorKind::InvalidInput, "history path has no parent")
        })?;
        fs::create_dir_all(parent)?;
        let encoded = serde_json::to_vec(&HistoryFileRef {
            version: FORMAT_VERSION,
            series: &self.series,
        })?;
        if encoded.len() > MAX_INPUT_BYTES {
            return Err(io::Error::new(
                io::ErrorKind::InvalidData,
                "history exceeds size limit",
            ));
        }
        let temporary = path.with_extension(format!("tmp-{}", std::process::id()));
        fs::write(&temporary, encoded)?;
        fs::rename(&temporary, path).inspect_err(|_| {
            let _ = fs::remove_file(&temporary);
        })
    }
}

fn read_history(path: &Path) -> io::Result<Vec<HistorySeries>> {
    if !path.is_file() {
        return Ok(Vec::new());
    }
    let mut encoded = Vec::new();
    File::open(path)?
        .take((MAX_INPUT_BYTES + 1) as u64)
        .read_to_end(&mut encoded)?;
    if encoded.len() > MAX_INPUT_BYTES {
        return Err(io::Error::new(
            io::ErrorKind::InvalidData,
            "history exceeds size limit",
        ));
    }
    let mut history: HistoryFile = serde_json::from_slice(&encoded)?;
    if history.version != FORMAT_VERSION {
        return Err(io::Error::new(
            io::ErrorKind::InvalidData,
            "unsupported history version",
        ));
    }
    history.series.retain_mut(|series| {
        series
            .samples
            .retain(|sample| sample.remaining_percent <= 100);
        if series.samples.len() > MAX_SAMPLES_PER_SERIES {
            let excess = series.samples.len() - MAX_SAMPLES_PER_SERIES;
            series.samples.drain(..excess);
        }
        !series.provider_id.is_empty()
            && !series.plan_id.is_empty()
            && !series.window_id.is_empty()
            && !series.samples.is_empty()
    });
    Ok(history.series)
}

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

fn system_time_millis(value: SystemTime) -> io::Result<u64> {
    value
        .duration_since(UNIX_EPOCH)
        .map(|duration| duration.as_millis() as u64)
        .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "time predates Unix epoch"))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::domain::{UsageStatus, UsageWindow};

    fn plan(at_millis: u64, remaining_percent: u8) -> CodingPlan {
        CodingPlan {
            id: "openai-codex".to_owned(),
            provider_id: "openai".to_owned(),
            display_name: "private@example.com".to_owned(),
            fetched_at: UNIX_EPOCH + Duration::from_millis(at_millis),
            windows: vec![UsageWindow {
                id: "weekly".to_owned(),
                label: "7 day".to_owned(),
                period: Some(Duration::from_secs(7 * 24 * 60 * 60)),
                remaining_percent,
                resets_at: None,
                status: UsageStatus::Available,
            }],
        }
    }

    fn temporary_path(name: &str) -> PathBuf {
        env::temp_dir().join(format!(
            "limitdeck-history-{name}-{}-{}.json",
            std::process::id(),
            SystemTime::now()
                .duration_since(UNIX_EPOCH)
                .unwrap()
                .as_nanos()
        ))
    }

    #[test]
    fn records_real_samples_and_bootstraps_a_flat_trend() {
        let mut history = UsageHistory::empty();
        assert!(history.record(&plan(1_000, 61)).unwrap());
        assert!(history.record(&plan(2_000, 61)).unwrap());
        assert!(!history.record(&plan(3_000, 61)).unwrap());
        assert!(history.record(&plan(4_000, 60)).unwrap());

        let samples = history.samples("openai", "openai-codex", "weekly");
        assert_eq!(samples.len(), 3);
        assert_eq!(samples[0].remaining_percent, 61);
        assert_eq!(samples[2].remaining_percent, 60);
    }

    #[test]
    fn persisted_history_contains_only_quota_identifiers_and_values() {
        let path = temporary_path("privacy");
        let mut history = UsageHistory {
            path: Some(path.clone()),
            series: Vec::new(),
        };
        history.record(&plan(1_000, 61)).unwrap();

        let encoded = fs::read_to_string(&path).unwrap();
        assert!(encoded.contains("openai-codex"));
        assert!(encoded.contains("remaining_percent"));
        assert!(!encoded.contains("private@example.com"));
        assert!(!encoded.contains("7 day"));

        let loaded = read_history(&path).unwrap();
        assert_eq!(loaded.len(), 1);
        assert_eq!(loaded[0].samples[0].remaining_percent, 61);
        fs::remove_file(path).unwrap();
    }

    #[test]
    fn out_of_range_samples_are_discarded_on_load() {
        let path = temporary_path("invalid");
        fs::write(
            &path,
            r#"{"version":1,"series":[{"provider_id":"openai","plan_id":"codex","window_id":"weekly","samples":[{"at_millis":1,"remaining_percent":255}]}]}"#,
        )
        .unwrap();
        assert!(read_history(&path).unwrap().is_empty());
        fs::remove_file(path).unwrap();
    }
}