claude_profile 1.2.0

Claude Code account credential management and token status
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
//! Display formatting helpers for the quota table.
//!
//! All functions here are pure data-to-string converters: no I/O, no side effects.
//! They are called by `render.rs`, `sort.rs`, `touch.rs`, and `fetch.rs`.

use crate::output::format_duration_secs;
use super::types::{ AccountQuota, PreferStrategy };

// ── Token expiry label ────────────────────────────────────────────────────────

/// Format token expiry as a human-readable label for trace output.
///
/// Returns `"expired(Xd Yh ago)"` or `"valid(Xd Yh left)"` using the same
/// duration format as `format_duration_secs`.
pub( crate ) fn token_exp_label( expires_at_ms : u64 ) -> String
{
  let now_ms = u64::try_from(
    std::time::SystemTime::now()
      .duration_since( std::time::UNIX_EPOCH )
      .unwrap_or_default()
      .as_millis()
  ).unwrap_or( u64::MAX );
  if now_ms >= expires_at_ms
  {
    format!( "expired({} ago)", format_duration_secs( ( now_ms - expires_at_ms ) / 1000 ) )
  }
  else
  {
    format!( "valid({} left)", format_duration_secs( ( expires_at_ms - now_ms ) / 1000 ) )
  }
}

// ── Token expiry cell ─────────────────────────────────────────────────────────

/// Compute the `Expires` cell value for a given token expiry and current time.
///
/// Returns `"EXPIRED"` when `expires_at_ms / 1000 ≤ now_secs` (saturating), or
/// `"in Xh Ym"` when the token is still valid.
pub( crate ) fn compute_expires_cell( expires_at_ms : u64, now_secs : u64 ) -> String
{
  let remaining = ( expires_at_ms / 1000 ).saturating_sub( now_secs );
  if remaining == 0
  {
    "EXPIRED".to_string()
  }
  else
  {
    format!( "in {}", format_duration_secs( remaining ) )
  }
}

// ── Date helpers ──────────────────────────────────────────────────────────────

/// Convert a Unix timestamp (seconds) to a Gregorian `(year, month, day)` tuple.
///
/// Month is 1-based (1 = January). Day is 1-based (1 = first of month).
/// No external dependencies — hand-rolled Gregorian arithmetic.
pub( crate ) fn unix_to_date( unix_secs : u64 ) -> ( u64, u64, u64 )
{
  let is_leap     = |y : u64| ( y % 4 == 0 && y % 100 != 0 ) || y % 400 == 0;
  let mut days    = unix_secs / 86_400;
  let mut year    = 1970_u64;
  loop
  {
    let in_year = if is_leap( year ) { 366 } else { 365 };
    if days < in_year { break; }
    days -= in_year;
    year += 1;
  }
  let feb = if is_leap( year ) { 29 } else { 28 };
  let month_days : [ u64; 12 ] = [ 31, feb, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 ];
  let mut month = 0_u64;
  for d in &month_days
  {
    if days < *d { break; }
    days -= d;
    month += 1;
  }
  ( year, month + 1, days + 1 )
}

// ── ISO-8601 parsing helpers ──────────────────────────────────────────────────

/// Convert a `(year, month, day)` tuple to Unix seconds at midnight UTC.
///
/// Month is 1-based (1 = January). Day is 1-based. Assumes year ≥ 1970.
fn date_to_unix( year : u64, month : u64, day : u64 ) -> u64
{
  let is_leap  = |y : u64| ( y % 4 == 0 && y % 100 != 0 ) || y % 400 == 0;
  let mut days = 0_u64;
  for y in 1970..year { days += if is_leap( y ) { 366 } else { 365 }; }
  let feb        = if is_leap( year ) { 29 } else { 28 };
  let month_days : [ u64; 12 ] = [ 31, feb, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 ];
  for &month_day in month_days.iter().take( usize::try_from( month - 1 ).unwrap_or( 0 ) ) { days += month_day; }
  days += day - 1;
  days * 86_400
}

