1use chrono::{Duration, NaiveDate};
4use serde::{Deserialize, Serialize};
5
6pub const MAX_SNAPSHOT_ROWS: usize = 2_000;
8
9pub const MAX_SKILL_LEN: usize = 64;
11
12pub const MAX_DAY_AGE: i64 = 40;
14
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16pub struct UsageRow {
17 pub day: String,
18 pub skill: String,
19 pub tokens_sent: i64,
20 pub tokens_saved: i64,
21 pub runs: i64,
22}
23
24pub fn is_skill_char(c: char) -> bool {
26 c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '+' | '-')
27}
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30pub struct UsageSnapshot {
31 pub device_id: String,
32 pub client_version: String,
33 pub rows: Vec<UsageRow>,
34}
35
36impl UsageRow {
37 fn acceptable(&self, today: NaiveDate) -> bool {
38 let day_ok = self.day.len() == 10
39 && NaiveDate::parse_from_str(&self.day, "%Y-%m-%d").is_ok_and(|d| {
40 d >= today - Duration::days(MAX_DAY_AGE) && d <= today + Duration::days(1)
41 });
42 let skill_ok = !self.skill.is_empty()
43 && self.skill.len() <= MAX_SKILL_LEN
44 && self.skill.chars().all(is_skill_char);
45 day_ok && skill_ok && self.tokens_sent >= 0 && self.tokens_saved >= 0 && self.runs >= 0
46 }
47}
48
49impl UsageSnapshot {
50 pub fn validate(&self) -> Result<(), String> {
52 let uuid_like = self.device_id.len() == 36
53 && self
54 .device_id
55 .chars()
56 .all(|c| c.is_ascii_hexdigit() || c == '-');
57 if !uuid_like {
58 return Err("device_id must be a UUID".into());
59 }
60 if self.rows.len() > MAX_SNAPSHOT_ROWS {
61 return Err(format!("at most {MAX_SNAPSHOT_ROWS} rows per snapshot"));
62 }
63 Ok(())
64 }
65
66 pub fn accepted_rows(&self, today: NaiveDate) -> (Vec<UsageRow>, usize) {
69 let kept: Vec<UsageRow> = self
70 .rows
71 .iter()
72 .filter(|r| r.acceptable(today))
73 .cloned()
74 .collect();
75 let dropped = self.rows.len() - kept.len();
76 (kept, dropped)
77 }
78}
79
80#[cfg(test)]
81mod tests {
82 use super::*;
83
84 fn snap(device_id: &str, rows: Vec<UsageRow>) -> UsageSnapshot {
85 UsageSnapshot {
86 device_id: device_id.into(),
87 client_version: "test".into(),
88 rows,
89 }
90 }
91
92 fn row(day: &str, sent: i64) -> UsageRow {
93 UsageRow {
94 day: day.into(),
95 skill: "git".into(),
96 tokens_sent: sent,
97 tokens_saved: 0,
98 runs: 1,
99 }
100 }
101
102 const DEV: &str = "11111111-1111-4111-8111-111111111111";
103
104 #[test]
105 fn accepts_a_valid_snapshot() {
106 assert!(snap(DEV, vec![row("2026-10-18", 5)]).validate().is_ok());
107 }
108
109 #[test]
110 fn rejects_a_bad_device_or_an_oversized_snapshot() {
111 assert!(snap("laptop", vec![]).validate().is_err());
112 assert!(snap(DEV, vec![row("2026-10-18", 1); MAX_SNAPSHOT_ROWS + 1])
113 .validate()
114 .is_err());
115 }
116
117 fn day_offset(today: chrono::NaiveDate, days: i64) -> String {
118 (today + chrono::Duration::days(days))
119 .format("%Y-%m-%d")
120 .to_string()
121 }
122
123 #[test]
124 fn bad_rows_are_dropped_not_the_whole_snapshot() {
125 let today = chrono::NaiveDate::from_ymd_opt(2026, 10, 18).unwrap();
126 let good = |day: String| UsageRow {
127 day,
128 skill: "git".into(),
129 tokens_sent: 1,
130 tokens_saved: 1,
131 runs: 1,
132 };
133 let mut rows = vec![good(day_offset(today, 0)), good(day_offset(today, -40))];
134 rows.push(UsageRow {
135 skill: "cargo test --workspace 2>&1".into(),
136 ..good(day_offset(today, 0))
137 });
138 rows.push(good("2026-13-45".into()));
139 rows.push(good(day_offset(today, 2)));
140 rows.push(good(day_offset(today, -41)));
141 rows.push(UsageRow {
142 runs: -1,
143 ..good(day_offset(today, 0))
144 });
145 rows.push(UsageRow {
146 tokens_sent: -1,
147 ..good(day_offset(today, 0))
148 });
149 rows.push(UsageRow {
150 skill: String::new(),
151 ..good(day_offset(today, 0))
152 });
153 rows.push(good("2026-1-18".into()));
154 let (kept, dropped) = snap(DEV, rows).accepted_rows(today);
155 assert_eq!(kept.len(), 2);
156 assert_eq!(dropped, 8);
157 }
158}