use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FieldCountRecord {
pub workspace_id: Option<String>,
pub endpoint: String,
pub method: String,
pub field_count: u32,
pub recorded_at: DateTime<Utc>,
}
#[derive(Debug, Clone)]
pub struct FieldCountTracker {
records: HashMap<String, Vec<FieldCountRecord>>,
}
impl FieldCountTracker {
pub fn new() -> Self {
Self {
records: HashMap::new(),
}
}
pub fn record_count(
&mut self,
workspace_id: Option<&str>,
endpoint: &str,
method: &str,
field_count: u32,
) {
let key = Self::make_key(workspace_id, endpoint, method);
let record = FieldCountRecord {
workspace_id: workspace_id.map(|s| s.to_string()),
endpoint: endpoint.to_string(),
method: method.to_string(),
field_count,
recorded_at: Utc::now(),
};
self.records.entry(key).or_default().push(record);
}
pub fn get_baseline_count(
&self,
workspace_id: Option<&str>,
endpoint: &str,
method: &str,
before: Option<DateTime<Utc>>,
) -> Option<u32> {
let key = Self::make_key(workspace_id, endpoint, method);
let records = self.records.get(&key)?;
let filtered: Vec<&FieldCountRecord> = if let Some(before_time) = before {
records.iter().filter(|r| r.recorded_at <= before_time).collect()
} else {
records.iter().collect()
};
filtered.iter().max_by_key(|r| r.recorded_at).map(|r| r.field_count)
}
pub fn get_average_count(
&self,
workspace_id: Option<&str>,
endpoint: &str,
method: &str,
window_days: u32,
) -> Option<f64> {
let key = Self::make_key(workspace_id, endpoint, method);
let records = self.records.get(&key)?;
let cutoff = Utc::now() - chrono::Duration::days(window_days as i64);
let window_records: Vec<&FieldCountRecord> =
records.iter().filter(|r| r.recorded_at >= cutoff).collect();
if window_records.is_empty() {
return None;
}
let sum: u32 = window_records.iter().map(|r| r.field_count).sum();
Some(sum as f64 / window_records.len() as f64)
}
pub fn calculate_churn_percent(
&self,
workspace_id: Option<&str>,
endpoint: &str,
method: &str,
current_count: u32,
window_days: Option<u32>,
) -> Option<f64> {
let baseline = if let Some(days) = window_days {
self.get_average_count(workspace_id, endpoint, method, days)?
} else {
self.get_baseline_count(workspace_id, endpoint, method, None)? as f64
};
if baseline == 0.0 {
return None;
}
let change = current_count as f64 - baseline;
Some((change / baseline) * 100.0)
}
pub fn cleanup_old_records(&mut self, retention_days: u32) {
let cutoff = Utc::now() - chrono::Duration::days(retention_days as i64);
for records in self.records.values_mut() {
records.retain(|r| r.recorded_at >= cutoff);
}
self.records.retain(|_, records| !records.is_empty());
}
fn make_key(workspace_id: Option<&str>, endpoint: &str, method: &str) -> String {
if let Some(ws_id) = workspace_id {
format!("{}:{} {}", ws_id, method, endpoint)
} else {
format!("{} {}", method, endpoint)
}
}
}
impl Default for FieldCountTracker {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_record_and_retrieve_count() {
let mut tracker = FieldCountTracker::new();
tracker.record_count(None, "/api/users", "GET", 10);
let count = tracker.get_baseline_count(None, "/api/users", "GET", None);
assert_eq!(count, Some(10));
}
#[test]
fn test_calculate_churn_percent() {
let mut tracker = FieldCountTracker::new();
tracker.record_count(None, "/api/users", "GET", 10);
let churn = tracker.calculate_churn_percent(None, "/api/users", "GET", 12, None);
assert!(churn.is_some());
let churn_value = churn.unwrap();
assert!((churn_value - 20.0).abs() < 0.1); }
#[test]
fn test_average_count_over_window() {
let mut tracker = FieldCountTracker::new();
tracker.record_count(None, "/api/users", "GET", 10);
tracker.record_count(None, "/api/users", "GET", 12);
tracker.record_count(None, "/api/users", "GET", 14);
let avg = tracker.get_average_count(None, "/api/users", "GET", 30);
assert!(avg.is_some());
let avg_value = avg.unwrap();
assert!((avg_value - 12.0).abs() < 0.1); }
}