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    fn accumulate(&mut self, v: Version, v_tool: Option<ToolName>) {
489        match self {
490            NextestVersionReq::Version { version, tool } => {
491                // This is v >= version rather than v > version, so that if multiple tools specify
492                // the same version, the last tool wins.
493                if &v >= version {
494                    *version = v;
495                    *tool = v_tool;
496                }
497            }
498            NextestVersionReq::None => {
499                *self = NextestVersionReq::Version {
500                    version: v,
501                    tool: v_tool,
502                };
503            }
504        }
505    }
506
507    fn satisfies(&self, version: &Version) -> Result<(), (&Version, Option<&ToolName>)> {
508        match self {
509            NextestVersionReq::Version {
510                version: required,
511                tool,
512            } => {
513                if version >= required {
514                    Ok(())
515                } else {
516                    Err((required, tool.as_ref()))
517                }
518            }
519            NextestVersionReq::None => Ok(()),
520        }
521    }
522}
523
524/// The result of checking whether a [`NextestVersionConfig`] satisfies a requirement.
525///
526/// Returned by [`NextestVersionConfig::eval`].
527#[derive(Debug, Clone, PartialEq, Eq)]
528pub enum NextestVersionEval {
529    /// The version satisfies the requirement.
530    Satisfied,
531
532    /// An error should be produced.
533    Error {
534        /// The minimum version required.
535        required: Version,
536        /// The current version.
537        current: Version,
538        /// The tool which produced this version specification.
539        tool: Option<ToolName>,
540    },
541
542    /// A warning should be produced.
543    Warn {
544        /// The minimum version recommended.
545        recommended: Version,
546        /// The current version.
547        current: Version,
548        /// The tool which produced this version specification.
549        tool: Option<ToolName>,
550    },
551
552    /// An error should be produced but the version is overridden.
553    ErrorOverride {
554        /// The minimum version recommended.
555        required: Version,
556        /// The current version.
557        current: Version,
558        /// The tool which produced this version specification.
559        tool: Option<ToolName>,
560    },
561
562    /// A warning should be produced but the version is overridden.
563    WarnOverride {
564        /// The minimum version recommended.
565        recommended: Version,
566        /// The current version.
567        current: Version,
568        /// The tool which produced this version specification.
569        tool: Option<ToolName>,
570    },
571}
572
573/// Nextest version configuration.
574///
575/// Similar to the [`rust-version`
576/// field](https://doc.rust-lang.org/cargo/reference/manifest.html#the-rust-version-field),
577/// `nextest-version` lets you specify the minimum required version of nextest for a repository.
578#[derive(Debug, Clone, PartialEq, Eq)]
579pub(crate) struct NextestVersionDeserialize {
580    /// The minimum version of nextest that this repository requires.
581    required: Option<Version>,
582
583    /// The minimum version of nextest that this repository produces a warning against.
584    recommended: Option<Version>,
585}
586
587impl<'de> Deserialize<'de> for NextestVersionDeserialize {
588    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
589    where
590        D: Deserializer<'de>,
591    {
592        struct V;
593
594        impl<'de2> serde::de::Visitor<'de2> for V {
595            type Value = NextestVersionDeserialize;
596
597            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
598                formatter.write_str(
599                    "a table ({{ required = \"0.9.20\", recommended = \"0.9.30\" }}) or a string (\"0.9.50\")",
600                )
601            }
602
603            fn visit_str<E>(self, s: &str) -> std::result::Result<Self::Value, E>
604            where
605                E: serde::de::Error,
606            {
607                let required = parse_version::<E>(s.to_owned())?;
608                Ok(NextestVersionDeserialize {
609                    required: Some(required),
610                    recommended: None,
611                })
612            }
613
614            fn visit_map<A>(self, map: A) -> std::result::Result<Self::Value, A::Error>
615            where
616                A: serde::de::MapAccess<'de2>,
617            {
618                #[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
619                struct NextestVersionMap {
620                    #[serde(default, deserialize_with = "deserialize_version_opt")]
621                    required: Option<Version>,
622                    #[serde(default, deserialize_with = "deserialize_version_opt")]
623                    recommended: Option<Version>,
624                }
625
626                let NextestVersionMap {
627                    required,
628                    recommended,
629                } = NextestVersionMap::deserialize(serde::de::value::MapAccessDeserializer::new(
630                    map,
631                ))?;
632
633                if let (Some(required), Some(recommended)) = (&required, &recommended)
634                    && required > recommended
635                {
636                    return Err(serde::de::Error::custom(format!(
637                        "required version ({required}) must not be greater than recommended version ({recommended})"
638                    )));
639                }
640
641                Ok(NextestVersionDeserialize {
642                    required,
643                    recommended,
644                })
645            }
646        }
647
648        deserializer.deserialize_any(V)
649    }
650}
651
652/// This has similar logic to the [`rust-version`
653/// field](https://doc.rust-lang.org/cargo/reference/manifest.html#the-rust-version-field).
654///
655/// Adapted from cargo_metadata
656fn deserialize_version_opt<'de, D>(
657    deserializer: D,
658) -> std::result::Result<Option<Version>, D::Error>
659where
660    D: Deserializer<'de>,
661{
662    let s = Option::<String>::deserialize(deserializer)?;
663    s.map(parse_version::<D::Error>).transpose()
664}
665
666fn parse_version<E>(mut s: String) -> std::result::Result<Version, E>
667where
668    E: serde::de::Error,
669{
670    for ch in s.chars() {
671        if ch == '-' {
672            return Err(E::custom(
673                "pre-release identifiers are not supported in nextest-version",
674            ));
675        } else if ch == '+' {
676            return Err(E::custom(
677                "build metadata is not supported in nextest-version",
678            ));
679        }
680    }
681
682    // The major.minor format is not used with nextest 0.9, but support it anyway to match
683    // rust-version.
684    if s.matches('.').count() == 1 {
685        // e.g. 1.0 -> 1.0.0
686        s.push_str(".0");
687    }
688
689    Version::parse(&s).map_err(E::custom)
690}
691
692#[cfg(test)]
693mod tests {
694    use super::*;
695    use test_case::test_case;
696
697    #[test_case(
698        r#"
699            nextest-version = "0.9"
700        "#,
701        NextestVersionDeserialize { required: Some("0.9.0".parse().unwrap()), recommended: None } ; "basic"
702    )]
703    #[test_case(
704        r#"
705            nextest-version = "0.9.30"
706        "#,
707        NextestVersionDeserialize { required: Some("0.9.30".parse().unwrap()), recommended: None } ; "basic with patch"
708    )]
709    #[test_case(
710        r#"
711            nextest-version = { recommended = "0.9.20" }
712        "#,
713        NextestVersionDeserialize { required: None, recommended: Some("0.9.20".parse().unwrap()) } ; "with warning"
714    )]
715    #[test_case(
716        r#"
717            nextest-version = { required = "0.9.20", recommended = "0.9.25" }
718        "#,
719        NextestVersionDeserialize {
720            required: Some("0.9.20".parse().unwrap()),
721            recommended: Some("0.9.25".parse().unwrap()),
722        } ; "with error and warning"
723    )]
724    fn test_valid_nextest_version(input: &str, expected: NextestVersionDeserialize) {
725        let actual: VersionOnlyDeserialize = toml::from_str(input).unwrap();
726        assert_eq!(actual.nextest_version.unwrap(), expected);
727    }
728
729    #[test_case(
730        r#"
731            nextest-version = 42
732        "#,
733        "a table ({{ required = \"0.9.20\", recommended = \"0.9.30\" }}) or a string (\"0.9.50\")" ; "empty"
734    )]
735    #[test_case(
736        r#"
737            nextest-version = "0.9.30-rc.1"
738        "#,
739        "pre-release identifiers are not supported in nextest-version" ; "pre-release"
740    )]
741    #[test_case(
742        r#"
743            nextest-version = "0.9.40+mybuild"
744        "#,
745        "build metadata is not supported in nextest-version" ; "build metadata"
746    )]
747    #[test_case(
748        r#"
749            nextest-version = { required = "0.9.20", recommended = "0.9.10" }
750        "#,
751        "required version (0.9.20) must not be greater than recommended version (0.9.10)" ; "error greater than warning"
752    )]
753    fn test_invalid_nextest_version(input: &str, error_message: &str) {
754        let err = toml::from_str::<VersionOnlyDeserialize>(input).unwrap_err();
755        assert!(
756            err.to_string().contains(error_message),
757            "error `{err}` contains `{error_message}`"
758        );
759    }
760
761    fn tool_name(s: &str) -> ToolName {
762        ToolName::new(s.into()).unwrap()
763    }
764
765    #[test]
766    fn test_accumulate() {
767        let mut nextest_version = NextestVersionConfig::default();
768        nextest_version.accumulate(
769            NextestVersionDeserialize {
770                required: Some("0.9.20".parse().unwrap()),
771                recommended: None,
772            },
773            Some(tool_name("tool1")),
774        );
775        nextest_version.accumulate(
776            NextestVersionDeserialize {
777                required: Some("0.9.30".parse().unwrap()),
778                recommended: Some("0.9.35".parse().unwrap()),
779            },
780            Some(tool_name("tool2")),
781        );
782        nextest_version.accumulate(
783            NextestVersionDeserialize {
784                required: None,
785                // This recommended version is ignored since it is less than the last recommended
786                // version.
787                recommended: Some("0.9.25".parse().unwrap()),
788            },
789            Some(tool_name("tool3")),
790        );
791        nextest_version.accumulate(
792            NextestVersionDeserialize {
793                // This is accepted because it is the same as the last required version, and the
794                // last tool wins.
795                required: Some("0.9.30".parse().unwrap()),
796                recommended: None,
797            },
798            Some(tool_name("tool4")),
799        );
800
801        assert_eq!(
802            nextest_version,
803            NextestVersionConfig {
804                required: NextestVersionReq::Version {
805                    version: "0.9.30".parse().unwrap(),
806                    tool: Some(tool_name("tool4")),
807                },
808                recommended: NextestVersionReq::Version {
809                    version: "0.9.35".parse().unwrap(),
810                    tool: Some(tool_name("tool2")),
811                },
812            }
813        );
814    }
815
816    #[test]
817    fn test_from_env_benchmarks() {
818        // SAFETY:
819        // https://nexte.st/docs/configuration/env-vars/#altering-the-environment-within-tests
820        unsafe { std::env::set_var("NEXTEST_EXPERIMENTAL_BENCHMARKS", "1") };
821        assert!(ConfigExperimental::from_env().contains(&ConfigExperimental::Benchmarks));
822
823        // Other values do not enable the feature.
824        // SAFETY:
825        // https://nexte.st/docs/configuration/env-vars/#altering-the-environment-within-tests
826        unsafe { std::env::set_var("NEXTEST_EXPERIMENTAL_BENCHMARKS", "0") };
827        assert!(!ConfigExperimental::from_env().contains(&ConfigExperimental::Benchmarks));
828
829        // SAFETY:
830        // https://nexte.st/docs/configuration/env-vars/#altering-the-environment-within-tests
831        unsafe { std::env::set_var("NEXTEST_EXPERIMENTAL_BENCHMARKS", "true") };
832        assert!(!ConfigExperimental::from_env().contains(&ConfigExperimental::Benchmarks));
833
834        // SetupScripts and WrapperScripts have no env vars, so they are never
835        // enabled via from_env.
836        // SAFETY:
837        // https://nexte.st/docs/configuration/env-vars/#altering-the-environment-within-tests
838        unsafe { std::env::set_var("NEXTEST_EXPERIMENTAL_BENCHMARKS", "1") };
839        let set = ConfigExperimental::from_env();
840        assert!(!set.contains(&ConfigExperimental::SetupScripts));
841        assert!(!set.contains(&ConfigExperimental::WrapperScripts));
842    }
843
844    #[test]
845    fn test_experimental_formats() {
846        // For the array format, valid features should parse correctly.
847        let input = r#"experimental = ["setup-scripts", "benchmarks"]"#;
848        let d: VersionOnlyDeserialize = toml::from_str(input).unwrap();
849        assert_eq!(
850            d.experimental.known,
851            BTreeSet::from([
852                ConfigExperimental::SetupScripts,
853                ConfigExperimental::Benchmarks
854            ]),
855            "expected 2 known features"
856        );
857        assert!(d.experimental.unknown.is_empty());
858
859        // An empty array is empty.
860        let input = r#"experimental = []"#;
861        let d: VersionOnlyDeserialize = toml::from_str(input).unwrap();
862        assert!(
863            d.experimental.is_empty(),
864            "expected empty, got {:?}",
865            d.experimental
866        );
867
868        // Unknown features in the array format are recorded.
869        let input = r#"experimental = ["setup-scripts", "unknown-feature"]"#;
870        let d: VersionOnlyDeserialize = toml::from_str(input).unwrap();
871        assert_eq!(
872            d.experimental.known,
873            BTreeSet::from([ConfigExperimental::SetupScripts])
874        );
875        assert_eq!(
876            d.experimental.unknown,
877            BTreeSet::from(["unknown-feature".to_owned()])
878        );
879
880        // Table format: valid features parse correctly.
881        let input = r#"
882[experimental]
883setup-scripts = true
884benchmarks = true
885"#;
886        let d: VersionOnlyDeserialize = toml::from_str(input).unwrap();
887        assert_eq!(
888            d.experimental.known,
889            BTreeSet::from([
890                ConfigExperimental::SetupScripts,
891                ConfigExperimental::Benchmarks
892            ])
893        );
894        assert!(d.experimental.unknown.is_empty());
895
896        // Empty table is empty.
897        let input = r#"[experimental]"#;
898        let d: VersionOnlyDeserialize = toml::from_str(input).unwrap();
899        assert!(
900            d.experimental.is_empty(),
901            "expected empty, got {:?}",
902            d.experimental
903        );
904
905        // If all features are false, the result is empty.
906        let input = r#"
907[experimental]
908setup-scripts = false
909"#;
910        let d: VersionOnlyDeserialize = toml::from_str(input).unwrap();
911        assert!(
912            d.experimental.is_empty(),
913            "expected empty, got {:?}",
914            d.experimental
915        );
916
917        // Unknown features in the table format are recorded.
918        let input = r#"
919[experimental]
920setup-scripts = true
921unknown-feature = true
922"#;
923        let d: VersionOnlyDeserialize = toml::from_str(input).unwrap();
924        assert_eq!(
925            d.experimental.known,
926            BTreeSet::from([ConfigExperimental::SetupScripts])
927        );
928        assert!(d.experimental.unknown.contains("unknown-feature"));
929
930        // An invalid type shows a helpful error mentioning both formats.
931        let input = r#"experimental = 42"#;
932        let err = toml::from_str::<VersionOnlyDeserialize>(input).unwrap_err();
933        let err_str = err.to_string();
934        assert!(
935            err_str.contains("expected a table") && err_str.contains("or an array"),
936            "expected error to mention both formats, got: {}",
937            err_str
938        );
939
940        let input = r#"experimental = "setup-scripts""#;
941        let err = toml::from_str::<VersionOnlyDeserialize>(input).unwrap_err();
942        let err_str = err.to_string();
943        assert!(
944            err_str.contains("expected a table") && err_str.contains("or an array"),
945            "expected error to mention both formats, got: {}",
946            err_str
947        );
948    }
949}