1use std::fmt::Write as _;
11use std::time::Duration;
12
13use chrono::DateTime;
14use serde_json::Value;
15use sha2::{Digest, Sha256};
16
17use crate::cache::{Cache, acquire_lock_async};
18use crate::error::{AppError, Result};
19use crate::vendor::{MAX_BODY_BYTES, read_body_capped};
20
21use super::types::{Snapshot, apply_subscription, parse_credits};
22
23pub const BASE_URL: &str = "https://api.commandcode.ai";
24
25pub const WHOAMI_PATH: &str = "/alpha/whoami";
26pub const CREDITS_PATH: &str = "/alpha/billing/credits";
27pub const SUBSCRIPTIONS_PATH: &str = "/alpha/billing/subscriptions";
28
29const HTTP_TIMEOUT: Duration = Duration::from_secs(10);
30const LOCK_TIMEOUT: Duration = Duration::from_secs(15);
31const SCHEMA_ERROR: &str = "Command Code usage response schema mismatch";
32
33#[derive(Debug, Clone)]
34pub struct Endpoints {
35 pub base: String,
36}
37
38impl Default for Endpoints {
39 fn default() -> Self {
40 Self {
41 base: BASE_URL.to_string(),
42 }
43 }
44}
45
46impl Endpoints {
47 fn url(&self, path: &str) -> String {
48 format!("{}{path}", self.base.trim_end_matches('/'))
49 }
50}
51
52pub type FetchOutcome = crate::outcome::Outcome<Snapshot>;
55
56pub async fn fetch_snapshot(
57 client: &reqwest::Client,
58 token: &str,
59 cache: &Cache,
60 endpoints: &Endpoints,
61 ttl: Duration,
62) -> Result<FetchOutcome> {
63 cache.ensure_dir()?;
64 let _lock = acquire_lock_async(&cache.lock_path(), LOCK_TIMEOUT).await?;
65 let target = target_key(endpoints, token);
66
67 if let Some(bytes) = cache.fresh_payload(ttl)?
68 && let Ok(snapshot) = parse_cache(&bytes, &target)
69 {
70 return Ok(crate::outcome::Outcome::cached(snapshot, cache, false));
71 }
72
73 match fetch_live(client, token, endpoints).await {
74 Ok(snapshot) => {
75 let body = serde_json::to_vec(&serde_json::json!({
76 "target": target,
77 "response": snapshot_repr(&snapshot),
78 }))?;
79 cache.write_payload(&body)?;
80 Ok(crate::outcome::Outcome::fresh(snapshot))
81 }
82 Err(error @ AppError::Transport(_)) => fallback_or_error(cache, None, &target, error),
83 Err(AppError::Http { status, .. }) => {
84 let message = status_message(status).to_string();
85 cache.mark_stale();
86 cache.write_last_error(status, &message);
87 fallback_or_error(
88 cache,
89 Some((status, message.clone())),
90 &target,
91 AppError::Http {
92 status,
93 body: message,
94 },
95 )
96 }
97 Err(AppError::Schema(_)) => {
98 let message = SCHEMA_ERROR.to_string();
99 cache.mark_stale();
100 cache.write_last_error(0, &message);
101 fallback_or_error(
102 cache,
103 Some((0, message.clone())),
104 &target,
105 AppError::Schema(message),
106 )
107 }
108 Err(error) => fallback_or_error(cache, None, &target, error),
109 }
110}
111
112async fn fetch_live(
113 client: &reqwest::Client,
114 token: &str,
115 endpoints: &Endpoints,
116) -> Result<Snapshot> {
117 let org_id = get_json(client, token, &endpoints.url(WHOAMI_PATH), &[])
120 .await
121 .ok()
122 .and_then(|value| value.get("org")?.get("id")?.as_str().map(str::to_string));
123 let scope: Vec<(&str, String)> = org_id.iter().map(|id| ("orgId", id.clone())).collect();
124
125 let credits = get_json(client, token, &endpoints.url(CREDITS_PATH), &scope).await?;
126 let mut snapshot =
127 parse_credits(&credits).map_err(|error| AppError::Schema(error.to_string()))?;
128
129 if let Ok(subscription) =
131 get_json(client, token, &endpoints.url(SUBSCRIPTIONS_PATH), &scope).await
132 {
133 apply_subscription(&mut snapshot, &subscription);
134 }
135
136 Ok(snapshot)
137}
138
139async fn get_json(
140 client: &reqwest::Client,
141 token: &str,
142 url: &str,
143 query: &[(&str, String)],
144) -> Result<Value> {
145 let response = tokio::time::timeout(
146 HTTP_TIMEOUT,
147 client
148 .get(url)
149 .bearer_auth(token)
150 .query(query)
151 .header(reqwest::header::ACCEPT, "application/json")
152 .send(),
153 )
154 .await
155 .map_err(|_| AppError::Transport("Command Code request timed out".to_string()))??;
156
157 let status = response.status();
158 let body = read_body_capped(response, MAX_BODY_BYTES).await?;
159 if !status.is_success() {
160 return Err(AppError::Http {
161 status: status.as_u16(),
162 body: status_message(status.as_u16()).to_string(),
163 });
164 }
165 serde_json::from_slice(&body).map_err(|error| AppError::Schema(error.to_string()))
166}
167
168fn target_key(endpoints: &Endpoints, token: &str) -> String {
171 let digest = Sha256::digest(token.as_bytes());
172 let mut fingerprint = String::with_capacity(digest.len() * 2);
173 for byte in digest {
174 let _ = write!(fingerprint, "{byte:02x}");
175 }
176 format!("{}|key:{fingerprint}", endpoints.base)
177}
178
179fn status_message(status: u16) -> &'static str {
180 match status {
181 401 | 403 => crate::error::AUTH_FAILURE_MESSAGE,
182 429 => "Command Code rate limited the usage request",
183 500..=599 => "Command Code usage endpoint is unavailable",
184 _ => "Command Code usage request failed",
185 }
186}
187
188fn snapshot_repr(snapshot: &Snapshot) -> Value {
191 let window = |window: &Option<super::types::SpendWindow>| {
192 window.as_ref().map(|w| {
193 serde_json::json!({
194 "used": w.used,
195 "cap": w.cap,
196 "resetAt": w.resets_at.map(|at| at.timestamp_millis()),
197 })
198 })
199 };
200 serde_json::json!({
201 "plan": snapshot.plan,
202 "creditPool": snapshot.credit_pool,
203 "periodEnd": snapshot.period_end.map(|at| at.timestamp_millis()),
204 "credits": snapshot.credits.as_ref().map(|c| serde_json::json!({
205 "monthlyCredits": c.monthly,
206 "purchasedCredits": c.purchased,
207 "freeCredits": c.free,
208 })),
209 "windowLimits": {
210 "fiveHour": window(&snapshot.five_hour),
211 "weekly": window(&snapshot.weekly),
212 },
213 })
214}
215
216fn parse_cache(bytes: &[u8], target: &str) -> Result<Snapshot> {
217 let value: Value = serde_json::from_slice(bytes)
218 .map_err(|_| AppError::Schema("Command Code cache is invalid".into()))?;
219 if value.get("target").and_then(Value::as_str) != Some(target) {
220 return Err(AppError::Schema(
221 "Command Code cache belongs to a different account".into(),
222 ));
223 }
224 let response = value
225 .get("response")
226 .ok_or_else(|| AppError::Schema("Command Code cache is missing its response".into()))?;
227 let mut snapshot =
228 parse_credits(response).map_err(|error| AppError::Schema(error.to_string()))?;
229 snapshot.plan = response
230 .get("plan")
231 .and_then(Value::as_str)
232 .map(str::to_string);
233 snapshot.credit_pool = response.get("creditPool").and_then(Value::as_f64);
234 snapshot.period_end = response
237 .get("periodEnd")
238 .and_then(Value::as_i64)
239 .and_then(DateTime::from_timestamp_millis);
240 Ok(snapshot)
241}
242
243fn fallback_or_error(
244 cache: &Cache,
245 last_error: Option<(u16, String)>,
246 target: &str,
247 error: AppError,
248) -> Result<FetchOutcome> {
249 crate::outcome::fallback(cache, last_error, error, |body| parse_cache(body, target))
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255 use crate::cache::Cache;
256
257 const CREDITS_BODY: &str = include_str!("../../tests/fixtures/commandcode/credits.json");
258 const SUBSCRIPTION_BODY: &str =
259 include_str!("../../tests/fixtures/commandcode/subscriptions.json");
260
261 fn cache_in(dir: &std::path::Path) -> Cache {
262 Cache::at(dir.join("commandcode"))
263 }
264
265 fn endpoints(server: &mockito::Server) -> Endpoints {
266 Endpoints { base: server.url() }
267 }
268
269 #[tokio::test]
270 async fn walks_the_chain_and_scopes_calls_to_the_org() {
271 let mut server = mockito::Server::new_async().await;
272 let whoami = server
273 .mock("GET", WHOAMI_PATH)
274 .with_body(r#"{"org":{"id":"org-42"}}"#)
275 .create_async()
276 .await;
277 let credits = server
278 .mock("GET", CREDITS_PATH)
279 .match_query(mockito::Matcher::UrlEncoded(
280 "orgId".into(),
281 "org-42".into(),
282 ))
283 .with_body(CREDITS_BODY)
284 .create_async()
285 .await;
286 let subscription = server
287 .mock("GET", SUBSCRIPTIONS_PATH)
288 .match_query(mockito::Matcher::UrlEncoded(
289 "orgId".into(),
290 "org-42".into(),
291 ))
292 .with_body(SUBSCRIPTION_BODY)
293 .create_async()
294 .await;
295 let dir = tempfile::tempdir().unwrap();
296
297 let outcome = fetch_snapshot(
298 &reqwest::Client::new(),
299 "tok",
300 &cache_in(dir.path()),
301 &endpoints(&server),
302 Duration::from_secs(60),
303 )
304 .await
305 .expect("fetch must succeed");
306
307 whoami.assert_async().await;
308 credits.assert_async().await;
309 subscription.assert_async().await;
310 assert_eq!(outcome.snapshot.plan.as_deref(), Some("GOAT"));
311 assert_eq!(outcome.snapshot.five_hour.unwrap().pct(), 25);
312 assert!(!outcome.stale);
313 }
314
315 #[tokio::test]
316 async fn a_personal_account_without_an_org_still_gets_its_ledger() {
317 let mut server = mockito::Server::new_async().await;
318 server
319 .mock("GET", WHOAMI_PATH)
320 .with_body(r#"{"org":null}"#)
321 .create_async()
322 .await;
323 let credits = server
324 .mock("GET", CREDITS_PATH)
325 .match_query(mockito::Matcher::Missing)
326 .with_body(CREDITS_BODY)
327 .create_async()
328 .await;
329 server
330 .mock("GET", SUBSCRIPTIONS_PATH)
331 .with_body(SUBSCRIPTION_BODY)
332 .create_async()
333 .await;
334 let dir = tempfile::tempdir().unwrap();
335
336 let outcome = fetch_snapshot(
337 &reqwest::Client::new(),
338 "tok",
339 &cache_in(dir.path()),
340 &endpoints(&server),
341 Duration::from_secs(60),
342 )
343 .await
344 .expect("fetch must succeed without an org");
345
346 credits.assert_async().await;
347 assert!(outcome.snapshot.weekly.is_some());
348 }
349
350 #[tokio::test]
351 async fn a_failing_subscription_costs_the_plan_not_the_windows() {
352 let mut server = mockito::Server::new_async().await;
353 server
354 .mock("GET", WHOAMI_PATH)
355 .with_body("{}")
356 .create_async()
357 .await;
358 server
359 .mock("GET", CREDITS_PATH)
360 .with_body(CREDITS_BODY)
361 .create_async()
362 .await;
363 server
364 .mock("GET", SUBSCRIPTIONS_PATH)
365 .with_status(500)
366 .create_async()
367 .await;
368 let dir = tempfile::tempdir().unwrap();
369
370 let outcome = fetch_snapshot(
371 &reqwest::Client::new(),
372 "tok",
373 &cache_in(dir.path()),
374 &endpoints(&server),
375 Duration::from_secs(60),
376 )
377 .await
378 .expect("windows must survive a subscription failure");
379
380 assert!(outcome.snapshot.plan.is_none());
381 assert_eq!(outcome.snapshot.weekly.unwrap().pct(), 30);
382 }
383
384 #[tokio::test]
385 async fn a_failing_ledger_is_fatal_and_maps_401_to_the_auth_message() {
386 let mut server = mockito::Server::new_async().await;
387 server
388 .mock("GET", WHOAMI_PATH)
389 .with_body("{}")
390 .create_async()
391 .await;
392 server
393 .mock("GET", CREDITS_PATH)
394 .with_status(401)
395 .create_async()
396 .await;
397 let dir = tempfile::tempdir().unwrap();
398
399 let error = fetch_snapshot(
400 &reqwest::Client::new(),
401 "tok",
402 &cache_in(dir.path()),
403 &endpoints(&server),
404 Duration::from_secs(60),
405 )
406 .await
407 .expect_err("the ledger is load-bearing");
408
409 assert!(
410 error
411 .to_string()
412 .contains(crate::error::AUTH_FAILURE_MESSAGE),
413 "{error}"
414 );
415 }
416
417 #[tokio::test]
418 async fn a_fresh_cache_is_served_without_touching_the_network() {
419 let mut server = mockito::Server::new_async().await;
420 let whoami = server
421 .mock("GET", WHOAMI_PATH)
422 .expect(1)
423 .with_body("{}")
424 .create_async()
425 .await;
426 server
427 .mock("GET", CREDITS_PATH)
428 .expect(1)
429 .with_body(CREDITS_BODY)
430 .create_async()
431 .await;
432 server
433 .mock("GET", SUBSCRIPTIONS_PATH)
434 .expect(1)
435 .with_body(SUBSCRIPTION_BODY)
436 .create_async()
437 .await;
438 let dir = tempfile::tempdir().unwrap();
439 let cache = cache_in(dir.path());
440 let endpoints = endpoints(&server);
441 let client = reqwest::Client::new();
442
443 let first = fetch_snapshot(&client, "tok", &cache, &endpoints, Duration::from_secs(600))
444 .await
445 .unwrap();
446 let second = fetch_snapshot(&client, "tok", &cache, &endpoints, Duration::from_secs(600))
447 .await
448 .unwrap();
449
450 whoami.assert_async().await;
452 assert_eq!(first.snapshot, second.snapshot);
453 assert_eq!(second.snapshot.plan.as_deref(), Some("GOAT"));
454 }
455
456 #[tokio::test]
457 async fn a_cache_written_for_another_token_is_not_reused() {
458 let mut server = mockito::Server::new_async().await;
459 server
460 .mock("GET", WHOAMI_PATH)
461 .with_body("{}")
462 .expect_at_least(2)
463 .create_async()
464 .await;
465 server
466 .mock("GET", CREDITS_PATH)
467 .with_body(CREDITS_BODY)
468 .expect_at_least(2)
469 .create_async()
470 .await;
471 server
472 .mock("GET", SUBSCRIPTIONS_PATH)
473 .with_body(SUBSCRIPTION_BODY)
474 .expect_at_least(2)
475 .create_async()
476 .await;
477 let dir = tempfile::tempdir().unwrap();
478 let cache = cache_in(dir.path());
479 let endpoints = endpoints(&server);
480 let client = reqwest::Client::new();
481
482 fetch_snapshot(
483 &client,
484 "token-a",
485 &cache,
486 &endpoints,
487 Duration::from_secs(600),
488 )
489 .await
490 .unwrap();
491 fetch_snapshot(
493 &client,
494 "token-b",
495 &cache,
496 &endpoints,
497 Duration::from_secs(600),
498 )
499 .await
500 .unwrap();
501 }
502
503 #[tokio::test]
504 async fn a_stale_cache_carries_the_widget_through_an_outage() {
505 let mut server = mockito::Server::new_async().await;
506 let whoami = server
507 .mock("GET", WHOAMI_PATH)
508 .with_body("{}")
509 .create_async()
510 .await;
511 let credits = server
512 .mock("GET", CREDITS_PATH)
513 .with_body(CREDITS_BODY)
514 .create_async()
515 .await;
516 let subscription = server
517 .mock("GET", SUBSCRIPTIONS_PATH)
518 .with_body(SUBSCRIPTION_BODY)
519 .create_async()
520 .await;
521 let dir = tempfile::tempdir().unwrap();
522 let cache = cache_in(dir.path());
523 let endpoints = endpoints(&server);
524 let client = reqwest::Client::new();
525
526 fetch_snapshot(&client, "tok", &cache, &endpoints, Duration::ZERO)
527 .await
528 .unwrap();
529
530 whoami.remove_async().await;
532 credits.remove_async().await;
533 subscription.remove_async().await;
534 server
535 .mock("GET", CREDITS_PATH)
536 .with_status(503)
537 .create_async()
538 .await;
539 server
540 .mock("GET", WHOAMI_PATH)
541 .with_status(503)
542 .create_async()
543 .await;
544
545 let outcome = fetch_snapshot(&client, "tok", &cache, &endpoints, Duration::ZERO)
546 .await
547 .expect("a stale snapshot beats no snapshot");
548
549 assert!(outcome.stale);
550 assert_eq!(outcome.snapshot.plan.as_deref(), Some("GOAT"));
551 assert_eq!(outcome.last_error.unwrap().0, 503);
552 }
553
554 #[tokio::test]
555 async fn a_schema_mismatch_is_reported_as_drift_not_as_success() {
556 let mut server = mockito::Server::new_async().await;
557 server
558 .mock("GET", WHOAMI_PATH)
559 .with_body("{}")
560 .create_async()
561 .await;
562 server
563 .mock("GET", CREDITS_PATH)
564 .with_body(r#"{"unexpected":true}"#)
565 .create_async()
566 .await;
567 let dir = tempfile::tempdir().unwrap();
568
569 let error = fetch_snapshot(
570 &reqwest::Client::new(),
571 "tok",
572 &cache_in(dir.path()),
573 &endpoints(&server),
574 Duration::from_secs(60),
575 )
576 .await
577 .expect_err("an unrecognised ledger must not pass as usage");
578
579 assert!(error.to_string().contains("schema"), "{error}");
580 }
581}