tokmesh-cli 0.1.1

CLI and TUI for tokmesh — AI token usage analytics
Documentation
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
// ── Sakana (Fugu) subscription-usage provider ──
//
// IMPORTANT — read before touching this file:
//
//   Sakana (Fugu) exposes NO public usage/quota API. This was investigated and
//   confirmed: there is no documented REST endpoint and no OAuth-scoped usage
//   route comparable to Claude's `/api/oauth/usage` or Z.ai's quota endpoint.
//   The ONLY source of subscription-usage data is the authenticated billing
//   console at https://console.sakana.ai/billing.
//
//   That console is a Next.js app, but the rendered usage values ARE present in
//   the served HTML of a plain authenticated GET (verified against the real
//   page). So this provider fetches that HTML with the user's session cookie and
//   scrapes the values out of it.
//
//   Consequences you MUST keep in mind:
//     * This is a best-effort, COOKIE-AUTH, LAYOUT-COUPLED scraper. If Sakana
//       restructures the billing page, the parser silently degrades (fields
//       become None) or fails to find the markers. It is not a stable contract.
//     * The session cookie EXPIRES. When it does, the GET returns a login page
//       (or 401/403). We detect that and tell the user to refresh the cookie —
//       we do NOT panic and do NOT emit a bogus parse.
//     * The numbers reported here are rolling QUOTA windows (5-hour / weekly
//       "% used"), NOT dollar spend. Sakana subscription billing is a flat
//       monthly fee ($NN/mo), so there is no per-request spend to report; the
//       monthly price and next-renewal date are surfaced as plan metadata only.
//
//   Parsing is done with plain `str` scanning (no `regex` dependency in this
//   crate) but follows the exact validated token formats documented inline.

use anyhow::Result;

use super::{UsageMetric, UsageOutput};

const BILLING_URL: &str = "https://console.sakana.ai/billing";
const USER_AGENT: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \
AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";

// ── Credential discovery ──

/// Returns the session cookie string if one is available, else None.
///
/// Source order:
///   1. env var `SAKANA_SESSION_COOKIE`
///   2. file `<config dir>/sakana-session` (raw cookie string, trimmed)
///
/// The config dir is resolved via the canonical `crate::paths::get_config_dir()`
/// so `TOKMESH_CONFIG_DIR` / XDG overrides are honored (matching every other
/// provider, e.g. `codex.rs`), instead of hardcoding `~/.config/tokmesh`.
///
/// Empty / whitespace-only values are treated as absent.
fn session_cookie() -> Option<String> {
    if let Ok(val) = std::env::var("SAKANA_SESSION_COOKIE") {
        let trimmed = val.trim();
        if !trimmed.is_empty() {
            return Some(trimmed.to_string());
        }
    }

    let path = crate::paths::get_config_dir().join("sakana-session");
    if let Ok(content) = std::fs::read_to_string(&path) {
        let trimmed = content.trim();
        if !trimmed.is_empty() {
            return Some(trimmed.to_string());
        }
    }

    None
}

pub fn has_credentials() -> bool {
    session_cookie().is_some()
}

// ── Parsed shape ──

#[derive(Debug, Default, PartialEq)]
struct ParsedBilling {
    plan: Option<String>,
    monthly_price: Option<u32>,
    next_renewal: Option<String>,
    windows: Vec<ParsedWindow>,
}

#[derive(Debug, PartialEq)]
struct ParsedWindow {
    label: String,
    used_percent: f64,
    resets_at: Option<String>,
}

// ── Login-page detection ──

/// Heuristic: does this HTML look like a logged-out / login page rather than the
/// authenticated billing console?
///
/// We key off marker ABSENCE rather than sign-in strings on purpose: a valid
/// logged-in page can legitimately contain "/login" / "Sign in" references (auth
/// nav, callbacks), which would otherwise false-flag a working session.
///
/// However, the bare substrings "Billing" + ("/mo" | "% used") are too weak: an
/// error page, a redirect shell, or a partially-rendered page can carry those
/// tokens (e.g. inside script/RSC noise) yet contain no real billing data,
/// producing a bogus empty card instead of a needs-auth signal. So we require a
/// STRONGER positive signal — a real window label (`>5-hour<` / `>Weekly<`) or a
/// concrete `$NN/mo` price match — before trusting the page. A price-only shell
/// (price present but NO quota windows) is caught downstream in `parse_billing`,
/// which treats a windowless parse as not logged in. This stays conservative: a
/// genuine billing page always renders quota windows.
fn looks_logged_out(html: &str) -> bool {
    if !html.contains("Billing") {
        return true;
    }
    let has_real_window = !find_window_label_positions(html).is_empty();
    let has_real_price = find_monthly_price(html).is_some();
    !(has_real_window || has_real_price)
}

