use serde::Serialize;
use crate::account::profiles::ProfileMap;
use crate::account::scoring::SATURATION_PCT;
use crate::usage::model::UsageData;
pub const WARN_PCT: i64 = SATURATION_PCT;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Status {
Ok,
NearLimit,
Errored,
NoData,
}
impl Status {
pub fn label(self) -> &'static str {
match self {
Status::Ok => "ok",
Status::NearLimit => "\u{26a0} near-limit",
Status::Errored => "\u{2716} errored",
Status::NoData => "\u{00b7} no data",
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct Row {
pub name: String,
pub registered: bool,
pub session_pct: Option<i64>,
pub week_all_pct: Option<i64>,
pub week_sonnet_pct: Option<i64>,
pub resets: Option<String>,
pub status: Status,
pub error: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct Report {
pub rows: Vec<Row>,
pub stale_secs: Option<u64>,
pub configured: bool,
pub no_usage: bool,
pub captured_at: Option<String>,
}
pub fn build_report(
profiles: &ProfileMap,
usage: Option<&UsageData>,
configured: bool,
stale_secs: Option<u64>,
) -> Report {
let mut rows: Vec<Row> = Vec::new();
let errors = usage.and_then(|u| u.errors.as_ref());
for name in profiles.names_sorted() {
rows.push(join_one(name, true, usage, errors));
}
if let Some(u) = usage {
let mut extra: Vec<&str> = u
.profiles
.keys()
.map(String::as_str)
.filter(|n| !profiles.contains(n))
.collect();
if let Some(errs) = errors {
for n in errs.keys() {
if !profiles.contains(n) && !u.profiles.contains_key(n) {
extra.push(n.as_str());
}
}
}
extra.sort_unstable();
extra.dedup();
for name in extra {
rows.push(join_one(name, false, usage, errors));
}
}
let no_usage = usage.is_none();
Report {
rows,
stale_secs,
configured,
no_usage,
captured_at: usage.and_then(|u| u.captured_at.clone()),
}
}
fn join_one(
name: &str,
registered: bool,
usage: Option<&UsageData>,
errors: Option<&std::collections::HashMap<String, String>>,
) -> Row {
if let Some(err) = errors.and_then(|e| e.get(name)) {
return Row {
name: name.to_owned(),
registered,
session_pct: None,
week_all_pct: None,
week_sonnet_pct: None,
resets: None,
status: Status::Errored,
error: Some(err.clone()),
};
}
let pu = usage.and_then(|u| u.profiles.get(name));
let session_pct = pu.and_then(|p| p.session.as_ref()).map(|s| s.pct);
let week_all_pct = pu.and_then(|p| p.week_all.as_ref()).map(|s| s.pct);
let week_sonnet_pct = pu.and_then(|p| p.week_sonnet.as_ref()).map(|s| s.pct);
let resets = pu.and_then(|p| {
p.session
.as_ref()
.and_then(|s| s.resets.clone())
.or_else(|| p.week_all.as_ref().and_then(|s| s.resets.clone()))
});
let has_any = session_pct.is_some() || week_all_pct.is_some() || week_sonnet_pct.is_some();
let status = if !has_any {
Status::NoData
} else if [session_pct, week_all_pct, week_sonnet_pct]
.into_iter()
.flatten()
.any(|p| p >= WARN_PCT)
{
Status::NearLimit
} else {
Status::Ok
};
Row {
name: name.to_owned(),
registered,
session_pct,
week_all_pct,
week_sonnet_pct,
resets,
status,
error: None,
}
}
pub fn render_table(report: &Report) -> String {
let mut out = String::new();
if let Some(age) = report.stale_secs {
if age >= 60 {
out.push_str(&format!(
"\u{26a0} hub data is {} old (showing last-known cache)\n",
humanize_age(age)
));
}
}
if !report.configured {
out.push_str(
"usage metering disabled — set CLAUDE_USAGE_URL + CLAUDE_HUB_HOSTNAME to enable\n",
);
}
if report.rows.is_empty() {
out.push_str("(no profiles configured — `csm profiles add <name>`)\n");
return out;
}
let name_w = report
.rows
.iter()
.map(|r| display_name(r).len())
.max()
.unwrap_or(8)
.max(8);
out.push_str(&format!(
"{:<nw$} {:>7} {:>9} {:>9} {:<20} {}\n",
"PROFILE",
"SESSION",
"WEEK(all)",
"WK(sonnet)",
"RESETS",
"STATUS",
nw = name_w,
));
for r in &report.rows {
out.push_str(&format!(
"{:<nw$} {:>7} {:>9} {:>9} {:<20} {}\n",
display_name(r),
pct(r.session_pct),
pct(r.week_all_pct),
pct(r.week_sonnet_pct),
truncate(r.resets.as_deref().unwrap_or("\u{2014}"), 20),
status_cell(r),
nw = name_w,
));
}
if report.no_usage && report.configured {
out.push_str("usage metering unavailable (hub unreachable, no cache)\n");
}
out
}
fn display_name(r: &Row) -> String {
if r.registered {
r.name.clone()
} else {
format!("{} (unreg)", r.name)
}
}
fn status_cell(r: &Row) -> String {
match (&r.status, &r.error) {
(Status::Errored, Some(msg)) => format!("{}: {}", Status::Errored.label(), msg),
(s, _) => s.label().to_owned(),
}
}
fn pct(v: Option<i64>) -> String {
match v {
Some(p) => format!("{p}%"),
None => "\u{2014}".to_owned(),
}
}
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_owned()
} else {
let mut t: String = s.chars().take(max.saturating_sub(1)).collect();
t.push('\u{2026}');
t
}
}
fn humanize_age(secs: u64) -> String {
if secs >= 86_400 {
format!("{}d", secs / 86_400)
} else if secs >= 3_600 {
format!("{}h", secs / 3_600)
} else if secs >= 60 {
format!("{}m", secs / 60)
} else {
format!("{secs}s")
}
}
#[derive(Debug, Serialize)]
struct JsonReport<'a> {
captured_at: Option<&'a str>,
stale_secs: Option<u64>,
configured: bool,
profiles: std::collections::BTreeMap<&'a str, JsonRow<'a>>,
}
#[derive(Debug, Serialize)]
struct JsonRow<'a> {
registered: bool,
session_pct: Option<i64>,
week_all_pct: Option<i64>,
week_sonnet_pct: Option<i64>,
resets: Option<&'a str>,
status: Status,
error: Option<&'a str>,
}
pub fn render_json(report: &Report) -> Result<String, serde_json::Error> {
let profiles: std::collections::BTreeMap<&str, JsonRow> = report
.rows
.iter()
.map(|r| {
(
r.name.as_str(),
JsonRow {
registered: r.registered,
session_pct: r.session_pct,
week_all_pct: r.week_all_pct,
week_sonnet_pct: r.week_sonnet_pct,
resets: r.resets.as_deref(),
status: r.status,
error: r.error.as_deref(),
},
)
})
.collect();
let wire = JsonReport {
captured_at: report.captured_at.as_deref(),
stale_secs: report.stale_secs,
configured: report.configured,
profiles,
};
serde_json::to_string_pretty(&wire)
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn registry(names: &[&str]) -> ProfileMap {
let mut m = HashMap::new();
for n in names {
m.insert((*n).to_owned(), format!("/tmp/.claude.{n}"));
}
ProfileMap(m)
}
fn sample_usage() -> UsageData {
serde_json::from_str(
r#"{
"captured_at": "2026-06-19T07:00:00Z",
"profiles": {
"home": {
"session": {"pct": 12, "resets": "9pm (Asia/Seoul)"},
"week_all": {"pct": 34, "resets": "Jun 22"},
"week_sonnet": {"pct": 8}
},
"work": {
"session": {"pct": 40},
"week_all": {"pct": 96, "resets": "Jun 22"},
"week_sonnet": null
},
"ghost": {
"session": {"pct": 3},
"week_all": {"pct": 5}
}
},
"errors": { "errored-acct": "HTTP 401: no credentials" }
}"#,
)
.unwrap()
}
#[test]
fn join_classifies_status_per_profile() {
let reg = registry(&["home", "work", "errored-acct"]);
let u = sample_usage();
let report = build_report(®, Some(&u), true, Some(5));
let by = |n: &str| report.rows.iter().find(|r| r.name == n).unwrap().clone();
assert_eq!(by("home").status, Status::Ok);
assert_eq!(by("home").session_pct, Some(12));
assert_eq!(by("work").status, Status::NearLimit); assert_eq!(by("errored-acct").status, Status::Errored);
assert_eq!(
by("errored-acct").error.as_deref(),
Some("HTTP 401: no credentials")
);
}
#[test]
fn registered_without_hub_data_is_no_data() {
let reg = registry(&["home", "lonely"]);
let u = sample_usage(); let report = build_report(®, Some(&u), true, None);
let lonely = report.rows.iter().find(|r| r.name == "lonely").unwrap();
assert_eq!(lonely.status, Status::NoData);
assert!(lonely.registered);
assert_eq!(lonely.session_pct, None);
}
#[test]
fn unregistered_hub_profile_is_appended_and_tagged() {
let reg = registry(&["home", "work"]); let u = sample_usage();
let report = build_report(®, Some(&u), true, None);
let ghost = report.rows.iter().find(|r| r.name == "ghost").unwrap();
assert!(!ghost.registered);
let names: Vec<&str> = report.rows.iter().map(|r| r.name.as_str()).collect();
let ghost_idx = names.iter().position(|n| *n == "ghost").unwrap();
let home_idx = names.iter().position(|n| *n == "home").unwrap();
assert!(
ghost_idx > home_idx,
"unregistered must sort after registered: {names:?}"
);
let table = render_table(&report);
assert!(table.contains("ghost (unreg)"), "table:\n{table}");
}
#[test]
fn error_only_hub_profile_surfaces_as_row() {
let reg = registry(&["home"]);
let u = sample_usage();
let report = build_report(®, Some(&u), true, None);
let errored = report.rows.iter().find(|r| r.name == "errored-acct");
assert!(errored.is_some(), "error-only hub profile must surface");
assert_eq!(errored.unwrap().status, Status::Errored);
}
#[test]
fn no_usage_blob_makes_every_registered_row_no_data() {
let reg = registry(&["home", "work"]);
let report = build_report(®, None, true, None);
assert!(report.no_usage);
assert_eq!(report.rows.len(), 2);
assert!(report.rows.iter().all(|r| r.status == Status::NoData));
let table = render_table(&report);
assert!(
table.contains("usage metering unavailable"),
"table:\n{table}"
);
}
#[test]
fn unconfigured_shows_disabled_message_and_registry() {
let reg = registry(&["home"]);
let report = build_report(®, None, false, None);
let table = render_table(&report);
assert!(table.contains("usage metering disabled"), "table:\n{table}");
assert!(
table.contains("home"),
"registry must still render: {table}"
);
assert!(!table.contains("unavailable"), "table:\n{table}");
}
#[test]
fn empty_registry_renders_hint() {
let reg = registry(&[]);
let report = build_report(®, None, false, None);
let table = render_table(&report);
assert!(table.contains("no profiles configured"), "table:\n{table}");
}
#[test]
fn stale_header_only_when_old() {
let reg = registry(&["home"]);
let u = sample_usage();
let fresh = render_table(&build_report(®, Some(&u), true, Some(5)));
assert!(
!fresh.contains("hub data is"),
"fresh should not warn: {fresh}"
);
let stale = render_table(&build_report(®, Some(&u), true, Some(420)));
assert!(stale.contains("hub data is 7m old"), "stale:\n{stale}");
}
#[test]
fn humanize_age_units() {
assert_eq!(humanize_age(45), "45s");
assert_eq!(humanize_age(420), "7m");
assert_eq!(humanize_age(7_200), "2h");
assert_eq!(humanize_age(172_800), "2d");
}
#[test]
fn json_shape_is_stable_and_sorted() {
let reg = registry(&["home", "work"]);
let u = sample_usage();
let report = build_report(®, Some(&u), true, Some(30));
let json = render_json(&report).unwrap();
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(v["configured"], serde_json::json!(true));
assert_eq!(v["stale_secs"], serde_json::json!(30));
assert_eq!(v["captured_at"], serde_json::json!("2026-06-19T07:00:00Z"));
let keys: Vec<&str> = v["profiles"]
.as_object()
.unwrap()
.keys()
.map(String::as_str)
.collect();
let mut sorted = keys.clone();
sorted.sort_unstable();
assert_eq!(keys, sorted, "json profile keys must be sorted: {keys:?}");
assert_eq!(
v["profiles"]["work"]["status"],
serde_json::json!("near_limit")
);
assert_eq!(v["profiles"]["home"]["session_pct"], serde_json::json!(12));
assert_eq!(
v["profiles"]["errored-acct"]["status"],
serde_json::json!("errored")
);
assert_eq!(
v["profiles"]["errored-acct"]["error"],
serde_json::json!("HTTP 401: no credentials")
);
}
#[test]
fn truncate_respects_max() {
assert_eq!(truncate("short", 20), "short");
let long = "this is a very long reset string indeed";
let t = truncate(long, 10);
assert_eq!(t.chars().count(), 10);
assert!(t.ends_with('\u{2026}'));
}
}