1use chrono::{DateTime, Duration, Utc};
2use serde::{Deserialize, Serialize};
3
4#[derive(Debug, Serialize, Deserialize)]
9pub struct UsageReport {
10 pub api_key_name: String,
11 pub since: DateTime<Utc>,
12 pub until: DateTime<Utc>,
13 sessions: SessionCounts,
14 compute: ComputeTotals,
15 #[serde(default)]
20 pub daily: Vec<UsageBucket>,
21 #[serde(default, skip_serializing_if = "Option::is_none")]
23 pub bundle_builds: Option<BundleBuildUsage>,
24}
25
26impl UsageReport {
27 pub fn new(
29 api_key_name: String,
30 since: DateTime<Utc>,
31 until: DateTime<Utc>,
32 daily: Vec<UsageBucket>,
33 bundle_builds: Option<BundleBuildUsage>,
34 ) -> Self {
35 let (sessions, compute) = daily.iter().fold(
36 (SessionCounts::default(), ComputeTotals::default()),
37 |(sessions, compute), bucket| {
38 (
39 sessions.saturating_add(bucket.sessions),
40 compute.saturating_add(bucket.compute),
41 )
42 },
43 );
44 Self {
45 api_key_name,
46 since,
47 until,
48 sessions,
49 compute,
50 daily,
51 bundle_builds,
52 }
53 }
54
55 pub fn sessions(&self) -> SessionCounts {
58 self.sessions
59 }
60
61 pub fn compute(&self) -> ComputeTotals {
64 self.compute
65 }
66}
67
68pub const BUCKET_SPAN: Duration = Duration::days(1);
70
71#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
75pub struct UsageBucket {
76 pub start: DateTime<Utc>,
77 pub end: DateTime<Utc>,
78 pub sessions: SessionCounts,
79 pub compute: ComputeTotals,
80}
81
82impl UsageBucket {
83 pub fn is_partial(&self) -> bool {
85 self.end.signed_duration_since(self.start) < BUCKET_SPAN
86 }
87
88 pub fn is_empty(&self) -> bool {
90 self.sessions == SessionCounts::default() && self.compute == ComputeTotals::default()
91 }
92}
93
94#[derive(Debug, Default, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
96pub struct BundleBuildUsage {
97 pub requested: u64,
98}
99
100#[derive(Debug, Default, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
108pub struct SessionCounts {
109 pub started: u64,
110 pub completed: u64,
111 pub failed: u64,
112}
113
114#[derive(Debug, Default, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
117pub struct ComputeTotals {
118 pub executed_slot_count: u64,
119 pub session_duration_ms: u64,
120}
121
122impl SessionCounts {
123 pub fn saturating_add(self, other: Self) -> Self {
124 Self {
125 started: self.started.saturating_add(other.started),
126 completed: self.completed.saturating_add(other.completed),
127 failed: self.failed.saturating_add(other.failed),
128 }
129 }
130}
131
132impl ComputeTotals {
133 pub fn saturating_add(self, other: Self) -> Self {
134 Self {
135 executed_slot_count: self
136 .executed_slot_count
137 .saturating_add(other.executed_slot_count),
138 session_duration_ms: self
139 .session_duration_ms
140 .saturating_add(other.session_duration_ms),
141 }
142 }
143}
144
145#[cfg(test)]
146mod tests {
147 use super::*;
148
149 fn report(bundle_builds: Option<BundleBuildUsage>) -> UsageReport {
150 UsageReport::new(
151 "k".to_string(),
152 "2026-01-01T00:00:00Z".parse().unwrap(),
153 "2026-01-02T00:00:00Z".parse().unwrap(),
154 vec![UsageBucket {
155 start: "2026-01-01T00:00:00Z".parse().unwrap(),
156 end: "2026-01-02T00:00:00Z".parse().unwrap(),
157 sessions: SessionCounts::default(),
158 compute: ComputeTotals::default(),
159 }],
160 bundle_builds,
161 )
162 }
163
164 fn bucket(day: &str, sessions: SessionCounts, compute: ComputeTotals) -> UsageBucket {
165 let start: DateTime<Utc> = format!("2026-01-{day}T00:00:00Z").parse().unwrap();
166 UsageBucket {
167 start,
168 end: start + BUCKET_SPAN,
169 sessions,
170 compute,
171 }
172 }
173
174 #[test]
175 fn new_sums_the_totals_from_the_buckets() {
176 let report = UsageReport::new(
177 "k".to_string(),
178 "2026-01-01T00:00:00Z".parse().unwrap(),
179 "2026-01-03T00:00:00Z".parse().unwrap(),
180 vec![
181 bucket(
182 "01",
183 SessionCounts {
184 started: 1,
185 completed: 1,
186 failed: 0,
187 },
188 ComputeTotals {
189 executed_slot_count: 5,
190 session_duration_ms: 9,
191 },
192 ),
193 bucket(
194 "02",
195 SessionCounts {
196 started: 2,
197 completed: 1,
198 failed: 1,
199 },
200 ComputeTotals {
201 executed_slot_count: 7,
202 session_duration_ms: 3,
203 },
204 ),
205 ],
206 None,
207 );
208 assert_eq!(
209 report.sessions(),
210 SessionCounts {
211 started: 3,
212 completed: 2,
213 failed: 1
214 }
215 );
216 assert_eq!(
217 report.compute(),
218 ComputeTotals {
219 executed_slot_count: 12,
220 session_duration_ms: 12
221 }
222 );
223 }
224
225 #[test]
226 fn new_totals_of_no_buckets_are_zero() {
227 let report = UsageReport::new(
228 "k".to_string(),
229 "2026-01-01T00:00:00Z".parse().unwrap(),
230 "2026-01-01T00:00:00Z".parse().unwrap(),
231 vec![],
232 None,
233 );
234 assert_eq!(report.sessions(), SessionCounts::default());
235 assert_eq!(report.compute(), ComputeTotals::default());
236 }
237
238 #[test]
239 fn usage_report_absent_bundle_builds_round_trips_to_none() {
240 let json = serde_json::to_string(&report(None)).unwrap();
241 assert!(!json.contains("bundle_builds"));
242 let parsed: UsageReport = serde_json::from_str(&json).unwrap();
243 assert_eq!(parsed.bundle_builds, None);
244 }
245
246 #[test]
247 fn usage_report_absent_daily_round_trips_to_empty() {
248 let json = r#"{"api_key_name":"k","since":"2026-01-01T00:00:00Z","until":"2026-01-02T00:00:00Z","sessions":{"started":1,"completed":1,"failed":0},"compute":{"executed_slot_count":5,"session_duration_ms":9}}"#;
249 let parsed: UsageReport = serde_json::from_str(json).unwrap();
250 assert!(parsed.daily.is_empty());
251 assert_eq!(
253 parsed.sessions(),
254 SessionCounts {
255 started: 1,
256 completed: 1,
257 failed: 0
258 }
259 );
260 assert_eq!(
261 parsed.compute(),
262 ComputeTotals {
263 executed_slot_count: 5,
264 session_duration_ms: 9
265 }
266 );
267 }
268
269 #[test]
270 fn usage_report_daily_round_trips() {
271 let json = serde_json::to_string(&report(None)).unwrap();
272 let parsed: UsageReport = serde_json::from_str(&json).unwrap();
273 assert_eq!(parsed.daily, report(None).daily);
274 }
275
276 #[test]
277 fn usage_report_present_bundle_builds_round_trips() {
278 let json = serde_json::to_string(&report(Some(BundleBuildUsage { requested: 3 }))).unwrap();
279 let parsed: UsageReport = serde_json::from_str(&json).unwrap();
280 assert_eq!(
281 parsed.bundle_builds,
282 Some(BundleBuildUsage { requested: 3 })
283 );
284 }
285}