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