claude-smart 0.2.12

Cross-platform Claude Code smart session manager
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
//! Account scoring: choose the best profile to launch under.
//!
//! Logic (spec §2 Account pick + scoring):
//!
//! 1. Build candidate rows: profiles NOT in errors{}, with a numeric week_all.pct.
//! 2. Exclusions (in order):
//!    - `session.pct >= LIMIT_PCT(99)` → skip (absent session.pct = -1, never fires).
//!    - `week_all.pct >= SATURATION_PCT(95)` → skip.
//! 3. Among the survivors, choose the one whose weekly reset returns SOONEST:
//!    a known `week_all.resets` epoch beats an unknown one, and a smaller
//!    (sooner) epoch beats a larger one. Ties (equal epoch, or all epochs
//!    unknown) break to the HIGHER week_all.pct, then to the first candidate
//!    in name order. Rationale: budget spent on the account that refills first
//!    is the cheapest budget — drain that account, keep the later-resetting
//!    ones in reserve.
//!    (Policy changed post-0.2.11: the retired shell source — pick_account,
//!    claude-smart-helper.sh.j2 lines 883–971 — drained highest-pct-first with
//!    a soonest-reset tie-break; the primary and secondary keys are now
//!    swapped.)
//! 4. `include_current = false` (reactive / hook): skip the current profile entirely.
//! 5. `include_current = true` (proactive / fresh csm): current competes; if the
//!    winner is current → return `Ok(None)` so the caller keeps it with no switch.
//! 6. No viable candidate → `Err(ScoringError::AllSaturated)`.
//!
//! Env overrides: `CLAUDE_LIMIT_PCT` / `CLAUDE_PICK_SATURATION_PCT` (spec §2).

use std::collections::HashMap;
use std::env;

use chrono::{DateTime, Utc};

use crate::account::reset::resets_to_epoch_at;
use crate::usage::{FetchError, UsageData};

// ─── constants ────────────────────────────────────────────────────────────────

/// The session usage percentage at which a profile is excluded as session-limited.
/// Override via `CLAUDE_LIMIT_PCT`.
pub const LIMIT_PCT: i64 = 99;

/// The week_all usage percentage at which a profile is considered saturated.
/// Override via `CLAUDE_PICK_SATURATION_PCT`.
pub const SATURATION_PCT: i64 = 95;

/// Sentinel value meaning "session pct absent" — intentionally chosen to be
/// negative so it never triggers the `>= LIMIT_PCT` gate.
pub const ABSENT_SESSION_PCT: i64 = -1;

// ─── helpers ─────────────────────────────────────────────────────────────────

/// Read `CLAUDE_LIMIT_PCT` from the environment, falling back to [`LIMIT_PCT`].
fn limit_pct() -> i64 {
    env::var("CLAUDE_LIMIT_PCT")
        .ok()
        .and_then(|v| v.trim().parse::<i64>().ok())
        .unwrap_or(LIMIT_PCT)
}

/// Read `CLAUDE_PICK_SATURATION_PCT` from the environment, falling back to
/// [`SATURATION_PCT`].
fn saturation_pct() -> i64 {
    env::var("CLAUDE_PICK_SATURATION_PCT")
        .ok()
        .and_then(|v| v.trim().parse::<i64>().ok())
        .unwrap_or(SATURATION_PCT)
}

/// Default max age, in seconds, of the usage data that account auto-pick will
/// still trust. The hub re-scrapes `limits` every 30 min (and the cc-sync
/// freshness alert pages at the same horizon), so data older than this means
/// the hub's scrape has stalled — the percentages no longer reflect reality and
/// auto-picking on them can route into an account that is actually over its
/// limit. Override via `CLAUDE_USAGE_MAX_AGE` (alias `CSM_USAGE_MAX_AGE_SECS`).
/// `0` disables the gate entirely (trust any age).
pub const USAGE_MAX_AGE_SECS: u64 = 1800;

/// Read the usage max-age gate (seconds) from the environment.
///
/// `CLAUDE_USAGE_MAX_AGE` wins; `CSM_USAGE_MAX_AGE_SECS` is the namespaced
/// alias (mirrors the `CLAUDE_USAGE_TTL` / `CSM_USAGE_TTL_SECS` pair in the
/// transport layer). Unparseable / unset → [`USAGE_MAX_AGE_SECS`]. `0` = gate
/// off.
fn usage_max_age_secs() -> u64 {
    env::var("CLAUDE_USAGE_MAX_AGE")
        .ok()
        .or_else(|| env::var("CSM_USAGE_MAX_AGE_SECS").ok())
        .and_then(|v| v.trim().parse::<u64>().ok())
        .unwrap_or(USAGE_MAX_AGE_SECS)
}

/// The freshest `captured_at` instant in `data`: the top-level field if
/// present, else the newest per-profile `captured_at`. `None` when no timestamp
/// anywhere parses (old cache files predate the field, or a non-hub source omit
/// it). RFC-3339 / ISO-8601 with a `Z` or offset (e.g. `2026-06-17T07:13:19Z`).
fn newest_captured_at(data: &UsageData) -> Option<DateTime<Utc>> {
    fn parse(s: &str) -> Option<DateTime<Utc>> {
        DateTime::parse_from_rfc3339(s.trim())
            .ok()
            .map(|dt| dt.with_timezone(&Utc))
    }
    let mut newest: Option<DateTime<Utc>> = data.captured_at.as_deref().and_then(parse);
    for pu in data.profiles.values() {
        if let Some(ts) = pu.captured_at.as_deref().and_then(parse) {
            newest = Some(match newest {
                Some(cur) if cur >= ts => cur,
                _ => ts,
            });
        }
    }
    newest
}

