use serde::{Deserialize, Serialize};
pub const FEATURE_VERSION: u32 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TaskKind {
CodeEdit,
TestGen,
Explore,
Review,
Extract,
Chat,
#[default]
Other,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Features {
pub version: u32,
pub task_kind: TaskKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub language: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subagent: Option<String>,
pub prompt_token_bucket: u32,
pub tool_count: u32,
pub has_images: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repo_fingerprint: Option<String>,
pub session_failure_count: u32,
pub hour_bucket: u8,
}
impl Features {
#[must_use]
pub fn new(task_kind: TaskKind) -> Self {
Self {
version: FEATURE_VERSION,
task_kind,
language: None,
agent: None,
subagent: None,
prompt_token_bucket: 0,
tool_count: 0,
has_images: false,
repo_fingerprint: None,
session_failure_count: 0,
hour_bucket: 0,
}
}
}
#[must_use]
pub fn token_bucket(n: u64) -> u32 {
if n < 2 {
0
} else {
63 - n.leading_zeros()
}
}
#[must_use]
pub fn repo_fingerprint(salt: &str, repo: &str) -> String {
use sha2::{Digest, Sha256};
let mut h = Sha256::new();
h.update(salt.as_bytes());
h.update([0u8]); h.update(repo.as_bytes());
let digest = h.finalize();
hex::encode(&digest[..8]) }
#[must_use]
pub fn hour_bucket(ts: jiff::Timestamp) -> u8 {
ts.to_zoned(jiff::tz::TimeZone::UTC).hour() as u8
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn token_bucket_is_monotonic_and_coarse() {
assert_eq!(token_bucket(0), 0);
assert_eq!(token_bucket(1), 0);
assert_eq!(token_bucket(2), 1);
assert_eq!(token_bucket(3), 1);
assert_eq!(token_bucket(4), 2);
assert_eq!(token_bucket(1024), 10);
let mut last = 0;
for n in 0..5000u64 {
let b = token_bucket(n);
assert!(b >= last);
last = b;
}
}
#[test]
fn repo_fingerprint_is_deterministic_salted_and_truncated() {
let a = repo_fingerprint("salt1", "github.com/acme/api");
assert_eq!(a, repo_fingerprint("salt1", "github.com/acme/api")); assert_ne!(a, repo_fingerprint("salt2", "github.com/acme/api")); assert_ne!(a, repo_fingerprint("salt1", "github.com/acme/web")); assert_eq!(a.len(), 16);
assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
assert_ne!(repo_fingerprint("a", "bc"), repo_fingerprint("ab", "c"));
}
#[test]
fn hour_bucket_in_range() {
let ts: jiff::Timestamp = "2026-07-08T15:04:05Z".parse().unwrap();
assert_eq!(hour_bucket(ts), 15);
let midnight: jiff::Timestamp = "2026-01-01T00:00:00Z".parse().unwrap();
assert_eq!(hour_bucket(midnight), 0);
}
#[test]
fn features_default_version_stamped() {
let f = Features::new(TaskKind::CodeEdit);
assert_eq!(f.version, FEATURE_VERSION);
assert_eq!(f.task_kind, TaskKind::CodeEdit);
}
}