1use crate::arkuse::window::days_from_civil;
20use serde::Deserialize;
21use std::process::{Command, Stdio};
22use std::time::{Duration, Instant};
23
24pub const CLI_TIMEOUT_SECS: u64 = 20;
27
28#[derive(Debug, Clone)]
29pub struct Meter {
30 pub status: String,
32 pub percent: f64,
34 pub used: f64,
35 pub quota: f64,
36 pub resets_at: Option<String>,
38 reset_ms: Option<i64>,
39 offset_secs: i64,
40}
41
42impl Meter {
43 pub fn from_parts(
44 status: &str,
45 percent: f64,
46 used: f64,
47 quota: f64,
48 resets_at: Option<&str>,
49 offset_secs: Option<i64>,
50 ) -> Self {
51 let parsed = resets_at.and_then(parse_instant);
52 Self {
53 status: status.into(),
54 percent,
55 used,
56 quota,
57 resets_at: resets_at.map(str::to_string),
58 reset_ms: parsed.map(|(ms, _)| ms),
59 offset_secs: offset_secs
60 .or(parsed.map(|(_, off)| off))
61 .unwrap_or(8 * 3600),
62 }
63 }
64
65 pub fn reset_ms(&self) -> Option<i64> {
66 self.reset_ms
67 }
68
69 pub fn offset_secs(&self) -> i64 {
71 self.offset_secs
72 }
73
74 pub fn exceeded(&self) -> bool {
75 self.percent >= 100.0
76 }
77
78 pub fn remaining(&self) -> f64 {
80 (self.quota - self.used).max(0.0)
81 }
82}
83
84#[derive(Debug, Clone)]
85pub struct Usage {
86 pub five_hour: Meter,
87 pub daily: Option<Meter>,
90 pub weekly: Meter,
91 pub monthly: Meter,
92 pub tier: Option<String>,
95 pub profile: String,
96}
97
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub enum Unavailable {
100 Disabled,
102 NoProfile,
104 CliMissing,
106 Failed(String),
107}
108
109impl Unavailable {
110 pub fn reason(&self) -> String {
111 match self {
112 Unavailable::Disabled => "API disabled (--no-api)".into(),
113 Unavailable::NoProfile => "no agent-plan arkcli profile".into(),
114 Unavailable::CliMissing => "arkcli not found on PATH".into(),
115 Unavailable::Failed(why) => format!("arkcli: {why}"),
116 }
117 }
118}
119
120#[derive(Deserialize)]
121struct ProfileList {
122 profiles: Vec<ProfileEntry>,
123}
124
125#[derive(Deserialize)]
126struct ProfileEntry {
127 name: String,
128 #[serde(rename = "type")]
129 kind: Option<String>,
130 #[serde(rename = "plan_tier")]
131 tier: Option<String>,
132}
133
134pub fn resolve(explicit: Option<&str>) -> Option<(String, Option<String>)> {
138 let name = explicit.map(str::to_string).or_else(|| {
139 std::env::var("ARKUSE_PROFILE")
140 .ok()
141 .filter(|v| !v.is_empty())
142 });
143 let listed = profiles();
144 if let Some(name) = name {
145 let tier = listed
146 .iter()
147 .find(|(n, _, _)| n == &name)
148 .and_then(|(_, _, t)| t.clone());
149 return Some((name, tier));
150 }
151 listed
152 .into_iter()
153 .find(|(_, kind, _)| kind.as_deref() == Some("agent-plan"))
154 .map(|(name, _, tier)| (name, tier))
155}
156
157fn profiles() -> Vec<(String, Option<String>, Option<String>)> {
158 let Ok(out) = run_cli(&["profile", "list", "--format", "json"]) else {
159 return Vec::new();
160 };
161 serde_json::from_str::<ProfileList>(&out)
162 .map(|list| {
163 list.profiles
164 .into_iter()
165 .map(|p| (p.name, p.kind, p.tier))
166 .collect()
167 })
168 .unwrap_or_default()
169}
170
171#[derive(Deserialize)]
172struct UsageEnvelope {
173 items: Vec<UsageItem>,
174}
175
176#[derive(Deserialize)]
177struct UsageItem {
178 edition: Option<String>,
179 tier: Option<String>,
180 subscribed: Option<bool>,
181 #[serde(default)]
182 periods: Vec<Period>,
183}
184
185#[derive(Deserialize)]
186struct Period {
187 label: String,
188 #[serde(default)]
194 used: Option<f64>,
195 #[serde(default)]
196 total: f64,
197 #[serde(default)]
198 percent: Option<f64>,
199 reset_at: Option<String>,
200}
201
202impl Period {
203 fn figures(&self) -> (f64, f64) {
206 let used = self
207 .used
208 .unwrap_or_else(|| self.total * self.percent.unwrap_or(0.0) / 100.0);
209 let percent = self.percent.unwrap_or_else(|| {
210 if self.total > 0.0 {
211 used / self.total * 100.0
212 } else {
213 0.0
214 }
215 });
216 (used, percent)
217 }
218}
219
220pub fn fetch(profile: &str) -> Result<Usage, Unavailable> {
222 let raw = run_cli(&[
223 "usage",
224 "plan",
225 "--profile",
226 profile,
227 "--product",
228 "agent-plan",
229 "--format",
230 "json",
231 ])?;
232 parse_usage(&raw, profile)
233}
234
235fn parse_usage(raw: &str, profile: &str) -> Result<Usage, Unavailable> {
236 let envelope: UsageEnvelope =
237 serde_json::from_str(raw).map_err(|e| Unavailable::Failed(format!("parse: {e}")))?;
238 let item = envelope
240 .items
241 .iter()
242 .find(|i| i.edition.as_deref() == Some("personal"))
243 .or_else(|| envelope.items.first())
244 .ok_or_else(|| Unavailable::Failed("no plan items in the answer".into()))?;
245 if item.subscribed == Some(false) {
246 return Err(Unavailable::Failed("no active subscription".into()));
247 }
248 let meter = |label: &str| -> Option<Meter> {
249 item.periods.iter().find(|p| p.label == label).map(|p| {
250 let (used, percent) = p.figures();
251 let status = if percent >= 100.0 { "exceeded" } else { "ok" };
252 Meter::from_parts(status, percent, used, p.total, p.reset_at.as_deref(), None)
253 })
254 };
255 let missing = |label: &str| Unavailable::Failed(format!("the answer has no {label} window"));
256 Ok(Usage {
257 five_hour: meter("5h").ok_or_else(|| missing("5h"))?,
258 daily: meter("daily"),
259 weekly: meter("weekly").ok_or_else(|| missing("weekly"))?,
260 monthly: meter("monthly").ok_or_else(|| missing("monthly"))?,
261 tier: item.tier.clone(),
262 profile: profile.into(),
263 })
264}
265
266fn run_cli(args: &[&str]) -> Result<String, Unavailable> {
269 let mut child = Command::new("arkcli")
270 .args(args)
271 .stdin(Stdio::null())
272 .stdout(Stdio::piped())
273 .stderr(Stdio::piped())
274 .spawn()
275 .map_err(|e| {
276 if e.kind() == std::io::ErrorKind::NotFound {
277 Unavailable::CliMissing
278 } else {
279 Unavailable::Failed(e.to_string())
280 }
281 })?;
282 let started = Instant::now();
283 loop {
284 match child.try_wait() {
285 Ok(Some(status)) => {
286 let out = child
287 .wait_with_output()
288 .map_err(|e| Unavailable::Failed(format!("read stdout: {e}")))?;
289 if !status.success() {
290 let err = String::from_utf8_lossy(&out.stderr);
291 return Err(Unavailable::Failed(cli_error(&err)));
292 }
293 return Ok(String::from_utf8_lossy(&out.stdout).into_owned());
294 }
295 Ok(None) => {
296 if started.elapsed() > Duration::from_secs(CLI_TIMEOUT_SECS) {
297 let _ = child.kill();
298 return Err(Unavailable::Failed(format!(
299 "timed out after {CLI_TIMEOUT_SECS}s"
300 )));
301 }
302 std::thread::sleep(Duration::from_millis(50));
303 }
304 Err(e) => return Err(Unavailable::Failed(e.to_string())),
305 }
306 }
307}
308
309fn cli_error(stderr: &str) -> String {
314 #[derive(Deserialize)]
315 struct Envelope {
316 error: Option<Inner>,
317 }
318 #[derive(Deserialize)]
319 struct Inner {
320 message: Option<String>,
321 }
322 if let Ok(env) = serde_json::from_str::<Envelope>(stderr.trim()) {
323 if let Some(text) = env.error.and_then(|e| e.message) {
324 if !text.trim().is_empty() {
325 return text.trim().to_string();
326 }
327 }
328 }
329 stderr
330 .lines()
331 .map(str::trim)
332 .rfind(|l| !l.is_empty())
333 .unwrap_or("failed")
334 .to_string()
335}
336
337pub fn parse_instant(value: &str) -> Option<(i64, i64)> {
340 let (date, rest) = value.split_once('T')?;
341 let (time, offset) = match rest.find(['+', '-']) {
342 Some(at) => (&rest[..at], &rest[at..]),
343 None => (rest.trim_end_matches('Z'), "+00:00"),
344 };
345 let mut dp = date.split('-');
346 let y: i64 = dp.next()?.parse().ok()?;
347 let m: i64 = dp.next()?.parse().ok()?;
348 let d: i64 = dp.next()?.parse().ok()?;
349 let time = time.trim_end_matches('Z');
350 let mut tp = time.split(':');
351 let hh: i64 = tp.next().unwrap_or("0").parse().unwrap_or(0);
352 let mm: i64 = tp.next().unwrap_or("0").parse().unwrap_or(0);
353 let ss: i64 = tp
354 .next()
355 .unwrap_or("0")
356 .split('.')
357 .next()?
358 .parse()
359 .unwrap_or(0);
360 let (sign, off) = match offset.strip_prefix('-') {
361 Some(rest) => (-1, rest),
362 None => (1, offset.trim_start_matches('+')),
363 };
364 let mut op = off.split(':');
365 let oh: i64 = op.next().unwrap_or("0").parse().unwrap_or(0);
366 let om: i64 = op.next().unwrap_or("0").parse().unwrap_or(0);
367 let offset_secs = sign * (oh * 3600 + om * 60);
368 let local_secs = days_from_civil(y, m, d) * 86_400 + hh * 3600 + mm * 60 + ss;
369 Some(((local_secs - offset_secs) * 1000, offset_secs))
370}
371
372#[cfg(test)]
373mod tests {
374 use super::*;
375
376 #[test]
377 fn parses_the_servers_offset_instant() {
378 let (ms, off) = parse_instant("2026-10-20T00:24:37+08:00").unwrap();
379 assert_eq!(off, 8 * 3600);
380 assert_eq!(
381 ms,
382 (days_from_civil(2026, 10, 19) * 86_400 + 16 * 3600 + 24 * 60 + 37) * 1000
383 );
384 let (ms, off) = parse_instant("2026-10-20T00:24:37Z").unwrap();
385 assert_eq!(off, 0);
386 assert_eq!(
387 ms,
388 (days_from_civil(2026, 10, 20) * 86_400 + 24 * 60 + 37) * 1000
389 );
390 }
391
392 #[test]
393 fn reads_the_four_windows_and_the_tier() {
394 let raw = r#"{"items":[{"edition":"personal","tier":"medium","subscribed":true,
395 "periods":[
396 {"label":"5h","used":2975.67,"total":10000,"percent":29.75,"reset_at":"2026-10-20T00:24:37+08:00"},
397 {"label":"daily","used":100.0,"total":50000,"percent":0.2,"reset_at":"2026-10-20T00:00:00+08:00"},
398 {"label":"weekly","used":2975.67,"total":35000,"percent":8.5,"reset_at":"2026-10-26T00:00:00+08:00"},
399 {"label":"monthly","used":2975.67,"total":100000,"percent":2.98,"reset_at":"2026-11-19T23:59:59+08:00"}]}]}"#;
400 let usage = parse_usage(raw, "agent-plan_cn-beijing_medium").unwrap();
401 assert_eq!(usage.tier.as_deref(), Some("medium"));
402 assert_eq!(usage.five_hour.quota, 10_000.0);
403 assert!(usage.daily.is_some());
404 assert!(usage.five_hour.reset_ms().is_some());
405 }
406
407 #[test]
408 fn a_text_only_plan_has_no_daily_window() {
409 let raw = r#"{"items":[{"edition":"personal","tier":"small","subscribed":true,
410 "periods":[
411 {"label":"5h","used":1,"total":2000,"percent":0.05,"reset_at":"2026-10-20T00:24:37+08:00"},
412 {"label":"weekly","used":1,"total":7000,"percent":0.01,"reset_at":"2026-10-26T00:00:00+08:00"},
413 {"label":"monthly","used":1,"total":20000,"percent":0.005,"reset_at":"2026-11-19T23:59:59+08:00"}]}]}"#;
414 let usage = parse_usage(raw, "p").unwrap();
415 assert!(usage.daily.is_none());
416 }
417
418 #[test]
419 fn a_window_without_figures_is_derived_not_rejected() {
420 let raw = r#"{"items":[{"edition":"personal","tier":"medium","subscribed":true,
425 "periods":[
426 {"label":"5h","total":10000,"percent":0},
427 {"label":"weekly","used":2975.67,"total":35000,"percent":8.5,"reset_at":"2026-10-26T00:00:00+08:00"},
428 {"label":"monthly","used":2975.67,"total":100000,"percent":2.98,"reset_at":"2026-11-19T23:59:59+08:00"}]}]}"#;
429 let usage = parse_usage(raw, "p").expect("a figureless window is still an answer");
430 assert_eq!(usage.five_hour.used, 0.0);
431 assert_eq!(usage.five_hour.quota, 10_000.0);
432 assert_eq!(usage.five_hour.percent, 0.0);
433 assert!(usage.five_hour.reset_ms().is_none());
434 }
435
436 #[test]
437 fn a_missing_percentage_is_computed_from_used_and_total() {
438 let raw = r#"{"items":[{"edition":"personal","tier":"medium","subscribed":true,
439 "periods":[
440 {"label":"5h","used":2500,"total":10000},
441 {"label":"weekly","used":1,"total":35000},
442 {"label":"monthly","used":1,"total":100000}]}]}"#;
443 let usage = parse_usage(raw, "p").unwrap();
444 assert_eq!(usage.five_hour.percent, 25.0);
445 assert_eq!(usage.five_hour.used, 2_500.0);
446 }
447
448 #[test]
449 fn an_unsubscribed_item_is_an_error() {
450 let raw =
451 r#"{"items":[{"edition":"personal","tier":"medium","subscribed":false,"periods":[]}]}"#;
452 assert!(matches!(parse_usage(raw, "p"), Err(Unavailable::Failed(_))));
453 }
454}