use chrono::{Duration, NaiveDate};
use serde::{Deserialize, Serialize};
pub const MAX_SNAPSHOT_ROWS: usize = 2_000;
pub const MAX_SKILL_LEN: usize = 64;
pub const MAX_DAY_AGE: i64 = 40;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UsageRow {
pub day: String,
pub skill: String,
pub tokens_sent: i64,
pub tokens_saved: i64,
pub runs: i64,
}
pub fn is_skill_char(c: char) -> bool {
c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '+' | '-')
}
pub fn skill_name(program: &str) -> String {
let first = program.split_whitespace().next().unwrap_or("");
let path: String = first
.chars()
.take_while(|&c| c == '/' || is_skill_char(c))
.collect();
let mut name: String = path.rsplit('/').next().unwrap_or("").to_string();
name.truncate(MAX_SKILL_LEN);
if name.is_empty() {
"other".into()
} else {
name
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UsageSnapshot {
pub device_id: String,
pub client_version: String,
pub rows: Vec<UsageRow>,
}
impl UsageRow {
fn acceptable(&self, today: NaiveDate) -> bool {
let day_ok = self.day.len() == 10
&& NaiveDate::parse_from_str(&self.day, "%Y-%m-%d").is_ok_and(|d| {
d >= today - Duration::days(MAX_DAY_AGE) && d <= today + Duration::days(1)
});
let skill_ok = !self.skill.is_empty()
&& self.skill.len() <= MAX_SKILL_LEN
&& self.skill.chars().all(is_skill_char);
day_ok && skill_ok && self.tokens_sent >= 0 && self.tokens_saved >= 0 && self.runs >= 0
}
}
impl UsageSnapshot {
pub fn validate(&self) -> Result<(), String> {
let uuid_like = self.device_id.len() == 36
&& self
.device_id
.chars()
.all(|c| c.is_ascii_hexdigit() || c == '-');
if !uuid_like {
return Err("device_id must be a UUID".into());
}
if self.rows.len() > MAX_SNAPSHOT_ROWS {
return Err(format!("at most {MAX_SNAPSHOT_ROWS} rows per snapshot"));
}
Ok(())
}
pub fn accepted_rows(&self, today: NaiveDate) -> (Vec<UsageRow>, usize) {
let kept: Vec<UsageRow> = self
.rows
.iter()
.filter(|r| r.acceptable(today))
.cloned()
.collect();
let dropped = self.rows.len() - kept.len();
(kept, dropped)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn snap(device_id: &str, rows: Vec<UsageRow>) -> UsageSnapshot {
UsageSnapshot {
device_id: device_id.into(),
client_version: "test".into(),
rows,
}
}
fn row(day: &str, sent: i64) -> UsageRow {
UsageRow {
day: day.into(),
skill: "git".into(),
tokens_sent: sent,
tokens_saved: 0,
runs: 1,
}
}
const DEV: &str = "11111111-1111-4111-8111-111111111111";
#[test]
fn accepts_a_valid_snapshot() {
assert!(snap(DEV, vec![row("2026-10-18", 5)]).validate().is_ok());
}
#[test]
fn rejects_a_bad_device_or_an_oversized_snapshot() {
assert!(snap("laptop", vec![]).validate().is_err());
assert!(snap(DEV, vec![row("2026-10-18", 1); MAX_SNAPSHOT_ROWS + 1])
.validate()
.is_err());
}
fn day_offset(today: chrono::NaiveDate, days: i64) -> String {
(today + chrono::Duration::days(days))
.format("%Y-%m-%d")
.to_string()
}
#[test]
fn bad_rows_are_dropped_not_the_whole_snapshot() {
let today = chrono::NaiveDate::from_ymd_opt(2026, 10, 18).unwrap();
let good = |day: String| UsageRow {
day,
skill: "git".into(),
tokens_sent: 1,
tokens_saved: 1,
runs: 1,
};
let mut rows = vec![good(day_offset(today, 0)), good(day_offset(today, -40))];
rows.push(UsageRow {
skill: "cargo test --workspace 2>&1".into(),
..good(day_offset(today, 0))
});
rows.push(good("2026-13-45".into()));
rows.push(good(day_offset(today, 2)));
rows.push(good(day_offset(today, -41)));
rows.push(UsageRow {
runs: -1,
..good(day_offset(today, 0))
});
rows.push(UsageRow {
tokens_sent: -1,
..good(day_offset(today, 0))
});
rows.push(UsageRow {
skill: String::new(),
..good(day_offset(today, 0))
});
rows.push(good("2026-1-18".into()));
let (kept, dropped) = snap(DEV, rows).accepted_rows(today);
assert_eq!(kept.len(), 2);
assert_eq!(dropped, 8);
}
}