Skip to main content

vct_core/quota/
http.rs

1//! Shared HTTP helpers for the quota fetchers.
2//!
3//! One blocking client is built without a default `User-Agent` and shared
4//! across the provider workers; each request sets its own UA. Also holds the
5//! ISO-timestamp conversion used by the fetchers.
6
7use anyhow::{Context, Result};
8
9/// Detects an installed CLI's version by running `<bin> --version`, caching the
10/// result under `~/.vct/<cache_file>` for the day so it is not re-run on every
11/// launch. Falls back to `fallback` when the CLI is absent or unreadable, so the
12/// User-Agent it feeds is always a plausible client version.
13pub fn detect_cli_version(bin: &str, cache_file: &str, fallback: &str) -> String {
14    if let Some(v) = read_cached_version(cache_file) {
15        return v;
16    }
17    if let Some(v) = run_cli_version(bin) {
18        let _ = write_cached_version(cache_file, &v);
19        return v;
20    }
21    fallback.to_string()
22}
23
24/// Extracts the first version-shaped token (`2.1.201`, `0.142.5`) from a CLI's
25/// `--version` output, tolerating a leading program name (`codex-cli 0.142.5`)
26/// or a trailing label (`2.1.201 (Claude Code)`).
27pub fn parse_version(raw: &str) -> Option<String> {
28    raw.split_whitespace()
29        .find(|t| t.starts_with(|c: char| c.is_ascii_digit()) && t.contains('.'))
30        .map(str::to_string)
31}
32
33/// Reads the `{version, last_checked_at}` cache, returning the version only if
34/// it was stamped earlier on the current UTC day.
35fn read_cached_version(cache_file: &str) -> Option<String> {
36    let path = crate::utils::get_cache_dir().ok()?.join(cache_file);
37    let text = std::fs::read_to_string(path).ok()?;
38    let v: serde_json::Value = serde_json::from_str(&text).ok()?;
39    if !is_today_utc(v.get("last_checked_at")?.as_str()?) {
40        return None;
41    }
42    v.get("version")?.as_str().map(str::to_string)
43}
44
45/// Persists `version` stamped with the current UTC time (best-effort, atomic).
46fn write_cached_version(cache_file: &str, version: &str) -> Result<()> {
47    let path = crate::utils::get_cache_dir()?.join(cache_file);
48    crate::utils::write_json_atomic(
49        path,
50        &serde_json::json!({
51            "version": version,
52            "last_checked_at": crate::utils::now_rfc3339_utc_nanos(),
53        }),
54    )
55}
56
57/// Runs `<bin> --version` and parses the version token from stdout.
58fn run_cli_version(bin: &str) -> Option<String> {
59    let output = std::process::Command::new(bin)
60        .arg("--version")
61        .output()
62        .ok()?;
63    output.status.success().then_some(())?;
64    parse_version(&String::from_utf8_lossy(&output.stdout))
65}
66
67/// Whether `ts` (an RFC3339 timestamp) falls on the current UTC calendar day.
68///
69/// The version cache stores a full RFC3339 nanosecond stamp but is only
70/// refreshed once per day, so staleness is decided on the UTC date alone. An
71/// unparseable stamp reads as stale so the version is re-detected.
72fn is_today_utc(ts: &str) -> bool {
73    chrono::DateTime::parse_from_rfc3339(ts)
74        .map(|dt| dt.with_timezone(&chrono::Utc).date_naive() == chrono::Utc::now().date_naive())
75        .unwrap_or(false)
76}
77
78/// Builds the shared blocking HTTP client (8s timeout, no default UA).
79///
80/// The UA is intentionally left unset so each request can supply its own via a
81/// header; setting it on both the client and the request would send a duplicate
82/// `User-Agent`.
83///
84/// # Errors
85///
86/// Returns an error if the client cannot be constructed.
87pub fn build_client() -> Result<reqwest::blocking::Client> {
88    reqwest::blocking::Client::builder()
89        .timeout(std::time::Duration::from_secs(8))
90        .build()
91        .context("Failed to build HTTP client")
92}
93
94/// Parses an ISO-8601 timestamp into Unix **seconds**, or `None` on failure.
95///
96/// [`crate::utils::parse_iso_timestamp`] returns Unix *milliseconds* and `0` on
97/// failure; this divides by 1000 and maps `0` to `None` so a bad timestamp
98/// renders as "no reset" rather than the epoch.
99pub fn iso_to_unix_secs(s: &str) -> Option<i64> {
100    let ms = crate::utils::parse_iso_timestamp(s);
101    (ms > 0).then_some(ms / 1000)
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    #[test]
109    fn iso_to_unix_secs_handles_bad_input() {
110        assert_eq!(iso_to_unix_secs("not-a-date"), None);
111        assert!(iso_to_unix_secs("2026-07-03T17:09:59.651608+00:00").unwrap() > 0);
112    }
113
114    #[test]
115    fn is_today_utc_matches_now_but_not_a_past_day() {
116        let now = crate::utils::now_rfc3339_utc_nanos();
117        assert!(is_today_utc(&now));
118        assert!(!is_today_utc("2000-01-01T00:00:00Z"));
119        assert!(!is_today_utc("not-a-timestamp"));
120    }
121
122    #[test]
123    fn parse_version_handles_leading_or_trailing_labels() {
124        // Claude: version first, label after.
125        assert_eq!(
126            parse_version("2.1.201 (Claude Code)").as_deref(),
127            Some("2.1.201")
128        );
129        // Codex: program name first, version after.
130        assert_eq!(
131            parse_version("codex-cli 0.142.5").as_deref(),
132            Some("0.142.5")
133        );
134        assert_eq!(parse_version("  2.0.14\n").as_deref(), Some("2.0.14"));
135        assert_eq!(parse_version(""), None);
136        assert_eq!(parse_version("Claude Code"), None);
137    }
138}