Skip to main content

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#[cfg(feature = "config-schema")]
262impl schemars::JsonSchema for ExperimentalDeserialize {
263    fn schema_name() -> Cow<'static, str> {
264        "ExperimentalDeserialize".into()
265    }
266
267    fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
268        schemars::json_schema!({
269            "oneOf": [
270                {
271                    "type": "array",
272                    "items": {
273                        "type": "string",
274                        "enum": ["setup-scripts", "wrapper-scripts", "benchmarks"],
275                    },
276                },
277                {
278                    "type": "object",
279                    "properties": {
280                        "setup-scripts": generator.subschema_for::<bool>(),
281                        "wrapper-scripts": generator.subschema_for::<bool>(),
282                        "benchmarks": generator.subschema_for::<bool>(),
283                    },
284                    "additionalProperties": true,
285                }
286            ]
287        })
288    }
289}
290
291/// Nextest version configuration.
292///
293/// Similar to the [`rust-version`
294/// field](https://doc.rust-lang.org/cargo/reference/manifest.html#the-rust-version-field),
295/// `nextest-version` lets you specify the minimum required version of nextest for a repository.
296#[derive(Debug, Default, Clone, PartialEq, Eq)]
297pub struct NextestVersionConfig {
298    /// The minimum version of nextest to produce an error before.
299    pub required: NextestVersionReq,
300
301    /// The minimum version of nextest to produce a warning before.
302    ///
303    /// This might be lower than [`Self::required`], in which case it is ignored. [`Self::eval`]
304    /// checks for required versions before it checks for recommended versions.
305    pub recommended: NextestVersionReq,
306}
307
308impl NextestVersionConfig {
309    /// Accumulates a deserialized version requirement into this configuration.
310    pub(crate) fn accumulate(&mut self, v: NextestVersionDeserialize, v_tool: Option<ToolName>) {
311        if let Some(version) = v.required {
312            self.required.accumulate(version, v_tool.clone());
313        }
314        if let Some(version) = v.recommended {
315            self.recommended.accumulate(version, v_tool);
316        }
317    }
318
319    /// Returns whether the given version satisfies the nextest version requirement.
320    pub fn eval(
321        &self,
322        current_version: &Version,
323        override_version_check: bool,
324    ) -> NextestVersionEval {
325        match self.required.satisfies(current_version) {
326            Ok(()) => {}
327            Err((required, tool)) => {
328                if override_version_check {
329                    return NextestVersionEval::ErrorOverride {
330                        required: required.clone(),
331                        current: current_version.clone(),
332                        tool: tool.cloned(),
333                    };
334                } else {
335                    return NextestVersionEval::Error {
336                        required: required.clone(),
337                        current: current_version.clone(),
338                        tool: tool.cloned(),
339                    };
340                }
341            }
342        }
343
344        match self.recommended.satisfies(current_version) {
345            Ok(()) => NextestVersionEval::Satisfied,
346            Err((recommended, tool)) => {
347                if override_version_check {
348                    NextestVersionEval::WarnOverride {
349                        recommended: recommended.clone(),
350                        current: current_version.clone(),
351                        tool: tool.cloned(),
352                    }
353                } else {
354                    NextestVersionEval::Warn {
355                        recommended: recommended.clone(),
356                        current: current_version.clone(),
357                        tool: tool.cloned(),
358                    }
359                }
360            }
361        }
362    }
363}
364
365/// Experimental features configuration.
366///
367/// This stores both known and unknown experimental features. Unknown features are stored rather
368/// than immediately causing an error, so that the nextest version check can run first.
369#[derive(Debug, Default, Clone, PartialEq, Eq)]
370pub struct ExperimentalConfig {
371    /// Known experimental features that are enabled.
372    known: BTreeSet<ConfigExperimental>,
373
374    /// Unknown experimental feature names.
375    unknown: BTreeSet<String>,
376}
377
378impl ExperimentalConfig {
379    /// Returns the known experimental features that are enabled.
380    pub fn known(&self) -> &BTreeSet<ConfigExperimental> {
381        &self.known
382    }
383
384    /// Evaluates the experimental configuration.
385    ///
386    /// This should be called after the nextest version check, so that the version error takes
387    /// precedence over unknown experimental features (a future version may have new features).
388    pub fn eval(&self) -> ExperimentalConfigEval {
389        if self.unknown.is_empty() {
390            ExperimentalConfigEval::Satisfied
391        } else {
392            ExperimentalConfigEval::UnknownFeatures {
393                unknown: self.unknown.clone(),
394                known: ConfigExperimental::known_features().collect(),
395            }
396        }
397    }
398}
399
400/// The result of evaluating an [`ExperimentalConfig`].
401///
402/// Returned by [`ExperimentalConfig::eval`].
403#[derive(Debug, Clone, PartialEq, Eq)]
404pub enum ExperimentalConfigEval {
405    /// All experimental features are known.
406    Satisfied,
407
408    /// Unknown experimental features were found.
409    UnknownFeatures {
410        /// The set of unknown feature names.
411        unknown: BTreeSet<String>,
412
413        /// The set of known features.
414        known: BTreeSet<ConfigExperimental>,
415    },
416}
417
418impl ExperimentalConfigEval {
419    /// Converts this eval result into an error, if it represents an error condition.
420    ///
421    /// Returns `Some(ConfigParseError)` if this is `UnknownFeatures`, and `None` if `Satisfied`.
422    pub fn into_error(self, config_file: impl Into<Utf8PathBuf>) -> Option<ConfigParseError> {
423        match self {
424            ExperimentalConfigEval::Satisfied => None,
425            ExperimentalConfigEval::UnknownFeatures { unknown, known } => {
426                Some(ConfigParseError::new(
427                    config_file,
428                    None,
429                    ConfigParseErrorKind::UnknownExperimentalFeatures { unknown, known },
430                ))
431            }
432        }
433    }
434}
435
436/// Experimental configuration features.
437#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Ord, PartialOrd)]
438#[non_exhaustive]
439pub enum ConfigExperimental {
440    /// Enable support for setup scripts.
441    SetupScripts,
442    /// Enable support for wrapper scripts.
443    WrapperScripts,
444    /// Enable support for benchmarks.
445    Benchmarks,
446}
447
448impl ConfigExperimental {
449    /// Returns an iterator over all known experimental features.
450    pub fn known_features() -> impl Iterator<Item = Self> {
451        vec![Self::SetupScripts, Self::WrapperScripts, Self::Benchmarks].into_iter()
452    }
453
454    /// Returns the environment variable name for this feature, if any.
455    pub fn env_var(self) -> Option<&'static str> {
456        match self {
457            Self::SetupScripts => None,
458            Self::WrapperScripts => None,
459            Self::Benchmarks => Some("NEXTEST_EXPERIMENTAL_BENCHMARKS"),
460        }
461    }
462
463    /// Returns the set of experimental features enabled via environment variables.
464    pub fn from_env() -> std::collections::BTreeSet<Self> {
465        let mut set = std::collections::BTreeSet::new();
466        for feature in Self::known_features() {
467            if let Some(env_var) = feature.env_var()
468                && std::env::var(env_var).as_deref() == Ok("1")
469            {
470                set.insert(feature);
471            }
472        }
473        set
474    }
475}
476
477impl FromStr for ConfigExperimental {
478    type Err = ();
479
480    fn from_str(s: &str) -> Result<Self, Self::Err> {
481        match s {
482            "setup-scripts" => Ok(Self::SetupScripts),
483            "wrapper-scripts" => Ok(Self::WrapperScripts),
484            "benchmarks" => Ok(Self::Benchmarks),
485            _ => Err(()),
486        }
487    }
488}
489
490impl fmt::Display for ConfigExperimental {
491    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
492        match self {
493            Self::SetupScripts => write!(f, "setup-scripts"),
494            Self::WrapperScripts => write!(f, "wrapper-scripts"),
495            Self::Benchmarks => write!(f, "benchmarks"),
496        }
497    }
498}
499
500/// Specification for a nextest version. Part of [`NextestVersionConfig`].
501#[derive(Debug, Default, Clone, PartialEq, Eq)]
502pub enum NextestVersionReq {
503    /// A version was specified.
504    Version {
505        /// The version to warn before.
506        version: Version,
507
508        /// The tool which produced this version specification.
509        tool: Option<ToolName>,
510    },
511
512    /// No version was specified.
513    #[default]
514    None,
515}
516
517impl NextestVersionReq {
518    /// Returns the version, if one was specified.
519    pub fn version(&self) -> Option<&Version> {
520        match self {
521            NextestVersionReq::Version { version, .. } => Some(version),
522            NextestVersionReq::None => None,
523        }
524    }
525
526    fn accumulate(&mut self, v: Version, v_tool: Option<ToolName>) {
527        match self {
528            NextestVersionReq::Version { version, tool } => {
529                // This is v >= version rather than v > version, so that if multiple tools specify
530                // the same version, the last tool wins.
531                if &v >= version {
532                    *version = v;
533                    *tool = v_tool;
534                }
535            }
536            NextestVersionReq::None => {
537                *self = NextestVersionReq::Version {
538                    version: v,
539                    tool: v_tool,
540                };
541            }
542        }
543    }
544
545    fn satisfies(&self, version: &Version) -> Result<(), (&Version, Option<&ToolName>)> {
546        match self {
547            NextestVersionReq::Version {
548                version: required,
549                tool,
550            } => {
551                if version >= required {
552                    Ok(())
553                } else {
554                    Err((required, tool.as_ref()))
555                }
556            }
557            NextestVersionReq::None => Ok(()),
558        }
559    }
560}
561
562/// The result of checking whether a [`NextestVersionConfig`] satisfies a requirement.
563///
564/// Returned by [`NextestVersionConfig::eval`].
565#[derive(Debug, Clone, PartialEq, Eq)]
566pub enum NextestVersionEval {
567    /// The version satisfies the requirement.
568    Satisfied,
569
570    /// An error should be produced.
571    Error {
572        /// The minimum version required.
573        required: Version,
574        /// The current version.
575        current: Version,
576        /// The tool which produced this version specification.
577        tool: Option<ToolName>,
578    },
579
580    /// A warning should be produced.
581    Warn {
582        /// The minimum version recommended.
583        recommended: Version,
584        /// The current version.
585        current: Version,
586        /// The tool which produced this version specification.
587        tool: Option<ToolName>,
588    },
589
590    /// An error should be produced but the version is overridden.
591    ErrorOverride {
592        /// The minimum version recommended.
593        required: Version,
594        /// The current version.
595        current: Version,
596        /// The tool which produced this version specification.
597        tool: Option<ToolName>,
598    },
599
600    /// A warning should be produced but the version is overridden.
601    WarnOverride {
602        /// The minimum version recommended.
603        recommended: Version,
604        /// The current version.
605        current: Version,
606        /// The tool which produced this version specification.
607        tool: Option<ToolName>,
608    },
609}
610
611/// Nextest version configuration.
612///
613/// Similar to the [`rust-version`
614/// field](https://doc.rust-lang.org/cargo/reference/manifest.html#the-rust-version-field),
615/// `nextest-version` lets you specify the minimum required version of nextest for a repository.
616#[derive(Debug, Clone, PartialEq, Eq)]
617pub(crate) struct NextestVersionDeserialize {
618    /// The minimum version of nextest that this repository requires.
619    required: Option<Version>,
620
621    /// The minimum version of nextest that this repository produces a warning against.
622    recommended: Option<Version>,
623}
624
625impl<'de> Deserialize<'de> for NextestVersionDeserialize {
626    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
627    where
628        D: Deserializer<'de>,
629    {
630        struct V;
631
632        impl<'de2> serde::de::Visitor<'de2> for V {
633            type Value = NextestVersionDeserialize;
634
635            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
636                formatter.write_str(
637                    "a table ({{ required = \"0.9.20\", recommended = \"0.9.30\" }}) or a string (\"0.9.50\")",
638                )
639            }
640
641            fn visit_str<E>(self, s: &str) -> std::result::Result<Self::Value, E>
642            where
643                E: serde::de::Error,
644            {
645                let required = parse_version::<E>(s.to_owned())?;
646                Ok(NextestVersionDeserialize {
647                    required: Some(required),
648                    recommended: None,
649                })
650            }
651
652            fn visit_map<A>(self, map: A) -> std::result::Result<Self::Value, A::Error>
653            where
654                A: serde::de::MapAccess<'de2>,
655            {
656                #[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
657                struct NextestVersionMap {
658                    #[serde(default, deserialize_with = "deserialize_version_opt")]
659                    required: Option<Version>,
660                    #[serde(default, deserialize_with = "deserialize_version_opt")]
661                    recommended: Option<Version>,
662                }
663
664                let NextestVersionMap {
665                    required,
666                    recommended,
667                } = NextestVersionMap::deserialize(serde::de::value::MapAccessDeserializer::new(
668                    map,
669                ))?;
670
671                if let (Some(required), Some(recommended)) = (&required, &recommended)
672                    && required > recommended
673                {
674                    return Err(serde::de::Error::custom(format!(
675                        "required version ({required}) must not be greater than recommended version ({recommended})"
676                    )));
677                }
678
679                Ok(NextestVersionDeserialize {
680                    required,
681                    recommended,
682                })
683            }
684        }
685
686        deserializer.deserialize_any(V)
687    }
688}
689
690#[cfg(feature = "config-schema")]
691impl schemars::JsonSchema for NextestVersionDeserialize {
692    fn schema_name() -> Cow<'static, str> {
693        "NextestVersionDeserialize".into()
694    }
695
696    fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
697        schemars::json_schema!({
698            "oneOf": [
699                generator.subschema_for::<String>(),
700                {
701                    "type": "object",
702                    "properties": {
703                        "required": generator.subschema_for::<String>(),
704                        "recommended": generator.subschema_for::<String>(),
705                    },
706                    "additionalProperties": false,
707                }
708            ]
709        })
710    }
711}
712
713/// This has similar logic to the [`rust-version`
714/// field](https://doc.rust-lang.org/cargo/reference/manifest.html#the-rust-version-field).
715///
716/// Adapted from cargo_metadata
717fn deserialize_version_opt<'de, D>(
718    deserializer: D,
719) -> std::result::Result<Option<Version>, D::Error>
720where
721    D: Deserializer<'de>,
722{
723    let s = Option::<String>::deserialize(deserializer)?;
724    s.map(parse_version::<D::Error>).transpose()
725}
726
727fn parse_version<E>(mut s: String) -> std::result::Result<Version, E>
728where
729    E: serde::de::Error,
730{
731    for ch in s.chars() {
732        if ch == '-' {
733            return Err(E::custom(
734                "pre-release identifiers are not supported in nextest-version",
735            ));
736        } else if ch == '+' {
737            return Err(E::custom(
738                "build metadata is not supported in nextest-version",
739            ));
740        }
741    }
742
743    // The major.minor format is not used with nextest 0.9, but support it anyway to match
744    // rust-version.
745    if s.matches('.').count() == 1 {
746        // e.g. 1.0 -> 1.0.0
747        s.push_str(".0");
748    }
749
750    Version::parse(&s).map_err(E::custom)
751}
752
753#[cfg(test)]
754mod tests {
755    use super::*;
756    use test_case::test_case;
757
758    #[test_case(
759        r#"
760            nextest-version = "0.9"
761        "#,
762        NextestVersionDeserialize { required: Some("0.9.0".parse().unwrap()), recommended: None } ; "basic"
763    )]
764    #[test_case(
765        r#"
766            nextest-version = "0.9.30"
767        "#,
768        NextestVersionDeserialize { required: Some("0.9.30".parse().unwrap()), recommended: None } ; "basic with patch"
769    )]
770    #[test_case(
771        r#"
772            nextest-version = { recommended = "0.9.20" }
773        "#,
774        NextestVersionDeserialize { required: None, recommended: Some("0.9.20".parse().unwrap()) } ; "with warning"
775    )]
776    #[test_case(
777        r#"
778            nextest-version = { required = "0.9.20", recommended = "0.9.25" }
779        "#,
780        NextestVersionDeserialize {
781            required: Some("0.9.20".parse().unwrap()),
782            recommended: Some("0.9.25".parse().unwrap()),
783        } ; "with error and warning"
784    )]
785    fn test_valid_nextest_version(input: &str, expected: NextestVersionDeserialize) {
786        let actual: VersionOnlyDeserialize = toml::from_str(input).unwrap();
787        assert_eq!(actual.nextest_version.unwrap(), expected);
788    }
789
790    #[test_case(
791        r#"
792            nextest-version = 42
793        "#,
794        "a table ({{ required = \"0.9.20\", recommended = \"0.9.30\" }}) or a string (\"0.9.50\")" ; "empty"
795    )]
796    #[test_case(
797        r#"
798            nextest-version = "0.9.30-rc.1"
799        "#,
800        "pre-release identifiers are not supported in nextest-version" ; "pre-release"
801    )]
802    #[test_case(
803        r#"
804            nextest-version = "0.9.40+mybuild"
805        "#,
806        "build metadata is not supported in nextest-version" ; "build metadata"
807    )]
808    #[test_case(
809        r#"
810            nextest-version = { required = "0.9.20", recommended = "0.9.10" }
811        "#,
812        "required version (0.9.20) must not be greater than recommended version (0.9.10)" ; "error greater than warning"
813    )]
814    fn test_invalid_nextest_version(input: &str, error_message: &str) {
815        let err = toml::from_str::<VersionOnlyDeserialize>(input).unwrap_err();
816        assert!(
817            err.to_string().contains(error_message),
818            "error `{err}` contains `{error_message}`"
819        );
820    }
821
822    fn tool_name(s: &str) -> ToolName {
823        ToolName::new(s.into()).unwrap()
824    }
825
826    #[test]
827    fn test_accumulate() {
828        let mut nextest_version = NextestVersionConfig::default();
829        nextest_version.accumulate(
830            NextestVersionDeserialize {
831                required: Some("0.9.20".parse().unwrap()),
832                recommended: None,
833            },
834            Some(tool_name("tool1")),
835        );
836        nextest_version.accumulate(
837            NextestVersionDeserialize {
838                required: Some("0.9.30".parse().unwrap()),
839                recommended: Some("0.9.35".parse().unwrap()),
840            },
841            Some(tool_name("tool2")),
842        );
843        nextest_version.accumulate(
844            NextestVersionDeserialize {
845                required: None,
846                // This recommended version is ignored since it is less than the last recommended
847                // version.
848                recommended: Some("0.9.25".parse().unwrap()),
849            },
850            Some(tool_name("tool3")),
851        );
852        nextest_version.accumulate(
853            NextestVersionDeserialize {
854                // This is accepted because it is the same as the last required version, and the
855                // last tool wins.
856                required: Some("0.9.30".parse().unwrap()),
857                recommended: None,
858            },
859            Some(tool_name("tool4")),
860        );
861
862        assert_eq!(
863            nextest_version,
864            NextestVersionConfig {
865                required: NextestVersionReq::Version {
866                    version: "0.9.30".parse().unwrap(),
867                    tool: Some(tool_name("tool4")),
868                },
869                recommended: NextestVersionReq::Version {
870                    version: "0.9.35".parse().unwrap(),
871                    tool: Some(tool_name("tool2")),
872                },
873            }
874        );
875    }
876
877    #[test]
878    fn test_from_env_benchmarks() {
879        // SAFETY:
880        // https://nexte.st/docs/configuration/env-vars/#altering-the-environment-within-tests
881        unsafe { std::env::set_var("NEXTEST_EXPERIMENTAL_BENCHMARKS", "1") };
882        assert!(ConfigExperimental::from_env().contains(&ConfigExperimental::Benchmarks));
883
884        // Other values do not enable the feature.
885        // SAFETY:
886        // https://nexte.st/docs/configuration/env-vars/#altering-the-environment-within-tests
887        unsafe { std::env::set_var("NEXTEST_EXPERIMENTAL_BENCHMARKS", "0") };
888        assert!(!ConfigExperimental::from_env().contains(&ConfigExperimental::Benchmarks));
889
890        // SAFETY:
891        // https://nexte.st/docs/configuration/env-vars/#altering-the-environment-within-tests
892        unsafe { std::env::set_var("NEXTEST_EXPERIMENTAL_BENCHMARKS", "true") };
893        assert!(!ConfigExperimental::from_env().contains(&ConfigExperimental::Benchmarks));
894
895        // SetupScripts and WrapperScripts have no env vars, so they are never
896        // enabled via from_env.
897        // SAFETY:
898        // https://nexte.st/docs/configuration/env-vars/#altering-the-environment-within-tests
899        unsafe { std::env::set_var("NEXTEST_EXPERIMENTAL_BENCHMARKS", "1") };
900        let set = ConfigExperimental::from_env();
901        assert!(!set.contains(&ConfigExperimental::SetupScripts));
902        assert!(!set.contains(&ConfigExperimental::WrapperScripts));
903    }
904
905    #[test]
906    fn test_experimental_formats() {
907        // For the array format, valid features should parse correctly.
908        let input = r#"experimental = ["setup-scripts", "benchmarks"]"#;
909        let d: VersionOnlyDeserialize = toml::from_str(input).unwrap();
910        assert_eq!(
911            d.experimental.known,
912            BTreeSet::from([
913                ConfigExperimental::SetupScripts,
914                ConfigExperimental::Benchmarks
915            ]),
916            "expected 2 known features"
917        );
918        assert!(d.experimental.unknown.is_empty());
919
920        // An empty array is empty.
921        let input = r#"experimental = []"#;
922        let d: VersionOnlyDeserialize = toml::from_str(input).unwrap();
923        assert!(
924            d.experimental.is_empty(),
925            "expected empty, got {:?}",
926            d.experimental
927        );
928
929        // Unknown features in the array format are recorded.
930        let input = r#"experimental = ["setup-scripts", "unknown-feature"]"#;
931        let d: VersionOnlyDeserialize = toml::from_str(input).unwrap();
932        assert_eq!(
933            d.experimental.known,
934            BTreeSet::from([ConfigExperimental::SetupScripts])
935        );
936        assert_eq!(
937            d.experimental.unknown,
938            BTreeSet::from(["unknown-feature".to_owned()])
939        );
940
941        // Table format: valid features parse correctly.
942        let input = r#"
943[experimental]
944setup-scripts = true
945benchmarks = true
946"#;
947        let d: VersionOnlyDeserialize = toml::from_str(input).unwrap();
948        assert_eq!(
949            d.experimental.known,
950            BTreeSet::from([
951                ConfigExperimental::SetupScripts,
952                ConfigExperimental::Benchmarks
953            ])
954        );
955        assert!(d.experimental.unknown.is_empty());
956
957        // Empty table is empty.
958        let input = r#"[experimental]"#;
959        let d: VersionOnlyDeserialize = toml::from_str(input).unwrap();
960        assert!(
961            d.experimental.is_empty(),
962            "expected empty, got {:?}",
963            d.experimental
964        );
965
966        // If all features are false, the result is empty.
967        let input = r#"
968[experimental]
969setup-scripts = false
970"#;
971        let d: VersionOnlyDeserialize = toml::from_str(input).unwrap();
972        assert!(
973            d.experimental.is_empty(),
974            "expected empty, got {:?}",
975            d.experimental
976        );
977
978        // Unknown features in the table format are recorded.
979        let input = r#"
980[experimental]
981setup-scripts = true
982unknown-feature = true
983"#;
984        let d: VersionOnlyDeserialize = toml::from_str(input).unwrap();
985        assert_eq!(
986            d.experimental.known,
987            BTreeSet::from([ConfigExperimental::SetupScripts])
988        );
989        assert!(d.experimental.unknown.contains("unknown-feature"));
990
991        // An invalid type shows a helpful error mentioning both formats.
992        let input = r#"experimental = 42"#;
993        let err = toml::from_str::<VersionOnlyDeserialize>(input).unwrap_err();
994        let err_str = err.to_string();
995        assert!(
996            err_str.contains("expected a table") && err_str.contains("or an array"),
997            "expected error to mention both formats, got: {}",
998            err_str
999        );
1000
1001        let input = r#"experimental = "setup-scripts""#;
1002        let err = toml::from_str::<VersionOnlyDeserialize>(input).unwrap_err();
1003        let err_str = err.to_string();
1004        assert!(
1005            err_str.contains("expected a table") && err_str.contains("or an array"),
1006            "expected error to mention both formats, got: {}",
1007            err_str
1008        );
1009    }
1010}