// ── Parsing helpers (plain str, no regex) ──

/// Find the monthly price for the pattern `\$(\d+)\s*/\s*mo`, e.g. `$20 / mo`,
/// `$20/mo`. Returns (price, byte index of the `$` that matched).
fn find_monthly_price(html: &str) -> Option<(u32, usize)> {
    let bytes = html.as_bytes();
    let mut search_from = 0usize;
    while let Some(rel) = html[search_from..].find('$') {
        let dollar_idx = search_from + rel;
        let mut i = dollar_idx + 1;
        // digits
        let digits_start = i;
        while i < bytes.len() && bytes[i].is_ascii_digit() {
            i += 1;
        }
        if i == digits_start {
            search_from = dollar_idx + 1;
            continue;
        }
        let digits = &html[digits_start..i];
        // optional whitespace
        while i < bytes.len() && bytes[i].is_ascii_whitespace() {
            i += 1;
        }
        // slash
        if i < bytes.len() && bytes[i] == b'/' {
            i += 1;
            while i < bytes.len() && bytes[i].is_ascii_whitespace() {
                i += 1;
            }
            if html[i..].starts_with("mo") {
                if let Ok(price) = digits.parse::<u32>() {
                    return Some((price, dollar_idx));
                }
            }
        }
        search_from = dollar_idx + 1;
    }
    None
}

/// Determine the active plan tier. The active tier is the (Standard|Pro|Max)
/// token NEAREST-PRECEDING the `$NN/mo` price match — the upgrade buttons also
/// contain "Pro"/"Max", but the active tier is the one rendered with the price.
/// If we cannot find one before the price, fall back to the first
/// (Standard|Pro|Max) occurrence after the word "Billing".
fn find_plan(html: &str, price_idx: Option<usize>) -> Option<String> {
    const TIERS: [&str; 3] = ["Standard", "Pro", "Max"];

    // Nearest-preceding the price.
    if let Some(idx) = price_idx {
        let prefix = &html[..idx];
        let mut best: Option<(usize, &str)> = None;
        for tier in TIERS {
            if let Some(pos) = prefix.rfind(tier) {
                match best {
                    Some((bp, _)) if bp >= pos => {}
                    _ => best = Some((pos, tier)),
                }
            }
        }
        if let Some((_, tier)) = best {
            return Some(tier.to_string());
        }
    }

    // Fallback: first tier after "Billing".
    let start = html
        .find("Billing")
        .map(|i| i + "Billing".len())
        .unwrap_or(0);
    let region = &html[start..];
    let mut best: Option<(usize, &str)> = None;
    for tier in TIERS {
        if let Some(pos) = region.find(tier) {
            match best {
                Some((bp, _)) if bp <= pos => {}
                _ => best = Some((pos, tier)),
            }
        }
    }
    best.map(|(_, tier)| tier.to_string())
}

