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