Skip to main content

nntp_proxy/metrics/
user_stats.rs

1//! User statistics type and calculation methods
2
3use crate::metrics::types::{CommandCount, ErrorCount};
4use crate::types::{BytesPerSecondRate, BytesReceived, BytesSent, TotalConnections};
5
6#[allow(clippy::cast_precision_loss)] // User error percentages are display metrics derived from exact counters.
7const fn count_as_f64_for_rate(value: u64) -> f64 {
8    // User error rates are derived display metrics. The underlying counters
9    // remain exact integers; floating point is only used for the percentage.
10    value as f64
11}
12
13/// Statistics for a single user (snapshot)
14#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
15pub struct UserStats {
16    pub username: String,
17    #[serde(skip, default)]
18    pub active_connections: usize,
19    pub total_connections: TotalConnections,
20    pub bytes_sent: BytesSent,
21    pub bytes_received: BytesReceived,
22    pub total_commands: CommandCount,
23    pub errors: ErrorCount,
24    /// Transfer rates (bytes/sec) - populated by TUI from deltas, 0 in raw snapshots
25    #[serde(skip, default)]
26    pub bytes_sent_per_sec: BytesPerSecondRate,
27    #[serde(skip, default)]
28    pub bytes_received_per_sec: BytesPerSecondRate,
29}
30
31impl UserStats {
32    /// Create new user stats with username
33    #[must_use]
34    pub fn new(username: impl Into<String>) -> Self {
35        Self {
36            username: username.into(),
37            ..Default::default()
38        }
39    }
40
41    /// Get total bytes transferred (sent + received)
42    #[must_use]
43    #[inline]
44    pub const fn total_bytes(&self) -> u64 {
45        self.bytes_sent
46            .as_u64()
47            .saturating_add(self.bytes_received.as_u64())
48    }
49
50    /// Get total transfer rate (sent + received) in bytes/sec
51    #[must_use]
52    #[inline]
53    pub const fn total_bytes_per_sec(&self) -> u64 {
54        self.bytes_sent_per_sec
55            .get()
56            .saturating_add(self.bytes_received_per_sec.get())
57    }
58
59    /// Calculate error rate as percentage
60    #[must_use]
61    pub fn error_rate_percent(&self) -> f64 {
62        let total_cmds = self.total_commands.get();
63        if total_cmds > 0 {
64            (count_as_f64_for_rate(self.errors.get()) / count_as_f64_for_rate(total_cmds)) * 100.0
65        } else {
66            0.0
67        }
68    }
69
70    /// Check if user has any activity
71    #[must_use]
72    #[inline]
73    pub const fn has_activity(&self) -> bool {
74        self.total_commands.get() > 0 || self.total_connections.get() > 0
75    }
76
77    /// Check if user is currently connected
78    #[must_use]
79    #[inline]
80    pub const fn is_connected(&self) -> bool {
81        self.active_connections > 0
82    }
83}
84
85#[cfg(test)]
86#[allow(clippy::float_cmp)] // These percentage tests rely on exact zero/default results from fixed counters.
87mod tests {
88    use super::*;
89
90    #[test]
91    fn test_user_stats_new() {
92        let stats = UserStats::new("testuser");
93        assert_eq!(stats.username, "testuser");
94        assert_eq!(stats.active_connections, 0);
95        assert_eq!(stats.total_connections.get(), 0);
96        assert_eq!(stats.bytes_sent.as_u64(), 0);
97        assert_eq!(stats.bytes_received.as_u64(), 0);
98        assert_eq!(stats.total_commands.get(), 0);
99        assert_eq!(stats.errors.get(), 0);
100    }
101
102    #[test]
103    fn test_user_stats_new_with_string() {
104        let username = String::from("alice");
105        let stats = UserStats::new(username);
106        assert_eq!(stats.username, "alice");
107    }
108
109    #[test]
110    fn test_total_bytes() {
111        let stats = UserStats {
112            bytes_sent: BytesSent::new(1000),
113            bytes_received: BytesReceived::new(2000),
114            ..Default::default()
115        };
116        assert_eq!(stats.total_bytes(), 3000);
117    }
118
119    #[test]
120    fn test_total_bytes_zero() {
121        let stats = UserStats::default();
122        assert_eq!(stats.total_bytes(), 0);
123    }
124
125    #[test]
126    fn test_total_bytes_saturating() {
127        let stats = UserStats {
128            bytes_sent: BytesSent::new(u64::MAX),
129            bytes_received: BytesReceived::new(1),
130            ..Default::default()
131        };
132        // Should saturate at u64::MAX, not overflow
133        assert_eq!(stats.total_bytes(), u64::MAX);
134    }
135
136    #[test]
137    fn test_total_bytes_per_sec() {
138        let stats = UserStats {
139            bytes_sent_per_sec: BytesPerSecondRate::new(100),
140            bytes_received_per_sec: BytesPerSecondRate::new(200),
141            ..Default::default()
142        };
143        assert_eq!(stats.total_bytes_per_sec(), 300);
144    }
145
146    #[test]
147    fn test_total_bytes_per_sec_zero() {
148        let stats = UserStats::default();
149        assert_eq!(stats.total_bytes_per_sec(), 0);
150    }
151
152    #[test]
153    fn test_total_bytes_per_sec_saturating() {
154        let stats = UserStats {
155            bytes_sent_per_sec: BytesPerSecondRate::new(u64::MAX),
156            bytes_received_per_sec: BytesPerSecondRate::new(1),
157            ..Default::default()
158        };
159        assert_eq!(stats.total_bytes_per_sec(), u64::MAX);
160    }
161
162    #[test]
163    fn test_error_rate_percent() {
164        let stats = UserStats {
165            total_commands: CommandCount::new(100),
166            errors: ErrorCount::new(5),
167            ..Default::default()
168        };
169        assert!((stats.error_rate_percent() - 5.0).abs() < 0.01);
170    }
171
172    #[test]
173    fn test_error_rate_percent_zero_commands() {
174        let stats = UserStats {
175            total_commands: CommandCount::new(0),
176            errors: ErrorCount::new(10),
177            ..Default::default()
178        };
179        assert_eq!(stats.error_rate_percent(), 0.0);
180    }
181
182    #[test]
183    fn test_error_rate_percent_no_errors() {
184        let stats = UserStats {
185            total_commands: CommandCount::new(100),
186            errors: ErrorCount::new(0),
187            ..Default::default()
188        };
189        assert_eq!(stats.error_rate_percent(), 0.0);
190    }
191
192    #[test]
193    fn test_error_rate_percent_high() {
194        let stats = UserStats {
195            total_commands: CommandCount::new(10),
196            errors: ErrorCount::new(3),
197            ..Default::default()
198        };
199        assert!((stats.error_rate_percent() - 30.0).abs() < 0.01);
200    }
201
202    #[test]
203    fn test_has_activity_with_commands() {
204        let stats = UserStats {
205            total_commands: CommandCount::new(1),
206            ..Default::default()
207        };
208        assert!(stats.has_activity());
209    }
210
211    #[test]
212    fn test_has_activity_with_connections() {
213        let stats = UserStats {
214            total_connections: TotalConnections::new(1),
215            ..Default::default()
216        };
217        assert!(stats.has_activity());
218    }
219
220    #[test]
221    fn test_has_activity_with_both() {
222        let stats = UserStats {
223            total_commands: CommandCount::new(5),
224            total_connections: TotalConnections::new(2),
225            ..Default::default()
226        };
227        assert!(stats.has_activity());
228    }
229
230    #[test]
231    fn test_has_activity_none() {
232        let stats = UserStats::default();
233        assert!(!stats.has_activity());
234    }
235
236    #[test]
237    fn test_is_connected_active() {
238        let stats = UserStats {
239            active_connections: 1,
240            ..Default::default()
241        };
242        assert!(stats.is_connected());
243    }
244
245    #[test]
246    fn test_is_connected_multiple() {
247        let stats = UserStats {
248            active_connections: 5,
249            ..Default::default()
250        };
251        assert!(stats.is_connected());
252    }
253
254    #[test]
255    fn test_is_connected_none() {
256        let stats = UserStats::default();
257        assert!(!stats.is_connected());
258    }
259
260    #[test]
261    fn test_user_stats_default() {
262        let stats = UserStats::default();
263        assert_eq!(stats.username, "");
264        assert_eq!(stats.active_connections, 0);
265        assert!(!stats.has_activity());
266        assert!(!stats.is_connected());
267    }
268
269    #[test]
270    fn test_user_stats_clone() {
271        let stats = UserStats {
272            username: "bob".to_string(),
273            active_connections: 2,
274            total_connections: TotalConnections::new(10),
275            bytes_sent: BytesSent::new(1000),
276            bytes_received: BytesReceived::new(2000),
277            total_commands: CommandCount::new(50),
278            errors: ErrorCount::new(2),
279            bytes_sent_per_sec: BytesPerSecondRate::new(100),
280            bytes_received_per_sec: BytesPerSecondRate::new(200),
281        };
282
283        let cloned = stats.clone();
284        assert_eq!(stats.username, cloned.username);
285        assert_eq!(stats.active_connections, cloned.active_connections);
286        assert_eq!(stats.total_bytes(), cloned.total_bytes());
287    }
288}