nils-gemini-cli 0.7.3

CLI crate for nils-gemini-cli in the nils-cli workspace.
Documentation
use std::path::{Path, PathBuf};
use std::process::Command;

use crate::json;

pub struct UsageResponse {
    pub body: String,
}

pub struct UsageRequest {
    pub target_file: PathBuf,
    pub refresh_on_401: bool,
    pub endpoint: String,
    pub api_version: String,
    pub project: String,
    pub connect_timeout_seconds: u64,
    pub max_time_seconds: u64,
}

pub fn fetch_usage(request: &UsageRequest) -> Result<UsageResponse, String> {
    let access_token = read_tokens(&request.target_file)?;
    let mut response = send_request(request, &access_token)?;

    if response.status == 401
        && request.refresh_on_401
        && let Ok(access_token) = read_tokens(&request.target_file)
    {
        response = send_request(request, &access_token)?;
    }

    if response.status != 200 {
        let preview = response
            .body
            .chars()
            .take(200)
            .collect::<String>()
            .replace(['\n', '\r'], " ");
        if preview.is_empty() {
            return Err(format!(
                "gemini-rate-limits: POST {} failed (HTTP {})",
                response.url, response.status
            ));
        }
        return Err(format!(
            "gemini-rate-limits: POST {} failed (HTTP {})\ngemini-rate-limits: body: {}",
            response.url, response.status, preview
        ));
    }

    Ok(UsageResponse {
        body: response.body,
    })
}

pub fn read_tokens(target_file: &Path) -> Result<String, String> {
    let value = json::read_json(target_file).map_err(|err| err.to_string())?;
    let access_token = json::string_at(&value, &["tokens", "access_token"])
        .or_else(|| json::string_at(&value, &["access_token"]))
        .ok_or_else(|| "missing access_token".to_string())?;
    Ok(access_token)
}

fn send_request(request: &UsageRequest, access_token: &str) -> Result<HttpResponse, String> {
    let url = build_usage_url(&request.endpoint, &request.api_version)?;
    let payload = serde_json::json!({
        "project": request.project,
    })
    .to_string();

    let response = Command::new("curl")
        .arg("-sS")
        .arg("--connect-timeout")
        .arg(request.connect_timeout_seconds.max(1).to_string())
        .arg("--max-time")
        .arg(request.max_time_seconds.max(1).to_string())
        .arg("-X")
        .arg("POST")
        .arg("-H")
        .arg("Accept: application/json")
        .arg("-H")
        .arg("Content-Type: application/json")
        .arg("-H")
        .arg("User-Agent: gemini-cli")
        .arg("-H")
        .arg(format!("Authorization: Bearer {access_token}"))
        .arg("--data")
        .arg(payload)
        .arg(&url)
        .arg("-w")
        .arg("\n__HTTP_STATUS__:%{http_code}")
        .output()
        .map_err(|_| format!("gemini-rate-limits: request failed: {url}"))?;

    if !response.status.success() {
        return Err(format!("gemini-rate-limits: request failed: {url}"));
    }

    let response_text = String::from_utf8_lossy(&response.stdout).to_string();
    let (body, status) = split_http_status_marker(&response_text);
    if status == 0 {
        return Err(format!("gemini-rate-limits: request failed: {url}"));
    }

    Ok(HttpResponse { status, body, url })
}

fn build_usage_url(endpoint: &str, api_version: &str) -> Result<String, String> {
    let endpoint = endpoint.trim().trim_end_matches('/');
    if endpoint.is_empty() || !(endpoint.starts_with("https://") || endpoint.starts_with("http://"))
    {
        return Err(format!(
            "gemini-rate-limits: unsupported endpoint: {}",
            endpoint
        ));
    }
    let api_version = api_version.trim();
    if api_version.is_empty() {
        return Err("gemini-rate-limits: missing code assist api version".to_string());
    }
    Ok(format!("{endpoint}/{api_version}:retrieveUserQuota"))
}

fn split_http_status_marker(raw: &str) -> (String, u16) {
    let marker = "__HTTP_STATUS__:";
    if let Some(index) = raw.rfind(marker) {
        let body = raw[..index]
            .trim_end_matches('\n')
            .trim_end_matches('\r')
            .to_string();
        let status_raw = raw[index + marker.len()..].trim();
        let status = status_raw.parse::<u16>().unwrap_or(0);
        (body, status)
    } else {
        (raw.to_string(), 0)
    }
}

struct HttpResponse {
    status: u16,
    body: String,
    url: String,
}