Skip to main content

bctx_cloud_core/
usage.rs

1//! Wire format of the daily usage snapshot a client sends to the cloud.
2
3use chrono::{Duration, NaiveDate};
4use serde::{Deserialize, Serialize};
5
6/// Most rows one snapshot may carry.
7pub const MAX_SNAPSHOT_ROWS: usize = 2_000;
8
9/// Longest skill name, in bytes.
10pub const MAX_SKILL_LEN: usize = 64;
11
12/// Oldest day, counted back from today, that a snapshot may still change.
13pub 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
24/// Characters a skill name may use: a program's basename, never its arguments.
25pub 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    /// Rejects snapshots that cannot be stored at all: an unusable device id or too many rows.
51    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    /// The rows worth storing, and how many were dropped: one bad row (an odd skill
67    /// name, a day outside the last [`MAX_DAY_AGE`] days) must not cost the rest.
68    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}