grav-bar 26.9.1

Fast, zero-dependency, and themed status line for the Google Antigravity CLI. Compatible also with Claude code.
//! Normalizes the two supported payloads (Claude Code and Google Antigravity)
//! into one [`Status`] the renderer can draw.

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 {
    /// Parses a `--caller` value.
    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,
        }
    }

    /// Guesses the caller from keys only Claude Code emits. Antigravity is the
    /// backward-compatible default.
    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
        }
    }
}

/// One rate-limit window.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Usage {
    /// Percent of the window used, 0..=100.
    pub pct: u32,
    /// Seconds until the window resets, if known and in the future.
    pub reset_secs: Option<u64>,
}

/// Everything read from the payload itself, before environment lookups.
#[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>,
}

/// Values gathered from the process environment by `main`.
pub struct Env {
    pub username: String,
    pub home: String,
    pub columns: Option<usize>,
}

/// What the renderer draws.
#[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>,
    /// Terminal width in cells; 0 means unknown, which disables compaction.
    pub cols: usize,
}

/// Truncates a percentage to an integer; `None` if it is not a number or
/// falls outside 0..=100 (mirrors the bash script's `valid_pct`).
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),
    }
}

/// Current branch name via git, or `None` outside a repository.
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 {
        // Claude Code sets $COLUMNS and has no width in the payload; Antigravity
        // is the other way round. Prefer the caller's native source.
        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)
            })
        );
        // resets_at in the past: percentage kept, timer hidden.
        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));
        // Claude model picks the 3p keys and inverts remaining -> used.
        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);
    }
}