/// Collect all `(\d+(\.\d+)?)% used` percentages in document order.
///
/// The number immediately preceding `%` is parsed in full as an `f64`,
/// INCLUDING a single decimal point (e.g. `7.5% used` -> 7.5). A previous
/// version walked back over at most 3 *digits*, which silently truncated
/// decimals — `7.5%` captured only `5` and reported `5.0`, confidently wrong.
/// Values are clamped to the sane percentage range `0..=100`.
fn find_used_percents(html: &str) -> Vec<f64> {
    const NEEDLE: &str = "% used";
    let bytes = html.as_bytes();
    let mut out = Vec::new();
    let mut search_from = 0usize;
    while let Some(rel) = html[search_from..].find(NEEDLE) {
        let pct_sign = search_from + rel; // index of '%'

        // Walk backwards over the contiguous numeric run immediately preceding
        // '%': ASCII digits plus a single decimal point. Stop at the first
        // non-numeric byte (or a second '.').
        let mut start = pct_sign;
        let mut seen_dot = false;
        while start > 0 {
            let b = bytes[start - 1];
            if b.is_ascii_digit() {
                start -= 1;
            } else if b == b'.' && !seen_dot {
                seen_dot = true;
                start -= 1;
            } else {
                break;
            }
        }

        // Reject a run that is just "." (no digits) or has a leading/trailing
        // dot that won't parse as f64; `parse` enforces the rest.
        let token = &html[start..pct_sign];
        if token.bytes().any(|b| b.is_ascii_digit()) {
            if let Ok(v) = token.parse::<f64>() {
                out.push(v.clamp(0.0, 100.0));
            }
        }
        search_from = pct_sign + NEEDLE.len();
    }
    out
}

/// Collect window label positions matching `>(5-hour|Weekly)<`, sorted by
/// document order. Returns (byte index of the label, label).
fn find_window_label_positions(html: &str) -> Vec<(usize, String)> {
    const LABELS: [&str; 2] = ["5-hour", "Weekly"];
    let mut found: Vec<(usize, String)> = Vec::new();
    for label in LABELS {
        let needle = format!(">{label}<");
        let mut search_from = 0usize;
        while let Some(rel) = html[search_from..].find(&needle) {
            let idx = search_from + rel;
            found.push((idx, label.to_string()));
            search_from = idx + needle.len();
        }
    }
    found.sort_by_key(|(idx, _)| *idx);
    found
}

/// Build one window per on-page label (`5-hour` / `Weekly`), binding the
/// percentage and reset time that STRUCTURALLY belong to that label — the first
/// of each within the label's section (from the label up to the next label) —
/// rather than collecting every `% used` in the document.
///
/// This matters because the served HTML embeds each usage value MORE THAN ONCE
/// (the rendered card markup AND serialized RSC data), so a global
/// "collect-all-percents, pair-by-index" approach invents phantom windows and
/// mis-pairs percentages. The window labels appear only in the rendered cards,
/// so anchoring on them is the reliable structural key.
fn parse_windows(html: &str) -> Vec<ParsedWindow> {
    let labels = find_window_label_positions(html);
    if !labels.is_empty() {
        let mut windows = Vec::with_capacity(labels.len());
        for (k, (pos, label)) in labels.iter().enumerate() {
            let end = labels.get(k + 1).map(|(p, _)| *p).unwrap_or(html.len());
            let segment = &html[*pos..end];
            if let Some(&pct) = find_used_percents(segment).first() {
                windows.push(ParsedWindow {
                    label: label.clone(),
                    used_percent: pct,
                    resets_at: find_reset_times(segment).into_iter().next(),
                });
            }
        }
        return windows;
    }

    // Degraded fallback: no labels found at all. Emit at most the known number
    // of windows from the leading percentages, in document order, so a label
    // markup change still surfaces *something* without inventing phantoms.
    const FALLBACK_LABELS: [&str; 2] = ["5-hour", "Weekly"];
    find_used_percents(html)
        .into_iter()
        .take(FALLBACK_LABELS.len())
        .enumerate()
        .map(|(i, pct)| ParsedWindow {
            label: FALLBACK_LABELS[i].to_string(),
            used_percent: pct,
            resets_at: None,
        })
        .collect()
}

/// Collect reset times matching
/// `Resets on\s+([A-Z][a-z]+ \d{1,2}, \d{4} at \d{1,2}:\d{2} [AP]M)` in order.
fn find_reset_times(html: &str) -> Vec<String> {
    const PREFIX: &str = "Resets on";
    let mut out = Vec::new();
    let mut search_from = 0usize;
    while let Some(rel) = html[search_from..].find(PREFIX) {
        let idx = search_from + rel;
        let after = &html[idx + PREFIX.len()..];
        let trimmed = after.trim_start();
        if let Some(reset) = parse_reset_value(trimmed) {
            out.push(reset);
        }
        search_from = idx + PREFIX.len();
    }
    out
}

