1use std::path::{Path, PathBuf};
10use std::process::{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 run_blocking(
129 bin: &Path,
130 args: &[String],
131 timeout: Duration,
132 provider: &str,
133) -> Result<RawOutput, CodexBarError> {
134 let mut cmd = Command::new(bin);
135 cmd.args(args)
136 .stdin(Stdio::null())
137 .stdout(Stdio::piped())
138 .stderr(Stdio::piped());
139 #[cfg(unix)]
143 {
144 cmd.process_group(0);
145 }
146
147 let mut child = match cmd.spawn() {
148 Ok(c) => c,
149 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
150 return Err(CodexBarError::NotFound {
151 bin: bin.display().to_string(),
152 });
153 }
154 Err(e) => return Err(CodexBarError::Spawn(e.to_string())),
155 };
156
157 let stdout_reader = spawn_reader(child.stdout.take());
162 let stderr_reader = spawn_reader(child.stderr.take());
163
164 let timeout_ms = timeout.as_millis();
165 let deadline = Instant::now() + timeout;
166 let status = loop {
167 match child.try_wait() {
168 Ok(Some(status)) => break status,
169 Ok(None) => {
170 if Instant::now() >= deadline {
171 let _ = child.kill();
172 let _ = child.wait();
173 let _ = stdout_reader.join();
175 let _ = stderr_reader.join();
176 return Err(CodexBarError::Timeout {
177 provider: provider.to_string(),
178 ms: timeout_ms,
179 });
180 }
181 std::thread::sleep(POLL_INTERVAL);
182 }
183 Err(e) => return Err(CodexBarError::Spawn(e.to_string())),
184 }
185 };
186
187 let stdout = stdout_reader.join().unwrap_or_default();
188 let stderr = stderr_reader.join().unwrap_or_default();
189
190 if status.code() == Some(TIMEOUT_EXIT_CODE) {
192 return Err(CodexBarError::Timeout {
193 provider: provider.to_string(),
194 ms: timeout_ms,
195 });
196 }
197
198 Ok(RawOutput {
199 stdout,
200 stderr,
201 code: status.code(),
202 })
203}
204
205fn parse_response(
206 stdout: &str,
207 stderr: &str,
208 code: Option<i32>,
209 provider: &str,
210) -> Result<CodexBarUsageResponse, CodexBarError> {
211 if code != Some(0) {
212 return Err(CodexBarError::Exited {
213 code,
214 provider: provider.to_string(),
215 stderr: stderr.trim().to_string(),
216 });
217 }
218
219 let value: serde_json::Value =
220 serde_json::from_str(stdout).map_err(|e| CodexBarError::Parse(e.to_string()))?;
221 let entries = value
224 .as_array()
225 .ok_or_else(|| CodexBarError::NonArray(provider.to_string()))?;
226 for item in entries {
227 let Ok(entry) = serde_json::from_value::<CodexBarUsageResponse>(item.clone()) else {
228 continue;
229 };
230 if entry.provider == provider {
231 return Ok(entry);
232 }
233 }
234 Err(CodexBarError::NoEntry(provider.to_string()))
235}
236
237#[cfg(test)]
238#[expect(clippy::expect_used, reason = "tests may panic on unexpected fixtures")]
239mod tests {
240 use super::*;
241
242 #[test]
243 fn resolve_bin_path_prefers_explicit() {
244 let p = resolve_bin_path(Some("/custom/codexbar"));
245 assert_eq!(p, PathBuf::from("/custom/codexbar"));
246 }
247
248 #[test]
249 fn parse_response_unwraps_matching_provider() {
250 let stdout = r#"[{"provider":"claude","usage":{"primary":{"usedPercent":40}}}]"#;
251 let entry = parse_response(stdout, "", Some(0), "claude").expect("entry");
252 assert_eq!(entry.provider, "claude");
253 let primary = entry.usage.primary.expect("primary");
254 assert!((primary.used_percent - 40.0).abs() < f64::EPSILON);
255 }
256
257 #[test]
258 fn parse_response_no_entry_for_unknown_provider() {
259 let stdout = r#"[{"provider":"claude","usage":{}}]"#;
260 let err = parse_response(stdout, "", Some(0), "zai").expect_err("no entry");
261 assert!(matches!(err, CodexBarError::NoEntry(_)));
262 }
263
264 #[test]
265 fn parse_response_nonzero_exit_is_error() {
266 let err = parse_response("", "boom", Some(2), "claude").expect_err("exit err");
267 assert!(matches!(err, CodexBarError::Exited { .. }));
268 }
269
270 #[test]
271 fn parse_response_non_array_payload() {
272 let err = parse_response("{}", "", Some(0), "claude").expect_err("non-array");
273 assert!(matches!(err, CodexBarError::NonArray(_)));
274 }
275}