/// Parse an ISO-8601 UTC timestamp (`"YYYY-MM-DDTHH:MM:SSZ"`) to Unix seconds.
///
/// Returns `None` on parse failure or year before 1970.
fn parse_iso_secs( s : &str ) -> Option< u64 >
{
  if s.len() < 19 { return None; }
  let year  : u64 = s[ 0..4   ].parse().ok()?;
  let month : u64 = s[ 5..7   ].parse().ok()?;
  let day   : u64 = s[ 8..10  ].parse().ok()?;
  let hour  : u64 = s[ 11..13 ].parse().ok()?;
  let min   : u64 = s[ 14..16 ].parse().ok()?;
  let sec   : u64 = s[ 17..19 ].parse().ok()?;
  if year < 1970 || month == 0 || month > 12 || day == 0 || day > 31 { return None; }
  Some( date_to_unix( year, month, day ) + hour * 3_600 + min * 60 + sec )
}

// ── Renewal timing ─────────────────────────────────────────────────────────────

/// Compute seconds until the next billing renewal and whether the value is an estimate.
///
/// Priority:
/// 1. **Exact** (`renewal_at_opt` set): parse the ISO-8601 string; auto-advance monthly
///    (+ 2 592 000 s per step) until the timestamp is in the future; return `(secs, false)`.
/// 2. **Estimate** (`org_created_at_opt` set): derive the billing day-of-month from the
///    `org_created_at` string and find the next occurrence; return `(secs, true)`.
/// 3. **Absent** (both `None`) or parse failure: return `None`.
pub( crate ) fn renewal_secs(
  renewal_at_opt     : Option< &str >,
  org_created_at_opt : Option< &str >,
  now_secs           : u64,
) -> Option< ( u64, bool ) >
{
  if let Some( renewal_at ) = renewal_at_opt
  {
    let mut ts = parse_iso_secs( renewal_at )?;
    while ts < now_secs { ts = ts.saturating_add( 2_592_000 ); }
    return Some( ( ts.saturating_sub( now_secs ), false ) );
  }
  if let Some( org_created_at ) = org_created_at_opt
  {
    if org_created_at.len() < 10 { return None; }
    let billing_day : u64 = org_created_at[ 8..10 ].parse().ok()?;
    if billing_day == 0 || billing_day > 31 { return None; }
    let ( year, month, day ) = unix_to_date( now_secs );
    let ( renewal_year, renewal_month ) = if billing_day > day
    {
      ( year, month )
    }
    else if month == 12
    {
      ( year + 1, 1 )
    }
    else
    {
      ( year, month + 1 )
    };
    let renewal_ts = date_to_unix( renewal_year, renewal_month, billing_day );
    return Some( ( renewal_ts.saturating_sub( now_secs ), true ) );
  }
  None
}

/// Format the next billing renewal as a duration string.
///
/// - Both absent → `"?"`.
/// - Parse failure → `"—"` (em-dash).
/// - Exact (`_renewal_at` set, auto-advanced) → `"in Xh Ym"` (no `~`).
/// - Estimate (only `org_created_at`) → `"~in Xd"`.
pub( crate ) fn renews_label(
  renewal_at_opt     : Option< &str >,
  org_created_at_opt : Option< &str >,
  now_secs           : u64,
) -> String
{
  if renewal_at_opt.is_none() && org_created_at_opt.is_none()
  {
    return "?".to_string();
  }
  match renewal_secs( renewal_at_opt, org_created_at_opt, now_secs )
  {
    None                    => "\u{2014}".to_string(),
    Some( ( secs, false ) ) => format!( "in {}",  format_duration_secs( secs ) ),
    Some( ( secs, true  ) ) => format!( "~in {}", format_duration_secs( secs ) ),
  }
}

// ── Next event label ─────────────────────────────────────────────────────────