/// Parse `Month D, YYYY at H:MM AM/PM` from the start of `s`.
fn parse_reset_value(s: &str) -> Option<String> {
    // Month
    let (month, rest) = take_capitalized_word(s)?;
    let rest = rest.strip_prefix(' ')?;
    let (_day, rest) = take_digits(rest, 1, 2)?;
    let rest = rest.strip_prefix(", ")?;
    let (_year, rest) = take_digits(rest, 4, 4)?;
    let rest = rest.strip_prefix(" at ")?;
    let (_hour, rest) = take_digits(rest, 1, 2)?;
    let rest = rest.strip_prefix(':')?;
    let (_min, rest) = take_digits(rest, 2, 2)?;
    let rest = rest.strip_prefix(' ')?;
    let meridiem = if rest.starts_with("AM") {
        "AM"
    } else if rest.starts_with("PM") {
        "PM"
    } else {
        return None;
    };
    // Reconstruct the exact matched substring.
    let consumed = s.len() - rest.len() + meridiem.len();
    let _ = month;
    Some(s[..consumed].to_string())
}

/// Next renewal: `Next renewal:?\s*([A-Z][a-z]+ \d{1,2}, \d{4})`.
fn find_next_renewal(html: &str) -> Option<String> {
    const PREFIX: &str = "Next renewal";
    let idx = html.find(PREFIX)?;
    let mut after = &html[idx + PREFIX.len()..];
    after = after.strip_prefix(':').unwrap_or(after);
    let after = after.trim_start();
    parse_date_value(after)
}

/// Parse `Month D, YYYY` from the start of `s`.
fn parse_date_value(s: &str) -> Option<String> {
    let (_month, rest) = take_capitalized_word(s)?;
    let rest = rest.strip_prefix(' ')?;
    let (_day, rest) = take_digits(rest, 1, 2)?;
    let rest = rest.strip_prefix(", ")?;
    let (_year, rest) = take_digits(rest, 4, 4)?;
    let consumed = s.len() - rest.len();
    Some(s[..consumed].to_string())
}

/// Take a leading `[A-Z][a-z]+` word; return (word, remainder).
fn take_capitalized_word(s: &str) -> Option<(&str, &str)> {
    let bytes = s.as_bytes();
    if bytes.is_empty() || !bytes[0].is_ascii_uppercase() {
        return None;
    }
    let mut i = 1;
    while i < bytes.len() && bytes[i].is_ascii_lowercase() {
        i += 1;
    }
    if i < 2 {
        return None;
    }
    Some((&s[..i], &s[i..]))
}

/// Take between `min` and `max` leading ASCII digits; return (digits, remainder).
fn take_digits(s: &str, min: usize, max: usize) -> Option<(&str, &str)> {
    let bytes = s.as_bytes();
    let mut i = 0;
    while i < bytes.len() && i < max && bytes[i].is_ascii_digit() {
        i += 1;
    }
    if i < min {
        return None;
    }
    Some((&s[..i], &s[i..]))
}

// ── Top-level parser ──

/// Parse the served billing HTML into a structured shape. Returns Err only when
/// the page looks logged-out / the session is invalid. Missing individual fields
/// degrade to None / empty rather than failing the whole parse.
fn parse_billing(html: &str) -> Result<ParsedBilling> {
    if looks_logged_out(html) {
        anyhow::bail!("NEEDS_AUTH");
    }

    let price = find_monthly_price(html);
    let monthly_price = price.map(|(p, _)| p);
    let price_idx = price.map(|(_, i)| i);
    let plan = find_plan(html, price_idx);
    let next_renewal = find_next_renewal(html);
    let windows = parse_windows(html);

    // Defense in depth: the real billing console ALWAYS renders quota windows.
    // A parse that recovered no windows is not a usable billing page — even when
    // a price/plan was scraped. An expired-cookie/error shell can legitimately
    // carry `Billing` + a spaced price like `$20 / mo` (so `find_monthly_price`
    // succeeds and `looks_logged_out` is satisfied) while containing zero quota
    // windows. Accepting that would emit a Sakana result with a plan and zero
    // metrics instead of asking the user to refresh the cookie. Require actual
    // quota-window data before trusting the page.
    if windows.is_empty() {
        anyhow::bail!("NEEDS_AUTH");
    }

    Ok(ParsedBilling {
        plan,
        monthly_price,
        next_renewal,
        windows,
    })
}

