Skip to main content

mcpls_core/config/
bounded_secs.rs

1//! Bounded whole-second durations for configuration values.
2//!
3//! A value of these types is in range by construction, so neither
4//! `ServerConfig::validate` nor the use sites need to re-check or clamp it.
5
6use std::num::NonZeroU64;
7use std::time::Duration;
8
9use serde::{Deserialize, Serialize};
10
11use super::bounded_number::impl_bounded_number;
12use super::limits::MAX_TIMEOUT_SECONDS;
13use crate::bridge::{
14    DEFAULT_INDEXING_READY_TIMEOUT_SECS, INDEXING_STALENESS_BOUND, PROGRESS_SETTLE,
15};
16
17/// Why a number of seconds is not a valid [`BoundedSecs`].
18#[derive(thiserror::Error, Debug, Clone, Copy, PartialEq, Eq)]
19#[error("must be between {min} and {max} seconds, got {value}")]
20pub struct InvalidSecs {
21    /// The rejected value.
22    pub value: u64,
23    /// Smallest accepted value.
24    pub min: u64,
25    /// Largest accepted value.
26    pub max: u64,
27}
28
29/// A whole number of seconds in `MIN..=MAX`, with `MIN >= 1`.
30///
31/// Deserializes from a TOML integer and rejects out-of-range values at load
32/// time, so an embedder cannot build an out-of-range value either.
33///
34/// # Examples
35///
36/// ```
37/// use std::time::Duration;
38/// use mcpls_core::config::TimeoutSecs;
39///
40/// assert!(TimeoutSecs::new(0).is_err());
41/// assert!(TimeoutSecs::new(901).is_err());
42/// assert_eq!(TimeoutSecs::new(45).unwrap().as_duration(), Duration::from_secs(45));
43/// ```
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
45#[serde(try_from = "u64", into = "u64")]
46pub struct BoundedSecs<const MIN: u64, const MAX: u64>(NonZeroU64);
47
48impl<const MIN: u64, const MAX: u64> BoundedSecs<MIN, MAX> {
49    /// Builds the value from `secs`.
50    ///
51    /// # Errors
52    ///
53    /// [`InvalidSecs`] when `secs` is outside `MIN..=MAX`.
54    pub const fn new(secs: u64) -> Result<Self, InvalidSecs> {
55        const { assert!(MIN >= 1 && MIN <= MAX, "MIN must satisfy 1 <= MIN <= MAX") };
56        if secs >= MIN
57            && secs <= MAX
58            && let Some(secs) = NonZeroU64::new(secs)
59        {
60            return Ok(Self(secs));
61        }
62        Err(InvalidSecs {
63            value: secs,
64            min: MIN,
65            max: MAX,
66        })
67    }
68
69    /// The wrapped number of seconds, within `MIN..=MAX`.
70    #[must_use]
71    pub const fn get(self) -> u64 {
72        self.0.get()
73    }
74
75    /// The wrapped number of seconds as a [`Duration`].
76    #[must_use]
77    pub const fn as_duration(self) -> Duration {
78        Duration::from_secs(self.0.get())
79    }
80}
81
82impl_bounded_number!(
83    @convert [const MIN: u64, const MAX: u64] BoundedSecs<MIN, MAX>, u64, InvalidSecs,
84    |secs: BoundedSecs<MIN, MAX>| secs.get()
85);
86
87/// A server handshake or request timeout: 1 to [`MAX_TIMEOUT_SECONDS`] seconds.
88pub type TimeoutSecs = BoundedSecs<1, MAX_TIMEOUT_SECONDS>;
89
90impl_bounded_number!(@default TimeoutSecs, 30, "Thirty seconds.");
91
92/// How long a whole-workspace query waits for indexing readiness: strictly
93/// between `PROGRESS_SETTLE` and `INDEXING_STALENESS_BOUND`.
94///
95/// Violating either bound would reopen the cross-caller self-heal race
96/// `INDEXING_STALENESS_BOUND` exists to prevent, or time out while the entry
97/// is merely mid-settle.
98pub type IndexingReadyTimeoutSecs =
99    BoundedSecs<{ PROGRESS_SETTLE.as_secs() + 1 }, { INDEXING_STALENESS_BOUND.as_secs() - 1 }>;
100
101impl_bounded_number!(
102    @default IndexingReadyTimeoutSecs,
103    DEFAULT_INDEXING_READY_TIMEOUT_SECS,
104    "The built-in default, `DEFAULT_INDEXING_READY_TIMEOUT_SECS`."
105);
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110
111    #[test]
112    fn test_timeout_bounds() {
113        assert!(TimeoutSecs::new(0).is_err());
114        assert_eq!(
115            TimeoutSecs::new(901),
116            Err(InvalidSecs {
117                value: 901,
118                min: 1,
119                max: MAX_TIMEOUT_SECONDS
120            })
121        );
122        assert_eq!(TimeoutSecs::new(1).unwrap().get(), 1);
123        assert_eq!(
124            TimeoutSecs::new(MAX_TIMEOUT_SECONDS).unwrap().get(),
125            MAX_TIMEOUT_SECONDS
126        );
127        assert!(TimeoutSecs::new(MAX_TIMEOUT_SECONDS + 1).is_err());
128    }
129
130    #[test]
131    fn test_indexing_ready_timeout_bounds_are_exclusive() {
132        let settle = PROGRESS_SETTLE.as_secs();
133        let stale = INDEXING_STALENESS_BOUND.as_secs();
134        assert!(IndexingReadyTimeoutSecs::new(settle).is_err());
135        assert!(IndexingReadyTimeoutSecs::new(settle + 1).is_ok());
136        assert!(IndexingReadyTimeoutSecs::new(stale - 1).is_ok());
137        assert!(IndexingReadyTimeoutSecs::new(stale).is_err());
138    }
139
140    #[test]
141    fn test_defaults_match_documented_values() {
142        assert_eq!(TimeoutSecs::default().get(), 30);
143        assert_eq!(
144            IndexingReadyTimeoutSecs::default().get(),
145            DEFAULT_INDEXING_READY_TIMEOUT_SECS
146        );
147    }
148
149    #[test]
150    fn test_try_from_error_reports_range() {
151        let err = TimeoutSecs::try_from(0).unwrap_err();
152        assert_eq!(
153            err.to_string(),
154            format!("must be between 1 and {MAX_TIMEOUT_SECONDS} seconds, got 0")
155        );
156    }
157
158    #[test]
159    fn test_serde_round_trip_and_rejection() {
160        #[derive(Debug, Serialize, Deserialize, PartialEq)]
161        struct Holder {
162            secs: TimeoutSecs,
163        }
164
165        let holder: Holder = toml::from_str("secs = 12").unwrap();
166        assert_eq!(holder.secs.get(), 12);
167        assert_eq!(toml::to_string(&holder).unwrap().trim(), "secs = 12");
168        assert!(toml::from_str::<Holder>("secs = -1").is_err());
169    }
170}