nextest_runner/config/
nextest_version.rs

1// Copyright (c) The nextest Contributors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Nextest version configuration.
5
6use super::{NextestConfig, ToolConfigFile};
7use crate::errors::{ConfigParseError, ConfigParseErrorKind};
8use camino::Utf8Path;
9use semver::Version;
10use serde::{Deserialize, Deserializer};
11use std::{borrow::Cow, collections::BTreeSet, fmt, str::FromStr};
12
13/// A "version-only" form of the nextest configuration.
14///
15/// This is used as a first pass to determine the required nextest version before parsing the rest
16/// of the configuration. That avoids issues parsing incompatible configuration.
17#[derive(Debug, Default, Clone, PartialEq, Eq)]
18pub struct VersionOnlyConfig {
19    /// The nextest version configuration.
20    nextest_version: NextestVersionConfig,
21
22    /// Experimental features enabled.
23    experimental: BTreeSet<ConfigExperimental>,
24}
25
26impl VersionOnlyConfig {
27    /// Reads the nextest version configuration from the given sources.
28    ///
29    /// See [`NextestConfig::from_sources`] for more details.
30    pub fn from_sources<'a, I>(
31        workspace_root: &Utf8Path,
32        config_file: Option<&Utf8Path>,
33        tool_config_files: impl IntoIterator<IntoIter = I>,
34    ) -> Result<Self, ConfigParseError>
35    where
36        I: Iterator<Item = &'a ToolConfigFile> + DoubleEndedIterator,
37    {
38        let tool_config_files_rev = tool_config_files.into_iter().rev();
39
40        Self::read_from_sources(workspace_root, config_file, tool_config_files_rev)
41    }
42
43    /// Returns the nextest version requirement.
44    pub fn nextest_version(&self) -> &NextestVersionConfig {
45        &self.nextest_version
46    }
47
48    /// Returns the experimental features enabled.
49    pub fn experimental(&self) -> &BTreeSet<ConfigExperimental> {
50        &self.experimental
51    }
52
53    fn read_from_sources<'a>(
54        workspace_root: &Utf8Path,
55        config_file: Option<&Utf8Path>,
56        tool_config_files_rev: impl Iterator<Item = &'a ToolConfigFile>,
57    ) -> Result<Self, ConfigParseError> {
58        let mut nextest_version = NextestVersionConfig::default();
59        let mut experimental = BTreeSet::new();
60
61        // Merge in tool configs.
62        for ToolConfigFile { config_file, tool } in tool_config_files_rev {
63            if let Some(v) = Self::read_and_deserialize(config_file, Some(tool))?.nextest_version {
64                nextest_version.accumulate(v, Some(tool));
65            }
66        }
67
68        // Finally, merge in the repo config.
69        let config_file = match config_file {
70            Some(file) => Some(Cow::Borrowed(file)),
71            None => {
72                let config_file = workspace_root.join(NextestConfig::CONFIG_PATH);
73                config_file.exists().then_some(Cow::Owned(config_file))
74            }
75        };
76        if let Some(config_file) = config_file {
77            let d = Self::read_and_deserialize(&config_file, None)?;
78            if let Some(v) = d.nextest_version {
79                nextest_version.accumulate(v, None);
80            }
81
82            // Check for unknown features.
83            let unknown: BTreeSet<_> = d
84                .experimental
85                .into_iter()
86                .filter(|feature| {
87                    if let Ok(feature) = feature.parse::<ConfigExperimental>() {
88                        experimental.insert(feature);
89                        false
90                    } else {
91                        true
92                    }
93                })
94                .collect();
95            if !unknown.is_empty() {
96                let known = ConfigExperimental::known().collect();
97                return Err(ConfigParseError::new(
98                    config_file.into_owned(),
99                    None,
100                    ConfigParseErrorKind::UnknownExperimentalFeatures { unknown, known },
101                ));
102            }
103        }
104
105        Ok(Self {
106            nextest_version,
107            experimental,
108        })
109    }
110
111    fn read_and_deserialize(
112        config_file: &Utf8Path,
113        tool: Option<&str>,
114    ) -> Result<VersionOnlyDeserialize, ConfigParseError> {
115        let toml_str = std::fs::read_to_string(config_file.as_str()).map_err(|error| {
116            ConfigParseError::new(
117                config_file,
118                tool,
119                ConfigParseErrorKind::VersionOnlyReadError(error),
120            )
121        })?;
122        let toml_de = toml::de::Deserializer::new(&toml_str);
123        let v: VersionOnlyDeserialize =
124            serde_path_to_error::deserialize(toml_de).map_err(|error| {
125                ConfigParseError::new(
126                    config_file,
127                    tool,
128                    ConfigParseErrorKind::VersionOnlyDeserializeError(Box::new(error)),
129                )
130            })?;
131        if tool.is_some() && !v.experimental.is_empty() {
132            return Err(ConfigParseError::new(
133                config_file,
134                tool,
135                ConfigParseErrorKind::ExperimentalFeaturesInToolConfig {
136                    features: v.experimental,
137                },
138            ));
139        }
140
141        Ok(v)
142    }
143}
144
145/// A version of configuration that only deserializes the nextest version.
146#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize)]
147#[serde(rename_all = "kebab-case")]
148struct VersionOnlyDeserialize {
149    #[serde(default)]
150    nextest_version: Option<NextestVersionDeserialize>,
151    #[serde(default)]
152    experimental: BTreeSet<String>,
153}
154
155/// Nextest version configuration.
156///
157/// Similar to the [`rust-version`
158/// field](https://doc.rust-lang.org/cargo/reference/manifest.html#the-rust-version-field),
159/// `nextest-version` lets you specify the minimum required version of nextest for a repository.
160#[derive(Debug, Default, Clone, PartialEq, Eq)]
161pub struct NextestVersionConfig {
162    /// The minimum version of nextest to produce an error before.
163    pub required: NextestVersionReq,
164
165    /// The minimum version of nextest to produce a warning before.
166    ///
167    /// This might be lower than [`Self::required`], in which case it is ignored. [`Self::eval`]
168    /// checks for required versions before it checks for recommended versions.
169    pub recommended: NextestVersionReq,
170}
171
172impl NextestVersionConfig {
173    /// Accumulates a deserialized version requirement into this configuration.
174    pub(crate) fn accumulate(&mut self, v: NextestVersionDeserialize, v_tool: Option<&str>) {
175        if let Some(v) = v.required {
176            self.required.accumulate(v, v_tool);
177        }
178        if let Some(v) = v.recommended {
179            self.recommended.accumulate(v, v_tool);
180        }
181    }
182
183    /// Returns whether the given version satisfies the nextest version requirement.
184    pub fn eval(
185        &self,
186        current_version: &Version,
187        override_version_check: bool,
188    ) -> NextestVersionEval {
189        match self.required.satisfies(current_version) {
190            Ok(()) => {}
191            Err((required, tool)) => {
192                if override_version_check {
193                    return NextestVersionEval::ErrorOverride {
194                        required: required.clone(),
195                        current: current_version.clone(),
196                        tool: tool.map(|s| s.to_owned()),
197                    };
198                } else {
199                    return NextestVersionEval::Error {
200                        required: required.clone(),
201                        current: current_version.clone(),
202                        tool: tool.map(|s| s.to_owned()),
203                    };
204                }
205            }
206        }
207
208        match self.recommended.satisfies(current_version) {
209            Ok(()) => NextestVersionEval::Satisfied,
210            Err((recommended, tool)) => {
211                if override_version_check {
212                    NextestVersionEval::WarnOverride {
213                        recommended: recommended.clone(),
214                        current: current_version.clone(),
215                        tool: tool.map(|s| s.to_owned()),
216                    }
217                } else {
218                    NextestVersionEval::Warn {
219                        recommended: recommended.clone(),
220                        current: current_version.clone(),
221                        tool: tool.map(|s| s.to_owned()),
222                    }
223                }
224            }
225        }
226    }
227}
228
229/// Experimental configuration features.
230#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Ord, PartialOrd)]
231#[non_exhaustive]
232pub enum ConfigExperimental {
233    /// Enable support for setup scripts.
234    SetupScripts,
235    /// Enable support for wrapper scripts.
236    WrapperScripts,
237}
238
239impl ConfigExperimental {
240    fn known() -> impl Iterator<Item = Self> {
241        vec![Self::SetupScripts, Self::WrapperScripts].into_iter()
242    }
243}
244
245impl FromStr for ConfigExperimental {
246    type Err = ();
247
248    fn from_str(s: &str) -> Result<Self, Self::Err> {
249        match s {
250            "setup-scripts" => Ok(Self::SetupScripts),
251            "wrapper-scripts" => Ok(Self::WrapperScripts),
252            _ => Err(()),
253        }
254    }
255}
256
257impl fmt::Display for ConfigExperimental {
258    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
259        match self {
260            Self::SetupScripts => write!(f, "setup-scripts"),
261            Self::WrapperScripts => write!(f, "wrapper-scripts"),
262        }
263    }
264}
265
266/// Specification for a nextest version. Part of [`NextestVersionConfig`].
267#[derive(Debug, Default, Clone, PartialEq, Eq)]
268pub enum NextestVersionReq {
269    /// A version was specified.
270    Version {
271        /// The version to warn before.
272        version: Version,
273
274        /// The tool which produced this version specification.
275        tool: Option<String>,
276    },
277
278    /// No version was specified.
279    #[default]
280    None,
281}
282
283impl NextestVersionReq {
284    fn accumulate(&mut self, v: Version, v_tool: Option<&str>) {
285        match self {
286            NextestVersionReq::Version { version, tool } => {
287                // This is v >= version rather than v > version, so that if multiple tools specify
288                // the same version, the last tool wins.
289                if &v >= version {
290                    *version = v;
291                    *tool = v_tool.map(|s| s.to_owned());
292                }
293            }
294            NextestVersionReq::None => {
295                *self = NextestVersionReq::Version {
296                    version: v,
297                    tool: v_tool.map(|s| s.to_owned()),
298                };
299            }
300        }
301    }
302
303    fn satisfies(&self, version: &Version) -> Result<(), (&Version, Option<&str>)> {
304        match self {
305            NextestVersionReq::Version {
306                version: required,
307                tool,
308            } => {
309                if version >= required {
310                    Ok(())
311                } else {
312                    Err((required, tool.as_deref()))
313                }
314            }
315            NextestVersionReq::None => Ok(()),
316        }
317    }
318}
319
320/// The result of checking whether a [`NextestVersionConfig`] satisfies a requirement.
321///
322/// Returned by [`NextestVersionConfig::eval`].
323#[derive(Debug, Clone, PartialEq, Eq)]
324pub enum NextestVersionEval {
325    /// The version satisfies the requirement.
326    Satisfied,
327
328    /// An error should be produced.
329    Error {
330        /// The minimum version required.
331        required: Version,
332        /// The current version.
333        current: Version,
334        /// The tool which produced this version specification.
335        tool: Option<String>,
336    },
337
338    /// A warning should be produced.
339    Warn {
340        /// The minimum version recommended.
341        recommended: Version,
342        /// The current version.
343        current: Version,
344        /// The tool which produced this version specification.
345        tool: Option<String>,
346    },
347
348    /// An error should be produced but the version is overridden.
349    ErrorOverride {
350        /// The minimum version recommended.
351        required: Version,
352        /// The current version.
353        current: Version,
354        /// The tool which produced this version specification.
355        tool: Option<String>,
356    },
357
358    /// A warning should be produced but the version is overridden.
359    WarnOverride {
360        /// The minimum version recommended.
361        recommended: Version,
362        /// The current version.
363        current: Version,
364        /// The tool which produced this version specification.
365        tool: Option<String>,
366    },
367}
368
369/// Nextest version configuration.
370///
371/// Similar to the [`rust-version`
372/// field](https://doc.rust-lang.org/cargo/reference/manifest.html#the-rust-version-field),
373/// `nextest-version` lets you specify the minimum required version of nextest for a repository.
374#[derive(Debug, Clone, PartialEq, Eq)]
375pub(crate) struct NextestVersionDeserialize {
376    /// The minimum version of nextest that this repository requires.
377    required: Option<Version>,
378
379    /// The minimum version of nextest that this repository produces a warning against.
380    recommended: Option<Version>,
381}
382
383impl<'de> Deserialize<'de> for NextestVersionDeserialize {
384    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
385    where
386        D: Deserializer<'de>,
387    {
388        struct V;
389
390        impl<'de2> serde::de::Visitor<'de2> for V {
391            type Value = NextestVersionDeserialize;
392
393            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
394                formatter.write_str(
395                    "a table ({{ required = \"0.9.20\", recommended = \"0.9.30\" }}) or a string (\"0.9.50\")",
396                )
397            }
398
399            fn visit_str<E>(self, s: &str) -> std::result::Result<Self::Value, E>
400            where
401                E: serde::de::Error,
402            {
403                let required = parse_version::<E>(s.to_owned())?;
404                Ok(NextestVersionDeserialize {
405                    required: Some(required),
406                    recommended: None,
407                })
408            }
409
410            fn visit_map<A>(self, map: A) -> std::result::Result<Self::Value, A::Error>
411            where
412                A: serde::de::MapAccess<'de2>,
413            {
414                #[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
415                struct NextestVersionMap {
416                    #[serde(default, deserialize_with = "deserialize_version_opt")]
417                    required: Option<Version>,
418                    #[serde(default, deserialize_with = "deserialize_version_opt")]
419                    recommended: Option<Version>,
420                }
421
422                let NextestVersionMap {
423                    required,
424                    recommended,
425                } = NextestVersionMap::deserialize(serde::de::value::MapAccessDeserializer::new(
426                    map,
427                ))?;
428
429                if let (Some(required), Some(recommended)) = (&required, &recommended) {
430                    if required > recommended {
431                        return Err(serde::de::Error::custom(format!(
432                            "required version ({required}) must not be greater than recommended version ({recommended})"
433                        )));
434                    }
435                }
436
437                Ok(NextestVersionDeserialize {
438                    required,
439                    recommended,
440                })
441            }
442        }
443
444        deserializer.deserialize_any(V)
445    }
446}
447
448/// This has similar logic to the [`rust-version`
449/// field](https://doc.rust-lang.org/cargo/reference/manifest.html#the-rust-version-field).
450///
451/// Adapted from cargo_metadata
452fn deserialize_version_opt<'de, D>(
453    deserializer: D,
454) -> std::result::Result<Option<Version>, D::Error>
455where
456    D: Deserializer<'de>,
457{
458    let s = Option::<String>::deserialize(deserializer)?;
459    s.map(parse_version::<D::Error>).transpose()
460}
461
462fn parse_version<E>(mut s: String) -> std::result::Result<Version, E>
463where
464    E: serde::de::Error,
465{
466    for ch in s.chars() {
467        if ch == '-' {
468            return Err(E::custom(
469                "pre-release identifiers are not supported in nextest-version",
470            ));
471        } else if ch == '+' {
472            return Err(E::custom(
473                "build metadata is not supported in nextest-version",
474            ));
475        }
476    }
477
478    // The major.minor format is not used with nextest 0.9, but support it anyway to match
479    // rust-version.
480    if s.matches('.').count() == 1 {
481        // e.g. 1.0 -> 1.0.0
482        s.push_str(".0");
483    }
484
485    Version::parse(&s).map_err(E::custom)
486}
487
488#[cfg(test)]
489mod tests {
490    use super::*;
491    use test_case::test_case;
492
493    #[test_case(
494        r#"
495            nextest-version = "0.9"
496        "#,
497        NextestVersionDeserialize { required: Some("0.9.0".parse().unwrap()), recommended: None } ; "basic"
498    )]
499    #[test_case(
500        r#"
501            nextest-version = "0.9.30"
502        "#,
503        NextestVersionDeserialize { required: Some("0.9.30".parse().unwrap()), recommended: None } ; "basic with patch"
504    )]
505    #[test_case(
506        r#"
507            nextest-version = { recommended = "0.9.20" }
508        "#,
509        NextestVersionDeserialize { required: None, recommended: Some("0.9.20".parse().unwrap()) } ; "with warning"
510    )]
511    #[test_case(
512        r#"
513            nextest-version = { required = "0.9.20", recommended = "0.9.25" }
514        "#,
515        NextestVersionDeserialize {
516            required: Some("0.9.20".parse().unwrap()),
517            recommended: Some("0.9.25".parse().unwrap()),
518        } ; "with error and warning"
519    )]
520    fn test_valid_nextest_version(input: &str, expected: NextestVersionDeserialize) {
521        let actual: VersionOnlyDeserialize = toml::from_str(input).unwrap();
522        assert_eq!(actual.nextest_version.unwrap(), expected);
523    }
524
525    #[test_case(
526        r#"
527            nextest-version = 42
528        "#,
529        "a table ({{ required = \"0.9.20\", recommended = \"0.9.30\" }}) or a string (\"0.9.50\")" ; "empty"
530    )]
531    #[test_case(
532        r#"
533            nextest-version = "0.9.30-rc.1"
534        "#,
535        "pre-release identifiers are not supported in nextest-version" ; "pre-release"
536    )]
537    #[test_case(
538        r#"
539            nextest-version = "0.9.40+mybuild"
540        "#,
541        "build metadata is not supported in nextest-version" ; "build metadata"
542    )]
543    #[test_case(
544        r#"
545            nextest-version = { required = "0.9.20", recommended = "0.9.10" }
546        "#,
547        "required version (0.9.20) must not be greater than recommended version (0.9.10)" ; "error greater than warning"
548    )]
549    fn test_invalid_nextest_version(input: &str, error_message: &str) {
550        let err = toml::from_str::<VersionOnlyDeserialize>(input).unwrap_err();
551        assert!(
552            err.to_string().contains(error_message),
553            "error `{err}` contains `{error_message}`"
554        );
555    }
556
557    #[test]
558    fn test_accumulate() {
559        let mut nextest_version = NextestVersionConfig::default();
560        nextest_version.accumulate(
561            NextestVersionDeserialize {
562                required: Some("0.9.20".parse().unwrap()),
563                recommended: None,
564            },
565            Some("tool1"),
566        );
567        nextest_version.accumulate(
568            NextestVersionDeserialize {
569                required: Some("0.9.30".parse().unwrap()),
570                recommended: Some("0.9.35".parse().unwrap()),
571            },
572            Some("tool2"),
573        );
574        nextest_version.accumulate(
575            NextestVersionDeserialize {
576                required: None,
577                // This recommended version is ignored since it is less than the last recommended
578                // version.
579                recommended: Some("0.9.25".parse().unwrap()),
580            },
581            Some("tool3"),
582        );
583        nextest_version.accumulate(
584            NextestVersionDeserialize {
585                // This is accepted because it is the same as the last required version, and the
586                // last tool wins.
587                required: Some("0.9.30".parse().unwrap()),
588                recommended: None,
589            },
590            Some("tool4"),
591        );
592
593        assert_eq!(
594            nextest_version,
595            NextestVersionConfig {
596                required: NextestVersionReq::Version {
597                    version: "0.9.30".parse().unwrap(),
598                    tool: Some("tool4".to_owned()),
599                },
600                recommended: NextestVersionReq::Version {
601                    version: "0.9.35".parse().unwrap(),
602                    tool: Some("tool2".to_owned()),
603                },
604            }
605        );
606    }
607}