Skip to main content

ai_usagebar/
active.rs

1//! Active-vendor state file. Set by `--cycle-next` / `--cycle-prev` (which
2//! Waybar's `on-scroll-up`/`on-scroll-down` invoke), read by the widget on
3//! every tick. The TUI does NOT consult this — it has its own tab state.
4//!
5//! On-disk shape: a single line with the vendor slug (e.g. `openai`). Located
6//! at `<cache-dir>/active_vendor`.
7
8use std::fs;
9use std::path::PathBuf;
10
11use crate::cache::atomic_write;
12use crate::error::{AppError, Result};
13use crate::vendor::VendorId;
14
15fn state_dir() -> Result<PathBuf> {
16    let base = directories::BaseDirs::new()
17        .ok_or_else(|| AppError::Other("could not resolve XDG cache dir".into()))?;
18    Ok(base.cache_dir().join("ai-usagebar"))
19}
20
21fn state_path() -> Result<PathBuf> {
22    Ok(state_dir()?.join("active_vendor"))
23}
24
25/// Read the persisted active vendor, if any. `None` means "no override —
26/// callers fall back to [ui] primary or anthropic".
27pub fn read() -> Option<VendorId> {
28    let path = state_path().ok()?;
29    let raw = fs::read_to_string(&path).ok()?;
30    parse_slug(raw.trim())
31}
32
33/// Persist `vendor` as the active one. Atomic.
34pub fn write(vendor: VendorId) -> Result<()> {
35    let path = state_path()?;
36    atomic_write(&path, vendor.slug().as_bytes())
37}
38
39/// Cycle the active vendor by `delta` positions through `enabled` (which
40/// preserves canonical order). Wraps. If no state exists, starts at `start`
41/// (usually `[ui] primary` or anthropic).
42pub fn cycle(enabled: &[VendorId], start: VendorId, delta: i32) -> Result<VendorId> {
43    if enabled.is_empty() {
44        return Err(AppError::Other("no enabled vendors to cycle".into()));
45    }
46    let current = read().filter(|v| enabled.contains(v)).unwrap_or(start);
47    let cur_idx = enabled.iter().position(|v| *v == current).unwrap_or(0);
48    let n = enabled.len() as i32;
49    let next_idx = ((cur_idx as i32 + delta).rem_euclid(n)) as usize;
50    let next = enabled[next_idx];
51    write(next)?;
52    Ok(next)
53}
54
55fn parse_slug(s: &str) -> Option<VendorId> {
56    match s {
57        "anthropic" => Some(VendorId::Anthropic),
58        "openai" => Some(VendorId::Openai),
59        "zai" => Some(VendorId::Zai),
60        "openrouter" => Some(VendorId::Openrouter),
61        "deepseek" => Some(VendorId::Deepseek),
62        _ => None,
63    }
64}
65
66#[cfg(test)]
67mod tests {
68    use super::*;
69
70    #[test]
71    fn parse_slug_round_trip() {
72        for id in VendorId::all() {
73            assert_eq!(parse_slug(id.slug()), Some(*id));
74        }
75    }
76
77    #[test]
78    fn parse_slug_unknown_returns_none() {
79        assert!(parse_slug("not-a-vendor").is_none());
80        assert!(parse_slug("").is_none());
81    }
82
83    #[test]
84    fn cycle_wraps_forward_and_backward() {
85        // Pure cycle math (no disk I/O — we don't call read/write here,
86        // only go through `cycle` which would touch disk). Verify the
87        // index arithmetic directly.
88        let enabled = [
89            VendorId::Anthropic,
90            VendorId::Openai,
91            VendorId::Zai,
92            VendorId::Openrouter,
93        ];
94        let step = |from: usize, delta: i32| -> VendorId {
95            enabled[((from as i32 + delta).rem_euclid(4)) as usize]
96        };
97        // forward from Anthropic → Openai
98        assert_eq!(step(0, 1), VendorId::Openai);
99        // backward from Anthropic → Openrouter (wrap)
100        assert_eq!(step(0, -1), VendorId::Openrouter);
101        // forward from Openrouter → Anthropic (wrap)
102        assert_eq!(step(3, 1), VendorId::Anthropic);
103    }
104}