ai_usagebar/opencode_go/
types.rs1use chrono::{DateTime, Utc};
4use serde_json::Value;
5
6#[derive(Debug, Clone, PartialEq)]
7pub struct Window {
8 pub status: String,
9 pub percent: f64,
10 pub resets_at: DateTime<Utc>,
11}
12
13#[derive(Debug, Clone, PartialEq)]
14pub struct Usage {
15 pub rolling: Option<Window>,
16 pub weekly: Option<Window>,
17 pub monthly: Option<Window>,
18}
19
20impl Eq for Usage {}
21
22pub fn parse_usage(value: &Value) -> Result<Usage, String> {
23 let root = value
24 .as_object()
25 .ok_or_else(|| "OpenCode Go response must be a JSON object".to_string())?;
26 if root.contains_key("error") {
27 return Err("OpenCode Go response is an error envelope".to_string());
28 }
29 let usage = root
30 .get("usage")
31 .ok_or_else(|| "OpenCode Go response missing top-level usage".to_string())?
32 .as_object()
33 .ok_or_else(|| "OpenCode Go response usage must be an object".to_string())?;
34
35 let parsed = Usage {
36 rolling: parse_optional_window(usage, "rolling")?,
37 weekly: parse_optional_window(usage, "weekly")?,
38 monthly: parse_optional_window(usage, "monthly")?,
39 };
40 if parsed.rolling.is_none() && parsed.weekly.is_none() && parsed.monthly.is_none() {
41 return Err("OpenCode Go response has no supported usage windows".to_string());
42 }
43 Ok(parsed)
44}
45
46fn parse_optional_window(
47 usage: &serde_json::Map<String, Value>,
48 name: &str,
49) -> Result<Option<Window>, String> {
50 usage
51 .get(name)
52 .map(|value| parse_window(value, name))
53 .transpose()
54}
55
56fn parse_window(value: &Value, name: &str) -> Result<Window, String> {
57 let object = value
58 .as_object()
59 .ok_or_else(|| format!("usage.{name} must be an object"))?;
60 let status = object
61 .get("status")
62 .and_then(Value::as_str)
63 .ok_or_else(|| format!("usage.{name}.status must be a string"))?
64 .to_string();
65 if !matches!(status.as_str(), "ok" | "rate-limited") {
66 return Err(format!("usage.{name}.status is unsupported"));
67 }
68 let percent = object
69 .get("percent")
70 .and_then(Value::as_f64)
71 .ok_or_else(|| format!("usage.{name}.percent must be a number"))?;
72 if !percent.is_finite() || !(0.0..=100.0).contains(&percent) {
73 return Err(format!(
74 "usage.{name}.percent must be finite and between 0 and 100"
75 ));
76 }
77 let resets_at = object
78 .get("resetsAt")
79 .and_then(Value::as_str)
80 .ok_or_else(|| format!("usage.{name}.resetsAt must be an RFC3339 timestamp"))?
81 .parse::<DateTime<chrono::FixedOffset>>()
82 .map_err(|error| format!("usage.{name}.resetsAt is not RFC3339: {error}"))?
83 .with_timezone(&Utc);
84
85 Ok(Window {
86 status,
87 percent,
88 resets_at,
89 })
90}
91
92#[cfg(test)]
93mod tests {
94 use super::*;
95
96 fn window(percent: Value) -> Value {
97 serde_json::json!({
98 "status": "ok",
99 "percent": percent,
100 "resetsAt": "2026-08-16T20:00:00Z"
101 })
102 }
103
104 #[test]
105 fn parses_required_window_fields_and_ignores_additive_fields() {
106 let value = serde_json::json!({
107 "usage": {
108 "rolling": {
109 "status": "ok",
110 "percent": 12.3,
111 "resetsAt": "2026-08-16T20:00:00Z",
112 "future": {"ignored": true}
113 }
114 },
115 "future_envelope_field": true
116 });
117
118 let parsed = parse_usage(&value).expect("usage should parse");
119 let rolling = parsed.rolling.expect("rolling window");
120 assert_eq!(rolling.status, "ok");
121 assert_eq!(rolling.percent, 12.3);
122 assert_eq!(
123 rolling.resets_at,
124 "2026-08-16T20:00:00Z".parse::<DateTime<Utc>>().unwrap()
125 );
126 assert!(parsed.weekly.is_none());
127 assert!(parsed.monthly.is_none());
128 }
129
130 #[test]
131 fn rejects_missing_usage_and_error_envelopes() {
132 for value in [
133 serde_json::json!({}),
134 serde_json::json!({"error": "unauthorized"}),
135 serde_json::json!({
136 "error": "unauthorized",
137 "usage": {"rolling": window(serde_json::json!(1))}
138 }),
139 serde_json::json!({"usage": null}),
140 serde_json::json!({"usage": {}}),
141 ] {
142 assert!(
143 parse_usage(&value).is_err(),
144 "unexpectedly accepted {value}"
145 );
146 }
147 }
148
149 #[test]
150 fn rejects_obsolete_usage_percent_field() {
151 let value = serde_json::json!({
152 "usage": {
153 "rolling": {
154 "status": "ok",
155 "usagePercent": 12.3,
156 "resetsAt": "2026-08-16T20:00:00Z"
157 }
158 }
159 });
160 assert!(parse_usage(&value).is_err());
161 }
162
163 #[test]
164 fn rejects_non_finite_or_out_of_range_percent() {
165 for percent in [
166 serde_json::json!(-0.1),
167 serde_json::json!(100.1),
168 serde_json::json!("NaN"),
169 serde_json::json!(null),
170 ] {
171 let value = serde_json::json!({"usage": {"rolling": window(percent)}});
172 assert!(
173 parse_usage(&value).is_err(),
174 "unexpectedly accepted {value}"
175 );
176 }
177 }
178
179 #[test]
180 fn rejects_invalid_timestamp_and_window_shape() {
181 let invalid_timestamp = serde_json::json!({
182 "usage": {
183 "rolling": {
184 "status": "ok",
185 "percent": 1,
186 "resetsAt": "tomorrow"
187 }
188 }
189 });
190 assert!(parse_usage(&invalid_timestamp).is_err());
191
192 let invalid_window = serde_json::json!({"usage": {"weekly": "not-an-object"}});
193 assert!(parse_usage(&invalid_window).is_err());
194
195 let invalid_status = serde_json::json!({
196 "usage": {
197 "rolling": {
198 "status": "limited",
199 "percent": 1,
200 "resetsAt": "2026-08-16T20:00:00Z"
201 }
202 }
203 });
204 assert!(parse_usage(&invalid_status).is_err());
205 }
206}