/// Decide whether `data` is too stale to auto-pick on, relative to `now`.
///
/// **fail-open**: returns `false` (trust the data) when the gate is disabled
/// (`max_age == 0`) OR no `captured_at` parses — we never *block* on an unknown
/// age, only on a known-and-too-old one. This preserves the pre-gate behaviour
/// for cache files / sources that carry no timestamp, while a hub whose scrape
/// froze (its `captured_at` stops advancing) is correctly caught.
fn data_too_stale_at(data: &UsageData, max_age_secs: u64, now: DateTime<Utc>) -> bool {
    if max_age_secs == 0 {
        return false; // gate disabled
    }
    let Some(captured) = newest_captured_at(data) else {
        return false; // unknown age → fail-open
    };
    let age = now.signed_duration_since(captured);
    // Negative age (captured_at in the future — clock skew) is not "stale".
    age.num_seconds() > max_age_secs as i64
}

// ─── public error + result types ─────────────────────────────────────────────

/// Errors that `pick_best` can return.
#[derive(Debug, thiserror::Error)]
pub enum ScoringError {
    /// All profiles are either saturated, session-limited, or errored.
    /// Caller should warn and proceed on the current profile.
    #[error("all profiles are saturated or at session limit")]
    AllSaturated,

    /// No profile carried any usable usage data — every profile was errored or
    /// had no `week_all` section (the fetch returned an empty/degenerate blob,
    /// not a confident "all limited" verdict). Distinct from [`AllSaturated`],
    /// where we *did* read real percentages and they were all over the line.
    ///
    /// "We couldn't tell" must not be silently treated as "stay put": the caller
    /// opens the interactive picker so the user chooses deliberately, exactly as
    /// it does for [`FetchFailed`].
    #[error("no usable usage data for any profile")]
    NoUsableData,

    /// The usage fetch failed (hub down / negative-cache cooldown).
    /// Caller should open the hub-down interactive picker.
    #[error("usage fetch failed: {0}")]
    FetchFailed(#[from] FetchError),
}

/// Result of scoring: the profile name to switch to, or `None` if already on
/// the best profile (`--include-current` with winner == current).
pub type ScoringResult = Result<Option<String>, ScoringError>;

// ─── scoring core ─────────────────────────────────────────────────────────────

/// Pick the best profile given fetched usage data.
///
/// # Parameters
/// - `data`: fetched [`UsageData`] (already validated JSON).
/// - `current_profile`: name of the currently active profile (may be empty string
///   when `CLAUDE_CONFIG_DIR` is unset).
/// - `include_current`: if `true`, return `None` when the winner is the current
///   profile (caller treats this as "no switch needed").
///
/// # Returns
/// - `Ok(Some(name))` — switch to this profile.
/// - `Ok(None)` — winner is current and `include_current` is true (no-op).
/// - `Err(ScoringError::AllSaturated)` — no viable candidate; caller warns and
///   proceeds.
///
/// # Staleness gate
/// When `apply_stale_gate` is `true`, the data's freshness is checked against
/// the usage max-age (`usage_max_age_secs`) BEFORE scoring. If the newest
/// `captured_at` is older than the gate, the percentages are no longer
/// trustworthy and we return [`ScoringError::NoUsableData`] WITHOUT scoring —
/// exactly as if no profile had usable data. The caller then opens the
/// interactive picker (or, in a non-interactive context, fail-safes to the
/// current profile), rather than auto-routing into an account whose real usage
/// we cannot see. The gate fail-opens on a missing/unparseable timestamp (see
/// `data_too_stale_at`).
///
/// Proactive launch and the explicit `csm pick-account` CLI pass `true` (they
/// have a picker fallback, so "we can't tell → ask the user" is correct). The
/// reactive **hook** passes `false`: it fires only because the current profile
/// already hit a limit, and the hook is non-interactive (no picker). Refusing
/// to score on stale data there would strand the user ON the limited profile;
/// leaving for the freshest-known best, even on slightly stale numbers, is the
/// safer choice. See [`pick_best`] / [`pick_best_gated`].
///
/// # Ranking
/// Soonest known weekly reset first; ties break to higher week_all.pct, then
/// name order (see the module doc — this diverges from the retired shell
/// source, which ranked highest-pct-first).
///
/// Production callers go through [`pick_account`](crate::account::pick_account)
/// → [`pick_best_gated`]; this gate-on convenience wrapper is the documented
/// public entry and is exercised by the scoring tests, hence the non-test
/// `dead_code` allow.
#[cfg_attr(not(test), allow(dead_code))]
pub fn pick_best(data: &UsageData, current_profile: &str, include_current: bool) -> ScoringResult {
    pick_best_at(data, current_profile, include_current, true, Utc::now())
}

/// Like [`pick_best`] but with explicit control over the staleness gate.
/// `apply_stale_gate=false` scores even on stale data (reactive-hook path).
pub fn pick_best_gated(
    data: &UsageData,
    current_profile: &str,
    include_current: bool,
    apply_stale_gate: bool,
) -> ScoringResult {
    pick_best_at(
        data,
        current_profile,
        include_current,
        apply_stale_gate,
        Utc::now(),
    )
}

/// `now`-injected core of [`pick_best`], for deterministic staleness tests.
/// Mirrors the `resets_to_epoch` / `resets_to_epoch_at` split in `reset.rs`.
///
/// `apply_stale_gate` toggles the freshness gate (see [`pick_best`] docs).
pub fn pick_best_at(
    data: &UsageData,
    current_profile: &str,
    include_current: bool,
    apply_stale_gate: bool,
    now: DateTime<Utc>,
) -> ScoringResult {
    // Staleness gate (spec: proactive auto-pick must not fly on stale usage). A
    // frozen hub scrape keeps serving the same `captured_at`; once that ages
    // past the gate we refuse to score and let the caller fall back to the
    // picker/current. The reactive hook opts OUT (apply_stale_gate=false): it
    // must move off an already-limited profile even on stale numbers.
    if apply_stale_gate && data_too_stale_at(data, usage_max_age_secs(), now) {
        return Err(ScoringError::NoUsableData);
    }

    let lim = limit_pct();
    let sat = saturation_pct();

    // Candidates: profiles present in data.profiles, NOT in errors{}.
    // Build (name, week_all_pct, session_pct, resets_str) tuples.
    // Shell lines 924–934: jq emits profile name, week_all.pct, session.pct(-1 absent),
    // week_all.resets.
    // Borrow the errors map, or use an empty sentinel for the "no errors" case.
    let empty_errors: HashMap<String, String> = HashMap::new();
    let errors: &HashMap<String, String> = data
        .errors
        .as_ref()
        .map(|m| m as &HashMap<String, String>)
        .unwrap_or(&empty_errors);

    // We keep this borrowed ref around for the loop.
    struct Candidate<'a> {
        name: &'a str,
        week_pct: i64,
        session_pct: i64,
        resets: Option<&'a str>,
    }

