1use 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#[derive(Debug, Default, Clone, PartialEq, Eq)]
23pub struct VersionOnlyConfig {
24 nextest_version: NextestVersionConfig,
26
27 experimental: ExperimentalConfig,
29}
30
31impl VersionOnlyConfig {
32 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 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 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 pub fn nextest_version(&self) -> &NextestVersionConfig {
84 &self.nextest_version
85 }
86
87 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 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#[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#[derive(Debug, Default, Clone, PartialEq, Eq)]
179pub(crate) struct ExperimentalDeserialize {
180 known: BTreeSet<ConfigExperimental>,
182 unknown: BTreeSet<String>,
184}
185
186impl ExperimentalDeserialize {
187 fn is_empty(&self) -> bool {
189 self.known.is_empty() && self.unknown.is_empty()
190 }
191
192 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 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 #[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#[derive(Debug, Default, Clone, PartialEq, Eq)]
321pub struct NextestVersionConfig {
322 pub required: NextestVersionReq,
324
325 pub recommended: NextestVersionReq,
330}
331
332impl NextestVersionConfig {
333 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 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#[derive(Debug, Default, Clone, PartialEq, Eq)]
394pub struct ExperimentalConfig {
395 known: BTreeSet<ConfigExperimental>,
397
398 unknown: Vec<(ConfigSource, BTreeSet<String>)>,
400}
401
402impl ExperimentalConfig {
403 pub fn known(&self) -> &BTreeSet<ConfigExperimental> {
405 &self.known
406 }
407
408 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#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Ord, PartialOrd)]
427#[non_exhaustive]
428pub enum ConfigExperimental {
429 SetupScripts,
431 WrapperScripts,
433 Benchmarks,
435}
436
437impl ConfigExperimental {
438 pub fn known_features() -> impl Iterator<Item = Self> {
440 vec![Self::SetupScripts, Self::WrapperScripts, Self::Benchmarks].into_iter()
441 }
442
443 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 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#[derive(Debug, Default, Clone, PartialEq, Eq)]
491pub enum NextestVersionReq {
492 Version {
494 version: Version,
496
497 source: ConfigSource,
499 },
500
501 #[default]
503 None,
504}
505
506impl NextestVersionReq {
507 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 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#[derive(Debug, Clone, PartialEq, Eq)]
555pub enum NextestVersionEval {
556 Satisfied,
558
559 Error {
561 required: Version,
563 current: Version,
565 source: ConfigSource,
567 },
568
569 Warn {
571 recommended: Version,
573 current: Version,
575 source: ConfigSource,
577 },
578
579 ErrorOverride {
581 required: Version,
583 current: Version,
585 source: ConfigSource,
587 },
588
589 WarnOverride {
591 recommended: Version,
593 current: Version,
595 source: ConfigSource,
597 },
598}
599
600#[derive(Debug, Clone, PartialEq, Eq)]
606pub(crate) struct NextestVersionDeserialize {
607 required: Option<Version>,
609
610 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
702fn 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 if s.matches('.').count() == 1 {
735 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 recommended: Some("0.9.25".parse().unwrap()),
987 },
988 &tool3,
989 );
990 nextest_version.accumulate(
991 NextestVersionDeserialize {
992 required: Some("0.9.30".parse().unwrap()),
995 recommended: None,
996 },
997 &tool4,
998 );
999 nextest_version.accumulate(
1000 NextestVersionDeserialize {
1001 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 unsafe { std::env::set_var("NEXTEST_EXPERIMENTAL_BENCHMARKS", "1") };
1038 assert!(ConfigExperimental::from_env().contains(&ConfigExperimental::Benchmarks));
1039
1040 unsafe { std::env::set_var("NEXTEST_EXPERIMENTAL_BENCHMARKS", "0") };
1044 assert!(!ConfigExperimental::from_env().contains(&ConfigExperimental::Benchmarks));
1045
1046 unsafe { std::env::set_var("NEXTEST_EXPERIMENTAL_BENCHMARKS", "true") };
1049 assert!(!ConfigExperimental::from_env().contains(&ConfigExperimental::Benchmarks));
1050
1051 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 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 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 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 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 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 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 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 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}