Skip to main content

ai_usagebar/opencode_go/
types.rs

1//! OpenCode Go usage response types and schema validation.
2
3use 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}