nextest_runner/config/core/
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, ToolName};
7use crate::errors::{ConfigParseError, ConfigParseErrorKind};
8use camino::{Utf8Path, Utf8PathBuf};
9use semver::Version;
10use serde::{
11    Deserialize, Deserializer,
12    de::{MapAccess, SeqAccess, Visitor},
13};
14use std::{borrow::Cow, collections::BTreeSet, fmt, str::FromStr};
15
16/// A "version-only" form of the nextest configuration.
17///
18/// This is used as a first pass to determine the required nextest version before parsing the rest
19/// of the configuration. That avoids issues parsing incompatible configuration.
20#[derive(Debug, Default, Clone, PartialEq, Eq)]
21pub struct VersionOnlyConfig {
22    /// The nextest version configuration.
23    nextest_version: NextestVersionConfig,
24
25    /// Experimental features configuration.
26    experimental: ExperimentalConfig,
27}
28
29impl VersionOnlyConfig {
30    /// Reads the nextest version configuration from the given sources.
31    ///
32    /// See [`NextestConfig::from_sources`] for more details.
33    pub fn from_sources<'a, I>(
34        workspace_root: &Utf8Path,
35        config_file: Option<&Utf8Path>,
36        tool_config_files: impl IntoIterator<IntoIter = I>,
37    ) -> Result<Self, ConfigParseError>
38    where
39        I: Iterator<Item = &'a ToolConfigFile> + DoubleEndedIterator,
40    {
41        let tool_config_files_rev = tool_config_files.into_iter().rev();
42
43        Self::read_from_sources(workspace_root, config_file, tool_config_files_rev)
44    }
45
46    /// Returns the nextest version requirement.
47    pub fn nextest_version(&self) -> &NextestVersionConfig {
48        &self.nextest_version
49    }
50
51    /// Returns the experimental features configuration.
52    pub fn experimental(&self) -> &ExperimentalConfig {
53        &self.experimental
54    }
55
56    fn read_from_sources<'a>(
57        workspace_root: &Utf8Path,
58        config_file: Option<&Utf8Path>,
59        tool_config_files_rev: impl Iterator<Item = &'a ToolConfigFile>,
60    ) -> Result<Self, ConfigParseError> {
61        let mut nextest_version = NextestVersionConfig::default();
62        let mut known = BTreeSet::new();
63        let mut unknown = BTreeSet::new();
64
65        // Merge in tool configs.
66        for ToolConfigFile { config_file, tool } in tool_config_files_rev {
67            if let Some(v) = Self::read_and_deserialize(config_file, Some(tool))?.nextest_version {
68                nextest_version.accumulate(v, Some(tool.clone()));
69            }
70        }
71
72        // Finally, merge in the repo config.
73        let config_file = match config_file {
74            Some(file) => Some(Cow::Borrowed(file)),
75            None => {
76                let config_file = workspace_root.join(NextestConfig::CONFIG_PATH);
77                config_file.exists().then_some(Cow::Owned(config_file))
78            }
79        };
80        if let Some(config_file) = config_file {
81            let d = Self::read_and_deserialize(&config_file, None)?;
82            if let Some(v) = d.nextest_version {
83                nextest_version.accumulate(v, None);
84            }
85
86            // Process experimental features. Unknown features are stored rather
87            // than immediately causing an error, so that the nextest version
88            // check can run first.
89            known.extend(d.experimental.known);
90            unknown.extend(d.experimental.unknown);
91        }
92
93        Ok(Self {
94            nextest_version,
95            experimental: ExperimentalConfig { known, unknown },
96        })
97    }
98
99    fn read_and_deserialize(
100        config_file: &Utf8Path,
101        tool: Option<&ToolName>,
102    ) -> Result<VersionOnlyDeserialize, ConfigParseError> {
103        let toml_str = std::fs::read_to_string(config_file.as_str()).map_err(|error| {
104            ConfigParseError::new(
105                config_file,
106                tool,
107                ConfigParseErrorKind::VersionOnlyReadError(error),
108            )
109        })?;
110        let toml_de = toml::de::Deserializer::parse(&toml_str).map_err(|error| {
111            ConfigParseError::new(
112                config_file,
113                tool,
114                ConfigParseErrorKind::TomlParseError(Box::new(error)),
115            )
116        })?;
117        let v: VersionOnlyDeserialize =
118            serde_path_to_error::deserialize(toml_de).map_err(|error| {
119                ConfigParseError::new(
120                    config_file,
121                    tool,
122                    ConfigParseErrorKind::VersionOnlyDeserializeError(Box::new(error)),
123                )
124            })?;
125        if tool.is_some() && !v.experimental.is_empty() {
126            return Err(ConfigParseError::new(
127                config_file,
128                tool,
129                ConfigParseErrorKind::ExperimentalFeaturesInToolConfig {
130                    features: v.experimental.feature_names(),
131                },
132            ));
133        }
134
135        Ok(v)
136    }
137}
138
139/// A version of configuration that only deserializes the nextest version.
140#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize)]
141#[serde(rename_all = "kebab-case")]
142struct VersionOnlyDeserialize {
143    #[serde(default)]
144    nextest_version: Option<NextestVersionDeserialize>,
145    #[serde(default)]
146    experimental: ExperimentalDeserialize,
147}
148
149/// Intermediate representation for experimental config deserialization.
150///
151/// This supports both the table format (`[experimental] setup-scripts = true`)
152/// and the array format (`experimental = ["setup-scripts"]`). The array format
153/// will be deprecated in the future.
154#[derive(Debug, Default, Clone, PartialEq, Eq)]
155pub(crate) struct ExperimentalDeserialize {
156    /// Known experimental features that are enabled.
157    known: BTreeSet<ConfigExperimental>,
158    /// Unknown feature names (for error reporting).
159    unknown: BTreeSet<String>,
160}
161
162impl ExperimentalDeserialize {
163    /// Returns true if no experimental features are specified.
164    fn is_empty(&self) -> bool {
165        self.known.is_empty() && self.unknown.is_empty()
166    }
167
168    /// Returns the feature names for error messages (used by tool config
169    /// validation).
170    fn feature_names(&self) -> BTreeSet<String> {
171        let mut names = self.unknown.clone();
172        for feature in &self.known {
173            names.insert(feature.to_string());
174        }
175        names
176    }
177}
178
179impl<'de> Deserialize<'de> for ExperimentalDeserialize {
180    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
181    where
182        D: Deserializer<'de>,
183    {
184        struct ExperimentalVisitor;
185
186        impl<'de> Visitor<'de> for ExperimentalVisitor {
187            type Value = ExperimentalDeserialize;
188
189            fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
190                formatter.write_str(
191                    "a table ({ setup-scripts = true, benchmarks = true }) \
192                     or an array ([\"setup-scripts\", \"benchmarks\"])",
193                )
194            }
195
196            fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
197            where
198                A: SeqAccess<'de>,
199            {
200                // Array format: parse each string to ConfigExperimental.
201                let mut known = BTreeSet::new();
202                let mut unknown = BTreeSet::new();
203                while let Some(feature_str) = seq.next_element::<String>()? {
204                    if let Ok(feature) = feature_str.parse::<ConfigExperimental>() {
205                        known.insert(feature);
206                    } else {
207                        unknown.insert(feature_str);
208                    }
209                }
210                Ok(ExperimentalDeserialize { known, unknown })
211            }
212
213            fn visit_map<A>(self, map: A) -> Result<Self::Value, A::Error>
214            where
215                A: MapAccess<'de>,
216            {
217                // Table format: use typed struct with serde_ignored for unknown
218                // fields.
219                #[derive(Deserialize)]
220                #[serde(rename_all = "kebab-case")]
221                struct TableConfig {
222                    #[serde(default)]
223                    setup_scripts: bool,
224                    #[serde(default)]
225                    wrapper_scripts: bool,
226                    #[serde(default)]
227                    benchmarks: bool,
228                }
229
230                let mut unknown = BTreeSet::new();
231                let de = serde::de::value::MapAccessDeserializer::new(map);
232                let mut cb = |path: serde_ignored::Path| {
233                    unknown.insert(path.to_string());
234                };
235                let ignored_de = serde_ignored::Deserializer::new(de, &mut cb);
236                let TableConfig {
237                    setup_scripts,
238                    wrapper_scripts,
239                    benchmarks,
240                } = Deserialize::deserialize(ignored_de).map_err(serde::de::Error::custom)?;
241
242                let mut known = BTreeSet::new();
243                if setup_scripts {
244                    known.insert(ConfigExperimental::SetupScripts);
245                }
246                if wrapper_scripts {
247                    known.insert(ConfigExperimental::WrapperScripts);
248                }
249                if benchmarks {
250                    known.insert(ConfigExperimental::Benchmarks);
251                }
252
253                Ok(ExperimentalDeserialize { known, unknown })
254            }
255        }
256
257        deserializer.deserialize_any(ExperimentalVisitor)
258    }
259}
260
261/// Nextest version configuration.
262///
263/// Similar to the [`rust-version`
264/// field](https://doc.rust-lang.org/cargo/reference/manifest.html#the-rust-version-field),
265/// `nextest-version` lets you specify the minimum required version of nextest for a repository.
266#[derive(Debug, Default, Clone, PartialEq, Eq)]
267pub struct NextestVersionConfig {
268    /// The minimum version of nextest to produce an error before.
269    pub required: NextestVersionReq,
270
271    /// The minimum version of nextest to produce a warning before.
272    ///
273    /// This might be lower than [`Self::required`], in which case it is ignored. [`Self::eval`]
274    /// checks for required versions before it checks for recommended versions.
275    pub recommended: NextestVersionReq,
276}
277
278impl NextestVersionConfig {
279    /// Accumulates a deserialized version requirement into this configuration.
280    pub(crate) fn accumulate(&mut self, v: NextestVersionDeserialize, v_tool: Option<ToolName>) {
281        if let Some(version) = v.required {
282            self.required.accumulate(version, v_tool.clone());
283        }
284        if let Some(version) = v.recommended {
285            self.recommended.accumulate(version, v_tool);
286        }
287    }
288
289    /// Returns whether the given version satisfies the nextest version requirement.
290    pub fn eval(
291        &self,
292        current_version: &Version,
293        override_version_check: bool,
294    ) -> NextestVersionEval {
295        match self.required.satisfies(current_version) {
296            Ok(()) => {}
297            Err((required, tool)) => {
298                if override_version_check {
299                    return NextestVersionEval::ErrorOverride {
300                        required: required.clone(),
301                        current: current_version.clone(),
302                        tool: tool.cloned(),
303                    };
304                } else {
305                    return NextestVersionEval::Error {
306                        required: required.clone(),
307                        current: current_version.clone(),
308                        tool: tool.cloned(),
309                    };
310                }
311            }
312        }
313
314        match self.recommended.satisfies(current_version) {
315            Ok(()) => NextestVersionEval::Satisfied,
316            Err((recommended, tool)) => {
317                if override_version_check {
318                    NextestVersionEval::WarnOverride {
319                        recommended: recommended.clone(),
320                        current: current_version.clone(),
321                        tool: tool.cloned(),
322                    }
323                } else {
324                    NextestVersionEval::Warn {
325                        recommended: recommended.clone(),
326                        current: current_version.clone(),
327                        tool: tool.cloned(),
328                    }
329                }
330            }
331        }
332    }
333}
334
335/// Experimental features configuration.
336///
337/// This stores both known and unknown experimental features. Unknown features are stored rather
338/// than immediately causing an error, so that the nextest version check can run first.
339#[derive(Debug, Default, Clone, PartialEq, Eq)]
340pub struct ExperimentalConfig {
341    /// Known experimental features that are enabled.
342    known: BTreeSet<ConfigExperimental>,
343
344    /// Unknown experimental feature names.
345    unknown: BTreeSet<String>,
346}
347
348impl ExperimentalConfig {
349    /// Returns the known experimental features that are enabled.
350    pub fn known(&self) -> &BTreeSet<ConfigExperimental> {
351        &self.known
352    }
353
354    /// Evaluates the experimental configuration.
355    ///
356    /// This should be called after the nextest version check, so that the version error takes
357    /// precedence over unknown experimental features (a future version may have new features).
358    pub fn eval(&self) -> ExperimentalConfigEval {
359        if self.unknown.is_empty() {
360            ExperimentalConfigEval::Satisfied
361        } else {
362            ExperimentalConfigEval::UnknownFeatures {
363                unknown: self.unknown.clone(),
364                known: ConfigExperimental::known_features().collect(),
365            }
366        }
367    }
368}
369
370/// The result of evaluating an [`ExperimentalConfig`].
371///
372/// Returned by [`ExperimentalConfig::eval`].
373#[derive(Debug, Clone, PartialEq, Eq)]
374pub enum ExperimentalConfigEval {
375    /// All experimental features are known.
376    Satisfied,
377
378    /// Unknown experimental features were found.
379    UnknownFeatures {
380        /// The set of unknown feature names.
381        unknown: BTreeSet<String>,
382
383        /// The set of known features.
384        known: BTreeSet<ConfigExperimental>,
385    },
386}
387
388impl ExperimentalConfigEval {
389    /// Converts this eval result into an error, if it represents an error condition.
390    ///
391    /// Returns `Some(ConfigParseError)` if this is `UnknownFeatures`, and `None` if `Satisfied`.
392    pub fn into_error(self, config_file: impl Into<Utf8PathBuf>) -> Option<ConfigParseError> {
393        match self {
394            ExperimentalConfigEval::Satisfied => None,
395            ExperimentalConfigEval::UnknownFeatures { unknown, known } => {
396                Some(ConfigParseError::new(
397                    config_file,
398                    None,
399                    ConfigParseErrorKind::UnknownExperimentalFeatures { unknown, known },
400                ))
401            }
402        }
403    }
404}
405
406/// Experimental configuration features.
407#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Ord, PartialOrd)]
408#[non_exhaustive]
409pub enum ConfigExperimental {
410    /// Enable support for setup scripts.
411    SetupScripts,
412    /// Enable support for wrapper scripts.
413    WrapperScripts,
414    /// Enable support for benchmarks.
415    Benchmarks,
416}
417
418impl ConfigExperimental {
419    /// Returns an iterator over all known experimental features.
420    pub fn known_features() -> impl Iterator<Item = Self> {
421        vec![Self::SetupScripts, Self::WrapperScripts, Self::Benchmarks].into_iter()
422    }
423
424    /// Returns the environment variable name for this feature, if any.
425    pub fn env_var(self) -> Option<&'static str> {
426        match self {
427            Self::SetupScripts => None,
428            Self::WrapperScripts => None,
429            Self::Benchmarks => Some("NEXTEST_EXPERIMENTAL_BENCHMARKS"),
430        }
431    }
432
433    /// Returns the set of experimental features enabled via environment variables.
434    pub fn from_env() -> std::collections::BTreeSet<Self> {
435        let mut set = std::collections::BTreeSet::new();
436        for feature in Self::known_features() {
437            if let Some(env_var) = feature.env_var()
438                && std::env::var(env_var).as_deref() == Ok("1")
439            {
440                set.insert(feature);
441            }
442        }
443        set
444    }
445}
446
447impl FromStr for ConfigExperimental {
448    type Err = ();
449
450    fn from_str(s: &str) -> Result<Self, Self::Err> {
451        match s {
452            "setup-scripts" => Ok(Self::SetupScripts),
453            "wrapper-scripts" => Ok(Self::WrapperScripts),
454            "benchmarks" => Ok(Self::Benchmarks),
455            _ => Err(()),
456        }
457    }
458}
459
460impl fmt::Display for ConfigExperimental {
461    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
462        match self {
463            Self::SetupScripts => write!(f, "setup-scripts"),
464            Self::WrapperScripts => write!(f, "wrapper-scripts"),
465            Self::Benchmarks => write!(f, "benchmarks"),
466        }
467    }
468}
469
470/// Specification for a nextest version. Part of [`NextestVersionConfig`].
471#[derive(Debug, Default, Clone, PartialEq, Eq)]
472pub enum NextestVersionReq {
473    /// A version was specified.
474    Version {
475        /// The version to warn before.
476        version: Version,
477
478        /// The tool which produced this version specification.
479        tool: Option<ToolName>,
480    },
481
482    /// No version was specified.
483    #[default]
484    None,
485}
486
487impl NextestVersionReq {
488    /// Returns the version, if one was specified.
489    pub fn version(&self) -> Option<&Version> {
490        match self {
491            NextestVersionReq::Version { version, .. } => Some(version),
492            NextestVersionReq::None => None,
493        }
494    }
495
496    fn accumulate(&mut self, v: Version, v_tool: Option<ToolName>) {
497        match self {
498            NextestVersionReq::Version { version, tool } => {
499                // This is v >= version rather than v > version, so that if multiple tools specify
500                // the same version, the last tool wins.
501                if &v >= version {
502                    *version = v;
503                    *tool = v_tool;
504                }
505            }
506            NextestVersionReq::None => {
507                *self = NextestVersionReq::Version {
508                    version: v,
509                    tool: v_tool,
510                };
511            }
512        }
513    }
514
515    fn satisfies(&self, version: &Version) -> Result<(), (&Version, Option<&ToolName>)> {
516        match self {
517            NextestVersionReq::Version {
518                version: required,
519                tool,
520            } => {
521                if version >= required {
522                    Ok(())
523                } else {
524                    Err((required, tool.as_ref()))
525                }
526            }
527            NextestVersionReq::None => Ok(()),
528        }
529    }
530}
531
532/// The result of checking whether a [`NextestVersionConfig`] satisfies a requirement.
533///
534/// Returned by [`NextestVersionConfig::eval`].
535#[derive(Debug, Clone, PartialEq, Eq)]
536pub enum NextestVersionEval {
537    /// The version satisfies the requirement.
538    Satisfied,
539
540    /// An error should be produced.
541    Error {
542        /// The minimum version required.
543        required: Version,
544        /// The current version.
545        current: Version,
546        /// The tool which produced this version specification.
547        tool: Option<ToolName>,
548    },
549
550    /// A warning should be produced.
551    Warn {
552        /// The minimum version recommended.
553        recommended: Version,
554        /// The current version.
555        current: Version,
556        /// The tool which produced this version specification.
557        tool: Option<ToolName>,
558    },
559
560    /// An error should be produced but the version is overridden.
561    ErrorOverride {
562        /// The minimum version recommended.
563        required: Version,
564        /// The current version.
565        current: Version,
566        /// The tool which produced this version specification.
567        tool: Option<ToolName>,
568    },
569
570    /// A warning should be produced but the version is overridden.
571    WarnOverride {
572        /// The minimum version recommended.
573        recommended: Version,
574        /// The current version.
575        current: Version,
576        /// The tool which produced this version specification.
577        tool: Option<ToolName>,
578    },
579}
580
581/// Nextest version configuration.
582///
583/// Similar to the [`rust-version`
584/// field](https://doc.rust-lang.org/cargo/reference/manifest.html#the-rust-version-field),
585/// `nextest-version` lets you specify the minimum required version of nextest for a repository.
586#[derive(Debug, Clone, PartialEq, Eq)]
587pub(crate) struct NextestVersionDeserialize {
588    /// The minimum version of nextest that this repository requires.
589    required: Option<Version>,
590
591    /// The minimum version of nextest that this repository produces a warning against.
592    recommended: Option<Version>,
593}
594
595impl<'de> Deserialize<'de> for NextestVersionDeserialize {
596    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
597    where
598        D: Deserializer<'de>,
599    {
600        struct V;
601
602        impl<'de2> serde::de::Visitor<'de2> for V {
603            type Value = NextestVersionDeserialize;
604
605            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
606                formatter.write_str(
607                    "a table ({{ required = \"0.9.20\", recommended = \"0.9.30\" }}) or a string (\"0.9.50\")",
608                )
609            }
610
611            fn visit_str<E>(self, s: &str) -> std::result::Result<Self::Value, E>
612            where
613                E: serde::de::Error,
614            {
615                let required = parse_version::<E>(s.to_owned())?;
616                Ok(NextestVersionDeserialize {
617                    required: Some(required),
618                    recommended: None,
619                })
620            }
621
622            fn visit_map<A>(self, map: A) -> std::result::Result<Self::Value, A::Error>
623            where
624                A: serde::de::MapAccess<'de2>,
625            {
626                #[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
627                struct NextestVersionMap {
628                    #[serde(default, deserialize_with = "deserialize_version_opt")]
629                    required: Option<Version>,
630                    #[serde(default, deserialize_with = "deserialize_version_opt")]
631                    recommended: Option<Version>,
632                }
633
634                let NextestVersionMap {
635                    required,
636                    recommended,
637                } = NextestVersionMap::deserialize(serde::de::value::MapAccessDeserializer::new(
638                    map,
639                ))?;
640
641                if let (Some(required), Some(recommended)) = (&required, &recommended)
642                    && required > recommended
643                {
644                    return Err(serde::de::Error::custom(format!(
645                        "required version ({required}) must not be greater than recommended version ({recommended})"
646                    )));
647                }
648
649                Ok(NextestVersionDeserialize {
650                    required,
651                    recommended,
652                })
653            }
654        }
655
656        deserializer.deserialize_any(V)
657    }
658}
659
660/// This has similar logic to the [`rust-version`
661/// field](https://doc.rust-lang.org/cargo/reference/manifest.html#the-rust-version-field).
662///
663/// Adapted from cargo_metadata
664fn deserialize_version_opt<'de, D>(
665    deserializer: D,
666) -> std::result::Result<Option<Version>, D::Error>
667where
668    D: Deserializer<'de>,
669{
670    let s = Option::<String>::deserialize(deserializer)?;
671    s.map(parse_version::<D::Error>).transpose()
672}
673
674fn parse_version<E>(mut s: String) -> std::result::Result<Version, E>
675where
676    E: serde::de::Error,
677{
678    for ch in s.chars() {
679        if ch == '-' {
680            return Err(E::custom(
681                "pre-release identifiers are not supported in nextest-version",
682            ));
683        } else if ch == '+' {
684            return Err(E::custom(
685                "build metadata is not supported in nextest-version",
686            ));
687        }
688    }
689
690    // The major.minor format is not used with nextest 0.9, but support it anyway to match
691    // rust-version.
692    if s.matches('.').count() == 1 {
693        // e.g. 1.0 -> 1.0.0
694        s.push_str(".0");
695    }
696
697    Version::parse(&s).map_err(E::custom)
698}
699
700#[cfg(test)]
701mod tests {
702    use super::*;
703    use test_case::test_case;
704
705    #[test_case(
706        r#"
707            nextest-version = "0.9"
708        "#,
709        NextestVersionDeserialize { required: Some("0.9.0".parse().unwrap()), recommended: None } ; "basic"
710    )]
711    #[test_case(
712        r#"
713            nextest-version = "0.9.30"
714        "#,
715        NextestVersionDeserialize { required: Some("0.9.30".parse().unwrap()), recommended: None } ; "basic with patch"
716    )]
717    #[test_case(
718        r#"
719            nextest-version = { recommended = "0.9.20" }
720        "#,
721        NextestVersionDeserialize { required: None, recommended: Some("0.9.20".parse().unwrap()) } ; "with warning"
722    )]
723    #[test_case(
724        r#"
725            nextest-version = { required = "0.9.20", recommended = "0.9.25" }
726        "#,
727        NextestVersionDeserialize {
728            required: Some("0.9.20".parse().unwrap()),
729            recommended: Some("0.9.25".parse().unwrap()),
730        } ; "with error and warning"
731    )]
732    fn test_valid_nextest_version(input: &str, expected: NextestVersionDeserialize) {
733        let actual: VersionOnlyDeserialize = toml::from_str(input).unwrap();
734        assert_eq!(actual.nextest_version.unwrap(), expected);
735    }
736
737    #[test_case(
738        r#"
739            nextest-version = 42
740        "#,
741        "a table ({{ required = \"0.9.20\", recommended = \"0.9.30\" }}) or a string (\"0.9.50\")" ; "empty"
742    )]
743    #[test_case(
744        r#"
745            nextest-version = "0.9.30-rc.1"
746        "#,
747        "pre-release identifiers are not supported in nextest-version" ; "pre-release"
748    )]
749    #[test_case(
750        r#"
751            nextest-version = "0.9.40+mybuild"
752        "#,
753        "build metadata is not supported in nextest-version" ; "build metadata"
754    )]
755    #[test_case(
756        r#"
757            nextest-version = { required = "0.9.20", recommended = "0.9.10" }
758        "#,
759        "required version (0.9.20) must not be greater than recommended version (0.9.10)" ; "error greater than warning"
760    )]
761    fn test_invalid_nextest_version(input: &str, error_message: &str) {
762        let err = toml::from_str::<VersionOnlyDeserialize>(input).unwrap_err();
763        assert!(
764            err.to_string().contains(error_message),
765            "error `{err}` contains `{error_message}`"
766        );
767    }
768
769    fn tool_name(s: &str) -> ToolName {
770        ToolName::new(s.into()).unwrap()
771    }
772
773    #[test]
774    fn test_accumulate() {
775        let mut nextest_version = NextestVersionConfig::default();
776        nextest_version.accumulate(
777            NextestVersionDeserialize {
778                required: Some("0.9.20".parse().unwrap()),
779                recommended: None,
780            },
781            Some(tool_name("tool1")),
782        );
783        nextest_version.accumulate(
784            NextestVersionDeserialize {
785                required: Some("0.9.30".parse().unwrap()),
786                recommended: Some("0.9.35".parse().unwrap()),
787            },
788            Some(tool_name("tool2")),
789        );
790        nextest_version.accumulate(
791            NextestVersionDeserialize {
792                required: None,
793                // This recommended version is ignored since it is less than the last recommended
794                // version.
795                recommended: Some("0.9.25".parse().unwrap()),
796            },
797            Some(tool_name("tool3")),
798        );
799        nextest_version.accumulate(
800            NextestVersionDeserialize {
801                // This is accepted because it is the same as the last required version, and the
802                // last tool wins.
803                required: Some("0.9.30".parse().unwrap()),
804                recommended: None,
805            },
806            Some(tool_name("tool4")),
807        );
808
809        assert_eq!(
810            nextest_version,
811            NextestVersionConfig {
812                required: NextestVersionReq::Version {
813                    version: "0.9.30".parse().unwrap(),
814                    tool: Some(tool_name("tool4")),
815                },
816                recommended: NextestVersionReq::Version {
817                    version: "0.9.35".parse().unwrap(),
818                    tool: Some(tool_name("tool2")),
819                },
820            }
821        );
822    }
823
824    #[test]
825    fn test_from_env_benchmarks() {
826        // SAFETY:
827        // https://nexte.st/docs/configuration/env-vars/#altering-the-environment-within-tests
828        unsafe { std::env::set_var("NEXTEST_EXPERIMENTAL_BENCHMARKS", "1") };
829        assert!(ConfigExperimental::from_env().contains(&ConfigExperimental::Benchmarks));
830
831        // Other values do not enable the feature.
832        // SAFETY:
833        // https://nexte.st/docs/configuration/env-vars/#altering-the-environment-within-tests
834        unsafe { std::env::set_var("NEXTEST_EXPERIMENTAL_BENCHMARKS", "0") };
835        assert!(!ConfigExperimental::from_env().contains(&ConfigExperimental::Benchmarks));
836
837        // SAFETY:
838        // https://nexte.st/docs/configuration/env-vars/#altering-the-environment-within-tests
839        unsafe { std::env::set_var("NEXTEST_EXPERIMENTAL_BENCHMARKS", "true") };
840        assert!(!ConfigExperimental::from_env().contains(&ConfigExperimental::Benchmarks));
841
842        // SetupScripts and WrapperScripts have no env vars, so they are never
843        // enabled via from_env.
844        // SAFETY:
845        // https://nexte.st/docs/configuration/env-vars/#altering-the-environment-within-tests
846        unsafe { std::env::set_var("NEXTEST_EXPERIMENTAL_BENCHMARKS", "1") };
847        let set = ConfigExperimental::from_env();
848        assert!(!set.contains(&ConfigExperimental::SetupScripts));
849        assert!(!set.contains(&ConfigExperimental::WrapperScripts));
850    }
851
852    #[test]
853    fn test_experimental_formats() {
854        // For the array format, valid features should parse correctly.
855        let input = r#"experimental = ["setup-scripts", "benchmarks"]"#;
856        let d: VersionOnlyDeserialize = toml::from_str(input).unwrap();
857        assert_eq!(
858            d.experimental.known,
859            BTreeSet::from([
860                ConfigExperimental::SetupScripts,
861                ConfigExperimental::Benchmarks
862            ]),
863            "expected 2 known features"
864        );
865        assert!(d.experimental.unknown.is_empty());
866
867        // An empty array is empty.
868        let input = r#"experimental = []"#;
869        let d: VersionOnlyDeserialize = toml::from_str(input).unwrap();
870        assert!(
871            d.experimental.is_empty(),
872            "expected empty, got {:?}",
873            d.experimental
874        );
875
876        // Unknown features in the array format are recorded.
877        let input = r#"experimental = ["setup-scripts", "unknown-feature"]"#;
878        let d: VersionOnlyDeserialize = toml::from_str(input).unwrap();
879        assert_eq!(
880            d.experimental.known,
881            BTreeSet::from([ConfigExperimental::SetupScripts])
882        );
883        assert_eq!(
884            d.experimental.unknown,
885            BTreeSet::from(["unknown-feature".to_owned()])
886        );
887
888        // Table format: valid features parse correctly.
889        let input = r#"
890[experimental]
891setup-scripts = true
892benchmarks = true
893"#;
894        let d: VersionOnlyDeserialize = toml::from_str(input).unwrap();
895        assert_eq!(
896            d.experimental.known,
897            BTreeSet::from([
898                ConfigExperimental::SetupScripts,
899                ConfigExperimental::Benchmarks
900            ])
901        );
902        assert!(d.experimental.unknown.is_empty());
903
904        // Empty table is empty.
905        let input = r#"[experimental]"#;
906        let d: VersionOnlyDeserialize = toml::from_str(input).unwrap();
907        assert!(
908            d.experimental.is_empty(),
909            "expected empty, got {:?}",
910            d.experimental
911        );
912
913        // If all features are false, the result is empty.
914        let input = r#"
915[experimental]
916setup-scripts = false
917"#;
918        let d: VersionOnlyDeserialize = toml::from_str(input).unwrap();
919        assert!(
920            d.experimental.is_empty(),
921            "expected empty, got {:?}",
922            d.experimental
923        );
924
925        // Unknown features in the table format are recorded.
926        let input = r#"
927[experimental]
928setup-scripts = true
929unknown-feature = true
930"#;
931        let d: VersionOnlyDeserialize = toml::from_str(input).unwrap();
932        assert_eq!(
933            d.experimental.known,
934            BTreeSet::from([ConfigExperimental::SetupScripts])
935        );
936        assert!(d.experimental.unknown.contains("unknown-feature"));
937
938        // An invalid type shows a helpful error mentioning both formats.
939        let input = r#"experimental = 42"#;
940        let err = toml::from_str::<VersionOnlyDeserialize>(input).unwrap_err();
941        let err_str = err.to_string();
942        assert!(
943            err_str.contains("expected a table") && err_str.contains("or an array"),
944            "expected error to mention both formats, got: {}",
945            err_str
946        );
947
948        let input = r#"experimental = "setup-scripts""#;
949        let err = toml::from_str::<VersionOnlyDeserialize>(input).unwrap_err();
950        let err_str = err.to_string();
951        assert!(
952            err_str.contains("expected a table") && err_str.contains("or an array"),
953            "expected error to mention both formats, got: {}",
954            err_str
955        );
956    }
957}