Skip to main content

mcpls_core/config/
limits.rs

1//! Bounded numeric limits 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, NonZeroUsize};
7
8use serde::{Deserialize, Serialize};
9
10use super::bounded_number::impl_bounded_number;
11use super::text_newtype::impl_text_newtype;
12
13/// Default max depth for recursive marker search.
14pub const DEFAULT_HEURISTICS_MAX_DEPTH: usize = 10;
15
16/// Maximum allowed value, in seconds, for both [`super::LspServerConfig::timeout_seconds`]
17/// and [`super::LspServerConfig::request_timeout_seconds`], enforced by [`super::TimeoutSecs`].
18///
19/// tokio's `timeout`/`sleep` fall back to `Instant::far_future()` for
20/// astronomically large durations instead of panicking, so an unbounded value
21/// on either field (misconfiguration or typo) would silently disable the
22/// timeout rather than fail with a diagnosable error.
23///
24/// Set to 900 (15 minutes), not a rounder 3600 (1 hour): [`LspClient::request`]
25/// retries a request up to 4 times total on a `-32802` (`ServerCancelled`) or
26/// `-32801` (`ContentModified`) response (one shared budget across both
27/// codes), so the worst-case latency for a single call bounded by this value
28/// is `4 * 900 + 3.5s` ≈ 1 hour, not 4 hours — this constant bounds one
29/// attempt, so it is chosen such that the actually-experienced worst case
30/// (the retried total) stays within about an hour.
31///
32/// [`LspClient::request`]: crate::lsp::LspClient::request
33pub const MAX_TIMEOUT_SECONDS: u64 = 900;
34
35/// Upper bound on `workspace.heuristics_max_depth`.
36///
37/// A guard against typos and misconfiguration (e.g. `999999`), not a bound on
38/// walk cost: the recursive project-marker walk in
39/// `MarkerScan::collect` does not follow links, so
40/// its cost is bounded by the size of the tree regardless of this value.
41/// 64 is several times the default of 10 and well beyond any realistic
42/// project nesting.
43pub const MAX_HEURISTICS_DEPTH: usize = 64;
44
45/// Why a value is not a valid [`ServerStartConcurrency`].
46#[derive(thiserror::Error, Debug, Clone, Copy, PartialEq, Eq)]
47#[error("max_concurrent_server_starts must be at least 1")]
48pub struct InvalidServerStartConcurrency;
49
50/// How many LSP servers may be starting at the same time.
51///
52/// A fixed default keeps a generated configuration machine-independent.
53///
54/// # Examples
55///
56/// ```
57/// use mcpls_core::config::ServerStartConcurrency;
58///
59/// assert!(ServerStartConcurrency::new(0).is_err());
60/// assert_eq!(ServerStartConcurrency::new(2).unwrap().get(), 2);
61/// assert_eq!(ServerStartConcurrency::default(), ServerStartConcurrency::DEFAULT);
62/// ```
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(try_from = "usize", into = "usize")]
65pub struct ServerStartConcurrency(NonZeroUsize);
66
67impl ServerStartConcurrency {
68    /// Builds a limit.
69    ///
70    /// # Errors
71    ///
72    /// [`InvalidServerStartConcurrency`] for zero.
73    pub const fn new(limit: usize) -> Result<Self, InvalidServerStartConcurrency> {
74        match NonZeroUsize::new(limit) {
75            Some(limit) => Ok(Self(limit)),
76            None => Err(InvalidServerStartConcurrency),
77        }
78    }
79
80    /// The wrapped limit, at least 1.
81    #[must_use]
82    pub const fn get(self) -> usize {
83        self.0.get()
84    }
85}
86
87impl_bounded_number!(
88    ServerStartConcurrency,
89    usize,
90    InvalidServerStartConcurrency,
91    default = 8,
92    "Eight servers at a time.",
93    into = ServerStartConcurrency::get
94);
95
96/// Why a number is not a valid [`SearchDepth`].
97#[derive(thiserror::Error, Debug, Clone, Copy, PartialEq, Eq)]
98#[error("heuristics_max_depth ({value}) exceeds the maximum of {max}")]
99pub struct InvalidSearchDepth {
100    /// The rejected value.
101    pub value: usize,
102    /// Largest accepted value.
103    pub max: usize,
104}
105
106/// How many directory levels the recursive project-marker walk descends:
107/// at most [`MAX_HEURISTICS_DEPTH`].
108///
109/// Deserializes from a TOML integer and rejects an out-of-range value at load
110/// time.
111///
112/// # Examples
113///
114/// ```
115/// use mcpls_core::config::SearchDepth;
116///
117/// assert_eq!(SearchDepth::default().get(), 10);
118/// assert_eq!(SearchDepth::new(64).unwrap().get(), 64);
119/// assert!(SearchDepth::new(65).is_err());
120/// ```
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
122#[serde(try_from = "usize", into = "usize")]
123pub struct SearchDepth(u8);
124
125impl SearchDepth {
126    /// Builds a depth.
127    ///
128    /// # Errors
129    ///
130    /// [`InvalidSearchDepth`] when `depth` exceeds [`MAX_HEURISTICS_DEPTH`].
131    pub const fn new(depth: usize) -> Result<Self, InvalidSearchDepth> {
132        const { assert!(MAX_HEURISTICS_DEPTH <= u8::MAX as usize) };
133        if depth > MAX_HEURISTICS_DEPTH {
134            return Err(InvalidSearchDepth {
135                value: depth,
136                max: MAX_HEURISTICS_DEPTH,
137            });
138        }
139        #[expect(
140            clippy::cast_possible_truncation,
141            reason = "bounded by MAX_HEURISTICS_DEPTH, asserted to fit u8 above"
142        )]
143        Ok(Self(depth as u8))
144    }
145
146    /// The wrapped depth, at most [`MAX_HEURISTICS_DEPTH`].
147    #[must_use]
148    pub const fn get(self) -> usize {
149        self.0 as usize
150    }
151}
152
153impl_bounded_number!(
154    SearchDepth,
155    usize,
156    InvalidSearchDepth,
157    default = DEFAULT_HEURISTICS_MAX_DEPTH,
158    "The built-in default, [`DEFAULT_HEURISTICS_MAX_DEPTH`].",
159    into = SearchDepth::get
160);
161
162/// Upper bound for `workspace.max_file_size` (1 GiB).
163///
164/// Keeps the derived per-response disk-read budget (a multiple of it) far from
165/// overflow, and a misconfiguration from making every response a
166/// gigabyte-scale scan.
167pub const MAX_FILE_SIZE_LIMIT: u64 = 1 << 30;
168
169/// Why a number is not a valid [`SizeLimit`].
170#[derive(thiserror::Error, Debug, Clone, Copy, PartialEq, Eq)]
171#[error(
172    "max_file_size ({value}) exceeds the hard cap of {max} bytes; use a lower value, or 0 to \
173     disable the per-file limit"
174)]
175pub struct InvalidSizeLimit {
176    /// The rejected value.
177    pub value: u64,
178    /// Largest accepted value.
179    pub max: u64,
180}
181
182/// A byte limit that is either absent or a positive number of bytes up to
183/// [`MAX_FILE_SIZE_LIMIT`].
184///
185/// The TOML spelling of "unlimited" is `0`; it exists only at that edge, so no
186/// consumer re-checks for it. Deserializes from an integer and rejects an
187/// out-of-range value at load time.
188///
189/// # Examples
190///
191/// ```
192/// use mcpls_core::config::SizeLimit;
193///
194/// let limit = SizeLimit::new(100).unwrap();
195/// assert!(limit.admits(100));
196/// assert!(!limit.admits(101));
197/// assert!(SizeLimit::UNLIMITED.admits(u64::MAX));
198/// assert_eq!(SizeLimit::new(0).unwrap(), SizeLimit::UNLIMITED);
199/// assert!(SizeLimit::new(u64::MAX).is_err());
200/// ```
201#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
202#[serde(try_from = "u64", into = "u64")]
203pub struct SizeLimit(Option<NonZeroU64>);
204
205impl SizeLimit {
206    /// No limit.
207    pub const UNLIMITED: Self = Self(None);
208
209    /// A limit of `bytes`, where `0` means unlimited.
210    ///
211    /// # Errors
212    ///
213    /// Returns [`InvalidSizeLimit`] above [`MAX_FILE_SIZE_LIMIT`].
214    pub const fn new(bytes: u64) -> Result<Self, InvalidSizeLimit> {
215        if bytes > MAX_FILE_SIZE_LIMIT {
216            return Err(InvalidSizeLimit {
217                value: bytes,
218                max: MAX_FILE_SIZE_LIMIT,
219            });
220        }
221        Ok(Self(NonZeroU64::new(bytes)))
222    }
223
224    /// A limit from a literal, checked at compile time when evaluated in a
225    /// `const` context.
226    ///
227    /// # Panics
228    ///
229    /// Panics if `bytes` is above [`MAX_FILE_SIZE_LIMIT`].
230    #[must_use]
231    pub const fn from_static(bytes: u64) -> Self {
232        match Self::new(bytes) {
233            Ok(limit) => limit,
234            Err(_) => panic!("size limit above MAX_FILE_SIZE_LIMIT"),
235        }
236    }
237
238    /// The limit in bytes, or `None` when unlimited.
239    #[must_use]
240    pub const fn get(self) -> Option<NonZeroU64> {
241        self.0
242    }
243
244    /// Whether a file of `bytes` is within the limit.
245    #[must_use]
246    pub const fn admits(self, bytes: u64) -> bool {
247        match self.0 {
248            Some(max) => bytes <= max.get(),
249            None => true,
250        }
251    }
252
253    /// Byte cap for a bounded read: one more than the limit, so a read that
254    /// reaches the cap is known to have exceeded it, or `u64::MAX` when
255    /// unlimited.
256    #[must_use]
257    pub const fn read_cap(self) -> u64 {
258        match self.0 {
259            Some(max) => Self::read_cap_for(max),
260            None => u64::MAX,
261        }
262    }
263
264    /// [`Self::read_cap`] of a limit of `max` bytes.
265    #[must_use]
266    pub(crate) const fn read_cap_for(max: NonZeroU64) -> u64 {
267        max.get().saturating_add(1)
268    }
269}
270
271impl_bounded_number!(
272    SizeLimit,
273    u64,
274    InvalidSizeLimit,
275    default = 10 * 1024 * 1024,
276    "The built-in default, 10 MiB.",
277    into = |limit: SizeLimit| limit.0.map_or_default(NonZeroU64::get)
278);
279
280/// A count limit that is either absent or a positive number of documents.
281///
282/// The TOML spelling of "unlimited" is `0`; it exists only at that edge.
283///
284/// # Examples
285///
286/// ```
287/// use mcpls_core::config::DocumentLimit;
288///
289/// assert_eq!(DocumentLimit::default().get().unwrap().get(), 100);
290/// assert_eq!(DocumentLimit::new(0), DocumentLimit::UNLIMITED);
291/// assert!(DocumentLimit::UNLIMITED.get().is_none());
292/// ```
293#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
294#[serde(from = "usize", into = "usize")]
295pub struct DocumentLimit(Option<NonZeroUsize>);
296
297impl DocumentLimit {
298    /// No limit.
299    pub const UNLIMITED: Self = Self(None);
300
301    /// A limit of `count` documents, where `0` means unlimited.
302    #[must_use]
303    pub const fn new(count: usize) -> Self {
304        Self(NonZeroUsize::new(count))
305    }
306
307    /// The limit, or `None` when unlimited.
308    #[must_use]
309    pub const fn get(self) -> Option<NonZeroUsize> {
310        self.0
311    }
312}
313
314impl_bounded_number!(
315    DocumentLimit,
316    usize,
317    infallible,
318    default = 100,
319    "The built-in default, 100 documents.",
320    into = |limit: DocumentLimit| limit.0.map_or_default(NonZeroUsize::get)
321);
322
323/// Why a string is not a valid [`BoundedText`].
324#[derive(thiserror::Error, Debug, Clone, Copy, PartialEq, Eq)]
325#[non_exhaustive]
326pub enum InvalidBoundedText {
327    /// The text was empty or whitespace-only.
328    #[error("cannot be empty")]
329    Blank,
330    /// The text was longer than the byte cap.
331    #[error("exceeds the maximum of {max} bytes ({len} given)")]
332    TooLong {
333        /// The byte length of the rejected text.
334        len: usize,
335        /// The byte cap.
336        max: usize,
337    },
338}
339
340/// Non-blank text of at most `MAX` UTF-8 bytes.
341///
342/// Deserializes from a TOML string and rejects a blank or over-long value at
343/// load time. The cap counts bytes, not chars.
344///
345/// # Examples
346///
347/// ```
348/// use mcpls_core::config::BoundedText;
349///
350/// let text = BoundedText::<8>::new("hello").unwrap();
351/// assert_eq!(text.as_str(), "hello");
352/// assert!(BoundedText::<8>::new("  ").is_err());
353/// assert!(BoundedText::<8>::new("123456789").is_err());
354/// ```
355#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
356#[serde(try_from = "String", into = "String")]
357pub struct BoundedText<const MAX: usize>(String);
358
359impl<const MAX: usize> BoundedText<MAX> {
360    /// Builds the text from any string.
361    ///
362    /// # Errors
363    ///
364    /// Returns [`InvalidBoundedText::Blank`] before [`InvalidBoundedText::TooLong`],
365    /// so a whitespace-only value reports as blank.
366    pub fn new(text: impl Into<String>) -> Result<Self, InvalidBoundedText> {
367        let text = text.into();
368        if text.trim().is_empty() {
369            return Err(InvalidBoundedText::Blank);
370        }
371        let len = text.len();
372        if len > MAX {
373            return Err(InvalidBoundedText::TooLong { len, max: MAX });
374        }
375        Ok(Self(text))
376    }
377}
378
379impl_text_newtype!(@impl [const MAX: usize] BoundedText<MAX>, InvalidBoundedText);
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384
385    #[test]
386    fn test_bounded_text_blank_is_reported_before_length() {
387        assert_eq!(BoundedText::<4>::new(""), Err(InvalidBoundedText::Blank));
388        assert_eq!(
389            BoundedText::<4>::new("      "),
390            Err(InvalidBoundedText::Blank)
391        );
392    }
393
394    #[test]
395    fn test_bounded_text_counts_bytes_not_chars() {
396        assert!(BoundedText::<4>::new("éé").is_ok());
397        assert_eq!(
398            BoundedText::<4>::new("ééé"),
399            Err(InvalidBoundedText::TooLong { len: 6, max: 4 })
400        );
401        assert_eq!(
402            BoundedText::<4>::new("ééé").unwrap_err().to_string(),
403            "exceeds the maximum of 4 bytes (6 given)"
404        );
405    }
406
407    #[test]
408    fn test_bounded_text_has_the_shared_text_conversions() {
409        let text: BoundedText<8> = "hello".parse().unwrap();
410        assert_eq!(text.as_ref() as &str, "hello");
411        assert_eq!(std::borrow::Borrow::<str>::borrow(&text), "hello");
412        assert_eq!(text.to_string(), "hello");
413        assert_eq!(String::from(text), "hello");
414        assert!("123456789".parse::<BoundedText<8>>().is_err());
415    }
416
417    #[test]
418    fn test_bounded_text_serde() {
419        #[derive(Debug, Serialize, Deserialize, PartialEq)]
420        struct Holder {
421            text: BoundedText<5>,
422        }
423
424        let holder: Holder = toml::from_str(r#"text = "abc""#).unwrap();
425        assert_eq!(holder.text, "abc");
426        assert_eq!(toml::to_string(&holder).unwrap().trim(), r#"text = "abc""#);
427        assert!(toml::from_str::<Holder>(r#"text = "abcdef""#).is_err());
428        assert!(toml::from_str::<Holder>(r#"text = " ""#).is_err());
429    }
430
431    #[test]
432    fn test_size_limit_zero_is_unlimited_and_cap_is_enforced() {
433        assert_eq!(SizeLimit::new(0), Ok(SizeLimit::UNLIMITED));
434        assert!(SizeLimit::new(MAX_FILE_SIZE_LIMIT).is_ok());
435        assert_eq!(
436            SizeLimit::new(MAX_FILE_SIZE_LIMIT + 1),
437            Err(InvalidSizeLimit {
438                value: MAX_FILE_SIZE_LIMIT + 1,
439                max: MAX_FILE_SIZE_LIMIT
440            })
441        );
442    }
443
444    #[test]
445    fn test_size_limit_admits_up_to_and_including_the_limit() {
446        let limit = SizeLimit::from_static(4);
447        assert!(limit.admits(0));
448        assert!(limit.admits(4));
449        assert!(!limit.admits(5));
450        assert!(SizeLimit::UNLIMITED.admits(u64::MAX));
451    }
452
453    #[test]
454    fn test_read_cap_for_is_the_limit_plus_one_and_saturates() {
455        assert_eq!(SizeLimit::read_cap_for(NonZeroU64::new(100).unwrap()), 101);
456        assert_eq!(SizeLimit::read_cap_for(NonZeroU64::MAX), u64::MAX);
457    }
458
459    #[test]
460    fn test_server_start_concurrency_rejects_zero_with_a_typed_error() {
461        assert_eq!(
462            ServerStartConcurrency::new(0),
463            Err(InvalidServerStartConcurrency)
464        );
465        assert_eq!(ServerStartConcurrency::new(3).unwrap().get(), 3);
466    }
467
468    #[test]
469    fn test_size_limit_read_cap_is_limit_plus_one_or_unbounded() {
470        assert_eq!(SizeLimit::from_static(100).read_cap(), 101);
471        assert_eq!(
472            SizeLimit::from_static(MAX_FILE_SIZE_LIMIT).read_cap(),
473            MAX_FILE_SIZE_LIMIT + 1
474        );
475        assert_eq!(SizeLimit::UNLIMITED.read_cap(), u64::MAX);
476    }
477
478    #[test]
479    fn test_size_limit_and_document_limit_serde_use_zero_for_unlimited() {
480        #[derive(Debug, Serialize, Deserialize, PartialEq)]
481        struct Holder {
482            size: SizeLimit,
483            docs: DocumentLimit,
484        }
485
486        let holder: Holder = toml::from_str("size = 0\ndocs = 0").unwrap();
487        assert_eq!(holder.size, SizeLimit::UNLIMITED);
488        assert_eq!(holder.docs, DocumentLimit::UNLIMITED);
489        assert_eq!(
490            toml::to_string(&holder).unwrap().trim(),
491            "size = 0\ndocs = 0"
492        );
493        let holder: Holder = toml::from_str("size = 7\ndocs = 3").unwrap();
494        assert_eq!(holder.size.get().unwrap().get(), 7);
495        assert_eq!(holder.docs.get().unwrap().get(), 3);
496        assert!(toml::from_str::<Holder>("size = 1073741825\ndocs = 1").is_err());
497        assert!(toml::from_str::<Holder>("size = 1\ndocs = -1").is_err());
498    }
499
500    #[test]
501    fn test_defaults_match_documented_values() {
502        assert_eq!(SizeLimit::default().get().unwrap().get(), 10 * 1024 * 1024);
503        assert_eq!(DocumentLimit::default().get().unwrap().get(), 100);
504    }
505
506    #[test]
507    fn test_search_depth_bounds() {
508        assert_eq!(SearchDepth::new(0).unwrap().get(), 0);
509        assert_eq!(
510            SearchDepth::new(MAX_HEURISTICS_DEPTH).unwrap().get(),
511            MAX_HEURISTICS_DEPTH
512        );
513        assert!(SearchDepth::new(MAX_HEURISTICS_DEPTH + 1).is_err());
514        assert_eq!(SearchDepth::default().get(), DEFAULT_HEURISTICS_MAX_DEPTH);
515    }
516
517    #[test]
518    fn test_search_depth_error_reports_range() {
519        let err = SearchDepth::try_from(999_999).unwrap_err();
520        assert_eq!(
521            err.to_string(),
522            "heuristics_max_depth (999999) exceeds the maximum of 64"
523        );
524    }
525
526    #[test]
527    fn test_search_depth_serde() {
528        #[derive(Debug, Serialize, Deserialize, PartialEq)]
529        struct Holder {
530            depth: SearchDepth,
531        }
532
533        let holder: Holder = toml::from_str("depth = 5").unwrap();
534        assert_eq!(holder.depth.get(), 5);
535        assert_eq!(toml::to_string(&holder).unwrap().trim(), "depth = 5");
536        assert!(toml::from_str::<Holder>("depth = 65").is_err());
537        assert!(toml::from_str::<Holder>("depth = -1").is_err());
538    }
539}