use super::model::UsageData;
use super::FetchError;
use crate::paths;
const DEFAULT_POSITIVE_TTL_SECS: u64 = 60;
const DEFAULT_NEGATIVE_COOLDOWN_SECS: u64 = 120;
const DEFAULT_HTTP_TIMEOUT_SECS: u64 = 2;
#[cfg(unix)]
const DEFAULT_SSH_DEADLINE_SECS: u64 = 3;
const DEFAULT_HUB_USAGE_URL: &str = "";
pub fn fetch() -> Result<UsageData, FetchError> {
if is_hub_local() {
let data = read_hub_local()?;
if let Err(e) = write_positive_cache(&data) {
eprintln!("csm: warning: could not write usage cache: {e}");
}
let _ = std::fs::remove_file(paths::fetch_failed());
return Ok(data);
}
let positive_ttl = positive_ttl_secs();
let negative_cooldown = negative_cooldown_secs();
if let Some(data) = try_positive_cache(positive_ttl)? {
return Ok(data);
}
if let Some(cmd) = resolve_usage_command() {
match run_usage_command(&cmd) {
Ok(data) => {
if let Err(e) = write_positive_cache(&data) {
eprintln!("csm: warning: could not write usage cache: {e}");
}
let _ = std::fs::remove_file(paths::fetch_failed());
return Ok(data);
}
Err(e) => {
eprintln!("csm: warning: CSM_USAGE_CMD failed: {e}");
}
}
}
if negative_cache_active(negative_cooldown) {
return Err(FetchError::NegativeCacheActive);
}
match do_network_fetch() {
Ok(data) => {
if let Err(e) = write_positive_cache(&data) {
eprintln!("csm: warning: could not write usage cache: {e}");
}
let _ = std::fs::remove_file(paths::fetch_failed());
Ok(data)
}
Err(e) => {
stamp_negative_cache();
Err(e)
}
}
}
fn do_network_fetch() -> Result<UsageData, FetchError> {
let usage_url = resolve_usage_url();
if let Some(ref url) = usage_url {
match http_fetch(url) {
Ok(data) => return Ok(data),
Err(_) => {
}
}
}
#[cfg(unix)]
{
ssh_fetch()
}
#[cfg(not(unix))]
{
Err(FetchError::EmptyPayload)
}
}
fn resolve_usage_url() -> Option<String> {
let url = match std::env::var("CLAUDE_USAGE_URL") {
Ok(val) => val, Err(_) => DEFAULT_HUB_USAGE_URL.to_owned(), };
if url.is_empty() {
None } else {
Some(url)
}
}
fn resolve_usage_command() -> Option<String> {
std::env::var("CSM_USAGE_CMD")
.ok()
.map(|s| s.trim().to_owned())
.filter(|s| !s.is_empty())
}
fn run_usage_command(cmd: &str) -> Result<UsageData, FetchError> {
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
let timeout_secs = std::env::var("CSM_USAGE_CMD_TIMEOUT")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(10);
#[cfg(unix)]
let mut child = Command::new("sh")
.args(["-c", cmd])
.stdout(Stdio::piped())
.stderr(Stdio::null())
.stdin(Stdio::null())
.spawn()
.map_err(|e| FetchError::Command(format!("spawn failed: {e}")))?;
#[cfg(not(unix))]
let mut child = Command::new("cmd")
.args(["/C", cmd])
.stdout(Stdio::piped())
.stderr(Stdio::null())
.stdin(Stdio::null())
.spawn()
.map_err(|e| FetchError::Command(format!("spawn failed: {e}")))?;
let stdout_pipe = child
.stdout
.take()
.ok_or_else(|| FetchError::Command("stdout pipe missing".into()))?;
let reader = std::thread::spawn(move || {
let mut buf = Vec::new();
let mut pipe = stdout_pipe;
std::io::Read::read_to_end(&mut pipe, &mut buf).map(|_| buf)
});
let start = Instant::now();
let deadline = Duration::from_secs(timeout_secs);
let status = loop {
match child.try_wait() {
Ok(Some(status)) => break status,
Ok(None) => {
if start.elapsed() >= deadline {
let _ = child.kill();
let _ = child.wait(); drop(reader);
return Err(FetchError::Command(format!(
"timed out after {timeout_secs}s"
)));
}
std::thread::sleep(Duration::from_millis(50));
}
Err(e) => {
let _ = child.kill();
let _ = child.wait();
drop(reader);
return Err(FetchError::Command(format!("wait failed: {e}")));
}
}
};
let stdout_bytes = match reader.join() {
Ok(Ok(bytes)) => bytes,
Ok(Err(e)) => return Err(FetchError::Command(format!("read failed: {e}"))),
Err(_) => return Err(FetchError::Command("stdout reader thread panicked".into())),
};
if !status.success() {
return Err(FetchError::Command(format!(
"command exited with status {status}"
)));
}
let body = String::from_utf8(stdout_bytes)
.map_err(|_| FetchError::Command("command output is not valid UTF-8".into()))?;
if body.trim().is_empty() {
return Err(FetchError::Command("command produced empty output".into()));
}
serde_json::from_str(&body)
.map_err(|e| FetchError::Command(format!("output not UsageData: {e}")))
}
pub fn hub_hostname() -> Option<String> {
std::env::var("CLAUDE_HUB_HOSTNAME")
.ok()
.map(|h| h.trim().to_ascii_lowercase())
.filter(|h| !h.is_empty())
}
pub fn is_configured() -> bool {
is_hub_local() || resolve_usage_url().is_some() || resolve_usage_command().is_some()
}
pub fn cache_age_secs() -> Option<u64> {
let meta = std::fs::metadata(paths::usage_cache()).ok()?;
Some(file_age_secs_from_meta(&meta))
}
fn is_hub_local() -> bool {
match hub_hostname() {
Some(hub) => short_hostname().to_ascii_lowercase() == hub,
None => false,
}
}
fn read_hub_local() -> Result<UsageData, FetchError> {
let path = paths::hub_local_cache();
if !path.exists() {
return Err(FetchError::EmptyPayload);
}
let raw = std::fs::read_to_string(&path)?;
if raw.trim().is_empty() {
return Err(FetchError::EmptyPayload);
}
let data: UsageData = serde_json::from_str(&raw)?;
Ok(data)
}
fn short_hostname() -> String {
#[cfg(unix)]
{
use nix::unistd::gethostname;
gethostname()
.ok()
.and_then(|h| h.into_string().ok())
.map(|h| h.split('.').next().unwrap_or(&h).to_owned())
.unwrap_or_default()
}
#[cfg(not(unix))]
{
std::env::var("COMPUTERNAME")
.unwrap_or_default()
.split('.')
.next()
.unwrap_or("")
.to_ascii_lowercase()
.to_owned()
}
}
fn positive_ttl_secs() -> u64 {
std::env::var("CLAUDE_USAGE_TTL")
.ok()
.and_then(|v| v.parse().ok())
.or_else(|| {
std::env::var("CSM_USAGE_TTL_SECS")
.ok()
.and_then(|v| v.parse().ok())
})
.unwrap_or(DEFAULT_POSITIVE_TTL_SECS)
}
fn try_positive_cache(ttl_secs: u64) -> Result<Option<UsageData>, FetchError> {
let path = paths::usage_cache();
if !path.exists() {
return Ok(None);
}
let meta = std::fs::metadata(&path)?;
if meta.len() == 0 {
return Ok(None);
}
let age = file_age_secs_from_meta(&meta);
if age >= ttl_secs {
return Ok(None);
}
let raw = std::fs::read_to_string(&path)?;
let data: UsageData = serde_json::from_str(&raw)?;
Ok(Some(data))
}
fn negative_cooldown_secs() -> u64 {
std::env::var("CLAUDE_USAGE_FAIL_COOLDOWN")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(DEFAULT_NEGATIVE_COOLDOWN_SECS)
}
fn negative_cache_active(cooldown_secs: u64) -> bool {
let path = paths::fetch_failed();
if !path.exists() {
return false;
}
let content = std::fs::read_to_string(&path).unwrap_or_default();
let last_epoch: u64 = content.trim().parse().unwrap_or(0); let now_epoch = unix_now_secs();
let age = now_epoch.saturating_sub(last_epoch);
age < cooldown_secs
}
fn stamp_negative_cache() {
let path = paths::fetch_failed();
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let epoch = unix_now_secs();
let _ = std::fs::write(&path, epoch.to_string());
}
fn http_fetch(url: &str) -> Result<UsageData, FetchError> {
use std::time::Duration;
let http_timeout = std::env::var("CLAUDE_USAGE_HTTP_TIMEOUT")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(DEFAULT_HTTP_TIMEOUT_SECS);
let client = reqwest::blocking::Client::builder()
.connect_timeout(Duration::from_secs(1))
.timeout(Duration::from_secs(http_timeout))
.build()
.map_err(FetchError::Http)?;
let resp = client.get(url).send().map_err(FetchError::Http)?;
let resp = resp.error_for_status().map_err(FetchError::Http)?;
let body = resp.text().map_err(FetchError::Http)?;
if body.trim().is_empty() {
return Err(FetchError::EmptyPayload);
}
let data: UsageData = serde_json::from_str(&body)?;
Ok(data)
}
#[cfg(unix)]
fn ssh_fetch() -> Result<UsageData, FetchError> {
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
let hub = hub_hostname().ok_or_else(|| FetchError::Ssh("no hub hostname configured".into()))?;
if let Some(home) = dirs::home_dir() {
let _ = std::fs::create_dir_all(home.join(".ssh"));
}
let ssh_deadline = std::env::var("CLAUDE_USAGE_SSH_TIMEOUT")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(DEFAULT_SSH_DEADLINE_SECS);
let control_path = dirs::home_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join(".ssh")
.join("cm-claude-%C.sock");
let control_path_str = control_path.to_string_lossy().to_string();
let remote_cmd = r#"cat "$HOME/claude-code-usage/cache/usage-limits.json""#;
let start = Instant::now();
let mut child = Command::new("ssh")
.args([
"-o",
"BatchMode=yes",
"-o",
"ConnectTimeout=4",
"-o",
"ControlMaster=auto",
"-o",
&format!("ControlPath={control_path_str}"),
"-o",
"ControlPersist=300",
hub.as_str(), remote_cmd,
])
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.map_err(|e| FetchError::Ssh(format!("spawn failed: {e}")))?;
let deadline = Duration::from_secs(ssh_deadline);
let output = loop {
match child.try_wait() {
Ok(Some(_)) => {
break child
.wait_with_output()
.map_err(|e| FetchError::Ssh(format!("wait_with_output failed: {e}")))?;
}
Ok(None) => {
if start.elapsed() >= deadline {
let _ = child.kill();
return Err(FetchError::Ssh(format!(
"ssh timed out after {ssh_deadline}s"
)));
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
Err(e) => {
return Err(FetchError::Ssh(format!("wait failed: {e}")));
}
}
};
if !output.status.success() {
return Err(FetchError::Ssh(format!(
"ssh exited with status {}",
output.status
)));
}
let body = String::from_utf8_lossy(&output.stdout).to_string();
if body.trim().is_empty() {
return Err(FetchError::EmptyPayload);
}
let data: UsageData = serde_json::from_str(&body)?;
Ok(data)
}
fn write_positive_cache(data: &UsageData) -> Result<(), FetchError> {
let cache_path = paths::usage_cache();
let parent = cache_path
.parent()
.unwrap_or_else(|| std::path::Path::new("."));
std::fs::create_dir_all(parent)?;
let tmp_path = parent.join(format!(".usage-cache.json.{}", std::process::id()));
let json_bytes = serde_json::to_vec(data)?;
std::fs::write(&tmp_path, &json_bytes)?;
if let Err(e) = std::fs::rename(&tmp_path, &cache_path) {
let _ = std::fs::remove_file(&tmp_path);
return Err(FetchError::Io(e));
}
Ok(())
}
#[cfg(test)]
fn file_age_secs(path: &std::path::Path) -> Result<u64, FetchError> {
let meta = std::fs::metadata(path)?;
Ok(file_age_secs_from_meta(&meta))
}
fn file_age_secs_from_meta(meta: &std::fs::Metadata) -> u64 {
let mtime = meta.modified().unwrap_or(std::time::UNIX_EPOCH);
let now = std::time::SystemTime::now();
now.duration_since(mtime).map(|d| d.as_secs()).unwrap_or(0)
}
fn unix_now_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
#[cfg(test)]
pub(crate) fn parse_usage_json(raw: &str) -> Result<UsageData, FetchError> {
if raw.trim().is_empty() {
return Err(FetchError::EmptyPayload);
}
let data: UsageData = serde_json::from_str(raw)?;
Ok(data)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::sync::Mutex;
use tempfile::TempDir;
static ENV_LOCK: Mutex<()> = Mutex::new(());
const VALID_USAGE_JSON: &str = r#"{
"captured_at": "2026-06-17T07:13:19Z",
"profiles": {
"home": {
"session": { "pct": 42, "resets": "9pm (Asia/Seoul)" },
"week_all": { "pct": 31, "resets": "Jun 18 at 9pm (Asia/Seoul)" }
},
"work": {
"session": { "pct": 5, "resets": null },
"week_all": { "pct": 67, "resets": "Jun 20 at 8:20pm (Asia/Seoul)" },
"week_sonnet": null
}
},
"errors": { "broken": "HTTP 401" }
}"#;
const INVALID_JSON: &str = r#"{ "profiles": { "p": INVALID }"#;
#[cfg(unix)]
fn write_aged_file(path: &std::path::Path, content: &str, age_secs: u64) {
fs::write(path, content).unwrap();
#[cfg(unix)]
{
let target_epoch = unix_now_secs().saturating_sub(age_secs);
let ts = std::process::Command::new("date")
.args(["-r", &target_epoch.to_string(), "+%Y%m%d%H%M.%S"])
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_owned())
.or_else(|| {
std::process::Command::new("date")
.args(["-d", &format!("@{target_epoch}"), "+%Y%m%d%H%M.%S"])
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_owned())
})
.expect("could not format touch timestamp via date -r or date -d");
let status = std::process::Command::new("touch")
.args(["-t", &ts, path.to_string_lossy().as_ref()])
.status()
.expect("touch -t invocation failed");
assert!(status.success(), "touch -t exited with failure for ts={ts}");
}
}
#[test]
fn parse_valid_json_succeeds() {
let result = parse_usage_json(VALID_USAGE_JSON);
assert!(
result.is_ok(),
"expected Ok for valid JSON, got: {result:?}"
);
let data = result.unwrap();
assert!(data.profiles.contains_key("home"));
}
#[test]
fn parse_empty_string_returns_empty_payload() {
let result = parse_usage_json("");
assert!(
matches!(result, Err(FetchError::EmptyPayload)),
"expected EmptyPayload for empty string, got: {result:?}"
);
}
#[test]
fn parse_whitespace_only_returns_empty_payload() {
let result = parse_usage_json(" \n ");
assert!(
matches!(result, Err(FetchError::EmptyPayload)),
"expected EmptyPayload for whitespace, got: {result:?}"
);
}
#[test]
fn parse_invalid_json_returns_json_error() {
let result = parse_usage_json(INVALID_JSON);
assert!(
matches!(result, Err(FetchError::Json(_))),
"expected Json error for invalid JSON, got: {result:?}"
);
}
#[test]
fn parse_minimal_json_succeeds() {
let json = r#"{"profiles": {}}"#;
let result = parse_usage_json(json);
assert!(
result.is_ok(),
"expected Ok for minimal JSON, got: {result:?}"
);
}
#[test]
fn negative_cache_absent_is_not_active() {
let dir = TempDir::new().unwrap();
let _ = dir;
let non_existent = std::path::Path::new("/tmp/csm_test_never_exists_xyz123.fail");
assert!(
!non_existent.exists(),
"precondition: file should not exist"
);
let active = if !non_existent.exists() {
false
} else {
true };
assert!(!active);
}
#[test]
fn negative_cache_content_based_epoch_within_cooldown() {
let now = unix_now_secs();
let stamp = now.saturating_sub(30);
let content = stamp.to_string();
let last_epoch: u64 = content.trim().parse().unwrap_or(0);
let age = now.saturating_sub(last_epoch);
assert!(age < 120, "30s old stamp should be within 120s cooldown");
}
#[test]
fn negative_cache_content_based_epoch_beyond_cooldown() {
let now = unix_now_secs();
let stamp = now.saturating_sub(200);
let last_epoch: u64 = stamp.to_string().trim().parse().unwrap_or(0);
let age = now.saturating_sub(last_epoch);
assert!(age >= 120, "200s old stamp should be beyond 120s cooldown");
}
#[test]
fn negative_cache_empty_content_treated_as_zero() {
let content = "";
let last_epoch: u64 = content.trim().parse().unwrap_or(0);
assert_eq!(last_epoch, 0, "empty content should parse as 0");
}
#[test]
fn negative_cache_non_numeric_content_treated_as_zero() {
let content = "not-a-number";
let last_epoch: u64 = content.trim().parse().unwrap_or(0);
assert_eq!(last_epoch, 0, "non-numeric content should parse as 0");
}
#[test]
fn negative_cache_roundtrip_via_tempdir() {
let dir = TempDir::new().unwrap();
let fail_path = dir.path().join(".usage-fetch-failed");
let now = unix_now_secs();
let stamp = now.saturating_sub(10);
fs::write(&fail_path, stamp.to_string()).unwrap();
let content = fs::read_to_string(&fail_path).unwrap();
let last_epoch: u64 = content.trim().parse().unwrap_or(0);
let age = now.saturating_sub(last_epoch);
assert!(age < 120, "10s old stamp should be within 120s cooldown");
}
#[test]
fn negative_cache_roundtrip_expired_stamp() {
let dir = TempDir::new().unwrap();
let fail_path = dir.path().join(".usage-fetch-failed");
let now = unix_now_secs();
let stamp = now.saturating_sub(150);
fs::write(&fail_path, stamp.to_string()).unwrap();
let content = fs::read_to_string(&fail_path).unwrap();
let last_epoch: u64 = content.trim().parse().unwrap_or(0);
let age = now.saturating_sub(last_epoch);
assert!(age >= 120, "150s old stamp should be beyond 120s cooldown");
}
#[test]
fn positive_cache_fresh_file_has_small_age() {
let dir = TempDir::new().unwrap();
let cache = dir.path().join(".usage-cache.json");
fs::write(&cache, VALID_USAGE_JSON).unwrap();
let meta = fs::metadata(&cache).unwrap();
let age = file_age_secs_from_meta(&meta);
assert!(age < 5, "just-written file should have age < 5s, got {age}");
}
#[test]
#[cfg(unix)]
fn positive_cache_stale_file_exceeds_ttl() {
let dir = TempDir::new().unwrap();
let cache = dir.path().join(".usage-cache.json");
write_aged_file(&cache, VALID_USAGE_JSON, 90);
let meta = fs::metadata(&cache).unwrap();
let age = file_age_secs_from_meta(&meta);
assert!(
age >= 60,
"file aged 90s should have age >= 60s (TTL), got {age}"
);
}
#[test]
#[cfg(unix)]
fn positive_cache_fresh_file_within_ttl() {
let dir = TempDir::new().unwrap();
let cache = dir.path().join(".usage-cache.json");
write_aged_file(&cache, VALID_USAGE_JSON, 30);
let meta = fs::metadata(&cache).unwrap();
let age = file_age_secs_from_meta(&meta);
assert!(
age < 60,
"file aged 30s should have age < 60s (TTL), got {age}"
);
}
#[test]
fn json_validation_gate_blocks_invalid() {
let result = parse_usage_json(INVALID_JSON);
assert!(
matches!(result, Err(FetchError::Json(_))),
"invalid JSON must not pass the validation gate"
);
}
#[test]
fn json_validation_gate_passes_valid() {
let result = parse_usage_json(VALID_USAGE_JSON);
assert!(result.is_ok(), "valid JSON must pass the validation gate");
}
#[test]
fn write_positive_cache_writes_valid_json_atomically() {
let dir = TempDir::new().unwrap();
let cache_path = dir.path().join(".usage-cache.json");
let data: UsageData = serde_json::from_str(VALID_USAGE_JSON).unwrap();
let json_bytes = serde_json::to_vec(&data).unwrap();
let tmp_path = dir.path().join(".usage-cache.json.testpid");
fs::write(&tmp_path, &json_bytes).unwrap();
fs::rename(&tmp_path, &cache_path).unwrap();
assert!(cache_path.exists(), "cache file should exist after write");
assert!(!tmp_path.exists(), "tmp file should not exist after rename");
let on_disk = fs::read_to_string(&cache_path).unwrap();
let parsed: UsageData =
serde_json::from_str(&on_disk).expect("on-disk cache must be valid JSON");
assert!(
parsed.profiles.contains_key("home"),
"on-disk cache should contain home profile"
);
}
#[test]
fn unix_now_secs_is_reasonable() {
let now = unix_now_secs();
assert!(
now > 1_767_225_600,
"unix_now_secs should return a sane epoch, got {now}"
);
}
#[test]
fn stamp_and_read_negative_cache_via_tempdir() {
let now_before = unix_now_secs();
let dir = TempDir::new().unwrap();
let path = dir.path().join("fail");
let epoch = unix_now_secs();
fs::write(&path, epoch.to_string()).unwrap();
let now_after = unix_now_secs();
let content = fs::read_to_string(&path).unwrap();
let stored: u64 = content.trim().parse().unwrap();
assert!(stored >= now_before, "stored epoch should be >= before");
assert!(stored <= now_after, "stored epoch should be <= after");
}
#[test]
fn resolve_usage_url_disabled_when_env_unset() {
let _guard = ENV_LOCK.lock().unwrap();
let saved = std::env::var("CLAUDE_USAGE_URL").ok();
std::env::remove_var("CLAUDE_USAGE_URL");
let url = resolve_usage_url();
assert!(
url.is_none(),
"unset CLAUDE_USAGE_URL should disable HTTP (no compiled default)"
);
match saved {
Some(v) => std::env::set_var("CLAUDE_USAGE_URL", v),
None => std::env::remove_var("CLAUDE_USAGE_URL"),
}
}
#[test]
fn resolve_usage_url_empty_disables_http() {
let _guard = ENV_LOCK.lock().unwrap();
let saved = std::env::var("CLAUDE_USAGE_URL").ok();
std::env::set_var("CLAUDE_USAGE_URL", "");
let url = resolve_usage_url();
assert!(url.is_none(), "empty CLAUDE_USAGE_URL should disable HTTP");
match saved {
Some(v) => std::env::set_var("CLAUDE_USAGE_URL", v),
None => std::env::remove_var("CLAUDE_USAGE_URL"),
}
}
#[test]
fn resolve_usage_url_custom_value() {
let _guard = ENV_LOCK.lock().unwrap();
let saved = std::env::var("CLAUDE_USAGE_URL").ok();
std::env::set_var("CLAUDE_USAGE_URL", "http://custom-hub/api");
let url = resolve_usage_url();
assert_eq!(url.as_deref(), Some("http://custom-hub/api"));
match saved {
Some(v) => std::env::set_var("CLAUDE_USAGE_URL", v),
None => std::env::remove_var("CLAUDE_USAGE_URL"),
}
}
#[test]
fn resolve_usage_command_disabled_when_unset() {
let _guard = ENV_LOCK.lock().unwrap();
let saved = std::env::var("CSM_USAGE_CMD").ok();
std::env::remove_var("CSM_USAGE_CMD");
assert!(resolve_usage_command().is_none(), "unset → None");
std::env::set_var("CSM_USAGE_CMD", " ");
assert!(resolve_usage_command().is_none(), "blank → None");
match saved {
Some(v) => std::env::set_var("CSM_USAGE_CMD", v),
None => std::env::remove_var("CSM_USAGE_CMD"),
}
}
#[test]
#[cfg(unix)]
fn run_usage_command_parses_valid_json_stdout() {
let cmd = format!("printf '%s' '{}'", VALID_USAGE_JSON.replace('\n', " "));
let result = run_usage_command(&cmd);
assert!(result.is_ok(), "expected Ok, got: {result:?}");
assert!(result.unwrap().profiles.contains_key("home"));
}
#[test]
#[cfg(unix)]
fn run_usage_command_nonzero_exit_is_command_error() {
let result = run_usage_command("exit 3");
assert!(
matches!(result, Err(FetchError::Command(_))),
"non-zero exit must be a Command error, got: {result:?}"
);
}
#[test]
#[cfg(unix)]
fn run_usage_command_empty_stdout_is_command_error() {
let result = run_usage_command("true"); assert!(
matches!(result, Err(FetchError::Command(_))),
"empty stdout must be a Command error, got: {result:?}"
);
}
#[test]
#[cfg(unix)]
fn run_usage_command_non_json_stdout_is_command_error() {
let result = run_usage_command("echo not-json-at-all");
assert!(
matches!(result, Err(FetchError::Command(_))),
"non-JSON stdout must be a Command error, got: {result:?}"
);
}
#[test]
#[cfg(unix)]
fn run_usage_command_respects_timeout() {
let _guard = ENV_LOCK.lock().unwrap();
let saved = std::env::var("CSM_USAGE_CMD_TIMEOUT").ok();
std::env::set_var("CSM_USAGE_CMD_TIMEOUT", "1");
let start = std::time::Instant::now();
let result = run_usage_command("sleep 10");
let elapsed = start.elapsed();
assert!(
matches!(result, Err(FetchError::Command(_))),
"a command past the deadline must error, got: {result:?}"
);
assert!(
elapsed < std::time::Duration::from_secs(5),
"timeout must fire well before the command's own 10s, took {elapsed:?}"
);
match saved {
Some(v) => std::env::set_var("CSM_USAGE_CMD_TIMEOUT", v),
None => std::env::remove_var("CSM_USAGE_CMD_TIMEOUT"),
}
}
#[test]
#[cfg(unix)]
fn run_usage_command_handles_large_output_without_deadlock() {
let _guard = ENV_LOCK.lock().unwrap();
let saved = std::env::var("CSM_USAGE_CMD_TIMEOUT").ok();
std::env::set_var("CSM_USAGE_CMD_TIMEOUT", "3");
let n_profiles = 3001;
let cmd = r#"awk 'BEGIN{printf "{\"captured_at\":\"2024-01-01T00:00:00Z\",\"profiles\":{"; for(i=0;i<=3000;i++){if(i>0)printf ","; printf "\"p%d\":{\"session\":{\"pct\":%d,\"resets\":\"2024-01-01T00:00:00Z\"},\"week_all\":{\"pct\":%d,\"resets\":\"2024-01-01T00:00:00Z\"}}",i,i%100,i%100}; printf "},\"errors\":{}}"}'"#;
let start = std::time::Instant::now();
let result = run_usage_command(cmd);
let elapsed = start.elapsed();
assert!(
result.is_ok(),
"large output must parse (deadlock would time out): {result:?}"
);
assert_eq!(
result.unwrap().profiles.len(),
n_profiles,
"all profiles parsed"
);
assert!(
elapsed < std::time::Duration::from_secs(3),
"must not hit the deadline — a deadlock would, took {elapsed:?}"
);
match saved {
Some(v) => std::env::set_var("CSM_USAGE_CMD_TIMEOUT", v),
None => std::env::remove_var("CSM_USAGE_CMD_TIMEOUT"),
}
}
#[test]
#[cfg(unix)]
fn run_usage_command_timeout_is_hard_even_when_a_grandchild_holds_the_pipe() {
let _guard = ENV_LOCK.lock().unwrap();
let saved = std::env::var("CSM_USAGE_CMD_TIMEOUT").ok();
std::env::set_var("CSM_USAGE_CMD_TIMEOUT", "1");
let start = std::time::Instant::now();
let result = run_usage_command("sleep 30 | cat");
let elapsed = start.elapsed();
assert!(
matches!(result, Err(FetchError::Command(_))),
"a command past the deadline must error, got: {result:?}"
);
assert!(
elapsed < std::time::Duration::from_secs(5),
"timeout must stay hard even with a grandchild holding the pipe, took {elapsed:?}"
);
match saved {
Some(v) => std::env::set_var("CSM_USAGE_CMD_TIMEOUT", v),
None => std::env::remove_var("CSM_USAGE_CMD_TIMEOUT"),
}
}
#[test]
fn is_configured_true_when_only_command_set() {
let _guard = ENV_LOCK.lock().unwrap();
let saved_cmd = std::env::var("CSM_USAGE_CMD").ok();
let saved_url = std::env::var("CLAUDE_USAGE_URL").ok();
let saved_hub = std::env::var("CLAUDE_HUB_HOSTNAME").ok();
std::env::set_var("CLAUDE_USAGE_URL", "");
std::env::remove_var("CLAUDE_HUB_HOSTNAME");
std::env::set_var("CSM_USAGE_CMD", "echo {}");
assert!(
is_configured(),
"CSM_USAGE_CMD alone must count as configured"
);
match saved_cmd {
Some(v) => std::env::set_var("CSM_USAGE_CMD", v),
None => std::env::remove_var("CSM_USAGE_CMD"),
}
match saved_url {
Some(v) => std::env::set_var("CLAUDE_USAGE_URL", v),
None => std::env::remove_var("CLAUDE_USAGE_URL"),
}
match saved_hub {
Some(v) => std::env::set_var("CLAUDE_HUB_HOSTNAME", v),
None => std::env::remove_var("CLAUDE_HUB_HOSTNAME"),
}
}
#[test]
fn positive_ttl_alias_csm_secs() {
let _guard = ENV_LOCK.lock().unwrap();
let saved_legacy = std::env::var("CLAUDE_USAGE_TTL").ok();
let saved_alias = std::env::var("CSM_USAGE_TTL_SECS").ok();
std::env::remove_var("CLAUDE_USAGE_TTL");
std::env::set_var("CSM_USAGE_TTL_SECS", "17");
assert_eq!(positive_ttl_secs(), 17, "alias should be honored");
std::env::set_var("CLAUDE_USAGE_TTL", "5");
assert_eq!(positive_ttl_secs(), 5, "legacy var should win over alias");
match saved_legacy {
Some(v) => std::env::set_var("CLAUDE_USAGE_TTL", v),
None => std::env::remove_var("CLAUDE_USAGE_TTL"),
}
match saved_alias {
Some(v) => std::env::set_var("CSM_USAGE_TTL_SECS", v),
None => std::env::remove_var("CSM_USAGE_TTL_SECS"),
}
}
#[test]
fn short_hostname_is_nonempty() {
let h = short_hostname();
assert!(!h.is_empty(), "short_hostname() must not be empty");
}
#[test]
fn hub_hostname_env_contract() {
let _guard = ENV_LOCK.lock().unwrap();
let saved = std::env::var("CLAUDE_HUB_HOSTNAME").ok();
std::env::remove_var("CLAUDE_HUB_HOSTNAME");
assert!(hub_hostname().is_none(), "unset → None");
std::env::set_var("CLAUDE_HUB_HOSTNAME", " ");
assert!(hub_hostname().is_none(), "blank → None");
std::env::set_var("CLAUDE_HUB_HOSTNAME", " Some-Hub ");
assert_eq!(hub_hostname().as_deref(), Some("some-hub"));
match saved {
Some(v) => std::env::set_var("CLAUDE_HUB_HOSTNAME", v),
None => std::env::remove_var("CLAUDE_HUB_HOSTNAME"),
}
}
#[test]
fn positive_ttl_defaults_to_60() {
let _guard = ENV_LOCK.lock().unwrap();
let saved = std::env::var("CLAUDE_USAGE_TTL").ok();
std::env::remove_var("CLAUDE_USAGE_TTL");
assert_eq!(positive_ttl_secs(), 60);
match saved {
Some(v) => std::env::set_var("CLAUDE_USAGE_TTL", v),
None => std::env::remove_var("CLAUDE_USAGE_TTL"),
}
}
#[test]
fn positive_ttl_respects_env_override() {
let _guard = ENV_LOCK.lock().unwrap();
let saved = std::env::var("CLAUDE_USAGE_TTL").ok();
std::env::set_var("CLAUDE_USAGE_TTL", "30");
assert_eq!(positive_ttl_secs(), 30);
match saved {
Some(v) => std::env::set_var("CLAUDE_USAGE_TTL", v),
None => std::env::remove_var("CLAUDE_USAGE_TTL"),
}
}
#[test]
fn negative_cooldown_defaults_to_120() {
let _guard = ENV_LOCK.lock().unwrap();
let saved = std::env::var("CLAUDE_USAGE_FAIL_COOLDOWN").ok();
std::env::remove_var("CLAUDE_USAGE_FAIL_COOLDOWN");
assert_eq!(negative_cooldown_secs(), 120);
match saved {
Some(v) => std::env::set_var("CLAUDE_USAGE_FAIL_COOLDOWN", v),
None => std::env::remove_var("CLAUDE_USAGE_FAIL_COOLDOWN"),
}
}
#[test]
fn negative_cooldown_respects_env_override() {
let _guard = ENV_LOCK.lock().unwrap();
let saved = std::env::var("CLAUDE_USAGE_FAIL_COOLDOWN").ok();
std::env::set_var("CLAUDE_USAGE_FAIL_COOLDOWN", "60");
assert_eq!(negative_cooldown_secs(), 60);
match saved {
Some(v) => std::env::set_var("CLAUDE_USAGE_FAIL_COOLDOWN", v),
None => std::env::remove_var("CLAUDE_USAGE_FAIL_COOLDOWN"),
}
}
#[test]
fn file_age_secs_fresh_file_is_small() {
let dir = TempDir::new().unwrap();
let f = dir.path().join("test.txt");
fs::write(&f, "hello").unwrap();
let age = file_age_secs(&f).unwrap();
assert!(age < 5, "just-written file age should be < 5s, got {age}");
}
#[test]
fn file_age_secs_missing_file_returns_io_err() {
let result = file_age_secs(std::path::Path::new("/tmp/csm_nonexistent_xyz123.txt"));
assert!(
matches!(result, Err(FetchError::Io(_))),
"missing file should return Io error"
);
}
#[test]
#[cfg(unix)]
fn file_age_secs_aged_file_matches_expected() {
let dir = TempDir::new().unwrap();
let f = dir.path().join("old.txt");
write_aged_file(&f, "data", 70);
let age = file_age_secs(&f).unwrap();
assert!(
(65..=80).contains(&age),
"file aged 70s should report age ≈ 70s, got {age}"
);
}
}