// ── Output assembly ──

fn build_output(parsed: ParsedBilling) -> UsageOutput {
    let metrics = parsed
        .windows
        .into_iter()
        .map(|w| UsageMetric {
            label: w.label,
            used_percent: w.used_percent,
            remaining_percent: 100.0 - w.used_percent,
            remaining_label: None,
            resets_at: w.resets_at,
        })
        .collect();

    // Surface monthly price + next renewal as plan metadata (the struct has no
    // dedicated billing fields, and these are flat-fee subscription details, not
    // quota windows).
    let plan = match (parsed.plan, parsed.monthly_price, parsed.next_renewal) {
        (Some(tier), Some(price), Some(renew)) => {
            Some(format!("{tier} (${price}/mo, renews {renew})"))
        }
        (Some(tier), Some(price), None) => Some(format!("{tier} (${price}/mo)")),
        (Some(tier), None, Some(renew)) => Some(format!("{tier} (renews {renew})")),
        (Some(tier), None, None) => Some(tier),
        (None, Some(price), _) => Some(format!("${price}/mo")),
        (None, None, _) => None,
    };

    UsageOutput {
        provider: "Sakana".into(),
        account: None,
        plan,
        email: None,
        metrics,
        reset_credits: None,
        credit_status: None,
        spend_control: None,
    }
}

async fn fetch_billing_html(client: &reqwest::Client, cookie: &str) -> Result<String> {
    let resp = client
        .get(BILLING_URL)
        .header("Cookie", cookie)
        .header("User-Agent", USER_AGENT)
        .header("Accept", "text/html")
        .send()
        .await?;
    let status = resp.status();
    if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
        anyhow::bail!("NEEDS_AUTH");
    }
    if !status.is_success() {
        anyhow::bail!("Sakana billing request failed (HTTP {status})");
    }
    Ok(resp.text().await?)
}

