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};
8use std::path::Path;
9use std::sync::atomic::{AtomicBool, Ordering};
10
11/// Whether this process may probe the installed provider CLIs for their
12/// versions. Off until [`enable_cli_version_detection`] turns it on.
13static CLI_VERSION_DETECTION: AtomicBool = AtomicBool::new(false);
14
15/// Lets [`detect_cli_version`] probe the installed CLIs for this process.
16///
17/// Probing spawns `<bin> --version` and caches the answer under `~/.vct` —
18/// side effects that belong to the application rather than to a library call,
19/// so `vct-core` keeps them off until the binary opts in (once, before
20/// dispatch). Any other embedder — including the test suite — gets each
21/// provider's fallback version without a subprocess or a home directory write.
22pub fn enable_cli_version_detection() {
23    CLI_VERSION_DETECTION.store(true, Ordering::Relaxed);
24}
25
26/// Whether CLI version probing has been enabled for this process.
27fn cli_version_detection_enabled() -> bool {
28    CLI_VERSION_DETECTION.load(Ordering::Relaxed)
29}
30
31/// Detects an installed CLI's version by running `<bin> --version`, caching the
32/// result under `~/.vct/<cache_file>` for the day so it is not re-run on every
33/// launch. Falls back to `fallback` when the CLI is absent or unreadable, so the
34/// User-Agent it feeds is always a plausible client version — and returns that
35/// same fallback outright until [`enable_cli_version_detection`] is called.
36pub fn detect_cli_version(bin: &str, cache_file: &str, fallback: &str) -> String {
37    if !cli_version_detection_enabled() {
38        return fallback.to_string();
39    }
40    if let Some(v) = read_cached_version(cache_file) {
41        return v;
42    }
43    if let Some(v) = run_cli_version(bin) {
44        let _ = write_cached_version(cache_file, &v);
45        return v;
46    }
47    fallback.to_string()
48}
49
50/// Extracts the first version-shaped token (`2.1.201`, `0.142.5`) from a CLI's
51/// `--version` output, tolerating a leading program name (`codex-cli 0.142.5`)
52/// or a trailing label (`2.1.201 (Claude Code)`).
53pub fn parse_version(raw: &str) -> Option<String> {
54    raw.split_whitespace()
55        .find(|t| t.starts_with(|c: char| c.is_ascii_digit()) && t.contains('.'))
56        .map(str::to_string)
57}
58
59/// Reads the `{version, last_checked_at}` cache, returning the version only if
60/// it was stamped earlier on the current UTC day.
61fn read_cached_version(cache_file: &str) -> Option<String> {
62    read_cached_version_in(&crate::utils::get_cache_dir().ok()?, cache_file)
63}
64
65/// The injectable core of [`read_cached_version`]: reads from an explicit cache
66/// directory. Production passes `~/.vct`; tests pass a temp dir.
67fn read_cached_version_in(dir: &Path, cache_file: &str) -> Option<String> {
68    let text = std::fs::read_to_string(dir.join(cache_file)).ok()?;
69    let v: serde_json::Value = serde_json::from_str(&text).ok()?;
70    if !is_today_utc(v.get("last_checked_at")?.as_str()?) {
71        return None;
72    }
73    v.get("version")?.as_str().map(str::to_string)
74}
75
76/// Persists `version` stamped with the current UTC time (best-effort, atomic).
77fn write_cached_version(cache_file: &str, version: &str) -> Result<()> {
78    write_cached_version_in(&crate::utils::get_cache_dir()?, cache_file, version)
79}
80
81/// The injectable core of [`write_cached_version`], writing into an explicit
82/// cache directory.
83fn write_cached_version_in(dir: &Path, cache_file: &str, version: &str) -> Result<()> {
84    crate::utils::write_json_atomic(
85        dir.join(cache_file),
86        &serde_json::json!({
87            "version": version,
88            "last_checked_at": crate::utils::now_rfc3339_utc_nanos(),
89        }),
90    )
91}
92
93/// Runs `<bin> --version` and parses the version token from stdout.
94fn run_cli_version(bin: &str) -> Option<String> {
95    let output = std::process::Command::new(bin)
96        .arg("--version")
97        .output()
98        .ok()?;
99    output.status.success().then_some(())?;
100    parse_version(&String::from_utf8_lossy(&output.stdout))
101}
102
103/// Whether `ts` (an RFC3339 timestamp) falls on the current UTC calendar day.
104///
105/// The version cache stores a full RFC3339 nanosecond stamp but is only
106/// refreshed once per day, so staleness is decided on the UTC date alone. An
107/// unparseable stamp reads as stale so the version is re-detected.
108fn is_today_utc(ts: &str) -> bool {
109    chrono::DateTime::parse_from_rfc3339(ts)
110        .map(|dt| dt.with_timezone(&chrono::Utc).date_naive() == chrono::Utc::now().date_naive())
111        .unwrap_or(false)
112}
113
114/// Builds the shared blocking HTTP client (8s timeout, no default UA).
115///
116/// The UA is intentionally left unset so each request can supply its own via a
117/// header; setting it on both the client and the request would send a duplicate
118/// `User-Agent`.
119///
120/// # Errors
121///
122/// Returns an error if the client cannot be constructed.
123pub fn build_client() -> Result<reqwest::blocking::Client> {
124    reqwest::blocking::Client::builder()
125        .timeout(std::time::Duration::from_secs(8))
126        .build()
127        .context("Failed to build HTTP client")
128}
129
130/// Parses an ISO-8601 timestamp into Unix **seconds**, or `None` on failure.
131///
132/// [`crate::utils::parse_iso_timestamp`] returns Unix *milliseconds* and `0` on
133/// failure; this divides by 1000 and maps `0` to `None` so a bad timestamp
134/// renders as "no reset" rather than the epoch.
135pub fn iso_to_unix_secs(s: &str) -> Option<i64> {
136    let ms = crate::utils::parse_iso_timestamp(s);
137    (ms > 0).then_some(ms / 1000)
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn iso_to_unix_secs_handles_bad_input() {
146        assert_eq!(iso_to_unix_secs("not-a-date"), None);
147        assert!(iso_to_unix_secs("2026-07-03T17:09:59.651608+00:00").unwrap() > 0);
148    }
149
150    #[test]
151    fn is_today_utc_matches_now_but_not_a_past_day() {
152        let now = crate::utils::now_rfc3339_utc_nanos();
153        assert!(is_today_utc(&now));
154        assert!(!is_today_utc("2000-01-01T00:00:00Z"));
155        assert!(!is_today_utc("not-a-timestamp"));
156    }
157
158    /// Nothing in the suite may enable probing: it would spawn a provider CLI
159    /// and write into the developer's real `~/.vct`.
160    #[test]
161    fn cli_version_detection_stays_off_outside_the_binary() {
162        assert!(!cli_version_detection_enabled());
163        assert_eq!(
164            detect_cli_version("cargo", "cargo_version.json", "1.2.3"),
165            "1.2.3"
166        );
167    }
168
169    #[test]
170    fn cached_version_round_trips_and_goes_stale_the_next_utc_day() {
171        let dir = tempfile::tempdir().unwrap();
172        assert_eq!(
173            read_cached_version_in(dir.path(), "grok_version.json"),
174            None
175        );
176
177        write_cached_version_in(dir.path(), "grok_version.json", "0.2.112").unwrap();
178        assert_eq!(
179            read_cached_version_in(dir.path(), "grok_version.json").as_deref(),
180            Some("0.2.112")
181        );
182
183        std::fs::write(
184            dir.path().join("grok_version.json"),
185            serde_json::json!({ "version": "0.1.0", "last_checked_at": "2000-01-01T00:00:00Z" })
186                .to_string(),
187        )
188        .unwrap();
189        assert_eq!(
190            read_cached_version_in(dir.path(), "grok_version.json"),
191            None
192        );
193    }
194
195    #[test]
196    fn parse_version_handles_leading_or_trailing_labels() {
197        // Claude: version first, label after.
198        assert_eq!(
199            parse_version("2.1.201 (Claude Code)").as_deref(),
200            Some("2.1.201")
201        );
202        // Codex: program name first, version after.
203        assert_eq!(
204            parse_version("codex-cli 0.142.5").as_deref(),
205            Some("0.142.5")
206        );
207        assert_eq!(parse_version("  2.0.14\n").as_deref(), Some("2.0.14"));
208        assert_eq!(parse_version(""), None);
209        assert_eq!(parse_version("Claude Code"), None);
210    }
211}