use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
const SUPPORTED_SCHEMA_VERSION: u32 = 1;
const CACHE_TTL_SECS: u64 = 3600;
const CACHE_FILE_NAME: &str = "remote-stats-cache.json";
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RemoteDayStat {
pub date: String,
pub tokens: u64,
#[serde(default)]
pub input_tokens: u64,
#[serde(default)]
pub output_tokens: u64,
pub cost: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RemoteDeviceStat {
pub id: String,
#[serde(default)]
pub display_name: Option<String>,
#[serde(default)]
pub last_submitted_at: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RemoteStats {
pub schema_version: u32,
pub total_tokens: u64,
pub total_cost: f64,
pub device_count: u64,
#[serde(default)]
pub last_submitted_at: Option<String>,
#[serde(default)]
pub days: Vec<RemoteDayStat>,
#[serde(default)]
pub devices: Vec<RemoteDeviceStat>,
#[serde(default)]
pub fetched_at_secs: u64,
#[serde(default)]
pub cached_for_user: String,
#[serde(default)]
pub cached_for_api_url: String,
}
impl RemoteStats {
pub fn is_stale(&self) -> bool {
self.fetched_at_secs.saturating_add(CACHE_TTL_SECS) <= now_secs()
}
}
#[cfg_attr(test, allow(dead_code))]
pub fn fetch_remote_stats(token: &str, username: &str, api_base_url: &str) -> Result<RemoteStats> {
let url = format!("{}/api/me/stats", api_base_url.trim_end_matches('/'));
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
let mut stats: RemoteStats = rt.block_on(async {
let response = reqwest::Client::new()
.get(url)
.header("Authorization", format!("Bearer {}", token))
.send()
.await
.context("Failed to fetch remote stats")?;
if !response.status().is_success() {
anyhow::bail!("Remote stats request failed with {}", response.status());
}
response
.json::<RemoteStats>()
.await
.context("Failed to parse remote stats response")
})?;
if stats.schema_version != SUPPORTED_SCHEMA_VERSION {
anyhow::bail!(
"Unsupported remote stats schema version {}",
stats.schema_version
);
}
stats.fetched_at_secs = now_secs();
stats.cached_for_user = username.to_string();
stats.cached_for_api_url = api_base_url.to_string();
let _ = save_remote_stats_cache(&stats);
Ok(stats)
}
pub fn load_cached_remote_stats(
expected_user: &str,
expected_api_url: &str,
) -> Option<RemoteStats> {
if expected_user.is_empty() {
return None;
}
let cache_path = get_cache_path()?;
let content = fs::read_to_string(cache_path).ok()?;
let stats: RemoteStats = serde_json::from_str(&content).ok()?;
if stats.schema_version != SUPPORTED_SCHEMA_VERSION {
return None;
}
if stats.fetched_at_secs.saturating_add(CACHE_TTL_SECS) <= now_secs() {
return None;
}
if stats.cached_for_user.is_empty() || stats.cached_for_user != expected_user {
return None;
}
let cached_url = stats.cached_for_api_url.trim_end_matches('/');
if cached_url.is_empty() || cached_url != expected_api_url.trim_end_matches('/') {
return None;
}
Some(stats)
}
fn save_remote_stats_cache(stats: &RemoteStats) -> Result<()> {
let cache_path = get_cache_path().context("Could not resolve remote stats cache directory")?;
let json = serde_json::to_string(stats).context("Failed to serialize remote stats cache")?;
let temp_path = cache_path.with_extension("json.tmp");
fs::write(&temp_path, json).context("Failed to write remote stats temp cache file")?;
if tokmesh_core::fs_atomic::replace_file(&temp_path, &cache_path).is_err() {
let _ = fs::remove_file(&temp_path);
anyhow::bail!("Failed to move remote stats cache into place");
}
Ok(())
}
fn get_cache_path() -> Option<PathBuf> {
let dir = crate::paths::get_cache_dir();
if fs::create_dir_all(&dir).is_err() {
return None;
}
Some(dir.join(CACHE_FILE_NAME))
}
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
use std::env;
fn sample_stats(fetched_at_secs: u64) -> RemoteStats {
RemoteStats {
schema_version: SUPPORTED_SCHEMA_VERSION,
total_tokens: 1250,
total_cost: 1.75,
device_count: 2,
last_submitted_at: Some("2026-06-02T12:00:00.000Z".to_string()),
days: vec![RemoteDayStat {
date: "2026-06-01".to_string(),
tokens: 1000,
input_tokens: 600,
output_tokens: 400,
cost: 1.5,
}],
devices: vec![RemoteDeviceStat {
id: "device-1".to_string(),
display_name: Some("Work laptop".to_string()),
last_submitted_at: Some("2026-06-02T12:00:00.000Z".to_string()),
}],
fetched_at_secs,
cached_for_user: "alice".to_string(),
cached_for_api_url: "https://example.invalid".to_string(),
}
}
fn with_temp_config_dir(test: impl FnOnce()) {
let temp = tempfile::tempdir().expect("tempdir");
let prev = env::var_os("TOKMESH_CONFIG_DIR");
unsafe {
env::set_var("TOKMESH_CONFIG_DIR", temp.path());
}
test();
unsafe {
match prev {
Some(v) => env::set_var("TOKMESH_CONFIG_DIR", v),
None => env::remove_var("TOKMESH_CONFIG_DIR"),
}
}
}
#[test]
#[serial]
fn cache_round_trips_for_matching_account_and_server() {
with_temp_config_dir(|| {
let stats = sample_stats(now_secs());
save_remote_stats_cache(&stats).expect("save cache");
let loaded = load_cached_remote_stats("alice", "https://example.invalid")
.expect("fresh cache should load");
assert_eq!(loaded.total_tokens, 1250);
assert_eq!(loaded.device_count, 2);
assert_eq!(loaded.days.len(), 1);
assert_eq!(
loaded.devices[0].display_name.as_deref(),
Some("Work laptop")
);
});
}
#[test]
#[serial]
fn cache_normalizes_trailing_slash_in_api_url() {
with_temp_config_dir(|| {
save_remote_stats_cache(&sample_stats(now_secs())).expect("save cache");
assert!(load_cached_remote_stats("alice", "https://example.invalid/").is_some());
});
}
#[test]
#[serial]
fn cache_rejects_stale_entries() {
with_temp_config_dir(|| {
let stats = sample_stats(now_secs().saturating_sub(CACHE_TTL_SECS + 1));
save_remote_stats_cache(&stats).expect("save cache");
assert!(load_cached_remote_stats("alice", "https://example.invalid").is_none());
});
}
#[test]
#[serial]
fn cache_rejects_other_accounts_servers_and_anonymous_lookups() {
with_temp_config_dir(|| {
save_remote_stats_cache(&sample_stats(now_secs())).expect("save cache");
assert!(load_cached_remote_stats("bob", "https://example.invalid").is_none());
assert!(load_cached_remote_stats("alice", "https://other.example.invalid").is_none());
assert!(load_cached_remote_stats("", "https://example.invalid").is_none());
});
}
#[test]
#[serial]
fn cache_rejects_unknown_schema_versions() {
with_temp_config_dir(|| {
let mut stats = sample_stats(now_secs());
stats.schema_version = SUPPORTED_SCHEMA_VERSION + 1;
save_remote_stats_cache(&stats).expect("save cache");
assert!(load_cached_remote_stats("alice", "https://example.invalid").is_none());
});
}
#[test]
fn staleness_follows_ttl() {
assert!(!sample_stats(now_secs()).is_stale());
assert!(sample_stats(now_secs().saturating_sub(CACHE_TTL_SECS + 1)).is_stale());
}
}