use std::sync::atomic::{AtomicU32, Ordering};
use serde_json::json;
#[derive(Debug)]
pub struct SkillTelemetry {
slash_count: AtomicU32,
model_count: AtomicU32,
last_slash_name: std::sync::Mutex<Option<String>>,
last_model_name: std::sync::Mutex<Option<String>>,
}
impl Clone for SkillTelemetry {
fn clone(&self) -> Self {
Self {
slash_count: AtomicU32::new(self.slash_count.load(Ordering::Relaxed)),
model_count: AtomicU32::new(self.model_count.load(Ordering::Relaxed)),
last_slash_name: std::sync::Mutex::new(
self.last_slash_name
.lock()
.unwrap_or_else(|p| p.into_inner())
.clone(),
),
last_model_name: std::sync::Mutex::new(
self.last_model_name
.lock()
.unwrap_or_else(|p| p.into_inner())
.clone(),
),
}
}
}
impl Default for SkillTelemetry {
fn default() -> Self {
Self::new()
}
}
impl SkillTelemetry {
pub fn new() -> Self {
Self {
slash_count: AtomicU32::new(0),
model_count: AtomicU32::new(0),
last_slash_name: std::sync::Mutex::new(None),
last_model_name: std::sync::Mutex::new(None),
}
}
pub fn record_slash(&self, name: &str) {
self.slash_count.fetch_add(1, Ordering::Relaxed);
if let Ok(mut n) = self.last_slash_name.lock() {
*n = Some(name.to_string());
}
}
pub fn record_model(&self, name: &str) {
self.model_count.fetch_add(1, Ordering::Relaxed);
if let Ok(mut n) = self.last_model_name.lock() {
*n = Some(name.to_string());
}
}
pub fn snapshot_and_reset(&self) -> serde_json::Value {
let slash = self
.last_slash_name
.lock()
.unwrap_or_else(|p| p.into_inner())
.take();
let model = self
.last_model_name
.lock()
.unwrap_or_else(|p| p.into_inner())
.take();
let mut obj = serde_json::Map::new();
if let Some(name) = slash {
obj.insert("skill_trigger".into(), json!("slash"));
obj.insert("skill_name".into(), json!(name));
}
if let Some(name) = model {
obj.insert("skill_model_trigger".into(), json!("tool"));
obj.insert("skill_model_name".into(), json!(name));
}
serde_json::Value::Object(obj)
}
pub fn session_snapshot(&self) -> serde_json::Value {
json!({
"skill_slash_count": self.slash_count.load(Ordering::Relaxed),
"skill_model_count": self.model_count.load(Ordering::Relaxed),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_snapshot_is_null_object() {
let tel = SkillTelemetry::new();
let snap = tel.snapshot_and_reset();
assert_eq!(snap, json!({}));
}
#[test]
fn slash_record_appears_in_snapshot() {
let tel = SkillTelemetry::new();
tel.record_slash("review");
let snap = tel.snapshot_and_reset();
assert_eq!(snap["skill_trigger"], json!("slash"));
assert_eq!(snap["skill_name"], json!("review"));
}
#[test]
fn model_record_appears_in_snapshot() {
let tel = SkillTelemetry::new();
tel.record_model("code-review");
let snap = tel.snapshot_and_reset();
assert_eq!(snap["skill_model_trigger"], json!("tool"));
assert_eq!(snap["skill_model_name"], json!("code-review"));
}
#[test]
fn both_triggers_in_one_turn() {
let tel = SkillTelemetry::new();
tel.record_slash("review");
tel.record_model("design-review");
let snap = tel.snapshot_and_reset();
assert_eq!(snap["skill_trigger"], json!("slash"));
assert_eq!(snap["skill_model_trigger"], json!("tool"));
}
#[test]
fn snapshot_clears_per_turn_state() {
let tel = SkillTelemetry::new();
tel.record_slash("review");
tel.snapshot_and_reset();
let snap2 = tel.snapshot_and_reset();
assert_eq!(snap2, json!({}));
}
#[test]
fn session_counters_accumulate() {
let tel = SkillTelemetry::new();
tel.record_slash("a");
tel.record_slash("b");
tel.record_model("c");
tel.snapshot_and_reset();
let session = tel.session_snapshot();
assert_eq!(session["skill_slash_count"], json!(2));
assert_eq!(session["skill_model_count"], json!(1));
tel.record_model("d");
tel.snapshot_and_reset();
let session = tel.session_snapshot();
assert_eq!(session["skill_slash_count"], json!(2));
assert_eq!(session["skill_model_count"], json!(2));
}
}