1use std::sync::Arc;
17use std::time::Duration;
18
19use chrono::{DateTime, Utc};
20
21use super::cache::{CacheError, CacheStore, CachedUsage, Lock, LockStore};
22use super::credentials::Credentials;
23use super::errors::{CredentialError, JsonlError, UsageError};
24use super::fetcher::{self, UsageTransport};
25use super::jsonl::{self, JsonlAggregate};
26use super::usage::{FiveHourWindow, JsonlUsage, SevenDayWindow, UsageApiResponse, UsageData};
27
28pub const DEFAULT_CACHE_DURATION: Duration = Duration::from_secs(180);
31
32pub const DEFAULT_ERROR_TTL: Duration = Duration::from_secs(30);
36
37pub const DEFAULT_RATE_LIMIT_BACKOFF: Duration = Duration::from_secs(300);
41
42pub const DEFAULT_API_BASE_URL: &str = "https://api.anthropic.com";
44
45#[derive(Debug, Clone)]
48pub struct UsageCascadeConfig {
49 pub api_base_url: String,
50 pub timeout: Duration,
51 pub cache_duration: Duration,
52}
53
54impl Default for UsageCascadeConfig {
55 fn default() -> Self {
56 Self {
57 api_base_url: DEFAULT_API_BASE_URL.into(),
58 timeout: fetcher::DEFAULT_TIMEOUT,
59 cache_duration: DEFAULT_CACHE_DURATION,
60 }
61 }
62}
63
64pub fn resolve_usage(
78 cache: Option<&CacheStore>,
79 lock: Option<&LockStore>,
80 transport: &dyn UsageTransport,
81 credentials: &dyn Fn() -> Arc<Result<Credentials, CredentialError>>,
82 jsonl: &dyn Fn() -> Result<JsonlAggregate, JsonlError>,
83 now: &dyn Fn() -> DateTime<Utc>,
84 config: &UsageCascadeConfig,
85) -> Result<UsageData, UsageError> {
86 let cache_entry = read_cache(cache);
87 let lock_entry = read_lock(lock);
88 let now_ts = now();
89
90 if let Some(entry) = &cache_entry {
91 if is_fresh(entry, now_ts, config.cache_duration) {
92 if let Some(data) = entry.data.clone() {
93 return Ok(cached_to_usage_data(data));
94 }
95 }
96 }
97
98 let lock_active = lock_entry
99 .as_ref()
100 .is_some_and(|l| l.blocked_until > now_ts.timestamp());
101 if lock_active {
102 let lock_error = lock_entry.as_ref().and_then(|l| l.error.as_deref());
105 let lock_from_401 = lock_error == Some("Unauthorized");
106 if let Some(entry) = &cache_entry {
107 if !lock_from_401 {
114 if let Some(data) = entry.data.clone() {
115 return Ok(cached_to_usage_data(data));
116 }
117 }
118 if let Some(cached) = &entry.error {
119 return jsonl_or(jsonl, now_ts, usage_error_from_code(&cached.code));
120 }
121 }
122 let lock_err = lock_error
127 .map(usage_error_from_code)
128 .unwrap_or(UsageError::RateLimited { retry_after: None });
129 return jsonl_or(jsonl, now_ts, lock_err);
130 }
131
132 let creds_arc = credentials();
133 let creds = match &*creds_arc {
134 Ok(c) => c.clone(),
135 Err(CredentialError::NoCredentials) => {
143 return jsonl_or(jsonl, now_ts, UsageError::NoCredentials)
144 }
145 Err(other) => {
146 return jsonl_or(jsonl, now_ts, UsageError::Credentials(other.clone()));
152 }
153 };
154
155 match fetcher::fetch_usage(transport, &config.api_base_url, &creds, config.timeout) {
156 Ok(response) => {
157 write_cache(cache, CachedUsage::with_data(response.clone()));
158 write_lock(
159 lock,
160 Lock {
161 blocked_until: add_secs(now_ts.timestamp(), config.cache_duration),
162 error: None,
163 },
164 );
165 Ok(UsageData::Endpoint(response.into_endpoint_usage()))
166 }
167 Err(UsageError::Unauthorized) => {
179 write_failure_lock(lock, now_ts, &UsageError::Unauthorized);
180 jsonl_or(jsonl, now_ts, UsageError::Unauthorized)
181 }
182 Err(err) => {
183 write_failure_lock(lock, now_ts, &err);
187 if let Some(entry) = &cache_entry {
188 if let Some(data) = entry.data.clone() {
189 return Ok(cached_to_usage_data(data));
190 }
191 }
192 jsonl_or(jsonl, now_ts, err)
193 }
194 }
195}
196
197fn jsonl_or(
204 jsonl: &dyn Fn() -> Result<JsonlAggregate, JsonlError>,
205 now: DateTime<Utc>,
206 fallback: UsageError,
207) -> Result<UsageData, UsageError> {
208 match build_jsonl_usage(jsonl(), now) {
209 Some(data) => Ok(UsageData::Jsonl(data)),
210 None => Err(fallback),
211 }
212}
213
214fn build_jsonl_usage(
215 result: Result<JsonlAggregate, JsonlError>,
216 now: DateTime<Utc>,
217) -> Option<JsonlUsage> {
218 let agg = match result {
219 Ok(agg) => agg,
220 Err(JsonlError::NoEntries | JsonlError::DirectoryMissing) => return None,
221 Err(other) => {
222 crate::lsm_warn!(
228 "cascade: JSONL fallback unavailable ({other}); surfacing endpoint error"
229 );
230 return None;
231 }
232 };
233 let now_floor = jsonl::floor_to_hour(now);
240 let five_hour = agg.five_hour.as_ref().map(|block| {
241 let start = block.start.min(now_floor);
242 FiveHourWindow::new(block.token_counts, start)
243 });
244 let seven_day = SevenDayWindow::new(agg.seven_day.token_counts);
245 Some(JsonlUsage::new(five_hour, seven_day))
252}
253
254fn read_cache(cache: Option<&CacheStore>) -> Option<CachedUsage> {
255 cache.and_then(|c| match c.read() {
256 Ok(hit) => hit,
257 Err(e) => {
258 log_cache_read_failure("cache", &e);
259 None
260 }
261 })
262}
263
264fn read_lock(lock: Option<&LockStore>) -> Option<Lock> {
265 lock.and_then(|l| match l.read() {
266 Ok(hit) => hit,
267 Err(e) => {
268 log_cache_read_failure("lock", &e);
269 None
270 }
271 })
272}
273
274fn log_cache_read_failure(kind: &str, err: &super::cache::CacheError) {
283 use std::io::ErrorKind;
284 let io_kind = match err {
285 super::cache::CacheError::Io { cause, .. }
286 | super::cache::CacheError::Persist { cause, .. } => cause.kind(),
287 };
288 match io_kind {
289 ErrorKind::NotFound | ErrorKind::UnexpectedEof => {
290 crate::lsm_debug!("cascade: {kind} read failed: {err}; treating as miss");
291 }
292 _ => {
293 crate::lsm_warn!("cascade: {kind} read failed: {err}");
294 }
295 }
296}
297
298fn write_cache(cache: Option<&CacheStore>, entry: CachedUsage) {
299 if let Some(c) = cache {
300 if let Err(e) = c.write(&entry) {
301 log_persist_error("cache", &e);
302 }
303 }
304}
305
306fn write_lock(lock: Option<&LockStore>, entry: Lock) {
307 if let Some(l) = lock {
308 if let Err(e) = l.write(&entry) {
309 log_persist_error("lock", &e);
310 }
311 }
312}
313
314#[derive(Debug, Clone, Copy, PartialEq, Eq)]
319enum PersistLogClass {
320 Error,
321 Debug,
322}
323
324fn classify_persist_error(kind: &str, err: &CacheError) -> (PersistLogClass, String) {
329 if is_transient_persist_race(err) {
330 (
331 PersistLogClass::Debug,
332 format!("cascade: {kind} write race-loser (Windows MoveFileEx): {err}"),
333 )
334 } else {
335 (
336 PersistLogClass::Error,
337 format!("cascade: {kind} write failed: {err}"),
338 )
339 }
340}
341
342fn route_persist_error<D, E>(class: PersistLogClass, msg: &str, on_debug: D, on_error: E)
348where
349 D: FnOnce(&str),
350 E: FnOnce(&str),
351{
352 match class {
353 PersistLogClass::Debug => on_debug(msg),
354 PersistLogClass::Error => on_error(msg),
355 }
356}
357
358fn log_persist_error(kind: &str, err: &CacheError) {
367 let (class, msg) = classify_persist_error(kind, err);
368 route_persist_error(
369 class,
370 &msg,
371 |s| crate::lsm_debug!("{s}"),
372 |s| crate::lsm_error!("{s}"),
373 );
374}
375
376#[cfg(windows)]
377fn is_transient_persist_race(err: &CacheError) -> bool {
378 matches!(
379 err,
380 CacheError::Persist { cause, .. }
381 if cause.kind() == std::io::ErrorKind::PermissionDenied
382 )
383}
384
385#[cfg(not(windows))]
386fn is_transient_persist_race(_err: &CacheError) -> bool {
387 false
390}
391
392fn write_failure_lock(lock: Option<&LockStore>, now_ts: DateTime<Utc>, err: &UsageError) {
393 let backoff = backoff_for_error(err);
394 write_lock(
395 lock,
396 Lock {
397 blocked_until: add_secs(now_ts.timestamp(), backoff),
398 error: Some(err.code().to_string()),
399 },
400 );
401}
402
403fn backoff_for_error(err: &UsageError) -> Duration {
404 match err {
405 UsageError::RateLimited {
406 retry_after: Some(d),
407 } => *d,
408 UsageError::RateLimited { retry_after: None } => DEFAULT_RATE_LIMIT_BACKOFF,
409 _ => DEFAULT_ERROR_TTL,
410 }
411}
412
413fn add_secs(base_ts: i64, secs: Duration) -> i64 {
414 let offset = i64::try_from(secs.as_secs()).unwrap_or(i64::MAX);
418 base_ts.saturating_add(offset)
419}
420
421fn usage_error_from_code(code: &str) -> UsageError {
438 match code {
439 "NoCredentials" => UsageError::NoCredentials,
440 "Timeout" => UsageError::Timeout,
441 "RateLimited" => UsageError::RateLimited { retry_after: None },
442 "Unauthorized" => UsageError::Unauthorized,
443 "ParseError" => UsageError::ParseError,
444 _ => UsageError::NetworkError,
445 }
446}
447
448fn is_fresh(entry: &CachedUsage, now: DateTime<Utc>, ttl: Duration) -> bool {
449 match now.signed_duration_since(entry.cached_at).to_std() {
452 Ok(elapsed) => elapsed < ttl,
453 Err(_) => false,
454 }
455}
456
457fn cached_to_usage_data(data: super::cache::CachedData) -> UsageData {
458 let response: UsageApiResponse = data.into();
459 UsageData::Endpoint(response.into_endpoint_usage())
460}
461
462#[cfg(test)]
463mod tests {
464 use super::*;
465 use std::cell::{Cell, RefCell};
466 use std::io;
467
468 use chrono::Duration as ChronoDuration;
469 use tempfile::TempDir;
470
471 use crate::data_context::cache::{CacheStore, CachedUsage, Lock, LockStore};
472 use crate::data_context::credentials::Credentials;
473 use crate::data_context::errors::CredentialError;
474 use crate::data_context::fetcher::{HttpResponse, UsageTransport};
475 use crate::data_context::jsonl::{
476 FiveHourBlock, JsonlAggregate, SevenDayWindow as JsonlSevenDayWindow, TokenCounts,
477 };
478
479 struct FakeTransport {
480 response: RefCell<io::Result<HttpResponse>>,
481 calls: Cell<u32>,
482 }
483
484 impl FakeTransport {
485 fn ok(status: u16, body: &str, retry_after: Option<&str>) -> Self {
486 Self {
487 response: RefCell::new(Ok(HttpResponse {
488 status,
489 body: body.as_bytes().to_vec(),
490 retry_after: retry_after.map(String::from),
491 })),
492 calls: Cell::new(0),
493 }
494 }
495
496 fn err(kind: io::ErrorKind) -> Self {
497 Self {
498 response: RefCell::new(Err(io::Error::new(kind, "fake"))),
499 calls: Cell::new(0),
500 }
501 }
502 }
503
504 impl UsageTransport for FakeTransport {
505 fn get(&self, _url: &str, _token: &str, _timeout: Duration) -> io::Result<HttpResponse> {
506 self.calls.set(self.calls.get() + 1);
507 match &*self.response.borrow() {
508 Ok(r) => Ok(HttpResponse {
509 status: r.status,
510 body: r.body.clone(),
511 retry_after: r.retry_after.clone(),
512 }),
513 Err(e) => Err(io::Error::new(e.kind(), e.to_string())),
514 }
515 }
516 }
517
518 const SAMPLE_BODY: &str = r#"{
519 "five_hour": { "utilization": 42.0, "resets_at": "2026-04-19T05:00:00Z" },
520 "seven_day": { "utilization": 33.0, "resets_at": "2026-04-23T19:00:00Z" }
521 }"#;
522
523 fn sample_response() -> UsageApiResponse {
524 serde_json::from_str(SAMPLE_BODY).unwrap()
525 }
526
527 fn config() -> UsageCascadeConfig {
528 UsageCascadeConfig::default()
529 }
530
531 fn now_fn() -> impl Fn() -> DateTime<Utc> {
532 let ts = Utc::now();
533 move || ts
534 }
535
536 fn ok_creds() -> Arc<Result<Credentials, CredentialError>> {
537 Arc::new(Ok(Credentials::for_testing("test-token")))
538 }
539
540 fn no_creds() -> Arc<Result<Credentials, CredentialError>> {
541 Arc::new(Err(CredentialError::NoCredentials))
542 }
543
544 fn jsonl_empty() -> Result<JsonlAggregate, JsonlError> {
545 Err(JsonlError::NoEntries)
546 }
547
548 fn jsonl_ok() -> Result<JsonlAggregate, JsonlError> {
552 Ok(JsonlAggregate {
553 five_hour: None,
554 seven_day: JsonlSevenDayWindow {
555 window_start: Utc::now() - ChronoDuration::days(7),
556 token_counts: TokenCounts::from_parts(1_000_000, 200_000, 0, 0),
557 },
558 source_paths: Vec::new(),
559 })
560 }
561
562 fn jsonl_ok_with_active_block() -> Result<JsonlAggregate, JsonlError> {
566 let now = Utc::now();
567 let start = now - ChronoDuration::hours(1);
568 Ok(JsonlAggregate {
569 five_hour: Some(FiveHourBlock {
570 start,
571 actual_last_activity: now,
572 token_counts: TokenCounts::from_parts(400_000, 20_000, 0, 0),
573 models: vec!["claude-opus-4-7".into()],
574 usage_limit_reset: None,
575 }),
576 seven_day: JsonlSevenDayWindow {
577 window_start: now - ChronoDuration::days(7),
578 token_counts: TokenCounts::from_parts(1_000_000, 200_000, 0, 0),
579 },
580 source_paths: Vec::new(),
581 })
582 }
583
584 fn stale_cache_entry(age: ChronoDuration) -> CachedUsage {
585 let mut entry = CachedUsage::with_data(sample_response());
586 entry.cached_at = Utc::now() - age;
587 entry
588 }
589
590 fn assert_jsonl_matches_ok_fixture(data: &UsageData) {
598 let UsageData::Jsonl(j) = data else {
599 panic!("expected UsageData::Jsonl, got {data:?}");
600 };
601 assert!(
602 j.five_hour.is_none(),
603 "jsonl_ok fixture has no active 5h block",
604 );
605 assert_eq!(
606 j.seven_day.tokens.total(),
607 1_200_000,
608 "7d total must match jsonl_ok fixture (1M input + 200k output)",
609 );
610 }
611
612 #[test]
613 fn fresh_disk_cache_short_circuits_without_reading_credentials() {
614 let tmp = TempDir::new().unwrap();
615 let cache = CacheStore::new(tmp.path().to_path_buf());
616 cache
617 .write(&CachedUsage::with_data(sample_response()))
618 .unwrap();
619
620 let cred_calls = Cell::new(0u32);
621 let jsonl_calls = Cell::new(0u32);
622 let credentials = || {
623 cred_calls.set(cred_calls.get() + 1);
624 ok_creds()
625 };
626 let jsonl = || {
627 jsonl_calls.set(jsonl_calls.get() + 1);
628 jsonl_empty()
629 };
630 let transport = FakeTransport::ok(200, "", None);
631
632 let data = resolve_usage(
633 Some(&cache),
634 None,
635 &transport,
636 &credentials,
637 &jsonl,
638 &now_fn(),
639 &config(),
640 )
641 .expect("ok");
642
643 let UsageData::Endpoint(endpoint) = &data else {
644 panic!("expected endpoint variant, got {data:?}");
645 };
646 assert_eq!(endpoint.five_hour.unwrap().utilization.value(), 42.0);
647 assert_eq!(cred_calls.get(), 0, "credentials must not be called");
648 assert_eq!(jsonl_calls.get(), 0, "jsonl must not be called");
649 assert_eq!(transport.calls.get(), 0, "no HTTP on cache hit");
650 }
651
652 #[test]
653 fn stale_cache_without_lock_triggers_fetch_and_overwrites() {
654 let tmp = TempDir::new().unwrap();
655 let cache = CacheStore::new(tmp.path().to_path_buf());
656 cache
657 .write(&stale_cache_entry(ChronoDuration::minutes(10)))
658 .unwrap();
659 let lock = LockStore::new(tmp.path().to_path_buf());
660
661 let transport = FakeTransport::ok(200, SAMPLE_BODY, None);
662 let data = resolve_usage(
663 Some(&cache),
664 Some(&lock),
665 &transport,
666 &ok_creds,
667 &jsonl_empty,
668 &now_fn(),
669 &config(),
670 )
671 .expect("ok");
672
673 assert!(matches!(data, UsageData::Endpoint(_)));
674 assert_eq!(transport.calls.get(), 1);
675 let refreshed = cache.read().unwrap().unwrap();
676 let age = Utc::now().signed_duration_since(refreshed.cached_at);
677 assert!(age.num_seconds() < 5, "cache must be re-stamped on success");
678 }
679
680 #[test]
681 fn stale_cache_with_active_lock_serves_stale_without_credentials() {
682 let tmp = TempDir::new().unwrap();
683 let cache = CacheStore::new(tmp.path().to_path_buf());
684 cache
685 .write(&stale_cache_entry(ChronoDuration::minutes(10)))
686 .unwrap();
687 let lock = LockStore::new(tmp.path().to_path_buf());
688 lock.write(&Lock {
689 blocked_until: Utc::now().timestamp() + 60,
690 error: Some("rate-limited".into()),
691 })
692 .unwrap();
693
694 let cred_calls = Cell::new(0u32);
695 let credentials = || {
696 cred_calls.set(cred_calls.get() + 1);
697 ok_creds()
698 };
699 let transport = FakeTransport::ok(200, "", None);
700
701 let data = resolve_usage(
702 Some(&cache),
703 Some(&lock),
704 &transport,
705 &credentials,
706 &jsonl_empty,
707 &now_fn(),
708 &config(),
709 )
710 .expect("ok");
711
712 assert!(matches!(data, UsageData::Endpoint(_)));
713 assert_eq!(
714 cred_calls.get(),
715 0,
716 "active lock must short-circuit before credentials read",
717 );
718 assert_eq!(transport.calls.get(), 0, "no HTTP when lock + stale cache");
719 }
720
721 #[test]
722 fn no_credentials_surfaces_nocredentials_not_timeout() {
723 let transport = FakeTransport::err(io::ErrorKind::TimedOut);
724 let err = resolve_usage(
725 None,
726 None,
727 &transport,
728 &no_creds,
729 &jsonl_empty,
730 &now_fn(),
731 &config(),
732 )
733 .unwrap_err();
734 assert!(matches!(err, UsageError::NoCredentials));
735 assert_eq!(transport.calls.get(), 0, "no HTTP when credentials missing",);
736 }
737
738 #[test]
739 fn no_credentials_falls_through_to_jsonl_when_available() {
740 let data = resolve_usage(
745 None,
746 None,
747 &FakeTransport::ok(200, "", None),
748 &no_creds,
749 &jsonl_ok,
750 &now_fn(),
751 &config(),
752 )
753 .expect("ok");
754 assert_jsonl_matches_ok_fixture(&data);
755 }
756
757 #[test]
758 fn no_credentials_with_empty_jsonl_still_surfaces_nocredentials() {
759 let err = resolve_usage(
763 None,
764 None,
765 &FakeTransport::ok(200, "", None),
766 &no_creds,
767 &jsonl_empty,
768 &now_fn(),
769 &config(),
770 )
771 .unwrap_err();
772 assert!(matches!(err, UsageError::NoCredentials));
773 }
774
775 #[test]
776 fn endpoint_200_writes_cache_and_lock() {
777 let tmp = TempDir::new().unwrap();
778 let cache = CacheStore::new(tmp.path().to_path_buf());
779 let lock = LockStore::new(tmp.path().to_path_buf());
780 let transport = FakeTransport::ok(200, SAMPLE_BODY, None);
781
782 let data = resolve_usage(
783 Some(&cache),
784 Some(&lock),
785 &transport,
786 &ok_creds,
787 &jsonl_empty,
788 &now_fn(),
789 &config(),
790 )
791 .expect("ok");
792
793 assert!(matches!(data, UsageData::Endpoint(_)));
794 assert!(cache.read().unwrap().is_some(), "cache must be populated");
795 let persisted_lock = lock.read().unwrap().unwrap();
796 let expected_blocked_until =
797 Utc::now().timestamp() + config().cache_duration.as_secs() as i64;
798 assert!(
799 (persisted_lock.blocked_until - expected_blocked_until).abs() < 5,
800 "lock blocked_until = {}, expected near {}",
801 persisted_lock.blocked_until,
802 expected_blocked_until,
803 );
804 }
805
806 #[test]
807 fn endpoint_401_falls_through_to_jsonl_when_available() {
808 let transport = FakeTransport::ok(401, "", None);
814 let data = resolve_usage(
815 None,
816 None,
817 &transport,
818 &ok_creds,
819 &jsonl_ok,
820 &now_fn(),
821 &config(),
822 )
823 .expect("ok");
824 assert_jsonl_matches_ok_fixture(&data);
825 assert_eq!(transport.calls.get(), 1);
829 }
830
831 #[test]
832 fn jsonl_fallback_clamps_future_dated_block_start_to_now() {
833 let now = Utc::now();
842 let skewed_start = now + ChronoDuration::hours(2);
845 let skewed: Result<JsonlAggregate, JsonlError> = Ok(JsonlAggregate {
846 five_hour: Some(FiveHourBlock {
847 start: skewed_start,
848 actual_last_activity: now + ChronoDuration::minutes(30),
849 token_counts: TokenCounts::from_parts(100, 0, 0, 0),
850 models: vec!["claude-opus-4-7".into()],
851 usage_limit_reset: None,
852 }),
853 seven_day: JsonlSevenDayWindow {
854 window_start: now - ChronoDuration::days(7),
855 token_counts: TokenCounts::from_parts(100, 0, 0, 0),
856 },
857 source_paths: Vec::new(),
858 });
859 let skewed_closure = || match &skewed {
860 Ok(agg) => Ok(agg.clone()),
861 Err(_) => Err(JsonlError::NoEntries),
862 };
863 let now_clock = move || now;
864 let data = resolve_usage(
865 None,
866 None,
867 &FakeTransport::err(io::ErrorKind::TimedOut),
868 &ok_creds,
869 &skewed_closure,
870 &now_clock,
871 &config(),
872 )
873 .expect("ok");
874 let UsageData::Jsonl(j) = &data else {
875 panic!("expected jsonl variant, got {data:?}");
876 };
877 let window = j
878 .five_hour
879 .as_ref()
880 .expect("active block should populate five_hour window");
881 assert!(
884 window.ends_at() <= now + ChronoDuration::hours(5),
885 "ends_at={:?} must be clamped at/before now + 5h ({:?})",
886 window.ends_at(),
887 now + ChronoDuration::hours(5),
888 );
889 }
890
891 #[test]
892 fn jsonl_fallback_surfaces_five_hour_window_with_ends_at() {
893 let data = resolve_usage(
898 None,
899 None,
900 &FakeTransport::err(io::ErrorKind::TimedOut),
901 &ok_creds,
902 &jsonl_ok_with_active_block,
903 &now_fn(),
904 &config(),
905 )
906 .expect("ok");
907 let UsageData::Jsonl(j) = &data else {
908 panic!("expected jsonl variant, got {data:?}");
909 };
910 let window = j
911 .five_hour
912 .as_ref()
913 .expect("active block should populate five_hour window");
914 let expected_ends_at = Utc::now() + ChronoDuration::hours(4);
915 let drift = (window.ends_at() - expected_ends_at).num_seconds().abs();
916 assert!(
917 drift < 5,
918 "ends_at={:?} drifted {drift}s from expected",
919 window.ends_at(),
920 );
921 assert_eq!(window.tokens.total(), 420_000);
923 }
924
925 #[test]
926 fn endpoint_401_with_empty_jsonl_surfaces_unauthorized() {
927 let err = resolve_usage(
928 None,
929 None,
930 &FakeTransport::ok(401, "", None),
931 &ok_creds,
932 &jsonl_empty,
933 &now_fn(),
934 &config(),
935 )
936 .unwrap_err();
937 assert!(matches!(err, UsageError::Unauthorized));
938 }
939
940 #[test]
941 fn endpoint_401_does_not_serve_stale_cache() {
942 let tmp = TempDir::new().unwrap();
943 let cache = CacheStore::new(tmp.path().to_path_buf());
944 cache
945 .write(&stale_cache_entry(ChronoDuration::minutes(10)))
946 .unwrap();
947 let err = resolve_usage(
948 Some(&cache),
949 None,
950 &FakeTransport::ok(401, "", None),
951 &ok_creds,
952 &jsonl_empty,
953 &now_fn(),
954 &config(),
955 )
956 .unwrap_err();
957 assert!(matches!(err, UsageError::Unauthorized));
958 }
959
960 #[test]
961 fn invocation_after_401_does_not_serve_stale_cache_via_lock_active() {
962 let tmp = TempDir::new().unwrap();
970 let cache = CacheStore::new(tmp.path().to_path_buf());
971 cache
972 .write(&stale_cache_entry(ChronoDuration::minutes(10)))
973 .unwrap();
974 let lock = LockStore::new(tmp.path().to_path_buf());
975
976 let transport_a = FakeTransport::ok(401, "", None);
978 let err_a = resolve_usage(
979 Some(&cache),
980 Some(&lock),
981 &transport_a,
982 &ok_creds,
983 &jsonl_empty,
984 &now_fn(),
985 &config(),
986 )
987 .unwrap_err();
988 assert!(matches!(err_a, UsageError::Unauthorized));
989
990 let transport_b = FakeTransport::ok(200, SAMPLE_BODY, None);
993 let err_b = resolve_usage(
994 Some(&cache),
995 Some(&lock),
996 &transport_b,
997 &ok_creds,
998 &jsonl_empty,
999 &now_fn(),
1000 &config(),
1001 )
1002 .unwrap_err();
1003 assert!(matches!(err_b, UsageError::Unauthorized));
1004 assert_eq!(
1005 transport_b.calls.get(),
1006 0,
1007 "active lock must still gate the endpoint on invocation B",
1008 );
1009 }
1010
1011 #[test]
1012 fn invocation_after_401_falls_through_to_jsonl_when_available() {
1013 let tmp = TempDir::new().unwrap();
1018 let cache = CacheStore::new(tmp.path().to_path_buf());
1019 cache
1020 .write(&stale_cache_entry(ChronoDuration::minutes(10)))
1021 .unwrap();
1022 let lock = LockStore::new(tmp.path().to_path_buf());
1023
1024 let data_a = resolve_usage(
1025 Some(&cache),
1026 Some(&lock),
1027 &FakeTransport::ok(401, "", None),
1028 &ok_creds,
1029 &jsonl_ok,
1030 &now_fn(),
1031 &config(),
1032 )
1033 .expect("A falls through to JSONL with jsonl_ok");
1034 assert_jsonl_matches_ok_fixture(&data_a);
1035
1036 let transport_b = FakeTransport::ok(200, SAMPLE_BODY, None);
1037 let data_b = resolve_usage(
1038 Some(&cache),
1039 Some(&lock),
1040 &transport_b,
1041 &ok_creds,
1042 &jsonl_ok,
1043 &now_fn(),
1044 &config(),
1045 )
1046 .expect("B returns JSONL on lock-active path");
1047 assert_jsonl_matches_ok_fixture(&data_b);
1048 assert_eq!(transport_b.calls.get(), 0);
1049 }
1050
1051 #[test]
1052 fn active_unauthorized_lock_rejects_stale_cached_data() {
1053 let tmp = TempDir::new().unwrap();
1061 let cache = CacheStore::new(tmp.path().to_path_buf());
1062 cache
1063 .write(&stale_cache_entry(ChronoDuration::minutes(10)))
1064 .unwrap();
1065 let lock = LockStore::new(tmp.path().to_path_buf());
1066 lock.write(&Lock {
1067 blocked_until: Utc::now().timestamp() + 30,
1068 error: Some("Unauthorized".into()),
1069 })
1070 .unwrap();
1071
1072 let transport = FakeTransport::ok(200, SAMPLE_BODY, None);
1073 let err = resolve_usage(
1074 Some(&cache),
1075 Some(&lock),
1076 &transport,
1077 &ok_creds,
1078 &jsonl_empty,
1079 &now_fn(),
1080 &config(),
1081 )
1082 .unwrap_err();
1083 assert!(matches!(err, UsageError::Unauthorized));
1084 assert_eq!(transport.calls.get(), 0);
1085 }
1086
1087 #[test]
1088 fn endpoint_429_writes_lock_with_retry_after_backoff() {
1089 let tmp = TempDir::new().unwrap();
1092 let cache = CacheStore::new(tmp.path().to_path_buf());
1093 let lock = LockStore::new(tmp.path().to_path_buf());
1094
1095 let _ = resolve_usage(
1096 Some(&cache),
1097 Some(&lock),
1098 &FakeTransport::ok(429, "", Some("120")),
1099 &ok_creds,
1100 &jsonl_empty,
1101 &now_fn(),
1102 &config(),
1103 );
1104
1105 let persisted = lock.read().unwrap().expect("lock must be written");
1106 let expected = Utc::now().timestamp() + 120;
1107 assert!(
1108 (persisted.blocked_until - expected).abs() < 5,
1109 "blocked_until={}, expected near {}",
1110 persisted.blocked_until,
1111 expected,
1112 );
1113 assert_eq!(persisted.error.as_deref(), Some("RateLimited"));
1114 }
1115
1116 #[test]
1117 fn endpoint_timeout_writes_lock_with_error_ttl() {
1118 let tmp = TempDir::new().unwrap();
1119 let lock = LockStore::new(tmp.path().to_path_buf());
1120
1121 let _ = resolve_usage(
1122 None,
1123 Some(&lock),
1124 &FakeTransport::err(io::ErrorKind::TimedOut),
1125 &ok_creds,
1126 &jsonl_empty,
1127 &now_fn(),
1128 &config(),
1129 );
1130
1131 let persisted = lock.read().unwrap().expect("lock must be written");
1132 let expected = Utc::now().timestamp() + DEFAULT_ERROR_TTL.as_secs() as i64;
1133 assert!(
1134 (persisted.blocked_until - expected).abs() < 5,
1135 "blocked_until={}, expected near {}",
1136 persisted.blocked_until,
1137 expected,
1138 );
1139 assert_eq!(persisted.error.as_deref(), Some("Timeout"));
1140 }
1141
1142 #[test]
1143 fn lock_written_on_429_blocks_next_process_from_hitting_endpoint() {
1144 let tmp = TempDir::new().unwrap();
1149 let cache = CacheStore::new(tmp.path().to_path_buf());
1150 let lock = LockStore::new(tmp.path().to_path_buf());
1151
1152 let transport_a = FakeTransport::ok(429, "", Some("120"));
1153 let _ = resolve_usage(
1154 Some(&cache),
1155 Some(&lock),
1156 &transport_a,
1157 &ok_creds,
1158 &jsonl_empty,
1159 &now_fn(),
1160 &config(),
1161 );
1162
1163 let transport_b = FakeTransport::ok(200, SAMPLE_BODY, None);
1164 let result_b = resolve_usage(
1165 Some(&cache),
1166 Some(&lock),
1167 &transport_b,
1168 &ok_creds,
1169 &jsonl_empty,
1170 &now_fn(),
1171 &config(),
1172 );
1173 assert!(matches!(result_b, Err(UsageError::RateLimited { .. })));
1174 assert_eq!(
1175 transport_b.calls.get(),
1176 0,
1177 "process B must not hit endpoint"
1178 );
1179 }
1180
1181 #[test]
1182 fn endpoint_401_writes_lock_so_peers_skip_the_stale_token() {
1183 let tmp = TempDir::new().unwrap();
1184 let lock = LockStore::new(tmp.path().to_path_buf());
1185
1186 let _ = resolve_usage(
1187 None,
1188 Some(&lock),
1189 &FakeTransport::ok(401, "", None),
1190 &ok_creds,
1191 &jsonl_empty,
1192 &now_fn(),
1193 &config(),
1194 );
1195
1196 let persisted = lock.read().unwrap().expect("lock must be written");
1197 assert_eq!(persisted.error.as_deref(), Some("Unauthorized"));
1198 }
1199
1200 #[test]
1201 fn endpoint_429_with_stale_cache_serves_stale() {
1202 let tmp = TempDir::new().unwrap();
1203 let cache = CacheStore::new(tmp.path().to_path_buf());
1204 cache
1205 .write(&stale_cache_entry(ChronoDuration::minutes(10)))
1206 .unwrap();
1207 let data = resolve_usage(
1208 Some(&cache),
1209 None,
1210 &FakeTransport::ok(429, "", Some("120")),
1211 &ok_creds,
1212 &jsonl_empty,
1213 &now_fn(),
1214 &config(),
1215 )
1216 .expect("ok");
1217 let UsageData::Endpoint(endpoint) = &data else {
1218 panic!("expected endpoint variant, got {data:?}");
1219 };
1220 assert_eq!(endpoint.five_hour.unwrap().utilization.value(), 42.0);
1221 }
1222
1223 #[test]
1224 fn endpoint_429_with_empty_jsonl_surfaces_ratelimited() {
1225 let err = resolve_usage(
1228 None,
1229 None,
1230 &FakeTransport::ok(429, "", None),
1231 &ok_creds,
1232 &jsonl_empty,
1233 &now_fn(),
1234 &config(),
1235 )
1236 .unwrap_err();
1237 assert!(matches!(err, UsageError::RateLimited { .. }));
1238 }
1239
1240 #[test]
1241 fn endpoint_429_falls_through_to_jsonl_when_available() {
1242 let transport = FakeTransport::ok(429, "", None);
1245 let data = resolve_usage(
1246 None,
1247 None,
1248 &transport,
1249 &ok_creds,
1250 &jsonl_ok,
1251 &now_fn(),
1252 &config(),
1253 )
1254 .expect("ok");
1255 assert_jsonl_matches_ok_fixture(&data);
1256 assert_eq!(transport.calls.get(), 1);
1257 }
1258
1259 #[test]
1260 fn endpoint_timeout_with_stale_cache_serves_stale() {
1261 let tmp = TempDir::new().unwrap();
1262 let cache = CacheStore::new(tmp.path().to_path_buf());
1263 cache
1264 .write(&stale_cache_entry(ChronoDuration::minutes(10)))
1265 .unwrap();
1266 let data = resolve_usage(
1267 Some(&cache),
1268 None,
1269 &FakeTransport::err(io::ErrorKind::TimedOut),
1270 &ok_creds,
1271 &jsonl_empty,
1272 &now_fn(),
1273 &config(),
1274 )
1275 .expect("ok");
1276 assert!(matches!(data, UsageData::Endpoint(_)));
1277 }
1278
1279 #[test]
1280 fn endpoint_timeout_without_stale_falls_through_to_jsonl() {
1281 let transport = FakeTransport::err(io::ErrorKind::TimedOut);
1284 let data = resolve_usage(
1285 None,
1286 None,
1287 &transport,
1288 &ok_creds,
1289 &jsonl_ok,
1290 &now_fn(),
1291 &config(),
1292 )
1293 .expect("ok");
1294 assert_jsonl_matches_ok_fixture(&data);
1295 assert_eq!(
1296 transport.calls.get(),
1297 1,
1298 "endpoint must be attempted before JSONL fallback",
1299 );
1300 }
1301
1302 #[test]
1303 fn endpoint_timeout_without_stale_or_jsonl_surfaces_original_error() {
1304 let err = resolve_usage(
1305 None,
1306 None,
1307 &FakeTransport::err(io::ErrorKind::TimedOut),
1308 &ok_creds,
1309 &jsonl_empty,
1310 &now_fn(),
1311 &config(),
1312 )
1313 .unwrap_err();
1314 assert!(matches!(err, UsageError::Timeout));
1315 }
1316
1317 #[test]
1318 fn endpoint_network_error_falls_through_same_as_timeout() {
1319 let err = resolve_usage(
1320 None,
1321 None,
1322 &FakeTransport::err(io::ErrorKind::ConnectionRefused),
1323 &ok_creds,
1324 &jsonl_empty,
1325 &now_fn(),
1326 &config(),
1327 )
1328 .unwrap_err();
1329 assert!(matches!(err, UsageError::NetworkError));
1330 }
1331
1332 #[test]
1333 fn endpoint_malformed_response_falls_through_to_jsonl() {
1334 let err = resolve_usage(
1335 None,
1336 None,
1337 &FakeTransport::ok(200, "{ not valid", None),
1338 &ok_creds,
1339 &jsonl_empty,
1340 &now_fn(),
1341 &config(),
1342 )
1343 .unwrap_err();
1344 assert!(matches!(err, UsageError::ParseError));
1345 }
1346
1347 #[test]
1348 fn cascade_tolerates_missing_cache_and_lock_stores() {
1349 let data = resolve_usage(
1353 None,
1354 None,
1355 &FakeTransport::ok(200, SAMPLE_BODY, None),
1356 &ok_creds,
1357 &jsonl_empty,
1358 &now_fn(),
1359 &config(),
1360 )
1361 .expect("ok");
1362 assert!(matches!(data, UsageData::Endpoint(_)));
1363 }
1364
1365 #[test]
1366 fn expired_lock_does_not_gate_fetch() {
1367 let tmp = TempDir::new().unwrap();
1368 let cache = CacheStore::new(tmp.path().to_path_buf());
1369 cache
1370 .write(&stale_cache_entry(ChronoDuration::minutes(10)))
1371 .unwrap();
1372 let lock = LockStore::new(tmp.path().to_path_buf());
1373 lock.write(&Lock {
1374 blocked_until: Utc::now().timestamp() - 60,
1375 error: None,
1376 })
1377 .unwrap();
1378
1379 let transport = FakeTransport::ok(200, SAMPLE_BODY, None);
1380 let _ = resolve_usage(
1381 Some(&cache),
1382 Some(&lock),
1383 &transport,
1384 &ok_creds,
1385 &jsonl_empty,
1386 &now_fn(),
1387 &config(),
1388 )
1389 .expect("ok");
1390 assert_eq!(
1391 transport.calls.get(),
1392 1,
1393 "expired lock must not block fetch"
1394 );
1395 }
1396
1397 #[test]
1398 fn active_lock_with_no_cached_data_does_not_hit_endpoint() {
1399 let tmp = TempDir::new().unwrap();
1404 let cache = CacheStore::new(tmp.path().to_path_buf());
1405 let lock = LockStore::new(tmp.path().to_path_buf());
1406 lock.write(&Lock {
1407 blocked_until: Utc::now().timestamp() + 60,
1408 error: Some("RateLimited".into()),
1409 })
1410 .unwrap();
1411
1412 let cred_calls = Cell::new(0u32);
1413 let credentials = || {
1414 cred_calls.set(cred_calls.get() + 1);
1415 ok_creds()
1416 };
1417 let transport = FakeTransport::ok(200, SAMPLE_BODY, None);
1418 let err = resolve_usage(
1419 Some(&cache),
1420 Some(&lock),
1421 &transport,
1422 &credentials,
1423 &jsonl_empty,
1424 &now_fn(),
1425 &config(),
1426 )
1427 .unwrap_err();
1428 assert!(matches!(err, UsageError::RateLimited { .. }));
1429 assert_eq!(cred_calls.get(), 0, "must not resolve credentials");
1430 assert_eq!(transport.calls.get(), 0, "must not hit endpoint");
1431 }
1432
1433 #[test]
1434 fn active_lock_falls_through_to_jsonl_when_available() {
1435 let tmp = TempDir::new().unwrap();
1440 let cache = CacheStore::new(tmp.path().to_path_buf());
1441 let lock = LockStore::new(tmp.path().to_path_buf());
1442 lock.write(&Lock {
1443 blocked_until: Utc::now().timestamp() + 60,
1444 error: Some("RateLimited".into()),
1445 })
1446 .unwrap();
1447
1448 let transport = FakeTransport::ok(200, SAMPLE_BODY, None);
1449 let data = resolve_usage(
1450 Some(&cache),
1451 Some(&lock),
1452 &transport,
1453 &ok_creds,
1454 &jsonl_ok,
1455 &now_fn(),
1456 &config(),
1457 )
1458 .expect("ok");
1459 assert_jsonl_matches_ok_fixture(&data);
1460 assert_eq!(
1461 transport.calls.get(),
1462 0,
1463 "active lock must still gate the endpoint even with JSONL data"
1464 );
1465 }
1466
1467 #[test]
1468 fn active_lock_serves_cached_error_without_hitting_endpoint() {
1469 let tmp = TempDir::new().unwrap();
1474 let cache = CacheStore::new(tmp.path().to_path_buf());
1475 cache
1476 .write(&CachedUsage::with_error("Unauthorized"))
1477 .unwrap();
1478 let lock = LockStore::new(tmp.path().to_path_buf());
1479 lock.write(&Lock {
1480 blocked_until: Utc::now().timestamp() + 60,
1481 error: Some("RateLimited".into()),
1482 })
1483 .unwrap();
1484
1485 let transport = FakeTransport::ok(200, "", None);
1486 let err = resolve_usage(
1487 Some(&cache),
1488 Some(&lock),
1489 &transport,
1490 &ok_creds,
1491 &jsonl_empty,
1492 &now_fn(),
1493 &config(),
1494 )
1495 .unwrap_err();
1496 assert!(matches!(err, UsageError::Unauthorized));
1497 assert_eq!(transport.calls.get(), 0);
1498 }
1499
1500 #[test]
1501 fn active_lock_with_cached_error_falls_through_to_jsonl_when_available() {
1502 let tmp = TempDir::new().unwrap();
1509 let cache = CacheStore::new(tmp.path().to_path_buf());
1510 cache
1511 .write(&CachedUsage::with_error("Unauthorized"))
1512 .unwrap();
1513 let lock = LockStore::new(tmp.path().to_path_buf());
1514 lock.write(&Lock {
1515 blocked_until: Utc::now().timestamp() + 60,
1516 error: Some("RateLimited".into()),
1517 })
1518 .unwrap();
1519
1520 let transport = FakeTransport::ok(200, "", None);
1521 let data = resolve_usage(
1522 Some(&cache),
1523 Some(&lock),
1524 &transport,
1525 &ok_creds,
1526 &jsonl_ok,
1527 &now_fn(),
1528 &config(),
1529 )
1530 .expect("ok");
1531 assert_jsonl_matches_ok_fixture(&data);
1532 assert_eq!(transport.calls.get(), 0);
1533 }
1534
1535 #[test]
1536 fn credential_failure_other_than_missing_preserves_variant_tag() {
1537 let creds_err: Arc<Result<Credentials, CredentialError>> =
1543 Arc::new(Err(CredentialError::MissingField {
1544 path: std::path::PathBuf::from("/x"),
1545 }));
1546 let credentials = || creds_err.clone();
1547 let err = resolve_usage(
1548 None,
1549 None,
1550 &FakeTransport::err(io::ErrorKind::TimedOut),
1551 &credentials,
1552 &jsonl_empty,
1553 &now_fn(),
1554 &config(),
1555 )
1556 .unwrap_err();
1557 assert!(
1558 matches!(
1559 err,
1560 UsageError::Credentials(CredentialError::MissingField { .. })
1561 ),
1562 "expected Credentials(MissingField), got {err:?}",
1563 );
1564 assert_eq!(err.code(), "MissingField", "variant tag must round-trip");
1565 }
1566
1567 #[test]
1568 fn subprocess_failed_cred_preserves_subprocess_tag() {
1569 let creds_err: Arc<Result<Credentials, CredentialError>> = Arc::new(Err(
1573 CredentialError::SubprocessFailed(io::Error::new(io::ErrorKind::PermissionDenied, "x")),
1574 ));
1575 let credentials = || creds_err.clone();
1576 let err = resolve_usage(
1577 None,
1578 None,
1579 &FakeTransport::err(io::ErrorKind::TimedOut),
1580 &credentials,
1581 &jsonl_empty,
1582 &now_fn(),
1583 &config(),
1584 )
1585 .unwrap_err();
1586 assert_eq!(err.code(), "SubprocessFailed");
1587 }
1588
1589 #[test]
1590 fn credential_variant_falls_through_to_jsonl_when_available() {
1591 let creds_err: Arc<Result<Credentials, CredentialError>> = Arc::new(Err(
1596 CredentialError::SubprocessFailed(io::Error::new(io::ErrorKind::PermissionDenied, "x")),
1597 ));
1598 let credentials = || creds_err.clone();
1599 let data = resolve_usage(
1600 None,
1601 None,
1602 &FakeTransport::err(io::ErrorKind::TimedOut),
1603 &credentials,
1604 &jsonl_ok,
1605 &now_fn(),
1606 &config(),
1607 )
1608 .expect("ok");
1609 assert_jsonl_matches_ok_fixture(&data);
1610 }
1611
1612 #[test]
1617 fn cache_write_failure_does_not_block_returned_data() {
1618 let tmp = TempDir::new().unwrap();
1619 let blocking_file = tmp.path().join("blocked");
1620 std::fs::write(&blocking_file, "x").unwrap();
1621 let cache = CacheStore::new(blocking_file.join("nested"));
1622
1623 let data = resolve_usage(
1624 Some(&cache),
1625 None,
1626 &FakeTransport::ok(200, SAMPLE_BODY, None),
1627 &ok_creds,
1628 &jsonl_empty,
1629 &now_fn(),
1630 &config(),
1631 )
1632 .expect("ok");
1633 assert!(matches!(data, UsageData::Endpoint(_)));
1634 }
1635
1636 #[test]
1637 fn fresh_cache_is_source_endpoint_not_jsonl() {
1638 let tmp = TempDir::new().unwrap();
1643 let cache = CacheStore::new(tmp.path().to_path_buf());
1644 cache
1645 .write(&CachedUsage::with_data(sample_response()))
1646 .unwrap();
1647
1648 let data = resolve_usage(
1649 Some(&cache),
1650 None,
1651 &FakeTransport::ok(200, "", None),
1652 &ok_creds,
1653 &jsonl_empty,
1654 &now_fn(),
1655 &config(),
1656 )
1657 .expect("ok");
1658 assert!(matches!(data, UsageData::Endpoint(_)));
1659 }
1660
1661 #[test]
1662 fn clock_skew_future_cached_at_treats_entry_as_stale() {
1663 let tmp = TempDir::new().unwrap();
1669 let path = tmp.path().join("usage.json");
1670 let mut entry = CachedUsage::with_data(sample_response());
1671 entry.cached_at = Utc::now() + ChronoDuration::hours(1);
1672 std::fs::write(&path, serde_json::to_string(&entry).unwrap()).unwrap();
1673 let cache = CacheStore::new(tmp.path().to_path_buf());
1674
1675 let transport = FakeTransport::ok(200, SAMPLE_BODY, None);
1676 let _ = resolve_usage(
1677 Some(&cache),
1678 None,
1679 &transport,
1680 &ok_creds,
1681 &jsonl_empty,
1682 &now_fn(),
1683 &config(),
1684 )
1685 .expect("ok");
1686 assert_eq!(transport.calls.get(), 1);
1687 }
1688
1689 fn make_io_error(kind: io::ErrorKind) -> CacheError {
1700 CacheError::Io {
1701 path: std::path::PathBuf::from("/test/path"),
1702 cause: io::Error::new(kind, "test"),
1703 }
1704 }
1705
1706 fn make_persist_error(kind: io::ErrorKind) -> CacheError {
1707 CacheError::Persist {
1708 path: std::path::PathBuf::from("/test/path"),
1709 cause: io::Error::new(kind, "test"),
1710 }
1711 }
1712
1713 #[test]
1714 fn classify_persist_error_routes_io_failure_to_error() {
1715 let (class, msg) = classify_persist_error("cache", &make_io_error(io::ErrorKind::NotFound));
1716 assert_eq!(class, PersistLogClass::Error);
1717 assert!(
1718 msg.contains("cascade: cache write failed:"),
1719 "expected loud-signal prefix, got {msg:?}"
1720 );
1721 }
1722
1723 #[test]
1724 fn classify_persist_error_routes_lock_kind_into_message() {
1725 let (class, msg) =
1726 classify_persist_error("lock", &make_persist_error(io::ErrorKind::OutOfMemory));
1727 assert_eq!(class, PersistLogClass::Error);
1728 assert!(
1729 msg.contains("cascade: lock write failed:"),
1730 "kind label must thread through, got {msg:?}"
1731 );
1732 }
1733
1734 #[cfg(unix)]
1735 #[test]
1736 fn classify_persist_error_routes_permission_denied_to_error_on_unix() {
1737 let (class, msg) = classify_persist_error(
1741 "cache",
1742 &make_persist_error(io::ErrorKind::PermissionDenied),
1743 );
1744 assert_eq!(class, PersistLogClass::Error);
1745 assert!(msg.contains("cascade: cache write failed:"));
1746 }
1747
1748 #[cfg(windows)]
1749 #[test]
1750 fn classify_persist_error_routes_persist_permission_denied_to_debug_on_windows() {
1751 let (class, msg) = classify_persist_error(
1755 "cache",
1756 &make_persist_error(io::ErrorKind::PermissionDenied),
1757 );
1758 assert_eq!(class, PersistLogClass::Debug);
1759 assert!(
1760 msg.contains("race-loser") && msg.contains("Windows MoveFileEx"),
1761 "expected race-loser framing, got {msg:?}"
1762 );
1763 }
1764
1765 #[cfg(windows)]
1766 #[test]
1767 fn classify_persist_error_routes_io_permission_denied_to_error_on_windows() {
1768 let (class, _msg) =
1772 classify_persist_error("cache", &make_io_error(io::ErrorKind::PermissionDenied));
1773 assert_eq!(class, PersistLogClass::Error);
1774 }
1775
1776 #[test]
1781 fn route_persist_error_dispatches_debug_class_to_debug_closure_only() {
1782 let mut debug_calls = 0;
1783 let mut error_calls = 0;
1784 route_persist_error(
1785 PersistLogClass::Debug,
1786 "msg",
1787 |_| debug_calls += 1,
1788 |_| error_calls += 1,
1789 );
1790 assert_eq!((debug_calls, error_calls), (1, 0));
1791 }
1792
1793 #[test]
1794 fn route_persist_error_dispatches_error_class_to_error_closure_only() {
1795 let mut debug_calls = 0;
1796 let mut error_calls = 0;
1797 route_persist_error(
1798 PersistLogClass::Error,
1799 "msg",
1800 |_| debug_calls += 1,
1801 |_| error_calls += 1,
1802 );
1803 assert_eq!((debug_calls, error_calls), (0, 1));
1804 }
1805
1806 #[test]
1807 fn route_persist_error_passes_msg_through_unchanged() {
1808 let mut received: Option<String> = None;
1809 route_persist_error(
1810 PersistLogClass::Error,
1811 "cascade: cache write failed: disk full",
1812 |_| {},
1813 |s| received = Some(s.to_string()),
1814 );
1815 assert_eq!(
1816 received.as_deref(),
1817 Some("cascade: cache write failed: disk full")
1818 );
1819 }
1820}