    let mut candidates: Vec<Candidate<'_>> = data
        .profiles
        .iter()
        .filter_map(|(name, pu)| {
            // skip errored profiles (shell line 928: select(($e[.key] // null) == null))
            if errors.contains_key(name.as_str()) {
                return None;
            }
            // skip profiles with no week_all section (shell line 929:
            // select((.value.week_all.pct // null) != null))
            let week_pct = pu.week_all.as_ref()?.pct;
            let session_pct = pu
                .session
                .as_ref()
                .map(|s| s.pct)
                .unwrap_or(ABSENT_SESSION_PCT);
            let resets = pu.week_all.as_ref().and_then(|wa| wa.resets.as_deref());
            Some(Candidate {
                name: name.as_str(),
                week_pct,
                session_pct,
                resets,
            })
        })
        .collect();

    // Iterate rows, apply exclusion gates, track best.
    let mut best_name: Option<&str> = None;
    let mut best_key: (i64, i64) = (i64::MAX, i64::MAX);

    // Sort by name for deterministic tie-break behavior (HashMap order is
    // non-deterministic). The rank key is data-derived (epoch, pct), so stable
    // naming order ensures full ties are reproducible: the strictly-smaller
    // comparison below keeps the first name among fully-tied candidates.
    candidates.sort_by(|a, b| a.name.cmp(b.name));

    for c in &candidates {
        // Reactive (hook) mode: never target the current profile.
        if !include_current && !current_profile.is_empty() && c.name == current_profile {
            continue;
        }

        // Session-limit gate: session.pct >= LIMIT_PCT → skip.
        // -1 (absent) is < 99 so it intentionally passes.
        if c.session_pct >= lim {
            continue;
        }

        // Saturation gate: week_all.pct >= SATURATION_PCT → skip.
        if c.week_pct >= sat {
            continue;
        }

        // Rank key, smaller wins: (weekly reset epoch, negated week pct).
        // Primary: SOONEST weekly reset — parse failures / absent resets become
        // i64::MAX so a known epoch always beats an unknown one. Secondary:
        // higher week_all.pct (drain the fuller of two same-reset accounts).
        // Parsed against the same `now` as the staleness gate for determinism.
        let epoch: i64 = c
            .resets
            .and_then(|r| resets_to_epoch_at(r, now).ok())
            .map(|dt| dt.timestamp())
            .unwrap_or(i64::MAX);
        let key = (epoch, -c.week_pct);

        if best_name.is_none() || key < best_key {
            best_name = Some(c.name);
            best_key = key;
        }
    }

    match best_name {
        // No winner. Distinguish "we read real numbers and they were all over
        // the limit" (AllSaturated → keep current) from "no profile had any
        // usable usage at all" (NoUsableData → open the picker). `candidates`
        // already excluded errored / no-week_all profiles, so an empty
        // candidate set means we never had data to score on.
        None if candidates.is_empty() => Err(ScoringError::NoUsableData),
        None => Err(ScoringError::AllSaturated),
        Some(name) => {
            // include_current=true: winner == current → no-op (shell lines 967–969).
            if include_current && !current_profile.is_empty() && name == current_profile {
                Ok(None)
            } else {
                Ok(Some(name.to_owned()))
            }
        }
    }
}

/// Return the `(session_pct, week_all_pct)` for a given profile from `data`,
/// or `None` when the profile is errored, absent, or has no week_all section.
///
/// Absent `session.pct` is encoded as [`ABSENT_SESSION_PCT`] (-1) (spec §2,
/// shell `current-usage` lines 730–753).
///
/// Test-isolation helper: the production scorer inlines `data.current_usage`;
/// this named wrapper lets unit tests assert the §2 encoding independently.
#[allow(dead_code)]
pub fn current_usage_pcts(data: &UsageData, profile: &str) -> Option<(i64, i64)> {
    data.current_usage(profile)
}

/// Helper: is a profile excluded from candidacy?
///
/// Returns `true` when the profile should be skipped in scoring:
/// - present in `errors` map,
/// - `session.pct >= LIMIT_PCT` (env-overridable), or
/// - `week_all.pct >= SATURATION_PCT` (env-overridable).
///
/// Used by tests to assert individual exclusion conditions independently.
/// Test-isolation helper: the production scorer inlines the same three checks.
#[allow(dead_code)]
pub fn is_excluded(data: &UsageData, profile: &str) -> bool {
    let lim = limit_pct();
    let sat = saturation_pct();

    if let Some(errors) = &data.errors {
        if errors.contains_key(profile) {
            return true;
        }
    }

    if let Some(pu) = data.profiles.get(profile) {
        let session_pct = pu
            .session
            .as_ref()
            .map(|s| s.pct)
            .unwrap_or(ABSENT_SESSION_PCT);
        if session_pct >= lim {
            return true;
        }
        if let Some(wa) = &pu.week_all {
            if wa.pct >= sat {
                return true;
            }
        }
    }

    false
}

