bctx-cloud-core 0.1.39

bctx-cloud-core — cloud client and server for Vault sync, dashboard API, billing
Documentation
//! Wire format of the daily usage snapshot a client sends to the cloud.

use chrono::{Duration, NaiveDate};
use serde::{Deserialize, Serialize};

/// Most rows one snapshot may carry.
pub const MAX_SNAPSHOT_ROWS: usize = 2_000;

/// Longest skill name, in bytes.
pub const MAX_SKILL_LEN: usize = 64;

/// Oldest day, counted back from today, that a snapshot may still change.
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,
}

/// Characters a skill name may use: a program's basename, never its arguments.
pub fn is_skill_char(c: char) -> bool {
    c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '+' | '-')
}

/// The name usage is stored under: the program's basename, never its arguments. Some
/// records hold whole shell lines (paths, tokens, URLs) that must not be kept.
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 {
    /// Rejects snapshots that cannot be stored at all: an unusable device id or too many rows.
    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(())
    }

    /// The rows worth storing, and how many were dropped: one bad row (an odd skill
    /// name, a day outside the last [`MAX_DAY_AGE`] days) must not cost the rest.
    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);
    }
}