quotch 0.5.5

Fast cross-platform CLI for AI coding-agent usage limits
mod cache;
mod model;
mod providers;
mod registry;
mod render;

use std::io::IsTerminal;

use chrono::{DateTime, Utc};

use crate::cache::Cache;
use crate::model::{Account, Snapshot, Status};
use crate::providers::{FetchError, Provider};

const USAGE: &str = "\
quotch — AI coding-agent subscription usage at a glance
Usage: quotch [--json] [--raw] [--refresh] [-h|--help] [-V|--version]
       quotch skill install    install the quotch agent skill file
  --json     machine-readable JSON output       --refresh  ignore cache, always fetch
  --raw      include full provider response      -h,--help  show this help
                                                  -V,--version  show version
";

const SKILL_MD: &str = include_str!("../skill/SKILL.md");

const SKILL_USAGE: &str = "\
Usage: quotch skill print
       quotch skill install [--project | --dir <path>]
  print              print the skill file to stdout
  install            install to <home>/.claude/skills/quotch/SKILL.md
    --project        install to ./.claude/skills/quotch/SKILL.md
    --dir <path>     install to <path>/quotch/SKILL.md
";

fn main() {
    let args: Vec<String> = std::env::args().skip(1).collect();
    if args.first().map(String::as_str) == Some("skill") {
        run_skill(&args[1..]);
    }

    let mut json = false;
    let mut raw = false;
    let mut refresh = false;

    for arg in &args {
        match arg.as_str() {
            "--json" => json = true,
            "--raw" => raw = true,
            "--refresh" => refresh = true,
            "-h" | "--help" => {
                print!("{USAGE}");
                std::process::exit(0);
            }
            "-V" | "--version" => {
                println!("quotch {}", env!("CARGO_PKG_VERSION"));
                std::process::exit(0);
            }
            _ => {
                eprint!("{USAGE}");
                std::process::exit(2);
            }
        }
    }

    // Signal --refresh to the provider layer via the environment. Set once here,
    // before any fetch thread spawns, and only ever read afterwards — so this is a
    // safe use of the process-global env despite set_var being `unsafe` in 2024.
    // (The codex provider opts into a CLI token refresh only when this is set.)
    if refresh {
        unsafe { std::env::set_var("QUOTCH_REFRESH", "1") };
    }

    let cache = Cache::new();

    // Discovery order is the output order; index every (provider, account) pair.
    let providers = registry::all();
    let mut pairs: Vec<(&dyn Provider, Account)> = Vec::new();
    for p in &providers {
        for acct in p.discover() {
            pairs.push((p.as_ref(), acct));
        }
    }

    let now = Utc::now();
    let mut snaps: Vec<Option<Snapshot>> = vec![None; pairs.len()];
    let mut cached_snaps: Vec<Option<Snapshot>> = Vec::with_capacity(pairs.len());
    let mut to_fetch: Vec<usize> = Vec::new();

    for (i, pair) in pairs.iter().enumerate() {
        let acct = &pair.1;
        let cached = cache.load(acct.provider, &acct.id);
        if !refresh && cached.as_ref().is_some_and(|c| is_fresh(c, now)) {
            // Warm path: fresh cache, no network. Reuse as-is (always Ok on disk).
            snaps[i] = cached.clone();
        } else if !refresh && cache.recent_failure(acct.provider, &acct.id) {
            // Backoff path: the last fetch failed recently and the network is
            // presumably still down. Skip the round-trip so we don't re-pay the
            // full timeout during the backoff window — regardless of cache.
            snaps[i] = Some(match &cached {
                // Older cache exists: serve its data restamped stale.
                Some(old) => {
                    let mut snap = old.clone();
                    snap.status = Status::Stale;
                    snap
                }
                // No cache: emit an Error snapshot WITHOUT fetching.
                None => Snapshot {
                    provider: acct.provider.to_string(),
                    account: acct.id.clone(),
                    label: acct.label.clone(),
                    plan: None,
                    windows: vec![],
                    fetched_at: now,
                    status: Status::Error,
                    error: Some("recent fetch failed; backing off".into()),
                    raw: None,
                },
            });
        } else {
            to_fetch.push(i);
        }
        cached_snaps.push(cached);
    }

    // Fetch every stale/missing pair concurrently — one thread each. A provider's
    // own network timeout bounds its thread; a panic degrades to an Error snapshot.
    std::thread::scope(|s| {
        let mut handles = Vec::new();
        for &i in &to_fetch {
            let p = pairs[i].0;
            let acct = &pairs[i].1;
            let cached = cached_snaps[i].clone();
            let cache_ref = &cache;
            let handle = s.spawn(move || fetch_one(p, acct, cache_ref, cached));
            handles.push((i, p.id(), acct.id.clone(), acct.label.clone(), handle));
        }
        for (i, provider, account, label, handle) in handles {
            snaps[i] = Some(match handle.join() {
                Ok(snap) => snap,
                Err(_) => Snapshot {
                    provider: provider.to_string(),
                    account,
                    label,
                    plan: None,
                    windows: vec![],
                    fetched_at: Utc::now(),
                    status: Status::Error,
                    error: Some("provider panicked".to_string()),
                    raw: None,
                },
            });
        }
    });

    // Every slot is filled by construction; flatten preserves discovery order.
    let snaps: Vec<Snapshot> = snaps.into_iter().flatten().collect();

    if json {
        print!("{}", render::json::render(snaps, raw));
    } else {
        let color = std::io::stdout().is_terminal();
        let out = render::line::render(&snaps, color);
        if !out.is_empty() {
            println!("{out}");
        }
    }
    // Provider errors are data, not process failures: always exit 0.
}