/// Return the winning next-event candidate as `(secs, prefix, is_estimate)`.
///
/// Candidates with `secs == 0` are excluded. Minimum-secs wins; ties by iteration order.
/// Prefixes: `"+7d"` (7d reset), `"$ren"` (renewal). Token expiry (`!tok`) is not a candidate —
/// it is already surfaced in the `Expires` column. 5h resets are not candidates either.
pub( crate ) fn next_event_raw(
  seven_day_resets_secs : Option< u64 >,
  renewal_secs_opt      : Option< u64 >,
  renewal_is_estimate   : bool,
) -> Option< ( u64, &'static str, bool ) >
{
  let consider = |current : Option< ( u64, &'static str, bool ) >,
                  secs    : u64,
                  prefix  : &'static str,
                  est     : bool|
    -> Option< ( u64, &'static str, bool ) >
  {
    if secs == 0 { return current; }
    match current
    {
      None                                   => Some( ( secs, prefix, est ) ),
      Some( ( best, _, _ ) ) if secs < best => Some( ( secs, prefix, est ) ),
      other                                  => other,
    }
  };
  let mut best = None;
  if let Some( s ) = seven_day_resets_secs  { best = consider( best, s, "+7d",  false               ); }
  if let Some( s ) = renewal_secs_opt       { best = consider( best, s, "$ren", renewal_is_estimate ); }
  best
}

/// Format the soonest upcoming strategic event as a compact label for the `→ Next` column.
///
/// Candidates: `+7d` (7-day reset), `$ren` (renewal). All absent / zero → `"—"`.
pub( crate ) fn next_event_label(
  seven_day_resets_secs : Option< u64 >,
  renewal_secs_opt      : Option< u64 >,
  renewal_is_estimate   : bool,
) -> String
{
  match next_event_raw( seven_day_resets_secs, renewal_secs_opt, renewal_is_estimate )
  {
    None                             => "\u{2014}".to_string(),
    Some( ( secs, prefix, true  ) ) => format!( "~in {} {prefix}", format_duration_secs( secs ) ),
    Some( ( secs, prefix, false ) ) => format!( "in {} {prefix}",  format_duration_secs( secs ) ),
  }
}

// ── Subscription label ────────────────────────────────────────────────────────

/// Map account billing state to a short subscription label for the `Sub` column.
///
/// - `None`                      → `"?"` (fetch failed — state unknown)
/// - `billing_type == "none"`    → `"—"` (no active subscription)
/// - `has_max`                   → `"max"` (Claude Max plan)
/// - `"stripe_subscription"` + `!has_max` → `"pro"` (paid but not Max)
/// - anything else               → `"?"`
pub( crate ) fn sub_label( account : Option< &claude_quota::OauthAccountData > ) -> &'static str
{
  let Some( a ) = account else { return "?"; };
  if a.billing_type == "none"                { return "\u{2014}"; }
  if a.has_max                               { return "max"; }
  if a.billing_type == "stripe_subscription" { return "pro"; }
  "?"
}

// ── Error shortener ───────────────────────────────────────────────────────────

// Fix(BUG-152)
// Root cause: shorten_error had no HTTP 401 branch; the else { reason } arm returned the
//   verbose "HTTP transport error: HTTP 401" string verbatim into the 7d Reset column,
//   violating AC-03 ("shortened error reason"). HTTP 401 was added to T05 as a
//   pass-through regression guard in task 150, inadvertently documenting the wrong behaviour.
//   task/claude_profile/bug/152_shorten_error_omits_401.md
// Pitfall: shorten_error is a manual allowlist — each new HTTP error code from
//   QuotaError::HttpTransport needs an explicit branch. The else arm is NOT a shortener;
//   it is a verbatim passthrough. test_shorten_error_no_raw_http_transport_passthrough
//   enforces this invariant for known codes (401, 403, 429).
/// Shorten verbose quota error strings for display in the final table column.
///
/// `QuotaError::HttpTransport` formats errors as `"HTTP transport error: HTTP NNN"`.
/// Handled codes: `429` → `"rate limited (429)"`; `401` → `"auth expired (401)"`;
/// `403` → `"auth forbidden (403)"` (permission error returned by the usage API).
/// `QuotaError::MissingHeader` (displays as `"rate-limit header missing: …"`) is
/// shortened to `"no header"`. All other strings pass through unchanged.
/// The caller is responsible for wrapping the result in parentheses.
pub( crate ) fn shorten_error( reason : &str ) -> &str
{
  if reason.starts_with( "HTTP transport error: HTTP 429" )
  {
    "rate limited (429)"
  }
  else if reason.starts_with( "HTTP transport error: HTTP 401" )
  {
    "auth expired (401)"
  }
  else if reason.starts_with( "HTTP transport error: HTTP 403" )
  {
    "auth forbidden (403)"
  }
  else if reason.starts_with( "rate-limit header missing:" )
  {
    "no header"
  }
  else
  {
    reason
  }
}

// ── Quota left helpers ────────────────────────────────────────────────────────

/// Return `5h Left` as a percentage for sorting purposes.
///
/// Returns `100.0 - five_hour.utilization` for `Ok` accounts, or `-1.0` for `Err`
/// accounts (treated as below-exhausted for drain/reset floor sinking).
pub( crate ) fn five_hour_left( aq : &AccountQuota ) -> f64
{
  if let Ok( data ) = &aq.result
  {
    100.0 - data.five_hour.as_ref().map_or( 0.0, |p| p.utilization )
  }
  else
  {
    -1.0
  }
}

/// Return `7d Left` as a percentage for the `apply_touch` skip guard.
///
/// Returns `100.0 - seven_day.utilization` for `Ok` accounts with `seven_day` data,
/// `100.0` for `Ok` accounts where `seven_day` is absent (absent data ≠ exhausted),
/// or `0.0` for `Err` accounts (treated as fully exhausted — no touch beneficial).
pub( crate ) fn seven_day_left( aq : &AccountQuota ) -> f64
{
  let Ok( ref data ) = aq.result else { return 0.0; };
  100.0 - data.seven_day.as_ref().map_or( 0.0, |p| p.utilization )
}

/// Return `(five_hour_left, relevant_7d_left)` for a given `prefer` strategy.
///
/// `five_hour_left` = `100.0 - five_hour.utilization` for `Ok` accounts; `-1.0` for `Err`.
///
/// `relevant_7d_left` is model-aware:
/// - `Opus`   → raw `seven_day_left` (Sonnet cap irrelevant for Opus intent).
/// - `Sonnet` → `100.0 - sonnet.utilization` when `Some`; **`0.0`** when `None` (absent = unknown).
/// - `Any`    → `min(seven_day_left, 100.0 - sonnet.utilization)` when `Some`; else `seven_day_left`.
/// - `Err(_)` result → `(-1.0, 0.0)`.
///
/// Fix(BUG Phase-2): old `prefer_weekly` used `map_or(0.0, ...)` for Sonnet utilization —
///   when `seven_day_sonnet = None`, `100.0 - 0.0 = 100.0`, silently inflating the quota
///   and making accounts with absent Sonnet tiers appear fully eligible under `prefer::son`.
/// Root cause: `map_or(0.0, ...)` is correct for DISPLAY (absent = show nothing / 0% label)
///   but wrong for eligibility gates — absent ≠ exhausted ≠ available.
/// Pitfall: always use `if let Some(ref son)` for quota-gate logic. `map_or` folds None into
///   a numeric sentinel that is indistinguishable from an actual measured value.
pub( super ) fn relevant_quotas( aq : &AccountQuota, prefer : PreferStrategy ) -> ( f64, f64 )
{
  let Ok( data ) = &aq.result else { return ( -1.0, 0.0 ); };
  let five_h_left = 100.0 - data.five_hour.as_ref().map_or( 0.0, |p| p.utilization );
  let left_7d     = 100.0 - data.seven_day.as_ref().map_or( 0.0, |p| p.utilization );
  let relevant_7d = match prefer
  {
    PreferStrategy::Opus   => left_7d,
    PreferStrategy::Sonnet =>
    {
      if let Some( ref son ) = data.seven_day_sonnet { 100.0 - son.utilization }
      else { 0.0 }
    }
    PreferStrategy::Any =>
    {
      if let Some( ref son ) = data.seven_day_sonnet { left_7d.min( 100.0 - son.utilization ) }
      else { left_7d }
    }
  };
  ( five_h_left, relevant_7d )
}

/// Return the weekly quota left (%) for a given `prefer` strategy.
///
/// - `Opus`   → `7d Left` only.
/// - `Sonnet` → `7d(Son)` only; **`0.0`** when `seven_day_sonnet` is absent (unknown ≠ 100%).
/// - `Any`    → `min(7d Left, 7d(Son))` when Sonnet present; `7d Left` when absent.
///
/// Absent period data is treated as `0.0` left. `Err` accounts return `0.0`.
/// Delegates to `relevant_quotas()` for the model-aware computation.
pub( crate ) fn prefer_weekly( aq : &AccountQuota, prefer : PreferStrategy ) -> f64
{
  relevant_quotas( aq, prefer ).1
}

// ── Model recommendation ──────────────────────────────────────────────────────

/// Sonnet utilization threshold above which Opus is recommended.
///
/// Accounts where `100.0 - seven_day_sonnet.utilization < OPUS_OVERRIDE_THRESHOLD`
/// are recommended `"opus"` by `recommended_model()` and overridden by `apply_model_override()`.
/// The literal `15.0` must not be duplicated — always reference this constant.
pub( super ) const OPUS_OVERRIDE_THRESHOLD : f64 = 15.0;

/// Return the recommended session model for the next rotation candidate.
///
/// - `Ok(data)` with `seven_day_sonnet` present and `< OPUS_OVERRIDE_THRESHOLD` left → `"opus"`.
/// - `Ok(data)` with `seven_day_sonnet` absent (tier unknown) → `"sonnet"` (conservative).
/// - `Err(_)` → `"sonnet"` (quota unknown → conservative).
///
/// Mirrors the guard in `apply_model_override()`. Both reference `OPUS_OVERRIDE_THRESHOLD`
/// — the literal must not be duplicated.
pub( crate ) fn recommended_model( aq : &AccountQuota ) -> &'static str
{
  match &aq.result
  {
    Ok( data ) => match &data.seven_day_sonnet
    {
      Some( s ) if 100.0 - s.utilization < OPUS_OVERRIDE_THRESHOLD => "opus",
      _ => "sonnet",
    },
    Err( _ ) => "sonnet",
  }
}

// ── Cell renderers ────────────────────────────────────────────────────────────

/// Compute the 5 quota display cells for a successful OAuth usage fetch.
///
/// Returns `[5h_left, 5h_reset, 7d_left, 7d_son, 7d_reset]` as display strings.
/// `5h Left` and `7d Left` cells carry a `🟢`/`🟡` prefix (same threshold as `status_emoji`).
/// Absent periods render as em-dash; absent reset timestamps render as em-dash.
pub( crate ) fn quota_text_cells( data : &claude_quota::OauthUsageData, now_secs : u64 ) -> [ String; 5 ]
{
  let dash      = "\u{2014}".to_string();
  let pct_cell  = |util : Option< f64 >| -> String
  {
    util.map_or_else( || dash.clone(), |u| format!( "{:.0}%", 100.0 - u ) )
  };
  let pct_emoji = |util : Option< f64 >, threshold : f64| -> String
  {
    util.map_or_else( || dash.clone(), |u|
    {
      let left  = 100.0 - u;
      let emoji = if left > threshold { "🟢" } else { "🟡" };
      format!( "{emoji} {left:.0}%" )
    } )
  };
  let reset_cell = |iso : Option< &str >| -> String
  {
    iso.and_then( claude_quota::iso_to_unix_secs )
      .map_or_else( || dash.clone(), |t|
        format!( "in {}", format_duration_secs( t.saturating_sub( now_secs ) ) )
      )
  };
  [
    pct_emoji( data.five_hour.as_ref().map( |p| p.utilization ), 15.0 ),
    reset_cell( data.five_hour.as_ref().and_then( |p| p.resets_at.as_deref() ) ),
    pct_emoji( data.seven_day.as_ref().map( |p| p.utilization ), 5.0 ),
    pct_cell(  data.seven_day_sonnet.as_ref().map( |p| p.utilization ) ),
    reset_cell( data.seven_day.as_ref().and_then( |p| p.resets_at.as_deref() ) ),
  ]
}

/// Return the single-glyph quota status emoji for an account row.
///
/// - `"🔴"` — token is invalid or missing (`result` is `Err`).
/// - `"🟡"` — token valid, but `5h Left ≤ 15%` or `7d Left ≤ 5%`.
/// - `"🟢"` — token valid, `5h Left > 15%` AND `7d Left > 5%`.
///
/// Absent period data is treated as fully available (conservative, 0% utilised).
pub( crate ) fn status_emoji( result : &Result< claude_quota::OauthUsageData, String > ) -> &'static str
{
  match result
  {
    Err( _ ) => "🔴",
    Ok( data ) =>
    {
      let h5_left = 100.0 - data.five_hour.as_ref().map_or( 0.0, |p| p.utilization );
      let d7_left = 100.0 - data.seven_day.as_ref().map_or( 0.0, |p| p.utilization );
      if h5_left > 15.0 && d7_left > 5.0 { "🟢" } else { "🟡" }
    }
  }
}

// ── Tests ─────────────────────────────────────────────────────────────────────

#[ cfg( test ) ]
// Path-referenced: pub(crate) fns (shorten_error, compute_expires_cell, token_exp_label,
// status_emoji, quota_text_cells, renews_label, next_event_label) are not accessible
// from the external tests/ directory.
#[ path = "format_tests.rs" ]
mod tests;