claude-smart 0.2.10

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
//! Account scoring: choose the best profile to launch under.
//!
//! Logic (spec §2 Account pick + scoring, shell source pick_account ~lines 883–971):
//!
//! 1. Build candidate rows: profiles NOT in errors{}, with a numeric week_all.pct.
//! 2. Exclusions (in order, per shell lines 947–949):
//!    - `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 with the HIGHEST week_all.pct (shell lines
//!    955–961: `pct > best_pct` wins; `pct == best_pct` → soonest reset epoch wins
//!    — a known epoch beats unknown, a smaller epoch beats a larger one).
//! 4. `include_current = false` (reactive / hook): skip the current profile entirely
//!    (shell line 942–944).
//! 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
//!    (shell lines 967–969).
//! 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;
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
/// Before scoring, the data's freshness is checked against the usage max-age
/// (`usage_max_age_secs`). 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 / hook 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`).
///
/// # Shell source
/// `pick_account` in `claude-smart-helper.sh.j2` lines 883–971.
pub fn pick_best(data: &UsageData, current_profile: &str, include_current: bool) -> ScoringResult {
    pick_best_at(data, current_profile, include_current, 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`.
pub fn pick_best_at(
    data: &UsageData,
    current_profile: &str,
    include_current: bool,
    now: DateTime<Utc>,
) -> ScoringResult {
    // Staleness gate (spec: 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.
    if 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();

    // Shell lines 939–962: iterate rows, apply exclusion gates, track best.
    let mut best_name: Option<&str> = None;
    let mut best_pct: i64 = i64::MIN;
    let mut best_epoch: Option<i64> = None;

    // Sort by name for deterministic tie-break behavior in tests (HashMap order is
    // non-deterministic; the shell source reads jq output which may also vary).
    // The tie-break logic is epoch-based (from the data), so stable naming order
    // ensures tests with equal pcts and equal/absent epochs are reproducible.
    candidates.sort_by(|a, b| a.name.cmp(b.name));

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

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

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

        // Compute reset epoch for tie-breaking (shell line 950).
        // resets_to_epoch failures are treated as "unknown" (None), matching shell
        // behavior where `resets_to_epoch` prints nothing on parse failure.
        let epoch: Option<i64> = c
            .resets
            .and_then(|r| resets_to_epoch(r).ok())
            .map(|dt| dt.timestamp());

        // First viable candidate (shell lines 951–953).
        if best_name.is_none() {
            best_name = Some(c.name);
            best_pct = c.week_pct;
            best_epoch = epoch;
            continue;
        }

        // Higher pct wins (shell lines 955–956).
        if c.week_pct > best_pct {
            best_name = Some(c.name);
            best_pct = c.week_pct;
            best_epoch = epoch;
        } else if c.week_pct == best_pct {
            // Equal pct → soonest reset epoch wins (shell lines 957–960).
            // A known epoch beats unknown; a smaller epoch (sooner) beats a larger.
            let new_wins = match (epoch, best_epoch) {
                (Some(_), None) => true,       // known beats unknown
                (Some(e), Some(be)) => e < be, // smaller (sooner) wins
                _ => false,                    // unknown doesn't beat known or equal unknown
            };
            if new_wins {
                best_name = Some(c.name);
                best_pct = c.week_pct;
                best_epoch = epoch;
            }
        }
    }

    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 ──────────────────────────────────────────────────────────

    /// Shell behavior: among two healthy profiles, pick the one with the
    /// HIGHER week_all.pct (drain the account nearest its ceiling first).
    #[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"));
    }

    /// Higher week_pct profile is picked even when both are healthy.
    #[test]
    fn 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"));
    }

    // ─── tie-break by reset epoch ─────────────────────────────────────────────

    /// Equal week_pct, both with no resets string → both epochs are None → tie
    /// is broken by alphabetical candidate order (first alphabetically wins).
    /// This validates the tie-break code path without calling resets_to_epoch.
    #[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, 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, now).unwrap_err();
        assert!(
            matches!(err, ScoringError::NoUsableData),
            "stale data must be NoUsableData (open picker), got {err:?}"
        );
    }

    #[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, 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, 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, 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, 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, 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());
    }
}