use crate::cache;
use crate::config::{CshipConfig, UsageLimitsConfig};
use crate::context::Context;
use crate::usage_limits::UsageLimitsData;
pub fn render(ctx: &Context, cfg: &CshipConfig) -> Option<String> {
let ul_cfg = cfg.usage_limits.as_ref();
if ul_cfg.and_then(|c| c.disabled) == Some(true) {
return None;
}
let data = if let Some(from_stdin) = data_from_stdin_rate_limits(ctx) {
from_stdin
} else {
let transcript_str = ctx.transcript_path.as_deref()?;
let transcript_path = std::path::Path::new(transcript_str);
if let Some(cached) = cache::read_usage_limits(transcript_path, false) {
cached
} else {
let token = match crate::platform::get_oauth_token() {
Ok(t) => t,
Err(e) => {
tracing::warn!("cship.usage_limits: credential retrieval failed: {e}");
return None;
}
};
let ttl_secs = ul_cfg.and_then(|c| c.ttl).unwrap_or(60);
match fetch_with_timeout(move || crate::usage_limits::fetch_usage_limits(&token)) {
Some(fresh) => {
cache::write_usage_limits(transcript_path, &fresh, ttl_secs);
fresh
}
None => cache::read_usage_limits(transcript_path, true)?,
}
}
};
let default_ul_cfg = UsageLimitsConfig::default();
let content = format_output(&data, ul_cfg.unwrap_or(&default_ul_cfg));
let max_pct = data.five_hour_pct.max(data.seven_day_pct);
let style = ul_cfg.and_then(|c| c.style.as_deref());
let warn_threshold = ul_cfg.and_then(|c| c.warn_threshold);
let warn_style = ul_cfg.and_then(|c| c.warn_style.as_deref());
let critical_threshold = ul_cfg.and_then(|c| c.critical_threshold);
let critical_style = ul_cfg.and_then(|c| c.critical_style.as_deref());
Some(crate::ansi::apply_style_with_threshold(
&content,
Some(max_pct),
style,
warn_threshold,
warn_style,
critical_threshold,
critical_style,
))
}
fn fetch_with_timeout<F>(fetch_fn: F) -> Option<UsageLimitsData>
where
F: FnOnce() -> Result<UsageLimitsData, String> + Send + 'static,
{
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
tx.send(fetch_fn()).ok();
});
match rx.recv_timeout(std::time::Duration::from_secs(2)) {
Ok(Ok(data)) => Some(data),
Ok(Err(e)) => {
tracing::warn!("cship.usage_limits: API fetch failed: {e}");
None
}
Err(_) => {
tracing::warn!("cship.usage_limits: API fetch timed out after 2s");
None
}
}
}
fn data_from_stdin_rate_limits(ctx: &Context) -> Option<UsageLimitsData> {
let rl = ctx.rate_limits.as_ref()?;
let (five_pct, five_epoch) = match rl.five_hour.as_ref() {
Some(five) => (five.used_percentage.unwrap_or(0.0), five.resets_at),
None => {
tracing::warn!(
"rate_limits.five_hour absent from stdin; rendering with placeholder values"
);
(0.0, None)
}
};
let (seven_pct, seven_epoch) = match rl.seven_day.as_ref() {
Some(seven) => (seven.used_percentage.unwrap_or(0.0), seven.resets_at),
None => {
tracing::warn!(
"rate_limits.seven_day absent from stdin; rendering with placeholder values"
);
(0.0, None)
}
};
Some(UsageLimitsData {
five_hour_pct: five_pct,
seven_day_pct: seven_pct,
five_hour_resets_at: String::new(),
seven_day_resets_at: String::new(),
five_hour_resets_at_epoch: five_epoch,
seven_day_resets_at_epoch: seven_epoch,
})
}
fn format_output(data: &UsageLimitsData, cfg: &UsageLimitsConfig) -> String {
let five_h_pct = format!("{:.0}", data.five_hour_pct);
let five_h_remaining = format!("{:.0}", (100.0 - data.five_hour_pct).max(0.0));
let five_h_reset = match data.five_hour_resets_at_epoch {
Some(epoch) => format_time_until_epoch(epoch),
None => format_time_until(&data.five_hour_resets_at),
};
let seven_d_pct = format!("{:.0}", data.seven_day_pct);
let seven_d_remaining = format!("{:.0}", (100.0 - data.seven_day_pct).max(0.0));
let seven_d_reset = match data.seven_day_resets_at_epoch {
Some(epoch) => format_time_until_epoch(epoch),
None => format_time_until(&data.seven_day_resets_at),
};
let five_h_fmt = cfg
.five_hour_format
.as_deref()
.unwrap_or("5h: {pct}% resets in {reset}");
let seven_d_fmt = cfg
.seven_day_format
.as_deref()
.unwrap_or("7d: {pct}% resets in {reset}");
let sep = cfg.separator.as_deref().unwrap_or(" | ");
let five_h_part = five_h_fmt
.replace("{pct}", &five_h_pct)
.replace("{remaining}", &five_h_remaining)
.replace("{reset}", &five_h_reset);
let seven_d_part = seven_d_fmt
.replace("{pct}", &seven_d_pct)
.replace("{remaining}", &seven_d_remaining)
.replace("{reset}", &seven_d_reset);
format!("{five_h_part}{sep}{seven_d_part}")
}
fn format_time_until_epoch(reset_epoch: u64) -> String {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
if now >= reset_epoch {
return "now".to_string();
}
format_remaining_secs(reset_epoch - now)
}
fn format_time_until(resets_at: &str) -> String {
if resets_at.is_empty() {
return "?".to_string();
}
let reset_epoch = match crate::cache::iso8601_to_epoch(resets_at) {
Some(e) => e,
None => return "?".to_string(),
};
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
if now >= reset_epoch {
return "now".to_string();
}
format_remaining_secs(reset_epoch - now)
}
fn format_remaining_secs(secs: u64) -> String {
let mins = secs / 60;
let hours = mins / 60;
let days = hours / 24;
if days > 0 {
format!("{}d{}h", days, hours % 24)
} else if hours > 0 {
format!("{}h{}m", hours, mins % 60)
} else {
format!("{}m", mins)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{CshipConfig, UsageLimitsConfig};
use crate::context::Context;
fn epoch_to_iso(epoch: Option<u64>) -> String {
match epoch {
Some(e) => {
let days_since_epoch = (e / 86400) as i64;
let remaining = e % 86400;
let hour = remaining / 3600;
let min = (remaining % 3600) / 60;
let sec = remaining % 60;
let z = days_since_epoch + 719468;
let era = z.div_euclid(146097);
let doe = z - era * 146097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
format!(
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
y, m, d, hour, min, sec
)
}
None => String::new(),
}
}
#[test]
fn test_render_disabled_returns_none() {
let ctx = Context::default();
let cfg = CshipConfig {
usage_limits: Some(UsageLimitsConfig {
disabled: Some(true),
..Default::default()
}),
..Default::default()
};
assert!(render(&ctx, &cfg).is_none());
}
#[test]
fn test_render_no_transcript_path_returns_none() {
let ctx = Context {
transcript_path: None,
..Default::default()
};
let cfg = CshipConfig::default();
assert!(render(&ctx, &cfg).is_none());
}
#[test]
fn test_render_cache_hit_returns_formatted_output() {
let dir = tempfile::tempdir().unwrap();
let transcript = dir.path().join("test.jsonl");
let data = 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(),
five_hour_resets_at_epoch: None,
seven_day_resets_at_epoch: None,
};
crate::cache::write_usage_limits(&transcript, &data, 60);
let ctx = Context {
transcript_path: Some(transcript.to_str().unwrap().to_string()),
..Default::default()
};
let result = render(&ctx, &CshipConfig::default()).unwrap();
assert!(result.contains("5h:"), "expected 5h prefix: {result:?}");
assert!(result.contains("7d:"), "expected 7d prefix: {result:?}");
assert!(result.contains("23%"), "expected five_hour_pct: {result:?}");
assert!(result.contains("45%"), "expected seven_day_pct: {result:?}");
}
#[test]
fn test_render_warn_threshold_applies_ansi() {
let dir = tempfile::tempdir().unwrap();
let transcript = dir.path().join("test.jsonl");
let data = UsageLimitsData {
five_hour_pct: 65.0,
seven_day_pct: 10.0,
five_hour_resets_at: "2099-01-01T00:00:00Z".into(),
seven_day_resets_at: "2099-01-01T00:00:00Z".into(),
five_hour_resets_at_epoch: None,
seven_day_resets_at_epoch: None,
};
crate::cache::write_usage_limits(&transcript, &data, 60);
let ctx = Context {
transcript_path: Some(transcript.to_str().unwrap().to_string()),
..Default::default()
};
let cfg = CshipConfig {
usage_limits: Some(UsageLimitsConfig {
warn_threshold: Some(60.0),
warn_style: Some("bold yellow".to_string()),
..Default::default()
}),
..Default::default()
};
let result = render(&ctx, &cfg).unwrap();
assert!(
result.contains('\x1b'),
"expected ANSI codes for warn: {result:?}"
);
}
#[test]
fn test_render_critical_overrides_warn() {
let dir = tempfile::tempdir().unwrap();
let transcript = dir.path().join("test.jsonl");
let data = UsageLimitsData {
five_hour_pct: 85.0,
seven_day_pct: 20.0,
five_hour_resets_at: "2099-01-01T00:00:00Z".into(),
seven_day_resets_at: "2099-01-01T00:00:00Z".into(),
five_hour_resets_at_epoch: None,
seven_day_resets_at_epoch: None,
};
crate::cache::write_usage_limits(&transcript, &data, 60);
let ctx = Context {
transcript_path: Some(transcript.to_str().unwrap().to_string()),
..Default::default()
};
let cfg = CshipConfig {
usage_limits: Some(UsageLimitsConfig {
warn_threshold: Some(60.0),
warn_style: Some("yellow".to_string()),
critical_threshold: Some(80.0),
critical_style: Some("bold red".to_string()),
..Default::default()
}),
..Default::default()
};
let result = render(&ctx, &cfg).unwrap();
let content = format_output(&data, &UsageLimitsConfig::default());
let expected_critical = crate::ansi::apply_style(&content, Some("bold red"));
let expected_warn = crate::ansi::apply_style(&content, Some("yellow"));
assert_eq!(result, expected_critical, "expected critical style applied");
assert_ne!(result, expected_warn, "critical should override warn style");
}
#[test]
fn test_threshold_uses_higher_of_two_pcts() {
let dir = tempfile::tempdir().unwrap();
let transcript = dir.path().join("test.jsonl");
let data = UsageLimitsData {
five_hour_pct: 20.0,
seven_day_pct: 85.0,
five_hour_resets_at: "2099-01-01T00:00:00Z".into(),
seven_day_resets_at: "2099-01-01T00:00:00Z".into(),
five_hour_resets_at_epoch: None,
seven_day_resets_at_epoch: None,
};
crate::cache::write_usage_limits(&transcript, &data, 60);
let ctx = Context {
transcript_path: Some(transcript.to_str().unwrap().to_string()),
..Default::default()
};
let cfg = CshipConfig {
usage_limits: Some(UsageLimitsConfig {
critical_threshold: Some(80.0),
critical_style: Some("bold red".to_string()),
..Default::default()
}),
..Default::default()
};
let result = render(&ctx, &cfg).unwrap();
assert!(
result.contains('\x1b'),
"expected ANSI codes for critical: {result:?}"
);
}
#[test]
fn test_render_stale_cache_returned_on_fetch_timeout() {
let dir = tempfile::tempdir().unwrap();
let transcript = dir.path().join("test.jsonl");
let data = UsageLimitsData {
five_hour_pct: 50.0,
seven_day_pct: 30.0,
five_hour_resets_at: "2099-01-01T00:00:00Z".into(),
seven_day_resets_at: "2099-01-01T00:00:00Z".into(),
five_hour_resets_at_epoch: None,
seven_day_resets_at_epoch: None,
};
crate::cache::write_usage_limits(&transcript, &data, 60);
let stale = crate::cache::read_usage_limits(&transcript, true);
assert!(
stale.is_some(),
"stale read should return data regardless of TTL"
);
assert!((stale.unwrap().five_hour_pct - 50.0).abs() < f64::EPSILON);
}
#[test]
fn test_fetch_with_timeout_success_returns_data() {
let expected = UsageLimitsData {
five_hour_pct: 50.0,
seven_day_pct: 30.0,
five_hour_resets_at: "2099-01-01T00:00:00Z".into(),
seven_day_resets_at: "2099-01-01T00:00:00Z".into(),
five_hour_resets_at_epoch: None,
seven_day_resets_at_epoch: None,
};
let cloned = expected.clone();
let result = fetch_with_timeout(move || Ok(cloned));
assert!(result.is_some());
assert!((result.unwrap().five_hour_pct - 50.0).abs() < f64::EPSILON);
}
#[test]
fn test_fetch_with_timeout_api_error_returns_none() {
let result = fetch_with_timeout(|| Err("API error".to_string()));
assert!(result.is_none());
}
#[test]
#[ignore = "slow: blocks for 2s timeout"]
fn test_fetch_with_timeout_timeout_returns_none() {
let result = fetch_with_timeout(|| {
std::thread::sleep(std::time::Duration::from_secs(5));
Ok(UsageLimitsData {
five_hour_pct: 0.0,
seven_day_pct: 0.0,
five_hour_resets_at: String::new(),
seven_day_resets_at: String::new(),
five_hour_resets_at_epoch: None,
seven_day_resets_at_epoch: None,
})
});
assert!(result.is_none());
}
#[test]
fn test_epoch_to_iso_zero() {
assert_eq!(epoch_to_iso(Some(0)), "1970-01-01T00:00:00Z");
}
#[test]
fn test_epoch_to_iso_none() {
assert_eq!(epoch_to_iso(None), "");
}
#[test]
fn test_epoch_to_iso_known_value() {
assert_eq!(epoch_to_iso(Some(946_684_800)), "2000-01-01T00:00:00Z");
}
#[test]
fn test_epoch_to_iso_far_future() {
assert_eq!(epoch_to_iso(Some(4_102_358_400)), "2099-12-31T00:00:00Z");
}
#[test]
fn test_format_time_until_empty_string_returns_question_mark() {
assert_eq!(format_time_until(""), "?");
}
#[test]
fn test_format_time_until_past_timestamp_returns_now() {
assert_eq!(format_time_until("2000-01-01T00:00:00Z"), "now");
}
#[test]
fn test_format_time_until_hours_minutes() {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let future_epoch = now + 4 * 3600 + 12 * 60 + 30; let future_str = epoch_to_iso(Some(future_epoch));
let result = format_time_until(&future_str);
assert!(
result.contains('h') && result.contains('m'),
"expected Xh Ym format: {result}"
);
}
#[test]
fn test_format_time_until_days_hours() {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let future_epoch = now + 3 * 86400 + 2 * 3600 + 30; let future_str = epoch_to_iso(Some(future_epoch));
let result = format_time_until(&future_str);
assert!(
result.contains('d') && result.contains('h'),
"expected Xd Yh format: {result}"
);
}
#[test]
fn test_format_time_until_minutes_only() {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let future_epoch = now + 45 * 60 + 30; let future_str = epoch_to_iso(Some(future_epoch));
let result = format_time_until(&future_str);
assert!(
result.ends_with('m') && !result.contains('h'),
"expected Xm format: {result}"
);
}
#[test]
fn test_format_time_until_plus_offset_format() {
let result = format_time_until("2099-01-01T00:00:00+00:00");
assert_ne!(result, "?", "should parse +00:00 format, not return '?'");
assert_ne!(
result, "now",
"far-future +00:00 timestamp should not be 'now'"
);
}
#[test]
fn test_format_output_default_produces_legacy_format() {
let data = 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(),
five_hour_resets_at_epoch: None,
seven_day_resets_at_epoch: None,
};
let cfg = UsageLimitsConfig::default();
let result = format_output(&data, &cfg);
assert!(result.starts_with("5h: 23%"), "5h prefix: {result:?}");
assert!(result.contains(" | "), "default separator: {result:?}");
assert!(result.contains("7d: 45%"), "7d prefix: {result:?}");
}
#[test]
fn test_format_output_custom_five_hour_format() {
let data = UsageLimitsData {
five_hour_pct: 23.0,
seven_day_pct: 10.0,
five_hour_resets_at: "2099-01-01T00:00:00Z".into(),
seven_day_resets_at: "2099-01-01T00:00:00Z".into(),
five_hour_resets_at_epoch: None,
seven_day_resets_at_epoch: None,
};
let cfg = UsageLimitsConfig {
five_hour_format: Some("⏱: {pct}%({reset})".into()),
..Default::default()
};
let result = format_output(&data, &cfg);
assert!(
result.starts_with("⏱: 23%("),
"expected custom 5h format: {result:?}"
);
}
#[test]
fn test_format_output_custom_seven_day_format() {
let data = UsageLimitsData {
five_hour_pct: 10.0,
seven_day_pct: 45.0,
five_hour_resets_at: "2099-01-01T00:00:00Z".into(),
seven_day_resets_at: "2099-01-01T00:00:00Z".into(),
five_hour_resets_at_epoch: None,
seven_day_resets_at_epoch: None,
};
let cfg = UsageLimitsConfig {
seven_day_format: Some("7d {pct}%/{reset}".into()),
..Default::default()
};
let result = format_output(&data, &cfg);
assert!(
result.contains("7d 45%/"),
"expected custom 7d format: {result:?}"
);
}
#[test]
fn test_format_output_custom_separator() {
let data = UsageLimitsData {
five_hour_pct: 10.0,
seven_day_pct: 20.0,
five_hour_resets_at: "2099-01-01T00:00:00Z".into(),
seven_day_resets_at: "2099-01-01T00:00:00Z".into(),
five_hour_resets_at_epoch: None,
seven_day_resets_at_epoch: None,
};
let cfg = UsageLimitsConfig {
separator: Some(" — ".into()),
..Default::default()
};
let result = format_output(&data, &cfg);
assert!(
result.contains(" — "),
"expected em-dash separator: {result:?}"
);
assert!(
!result.contains(" | "),
"should not contain default separator: {result:?}"
);
}
#[test]
fn test_format_output_pct_only_no_reset_placeholder() {
let data = UsageLimitsData {
five_hour_pct: 30.0,
seven_day_pct: 50.0,
five_hour_resets_at: "2099-01-01T00:00:00Z".into(),
seven_day_resets_at: "2099-01-01T00:00:00Z".into(),
five_hour_resets_at_epoch: None,
seven_day_resets_at_epoch: None,
};
let cfg = UsageLimitsConfig {
five_hour_format: Some("{pct}%".into()),
seven_day_format: Some("{pct}%".into()),
..Default::default()
};
let result = format_output(&data, &cfg);
assert_eq!(result, "30% | 50%", "unexpected content: {result:?}");
}
#[test]
fn test_threshold_styling_applies_to_custom_format() {
let dir = tempfile::tempdir().unwrap();
let transcript = dir.path().join("test.jsonl");
let data = UsageLimitsData {
five_hour_pct: 75.0,
seven_day_pct: 10.0,
five_hour_resets_at: "2099-01-01T00:00:00Z".into(),
seven_day_resets_at: "2099-01-01T00:00:00Z".into(),
five_hour_resets_at_epoch: None,
seven_day_resets_at_epoch: None,
};
crate::cache::write_usage_limits(&transcript, &data, 60);
let ctx = Context {
transcript_path: Some(transcript.to_str().unwrap().to_string()),
..Default::default()
};
let cfg = CshipConfig {
usage_limits: Some(UsageLimitsConfig {
five_hour_format: Some("{pct}%".into()),
seven_day_format: Some("{pct}%".into()),
separator: Some("/".into()),
warn_threshold: Some(70.0),
warn_style: Some("bold yellow".to_string()),
..Default::default()
}),
..Default::default()
};
let result = render(&ctx, &cfg).unwrap();
assert!(
result.contains('\x1b'),
"expected ANSI codes on custom-formatted output: {result:?}"
);
assert!(
result.contains("75%"),
"custom format content should be present: {result:?}"
);
}
#[test]
fn test_data_from_stdin_rate_limits_uses_epoch_directly() {
let ctx = Context {
rate_limits: Some(crate::context::RateLimits {
five_hour: Some(crate::context::RateLimitPeriod {
used_percentage: Some(23.0),
resets_at: Some(9_999_999_999),
}),
seven_day: Some(crate::context::RateLimitPeriod {
used_percentage: Some(45.0),
resets_at: Some(9_999_999_999),
}),
}),
..Default::default()
};
let data = data_from_stdin_rate_limits(&ctx).unwrap();
assert_eq!(
data.five_hour_resets_at, "",
"ISO field must be empty on stdin path"
);
assert_eq!(
data.seven_day_resets_at, "",
"ISO field must be empty on stdin path"
);
assert_eq!(
data.five_hour_resets_at_epoch,
Some(9_999_999_999),
"epoch field must carry raw resets_at value"
);
assert_eq!(
data.seven_day_resets_at_epoch,
Some(9_999_999_999),
"epoch field must carry raw resets_at value"
);
}
#[test]
fn test_render_stdin_rate_limits_produces_output() {
let ctx = Context {
rate_limits: Some(crate::context::RateLimits {
five_hour: Some(crate::context::RateLimitPeriod {
used_percentage: Some(23.0),
resets_at: Some(9_999_999_999),
}),
seven_day: Some(crate::context::RateLimitPeriod {
used_percentage: Some(45.0),
resets_at: Some(9_999_999_999),
}),
}),
..Default::default()
};
let result = render(&ctx, &CshipConfig::default()).unwrap();
assert!(result.contains("5h:"), "expected 5h prefix: {result:?}");
assert!(result.contains("7d:"), "expected 7d prefix: {result:?}");
assert!(result.contains("23%"), "expected five_hour_pct: {result:?}");
assert!(result.contains("45%"), "expected seven_day_pct: {result:?}");
}
#[test]
fn test_stdin_only_five_hour_present_seven_day_absent() {
let ctx = Context {
rate_limits: Some(crate::context::RateLimits {
five_hour: Some(crate::context::RateLimitPeriod {
used_percentage: Some(42.0),
resets_at: None,
}),
seven_day: None,
}),
..Default::default()
};
let result = data_from_stdin_rate_limits(&ctx);
assert!(
result.is_some(),
"should return Some with only five_hour present"
);
let data = result.unwrap();
assert!(
(data.five_hour_pct - 42.0).abs() < f64::EPSILON,
"five_hour_pct should be 42.0"
);
assert!(
(data.seven_day_pct - 0.0).abs() < f64::EPSILON,
"seven_day_pct should be 0.0 placeholder"
);
assert_eq!(
data.seven_day_resets_at_epoch, None,
"absent seven_day epoch should be None"
);
}
#[test]
fn test_stdin_seven_day_used_percentage_absent_uses_zero() {
let ctx = Context {
rate_limits: Some(crate::context::RateLimits {
five_hour: Some(crate::context::RateLimitPeriod {
used_percentage: Some(10.0),
resets_at: Some(9_999_999_999),
}),
seven_day: Some(crate::context::RateLimitPeriod {
used_percentage: None,
resets_at: Some(9_999_999_999),
}),
}),
..Default::default()
};
let result = data_from_stdin_rate_limits(&ctx);
assert!(
result.is_some(),
"should return Some when seven_day.used_percentage is None"
);
let data = result.unwrap();
assert!(
(data.seven_day_pct - 0.0).abs() < f64::EPSILON,
"absent used_percentage should fall back to 0.0"
);
}
#[test]
fn test_stdin_rate_limits_entirely_absent_returns_none() {
let ctx = Context::default();
assert!(
data_from_stdin_rate_limits(&ctx).is_none(),
"absent rate_limits should return None"
);
}
#[test]
fn test_stdin_both_periods_present_full_data_happy_path() {
let ctx = Context {
rate_limits: Some(crate::context::RateLimits {
five_hour: Some(crate::context::RateLimitPeriod {
used_percentage: Some(55.0),
resets_at: Some(9_999_999_999),
}),
seven_day: Some(crate::context::RateLimitPeriod {
used_percentage: Some(80.0),
resets_at: Some(9_999_999_999),
}),
}),
..Default::default()
};
let result = data_from_stdin_rate_limits(&ctx);
assert!(
result.is_some(),
"both periods present → should return Some"
);
let data = result.unwrap();
assert!(
(data.five_hour_pct - 55.0).abs() < f64::EPSILON,
"five_hour_pct should be 55.0"
);
assert!(
(data.seven_day_pct - 80.0).abs() < f64::EPSILON,
"seven_day_pct should be 80.0"
);
assert_eq!(data.five_hour_resets_at_epoch, Some(9_999_999_999));
assert_eq!(data.seven_day_resets_at_epoch, Some(9_999_999_999));
}
#[test]
fn test_stdin_period_with_resets_at_none_uses_none_epoch() {
let ctx = Context {
rate_limits: Some(crate::context::RateLimits {
five_hour: Some(crate::context::RateLimitPeriod {
used_percentage: Some(30.0),
resets_at: None, }),
seven_day: Some(crate::context::RateLimitPeriod {
used_percentage: Some(50.0),
resets_at: Some(9_999_999_999),
}),
}),
..Default::default()
};
let data = data_from_stdin_rate_limits(&ctx).unwrap();
assert_eq!(
data.five_hour_resets_at_epoch, None,
"absent resets_at → None epoch"
);
assert_eq!(data.seven_day_resets_at_epoch, Some(9_999_999_999));
}
#[test]
fn test_stdin_only_seven_day_present_five_hour_absent() {
let ctx = Context {
rate_limits: Some(crate::context::RateLimits {
five_hour: None, seven_day: Some(crate::context::RateLimitPeriod {
used_percentage: Some(75.0),
resets_at: Some(9_999_999_999),
}),
}),
..Default::default()
};
let result = data_from_stdin_rate_limits(&ctx);
assert!(result.is_some());
let data = result.unwrap();
assert!(
(data.five_hour_pct - 0.0).abs() < f64::EPSILON,
"absent five_hour → 0.0 placeholder"
);
assert!((data.seven_day_pct - 75.0).abs() < f64::EPSILON);
assert_eq!(data.five_hour_resets_at_epoch, None);
}
#[test]
fn test_render_stdin_path_no_transcript_needed() {
let ctx = Context {
transcript_path: None, rate_limits: Some(crate::context::RateLimits {
five_hour: Some(crate::context::RateLimitPeriod {
used_percentage: Some(40.0),
resets_at: Some(9_999_999_999),
}),
seven_day: Some(crate::context::RateLimitPeriod {
used_percentage: Some(60.0),
resets_at: Some(9_999_999_999),
}),
}),
..Default::default()
};
let result = render(&ctx, &CshipConfig::default());
assert!(result.is_some(), "stdin path must not need transcript_path");
let output = result.unwrap();
assert!(
output.contains("40%"),
"expected five_hour_pct 40%: {output:?}"
);
assert!(
output.contains("60%"),
"expected seven_day_pct 60%: {output:?}"
);
}
#[test]
fn test_render_stdin_takes_priority_over_cache() {
let dir = tempfile::tempdir().unwrap();
let transcript = dir.path().join("test.jsonl");
let cache_data = UsageLimitsData {
five_hour_pct: 99.0, seven_day_pct: 99.0,
five_hour_resets_at: "2099-01-01T00:00:00Z".into(),
seven_day_resets_at: "2099-01-01T00:00:00Z".into(),
five_hour_resets_at_epoch: None,
seven_day_resets_at_epoch: None,
};
crate::cache::write_usage_limits(&transcript, &cache_data, 60);
let ctx = Context {
transcript_path: Some(transcript.to_str().unwrap().to_string()),
rate_limits: Some(crate::context::RateLimits {
five_hour: Some(crate::context::RateLimitPeriod {
used_percentage: Some(23.0),
resets_at: Some(9_999_999_999),
}),
seven_day: Some(crate::context::RateLimitPeriod {
used_percentage: Some(45.0),
resets_at: Some(9_999_999_999),
}),
}),
..Default::default()
};
let result = render(&ctx, &CshipConfig::default()).unwrap();
assert!(
result.contains("23%"),
"stdin must override cache: {result:?}"
);
assert!(
!result.contains("99%"),
"cache value must not appear: {result:?}"
);
}
#[test]
fn test_render_falls_back_to_oauth_path_when_rate_limits_absent() {
let ctx = Context {
transcript_path: None,
rate_limits: None,
..Default::default()
};
assert!(
render(&ctx, &CshipConfig::default()).is_none(),
"absent rate_limits with no transcript → None (OAuth path triggered, no token available)"
);
}
#[test]
fn test_format_time_until_epoch_past_returns_now() {
assert_eq!(format_time_until_epoch(0), "now");
assert_eq!(format_time_until_epoch(1), "now");
}
#[test]
fn test_format_time_until_epoch_hours_minutes() {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let future_epoch = now + 4 * 3600 + 12 * 60 + 30; let result = format_time_until_epoch(future_epoch);
assert!(
result.contains('h') && result.contains('m'),
"expected Xh Ym format: {result}"
);
}
#[test]
fn test_format_time_until_epoch_days_hours() {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let future_epoch = now + 3 * 86400 + 2 * 3600 + 30; let result = format_time_until_epoch(future_epoch);
assert!(
result.contains('d') && result.contains('h'),
"expected Xd Yh format: {result}"
);
}
#[test]
fn test_format_time_until_epoch_minutes_only() {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let future_epoch = now + 45 * 60 + 30; let result = format_time_until_epoch(future_epoch);
assert!(
result.ends_with('m') && !result.contains('h'),
"expected Xm format: {result}"
);
}
}