use std::path::Path;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use crate::usage_limits::UsageLimitsData;
const PASSTHROUGH_TTL: Duration = Duration::from_secs(5);
fn passthrough_cache_path(module_name: &str, transcript_path: &Path) -> Option<std::path::PathBuf> {
let dir = transcript_path.parent()?;
let stem = transcript_path.file_stem()?.to_str()?;
let safe_name = module_name.replace(['/', ' '], "_");
Some(
dir.join("cship")
.join(format!("{stem}-starship-{safe_name}")),
)
}
pub fn read_passthrough(module_name: &str, transcript_path: &Path) -> Option<String> {
let path = passthrough_cache_path(module_name, transcript_path)?;
let metadata = std::fs::metadata(&path).ok()?;
let modified = metadata.modified().ok()?;
let age = SystemTime::now().duration_since(modified).ok()?;
if age >= PASSTHROUGH_TTL {
return None; }
std::fs::read_to_string(&path).ok()
}
pub fn write_passthrough(module_name: &str, transcript_path: &Path, content: &str) {
if let Some(path) = passthrough_cache_path(module_name, transcript_path) {
if let Some(dir) = path.parent() {
let _ = std::fs::create_dir_all(dir);
}
let _ = std::fs::write(path, content);
}
}
#[derive(serde::Serialize, serde::Deserialize)]
struct UsageLimitsCacheEnvelope {
data: UsageLimitsData,
expires_at: u64,
five_hour_resets_at: u64,
seven_day_resets_at: u64,
}
fn usage_limits_cache_path(transcript_path: &Path) -> Option<std::path::PathBuf> {
let dir = transcript_path.parent()?;
let stem = transcript_path.file_stem()?.to_str()?;
Some(dir.join("cship").join(format!("{stem}-usage-limits")))
}
pub(crate) fn iso8601_to_epoch(s: &str) -> Option<u64> {
let s = s
.strip_suffix('Z')
.or_else(|| s.strip_suffix("+00:00"))
.unwrap_or(s);
let (date_s, time_s) = s.split_once('T')?;
let mut dp = date_s.split('-');
let year: i64 = dp.next()?.parse().ok()?;
let month: i64 = dp.next()?.parse().ok()?;
let day: i64 = dp.next()?.parse().ok()?;
let mut tp = time_s.split(':');
let hour: i64 = tp.next()?.parse().ok()?;
let min: i64 = tp.next()?.parse().ok()?;
let sec: i64 = tp.next()?.split('.').next()?.parse().ok()?;
let y = if month <= 2 { year - 1 } else { year };
let era = y.div_euclid(400);
let yoe = y - era * 400;
let doy = (153 * (month + if month > 2 { -3 } else { 9 }) + 2) / 5 + day - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
let days = era * 146097 + doe - 719468;
let total = days * 86400 + hour * 3600 + min * 60 + sec;
u64::try_from(total).ok()
}
fn now_epoch() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn epoch_or_never(s: &str) -> u64 {
if s.is_empty() {
return u64::MAX;
}
iso8601_to_epoch(s).filter(|&e| e > 0).unwrap_or(u64::MAX)
}
pub fn read_usage_limits(transcript_path: &Path, allow_stale: bool) -> Option<UsageLimitsData> {
let path = usage_limits_cache_path(transcript_path)?;
let raw = std::fs::read_to_string(&path).ok()?;
let envelope: UsageLimitsCacheEnvelope = serde_json::from_str(&raw).ok()?;
if allow_stale {
return Some(envelope.data);
}
let now = now_epoch();
if now >= envelope.expires_at {
return None; }
if now >= envelope.five_hour_resets_at || now >= envelope.seven_day_resets_at {
return None; }
Some(envelope.data)
}
pub fn write_usage_limits(transcript_path: &Path, data: &UsageLimitsData, ttl_secs: u64) {
let Some(path) = usage_limits_cache_path(transcript_path) else {
return;
};
if let Some(dir) = path.parent() {
let _ = std::fs::create_dir_all(dir);
}
let now = now_epoch();
let envelope = UsageLimitsCacheEnvelope {
data: data.clone(),
expires_at: now + ttl_secs,
five_hour_resets_at: epoch_or_never(&data.five_hour_resets_at),
seven_day_resets_at: epoch_or_never(&data.seven_day_resets_at),
};
if let Ok(json) = serde_json::to_string(&envelope) {
let _ = std::fs::write(path, json);
}
}
fn negative_marker_path(transcript_path: &Path) -> Option<std::path::PathBuf> {
let dir = transcript_path.parent()?;
let stem = transcript_path.file_stem()?.to_str()?;
Some(dir.join("cship").join(format!("{stem}-usage-limits-fail")))
}
pub fn read_negative_marker(transcript_path: &Path) -> bool {
let Some(path) = negative_marker_path(transcript_path) else {
return false;
};
let Ok(raw) = std::fs::read_to_string(&path) else {
return false;
};
let Ok(expires_at) = raw.trim().parse::<u64>() else {
return false;
};
now_epoch() < expires_at
}
pub fn write_negative_marker(transcript_path: &Path, cooldown_secs: u64) {
let Some(path) = negative_marker_path(transcript_path) else {
return;
};
if let Some(dir) = path.parent() {
let _ = std::fs::create_dir_all(dir);
}
let _ = std::fs::write(path, (now_epoch() + cooldown_secs).to_string());
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn temp_transcript(subdir: &str) -> (tempfile::TempDir, PathBuf) {
let dir = tempfile::tempdir().expect("tempdir");
let transcript = dir.path().join(subdir).join("test_transcript.jsonl");
(dir, transcript)
}
#[test]
fn test_cache_miss_returns_none_for_nonexistent_file() {
let (_dir, transcript) = temp_transcript("session1");
let result = read_passthrough("git_branch", &transcript);
assert!(result.is_none());
}
#[test]
fn test_cache_hit_returns_content_within_ttl() {
let (dir, transcript) = temp_transcript("session2");
write_passthrough("git_branch", &transcript, "main");
let result = read_passthrough("git_branch", &transcript);
assert_eq!(result, Some("main".to_string()));
drop(dir);
}
#[test]
fn test_write_creates_directory_if_missing() {
let dir = tempfile::tempdir().expect("tempdir");
let transcript = dir
.path()
.join("deep")
.join("nested")
.join("transcript.jsonl");
write_passthrough("directory", &transcript, "/home/user");
let cache_file = dir
.path()
.join("deep")
.join("nested")
.join("cship")
.join("transcript-starship-directory");
assert!(cache_file.exists(), "cache file should have been created");
let content = std::fs::read_to_string(&cache_file).unwrap();
assert_eq!(content, "/home/user");
}
#[test]
fn test_path_derivation() {
let dir = tempfile::tempdir().expect("tempdir");
let transcript = dir.path().join("transcript.jsonl");
write_passthrough("git_branch", &transcript, "main");
let expected = dir
.path()
.join("cship")
.join("transcript-starship-git_branch");
assert!(
expected.exists(),
"cache file at expected path: {expected:?}"
);
}
#[test]
fn test_module_name_sanitization() {
let dir = tempfile::tempdir().expect("tempdir");
let transcript = dir.path().join("transcript.jsonl");
write_passthrough("node/js lang", &transcript, "v20");
let expected = dir
.path()
.join("cship")
.join("transcript-starship-node_js_lang");
assert!(
expected.exists(),
"sanitized path should exist: {expected:?}"
);
let content = std::fs::read_to_string(&expected).unwrap();
assert_eq!(content, "v20");
}
#[test]
fn test_stale_cache_returns_none() {
use std::time::{Duration, SystemTime};
let dir = tempfile::tempdir().expect("tempdir");
let transcript = dir.path().join("transcript.jsonl");
write_passthrough("git_branch", &transcript, "main");
let cache_file = dir
.path()
.join("cship")
.join("transcript-starship-git_branch");
let stale_time = SystemTime::now() - Duration::from_secs(10);
filetime::set_file_mtime(
&cache_file,
filetime::FileTime::from_system_time(stale_time),
)
.expect("set mtime");
let result = read_passthrough("git_branch", &transcript);
assert!(result.is_none(), "stale cache should return None");
}
fn sample_data() -> UsageLimitsData {
UsageLimitsData {
five_hour_pct: 23.4,
seven_day_pct: 45.1,
five_hour_resets_at: "2099-01-01T00:00:00Z".into(),
seven_day_resets_at: "2099-01-01T00:00:00Z".into(),
..Default::default()
}
}
#[test]
fn test_usage_limits_cache_hit_within_ttl() {
let (dir, transcript) = temp_transcript("s5_2_hit");
write_usage_limits(&transcript, &sample_data(), 60);
let result = read_usage_limits(&transcript, false);
assert!(result.is_some(), "fresh cache should return Some");
let data = result.unwrap();
assert!((data.five_hour_pct - 23.4).abs() < f64::EPSILON);
assert!((data.seven_day_pct - 45.1).abs() < f64::EPSILON);
drop(dir);
}
#[test]
fn test_usage_limits_cache_miss_nonexistent_file() {
let (_dir, transcript) = temp_transcript("s5_2_miss");
let result = read_usage_limits(&transcript, false);
assert!(
result.is_none(),
"nonexistent cache file should return None"
);
}
#[test]
fn test_usage_limits_cache_file_path_and_json_structure() {
let dir = tempfile::tempdir().expect("tempdir");
let transcript = dir.path().join("transcript.jsonl");
write_usage_limits(&transcript, &sample_data(), 60);
let expected_path = dir.path().join("cship").join("transcript-usage-limits");
assert!(expected_path.exists(), "cache file at: {expected_path:?}");
let raw = std::fs::read_to_string(&expected_path).unwrap();
let v: serde_json::Value = serde_json::from_str(&raw).unwrap();
assert!(v["data"]["five_hour_pct"].is_number());
assert!(v["data"]["seven_day_pct"].is_number());
assert!(v["data"]["five_hour_resets_at"].is_string());
assert!(v["data"]["seven_day_resets_at"].is_string());
assert!(v["expires_at"].is_number());
drop(dir);
}
#[test]
fn test_usage_limits_ttl_invalidation() {
let dir = tempfile::tempdir().expect("tempdir");
let transcript = dir.path().join("transcript.jsonl");
write_usage_limits(&transcript, &sample_data(), 60);
let path = dir.path().join("cship").join("transcript-usage-limits");
let expired = serde_json::json!({
"data": {
"five_hour_pct": 23.4,
"seven_day_pct": 45.1,
"five_hour_resets_at": "2099-01-01T00:00:00Z",
"seven_day_resets_at": "2099-01-01T00:00:00Z"
},
"expires_at": 0_u64,
"five_hour_resets_at": 9_999_999_999_u64,
"seven_day_resets_at": 9_999_999_999_u64
});
std::fs::write(&path, serde_json::to_string(&expired).unwrap()).unwrap();
let result = read_usage_limits(&transcript, false);
assert!(result.is_none(), "expired TTL should return None");
drop(dir);
}
#[test]
fn test_usage_limits_resets_at_early_invalidation() {
let dir = tempfile::tempdir().expect("tempdir");
let transcript = dir.path().join("transcript.jsonl");
let data = UsageLimitsData {
five_hour_pct: 50.0,
seven_day_pct: 10.0,
five_hour_resets_at: "2000-01-01T00:00:00Z".into(), seven_day_resets_at: "2099-01-01T00:00:00Z".into(), ..Default::default()
};
write_usage_limits(&transcript, &data, 60);
let result = read_usage_limits(&transcript, false);
assert!(
result.is_none(),
"past five_hour_resets_at should invalidate cache"
);
drop(dir);
}
#[test]
fn test_usage_limits_write_creates_directory() {
let dir = tempfile::tempdir().expect("tempdir");
let transcript = dir.path().join("deep").join("nested").join("t.jsonl");
write_usage_limits(&transcript, &sample_data(), 60);
let cache_file = dir
.path()
.join("deep")
.join("nested")
.join("cship")
.join("t-usage-limits");
assert!(
cache_file.exists(),
"directory should be created: {cache_file:?}"
);
drop(dir);
}
#[test]
fn test_usage_limits_seven_day_resets_at_early_invalidation() {
let dir = tempfile::tempdir().expect("tempdir");
let transcript = dir.path().join("transcript.jsonl");
let data = UsageLimitsData {
five_hour_pct: 50.0,
seven_day_pct: 10.0,
five_hour_resets_at: "2099-01-01T00:00:00Z".into(), seven_day_resets_at: "2000-01-01T00:00:00Z".into(), ..Default::default()
};
write_usage_limits(&transcript, &data, 60);
let result = read_usage_limits(&transcript, false);
assert!(
result.is_none(),
"past seven_day_resets_at should invalidate cache"
);
drop(dir);
}
#[test]
fn test_usage_limits_empty_resets_at_does_not_invalidate() {
let dir = tempfile::tempdir().expect("tempdir");
let transcript = dir.path().join("transcript.jsonl");
let data = UsageLimitsData {
five_hour_pct: 50.0,
seven_day_pct: 10.0,
..Default::default()
};
write_usage_limits(&transcript, &data, 60);
let result = read_usage_limits(&transcript, false);
assert!(
result.is_some(),
"empty resets_at should not trigger early invalidation"
);
drop(dir);
}
#[test]
fn test_read_usage_limits_allow_stale_returns_expired_data() {
let dir = tempfile::tempdir().expect("tempdir");
let transcript = dir.path().join("transcript.jsonl");
write_usage_limits(&transcript, &sample_data(), 60);
let path = dir.path().join("cship").join("transcript-usage-limits");
let expired = serde_json::json!({
"data": {
"five_hour_pct": 77.0,
"seven_day_pct": 88.0,
"five_hour_resets_at": "2099-01-01T00:00:00Z",
"seven_day_resets_at": "2099-01-01T00:00:00Z"
},
"expires_at": 0_u64, "five_hour_resets_at": 9_999_999_999_u64,
"seven_day_resets_at": 9_999_999_999_u64
});
std::fs::write(&path, serde_json::to_string(&expired).unwrap()).unwrap();
assert!(
read_usage_limits(&transcript, false).is_none(),
"normal read should be None"
);
let stale = read_usage_limits(&transcript, true);
assert!(stale.is_some(), "stale read should return data");
assert!((stale.unwrap().five_hour_pct - 77.0).abs() < f64::EPSILON);
drop(dir);
}
#[test]
fn test_read_usage_limits_allow_stale_returns_none_when_no_file() {
let (_dir, transcript) = temp_transcript("stale_miss");
assert!(read_usage_limits(&transcript, true).is_none());
}
#[test]
fn test_iso8601_to_epoch_known_value() {
assert_eq!(iso8601_to_epoch("2000-01-01T00:00:00Z"), Some(946_684_800));
}
#[test]
fn test_iso8601_to_epoch_invalid_returns_none() {
assert_eq!(iso8601_to_epoch("not-a-date"), None);
assert_eq!(iso8601_to_epoch(""), None);
}
#[test]
fn test_iso8601_to_epoch_plus_offset_format() {
assert_eq!(
iso8601_to_epoch("2000-01-01T00:00:00+00:00"),
Some(946_684_800),
"+00:00 format should parse to same epoch as Z form"
);
assert_eq!(
iso8601_to_epoch("2000-01-01T00:00:01.943648+00:00"),
Some(946_684_801),
"fractional seconds with +00:00 should be truncated"
);
}
#[test]
fn test_usage_limits_cache_backwards_compat_old_format() {
let dir = tempfile::tempdir().expect("tempdir");
let transcript = dir.path().join("transcript.jsonl");
let path = dir.path().join("cship").join("transcript-usage-limits");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let old_cache = serde_json::json!({
"data": {
"five_hour_pct": 42.0,
"seven_day_pct": 18.0,
"five_hour_resets_at": "2099-01-01T00:00:00Z",
"seven_day_resets_at": "2099-01-01T00:00:00Z"
},
"expires_at": now + 300,
"five_hour_resets_at": 9_999_999_999_u64,
"seven_day_resets_at": 9_999_999_999_u64
});
std::fs::write(&path, serde_json::to_string(&old_cache).unwrap()).unwrap();
let result = read_usage_limits(&transcript, false);
assert!(
result.is_some(),
"old-format cache should still deserialize"
);
let data = result.unwrap();
assert!((data.five_hour_pct - 42.0).abs() < f64::EPSILON);
assert!((data.seven_day_pct - 18.0).abs() < f64::EPSILON);
assert!(data.extra_usage_enabled.is_none());
assert!(data.extra_usage_monthly_limit.is_none());
assert!(data.extra_usage_used_credits.is_none());
assert!(data.extra_usage_utilization.is_none());
assert!(data.seven_day_opus_pct.is_none());
assert!(data.seven_day_opus_resets_at.is_none());
assert!(data.seven_day_sonnet_pct.is_none());
assert!(data.seven_day_sonnet_resets_at.is_none());
assert!(data.seven_day_cowork_pct.is_none());
assert!(data.seven_day_cowork_resets_at.is_none());
assert!(data.seven_day_oauth_apps_pct.is_none());
assert!(data.seven_day_oauth_apps_resets_at.is_none());
drop(dir);
}
#[test]
fn test_usage_limits_early_invalidation_with_plus_offset_resets_at() {
let dir = tempfile::tempdir().expect("tempdir");
let transcript = dir.path().join("transcript.jsonl");
let data = UsageLimitsData {
five_hour_pct: 50.0,
seven_day_pct: 10.0,
five_hour_resets_at: "2000-01-01T00:00:00+00:00".into(), seven_day_resets_at: "2099-01-01T00:00:00+00:00".into(), ..Default::default()
};
write_usage_limits(&transcript, &data, 60);
let result = read_usage_limits(&transcript, false);
assert!(
result.is_none(),
"past five_hour_resets_at (+00:00 format) should invalidate cache"
);
drop(dir);
}
#[test]
fn test_iso8601_to_epoch_fractional_seconds() {
assert_eq!(
iso8601_to_epoch("2000-01-01T00:00:01.000Z"),
Some(946_684_801),
"fractional-second timestamp should parse correctly"
);
assert_eq!(
iso8601_to_epoch("2000-01-01T00:00:01.999Z"),
Some(946_684_801),
"fractional seconds are truncated, not rounded"
);
}
#[test]
fn test_usage_limits_custom_ttl_sets_expires_at() {
let dir = tempfile::tempdir().expect("tempdir");
let transcript = dir.path().join("transcript.jsonl");
write_usage_limits(&transcript, &sample_data(), 300);
let path = dir.path().join("cship").join("transcript-usage-limits");
let raw = std::fs::read_to_string(&path).unwrap();
let v: serde_json::Value = serde_json::from_str(&raw).unwrap();
let expires_at = v["expires_at"].as_u64().unwrap();
let now = now_epoch();
assert!(
expires_at >= now + 298 && expires_at <= now + 302,
"expected expires_at ~now+300, got delta={}",
expires_at.saturating_sub(now)
);
let result = read_usage_limits(&transcript, false);
assert!(result.is_some(), "cache with 300s TTL should be valid");
drop(dir);
}
}