/// Handles `quotch skill ...`. Dispatched before the flag loop so `skill`
/// is never mistaken for an unknown flag.
fn run_skill(args: &[String]) -> ! {
    match args.first().map(String::as_str) {
        Some("print") => {
            print!("{SKILL_MD}");
            std::process::exit(0);
        }
        Some("install") => run_skill_install(&args[1..]),
        _ => skill_usage_exit(),
    }
}

fn run_skill_install(args: &[String]) -> ! {
    let mut project = false;
    let mut dir: Option<&str> = None;

    let mut i = 0;
    while i < args.len() {
        match args[i].as_str() {
            "--project" => {
                project = true;
                i += 1;
            }
            "--dir" => {
                dir = args.get(i + 1).map(String::as_str);
                if dir.is_none() {
                    skill_usage_exit();
                }
                i += 2;
            }
            _ => skill_usage_exit(),
        }
    }
    if project && dir.is_some() {
        skill_usage_exit();
    }

    let path = if let Some(dir) = dir {
        std::path::Path::new(dir).join("quotch").join("SKILL.md")
    } else if project {
        std::path::Path::new(".claude")
            .join("skills")
            .join("quotch")
            .join("SKILL.md")
    } else {
        match std::env::home_dir() {
            Some(home) => home
                .join(".claude")
                .join("skills")
                .join("quotch")
                .join("SKILL.md"),
            None => {
                eprintln!("error: could not determine home directory");
                std::process::exit(1);
            }
        }
    };

    if let Some(parent) = path.parent()
        && let Err(e) = std::fs::create_dir_all(parent)
    {
        eprintln!("error: {e}");
        std::process::exit(1);
    }
    match std::fs::write(&path, SKILL_MD) {
        Ok(()) => {
            println!("installed: {}", path.display());
            std::process::exit(0);
        }
        Err(e) => {
            eprintln!("error: {e}");
            std::process::exit(1);
        }
    }
}

fn skill_usage_exit() -> ! {
    eprint!("{SKILL_USAGE}");
    std::process::exit(2);
}

fn is_fresh(snap: &Snapshot, now: DateTime<Utc>) -> bool {
    // A future fetched_at (clock skew) fails to_std and counts as stale → refetch.
    (now - snap.fetched_at)
        .to_std()
        .is_ok_and(|age| age < cache::TTL)
}

fn fetch_one(
    p: &dyn Provider,
    acct: &Account,
    cache: &Cache,
    cached: Option<Snapshot>,
) -> Snapshot {
    match p.fetch(acct) {
        Ok(snap) => {
            cache.store(&snap);
            cache.clear_failure(p.id(), &acct.id);
            snap
        }
        Err(FetchError::AuthMissing) => Snapshot {
            provider: p.id().to_string(),
            account: acct.id.clone(),
            label: acct.label.clone(),
            plan: None,
            windows: vec![],
            fetched_at: Utc::now(),
            status: Status::AuthMissing,
            error: None,
            raw: None,
        },
        Err(e) => {
            // Not AuthMissing: a real network/parse failure, worth backing off.
            cache.mark_failure(p.id(), &acct.id);
            match cached {
                // Older cache exists: keep its data and original fetched_at, mark Stale.
                Some(mut old) => {
                    old.status = Status::Stale;
                    old
                }
                None => Snapshot {
                    provider: p.id().to_string(),
                    account: acct.id.clone(),
                    label: acct.label.clone(),
                    plan: None,
                    windows: vec![],
                    fetched_at: Utc::now(),
                    status: Status::Error,
                    error: Some(e.to_string()),
                    raw: None,
                },
            }
        }
    }
}