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