use serde::{Deserialize, Serialize};
use std::collections::HashMap;
pub use super::shared::is_valid_rule_id;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum TelemetryEvent {
#[serde(rename = "validation_run")]
ValidationRun(ValidationRunEvent),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValidationRunEvent {
pub file_type_counts: HashMap<String, u32>,
pub rule_trigger_counts: HashMap<String, u32>,
pub error_count: u32,
pub warning_count: u32,
pub info_count: u32,
pub duration_ms: u64,
pub timestamp: String,
}
impl TelemetryEvent {
pub fn event_type(&self) -> &'static str {
match self {
TelemetryEvent::ValidationRun(_) => "validation_run",
}
}
pub fn timestamp(&self) -> &str {
match self {
TelemetryEvent::ValidationRun(e) => &e.timestamp,
}
}
pub fn validate_privacy(&self) -> Result<(), PrivacyViolation> {
match self {
TelemetryEvent::ValidationRun(e) => {
for key in e.file_type_counts.keys() {
if looks_like_path(key) {
return Err(PrivacyViolation::PathLikeKey(key.clone()));
}
}
for key in e.rule_trigger_counts.keys() {
if !is_valid_rule_id(key) {
return Err(PrivacyViolation::InvalidRuleId(key.clone()));
}
}
Ok(())
}
}
}
}
#[derive(Debug, Clone)]
pub enum PrivacyViolation {
PathLikeKey(String),
InvalidRuleId(String),
}
impl std::fmt::Display for PrivacyViolation {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PrivacyViolation::PathLikeKey(key) => {
write!(f, "Key looks like a path (privacy violation): {}", key)
}
PrivacyViolation::InvalidRuleId(id) => {
write!(f, "Invalid rule ID format: {}", id)
}
}
}
}
impl std::error::Error for PrivacyViolation {}
fn looks_like_path(s: &str) -> bool {
s.contains('/')
|| s.contains('\\')
|| s.ends_with(".md")
|| s.ends_with(".json")
|| s.ends_with(".toml")
|| s.ends_with(".yaml")
|| s.ends_with(".yml")
|| s.starts_with('.')
|| s.starts_with('~')
|| (s.len() > 1 && s.chars().nth(1) == Some(':'))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_looks_like_path() {
assert!(looks_like_path("/home/user/file.md"));
assert!(looks_like_path("C:\\Users\\file.json"));
assert!(looks_like_path("./relative/path"));
assert!(looks_like_path("CLAUDE.md"));
assert!(looks_like_path("~/.config"));
assert!(!looks_like_path("skill"));
assert!(!looks_like_path("claude_md"));
assert!(!looks_like_path("mcp"));
}
#[test]
fn test_is_valid_rule_id() {
assert!(is_valid_rule_id("AS-001"));
assert!(is_valid_rule_id("CC-HK-001"));
assert!(is_valid_rule_id("MCP-002"));
assert!(is_valid_rule_id("XP-001"));
assert!(!is_valid_rule_id(""));
assert!(!is_valid_rule_id("invalid"));
assert!(!is_valid_rule_id("AS-"));
assert!(!is_valid_rule_id("-001"));
assert!(!is_valid_rule_id("as-001")); assert!(!is_valid_rule_id("AS-abc")); }
#[test]
fn test_validation_run_event_serialization() {
let event = ValidationRunEvent {
file_type_counts: [("skill".to_string(), 5), ("mcp".to_string(), 3)]
.into_iter()
.collect(),
rule_trigger_counts: [("AS-001".to_string(), 2)].into_iter().collect(),
error_count: 1,
warning_count: 2,
info_count: 0,
duration_ms: 150,
timestamp: "2024-01-01T00:00:00Z".to_string(),
};
let json = serde_json::to_string(&event)
.expect("ValidationRunEvent serialization should not fail");
assert!(json.contains("\"error_count\":1"));
assert!(json.contains("\"duration_ms\":150"));
let parsed: ValidationRunEvent =
serde_json::from_str(&json).expect("round-trip deserialization should not fail");
assert_eq!(parsed.error_count, 1);
assert_eq!(parsed.duration_ms, 150);
}
#[test]
fn test_telemetry_event_serialization() {
let event = TelemetryEvent::ValidationRun(ValidationRunEvent {
file_type_counts: HashMap::new(),
rule_trigger_counts: HashMap::new(),
error_count: 0,
warning_count: 0,
info_count: 0,
duration_ms: 100,
timestamp: "2024-01-01T00:00:00Z".to_string(),
});
let json =
serde_json::to_string(&event).expect("TelemetryEvent serialization should not fail");
assert!(json.contains("\"type\":\"validation_run\""));
}
#[test]
fn test_privacy_validation_passes() {
let event = TelemetryEvent::ValidationRun(ValidationRunEvent {
file_type_counts: [("skill".to_string(), 5)].into_iter().collect(),
rule_trigger_counts: [("AS-001".to_string(), 2)].into_iter().collect(),
error_count: 1,
warning_count: 0,
info_count: 0,
duration_ms: 100,
timestamp: "2024-01-01T00:00:00Z".to_string(),
});
assert!(event.validate_privacy().is_ok());
}
#[test]
fn test_privacy_validation_catches_path() {
let event = TelemetryEvent::ValidationRun(ValidationRunEvent {
file_type_counts: [("/home/user/SKILL.md".to_string(), 1)]
.into_iter()
.collect(),
rule_trigger_counts: HashMap::new(),
error_count: 0,
warning_count: 0,
info_count: 0,
duration_ms: 100,
timestamp: "2024-01-01T00:00:00Z".to_string(),
});
assert!(matches!(
event.validate_privacy(),
Err(PrivacyViolation::PathLikeKey(_))
));
}
#[test]
fn test_privacy_validation_catches_invalid_rule() {
let event = TelemetryEvent::ValidationRun(ValidationRunEvent {
file_type_counts: HashMap::new(),
rule_trigger_counts: [("not-a-rule".to_string(), 1)].into_iter().collect(),
error_count: 0,
warning_count: 0,
info_count: 0,
duration_ms: 100,
timestamp: "2024-01-01T00:00:00Z".to_string(),
});
assert!(matches!(
event.validate_privacy(),
Err(PrivacyViolation::InvalidRuleId(_))
));
}
#[test]
fn test_privacy_validation_with_empty_hashmaps() {
let event = TelemetryEvent::ValidationRun(ValidationRunEvent {
file_type_counts: HashMap::new(),
rule_trigger_counts: HashMap::new(),
error_count: 0,
warning_count: 0,
info_count: 0,
duration_ms: 0,
timestamp: "2024-01-01T00:00:00Z".to_string(),
});
assert!(
event.validate_privacy().is_ok(),
"Empty hashmaps should pass validation"
);
}
#[test]
fn test_looks_like_path_edge_cases() {
assert!(looks_like_path("\\\\server\\share"));
assert!(looks_like_path("path\\to\\file"));
assert!(looks_like_path("C:"));
assert!(looks_like_path(".gitignore"));
assert!(!looks_like_path("rust"));
assert!(!looks_like_path("typescript"));
assert!(!looks_like_path("json_schema")); assert!(!looks_like_path("markdown_file")); assert!(!looks_like_path("claude_md_parser")); assert!(!looks_like_path("my_json_handler")); }
#[test]
fn test_is_valid_rule_id_edge_cases() {
assert!(is_valid_rule_id("A-001"));
assert!(is_valid_rule_id("ABCDEF-12345"));
assert!(is_valid_rule_id("CC-SK-001"));
assert!(!is_valid_rule_id("AS"));
assert!(is_valid_rule_id("AS-001")); assert!(!is_valid_rule_id("A-B-C-001"));
assert!(!is_valid_rule_id("--001"));
assert!(!is_valid_rule_id("AS--001"));
}
#[test]
fn test_event_type_method() {
let validation_event = TelemetryEvent::ValidationRun(ValidationRunEvent {
file_type_counts: HashMap::new(),
rule_trigger_counts: HashMap::new(),
error_count: 0,
warning_count: 0,
info_count: 0,
duration_ms: 0,
timestamp: "".to_string(),
});
assert_eq!(validation_event.event_type(), "validation_run");
}
}