pub fn fetch() -> Result<UsageOutput> {
    let cookie = session_cookie().ok_or_else(|| {
        anyhow::anyhow!(
            "No Sakana session cookie. Set SAKANA_SESSION_COOKIE or write a \
             `sakana-session` file in the tokmesh config dir."
        )
    })?;

    let rt = tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()?;
    rt.block_on(async {
        let client = reqwest::Client::builder()
            .timeout(std::time::Duration::from_secs(30))
            .redirect(reqwest::redirect::Policy::limited(10))
            .build()?;

        let html = fetch_billing_html(&client, &cookie).await.map_err(|e| {
            if e.to_string().contains("NEEDS_AUTH") {
                anyhow::anyhow!(
                    "Sakana session expired or invalid. Refresh SAKANA_SESSION_COOKIE \
                     (re-copy the __Secure-authjs.session-token cookie from \
                     console.sakana.ai)."
                )
            } else {
                e
            }
        })?;

        let parsed = parse_billing(&html).map_err(|e| {
            if e.to_string().contains("NEEDS_AUTH") {
                anyhow::anyhow!(
                    "Sakana session expired or invalid (login page returned). Refresh \
                     SAKANA_SESSION_COOKIE from console.sakana.ai."
                )
            } else {
                e
            }
        })?;

        Ok(build_output(parsed))
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    // A representative valid billing fixture using the exact validated token
    // formats. Synthetic — contains NO real cookies/tokens.
    const VALID_STANDARD: &str = r#"
<html><body>
  <nav>Billing</nav>
  <div class="plan-card">
    <span>Standard</span>
    <span>$20 / mo</span>
    <button>Upgrade to Pro</button>
    <button>Upgrade to Max</button>
  </div>
  <div class="window">
    <span>5-hour</span>
    <span>55% used</span>
    <span>Resets on June 22, 2026 at 9:58 AM</span>
  </div>
  <div class="window">
    <span>Weekly</span>
    <span>19% used</span>
    <span>Resets on June 28, 2026 at 12:00 PM</span>
  </div>
  <div>Next renewal: July 22, 2026</div>
</body></html>
"#;

    #[test]
    fn parses_valid_standard_billing() {
        let parsed = parse_billing(VALID_STANDARD).expect("should parse");
        assert_eq!(parsed.plan.as_deref(), Some("Standard"));
        assert_eq!(parsed.monthly_price, Some(20));
        assert_eq!(parsed.next_renewal.as_deref(), Some("July 22, 2026"));
        assert_eq!(parsed.windows.len(), 2);

        assert_eq!(parsed.windows[0].label, "5-hour");
        assert_eq!(parsed.windows[0].used_percent, 55.0);
        assert_eq!(
            parsed.windows[0].resets_at.as_deref(),
            Some("June 22, 2026 at 9:58 AM")
        );

        assert_eq!(parsed.windows[1].label, "Weekly");
        assert_eq!(parsed.windows[1].used_percent, 19.0);
        assert_eq!(
            parsed.windows[1].resets_at.as_deref(),
            Some("June 28, 2026 at 12:00 PM")
        );
    }

    #[test]
    fn build_output_shapes_metrics_and_plan() {
        let parsed = parse_billing(VALID_STANDARD).unwrap();
        let out = build_output(parsed);
        assert_eq!(out.provider, "Sakana");
        assert_eq!(
            out.plan.as_deref(),
            Some("Standard ($20/mo, renews July 22, 2026)")
        );
        assert_eq!(out.metrics.len(), 2);
        assert_eq!(out.metrics[0].label, "5-hour");
        assert_eq!(out.metrics[0].used_percent, 55.0);
        assert_eq!(out.metrics[0].remaining_percent, 45.0);
        assert_eq!(out.metrics[1].label, "Weekly");
        assert_eq!(out.metrics[1].used_percent, 19.0);
    }

    // Pro tier, where the "Upgrade to Max" button also contains a tier word.
    // The active tier ("Pro") is the one rendered nearest-preceding the price.
    const VALID_PRO: &str = r#"
<html><body>
  <nav>Billing</nav>
  <div class="plan-card">
    <span>Pro</span>
    <span>$100 / mo</span>
    <button>Upgrade to Max</button>
  </div>
  <div class="window">
    <span>5-hour</span>
    <span>8% used</span>
    <span>Resets on June 22, 2026 at 3:15 PM</span>
  </div>
  <div class="window">
    <span>Weekly</span>
    <span>72% used</span>
    <span>Resets on June 29, 2026 at 1:00 AM</span>
  </div>
  <div>Next renewal: July 22, 2026</div>
</body></html>
"#;

    #[test]
    fn disambiguates_active_pro_tier_from_upgrade_button() {
        let parsed = parse_billing(VALID_PRO).expect("should parse");
        // "Max" appears in the upgrade button AFTER the price; active tier is
        // "Pro", which is nearest-preceding the $100/mo price.
        assert_eq!(parsed.plan.as_deref(), Some("Pro"));
        assert_eq!(parsed.monthly_price, Some(100));
        assert_eq!(parsed.windows.len(), 2);
        assert_eq!(parsed.windows[0].used_percent, 8.0);
        assert_eq!(parsed.windows[1].used_percent, 72.0);
    }

    // Logged-out / login page: billing markers absent, sign-in present.
    const LOGIN_PAGE: &str = r#"
<html><body>
  <h1>Sign in to Sakana</h1>
  <a href="/login">Continue with Google</a>
</body></html>
"#;

    #[test]
    fn login_page_returns_needs_auth_error() {
        let err = parse_billing(LOGIN_PAGE).expect_err("login page must error");
        assert!(
            err.to_string().contains("NEEDS_AUTH"),
            "expected NEEDS_AUTH, got: {err}"
        );
    }

    // Missing renewal + reset; percentages must still parse.
    const MISSING_META: &str = r#"
<html><body>
  <nav>Billing</nav>
  <div class="plan-card">
    <span>Standard</span>
    <span>$20 / mo</span>
  </div>
  <div class="window">
    <span>5-hour</span>
    <span>33% used</span>
  </div>
  <div class="window">
    <span>Weekly</span>
    <span>5% used</span>
  </div>
</body></html>
"#;

    #[test]
    fn graceful_degradation_when_meta_missing() {
        let parsed = parse_billing(MISSING_META).expect("should still parse percentages");
        assert_eq!(parsed.plan.as_deref(), Some("Standard"));
        assert_eq!(parsed.monthly_price, Some(20));
        assert_eq!(parsed.next_renewal, None);
        assert_eq!(parsed.windows.len(), 2);
        assert_eq!(parsed.windows[0].label, "5-hour");
        assert_eq!(parsed.windows[0].used_percent, 33.0);
        assert_eq!(parsed.windows[0].resets_at, None);
        assert_eq!(parsed.windows[1].label, "Weekly");
        assert_eq!(parsed.windows[1].used_percent, 5.0);
        assert_eq!(parsed.windows[1].resets_at, None);
    }

    // Mirrors the REAL served HTML: the usage values are ALSO embedded in
    // serialized RSC data (extra "% used" tokens, some preceding the rendered
    // cards, with NO window label). The parser must anchor on the labels and
    // emit exactly 2 correctly-paired windows — not invent "Window 3/4" or
    // mis-pair the weekly value. Regression test for the bug caught by a live run.
    const DUPLICATED_RSC: &str = r#"
<html><body>
  <script>self.__next_f.push([1,"...stuff 19% used more 55% used trailing..."])</script>
  <nav>Billing</nav>
  <div class="plan-card"><span>Standard</span><span>$20 / mo</span></div>
  <div class="window"><span>5-hour</span><span>55% used</span><span>Resets on June 22, 2026 at 9:58 AM</span></div>
  <div class="window"><span>Weekly</span><span>19% used</span><span>Resets on June 29, 2026 at 12:00 AM</span></div>
  <script>self.__next_f.push([1,"...echo 55% used and 19% used again..."])</script>
</body></html>
"#;

    #[test]
    fn ignores_duplicated_rsc_percentages() {
        let parsed = parse_billing(DUPLICATED_RSC).expect("should parse");
        assert_eq!(
            parsed.windows.len(),
            2,
            "must bind to the 2 labels, not collect every % used"
        );
        assert_eq!(parsed.windows[0].label, "5-hour");
        assert_eq!(parsed.windows[0].used_percent, 55.0);
        assert_eq!(
            parsed.windows[0].resets_at.as_deref(),
            Some("June 22, 2026 at 9:58 AM")
        );
        assert_eq!(parsed.windows[1].label, "Weekly");
        assert_eq!(parsed.windows[1].used_percent, 19.0);
        assert_eq!(
            parsed.windows[1].resets_at.as_deref(),
            Some("June 29, 2026 at 12:00 AM")
        );
    }

    #[test]
    fn percents_parse_in_document_order() {
        let pcts = find_used_percents("a 55% used b 19% used c 100% used");
        assert_eq!(pcts, vec![55.0, 19.0, 100.0]);
    }

    #[test]
    fn decimal_percents_parse_in_full() {
        // Regression: the old digit-walk captured at most 3 trailing digits and
        // dropped the decimal point, so "7.5%" reported 5.0. Full f64 parse now.
        assert_eq!(find_used_percents("7.5% used"), vec![7.5]);
        // Integer case still works.
        assert_eq!(find_used_percents("42% used"), vec![42.0]);
        // Mixed decimal + integer, document order, including a >3-char number.
        assert_eq!(
            find_used_percents("a 7.5% used b 100% used c 12.25% used"),
            vec![7.5, 100.0, 12.25]
        );
        // Clamp out-of-range values to a sane percentage.
        assert_eq!(find_used_percents("250.5% used"), vec![100.0]);
    }

    #[test]
    fn decimal_percent_flows_through_parse() {
        let html = r#"
<html><body>
  <nav>Billing</nav>
  <div class="plan-card"><span>Standard</span><span>$20 / mo</span></div>
  <div class="window"><span>5-hour</span><span>7.5% used</span></div>
  <div class="window"><span>Weekly</span><span>19% used</span></div>
</body></html>
"#;
        let parsed = parse_billing(html).expect("should parse");
        assert_eq!(parsed.windows[0].used_percent, 7.5);
        assert_eq!(parsed.windows[1].used_percent, 19.0);
    }

    // An error / shell page that happens to carry the weak substrings
    // ("Billing", "/mo", "% used") inside script noise but has NO real window
    // label and NO concrete $NN/mo price. This previously yielded an empty,
    // all-zero card; it must now be treated as needs-auth.
    const WEAK_MARKERS_ERROR_PAGE: &str = r#"
<html><body>
  <h1>Something went wrong</h1>
  <script>self.__next_f.push([1,"...Billing strings like /mo and 0% used in RSC noise..."])</script>
</body></html>
"#;

    #[test]
    fn weak_marker_error_page_returns_needs_auth() {
        let err = parse_billing(WEAK_MARKERS_ERROR_PAGE).expect_err("must error, not empty card");
        assert!(
            err.to_string().contains("NEEDS_AUTH"),
            "expected NEEDS_AUTH, got: {err}"
        );
    }

    // An expired-cookie / error shell that still carries `Billing` and a spaced
    // plan price (`$20 / mo`) — so `find_monthly_price` succeeds and
    // `looks_logged_out` is satisfied — but contains NO quota windows. This must
    // be treated as needs-auth: a price-only page is NOT a usable billing page,
    // and we must ask the user to refresh the cookie rather than emit a Sakana
    // result with a plan and zero metrics. Regression for review feedback.
    const PRICE_ONLY_NO_WINDOWS: &str = r#"
<html><body>
  <h1>Session expired</h1>
  <nav>Billing</nav>
  <div class="plan-card"><span>Standard</span><span>$20 / mo</span></div>
</body></html>
"#;

    #[test]
    fn price_only_no_windows_returns_needs_auth() {
        // Sanity: the price marker really does satisfy `looks_logged_out`, so the
        // downstream guard is the thing under test.
        assert!(
            !looks_logged_out(PRICE_ONLY_NO_WINDOWS),
            "price marker should pass the cheap logged-out heuristic"
        );
        let err =
            parse_billing(PRICE_ONLY_NO_WINDOWS).expect_err("price-only shell must be needs-auth");
        assert!(
            err.to_string().contains("NEEDS_AUTH"),
            "expected NEEDS_AUTH, got: {err}"
        );
    }

    // Verifies fix (2): the cookie file is resolved via the canonical config
    // dir, which honors `TOKMESH_CONFIG_DIR`, instead of hardcoding
    // `~/.config/tokmesh`. Serial because it mutates process-global env.
    #[test]
    #[serial_test::serial]
    fn session_cookie_reads_from_overridden_config_dir() {
        use std::env;

        let prev_dir = env::var_os("TOKMESH_CONFIG_DIR");
        let prev_cookie = env::var_os("SAKANA_SESSION_COOKIE");

        let tmp = env::temp_dir().join(format!("tokmesh-sakana-test-{}", std::process::id()));
        std::fs::create_dir_all(&tmp).unwrap();
        std::fs::write(tmp.join("sakana-session"), "  cookie-from-file  \n").unwrap();

        unsafe {
            env::set_var("TOKMESH_CONFIG_DIR", &tmp);
            // Ensure the env-var source does not short-circuit the file read.
            env::remove_var("SAKANA_SESSION_COOKIE");
        }

        let got = session_cookie();

        unsafe {
            match prev_dir {
                Some(v) => env::set_var("TOKMESH_CONFIG_DIR", v),
                None => env::remove_var("TOKMESH_CONFIG_DIR"),
            }
            match prev_cookie {
                Some(v) => env::set_var("SAKANA_SESSION_COOKIE", v),
                None => env::remove_var("SAKANA_SESSION_COOKIE"),
            }
        }
        let _ = std::fs::remove_dir_all(&tmp);

        assert_eq!(got.as_deref(), Some("cookie-from-file"));
    }

    #[test]
    fn monthly_price_tolerates_spacing_variants() {
        assert_eq!(find_monthly_price("$20/mo").map(|(p, _)| p), Some(20));
        assert_eq!(find_monthly_price("$20 / mo").map(|(p, _)| p), Some(20));
        assert_eq!(find_monthly_price("$200  /  mo").map(|(p, _)| p), Some(200));
        assert_eq!(find_monthly_price("no price here"), None);
    }
}