use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
#[cfg(unix)]
use std::os::unix::process::CommandExt;
use super::errors::CodexBarError;
use super::types::CodexBarUsageResponse;
const DEFAULT_BIN: &str = "/usr/local/bin/codexbar";
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(15);
const POLL_INTERVAL: Duration = Duration::from_millis(50);
const TIMEOUT_EXIT_CODE: i32 = 4;
#[derive(Debug, Clone, Default)]
pub struct RunCodexBarUsageOptions {
pub bin_path: Option<String>,
pub account_label: Option<String>,
pub account_index: Option<i64>,
pub timeout: Option<Duration>,
}
fn which_codexbar() -> Option<PathBuf> {
let path = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&path) {
if dir.as_os_str().is_empty() {
continue;
}
let candidate = dir.join("codexbar");
if candidate.is_file() {
return Some(candidate);
}
}
None
}
fn resolve_bin_path(explicit: Option<&str>) -> PathBuf {
if let Some(p) = explicit
&& !p.is_empty()
{
return PathBuf::from(p);
}
if let Ok(env_bin) = std::env::var("SEHER_CODEXBAR_BIN")
&& !env_bin.is_empty()
{
return PathBuf::from(env_bin);
}
which_codexbar().unwrap_or_else(|| PathBuf::from(DEFAULT_BIN))
}
pub async fn run_codexbar_usage(
provider: &str,
opts: &RunCodexBarUsageOptions,
) -> Result<CodexBarUsageResponse, CodexBarError> {
let bin = resolve_bin_path(opts.bin_path.as_deref());
let timeout = opts.timeout.unwrap_or(DEFAULT_TIMEOUT);
let provider = provider.to_string();
let mut args: Vec<String> = vec![
"usage".into(),
"--format".into(),
"json".into(),
"--provider".into(),
provider.clone(),
];
if let Some(label) = &opts.account_label {
args.push("--account".into());
args.push(label.clone());
}
if let Some(idx) = opts.account_index {
args.push("--account-index".into());
args.push(idx.to_string());
}
let provider_for_blocking = provider.clone();
let raw = tokio::task::spawn_blocking(move || {
run_blocking(&bin, &args, timeout, &provider_for_blocking)
})
.await
.map_err(|e| CodexBarError::Spawn(e.to_string()))??;
parse_response(&raw.stdout, &raw.stderr, raw.code, &provider)
}
struct RawOutput {
stdout: String,
stderr: String,
code: Option<i32>,
}
fn spawn_reader<R: std::io::Read + Send + 'static>(
pipe: Option<R>,
) -> std::thread::JoinHandle<String> {
std::thread::spawn(move || {
let mut buf = String::new();
if let Some(mut p) = pipe {
let _ = p.read_to_string(&mut buf);
}
buf
})
}
fn build_command(bin: &Path, args: &[String]) -> Command {
let mut cmd = Command::new(bin);
cmd.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
#[cfg(unix)]
{
unsafe {
cmd.pre_exec(|| {
if libc::setsid() == -1 {
return Err(std::io::Error::last_os_error());
}
Ok(())
});
}
}
cmd
}
fn kill_process_group(child: &mut Child) {
#[cfg(unix)]
if let Ok(pid) = i32::try_from(child.id()) {
if unsafe { libc::kill(-pid, libc::SIGKILL) } == 0 {
return;
}
}
let _ = child.kill();
}
fn run_blocking(
bin: &Path,
args: &[String],
timeout: Duration,
provider: &str,
) -> Result<RawOutput, CodexBarError> {
let mut cmd = build_command(bin, args);
let mut child = match cmd.spawn() {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Err(CodexBarError::NotFound {
bin: bin.display().to_string(),
});
}
Err(e) => return Err(CodexBarError::Spawn(e.to_string())),
};
let stdout_reader = spawn_reader(child.stdout.take());
let stderr_reader = spawn_reader(child.stderr.take());
let timeout_ms = timeout.as_millis();
let deadline = Instant::now() + timeout;
let status = loop {
match child.try_wait() {
Ok(Some(status)) => break status,
Ok(None) => {
if Instant::now() >= deadline {
kill_process_group(&mut child);
let _ = child.wait();
let _ = stdout_reader.join();
let _ = stderr_reader.join();
return Err(CodexBarError::Timeout {
provider: provider.to_string(),
ms: timeout_ms,
});
}
std::thread::sleep(POLL_INTERVAL);
}
Err(e) => return Err(CodexBarError::Spawn(e.to_string())),
}
};
let stdout = stdout_reader.join().unwrap_or_default();
let stderr = stderr_reader.join().unwrap_or_default();
if status.code() == Some(TIMEOUT_EXIT_CODE) {
return Err(CodexBarError::Timeout {
provider: provider.to_string(),
ms: timeout_ms,
});
}
Ok(RawOutput {
stdout,
stderr,
code: status.code(),
})
}
fn parse_response(
stdout: &str,
stderr: &str,
code: Option<i32>,
provider: &str,
) -> Result<CodexBarUsageResponse, CodexBarError> {
if code != Some(0) {
return Err(CodexBarError::Exited {
code,
provider: provider.to_string(),
stderr: stderr.trim().to_string(),
});
}
let value: serde_json::Value =
serde_json::from_str(stdout).map_err(|e| CodexBarError::Parse(e.to_string()))?;
let entries = value
.as_array()
.ok_or_else(|| CodexBarError::NonArray(provider.to_string()))?;
for item in entries {
let Ok(entry) = serde_json::from_value::<CodexBarUsageResponse>(item.clone()) else {
continue;
};
if entry.provider == provider {
return Ok(entry);
}
}
Err(CodexBarError::NoEntry(provider.to_string()))
}
#[cfg(test)]
#[expect(clippy::expect_used, reason = "tests may panic on unexpected fixtures")]
mod tests {
use super::*;
#[test]
fn resolve_bin_path_prefers_explicit() {
let p = resolve_bin_path(Some("/custom/codexbar"));
assert_eq!(p, PathBuf::from("/custom/codexbar"));
}
#[test]
fn parse_response_unwraps_matching_provider() {
let stdout = r#"[{"provider":"claude","usage":{"primary":{"usedPercent":40}}}]"#;
let entry = parse_response(stdout, "", Some(0), "claude").expect("entry");
assert_eq!(entry.provider, "claude");
let primary = entry.usage.primary.expect("primary");
assert!((primary.used_percent - 40.0).abs() < f64::EPSILON);
}
#[test]
fn parse_response_no_entry_for_unknown_provider() {
let stdout = r#"[{"provider":"claude","usage":{}}]"#;
let err = parse_response(stdout, "", Some(0), "zai").expect_err("no entry");
assert!(matches!(err, CodexBarError::NoEntry(_)));
}
#[test]
fn parse_response_nonzero_exit_is_error() {
let err = parse_response("", "boom", Some(2), "claude").expect_err("exit err");
assert!(matches!(err, CodexBarError::Exited { .. }));
}
#[test]
fn parse_response_non_array_payload() {
let err = parse_response("{}", "", Some(0), "claude").expect_err("non-array");
assert!(matches!(err, CodexBarError::NonArray(_)));
}
#[cfg(unix)]
#[test]
fn build_command_detaches_child_into_own_session() {
let mut child = build_command(Path::new("/bin/sleep"), &["5".into()])
.spawn()
.expect("spawn sleep");
let child_pid = i32::try_from(child.id()).expect("pid fits in i32");
let session_of_child = unsafe { libc::getsid(child_pid) };
let session_of_parent = unsafe { libc::getsid(0) };
let _ = child.kill();
let _ = child.wait();
assert_eq!(
session_of_child, child_pid,
"child should lead a fresh session"
);
assert_ne!(
session_of_child, session_of_parent,
"child must not share our session"
);
}
}