1use 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
16const 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
32pub fn read() -> Option<VendorId> {
35 read_from(&state_path().ok()?)
36}
37
38pub fn read_from(path: &Path) -> Option<VendorId> {
42 let raw = fs::read_to_string(path).ok()?;
43 parse_slug(raw.trim())
44}
45
46pub fn write(vendor: VendorId) -> Result<()> {
48 write_to(&state_path()?, vendor)
49}
50
51pub fn write_to(path: &Path, vendor: VendorId) -> Result<()> {
54 atomic_write(path, vendor.slug().as_bytes())
55}
56
57pub fn cycle(enabled: &[VendorId], start: VendorId, delta: i32) -> Result<VendorId> {
61 cycle_at(&state_path()?, enabled, start, delta)
62}
63
64fn 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
72pub 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 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 "deepinfra" => Some(VendorId::Deepinfra),
110 "kimi" => Some(VendorId::Kimi),
111 "kilo" => Some(VendorId::Kilo),
112 "novita" => Some(VendorId::Novita),
113 "moonshot" => Some(VendorId::Moonshot),
114 "grok" => Some(VendorId::Grok),
115 "supergrok" => Some(VendorId::Supergrok),
116 "grokbot" => Some(VendorId::Grokbot),
117 "antigravity" => Some(VendorId::Antigravity),
118 "cursor" => Some(VendorId::Cursor),
119 "minimax" => Some(VendorId::Minimax),
120 "kiro" => Some(VendorId::Kiro),
121 "nous" => Some(VendorId::NousResearch),
122 "opencode-go" => Some(VendorId::OpenCodeGo),
123 "commandcode" => Some(VendorId::CommandCode),
124 "ollama" => Some(VendorId::Ollama),
125 "orcarouter" => Some(VendorId::OrcaRouter),
126 "modelstudio" => Some(VendorId::ModelStudio),
127 _ => None,
128 }
129}
130
131#[cfg(test)]
132mod tests {
133 use super::*;
134 use tempfile::TempDir;
135
136 const CYCLE_SET: [VendorId; 4] = [
139 VendorId::Anthropic,
140 VendorId::Openai,
141 VendorId::Zai,
142 VendorId::Openrouter,
143 ];
144
145 #[test]
151 fn concurrent_cycles_do_not_lose_a_step() {
152 let td = TempDir::new().unwrap();
153 let path = td.path().join("active_vendor");
154 let start = VendorId::Anthropic;
155
156 const THREADS: usize = 4;
159 std::thread::scope(|s| {
160 for _ in 0..THREADS {
161 s.spawn(|| {
162 let _ = cycle_at(&path, &CYCLE_SET, start, 1);
163 });
164 }
165 });
166
167 let landed = read_from(&path).expect("a vendor must have been persisted");
168 assert_eq!(
169 landed,
170 start,
171 "{THREADS} single steps over {} vendors must return to the start; \
172 landing on {landed:?} means a step was lost to a race",
173 CYCLE_SET.len()
174 );
175 }
176
177 #[test]
178 fn parse_slug_round_trip() {
179 for id in VendorId::all() {
180 assert_eq!(parse_slug(id.slug()), Some(*id));
181 }
182 }
183
184 #[test]
185 fn parse_slug_unknown_returns_none() {
186 assert!(parse_slug("not-a-vendor").is_none());
187 assert!(parse_slug("").is_none());
188 }
189
190 #[test]
191 fn read_from_missing_or_garbage_returns_none() {
192 let td = TempDir::new().unwrap();
193 assert!(read_from(&td.path().join("active_vendor")).is_none());
195 let path = td.path().join("active_vendor");
197 write_to(&path, VendorId::Zai).unwrap();
198 assert_eq!(read_from(&path), Some(VendorId::Zai));
199 fs::write(&path, "not-a-vendor").unwrap();
201 assert!(read_from(&path).is_none());
202 }
203
204 #[test]
205 fn cycle_at_persists_state_across_calls() {
206 let td = TempDir::new().unwrap();
207 let path = td.path().join("active_vendor");
208
209 let v = cycle_at(&path, &CYCLE_SET, VendorId::Anthropic, 1).unwrap();
211 assert_eq!(v, VendorId::Openai);
212 assert_eq!(read_from(&path), Some(VendorId::Openai));
213
214 let v = cycle_at(&path, &CYCLE_SET, VendorId::Anthropic, 1).unwrap();
216 assert_eq!(v, VendorId::Zai);
217 assert_eq!(read_from(&path), Some(VendorId::Zai));
218 }
219
220 #[test]
221 fn cycle_at_wraps_forward_and_backward() {
222 let td = TempDir::new().unwrap();
223 let path = td.path().join("active_vendor");
224 write_to(&path, VendorId::Anthropic).unwrap();
225
226 assert_eq!(
228 cycle_at(&path, &CYCLE_SET, VendorId::Anthropic, -1).unwrap(),
229 VendorId::Openrouter
230 );
231 assert_eq!(
233 cycle_at(&path, &CYCLE_SET, VendorId::Anthropic, 1).unwrap(),
234 VendorId::Anthropic
235 );
236 }
237
238 #[test]
239 fn cycle_at_ignores_persisted_vendor_not_in_enabled_set() {
240 let td = TempDir::new().unwrap();
241 let path = td.path().join("active_vendor");
242 write_to(&path, VendorId::Deepseek).unwrap();
244 let enabled = [VendorId::Anthropic, VendorId::Openai];
245 let v = cycle_at(&path, &enabled, VendorId::Openai, 1).unwrap();
247 assert_eq!(v, VendorId::Anthropic);
248 }
249
250 #[test]
251 fn cycle_at_errors_on_empty_enabled() {
252 let td = TempDir::new().unwrap();
253 let path = td.path().join("active_vendor");
254 let res = cycle_at(&path, &[], VendorId::Anthropic, 1);
255 assert!(matches!(res, Err(AppError::Other(_))));
256 }
257}