Skip to main content

mockforge_contracts/contract_drift/
field_tracking.rs

1//! Field count tracking for percentage-based drift budgets
2//!
3//! This module provides functionality to track field counts over time
4//! for calculating percentage-based drift budgets.
5
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9
10/// Field count record for an endpoint
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct FieldCountRecord {
13    /// Workspace ID (optional)
14    pub workspace_id: Option<String>,
15    /// Endpoint path
16    pub endpoint: String,
17    /// HTTP method
18    pub method: String,
19    /// Number of fields in the contract
20    pub field_count: u32,
21    /// When this count was recorded
22    pub recorded_at: DateTime<Utc>,
23}
24
25/// In-memory field count tracker
26#[derive(Debug, Clone)]
27pub struct FieldCountTracker {
28    /// Field count records (key: "{workspace_id}:{method} {endpoint}")
29    records: HashMap<String, Vec<FieldCountRecord>>,
30}
31
32impl FieldCountTracker {
33    /// Create a new field count tracker
34    pub fn new() -> Self {
35        Self {
36            records: HashMap::new(),
37        }
38    }
39
40    /// Record a field count for an endpoint
41    pub fn record_count(
42        &mut self,
43        workspace_id: Option<&str>,
44        endpoint: &str,
45        method: &str,
46        field_count: u32,
47    ) {
48        let key = Self::make_key(workspace_id, endpoint, method);
49        let record = FieldCountRecord {
50            workspace_id: workspace_id.map(|s| s.to_string()),
51            endpoint: endpoint.to_string(),
52            method: method.to_string(),
53            field_count,
54            recorded_at: Utc::now(),
55        };
56
57        self.records.entry(key).or_default().push(record);
58    }
59
60    /// Get the baseline field count for an endpoint
61    ///
62    /// Returns the most recent field count recorded before the specified time,
63    /// or the most recent count if no time is specified.
64    pub fn get_baseline_count(
65        &self,
66        workspace_id: Option<&str>,
67        endpoint: &str,
68        method: &str,
69        before: Option<DateTime<Utc>>,
70    ) -> Option<u32> {
71        let key = Self::make_key(workspace_id, endpoint, method);
72        let records = self.records.get(&key)?;
73
74        // Filter by time if specified
75        let filtered: Vec<&FieldCountRecord> = if let Some(before_time) = before {
76            records.iter().filter(|r| r.recorded_at <= before_time).collect()
77        } else {
78            records.iter().collect()
79        };
80
81        // Get the most recent record
82        filtered.iter().max_by_key(|r| r.recorded_at).map(|r| r.field_count)
83    }
84
85    /// Get average field count over a time window
86    ///
87    /// Returns the average field count for records within the specified time window.
88    pub fn get_average_count(
89        &self,
90        workspace_id: Option<&str>,
91        endpoint: &str,
92        method: &str,
93        window_days: u32,
94    ) -> Option<f64> {
95        let key = Self::make_key(workspace_id, endpoint, method);
96        let records = self.records.get(&key)?;
97
98        let cutoff = Utc::now() - chrono::Duration::days(window_days as i64);
99        let window_records: Vec<&FieldCountRecord> =
100            records.iter().filter(|r| r.recorded_at >= cutoff).collect();
101
102        if window_records.is_empty() {
103            return None;
104        }
105
106        let sum: u32 = window_records.iter().map(|r| r.field_count).sum();
107        Some(sum as f64 / window_records.len() as f64)
108    }
109
110    /// Calculate field churn percentage
111    ///
112    /// Returns the percentage change in field count compared to the baseline.
113    /// Positive values indicate growth, negative values indicate reduction.
114    pub fn calculate_churn_percent(
115        &self,
116        workspace_id: Option<&str>,
117        endpoint: &str,
118        method: &str,
119        current_count: u32,
120        window_days: Option<u32>,
121    ) -> Option<f64> {
122        let baseline = if let Some(days) = window_days {
123            // Use average over time window as baseline
124            self.get_average_count(workspace_id, endpoint, method, days)?
125        } else {
126            // Use most recent count as baseline
127            self.get_baseline_count(workspace_id, endpoint, method, None)? as f64
128        };
129
130        if baseline == 0.0 {
131            return None;
132        }
133
134        let change = current_count as f64 - baseline;
135        Some((change / baseline) * 100.0)
136    }
137
138    /// Clean up old records beyond retention period
139    pub fn cleanup_old_records(&mut self, retention_days: u32) {
140        let cutoff = Utc::now() - chrono::Duration::days(retention_days as i64);
141
142        for records in self.records.values_mut() {
143            records.retain(|r| r.recorded_at >= cutoff);
144        }
145
146        // Remove empty entries
147        self.records.retain(|_, records| !records.is_empty());
148    }
149
150    /// Make a key for indexing records
151    fn make_key(workspace_id: Option<&str>, endpoint: &str, method: &str) -> String {
152        if let Some(ws_id) = workspace_id {
153            format!("{}:{} {}", ws_id, method, endpoint)
154        } else {
155            format!("{} {}", method, endpoint)
156        }
157    }
158}
159
160impl Default for FieldCountTracker {
161    fn default() -> Self {
162        Self::new()
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    #[test]
171    fn test_record_and_retrieve_count() {
172        let mut tracker = FieldCountTracker::new();
173        tracker.record_count(None, "/api/users", "GET", 10);
174
175        let count = tracker.get_baseline_count(None, "/api/users", "GET", None);
176        assert_eq!(count, Some(10));
177    }
178
179    #[test]
180    fn test_calculate_churn_percent() {
181        let mut tracker = FieldCountTracker::new();
182        tracker.record_count(None, "/api/users", "GET", 10);
183
184        // Record a new count with 20% increase
185        let churn = tracker.calculate_churn_percent(None, "/api/users", "GET", 12, None);
186        assert!(churn.is_some());
187        let churn_value = churn.unwrap();
188        assert!((churn_value - 20.0).abs() < 0.1); // 20% increase
189    }
190
191    #[test]
192    fn test_average_count_over_window() {
193        let mut tracker = FieldCountTracker::new();
194
195        // Record counts at different times
196        tracker.record_count(None, "/api/users", "GET", 10);
197        tracker.record_count(None, "/api/users", "GET", 12);
198        tracker.record_count(None, "/api/users", "GET", 14);
199
200        let avg = tracker.get_average_count(None, "/api/users", "GET", 30);
201        assert!(avg.is_some());
202        let avg_value = avg.unwrap();
203        assert!((avg_value - 12.0).abs() < 0.1); // Average of 10, 12, 14
204    }
205}