use std::process::Command;
use crate::json::{extract_f64_field, extract_string_field, extract_u64_field, object_slice};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Caller {
Claude,
Antigravity,
}
impl Caller {
pub fn parse(s: &str) -> Option<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"claude" | "claude-code" => Some(Self::Claude),
"agy" | "antigravity" => Some(Self::Antigravity),
_ => None,
}
}
pub fn detect(json: &str) -> Self {
const CLAUDE_KEYS: [&str; 3] = [
"\"context_window\"",
"\"rate_limits\"",
"\"transcript_path\"",
];
if CLAUDE_KEYS.iter().any(|k| json.contains(k)) {
Self::Claude
} else {
Self::Antigravity
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Usage {
pub pct: u32,
pub reset_secs: Option<u64>,
}
#[derive(Debug, Default, PartialEq)]
pub struct Parsed {
pub cwd: String,
pub branch: Option<String>,
pub model: String,
pub ctx_pct: Option<u32>,
pub five: Option<Usage>,
pub week: Option<Usage>,
pub width: Option<usize>,
}
pub struct Env {
pub username: String,
pub home: String,
pub columns: Option<usize>,
}
#[derive(Debug)]
pub struct Status {
pub username: String,
pub home: String,
pub cwd: String,
pub branch: Option<String>,
pub model: String,
pub ctx_pct: Option<u32>,
pub five: Option<Usage>,
pub week: Option<Usage>,
pub cols: usize,
}
pub fn valid_pct(v: Option<f64>) -> Option<u32> {
let v = v?;
if v.is_finite() && (0.0..=100.0).contains(&v) {
Some(v as u32)
} else {
None
}
}
pub fn parse(json: &str, caller: Caller, now_secs: u64) -> Parsed {
match caller {
Caller::Claude => parse_claude(json, now_secs),
Caller::Antigravity => parse_antigravity(json),
}
}
fn parse_claude(json: &str, now_secs: u64) -> Parsed {
let cwd = object_slice(json, "workspace")
.and_then(|w| extract_string_field(w, "current_dir"))
.or_else(|| extract_string_field(json, "cwd"))
.unwrap_or_default();
let model_obj = object_slice(json, "model").unwrap_or("");
let model = extract_string_field(model_obj, "display_name")
.or_else(|| extract_string_field(model_obj, "id"))
.unwrap_or_default();
let ctx_pct = valid_pct(
object_slice(json, "context_window").and_then(|c| extract_f64_field(c, "used_percentage")),
);
let limits = object_slice(json, "rate_limits");
let window = |key: &str| -> Option<Usage> {
let w = object_slice(limits?, key)?;
let pct = valid_pct(extract_f64_field(w, "used_percentage"))?;
let reset_secs = extract_u64_field(w, "resets_at")
.and_then(|at| at.checked_sub(now_secs))
.filter(|s| *s > 0);
Some(Usage { pct, reset_secs })
};
Parsed {
cwd,
branch: None,
model,
ctx_pct,
five: window("five_hour"),
week: window("seven_day"),
width: extract_f64_field(json, "terminal_width").map(|w| w as usize),
}
}
fn parse_antigravity(json: &str) -> Parsed {
let cwd = extract_string_field(json, "cwd").unwrap_or_default();
let branch = extract_string_field(json, "branch").filter(|b| !b.is_empty() && b != "none");
let model = extract_string_field(json, "display_name")
.or_else(|| extract_string_field(json, "id"))
.unwrap_or_default();
let ctx_pct = valid_pct(extract_f64_field(json, "used_percentage"));
let lower = model.to_lowercase();
let is_3p = lower.contains("claude") || lower.contains("gpt") || lower.contains("oss");
let prefix = if is_3p { "3p" } else { "gemini" };
let quota = object_slice(json, "quota");
let window = |suffix: &str| -> Option<Usage> {
let q = object_slice(quota?, &format!("{prefix}-{suffix}"))?;
let remaining = extract_f64_field(q, "remaining_fraction")?;
let pct = valid_pct(Some(((1.0 - remaining) * 100.0).round()))?;
let reset_secs = extract_u64_field(q, "reset_in_seconds").filter(|s| *s > 0);
Some(Usage { pct, reset_secs })
};
Parsed {
cwd,
branch,
model,
ctx_pct,
five: window("5h"),
week: window("weekly"),
width: extract_f64_field(json, "terminal_width").map(|w| w as usize),
}
}
pub fn get_git_branch(cwd: &str) -> Option<String> {
let mut cmd = Command::new("git");
if !cwd.is_empty() {
cmd.args(["-C", cwd]);
}
cmd.args(["--no-optional-locks", "rev-parse", "--abbrev-ref", "HEAD"]);
let out = cmd.output().ok()?;
if !out.status.success() {
return None;
}
let branch = String::from_utf8_lossy(&out.stdout).trim().to_string();
if branch.is_empty() {
None
} else {
Some(branch)
}
}
impl Status {
pub fn assemble(parsed: Parsed, caller: Caller, branch: Option<String>, env: Env) -> Self {
let cols = match caller {
Caller::Claude => env.columns.or(parsed.width),
Caller::Antigravity => parsed.width.or(env.columns),
}
.unwrap_or(0);
Self {
username: env.username,
home: env.home,
cwd: parsed.cwd,
branch,
model: parsed.model,
ctx_pct: parsed.ctx_pct,
five: parsed.five,
week: parsed.week,
cols,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const NOW: u64 = 1_757_000_000;
const CLAUDE: &str = r#"{"session_id":"abc","transcript_path":"/x","cwd":"/Users/ash/repos/grav-bar",
"model":{"id":"claude-fable-5-1[1m]","display_name":"Fable 5.1 (1M context)"},
"workspace":{"current_dir":"/Users/ash/repos/other","project_dir":"/Users/ash/repos/other","added_dirs":[]},
"context_window":{"total_input_tokens":45000,"used_percentage":4.5,"remaining_percentage":95.5},
"rate_limits":{"five_hour":{"used_percentage":37.2,"resets_at":1757008100},
"seven_day":{"used_percentage":62.9,"resets_at":1756000000}}}"#;
const AGY: &str = r#"{"cwd":"/Users/ash/repos/grav-bar","branch":"none","terminal_width":120,
"model":{"id":"claude-opus","display_name":"Claude Opus 4.6 (Thinking)"},
"context":{"used_percentage":42.7},
"quota":{"gemini-5h":{"remaining_fraction":0.9,"reset_in_seconds":100},
"3p-5h":{"remaining_fraction":0.628,"reset_in_seconds":8100},
"3p-weekly":{"remaining_fraction":0.371,"reset_in_seconds":0}}}"#;
#[test]
fn detects_caller() {
assert_eq!(Caller::detect(CLAUDE), Caller::Claude);
assert_eq!(Caller::detect(AGY), Caller::Antigravity);
assert_eq!(Caller::detect("{}"), Caller::Antigravity);
assert_eq!(Caller::parse("claude"), Some(Caller::Claude));
assert_eq!(Caller::parse("AGY"), Some(Caller::Antigravity));
assert_eq!(Caller::parse("antigravity"), Some(Caller::Antigravity));
assert_eq!(Caller::parse("nope"), None);
}
#[test]
fn parses_claude_payload() {
let p = parse(CLAUDE, Caller::Claude, NOW);
assert_eq!(p.cwd, "/Users/ash/repos/other");
assert_eq!(p.model, "Fable 5.1 (1M context)");
assert_eq!(p.branch, None);
assert_eq!(p.ctx_pct, Some(4));
assert_eq!(
p.five,
Some(Usage {
pct: 37,
reset_secs: Some(8100)
})
);
assert_eq!(
p.week,
Some(Usage {
pct: 62,
reset_secs: None
})
);
assert_eq!(p.width, None);
}
#[test]
fn claude_payload_without_limits() {
let json = r#"{"transcript_path":"/x","cwd":"/tmp","model":{"id":"m","display_name":"M"},
"context_window":{"used_percentage":null}}"#;
let p = parse(json, Caller::Claude, NOW);
assert_eq!(p.ctx_pct, None);
assert_eq!(p.five, None);
assert_eq!(p.week, None);
assert_eq!(p.model, "M");
}
#[test]
fn parses_antigravity_payload() {
let p = parse(AGY, Caller::Antigravity, NOW);
assert_eq!(p.cwd, "/Users/ash/repos/grav-bar");
assert_eq!(p.branch, None);
assert_eq!(p.model, "Claude Opus 4.6 (Thinking)");
assert_eq!(p.ctx_pct, Some(42));
assert_eq!(p.width, Some(120));
assert_eq!(
p.five,
Some(Usage {
pct: 37,
reset_secs: Some(8100)
})
);
assert_eq!(
p.week,
Some(Usage {
pct: 63,
reset_secs: None
})
);
}
#[test]
fn antigravity_gemini_keys_and_branch() {
let json = AGY
.replace("Claude Opus 4.6 (Thinking)", "Gemini 3 Pro")
.replace("\"branch\":\"none\"", "\"branch\":\"feature-x\"");
let p = parse(&json, Caller::Antigravity, NOW);
assert_eq!(p.branch.as_deref(), Some("feature-x"));
assert_eq!(
p.five,
Some(Usage {
pct: 10,
reset_secs: Some(100)
})
);
assert_eq!(p.week, None);
}
#[test]
fn valid_pct_bounds() {
assert_eq!(valid_pct(Some(99.9)), Some(99));
assert_eq!(valid_pct(Some(100.0)), Some(100));
assert_eq!(valid_pct(Some(100.1)), None);
assert_eq!(valid_pct(Some(-1.0)), None);
assert_eq!(valid_pct(Some(f64::NAN)), None);
assert_eq!(valid_pct(None), None);
}
#[test]
fn width_source_follows_caller() {
let env = |columns| Env {
username: "ash".into(),
home: "/Users/ash".into(),
columns,
};
let claude = Status::assemble(
parse(CLAUDE, Caller::Claude, NOW),
Caller::Claude,
None,
env(Some(160)),
);
assert_eq!(claude.cols, 160);
let agy = Status::assemble(
parse(AGY, Caller::Antigravity, NOW),
Caller::Antigravity,
None,
env(Some(160)),
);
assert_eq!(agy.cols, 120);
let unknown = Status::assemble(
parse(CLAUDE, Caller::Claude, NOW),
Caller::Claude,
None,
env(None),
);
assert_eq!(unknown.cols, 0);
}
}