// ─── tests ────────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use std::collections::HashMap;

    use crate::usage::model::{ProfileUsage, UsageData, UsageSection};

    use super::*;

    // ─── fixture builders ─────────────────────────────────────────────────────

    fn make_section(pct: i64, resets: Option<&str>) -> UsageSection {
        UsageSection {
            pct,
            resets: resets.map(String::from),
        }
    }

    fn make_profile(session_pct: Option<i64>, week_pct: i64, resets: Option<&str>) -> ProfileUsage {
        ProfileUsage {
            captured_at: None,
            session: session_pct.map(|p| make_section(p, None)),
            week_all: Some(make_section(week_pct, resets)),
            week_sonnet: None,
            session_stats: vec![],
        }
    }

    fn make_data(profiles: HashMap<String, ProfileUsage>) -> UsageData {
        UsageData {
            captured_at: None,
            profiles,
            errors: None,
        }
    }

    fn make_data_with_errors(
        profiles: HashMap<String, ProfileUsage>,
        errors: HashMap<String, String>,
    ) -> UsageData {
        UsageData {
            captured_at: None,
            profiles,
            errors: Some(errors),
        }
    }

    // ─── constants sanity ─────────────────────────────────────────────────────

    #[test]
    fn constants_are_sane() {
        const {
            assert!(
                LIMIT_PCT > SATURATION_PCT,
                "LIMIT_PCT must be > SATURATION_PCT"
            )
        };
        const { assert!(ABSENT_SESSION_PCT < 0, "absent sentinel must be negative") };
    }

    // ─── basic pick ──────────────────────────────────────────────────────────

    /// A saturated profile is excluded; the remaining healthy one wins.
    #[test]
    fn one_saturated_one_healthy_picks_healthy() {
        let mut profiles = HashMap::new();
        // "saturated" has week_pct = 96 (>= SATURATION_PCT=95) → excluded
        profiles.insert("saturated".to_string(), make_profile(Some(10), 96, None));
        // "healthy" has week_pct = 60 → viable
        profiles.insert("healthy".to_string(), make_profile(Some(5), 60, None));
        let data = make_data(profiles);
        let result = pick_best(&data, "other", false).unwrap();
        assert_eq!(result.as_deref(), Some("healthy"));
    }

    /// Fixed reference instant for reset-epoch ranking tests: noon UTC on
    /// Jun 17 2026 (= 9pm KST), matching the `reset.rs` test convention.
    fn ranking_now() -> chrono::DateTime<Utc> {
        Utc.with_ymd_and_hms(2026, 6, 17, 12, 0, 0).unwrap()
    }

    /// PRIMARY key: the account whose weekly reset returns soonest wins, even
    /// against a much higher week_all.pct. Budget on the account that refills
    /// first is the cheapest to spend.
    #[test]
    fn sooner_reset_beats_higher_pct() {
        let mut profiles = HashMap::new();
        // "fresh" barely used but resets later (Jun 20).
        profiles.insert(
            "fresh".to_string(),
            make_profile(Some(0), 0, Some("Jun 20 at 9pm (Asia/Seoul)")),
        );
        // "burning" heavily used but resets sooner (Jun 18).
        profiles.insert(
            "burning".to_string(),
            make_profile(Some(5), 70, Some("Jun 18 at 9pm (Asia/Seoul)")),
        );
        let data = make_data(profiles);
        let result = pick_best_at(&data, "", false, true, ranking_now()).unwrap();
        assert_eq!(result.as_deref(), Some("burning"));
    }

    /// Regression for the two-account shape that motivated the policy flip:
    /// `heavy` is the more-used account (week 31%) but resets LATER (Jul 9);
    /// `light` is barely used (week 0%) but resets SOONER (Jul 8). The old
    /// highest-pct-first rule kept draining `heavy`; the new rule drains
    /// `light` (soonest refill = cheapest budget). `now` sits before both
    /// resets so no year-rollover perturbs the ordering. Names are neutral
    /// (the no_private_names guard forbids real profile literals here).
    #[test]
    fn sooner_resetting_account_wins_over_more_used_one() {
        use chrono::TimeZone;
        let now = Utc.with_ymd_and_hms(2026, 7, 4, 12, 0, 0).unwrap();
        let mut profiles = HashMap::new();
        profiles.insert(
            "heavy".to_string(),
            make_profile(Some(27), 31, Some("Jul 9 at 8:59pm (Asia/Seoul)")),
        );
        profiles.insert(
            "light".to_string(),
            make_profile(Some(0), 0, Some("Jul 8 at 6pm (Asia/Seoul)")),
        );
        let data = make_data(profiles);
        // Proactive launch already on `heavy`: the sooner-resetting `light`
        // must be recommended as a switch, not silently kept.
        let result = pick_best_at(&data, "heavy", true, true, now).unwrap();
        assert_eq!(result.as_deref(), Some("light"));
    }

    /// A known weekly reset epoch beats an unknown one regardless of pct.
    #[test]
    fn known_reset_beats_unknown_regardless_of_pct() {
        let mut profiles = HashMap::new();
        profiles.insert("noreset".to_string(), make_profile(Some(5), 70, None));
        profiles.insert(
            "hasreset".to_string(),
            make_profile(Some(5), 10, Some("Jun 20 at 9pm (Asia/Seoul)")),
        );
        let data = make_data(profiles);
        let result = pick_best_at(&data, "", false, true, ranking_now()).unwrap();
        assert_eq!(result.as_deref(), Some("hasreset"));
    }

    /// Equal reset epoch → the HIGHER week_all.pct wins (drain the fuller of
    /// two accounts whose budgets refill at the same instant).
    #[test]
    fn equal_reset_higher_pct_wins() {
        let mut profiles = HashMap::new();
        let resets = Some("Jun 18 at 9pm (Asia/Seoul)");
        profiles.insert("low".to_string(), make_profile(Some(5), 30, resets));
        profiles.insert("high".to_string(), make_profile(Some(5), 70, resets));
        let data = make_data(profiles);
        let result = pick_best_at(&data, "", false, true, ranking_now()).unwrap();
        assert_eq!(result.as_deref(), Some("high"));
    }

    /// SECONDARY key fallback: when no candidate has a parseable reset, the
    /// higher week_pct profile is picked (the pre-0.3 primary key).
    #[test]
    fn no_resets_picks_highest_week_pct() {
        let mut profiles = HashMap::new();
        // "low" at 30%, "high" at 70%
        profiles.insert("low".to_string(), make_profile(Some(5), 30, None));
        profiles.insert("high".to_string(), make_profile(Some(5), 70, None));
        let data = make_data(profiles);
        let result = pick_best(&data, "", false).unwrap();
        assert_eq!(result.as_deref(), Some("high"));
    }

    // ─── full-tie fallback (equal epoch + equal pct) ──────────────────────────

    /// Equal week_pct, both with no resets string → both epochs are unknown →
    /// the full tie is broken by alphabetical candidate order (first wins).
    /// This validates the tie-break code path without calling the reset parser.
    #[test]
    fn tiebreak_no_resets_alphabetical_first_wins() {
        let mut profiles = HashMap::new();
        // Both at 50% with no resets string → epoch = None for both.
        // Candidates are sorted alphabetically so "alpha" is first, wins by
        // virtue of being first to set best_name when both epochs are None.
        profiles.insert("alpha".to_string(), make_profile(Some(5), 50, None));
        profiles.insert("beta".to_string(), make_profile(Some(5), 50, None));
        let data = make_data(profiles);
        let result = pick_best(&data, "", false).unwrap();
        assert!(result.is_some(), "tie-break must return Some");
        // "alpha" is first alphabetically; "beta" has no known epoch advantage
        // (both None → new_wins=false → alpha keeps best).
        assert_eq!(
            result.as_deref(),
            Some("alpha"),
            "with equal pct and no epochs, first alphabetical candidate wins"
        );
    }

    /// Equal week_pct, "early" has no resets (epoch=None), "zeta" has no
    /// resets either.  With equal pcts and both epochs unknown, first
    /// alphabetical wins.
    #[test]
    fn tiebreak_equal_pct_and_no_epoch_first_alphabetical_wins() {
        let mut profiles = HashMap::new();
        profiles.insert("early".to_string(), make_profile(Some(5), 50, None));
        profiles.insert("zeta".to_string(), make_profile(Some(5), 50, None));
        let data = make_data(profiles);
        let result = pick_best(&data, "", false).unwrap();
        // "early" < "zeta" alphabetically → "early" is first, wins the tie.
        assert_eq!(result.as_deref(), Some("early"));
    }

    // ─── include_current flag ─────────────────────────────────────────────────

    /// include_current=true: if winner IS current → return Ok(None) (no-op switch).
    #[test]
    fn include_current_no_op_when_winner_is_current() {
        let mut profiles = HashMap::new();
        // "current" is the only healthy profile — it should win but trigger the no-op.
        profiles.insert("current".to_string(), make_profile(Some(10), 70, None));
        let data = make_data(profiles);
        let result = pick_best(&data, "current", true).unwrap();
        assert_eq!(
            result, None,
            "winner == current with include_current=true must be None"
        );
    }

    /// include_current=true: if winner is NOT current, return that winner.
    #[test]
    fn include_current_returns_better_profile() {
        let mut profiles = HashMap::new();
        profiles.insert("current".to_string(), make_profile(Some(10), 30, None));
        profiles.insert("better".to_string(), make_profile(Some(5), 70, None));
        let data = make_data(profiles);
        let result = pick_best(&data, "current", true).unwrap();
        assert_eq!(result.as_deref(), Some("better"));
    }

    /// include_current=true with the current profile as the ONLY candidate, but
    /// saturated: the saturation gate drops it, leaving zero candidates, so the
    /// result is AllSaturated — NOT Ok(None). The distinction matters: Ok(None)
    /// means "stay, you're already best"; AllSaturated means "nothing viable,
    /// caller must warn". A saturated sole-current must take the second path so
    /// proactive launch surfaces the saturation rather than silently proceeding
    /// as if the current profile were a healthy pick.
    #[test]
    fn include_current_sole_saturated_current_is_all_saturated_not_noop() {
        let mut profiles = HashMap::new();
        profiles.insert("current".to_string(), make_profile(Some(10), 97, None)); // >= SATURATION_PCT
        let data = make_data(profiles);
        let err = pick_best(&data, "current", true).unwrap_err();
        assert!(
            matches!(err, ScoringError::AllSaturated),
            "a saturated sole current must be AllSaturated, not a no-op Ok(None)"
        );
    }

    // ─── no-usable-data vs all-saturated ──────────────────────────────────────

    /// A profile with NO week_all section (the usage blob arrived empty /
    /// degenerate for it). `make_profile` always fills week_all, so build it by
    /// hand.
    fn make_profile_no_week(session_pct: Option<i64>) -> ProfileUsage {
        ProfileUsage {
            captured_at: None,
            session: session_pct.map(|p| make_section(p, None)),
            week_all: None,
            week_sonnet: None,
            session_stats: vec![],
        }
    }

    /// Every profile lacks week_all → zero candidates ever formed. This is
    /// "we couldn't tell" (NoUsableData → caller opens the picker), NOT
    /// AllSaturated ("we read real numbers and they were all over the line").
    #[test]
    fn no_week_data_anywhere_is_no_usable_data_not_saturated() {
        let mut profiles = HashMap::new();
        profiles.insert("a".to_string(), make_profile_no_week(Some(10)));
        profiles.insert("b".to_string(), make_profile_no_week(None));
        let data = make_data(profiles);
        let err = pick_best(&data, "", true).unwrap_err();
        assert!(
            matches!(err, ScoringError::NoUsableData),
            "all-no-week must be NoUsableData (open picker), got {err:?}"
        );
    }

    // (empty-profiles and all-errored NoUsableData cases live next to their
    // former AllSaturated counterparts below, updated to the new verdict.)

    /// Contrast: profiles DID have week_all and they were all saturated →
    /// candidates formed then gated out → AllSaturated (keep current), NOT
    /// NoUsableData. Guards the boundary between the two verdicts.
    #[test]
    fn real_saturation_stays_all_saturated_not_no_usable_data() {
        let mut profiles = HashMap::new();
        profiles.insert("a".to_string(), make_profile(Some(10), 96, None)); // >= SATURATION
        profiles.insert("b".to_string(), make_profile(Some(10), 98, None));
        let data = make_data(profiles);
        let err = pick_best(&data, "", true).unwrap_err();
        assert!(
            matches!(err, ScoringError::AllSaturated),
            "real all-saturated must stay AllSaturated, got {err:?}"
        );
    }

    /// include_current=false: exclude current from candidates.
    #[test]
    fn exclude_current_in_reactive_mode() {
        let mut profiles = HashMap::new();
        // "current" has the highest pct but must be excluded.
        profiles.insert("current".to_string(), make_profile(Some(10), 80, None));
        profiles.insert("alt".to_string(), make_profile(Some(5), 40, None));
        let data = make_data(profiles);
        let result = pick_best(&data, "current", false).unwrap();
        assert_eq!(
            result.as_deref(),
            Some("alt"),
            "reactive mode must not return current"
        );
    }

    // ─── all-saturated ────────────────────────────────────────────────────────

    /// When all profiles are saturated (week_pct >= SATURATION_PCT), return AllSaturated.
    #[test]
    fn all_saturated_returns_error() {
        let mut profiles = HashMap::new();
        profiles.insert("p1".to_string(), make_profile(Some(10), 95, None)); // exactly SATURATION_PCT
        profiles.insert("p2".to_string(), make_profile(Some(10), 98, None));
        let data = make_data(profiles);
        let err = pick_best(&data, "other", false).unwrap_err();
        assert!(
            matches!(err, ScoringError::AllSaturated),
            "all saturated must return AllSaturated"
        );
    }

    /// Empty profiles → NoUsableData (we never had a number to score on — the
    /// caller opens the picker, it must not be mistaken for "all at the limit").
    #[test]
    fn empty_profiles_is_no_usable_data() {
        let data = make_data(HashMap::new());
        let err = pick_best(&data, "", false).unwrap_err();
        assert!(matches!(err, ScoringError::NoUsableData));
    }

    // ─── errored profiles excluded ────────────────────────────────────────────

    /// Profiles in the errors map must never be candidates.
    #[test]
    fn errored_profile_excluded() {
        let mut profiles = HashMap::new();
        // "errored" has a healthy week_pct but is in the errors map → must be excluded.
        profiles.insert("errored".to_string(), make_profile(Some(5), 80, None));
        profiles.insert("healthy".to_string(), make_profile(Some(5), 50, None));
        let mut errors = HashMap::new();
        errors.insert(
            "errored".to_string(),
            "HTTP 401: no credentials".to_string(),
        );
        let data = make_data_with_errors(profiles, errors);
        let result = pick_best(&data, "", false).unwrap();
        assert_eq!(
            result.as_deref(),
            Some("healthy"),
            "errored profile must be excluded"
        );
    }

    /// All profiles errored → NoUsableData (transport/scrape failures, not real
    /// limits — the caller opens the picker rather than silently keeping current).
    #[test]
    fn all_errored_is_no_usable_data() {
        let mut profiles = HashMap::new();
        profiles.insert("p1".to_string(), make_profile(Some(5), 50, None));
        let mut errors = HashMap::new();
        errors.insert("p1".to_string(), "error".to_string());
        let data = make_data_with_errors(profiles, errors);
        let err = pick_best(&data, "", false).unwrap_err();
        assert!(matches!(err, ScoringError::NoUsableData));
    }

    // ─── session-limit gate ───────────────────────────────────────────────────

    /// A profile with session.pct >= LIMIT_PCT must be excluded even if its
    /// week_all is healthy.
    #[test]
    fn session_limited_excluded() {
        let mut profiles = HashMap::new();
        // "session_hit" has session_pct=99 (== LIMIT_PCT) → excluded
        profiles.insert("session_hit".to_string(), make_profile(Some(99), 20, None));
        profiles.insert("healthy".to_string(), make_profile(Some(5), 10, None));
        let data = make_data(profiles);
        let result = pick_best(&data, "other", false).unwrap();
        assert_eq!(result.as_deref(), Some("healthy"));
    }

    /// A profile with absent session.pct (encoded as -1) must NOT be excluded
    /// by the session gate (shell comment: "Unknown session pct... encoded as -1
    /// and never excludes — only a POSITIVE limit reading disqualifies").
    #[test]
    fn absent_session_pct_not_excluded() {
        let mut profiles = HashMap::new();
        // session=None → ABSENT_SESSION_PCT (-1) → must not be excluded
        profiles.insert("no_session".to_string(), make_profile(None, 50, None));
        let data = make_data(profiles);
        let result = pick_best(&data, "", false).unwrap();
        assert_eq!(result.as_deref(), Some("no_session"));
    }

    // ─── is_excluded helper ───────────────────────────────────────────────────

    #[test]
    fn is_excluded_for_error_profile() {
        let mut profiles = HashMap::new();
        profiles.insert("p".to_string(), make_profile(Some(5), 50, None));
        let mut errors = HashMap::new();
        errors.insert("p".to_string(), "err".to_string());
        let data = make_data_with_errors(profiles, errors);
        assert!(is_excluded(&data, "p"));
    }

    #[test]
    fn is_excluded_for_session_limited() {
        let mut profiles = HashMap::new();
        profiles.insert("p".to_string(), make_profile(Some(LIMIT_PCT), 50, None));
        let data = make_data(profiles);
        assert!(is_excluded(&data, "p"));
    }

    #[test]
    fn is_excluded_for_saturated() {
        let mut profiles = HashMap::new();
        profiles.insert("p".to_string(), make_profile(Some(5), SATURATION_PCT, None));
        let data = make_data(profiles);
        assert!(is_excluded(&data, "p"));
    }

    #[test]
    fn is_excluded_healthy_is_false() {
        let mut profiles = HashMap::new();
        profiles.insert("p".to_string(), make_profile(Some(5), 50, None));
        let data = make_data(profiles);
        assert!(!is_excluded(&data, "p"));
    }

    // ─── current_usage_pcts ───────────────────────────────────────────────────

    #[test]
    fn current_usage_pcts_present_profile() {
        let mut profiles = HashMap::new();
        profiles.insert("p".to_string(), make_profile(Some(42), 31, None));
        let data = make_data(profiles);
        let (sess, week) = current_usage_pcts(&data, "p").unwrap();
        assert_eq!(sess, 42);
        assert_eq!(week, 31);
    }

    #[test]
    fn current_usage_pcts_absent_session_encodes_minus_one() {
        let mut profiles = HashMap::new();
        profiles.insert("p".to_string(), make_profile(None, 55, None));
        let data = make_data(profiles);
        let (sess, week) = current_usage_pcts(&data, "p").unwrap();
        assert_eq!(sess, ABSENT_SESSION_PCT);
        assert_eq!(week, 55);
    }

    #[test]
    fn current_usage_pcts_errored_is_none() {
        let mut profiles = HashMap::new();
        profiles.insert("p".to_string(), make_profile(Some(5), 50, None));
        let mut errors = HashMap::new();
        errors.insert("p".to_string(), "err".to_string());
        let data = make_data_with_errors(profiles, errors);
        assert!(current_usage_pcts(&data, "p").is_none());
    }

    #[test]
    fn current_usage_pcts_absent_is_none() {
        let data = make_data(HashMap::new());
        assert!(current_usage_pcts(&data, "nonexistent").is_none());
    }

    // ─── no_week_all_section ──────────────────────────────────────────────────

    /// A profile with no week_all section must be excluded from candidacy
    /// (shell line 929: select((.value.week_all.pct // null) != null)).
    #[test]
    fn profile_without_week_all_is_excluded() {
        let mut profiles = HashMap::new();
        // Profile with no week_all section
        let pu = ProfileUsage {
            captured_at: None,
            session: Some(make_section(5, None)),
            week_all: None,
            week_sonnet: None,
            session_stats: vec![],
        };
        profiles.insert("no_week_all".to_string(), pu);
        profiles.insert("has_week_all".to_string(), make_profile(Some(5), 40, None));
        let data = make_data(profiles);
        let result = pick_best(&data, "", false).unwrap();
        assert_eq!(result.as_deref(), Some("has_week_all"));
    }

    // ─── reactive-mode empty current ─────────────────────────────────────────

    /// include_current=false with empty current string must not filter anything.
    #[test]
    fn reactive_mode_empty_current_includes_all() {
        let mut profiles = HashMap::new();
        profiles.insert("p".to_string(), make_profile(Some(5), 60, None));
        let data = make_data(profiles);
        let result = pick_best(&data, "", false).unwrap();
        assert_eq!(result.as_deref(), Some("p"));
    }

    // ─── single profile include_current=false, current != profile ────────────

    #[test]
    fn reactive_mode_picks_non_current_profile() {
        let mut profiles = HashMap::new();
        profiles.insert("alt".to_string(), make_profile(Some(10), 50, None));
        let data = make_data(profiles);
        // current is "main" but only "alt" exists; alt must win
        let result = pick_best(&data, "main", false).unwrap();
        assert_eq!(result.as_deref(), Some("alt"));
    }

    // ─── staleness gate (max-age) ────────────────────────────────────────────

    use chrono::TimeZone;

    /// A clearly-pickable single-profile dataset with a top-level `captured_at`.
    fn make_data_captured(captured_at: &str) -> UsageData {
        let mut profiles = HashMap::new();
        profiles.insert("alt".to_string(), make_profile(Some(10), 50, None));
        UsageData {
            captured_at: Some(captured_at.to_string()),
            profiles,
            errors: None,
        }
    }

    fn at(s: &str) -> DateTime<Utc> {
        DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc)
    }

    #[test]
    fn fresh_data_scores_normally() {
        // captured 5 min before `now` — well inside the 1800s default gate.
        let data = make_data_captured("2026-06-29T12:00:00Z");
        let now = at("2026-06-29T12:05:00Z");
        let result = pick_best_at(&data, "main", false, true, now).unwrap();
        assert_eq!(result.as_deref(), Some("alt"), "fresh data must score");
    }

    #[test]
    fn stale_data_degrades_to_no_usable_data() {
        // captured 40 min before `now` — past the 1800s (30 min) default gate.
        // Even though "alt" is trivially pickable, the gate must refuse to score
        // and hand the caller NoUsableData (→ picker / fail-safe-to-current),
        // NOT a confident auto-pick on percentages we can no longer trust.
        let data = make_data_captured("2026-06-29T12:00:00Z");
        let now = at("2026-06-29T12:40:00Z");
        let err = pick_best_at(&data, "main", false, true, now).unwrap_err();
        assert!(
            matches!(err, ScoringError::NoUsableData),
            "stale data must be NoUsableData (open picker), got {err:?}"
        );
    }

    #[test]
    fn stale_data_scores_when_gate_off() {
        // Same 40-min-stale dataset as `stale_data_degrades_to_no_usable_data`,
        // but with the gate OFF (apply_stale_gate=false) — the reactive-hook
        // path. The hook fires only because the current profile already hit a
        // limit and is non-interactive, so it must move to the freshest-known
        // best even on stale numbers rather than strand the user on the limited
        // profile. This is the auto-switch bug fix: gate-on returned
        // NoUsableData → NotifyOnly (no switch); gate-off scores → LimitSwitch.
        let data = make_data_captured("2026-06-29T12:00:00Z");
        let now = at("2026-06-29T12:40:00Z");
        let result = pick_best_at(&data, "main", false, false, now).unwrap();
        assert_eq!(
            result.as_deref(),
            Some("alt"),
            "gate off must score stale data so the hook can switch off a limited profile"
        );
    }

    #[test]
    fn missing_captured_at_fails_open() {
        // No captured_at anywhere (legacy cache / non-hub source). The gate must
        // NOT block — unknown age is trusted, preserving pre-gate behaviour.
        let mut profiles = HashMap::new();
        profiles.insert("alt".to_string(), make_profile(Some(10), 50, None));
        let data = make_data(profiles); // captured_at: None
        let now = at("2026-06-29T12:40:00Z");
        let result = pick_best_at(&data, "main", false, true, now).unwrap();
        assert_eq!(
            result.as_deref(),
            Some("alt"),
            "missing captured_at must fail-open (trust the data)"
        );
    }

    #[test]
    fn per_profile_captured_at_is_used_when_top_level_absent() {
        // top-level captured_at absent, but the profile carries one that is
        // fresh → score normally (newest_captured_at falls back to per-profile).
        let mut profiles = HashMap::new();
        let mut p = make_profile(Some(10), 50, None);
        p.captured_at = Some("2026-06-29T12:04:00Z".to_string());
        profiles.insert("alt".to_string(), p);
        let data = UsageData {
            captured_at: None,
            profiles,
            errors: None,
        };
        let now = at("2026-06-29T12:05:00Z");
        let result = pick_best_at(&data, "main", false, true, now).unwrap();
        assert_eq!(result.as_deref(), Some("alt"));
    }

    #[test]
    fn gate_disabled_with_zero_trusts_any_age() {
        // CLAUDE_USAGE_MAX_AGE=0 disables the gate: even ancient data scores.
        // env is process-global; set/restore around the assertion.
        let saved = std::env::var("CLAUDE_USAGE_MAX_AGE").ok();
        std::env::set_var("CLAUDE_USAGE_MAX_AGE", "0");
        let data = make_data_captured("2020-01-01T00:00:00Z"); // years old
        let now = at("2026-06-29T12:00:00Z");
        let result = pick_best_at(&data, "main", false, true, now);
        match saved {
            Some(v) => std::env::set_var("CLAUDE_USAGE_MAX_AGE", v),
            None => std::env::remove_var("CLAUDE_USAGE_MAX_AGE"),
        }
        assert_eq!(
            result.unwrap().as_deref(),
            Some("alt"),
            "max-age 0 must disable the gate (trust any age)"
        );
    }

    #[test]
    fn future_captured_at_is_not_stale() {
        // Clock skew: captured_at slightly in the future. A negative age must
        // not be read as "stale" — score normally.
        let data = make_data_captured("2026-06-29T12:10:00Z");
        let now = at("2026-06-29T12:05:00Z");
        let result = pick_best_at(&data, "main", false, true, now).unwrap();
        assert_eq!(result.as_deref(), Some("alt"));
    }

    #[test]
    fn newest_captured_at_prefers_top_level() {
        // top-level present and fresh, a profile's per-slice stale → top-level
        // wins (newest), so the dataset is fresh and scores.
        let mut profiles = HashMap::new();
        let mut p = make_profile(Some(10), 50, None);
        p.captured_at = Some("2026-06-29T11:00:00Z".to_string()); // old slice
        profiles.insert("alt".to_string(), p);
        let data = UsageData {
            captured_at: Some("2026-06-29T12:04:00Z".to_string()), // fresh top-level
            profiles,
            errors: None,
        };
        let now = at("2026-06-29T12:05:00Z");
        let result = pick_best_at(&data, "main", false, true, now).unwrap();
        assert_eq!(result.as_deref(), Some("alt"));
        // sanity: the helper picks the newer of the two
        let newest = newest_captured_at(&data).unwrap();
        assert_eq!(newest, Utc.with_ymd_and_hms(2026, 6, 29, 12, 4, 0).unwrap());
    }
}