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::{Path, PathBuf};
10use std::time::Duration;
11
12use crate::cache::{acquire_lock, atomic_write};
13use crate::error::{AppError, Result};
14use crate::vendor::VendorId;
15
16/// Much shorter than the vendors' fetch locks (15–45s): the critical section
17/// here is one small read plus a rename, so a wait this long already means a
18/// wedged holder — and a scroll event that blocks for seconds is worse than a
19/// dropped one.
20const LOCK_TIMEOUT: Duration = Duration::from_secs(5);
21
22fn state_dir() -> Result<PathBuf> {
23    let base = directories::BaseDirs::new()
24        .ok_or_else(|| AppError::Other("could not resolve XDG cache dir".into()))?;
25    Ok(base.cache_dir().join("ai-usagebar"))
26}
27
28fn state_path() -> Result<PathBuf> {
29    Ok(state_dir()?.join("active_vendor"))
30}
31
32/// Read the persisted active vendor, if any. `None` means "no override —
33/// callers fall back to [ui] primary or anthropic".
34pub fn read() -> Option<VendorId> {
35    read_from(&state_path().ok()?)
36}
37
38/// Read the persisted active vendor from an explicit path. The real-path
39/// [`read`] is a thin wrapper over this; tests use this directly with a
40/// `TempDir`-backed path so they never touch `~/.cache/ai-usagebar`.
41pub fn read_from(path: &Path) -> Option<VendorId> {
42    let raw = fs::read_to_string(path).ok()?;
43    parse_slug(raw.trim())
44}
45
46/// Persist `vendor` as the active one. Atomic.
47pub fn write(vendor: VendorId) -> Result<()> {
48    write_to(&state_path()?, vendor)
49}
50
51/// Persist `vendor` to an explicit path. Atomic. Test-friendly counterpart
52/// to [`write`] (mirrors [`crate::cache::Cache::at`] vs `for_vendor`).
53pub fn write_to(path: &Path, vendor: VendorId) -> Result<()> {
54    atomic_write(path, vendor.slug().as_bytes())
55}
56
57/// Cycle the active vendor by `delta` positions through `enabled` (which
58/// preserves canonical order). Wraps. If no state exists, starts at `start`
59/// (usually `[ui] primary` or anthropic).
60pub fn cycle(enabled: &[VendorId], start: VendorId, delta: i32) -> Result<VendorId> {
61    cycle_at(&state_path()?, enabled, start, delta)
62}
63
64/// The flock guarding a state file's read-modify-write, as a sibling of the
65/// state file itself (mirroring `Cache::lock_path`'s `.fetch.lock`).
66fn lock_path_for(state: &Path) -> PathBuf {
67    let mut p = state.as_os_str().to_os_string();
68    p.push(".lock");
69    PathBuf::from(p)
70}
71
72/// Cycle using an explicit state-file path. The real-path [`cycle`] is a thin
73/// wrapper over this; tests drive this with a `TempDir` path so the cycle +
74/// persistence logic is covered without reading or writing the real cache.
75pub fn cycle_at(
76    path: &Path,
77    enabled: &[VendorId],
78    start: VendorId,
79    delta: i32,
80) -> Result<VendorId> {
81    if enabled.is_empty() {
82        return Err(AppError::Other("no enabled vendors to cycle".into()));
83    }
84    // One flick of a scroll wheel fires several `--cycle-next` processes at
85    // once. An atomic *write* only guarantees no torn file — it does not stop
86    // two of them reading the same current vendor and both persisting the same
87    // next one, silently eating a step. The lock has to span read→compute→write.
88    let _lock = acquire_lock(&lock_path_for(path), LOCK_TIMEOUT)?;
89    let current = read_from(path)
90        .filter(|v| enabled.contains(v))
91        .unwrap_or(start);
92    let cur_idx = enabled.iter().position(|v| *v == current).unwrap_or(0);
93    let n = enabled.len() as i32;
94    let next_idx = ((cur_idx as i32 + delta).rem_euclid(n)) as usize;
95    let next = enabled[next_idx];
96    write_to(path, next)?;
97    Ok(next)
98}
99
100fn parse_slug(s: &str) -> Option<VendorId> {
101    match s {
102        "anthropic" => Some(VendorId::Anthropic),
103        "anthropic_api" => Some(VendorId::AnthropicApi),
104        "openai" => Some(VendorId::Openai),
105        "copilot" => Some(VendorId::Copilot),
106        "zai" => Some(VendorId::Zai),
107        "openrouter" => Some(VendorId::Openrouter),
108        "deepseek" => Some(VendorId::Deepseek),
109        "kimi" => Some(VendorId::Kimi),
110        "kilo" => Some(VendorId::Kilo),
111        "novita" => Some(VendorId::Novita),
112        "moonshot" => Some(VendorId::Moonshot),
113        "grok" => Some(VendorId::Grok),
114        "supergrok" => Some(VendorId::Supergrok),
115        "grokbot" => Some(VendorId::Grokbot),
116        "antigravity" => Some(VendorId::Antigravity),
117        "cursor" => Some(VendorId::Cursor),
118        "minimax" => Some(VendorId::Minimax),
119        "kiro" => Some(VendorId::Kiro),
120        "nous" => Some(VendorId::NousResearch),
121        "opencode-go" => Some(VendorId::OpenCodeGo),
122        "commandcode" => Some(VendorId::CommandCode),
123        "ollama" => Some(VendorId::Ollama),
124        "orcarouter" => Some(VendorId::OrcaRouter),
125        "modelstudio" => Some(VendorId::ModelStudio),
126        _ => None,
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use tempfile::TempDir;
134
135    // Deliberately excludes Deepseek so the not-in-enabled-set fallback test
136    // below has a real vendor to persist that is outside this cycle set.
137    const CYCLE_SET: [VendorId; 4] = [
138        VendorId::Anthropic,
139        VendorId::Openai,
140        VendorId::Zai,
141        VendorId::Openrouter,
142    ];
143
144    /// One flick of a scroll wheel fires several `--cycle-next` processes at
145    /// once. Before the lock spanned the read-modify-write, two of them could
146    /// read the same current vendor and both persist the same next one, so N
147    /// events advanced fewer than N steps. With the lock held across the whole
148    /// operation, every cycle observes its predecessor's write.
149    #[test]
150    fn concurrent_cycles_do_not_lose_a_step() {
151        let td = TempDir::new().unwrap();
152        let path = td.path().join("active_vendor");
153        let start = VendorId::Anthropic;
154
155        // Four threads, each doing one +1 step, over a 4-vendor set: if no step
156        // is lost the value returns exactly to `start`.
157        const THREADS: usize = 4;
158        std::thread::scope(|s| {
159            for _ in 0..THREADS {
160                s.spawn(|| {
161                    let _ = cycle_at(&path, &CYCLE_SET, start, 1);
162                });
163            }
164        });
165
166        let landed = read_from(&path).expect("a vendor must have been persisted");
167        assert_eq!(
168            landed,
169            start,
170            "{THREADS} single steps over {} vendors must return to the start; \
171             landing on {landed:?} means a step was lost to a race",
172            CYCLE_SET.len()
173        );
174    }
175
176    #[test]
177    fn parse_slug_round_trip() {
178        for id in VendorId::all() {
179            assert_eq!(parse_slug(id.slug()), Some(*id));
180        }
181    }
182
183    #[test]
184    fn parse_slug_unknown_returns_none() {
185        assert!(parse_slug("not-a-vendor").is_none());
186        assert!(parse_slug("").is_none());
187    }
188
189    #[test]
190    fn read_from_missing_or_garbage_returns_none() {
191        let td = TempDir::new().unwrap();
192        // Missing file → None.
193        assert!(read_from(&td.path().join("active_vendor")).is_none());
194        // Round-trip a real slug.
195        let path = td.path().join("active_vendor");
196        write_to(&path, VendorId::Zai).unwrap();
197        assert_eq!(read_from(&path), Some(VendorId::Zai));
198        // Garbage content → None (not a known slug).
199        fs::write(&path, "not-a-vendor").unwrap();
200        assert!(read_from(&path).is_none());
201    }
202
203    #[test]
204    fn cycle_at_persists_state_across_calls() {
205        let td = TempDir::new().unwrap();
206        let path = td.path().join("active_vendor");
207
208        // No state yet → starts at `start`, steps forward to Openai.
209        let v = cycle_at(&path, &CYCLE_SET, VendorId::Anthropic, 1).unwrap();
210        assert_eq!(v, VendorId::Openai);
211        assert_eq!(read_from(&path), Some(VendorId::Openai));
212
213        // Next forward step reads the persisted Openai → Zai.
214        let v = cycle_at(&path, &CYCLE_SET, VendorId::Anthropic, 1).unwrap();
215        assert_eq!(v, VendorId::Zai);
216        assert_eq!(read_from(&path), Some(VendorId::Zai));
217    }
218
219    #[test]
220    fn cycle_at_wraps_forward_and_backward() {
221        let td = TempDir::new().unwrap();
222        let path = td.path().join("active_vendor");
223        write_to(&path, VendorId::Anthropic).unwrap();
224
225        // backward from Anthropic wraps to Openrouter
226        assert_eq!(
227            cycle_at(&path, &CYCLE_SET, VendorId::Anthropic, -1).unwrap(),
228            VendorId::Openrouter
229        );
230        // forward from Openrouter wraps back to Anthropic
231        assert_eq!(
232            cycle_at(&path, &CYCLE_SET, VendorId::Anthropic, 1).unwrap(),
233            VendorId::Anthropic
234        );
235    }
236
237    #[test]
238    fn cycle_at_ignores_persisted_vendor_not_in_enabled_set() {
239        let td = TempDir::new().unwrap();
240        let path = td.path().join("active_vendor");
241        // Persist a vendor that isn't in the enabled set → fall back to `start`.
242        write_to(&path, VendorId::Deepseek).unwrap();
243        let enabled = [VendorId::Anthropic, VendorId::Openai];
244        // start=Openai (idx 1), +1 wraps to idx 0 = Anthropic.
245        let v = cycle_at(&path, &enabled, VendorId::Openai, 1).unwrap();
246        assert_eq!(v, VendorId::Anthropic);
247    }
248
249    #[test]
250    fn cycle_at_errors_on_empty_enabled() {
251        let td = TempDir::new().unwrap();
252        let path = td.path().join("active_vendor");
253        let res = cycle_at(&path, &[], VendorId::Anthropic, 1);
254        assert!(matches!(res, Err(AppError::Other(_))));
255    }
256}