nntp_proxy/types/config/
limits.rs

1//! Connection and error limit configuration types
2
3use std::num::{NonZeroU32, NonZeroUsize};
4
5nonzero_newtype! {
6    /// A non-zero maximum connections limit
7    ///
8    /// Ensures connection pools always have at least 1 connection allowed.
9    ///
10    /// # Examples
11    /// ```
12    /// use nntp_proxy::types::MaxConnections;
13    ///
14    /// let max = MaxConnections::new(10).unwrap();
15    /// assert_eq!(max.get(), 10);
16    ///
17    /// // Zero connections is invalid
18    /// assert!(MaxConnections::new(0).is_none());
19    /// ```
20    #[doc(alias = "pool_size")]
21    #[doc(alias = "connection_limit")]
22    pub struct MaxConnections(NonZeroUsize: usize, serialize as serialize_u64);
23}
24
25impl MaxConnections {
26    /// Default maximum connections per backend
27    pub const DEFAULT: Self = Self(NonZeroUsize::new(10).unwrap());
28}
29
30nonzero_newtype! {
31    /// A non-zero maximum errors threshold.
32    ///
33    /// Used to specify the maximum number of errors allowed before taking action.
34    ///
35    /// This type is used in two primary contexts:
36    /// - **Health check error thresholds:** Ensures that health checks require at least one error before marking a service as unhealthy.
37    /// - **Retry logic:** Specifies the maximum number of retry attempts after errors.
38    ///
39    /// By enforcing a non-zero value, this type ensures that both health check and retry thresholds are always meaningful (at least 1 error required).
40    ///
41    /// # Examples
42    /// ```
43    /// use nntp_proxy::types::MaxErrors;
44    ///
45    /// let max = MaxErrors::new(3).unwrap();
46    /// assert_eq!(max.get(), 3);
47    ///
48    /// // Zero errors is invalid
49    /// assert!(MaxErrors::new(0).is_none());
50    /// ```
51    pub struct MaxErrors(NonZeroU32: u32, serialize as serialize_u32);
52}
53
54impl MaxErrors {
55    /// Default maximum errors threshold
56    pub const DEFAULT: Self = Self(NonZeroU32::new(3).unwrap());
57}
58
59/// A non-zero thread count
60///
61/// Ensures thread pools always have at least 1 thread.
62/// Special handling: passing 0 to `new()` returns the number of CPU cores.
63#[repr(transparent)]
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
65pub struct ThreadCount(NonZeroUsize);
66
67impl ThreadCount {
68    /// Default thread count (single-threaded)
69    pub const DEFAULT: Self = Self(NonZeroUsize::new(1).unwrap());
70
71    /// Create a new ThreadCount
72    ///
73    /// - If value is 0, returns the number of CPU cores
74    /// - Otherwise returns the specified value
75    /// - Always returns Some (never None)
76    #[must_use]
77    pub const fn new(value: usize) -> Option<Self> {
78        if value == 0 {
79            // 0 means auto-detect CPU count (handled at runtime)
80            // We can't call num_cpus() in const context, so return None
81            // and the caller should use from_value() instead
82            None
83        } else {
84            match NonZeroUsize::new(value) {
85                Some(nz) => Some(Self(nz)),
86                None => None,
87            }
88        }
89    }
90
91    /// Create a new ThreadCount from a value
92    ///
93    /// - If value is 0, returns the number of CPU cores
94    /// - Otherwise returns the specified value
95    /// - Always returns Some (never None)
96    #[must_use]
97    pub fn from_value(value: usize) -> Option<Self> {
98        if value == 0 {
99            Some(Self::num_cpus())
100        } else {
101            NonZeroUsize::new(value).map(Self)
102        }
103    }
104
105    /// Get the inner value
106    #[must_use]
107    #[inline]
108    pub const fn get(&self) -> usize {
109        self.0.get()
110    }
111
112    /// Get the number of available CPU cores (private helper)
113    fn num_cpus() -> Self {
114        let count = std::thread::available_parallelism()
115            .map(|p| p.get())
116            .unwrap_or(1);
117        // SAFETY: available_parallelism always returns at least 1
118        Self(NonZeroUsize::new(count).unwrap())
119    }
120}
121
122impl Default for ThreadCount {
123    /// Default to single-threaded (1 thread)
124    fn default() -> Self {
125        Self(NonZeroUsize::new(1).unwrap())
126    }
127}
128
129impl std::fmt::Display for ThreadCount {
130    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
131        write!(f, "{}", self.get())
132    }
133}
134
135impl From<ThreadCount> for usize {
136    fn from(val: ThreadCount) -> Self {
137        val.get()
138    }
139}
140
141impl serde::Serialize for ThreadCount {
142    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
143    where
144        S: serde::Serializer,
145    {
146        serializer.serialize_u64(self.get() as _)
147    }
148}
149
150impl<'de> serde::Deserialize<'de> for ThreadCount {
151    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
152    where
153        D: serde::Deserializer<'de>,
154    {
155        let value = usize::deserialize(deserializer)?;
156        Self::from_value(value).ok_or_else(|| {
157            serde::de::Error::custom("ThreadCount must be a positive integer (0 means auto-detect)")
158        })
159    }
160}
161
162impl std::str::FromStr for ThreadCount {
163    type Err = std::num::ParseIntError;
164
165    fn from_str(s: &str) -> Result<Self, Self::Err> {
166        let value = s.parse::<usize>()?;
167        Ok(Self::from_value(value).unwrap_or_else(Self::default))
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174    use proptest::prelude::*;
175
176    // ============================================================================
177    // Property Tests - MaxConnections
178    // ============================================================================
179
180    proptest! {
181        /// Property: Any non-zero usize round-trips through MaxConnections
182        #[test]
183        fn prop_max_connections_valid_range(value in 1usize..=10000) {
184            let max = MaxConnections::new(value).unwrap();
185            prop_assert_eq!(max.get(), value);
186        }
187
188        /// Property: Display shows exact value
189        #[test]
190        fn prop_max_connections_display(value in 1usize..=10000) {
191            let max = MaxConnections::new(value).unwrap();
192            prop_assert_eq!(max.to_string(), value.to_string());
193        }
194
195        /// Property: JSON serialization round-trips correctly
196        #[test]
197        fn prop_max_connections_serde_json(value in 1usize..=10000) {
198            let max = MaxConnections::new(value).unwrap();
199            let json = serde_json::to_string(&max).unwrap();
200            let parsed: MaxConnections = serde_json::from_str(&json).unwrap();
201            prop_assert_eq!(parsed.get(), value);
202        }
203
204        /// Property: Clone creates identical copy
205        #[test]
206        fn prop_max_connections_clone(value in 1usize..=10000) {
207            let max = MaxConnections::new(value).unwrap();
208            let cloned = max.clone();
209            prop_assert_eq!(max, cloned);
210        }
211    }
212
213    // Edge Cases - MaxConnections
214    #[test]
215    fn test_max_connections_zero_rejected() {
216        assert!(MaxConnections::new(0).is_none());
217    }
218
219    #[test]
220    fn test_max_connections_default() {
221        assert_eq!(MaxConnections::DEFAULT.get(), 10);
222    }
223
224    #[test]
225    fn test_max_connections_serde_json_zero_rejected() {
226        assert!(serde_json::from_str::<MaxConnections>("0").is_err());
227    }
228
229    // ============================================================================
230    // Property Tests - MaxErrors
231    // ============================================================================
232
233    proptest! {
234        /// Property: Any non-zero u32 round-trips through MaxErrors
235        #[test]
236        fn prop_max_errors_valid_range(value in 1u32..=1000) {
237            let max = MaxErrors::new(value).unwrap();
238            prop_assert_eq!(max.get(), value);
239        }
240
241        /// Property: Display shows exact value
242        #[test]
243        fn prop_max_errors_display(value in 1u32..=1000) {
244            let max = MaxErrors::new(value).unwrap();
245            prop_assert_eq!(max.to_string(), value.to_string());
246        }
247
248        /// Property: JSON serialization round-trips correctly
249        #[test]
250        fn prop_max_errors_serde_json(value in 1u32..=1000) {
251            let max = MaxErrors::new(value).unwrap();
252            let json = serde_json::to_string(&max).unwrap();
253            let parsed: MaxErrors = serde_json::from_str(&json).unwrap();
254            prop_assert_eq!(parsed.get(), value);
255        }
256
257        /// Property: Clone creates identical copy
258        #[test]
259        fn prop_max_errors_clone(value in 1u32..=1000) {
260            let max = MaxErrors::new(value).unwrap();
261            let cloned = max.clone();
262            prop_assert_eq!(max, cloned);
263        }
264    }
265
266    // Edge Cases - MaxErrors
267    #[test]
268    fn test_max_errors_zero_rejected() {
269        assert!(MaxErrors::new(0).is_none());
270    }
271
272    #[test]
273    fn test_max_errors_default() {
274        assert_eq!(MaxErrors::DEFAULT.get(), 3);
275    }
276
277    #[test]
278    fn test_max_errors_serde_json_zero_rejected() {
279        assert!(serde_json::from_str::<MaxErrors>("0").is_err());
280    }
281
282    // ============================================================================
283    // Property Tests - ThreadCount
284    // ============================================================================
285
286    proptest! {
287        /// Property: Any non-zero usize round-trips through ThreadCount
288        #[test]
289        fn prop_thread_count_valid_range(value in 1usize..=128) {
290            let threads = ThreadCount::new(value).unwrap();
291            prop_assert_eq!(threads.get(), value);
292        }
293
294        /// Property: from_value handles non-zero values correctly
295        #[test]
296        fn prop_thread_count_from_value(value in 1usize..=128) {
297            let threads = ThreadCount::from_value(value).unwrap();
298            prop_assert_eq!(threads.get(), value);
299        }
300
301        /// Property: Display shows exact value
302        #[test]
303        fn prop_thread_count_display(value in 1usize..=128) {
304            let threads = ThreadCount::new(value).unwrap();
305            prop_assert_eq!(threads.to_string(), value.to_string());
306        }
307
308        /// Property: Into<usize> conversion works
309        #[test]
310        fn prop_thread_count_into_usize(value in 1usize..=128) {
311            let threads = ThreadCount::new(value).unwrap();
312            let converted: usize = threads.into();
313            prop_assert_eq!(converted, value);
314        }
315
316        /// Property: FromStr parses valid numbers
317        #[test]
318        fn prop_thread_count_from_str(value in 1usize..=128) {
319            let s = value.to_string();
320            let threads: ThreadCount = s.parse().unwrap();
321            prop_assert_eq!(threads.get(), value);
322        }
323
324        /// Property: JSON serialization round-trips correctly
325        #[test]
326        fn prop_thread_count_serde_json(value in 1usize..=128) {
327            let threads = ThreadCount::new(value).unwrap();
328            let json = serde_json::to_string(&threads).unwrap();
329            let parsed: ThreadCount = serde_json::from_str(&json).unwrap();
330            prop_assert_eq!(parsed.get(), value);
331        }
332
333        /// Property: Clone creates identical copy
334        #[test]
335        fn prop_thread_count_clone(value in 1usize..=128) {
336            let threads = ThreadCount::new(value).unwrap();
337            let cloned = threads.clone();
338            prop_assert_eq!(threads, cloned);
339        }
340    }
341
342    // Edge Cases - ThreadCount
343    #[test]
344    fn test_thread_count_default() {
345        assert_eq!(ThreadCount::default().get(), 1);
346    }
347
348    #[test]
349    fn test_thread_count_new_zero_rejected() {
350        // new() can't call num_cpus() in const context, so returns None for 0
351        assert!(ThreadCount::new(0).is_none());
352    }
353
354    #[test]
355    fn test_thread_count_from_value_zero_auto_detects() {
356        // from_value(0) should auto-detect CPU count
357        let threads = ThreadCount::from_value(0).unwrap();
358        assert!(threads.get() >= 1);
359    }
360
361    #[test]
362    fn test_thread_count_from_str_zero_auto_detects() {
363        // Parsing "0" should auto-detect CPU count
364        let threads: ThreadCount = "0".parse().unwrap();
365        assert!(threads.get() >= 1);
366    }
367
368    #[test]
369    fn test_thread_count_from_str_invalid() {
370        assert!("not_a_number".parse::<ThreadCount>().is_err());
371    }
372
373    #[test]
374    fn test_thread_count_serde_json_zero_auto_detects() {
375        // Deserializing 0 should auto-detect CPU count
376        let parsed: ThreadCount = serde_json::from_str("0").unwrap();
377        assert!(parsed.get() >= 1);
378    }
379}