1use std::path::{Path, PathBuf};
10use std::process::{Child, Command, Stdio};
11use std::time::{Duration, Instant};
12
13#[cfg(unix)]
14use std::os::unix::process::CommandExt;
15
16use super::errors::CodexBarError;
17use super::types::CodexBarUsageResponse;
18
19const DEFAULT_BIN: &str = "/usr/local/bin/codexbar";
20const DEFAULT_TIMEOUT: Duration = Duration::from_secs(15);
21const POLL_INTERVAL: Duration = Duration::from_millis(50);
22const TIMEOUT_EXIT_CODE: i32 = 4;
24
25#[derive(Debug, Clone, Default)]
27pub struct RunCodexBarUsageOptions {
28 pub bin_path: Option<String>,
30 pub account_label: Option<String>,
32 pub account_index: Option<i64>,
34 pub timeout: Option<Duration>,
36}
37
38fn which_codexbar() -> Option<PathBuf> {
39 let path = std::env::var_os("PATH")?;
40 for dir in std::env::split_paths(&path) {
41 if dir.as_os_str().is_empty() {
42 continue;
43 }
44 let candidate = dir.join("codexbar");
45 if candidate.is_file() {
46 return Some(candidate);
47 }
48 }
49 None
50}
51
52fn resolve_bin_path(explicit: Option<&str>) -> PathBuf {
53 if let Some(p) = explicit
54 && !p.is_empty()
55 {
56 return PathBuf::from(p);
57 }
58 if let Ok(env_bin) = std::env::var("SEHER_CODEXBAR_BIN")
59 && !env_bin.is_empty()
60 {
61 return PathBuf::from(env_bin);
62 }
63 which_codexbar().unwrap_or_else(|| PathBuf::from(DEFAULT_BIN))
64}
65
66pub async fn run_codexbar_usage(
74 provider: &str,
75 opts: &RunCodexBarUsageOptions,
76) -> Result<CodexBarUsageResponse, CodexBarError> {
77 let bin = resolve_bin_path(opts.bin_path.as_deref());
78 let timeout = opts.timeout.unwrap_or(DEFAULT_TIMEOUT);
79 let provider = provider.to_string();
80
81 let mut args: Vec<String> = vec![
82 "usage".into(),
83 "--format".into(),
84 "json".into(),
85 "--provider".into(),
86 provider.clone(),
87 ];
88 if let Some(label) = &opts.account_label {
89 args.push("--account".into());
90 args.push(label.clone());
91 }
92 if let Some(idx) = opts.account_index {
93 args.push("--account-index".into());
94 args.push(idx.to_string());
95 }
96
97 let provider_for_blocking = provider.clone();
98 let raw = tokio::task::spawn_blocking(move || {
99 run_blocking(&bin, &args, timeout, &provider_for_blocking)
100 })
101 .await
102 .map_err(|e| CodexBarError::Spawn(e.to_string()))??;
103
104 parse_response(&raw.stdout, &raw.stderr, raw.code, &provider)
105}
106
107struct RawOutput {
108 stdout: String,
109 stderr: String,
110 code: Option<i32>,
111}
112
113fn spawn_reader<R: std::io::Read + Send + 'static>(
117 pipe: Option<R>,
118) -> std::thread::JoinHandle<String> {
119 std::thread::spawn(move || {
120 let mut buf = String::new();
121 if let Some(mut p) = pipe {
122 let _ = p.read_to_string(&mut buf);
123 }
124 buf
125 })
126}
127
128fn build_command(bin: &Path, args: &[String]) -> Command {
141 let mut cmd = Command::new(bin);
142 cmd.args(args)
143 .stdin(Stdio::null())
144 .stdout(Stdio::piped())
145 .stderr(Stdio::piped());
146 #[cfg(unix)]
147 {
148 unsafe {
152 cmd.pre_exec(|| {
153 if libc::setsid() == -1 {
157 return Err(std::io::Error::last_os_error());
158 }
159 Ok(())
160 });
161 }
162 }
163 cmd
164}
165
166fn kill_process_group(child: &mut Child) {
173 #[cfg(unix)]
174 if let Ok(pid) = i32::try_from(child.id()) {
175 if unsafe { libc::kill(-pid, libc::SIGKILL) } == 0 {
177 return;
178 }
179 }
180 let _ = child.kill();
181}
182
183fn run_blocking(
184 bin: &Path,
185 args: &[String],
186 timeout: Duration,
187 provider: &str,
188) -> Result<RawOutput, CodexBarError> {
189 let mut cmd = build_command(bin, args);
190
191 let mut child = match cmd.spawn() {
192 Ok(c) => c,
193 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
194 return Err(CodexBarError::NotFound {
195 bin: bin.display().to_string(),
196 });
197 }
198 Err(e) => return Err(CodexBarError::Spawn(e.to_string())),
199 };
200
201 let stdout_reader = spawn_reader(child.stdout.take());
206 let stderr_reader = spawn_reader(child.stderr.take());
207
208 let timeout_ms = timeout.as_millis();
209 let deadline = Instant::now() + timeout;
210 let status = loop {
211 match child.try_wait() {
212 Ok(Some(status)) => break status,
213 Ok(None) => {
214 if Instant::now() >= deadline {
215 kill_process_group(&mut child);
216 let _ = child.wait();
217 let _ = stdout_reader.join();
219 let _ = stderr_reader.join();
220 return Err(CodexBarError::Timeout {
221 provider: provider.to_string(),
222 ms: timeout_ms,
223 });
224 }
225 std::thread::sleep(POLL_INTERVAL);
226 }
227 Err(e) => return Err(CodexBarError::Spawn(e.to_string())),
228 }
229 };
230
231 let stdout = stdout_reader.join().unwrap_or_default();
232 let stderr = stderr_reader.join().unwrap_or_default();
233
234 if status.code() == Some(TIMEOUT_EXIT_CODE) {
236 return Err(CodexBarError::Timeout {
237 provider: provider.to_string(),
238 ms: timeout_ms,
239 });
240 }
241
242 Ok(RawOutput {
243 stdout,
244 stderr,
245 code: status.code(),
246 })
247}
248
249fn parse_response(
250 stdout: &str,
251 stderr: &str,
252 code: Option<i32>,
253 provider: &str,
254) -> Result<CodexBarUsageResponse, CodexBarError> {
255 if code != Some(0) {
256 return Err(CodexBarError::Exited {
257 code,
258 provider: provider.to_string(),
259 stderr: stderr.trim().to_string(),
260 });
261 }
262
263 let value: serde_json::Value =
264 serde_json::from_str(stdout).map_err(|e| CodexBarError::Parse(e.to_string()))?;
265 let entries = value
268 .as_array()
269 .ok_or_else(|| CodexBarError::NonArray(provider.to_string()))?;
270 for item in entries {
271 let Ok(entry) = serde_json::from_value::<CodexBarUsageResponse>(item.clone()) else {
272 continue;
273 };
274 if entry.provider == provider {
275 return Ok(entry);
276 }
277 }
278 Err(CodexBarError::NoEntry(provider.to_string()))
279}
280
281#[cfg(test)]
282#[expect(clippy::expect_used, reason = "tests may panic on unexpected fixtures")]
283mod tests {
284 use super::*;
285
286 #[test]
287 fn resolve_bin_path_prefers_explicit() {
288 let p = resolve_bin_path(Some("/custom/codexbar"));
289 assert_eq!(p, PathBuf::from("/custom/codexbar"));
290 }
291
292 #[test]
293 fn parse_response_unwraps_matching_provider() {
294 let stdout = r#"[{"provider":"claude","usage":{"primary":{"usedPercent":40}}}]"#;
295 let entry = parse_response(stdout, "", Some(0), "claude").expect("entry");
296 assert_eq!(entry.provider, "claude");
297 let primary = entry.usage.primary.expect("primary");
298 assert!((primary.used_percent - 40.0).abs() < f64::EPSILON);
299 }
300
301 #[test]
302 fn parse_response_no_entry_for_unknown_provider() {
303 let stdout = r#"[{"provider":"claude","usage":{}}]"#;
304 let err = parse_response(stdout, "", Some(0), "zai").expect_err("no entry");
305 assert!(matches!(err, CodexBarError::NoEntry(_)));
306 }
307
308 #[test]
309 fn parse_response_nonzero_exit_is_error() {
310 let err = parse_response("", "boom", Some(2), "claude").expect_err("exit err");
311 assert!(matches!(err, CodexBarError::Exited { .. }));
312 }
313
314 #[test]
315 fn parse_response_non_array_payload() {
316 let err = parse_response("{}", "", Some(0), "claude").expect_err("non-array");
317 assert!(matches!(err, CodexBarError::NonArray(_)));
318 }
319
320 #[cfg(unix)]
325 #[test]
326 fn build_command_detaches_child_into_own_session() {
327 let mut child = build_command(Path::new("/bin/sleep"), &["5".into()])
328 .spawn()
329 .expect("spawn sleep");
330 let child_pid = i32::try_from(child.id()).expect("pid fits in i32");
333 let session_of_child = unsafe { libc::getsid(child_pid) };
335 let session_of_parent = unsafe { libc::getsid(0) };
337 let _ = child.kill();
338 let _ = child.wait();
339 assert_eq!(
340 session_of_child, child_pid,
341 "child should lead a fresh session"
342 );
343 assert_ne!(
344 session_of_child, session_of_parent,
345 "child must not share our session"
346 );
347 }
348}