Skip to main content

structured_zstd/encoding/
parameters.rs

1//! Fine-grained compression parameters — the drop-in equivalent of C
2//! zstd's advanced `ZSTD_CCtx_setParameter` surface (#27).
3//!
4//! [`CompressionLevel`](crate::encoding::CompressionLevel) selects a
5//! whole tuning preset in one knob. This module exposes the individual
6//! knobs underneath it — window/hash/chain/search logs, the match
7//! strategy, and the long-distance-matching (LDM) block — so callers
8//! can override a level's defaults for domain-specific tuning.
9//!
10//! # Builder
11//!
12//! [`CompressionParameters`] is built through
13//! [`CompressionParameters::builder`], which takes an explicit base
14//! [`CompressionLevel`](crate::encoding::CompressionLevel) (there is no
15//! implicit default). Every knob left unset inherits that base level's
16//! resolved value, so a builder that overrides nothing reproduces plain
17//! level-based compression byte-for-byte.
18//!
19//! ```rust
20//! use structured_zstd::encoding::{CompressionLevel, CompressionParameters, Strategy};
21//!
22//! let params = CompressionParameters::builder(CompressionLevel::Level(19))
23//!     .window_log(22)
24//!     .strategy(Strategy::Btultra2)
25//!     .enable_long_distance_matching(true)
26//!     .build()
27//!     .expect("parameters within bounds");
28//! ```
29//!
30//! # Bounds
31//!
32//! Every knob has an inclusive `[lower, upper]` range, queryable via
33//! [`CParameter::bounds`] (the analogue of `ZSTD_cParam_getBounds`).
34//! [`CompressionParametersBuilder::build`] validates each set knob and
35//! returns [`ParameterError::OutOfBounds`] for the first violation.
36//!
37//! # Long-distance matching (LDM)
38//!
39//! LDM is **off at every [`CompressionLevel`](crate::encoding::CompressionLevel)
40//! preset**, matching upstream `libzstd.so.1` where `ZSTD_compress(..., level)`
41//! never enables LDM — even at level 22. It is activated either by
42//! [`CompressionParametersBuilder::enable_long_distance_matching`] or by any of
43//! the `ldm_*` setters, which each imply `enable_long_distance_matching(true)`.
44//! When enabled, the LDM producer attaches to the optimal (`btopt` / `btultra`
45//! / `btultra2`) match-finder; pair it with an optimal [`Strategy`] (or a level
46//! ≥ 16) for it to take effect.
47
48use crate::encoding::CompressionLevel;
49
50/// Match-finder strategy — the drop-in equivalent of C zstd's
51/// `ZSTD_strategy` enum (`ZSTD_fast` … `ZSTD_btultra2`). The numeric
52/// ordinals match upstream (`fast = 1` … `btultra2 = 9`), so
53/// [`Strategy::ordinal`] / [`Strategy::from_ordinal`] round-trip with
54/// the C `ZSTD_c_strategy` parameter value.
55#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
56pub enum Strategy {
57    /// `ZSTD_fast` (1) — single-table fast finder.
58    Fast,
59    /// `ZSTD_dfast` (2) — two parallel hash tables.
60    Dfast,
61    /// `ZSTD_greedy` (3) — commit the first acceptable match, no lookahead.
62    Greedy,
63    /// `ZSTD_lazy` (4) — one-position lazy lookahead.
64    Lazy,
65    /// `ZSTD_lazy2` (5) — two-position lazy lookahead.
66    Lazy2,
67    /// `ZSTD_btlazy2` (6) — binary-tree-assisted lazy2.
68    Btlazy2,
69    /// `ZSTD_btopt` (7) — optimal parser, no ultra refinements.
70    Btopt,
71    /// `ZSTD_btultra` (8) — optimal parser with refined price tables.
72    Btultra,
73    /// `ZSTD_btultra2` (9) — optimal parser with two-pass dynamic stats.
74    Btultra2,
75}
76
77impl Strategy {
78    /// Upstream `ZSTD_strategy` ordinal (`fast = 1` … `btultra2 = 9`).
79    pub const fn ordinal(self) -> u32 {
80        match self {
81            Self::Fast => 1,
82            Self::Dfast => 2,
83            Self::Greedy => 3,
84            Self::Lazy => 4,
85            Self::Lazy2 => 5,
86            Self::Btlazy2 => 6,
87            Self::Btopt => 7,
88            Self::Btultra => 8,
89            Self::Btultra2 => 9,
90        }
91    }
92
93    /// Construct from an upstream `ZSTD_strategy` ordinal. Returns
94    /// `None` outside `1..=9`.
95    pub const fn from_ordinal(ordinal: u32) -> Option<Self> {
96        Some(match ordinal {
97            1 => Self::Fast,
98            2 => Self::Dfast,
99            3 => Self::Greedy,
100            4 => Self::Lazy,
101            5 => Self::Lazy2,
102            6 => Self::Btlazy2,
103            7 => Self::Btopt,
104            8 => Self::Btultra,
105            9 => Self::Btultra2,
106            _ => return None,
107        })
108    }
109
110    /// Internal runtime strategy tag.
111    pub(crate) const fn tag(self) -> crate::encoding::strategy::StrategyTag {
112        use crate::encoding::strategy::StrategyTag;
113        match self {
114            Self::Fast => StrategyTag::Fast,
115            Self::Dfast => StrategyTag::Dfast,
116            Self::Greedy => StrategyTag::Greedy,
117            // Lazy / Lazy2 ride the runtime `Lazy` tag (the lazy lookahead
118            // depth carries the variance, see `lazy_depth`). `Btlazy2`
119            // keeps its own tag: `Lazy` resolves to the Row finder, while
120            // btlazy2 is a binary-tree search and must stay on the
121            // HashChain/BT storage.
122            Self::Lazy | Self::Lazy2 => StrategyTag::Lazy,
123            Self::Btlazy2 => StrategyTag::Btlazy2,
124            Self::Btopt => StrategyTag::BtOpt,
125            Self::Btultra => StrategyTag::BtUltra,
126            Self::Btultra2 => StrategyTag::BtUltra2,
127        }
128    }
129
130    /// Lazy lookahead depth for the greedy/lazy band (0/1/2). `Optimal`
131    /// strategies report 2 (the depth their hash-chain seed walk runs at).
132    pub(crate) const fn lazy_depth(self) -> u8 {
133        match self {
134            Self::Fast | Self::Dfast | Self::Greedy => 0,
135            Self::Lazy => 1,
136            _ => 2,
137        }
138    }
139}
140
141/// Whether literals are entropy-coded, the drop-in equivalent of C zstd's
142/// `ZSTD_c_literalCompressionMode` (`ZSTD_ps_auto` / `ZSTD_ps_enable` /
143/// `ZSTD_ps_disable`).
144#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
145pub enum LiteralCompressionMode {
146    /// The level decides: literals are stored raw on the fast strategy's
147    /// acceleration (negative) levels, where the Huffman pass costs more
148    /// speed than it saves, and compressed everywhere else.
149    #[default]
150    Auto,
151    /// Compress literals on every level, the negative ones included. A
152    /// block whose literals do not shrink still stores them raw.
153    Enable,
154    /// Never compress literals: every literal section is stored raw.
155    Disable,
156}
157
158/// One tunable compression parameter — the analogue of a C zstd
159/// `ZSTD_cParameter`. Used to query bounds via [`CParameter::bounds`].
160#[derive(Copy, Clone, Debug, PartialEq, Eq)]
161#[non_exhaustive]
162pub enum CParameter {
163    /// Maximum back-reference distance, `log2`. C `ZSTD_c_windowLog`.
164    WindowLog,
165    /// Match-finder hash table size, `log2`. C `ZSTD_c_hashLog`.
166    HashLog,
167    /// Match-finder chain table size, `log2`. C `ZSTD_c_chainLog`.
168    ChainLog,
169    /// Number of search attempts, `log2`. C `ZSTD_c_searchLog`.
170    SearchLog,
171    /// Minimum match length in bytes. C `ZSTD_c_minMatch`.
172    MinMatch,
173    /// "Good enough" match length that ends the search. C `ZSTD_c_targetLength`.
174    TargetLength,
175    /// Match-finder [`Strategy`] (1..=9). C `ZSTD_c_strategy`.
176    Strategy,
177    /// LDM enable flag (0/1). C `ZSTD_c_enableLongDistanceMatching`.
178    EnableLongDistanceMatching,
179    /// LDM hash table size, `log2`. C `ZSTD_c_ldmHashLog`.
180    LdmHashLog,
181    /// LDM minimum match length in bytes. C `ZSTD_c_ldmMinMatch`.
182    LdmMinMatch,
183    /// LDM bucket size, `log2`. C `ZSTD_c_ldmBucketSizeLog`.
184    LdmBucketSizeLog,
185    /// LDM hash-insertion rate, `log2`. C `ZSTD_c_ldmHashRateLog`.
186    LdmHashRateLog,
187}
188
189/// Inclusive `[lower_bound, upper_bound]` range for a [`CParameter`],
190/// the drop-in equivalent of C zstd's `ZSTD_bounds`.
191#[derive(Copy, Clone, Debug, PartialEq, Eq)]
192pub struct Bounds {
193    /// Smallest accepted value (inclusive).
194    pub lower_bound: i64,
195    /// Largest accepted value (inclusive).
196    pub upper_bound: i64,
197}
198
199impl Bounds {
200    /// Whether `value` falls within `[lower_bound, upper_bound]`.
201    pub const fn contains(&self, value: i64) -> bool {
202        value >= self.lower_bound && value <= self.upper_bound
203    }
204}
205
206impl CParameter {
207    /// Inclusive value bounds for this parameter, mirroring
208    /// `ZSTD_cParam_getBounds`. Window/hash/chain logs cap at 30 (the
209    /// encoder's match-finder ceiling) rather than the 31 C allows on
210    /// 64-bit, because the back-reference history is indexed with `u32`
211    /// positions over a `2 * window` eviction band.
212    pub const fn bounds(self) -> Bounds {
213        let (lower_bound, upper_bound) = match self {
214            // ZSTD_WINDOWLOG_MIN .. encoder ceiling.
215            Self::WindowLog => (10, 30),
216            // ZSTD_HASHLOG_MIN .. ZSTD_HASHLOG_MAX.
217            Self::HashLog => (6, 30),
218            // ZSTD_CHAINLOG_MIN .. ZSTD_CHAINLOG_MAX (64-bit).
219            Self::ChainLog => (6, 30),
220            // ZSTD_SEARCHLOG_MIN .. ZSTD_SEARCHLOG_MAX (64-bit).
221            Self::SearchLog => (1, 30),
222            // ZSTD_MINMATCH_MIN .. ZSTD_MINMATCH_MAX.
223            Self::MinMatch => (3, 7),
224            // ZSTD_TARGETLENGTH_MIN .. ZSTD_TARGETLENGTH_MAX.
225            Self::TargetLength => (0, 131_072),
226            // ZSTD_fast .. ZSTD_btultra2.
227            Self::Strategy => (1, 9),
228            // Boolean flag.
229            Self::EnableLongDistanceMatching => (0, 1),
230            // ZSTD_LDM_HASHLOG_MIN .. ZSTD_LDM_HASHLOG_MAX.
231            Self::LdmHashLog => (6, 30),
232            // ZSTD_LDM_MINMATCH_MIN .. ZSTD_LDM_MINMATCH_MAX.
233            Self::LdmMinMatch => (4, 4096),
234            // ZSTD_LDM_BUCKETSIZELOG_MIN .. ZSTD_LDM_BUCKETSIZELOG_MAX.
235            Self::LdmBucketSizeLog => (1, 8),
236            // ZSTD_LDM_HASHRATELOG_MIN .. ZSTD_WINDOWLOG_MAX - ZSTD_HASHLOG_MIN.
237            Self::LdmHashRateLog => (0, 24),
238        };
239        Bounds {
240            lower_bound,
241            upper_bound,
242        }
243    }
244}
245
246/// Error returned by [`CompressionParametersBuilder::build`] when a knob
247/// is set outside its [`CParameter::bounds`].
248#[derive(Copy, Clone, Debug, PartialEq, Eq)]
249#[non_exhaustive]
250pub enum ParameterError {
251    /// A parameter was set to a value outside its inclusive bounds.
252    OutOfBounds {
253        /// Which parameter violated its range.
254        parameter: CParameter,
255        /// The rejected value.
256        value: i64,
257        /// The inclusive `[lower, upper]` range it had to fall within.
258        bounds: Bounds,
259    },
260}
261
262impl core::fmt::Display for ParameterError {
263    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
264        match self {
265            Self::OutOfBounds {
266                parameter,
267                value,
268                bounds,
269            } => write!(
270                f,
271                "compression parameter {parameter:?} = {value} out of bounds \
272                 [{}, {}]",
273                bounds.lower_bound, bounds.upper_bound
274            ),
275        }
276    }
277}
278
279#[cfg(feature = "std")]
280impl std::error::Error for ParameterError {}
281
282/// LDM tuning overrides — every knob is `Option`, falling back to the
283/// strategy-derived upstream zstd default (`LdmParams::adjust_for`) when unset.
284#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
285pub(crate) struct LdmOverride {
286    pub(crate) hash_log: Option<u32>,
287    pub(crate) min_match: Option<u32>,
288    pub(crate) bucket_size_log: Option<u32>,
289    pub(crate) hash_rate_log: Option<u32>,
290}
291
292/// Internal per-knob override set consumed by the match-generator's
293/// `reset` path. Every field left `None` inherits the base level's
294/// resolved value, so the default path is byte-identical to level-based
295/// compression.
296#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
297pub(crate) struct ParamOverrides {
298    pub(crate) window_log: Option<u8>,
299    pub(crate) hash_log: Option<u32>,
300    pub(crate) chain_log: Option<u32>,
301    pub(crate) search_log: Option<u32>,
302    pub(crate) min_match: Option<u32>,
303    pub(crate) target_length: Option<u32>,
304    pub(crate) strategy: Option<Strategy>,
305    /// `Some` when `enable_long_distance_matching(true)` was set; carries
306    /// the (possibly empty) LDM knob overrides.
307    pub(crate) ldm: Option<LdmOverride>,
308    /// Whether literals are entropy-coded; `Auto` leaves it to the level.
309    pub(crate) literal_compression: LiteralCompressionMode,
310}
311
312impl ParamOverrides {
313    /// Whether any knob overrides the base level. An all-`None`
314    /// override is a no-op the `reset` path can skip entirely, keeping
315    /// the default level-based geometry byte-identical.
316    pub(crate) fn is_empty(&self) -> bool {
317        self.window_log.is_none()
318            && self.hash_log.is_none()
319            && self.chain_log.is_none()
320            && self.search_log.is_none()
321            && self.min_match.is_none()
322            && self.target_length.is_none()
323            && self.strategy.is_none()
324            && self.ldm.is_none()
325            && self.literal_compression == LiteralCompressionMode::Auto
326    }
327}
328
329/// Fully-resolved fine-grained compression parameters. Build through
330/// [`CompressionParameters::builder`]; pass to
331/// [`FrameCompressor::set_parameters`](crate::encoding::FrameCompressor::set_parameters)
332/// or [`compress_with_parameters`](crate::encoding::compress_with_parameters).
333///
334/// Wraps a base [`CompressionLevel`](crate::encoding::CompressionLevel)
335/// plus the set of knobs that override it. A parameter set that
336/// overrides nothing is equivalent to compressing at its base level.
337#[derive(Copy, Clone, Debug, PartialEq, Eq)]
338pub struct CompressionParameters {
339    level: CompressionLevel,
340    overrides: ParamOverrides,
341}
342
343impl CompressionParameters {
344    /// Start a builder from a base compression level. Knobs left unset
345    /// inherit that level's resolved defaults.
346    pub fn builder(level: CompressionLevel) -> CompressionParametersBuilder {
347        CompressionParametersBuilder {
348            level,
349            window_log: None,
350            hash_log: None,
351            chain_log: None,
352            search_log: None,
353            min_match: None,
354            target_length: None,
355            strategy: None,
356            enable_ldm: false,
357            ldm: LdmOverride::default(),
358            literal_compression: LiteralCompressionMode::Auto,
359        }
360    }
361
362    /// The base compression level these parameters override.
363    pub fn level(&self) -> CompressionLevel {
364        self.level
365    }
366
367    /// Whether long-distance matching is enabled.
368    pub fn long_distance_matching_enabled(&self) -> bool {
369        self.overrides.ldm.is_some()
370    }
371
372    /// How literals are entropy-coded (see [`LiteralCompressionMode`]).
373    pub fn literal_compression_mode(&self) -> LiteralCompressionMode {
374        self.overrides.literal_compression
375    }
376
377    pub(crate) fn overrides(&self) -> ParamOverrides {
378        self.overrides
379    }
380}
381
382/// Builder for [`CompressionParameters`]. Each setter records one knob;
383/// [`Self::build`] validates them against [`CParameter::bounds`].
384#[derive(Copy, Clone, Debug)]
385pub struct CompressionParametersBuilder {
386    level: CompressionLevel,
387    window_log: Option<u32>,
388    hash_log: Option<u32>,
389    chain_log: Option<u32>,
390    search_log: Option<u32>,
391    min_match: Option<u32>,
392    target_length: Option<u32>,
393    strategy: Option<Strategy>,
394    enable_ldm: bool,
395    ldm: LdmOverride,
396    literal_compression: LiteralCompressionMode,
397}
398
399impl CompressionParametersBuilder {
400    /// Decide whether literals are entropy-coded, overriding the level's own
401    /// choice. C `ZSTD_c_literalCompressionMode`.
402    ///
403    /// ```rust
404    /// use structured_zstd::encoding::{
405    ///     compress_with_parameters, CompressionLevel, CompressionParameters,
406    ///     LiteralCompressionMode,
407    /// };
408    ///
409    /// // Literal-heavy input: 32 distinct symbols and nothing for the match
410    /// // finder to repeat, so only entropy-coding the literals shrinks it.
411    /// let text: Vec<u8> = (0..8192u32)
412    ///     .map(|i| b'a' + (i.wrapping_mul(2_654_435_761) >> 27) as u8)
413    ///     .collect();
414    /// // Level -3 stores literals raw by default; asking for compression
415    /// // makes the frame smaller on input like this.
416    /// let compressed_literals = CompressionParameters::builder(CompressionLevel::Level(-3))
417    ///     .literal_compression(LiteralCompressionMode::Enable)
418    ///     .build()
419    ///     .unwrap();
420    /// let plain = compress_with_parameters(
421    ///     &text[..],
422    ///     &CompressionParameters::builder(CompressionLevel::Level(-3)).build().unwrap(),
423    /// );
424    /// let coded = compress_with_parameters(&text[..], &compressed_literals);
425    /// assert!(coded.len() < plain.len());
426    /// ```
427    pub fn literal_compression(mut self, mode: LiteralCompressionMode) -> Self {
428        self.literal_compression = mode;
429        self
430    }
431
432    /// Override the maximum back-reference distance (`log2`). C
433    /// `ZSTD_c_windowLog`.
434    pub fn window_log(mut self, value: u32) -> Self {
435        self.window_log = Some(value);
436        self
437    }
438
439    /// Override the match-finder hash table size (`log2`). C `ZSTD_c_hashLog`.
440    pub fn hash_log(mut self, value: u32) -> Self {
441        self.hash_log = Some(value);
442        self
443    }
444
445    /// Override the match-finder chain table size (`log2`). C `ZSTD_c_chainLog`.
446    pub fn chain_log(mut self, value: u32) -> Self {
447        self.chain_log = Some(value);
448        self
449    }
450
451    /// Override the search-attempts count (`log2`). C `ZSTD_c_searchLog`.
452    pub fn search_log(mut self, value: u32) -> Self {
453        self.search_log = Some(value);
454        self
455    }
456
457    /// Override the minimum match length in bytes. C `ZSTD_c_minMatch`.
458    pub fn min_match(mut self, value: u32) -> Self {
459        self.min_match = Some(value);
460        self
461    }
462
463    /// Override the "good enough" target match length. C `ZSTD_c_targetLength`.
464    pub fn target_length(mut self, value: u32) -> Self {
465        self.target_length = Some(value);
466        self
467    }
468
469    /// Override the match-finder [`Strategy`]. C `ZSTD_c_strategy`.
470    pub fn strategy(mut self, value: Strategy) -> Self {
471        self.strategy = Some(value);
472        self
473    }
474
475    /// Enable or disable long-distance matching. C
476    /// `ZSTD_c_enableLongDistanceMatching`. Off at every level preset.
477    /// This is the explicit activation toggle; the `ldm_*` knob setters
478    /// also enable LDM implicitly. The flag is plain last-write-wins, so
479    /// a trailing `enable_long_distance_matching(false)` disables LDM even
480    /// if an earlier `ldm_*` call set a knob (the knob is then ignored at
481    /// [`build`](Self::build)).
482    pub fn enable_long_distance_matching(mut self, enable: bool) -> Self {
483        self.enable_ldm = enable;
484        self
485    }
486
487    /// Override the LDM hash table size (`log2`). C `ZSTD_c_ldmHashLog`.
488    /// Implies [`Self::enable_long_distance_matching(true)`](Self::enable_long_distance_matching).
489    pub fn ldm_hash_log(mut self, value: u32) -> Self {
490        self.enable_ldm = true;
491        self.ldm.hash_log = Some(value);
492        self
493    }
494
495    /// Override the LDM minimum match length. C `ZSTD_c_ldmMinMatch`.
496    /// Implies [`Self::enable_long_distance_matching(true)`](Self::enable_long_distance_matching).
497    pub fn ldm_min_match(mut self, value: u32) -> Self {
498        self.enable_ldm = true;
499        self.ldm.min_match = Some(value);
500        self
501    }
502
503    /// Override the LDM bucket size (`log2`). C `ZSTD_c_ldmBucketSizeLog`.
504    /// Implies [`Self::enable_long_distance_matching(true)`](Self::enable_long_distance_matching).
505    pub fn ldm_bucket_size_log(mut self, value: u32) -> Self {
506        self.enable_ldm = true;
507        self.ldm.bucket_size_log = Some(value);
508        self
509    }
510
511    /// Override the LDM hash-insertion rate (`log2`). C `ZSTD_c_ldmHashRateLog`.
512    /// Implies [`Self::enable_long_distance_matching(true)`](Self::enable_long_distance_matching).
513    pub fn ldm_hash_rate_log(mut self, value: u32) -> Self {
514        self.enable_ldm = true;
515        self.ldm.hash_rate_log = Some(value);
516        self
517    }
518
519    /// Validate every set knob against [`CParameter::bounds`] and
520    /// produce the resolved [`CompressionParameters`].
521    ///
522    /// # Errors
523    ///
524    /// Returns [`ParameterError::OutOfBounds`] for the first knob whose
525    /// value falls outside its inclusive range.
526    pub fn build(self) -> Result<CompressionParameters, ParameterError> {
527        check(CParameter::WindowLog, self.window_log)?;
528        check(CParameter::HashLog, self.hash_log)?;
529        check(CParameter::ChainLog, self.chain_log)?;
530        check(CParameter::SearchLog, self.search_log)?;
531        check(CParameter::MinMatch, self.min_match)?;
532        check(CParameter::TargetLength, self.target_length)?;
533        if let Some(s) = self.strategy {
534            check(CParameter::Strategy, Some(s.ordinal()))?;
535        }
536        let ldm = if self.enable_ldm {
537            check(CParameter::LdmHashLog, self.ldm.hash_log)?;
538            check(CParameter::LdmMinMatch, self.ldm.min_match)?;
539            check(CParameter::LdmBucketSizeLog, self.ldm.bucket_size_log)?;
540            check(CParameter::LdmHashRateLog, self.ldm.hash_rate_log)?;
541            Some(self.ldm)
542        } else {
543            None
544        };
545        Ok(CompressionParameters {
546            level: self.level,
547            overrides: ParamOverrides {
548                // `window_log` is bounds-checked at <= 30, so the cast is lossless.
549                window_log: self.window_log.map(|v| v as u8),
550                hash_log: self.hash_log,
551                chain_log: self.chain_log,
552                search_log: self.search_log,
553                min_match: self.min_match,
554                target_length: self.target_length,
555                strategy: self.strategy,
556                ldm,
557                literal_compression: self.literal_compression,
558            },
559        })
560    }
561}
562
563/// Validate one optional knob against its bounds.
564fn check(parameter: CParameter, value: Option<u32>) -> Result<(), ParameterError> {
565    if let Some(value) = value {
566        let bounds = parameter.bounds();
567        let value = i64::from(value);
568        if !bounds.contains(value) {
569            return Err(ParameterError::OutOfBounds {
570                parameter,
571                value,
572                bounds,
573            });
574        }
575    }
576    Ok(())
577}
578
579#[cfg(test)]
580mod tests;