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/// The name usage is stored under: the program's basename, never its arguments. Some
30/// records hold whole shell lines (paths, tokens, URLs) that must not be kept.
31pub 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    /// Rejects snapshots that cannot be stored at all: an unusable device id or too many rows.
68    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    /// The rows worth storing, and how many were dropped: one bad row (an odd skill
84    /// name, a day outside the last [`MAX_DAY_AGE`] days) must not cost the rest.
85    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}