use crate::{agent::cancellation::AgentCancellation, tools::process};
use serde::Deserialize;
use std::{
io::ErrorKind,
process::{Command, Stdio},
time::Duration,
};
pub(crate) const QUOTA_AXI_BIN: &str = "quota-axi";
pub(crate) const QUOTA_AXI_INSTALL_INSTRUCTIONS: &str = "Install quota-axi, then rerun /usage.\n\nExample:\n npm install -g quota-axi\n\nExpected command:\n quota-axi --json";
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct UsageReport {
pub(crate) generated_at: Option<String>,
pub(crate) providers: Vec<UsageProviderRow>,
pub(crate) help: Vec<String>,
pub(crate) warning: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct UsageProviderRow {
pub(crate) provider: String,
pub(crate) label: String,
pub(crate) source: String,
pub(crate) plan: Option<String>,
pub(crate) status: String,
pub(crate) windows: Vec<UsageWindowRow>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct UsageWindowRow {
pub(crate) label: String,
pub(crate) kind: String,
pub(crate) percent_remaining_basis_points: u16,
pub(crate) percent_used_basis_points: Option<u16>,
pub(crate) reset_text: Option<String>,
pub(crate) resets_at: Option<String>,
pub(crate) spent_usd: Option<String>,
pub(crate) limit_usd: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum UsageLoadResult {
Loaded(UsageReport),
MissingCli,
Error(String),
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct QuotaAxiResponse {
generated_at: Option<String>,
schema_version: u64,
providers: Vec<QuotaAxiProvider>,
#[serde(default)]
help: Vec<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct QuotaAxiProvider {
provider: String,
label: String,
source: String,
plan: Option<String>,
#[serde(default)]
windows: Vec<QuotaAxiWindow>,
state: QuotaAxiState,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct QuotaAxiWindow {
label: String,
kind: String,
percent_used: Option<f64>,
percent_remaining: Option<f64>,
reset_text: Option<String>,
resets_at: Option<String>,
spent_usd: Option<f64>,
limit_usd: Option<f64>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct QuotaAxiState {
status: String,
}
pub(crate) fn load_usage() -> UsageLoadResult {
let mut command = Command::new(QUOTA_AXI_BIN);
command
.arg("--json")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
command.process_group(0);
}
let child = match command.spawn() {
Ok(child) => child,
Err(error) if error.kind() == ErrorKind::NotFound => return UsageLoadResult::MissingCli,
Err(error) => return UsageLoadResult::Error(format!("failed to start quota-axi: {error}")),
};
let output = match process::run_bounded_child_process(
child,
process::BoundedChildProcessLimits {
stdout_max_bytes: 256 * 1024,
stderr_max_bytes: 64 * 1024,
timeout: Duration::from_secs(15),
poll_interval: Duration::from_millis(25),
},
&AgentCancellation::default(),
) {
Ok(output) => output,
Err(error) => return UsageLoadResult::Error(format!("failed to run quota-axi: {error}")),
};
if output.timed_out {
return UsageLoadResult::Error("quota-axi timed out after 15s".to_string());
}
if output.stdout_truncated || output.stderr_truncated {
return UsageLoadResult::Error("quota-axi output exceeded safety limit".to_string());
}
let code = output.status.and_then(|status| status.code()).unwrap_or(-1);
let mut result = match code {
0 | 1 => parse_usage_report(&output.stdout)
.map(UsageLoadResult::Loaded)
.unwrap_or_else(UsageLoadResult::Error),
2 => UsageLoadResult::Error(short_error_with_snippet(
"quota-axi usage error; expected command: quota-axi --json",
&output.stderr,
&output.stdout,
)),
127 => UsageLoadResult::MissingCli,
other => UsageLoadResult::Error(format!("quota-axi failed with exit {other}")),
};
if let UsageLoadResult::Error(message) = &mut result
&& let Some(warning) = output.cleanup_warning.filter(|warning| !warning.is_empty())
{
message.push_str("; ");
message.push_str(&warning);
}
result
}
pub(crate) fn parse_usage_report(stdout: &str) -> Result<UsageReport, String> {
let response: QuotaAxiResponse = serde_json::from_str(stdout)
.map_err(|_| "quota-axi returned non-JSON output".to_string())?;
if response.schema_version != 2 {
return Err(format!(
"unsupported quota-axi schemaVersion {}; expected 2",
response.schema_version
));
}
let providers = response
.providers
.into_iter()
.filter_map(provider_row)
.collect::<Vec<_>>();
Ok(UsageReport {
generated_at: response.generated_at,
providers,
help: response.help,
warning: None,
})
}
fn provider_row(provider: QuotaAxiProvider) -> Option<UsageProviderRow> {
let windows = provider
.windows
.into_iter()
.filter_map(window_row)
.collect::<Vec<_>>();
(!windows.is_empty()).then_some(UsageProviderRow {
provider: provider.provider,
label: provider.label,
source: provider.source,
plan: provider.plan,
status: provider.state.status,
windows,
})
}
fn window_row(window: QuotaAxiWindow) -> Option<UsageWindowRow> {
let percent_remaining = window.percent_remaining.filter(|value| value.is_finite())?;
Some(UsageWindowRow {
label: window.label,
kind: window.kind,
percent_remaining_basis_points: percent_to_basis_points(percent_remaining),
percent_used_basis_points: window
.percent_used
.filter(|value| value.is_finite())
.map(percent_to_basis_points),
reset_text: window.reset_text,
resets_at: window.resets_at,
spent_usd: window
.spent_usd
.filter(|value| value.is_finite())
.map(format_usd),
limit_usd: window
.limit_usd
.filter(|value| value.is_finite())
.map(format_usd),
})
}
fn percent_to_basis_points(value: f64) -> u16 {
(value * 100.0).round().clamp(0.0, f64::from(u16::MAX)) as u16
}
fn format_usd(value: f64) -> String {
format!("${value:.2}")
}
pub(crate) fn format_percent_basis_points(basis_points: u16) -> String {
let whole = basis_points / 100;
let tenth = (basis_points % 100) / 10;
if tenth == 0 {
format!("{whole}%")
} else {
format!("{whole}.{tenth}%")
}
}
pub(crate) fn usage_text_content(
loading: bool,
result: Option<&UsageLoadResult>,
) -> Option<String> {
if loading || result.is_none() {
return Some("Loading provider usage…".to_string());
}
match result.unwrap() {
UsageLoadResult::MissingCli => Some(QUOTA_AXI_INSTALL_INSTRUCTIONS.to_string()),
UsageLoadResult::Error(message) => Some(format!(
"Could not load usage: {message}\n\nExpected command: quota-axi --json"
)),
UsageLoadResult::Loaded(report) if report.providers.is_empty() => {
let mut text = "No provider usage data available. Run provider login/setup commands, then rerun /usage.".to_string();
if !report.help.is_empty() {
text.push_str("\n\n");
text.push_str(&report.help.join("\n"));
}
Some(text)
}
UsageLoadResult::Loaded(_) => None,
}
}
pub(crate) fn usage_visual_rows(
loading: bool,
result: Option<&UsageLoadResult>,
wrap_width: u16,
) -> usize {
if let Some(text) = usage_text_content(loading, result) {
return wrapped_rows(&text, wrap_width);
}
let Some(UsageLoadResult::Loaded(report)) = result else {
return 0;
};
usize::from(report.generated_at.is_some())
+ report
.providers
.iter()
.map(|provider| 1 + provider.windows.len() * 2 + 1)
.sum::<usize>()
}
fn wrapped_rows(text: &str, wrap_width: u16) -> usize {
crate::tui::transcript::ratatui_wrapped_visual_rows(text, wrap_width.max(1))
}
fn short_error_with_snippet(prefix: &str, stderr: &str, stdout: &str) -> String {
let snippet = stderr
.trim()
.lines()
.next()
.or_else(|| stdout.trim().lines().next());
snippet.map_or_else(|| prefix.to_string(), |line| format!("{prefix}: {line}"))
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_json(extra_provider: &str) -> String {
format!(
r#"{{
"schemaVersion": 2,
"generatedAt": "2026-01-01T00:00:00Z",
"help": ["run setup"],
"providers": [
{{
"provider": "openai-codex",
"label": "OpenAI Codex",
"source": "oauth",
"plan": "Pro",
"state": {{"status": "fresh"}},
"windows": [{{
"label": "5h",
"kind": "rolling",
"percentUsed": 42.5,
"percentRemaining": 57.5,
"resetText": "in 2h",
"spentUsd": 1.5,
"limitUsd": 10
}}]
}}{extra_provider}
]
}}"#
)
}
#[test]
fn parses_schema_v2_sample_with_percent_remaining() {
let report = parse_usage_report(&sample_json("")).unwrap();
assert_eq!(report.generated_at.as_deref(), Some("2026-01-01T00:00:00Z"));
assert_eq!(report.providers.len(), 1);
let provider = &report.providers[0];
assert_eq!(provider.label, "OpenAI Codex");
assert_eq!(provider.windows[0].percent_remaining_basis_points, 5750);
assert_eq!(provider.windows[0].percent_used_basis_points, Some(4250));
assert_eq!(provider.windows[0].spent_usd.as_deref(), Some("$1.50"));
assert_eq!(provider.windows[0].limit_usd.as_deref(), Some("$10.00"));
}
#[test]
fn filters_auth_required_provider_with_empty_windows() {
let extra = r#",
{
"provider": "anthropic",
"label": "Anthropic",
"source": "none",
"state": {"status": "auth_required"},
"windows": []
}"#;
let report = parse_usage_report(&sample_json(extra)).unwrap();
assert_eq!(report.providers.len(), 1);
assert_eq!(report.providers[0].provider, "openai-codex");
}
#[test]
fn excludes_window_without_percent_remaining() {
let json = r#"{"schemaVersion":2,"providers":[{"provider":"p","label":"P","source":"s","state":{"status":"fresh"},"windows":[{"label":"daily","kind":"fixed","percentUsed":10}]}]}"#;
let report = parse_usage_report(json).unwrap();
assert!(report.providers.is_empty());
}
#[test]
fn rejects_unsupported_schema_version() {
let error = parse_usage_report(r#"{"schemaVersion":3,"providers":[]}"#).unwrap_err();
assert_eq!(error, "unsupported quota-axi schemaVersion 3; expected 2");
}
#[test]
fn maps_non_json_stdout_to_parse_error() {
let error = parse_usage_report("not json").unwrap_err();
assert_eq!(error, "quota-axi returned non-JSON output");
}
#[test]
fn usage_visual_rows_matches_wrapped_row_count_for_long_error_line() {
let long_line = "x".repeat(200);
let result = UsageLoadResult::Error(long_line);
let wrap_width = 20;
let expected = wrapped_rows(
&usage_text_content(false, Some(&result)).unwrap(),
wrap_width,
);
assert_eq!(
usage_visual_rows(false, Some(&result), wrap_width),
expected
);
assert!(expected > 1);
}
}