Skip to main content

nntp_proxy/types/
tui.rs

1//! Type-safe domain values for TUI
2
3use std::fmt;
4use std::num::NonZeroUsize;
5use std::time::Instant;
6
7/// Type-safe history size (non-zero)
8#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
9pub struct HistorySize(NonZeroUsize);
10
11impl HistorySize {
12    /// Default history size: 60 points = 1 minute at 1 update/sec
13    pub const DEFAULT: Self = Self(NonZeroUsize::new(60).unwrap());
14
15    /// Create a new history size
16    ///
17    /// # Panics
18    /// Panics if size is zero
19    #[must_use]
20    pub const fn new(size: usize) -> Self {
21        match NonZeroUsize::new(size) {
22            Some(non_zero) => Self(non_zero),
23            None => panic!("HistorySize must be non-zero"),
24        }
25    }
26
27    /// Get the raw value
28    #[must_use]
29    #[inline]
30    pub const fn get(&self) -> usize {
31        self.0.get()
32    }
33}
34
35impl Default for HistorySize {
36    fn default() -> Self {
37        Self::DEFAULT
38    }
39}
40
41/// Type-safe throughput in bytes per second (f64 for display formatting)
42///
43/// This is distinct from `metrics::BytesPerSecond` (u64) which is used for rate calculations.
44/// This type includes display formatting methods for the TUI.
45#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, serde::Serialize, serde::Deserialize)]
46pub struct Throughput(f64);
47
48impl Throughput {
49    /// Create from raw value
50    #[must_use]
51    #[inline]
52    pub const fn new(bps: f64) -> Self {
53        Self(bps)
54    }
55
56    /// Zero throughput
57    #[must_use]
58    #[inline]
59    pub const fn zero() -> Self {
60        Self(0.0)
61    }
62
63    /// Get raw value
64    #[must_use]
65    #[inline]
66    pub const fn get(&self) -> f64 {
67        self.0
68    }
69
70    /// Format as human-readable string
71    #[must_use]
72    pub fn format(&self) -> String {
73        const KIB: f64 = 1_024.0;
74        const MIB: f64 = KIB * 1_024.0;
75        const GIB: f64 = MIB * 1_024.0;
76        const TIB: f64 = GIB * 1_024.0;
77        const PIB: f64 = TIB * 1_024.0;
78
79        if self.0 >= PIB {
80            format!("{:.2} PiB/s", self.0 / PIB)
81        } else if self.0 >= TIB {
82            format!("{:.2} TiB/s", self.0 / TIB)
83        } else if self.0 >= GIB {
84            format!("{:.2} GiB/s", self.0 / GIB)
85        } else if self.0 >= MIB {
86            format!("{:.2} MiB/s", self.0 / MIB)
87        } else if self.0 >= KIB {
88            format!("{:.2} KiB/s", self.0 / KIB)
89        } else {
90            format!("{:.0} B/s", self.0)
91        }
92    }
93}
94
95impl Default for Throughput {
96    fn default() -> Self {
97        Self::zero()
98    }
99}
100
101impl fmt::Display for Throughput {
102    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
103        write!(f, "{}", self.format())
104    }
105}
106
107impl From<f64> for Throughput {
108    fn from(bps: f64) -> Self {
109        Self::new(bps)
110    }
111}
112
113/// Type-safe command rate in commands per second
114#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, serde::Serialize, serde::Deserialize)]
115pub struct CommandsPerSecond(f64);
116
117impl CommandsPerSecond {
118    /// Create from raw value
119    #[must_use]
120    #[inline]
121    pub const fn new(cps: f64) -> Self {
122        Self(cps)
123    }
124
125    /// Zero command rate
126    #[must_use]
127    #[inline]
128    pub const fn zero() -> Self {
129        Self(0.0)
130    }
131
132    /// Get raw value
133    #[must_use]
134    #[inline]
135    pub const fn get(&self) -> f64 {
136        self.0
137    }
138}
139
140impl Default for CommandsPerSecond {
141    fn default() -> Self {
142        Self::zero()
143    }
144}
145
146impl fmt::Display for CommandsPerSecond {
147    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
148        write!(f, "{:.1}", self.0)
149    }
150}
151
152impl From<f64> for CommandsPerSecond {
153    fn from(cps: f64) -> Self {
154        Self::new(cps)
155    }
156}
157
158/// Type-safe timestamp wrapper
159#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
160pub struct Timestamp(Instant);
161
162impl Timestamp {
163    /// Create from Instant
164    #[must_use]
165    #[inline]
166    pub const fn new(instant: Instant) -> Self {
167        Self(instant)
168    }
169
170    /// Current timestamp
171    #[must_use]
172    #[inline]
173    pub fn now() -> Self {
174        Self(Instant::now())
175    }
176
177    /// Get the inner Instant
178    #[must_use]
179    #[inline]
180    pub const fn into_inner(self) -> Instant {
181        self.0
182    }
183
184    /// Duration since this timestamp
185    #[must_use]
186    #[inline]
187    pub fn elapsed(&self) -> std::time::Duration {
188        self.0.elapsed()
189    }
190
191    /// Duration between two timestamps
192    #[must_use]
193    #[inline]
194    pub fn duration_since(&self, earlier: Self) -> std::time::Duration {
195        self.0.duration_since(earlier.0)
196    }
197}
198
199impl From<Instant> for Timestamp {
200    fn from(instant: Instant) -> Self {
201        Self::new(instant)
202    }
203}
204
205impl From<Timestamp> for Instant {
206    fn from(ts: Timestamp) -> Self {
207        ts.into_inner()
208    }
209}
210
211#[cfg(test)]
212#[allow(clippy::float_cmp)] // These newtype tests intentionally compare exact fixture values and zero constructors.
213mod tests {
214    use super::*;
215
216    // HistorySize tests
217    #[test]
218    fn test_history_size() {
219        let size = HistorySize::new(60);
220        assert_eq!(size.get(), 60);
221        assert_eq!(HistorySize::DEFAULT.get(), 60);
222        assert_eq!(HistorySize::default().get(), 60);
223    }
224
225    #[test]
226    #[should_panic(expected = "HistorySize must be non-zero")]
227    fn test_history_size_zero_panics() {
228        let _ = HistorySize::new(0);
229    }
230
231    #[test]
232    fn test_history_size_custom_values() {
233        assert_eq!(HistorySize::new(100).get(), 100);
234        assert_eq!(HistorySize::new(1).get(), 1);
235        assert_eq!(HistorySize::new(1000).get(), 1000);
236    }
237
238    // Throughput tests
239    #[test]
240    fn test_bytes_per_second() {
241        let bps = Throughput::new(1_500_000.0);
242        assert_eq!(bps.format(), "1.43 MiB/s");
243
244        let bps2 = Throughput::new(2_500.0);
245        assert_eq!(bps2.format(), "2.44 KiB/s");
246
247        let bps3 = Throughput::new(500.0);
248        assert_eq!(bps3.format(), "500 B/s");
249
250        assert_eq!(Throughput::zero().get(), 0.0);
251    }
252
253    #[test]
254    fn test_bytes_per_second_format_boundaries() {
255        // Just over 1 MiB/s
256        assert_eq!(Throughput::new(1_048_577.0).format(), "1.00 MiB/s");
257        // Just under 1 MiB/s
258        assert_eq!(Throughput::new(1_048_575.0).format(), "1024.00 KiB/s");
259        // Just over 1 KiB/s
260        assert_eq!(Throughput::new(1_025.0).format(), "1.00 KiB/s");
261        // Just under 1 KiB/s
262        assert_eq!(Throughput::new(999.0).format(), "999 B/s");
263        // Zero
264        assert_eq!(Throughput::zero().format(), "0 B/s");
265    }
266
267    #[test]
268    fn test_bytes_per_second_default() {
269        let bps = Throughput::default();
270        assert_eq!(bps.get(), 0.0);
271    }
272
273    #[test]
274    fn test_bytes_per_second_display() {
275        assert_eq!(Throughput::new(1_500_000.0).to_string(), "1.43 MiB/s");
276        assert_eq!(Throughput::new(2_500.0).to_string(), "2.44 KiB/s");
277        assert_eq!(Throughput::new(500.0).to_string(), "500 B/s");
278    }
279
280    #[test]
281    fn test_bytes_per_second_from_f64() {
282        let bps = Throughput::from(1234.5);
283        assert_eq!(bps.get(), 1234.5);
284    }
285
286    // CommandsPerSecond tests
287    #[test]
288    fn test_commands_per_second() {
289        let cps = CommandsPerSecond::new(123.456);
290        assert_eq!(cps.to_string(), "123.5");
291
292        assert_eq!(CommandsPerSecond::zero().get(), 0.0);
293    }
294
295    #[test]
296    fn test_commands_per_second_default() {
297        let cps = CommandsPerSecond::default();
298        assert_eq!(cps.get(), 0.0);
299    }
300
301    #[test]
302    fn test_commands_per_second_display() {
303        assert_eq!(CommandsPerSecond::new(0.0).to_string(), "0.0");
304        assert_eq!(CommandsPerSecond::new(1.5).to_string(), "1.5");
305        assert_eq!(CommandsPerSecond::new(99.99).to_string(), "100.0");
306    }
307
308    #[test]
309    fn test_commands_per_second_from_f64() {
310        let cps = CommandsPerSecond::from(42.7);
311        assert_eq!(cps.get(), 42.7);
312    }
313
314    // Timestamp tests
315    #[test]
316    fn test_timestamp() {
317        let ts = Timestamp::now();
318        std::thread::sleep(std::time::Duration::from_millis(10));
319        assert!(ts.elapsed() >= std::time::Duration::from_millis(10));
320    }
321
322    #[test]
323    fn test_timestamp_duration_since() {
324        let ts1 = Timestamp::now();
325        std::thread::sleep(std::time::Duration::from_millis(10));
326        let ts2 = Timestamp::now();
327
328        let duration = ts2.duration_since(ts1);
329        assert!(duration >= std::time::Duration::from_millis(10));
330    }
331
332    #[test]
333    fn test_timestamp_from_instant() {
334        let instant = Instant::now();
335        let ts = Timestamp::from(instant);
336        assert_eq!(ts.into_inner(), instant);
337    }
338
339    #[test]
340    fn test_timestamp_into_instant() {
341        let instant = Instant::now();
342        let ts = Timestamp::new(instant);
343        let instant2: Instant = ts.into();
344        assert_eq!(instant, instant2);
345    }
346}