1use std::path::{Path, PathBuf};
10use std::process::{Command, Stdio};
11use std::time::{Duration, Instant};
12
13use super::errors::CodexBarError;
14use super::types::CodexBarUsageResponse;
15
16const DEFAULT_BIN: &str = "/usr/local/bin/codexbar";
17const DEFAULT_TIMEOUT: Duration = Duration::from_secs(15);
18const POLL_INTERVAL: Duration = Duration::from_millis(50);
19const TIMEOUT_EXIT_CODE: i32 = 4;
21
22#[derive(Debug, Clone, Default)]
24pub struct RunCodexBarUsageOptions {
25 pub bin_path: Option<String>,
27 pub account_label: Option<String>,
29 pub account_index: Option<i64>,
31 pub timeout: Option<Duration>,
33}
34
35fn which_codexbar() -> Option<PathBuf> {
36 let path = std::env::var_os("PATH")?;
37 for dir in std::env::split_paths(&path) {
38 if dir.as_os_str().is_empty() {
39 continue;
40 }
41 let candidate = dir.join("codexbar");
42 if candidate.is_file() {
43 return Some(candidate);
44 }
45 }
46 None
47}
48
49fn resolve_bin_path(explicit: Option<&str>) -> PathBuf {
50 if let Some(p) = explicit
51 && !p.is_empty()
52 {
53 return PathBuf::from(p);
54 }
55 if let Ok(env_bin) = std::env::var("SEHER_CODEXBAR_BIN")
56 && !env_bin.is_empty()
57 {
58 return PathBuf::from(env_bin);
59 }
60 which_codexbar().unwrap_or_else(|| PathBuf::from(DEFAULT_BIN))
61}
62
63pub async fn run_codexbar_usage(
71 provider: &str,
72 opts: &RunCodexBarUsageOptions,
73) -> Result<CodexBarUsageResponse, CodexBarError> {
74 let bin = resolve_bin_path(opts.bin_path.as_deref());
75 let timeout = opts.timeout.unwrap_or(DEFAULT_TIMEOUT);
76 let provider = provider.to_string();
77
78 let mut args: Vec<String> = vec![
79 "usage".into(),
80 "--format".into(),
81 "json".into(),
82 "--provider".into(),
83 provider.clone(),
84 ];
85 if let Some(label) = &opts.account_label {
86 args.push("--account".into());
87 args.push(label.clone());
88 }
89 if let Some(idx) = opts.account_index {
90 args.push("--account-index".into());
91 args.push(idx.to_string());
92 }
93
94 let provider_for_blocking = provider.clone();
95 let raw = tokio::task::spawn_blocking(move || {
96 run_blocking(&bin, &args, timeout, &provider_for_blocking)
97 })
98 .await
99 .map_err(|e| CodexBarError::Spawn(e.to_string()))??;
100
101 parse_response(&raw.stdout, &raw.stderr, raw.code, &provider)
102}
103
104struct RawOutput {
105 stdout: String,
106 stderr: String,
107 code: Option<i32>,
108}
109
110fn spawn_reader<R: std::io::Read + Send + 'static>(
114 pipe: Option<R>,
115) -> std::thread::JoinHandle<String> {
116 std::thread::spawn(move || {
117 let mut buf = String::new();
118 if let Some(mut p) = pipe {
119 let _ = p.read_to_string(&mut buf);
120 }
121 buf
122 })
123}
124
125fn run_blocking(
126 bin: &Path,
127 args: &[String],
128 timeout: Duration,
129 provider: &str,
130) -> Result<RawOutput, CodexBarError> {
131 let mut child = match Command::new(bin)
132 .args(args)
133 .stdin(Stdio::null())
134 .stdout(Stdio::piped())
135 .stderr(Stdio::piped())
136 .spawn()
137 {
138 Ok(c) => c,
139 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
140 return Err(CodexBarError::NotFound {
141 bin: bin.display().to_string(),
142 });
143 }
144 Err(e) => return Err(CodexBarError::Spawn(e.to_string())),
145 };
146
147 let stdout_reader = spawn_reader(child.stdout.take());
152 let stderr_reader = spawn_reader(child.stderr.take());
153
154 let timeout_ms = timeout.as_millis();
155 let deadline = Instant::now() + timeout;
156 let status = loop {
157 match child.try_wait() {
158 Ok(Some(status)) => break status,
159 Ok(None) => {
160 if Instant::now() >= deadline {
161 let _ = child.kill();
162 let _ = child.wait();
163 let _ = stdout_reader.join();
165 let _ = stderr_reader.join();
166 return Err(CodexBarError::Timeout {
167 provider: provider.to_string(),
168 ms: timeout_ms,
169 });
170 }
171 std::thread::sleep(POLL_INTERVAL);
172 }
173 Err(e) => return Err(CodexBarError::Spawn(e.to_string())),
174 }
175 };
176
177 let stdout = stdout_reader.join().unwrap_or_default();
178 let stderr = stderr_reader.join().unwrap_or_default();
179
180 if status.code() == Some(TIMEOUT_EXIT_CODE) {
182 return Err(CodexBarError::Timeout {
183 provider: provider.to_string(),
184 ms: timeout_ms,
185 });
186 }
187
188 Ok(RawOutput {
189 stdout,
190 stderr,
191 code: status.code(),
192 })
193}
194
195fn parse_response(
196 stdout: &str,
197 stderr: &str,
198 code: Option<i32>,
199 provider: &str,
200) -> Result<CodexBarUsageResponse, CodexBarError> {
201 if code != Some(0) {
202 return Err(CodexBarError::Exited {
203 code,
204 provider: provider.to_string(),
205 stderr: stderr.trim().to_string(),
206 });
207 }
208
209 let value: serde_json::Value =
210 serde_json::from_str(stdout).map_err(|e| CodexBarError::Parse(e.to_string()))?;
211 let entries = value
214 .as_array()
215 .ok_or_else(|| CodexBarError::NonArray(provider.to_string()))?;
216 for item in entries {
217 let Ok(entry) = serde_json::from_value::<CodexBarUsageResponse>(item.clone()) else {
218 continue;
219 };
220 if entry.provider == provider {
221 return Ok(entry);
222 }
223 }
224 Err(CodexBarError::NoEntry(provider.to_string()))
225}
226
227#[cfg(test)]
228#[expect(clippy::expect_used, reason = "tests may panic on unexpected fixtures")]
229mod tests {
230 use super::*;
231
232 #[test]
233 fn resolve_bin_path_prefers_explicit() {
234 let p = resolve_bin_path(Some("/custom/codexbar"));
235 assert_eq!(p, PathBuf::from("/custom/codexbar"));
236 }
237
238 #[test]
239 fn parse_response_unwraps_matching_provider() {
240 let stdout = r#"[{"provider":"claude","usage":{"primary":{"usedPercent":40}}}]"#;
241 let entry = parse_response(stdout, "", Some(0), "claude").expect("entry");
242 assert_eq!(entry.provider, "claude");
243 let primary = entry.usage.primary.expect("primary");
244 assert!((primary.used_percent - 40.0).abs() < f64::EPSILON);
245 }
246
247 #[test]
248 fn parse_response_no_entry_for_unknown_provider() {
249 let stdout = r#"[{"provider":"claude","usage":{}}]"#;
250 let err = parse_response(stdout, "", Some(0), "zai").expect_err("no entry");
251 assert!(matches!(err, CodexBarError::NoEntry(_)));
252 }
253
254 #[test]
255 fn parse_response_nonzero_exit_is_error() {
256 let err = parse_response("", "boom", Some(2), "claude").expect_err("exit err");
257 assert!(matches!(err, CodexBarError::Exited { .. }));
258 }
259
260 #[test]
261 fn parse_response_non_array_payload() {
262 let err = parse_response("{}", "", Some(0), "claude").expect_err("non-array");
263 assert!(matches!(err, CodexBarError::NonArray(_)));
264 }
265}