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(0, 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    /// The built-in default, 100 documents.
302    pub const DEFAULT: Self = Self::new(100);
303
304    /// A limit of `count` documents, where `0` means unlimited.
305    #[must_use]
306    pub const fn new(count: usize) -> Self {
307        Self(NonZeroUsize::new(count))
308    }
309
310    /// The limit, or `None` when unlimited.
311    #[must_use]
312    pub const fn get(self) -> Option<NonZeroUsize> {
313        self.0
314    }
315}
316
317impl Default for DocumentLimit {
318    fn default() -> Self {
319        Self::DEFAULT
320    }
321}
322
323impl From<usize> for DocumentLimit {
324    fn from(count: usize) -> Self {
325        Self::new(count)
326    }
327}
328
329impl From<DocumentLimit> for usize {
330    fn from(limit: DocumentLimit) -> Self {
331        limit.0.map_or(0, NonZeroUsize::get)
332    }
333}
334
335/// Why a string is not a valid [`BoundedText`].
336#[derive(thiserror::Error, Debug, Clone, Copy, PartialEq, Eq)]
337pub enum InvalidBoundedText {
338    /// The text was empty or whitespace-only.
339    #[error("cannot be empty")]
340    Blank,
341    /// The text was longer than the byte cap.
342    #[error("exceeds the maximum of {max} bytes ({len} given)")]
343    TooLong {
344        /// The byte length of the rejected text.
345        len: usize,
346        /// The byte cap.
347        max: usize,
348    },
349}
350
351/// Non-blank text of at most `MAX` UTF-8 bytes.
352///
353/// Deserializes from a TOML string and rejects a blank or over-long value at
354/// load time. The cap counts bytes, not chars.
355///
356/// # Examples
357///
358/// ```
359/// use mcpls_core::config::BoundedText;
360///
361/// let text = BoundedText::<8>::new("hello").unwrap();
362/// assert_eq!(text.as_str(), "hello");
363/// assert!(BoundedText::<8>::new("  ").is_err());
364/// assert!(BoundedText::<8>::new("123456789").is_err());
365/// ```
366#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
367#[serde(try_from = "String", into = "String")]
368pub struct BoundedText<const MAX: usize>(String);
369
370impl<const MAX: usize> BoundedText<MAX> {
371    /// Builds the text from any string.
372    ///
373    /// # Errors
374    ///
375    /// Returns [`InvalidBoundedText::Blank`] before [`InvalidBoundedText::TooLong`],
376    /// so a whitespace-only value reports as blank.
377    pub fn new(text: impl Into<String>) -> Result<Self, InvalidBoundedText> {
378        let text = text.into();
379        if text.trim().is_empty() {
380            return Err(InvalidBoundedText::Blank);
381        }
382        let len = text.len();
383        if len > MAX {
384            return Err(InvalidBoundedText::TooLong { len, max: MAX });
385        }
386        Ok(Self(text))
387    }
388}
389
390impl_text_newtype!(@impl [const MAX: usize] BoundedText<MAX>, InvalidBoundedText);
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395
396    #[test]
397    fn test_bounded_text_blank_is_reported_before_length() {
398        assert_eq!(BoundedText::<4>::new(""), Err(InvalidBoundedText::Blank));
399        assert_eq!(
400            BoundedText::<4>::new("      "),
401            Err(InvalidBoundedText::Blank)
402        );
403    }
404
405    #[test]
406    fn test_bounded_text_counts_bytes_not_chars() {
407        assert!(BoundedText::<4>::new("éé").is_ok());
408        assert_eq!(
409            BoundedText::<4>::new("ééé"),
410            Err(InvalidBoundedText::TooLong { len: 6, max: 4 })
411        );
412        assert_eq!(
413            BoundedText::<4>::new("ééé").unwrap_err().to_string(),
414            "exceeds the maximum of 4 bytes (6 given)"
415        );
416    }
417
418    #[test]
419    fn test_bounded_text_has_the_shared_text_conversions() {
420        let text: BoundedText<8> = "hello".parse().unwrap();
421        assert_eq!(text.as_ref() as &str, "hello");
422        assert_eq!(std::borrow::Borrow::<str>::borrow(&text), "hello");
423        assert_eq!(text.to_string(), "hello");
424        assert_eq!(String::from(text), "hello");
425        assert!("123456789".parse::<BoundedText<8>>().is_err());
426    }
427
428    #[test]
429    fn test_bounded_text_serde() {
430        #[derive(Debug, Serialize, Deserialize, PartialEq)]
431        struct Holder {
432            text: BoundedText<5>,
433        }
434
435        let holder: Holder = toml::from_str(r#"text = "abc""#).unwrap();
436        assert_eq!(holder.text, "abc");
437        assert_eq!(toml::to_string(&holder).unwrap().trim(), r#"text = "abc""#);
438        assert!(toml::from_str::<Holder>(r#"text = "abcdef""#).is_err());
439        assert!(toml::from_str::<Holder>(r#"text = " ""#).is_err());
440    }
441
442    #[test]
443    fn test_size_limit_zero_is_unlimited_and_cap_is_enforced() {
444        assert_eq!(SizeLimit::new(0), Ok(SizeLimit::UNLIMITED));
445        assert!(SizeLimit::new(MAX_FILE_SIZE_LIMIT).is_ok());
446        assert_eq!(
447            SizeLimit::new(MAX_FILE_SIZE_LIMIT + 1),
448            Err(InvalidSizeLimit {
449                value: MAX_FILE_SIZE_LIMIT + 1,
450                max: MAX_FILE_SIZE_LIMIT
451            })
452        );
453    }
454
455    #[test]
456    fn test_size_limit_admits_up_to_and_including_the_limit() {
457        let limit = SizeLimit::from_static(4);
458        assert!(limit.admits(0));
459        assert!(limit.admits(4));
460        assert!(!limit.admits(5));
461        assert!(SizeLimit::UNLIMITED.admits(u64::MAX));
462    }
463
464    #[test]
465    fn test_read_cap_for_is_the_limit_plus_one_and_saturates() {
466        assert_eq!(SizeLimit::read_cap_for(NonZeroU64::new(100).unwrap()), 101);
467        assert_eq!(SizeLimit::read_cap_for(NonZeroU64::MAX), u64::MAX);
468    }
469
470    #[test]
471    fn test_server_start_concurrency_rejects_zero_with_a_typed_error() {
472        assert_eq!(
473            ServerStartConcurrency::new(0),
474            Err(InvalidServerStartConcurrency)
475        );
476        assert_eq!(ServerStartConcurrency::new(3).unwrap().get(), 3);
477    }
478
479    #[test]
480    fn test_size_limit_read_cap_is_limit_plus_one_or_unbounded() {
481        assert_eq!(SizeLimit::from_static(100).read_cap(), 101);
482        assert_eq!(
483            SizeLimit::from_static(MAX_FILE_SIZE_LIMIT).read_cap(),
484            MAX_FILE_SIZE_LIMIT + 1
485        );
486        assert_eq!(SizeLimit::UNLIMITED.read_cap(), u64::MAX);
487    }
488
489    #[test]
490    fn test_size_limit_and_document_limit_serde_use_zero_for_unlimited() {
491        #[derive(Debug, Serialize, Deserialize, PartialEq)]
492        struct Holder {
493            size: SizeLimit,
494            docs: DocumentLimit,
495        }
496
497        let holder: Holder = toml::from_str("size = 0\ndocs = 0").unwrap();
498        assert_eq!(holder.size, SizeLimit::UNLIMITED);
499        assert_eq!(holder.docs, DocumentLimit::UNLIMITED);
500        assert_eq!(
501            toml::to_string(&holder).unwrap().trim(),
502            "size = 0\ndocs = 0"
503        );
504        let holder: Holder = toml::from_str("size = 7\ndocs = 3").unwrap();
505        assert_eq!(holder.size.get().unwrap().get(), 7);
506        assert_eq!(holder.docs.get().unwrap().get(), 3);
507        assert!(toml::from_str::<Holder>("size = 1073741825\ndocs = 1").is_err());
508        assert!(toml::from_str::<Holder>("size = 1\ndocs = -1").is_err());
509    }
510
511    #[test]
512    fn test_defaults_match_documented_values() {
513        assert_eq!(SizeLimit::default().get().unwrap().get(), 10 * 1024 * 1024);
514        assert_eq!(DocumentLimit::default().get().unwrap().get(), 100);
515    }
516
517    #[test]
518    fn test_search_depth_bounds() {
519        assert_eq!(SearchDepth::new(0).unwrap().get(), 0);
520        assert_eq!(
521            SearchDepth::new(MAX_HEURISTICS_DEPTH).unwrap().get(),
522            MAX_HEURISTICS_DEPTH
523        );
524        assert!(SearchDepth::new(MAX_HEURISTICS_DEPTH + 1).is_err());
525        assert_eq!(SearchDepth::default().get(), DEFAULT_HEURISTICS_MAX_DEPTH);
526    }
527
528    #[test]
529    fn test_search_depth_error_reports_range() {
530        let err = SearchDepth::try_from(999_999).unwrap_err();
531        assert_eq!(
532            err.to_string(),
533            "heuristics_max_depth (999999) exceeds the maximum of 64"
534        );
535    }
536
537    #[test]
538    fn test_search_depth_serde() {
539        #[derive(Debug, Serialize, Deserialize, PartialEq)]
540        struct Holder {
541            depth: SearchDepth,
542        }
543
544        let holder: Holder = toml::from_str("depth = 5").unwrap();
545        assert_eq!(holder.depth.get(), 5);
546        assert_eq!(toml::to_string(&holder).unwrap().trim(), "depth = 5");
547        assert!(toml::from_str::<Holder>("depth = 65").is_err());
548        assert!(toml::from_str::<Holder>("depth = -1").is_err());
549    }
550}