1use super::{NextestConfig, ToolConfigFile};
7use crate::errors::{ConfigParseError, ConfigParseErrorKind};
8use camino::Utf8Path;
9use semver::Version;
10use serde::{Deserialize, Deserializer};
11use std::{borrow::Cow, collections::BTreeSet, fmt, str::FromStr};
12
13#[derive(Debug, Default, Clone, PartialEq, Eq)]
18pub struct VersionOnlyConfig {
19 nextest_version: NextestVersionConfig,
21
22 experimental: BTreeSet<ConfigExperimental>,
24}
25
26impl VersionOnlyConfig {
27 pub fn from_sources<'a, I>(
31 workspace_root: &Utf8Path,
32 config_file: Option<&Utf8Path>,
33 tool_config_files: impl IntoIterator<IntoIter = I>,
34 ) -> Result<Self, ConfigParseError>
35 where
36 I: Iterator<Item = &'a ToolConfigFile> + DoubleEndedIterator,
37 {
38 let tool_config_files_rev = tool_config_files.into_iter().rev();
39
40 Self::read_from_sources(workspace_root, config_file, tool_config_files_rev)
41 }
42
43 pub fn nextest_version(&self) -> &NextestVersionConfig {
45 &self.nextest_version
46 }
47
48 pub fn experimental(&self) -> &BTreeSet<ConfigExperimental> {
50 &self.experimental
51 }
52
53 fn read_from_sources<'a>(
54 workspace_root: &Utf8Path,
55 config_file: Option<&Utf8Path>,
56 tool_config_files_rev: impl Iterator<Item = &'a ToolConfigFile>,
57 ) -> Result<Self, ConfigParseError> {
58 let mut nextest_version = NextestVersionConfig::default();
59 let mut experimental = BTreeSet::new();
60
61 for ToolConfigFile { config_file, tool } in tool_config_files_rev {
63 if let Some(v) = Self::read_and_deserialize(config_file, Some(tool))?.nextest_version {
64 nextest_version.accumulate(v, Some(tool));
65 }
66 }
67
68 let config_file = match config_file {
70 Some(file) => Some(Cow::Borrowed(file)),
71 None => {
72 let config_file = workspace_root.join(NextestConfig::CONFIG_PATH);
73 config_file.exists().then_some(Cow::Owned(config_file))
74 }
75 };
76 if let Some(config_file) = config_file {
77 let d = Self::read_and_deserialize(&config_file, None)?;
78 if let Some(v) = d.nextest_version {
79 nextest_version.accumulate(v, None);
80 }
81
82 let unknown: BTreeSet<_> = d
84 .experimental
85 .into_iter()
86 .filter(|feature| {
87 if let Ok(feature) = feature.parse::<ConfigExperimental>() {
88 experimental.insert(feature);
89 false
90 } else {
91 true
92 }
93 })
94 .collect();
95 if !unknown.is_empty() {
96 let known = ConfigExperimental::known().collect();
97 return Err(ConfigParseError::new(
98 config_file.into_owned(),
99 None,
100 ConfigParseErrorKind::UnknownExperimentalFeatures { unknown, known },
101 ));
102 }
103 }
104
105 Ok(Self {
106 nextest_version,
107 experimental,
108 })
109 }
110
111 fn read_and_deserialize(
112 config_file: &Utf8Path,
113 tool: Option<&str>,
114 ) -> Result<VersionOnlyDeserialize, ConfigParseError> {
115 let toml_str = std::fs::read_to_string(config_file.as_str()).map_err(|error| {
116 ConfigParseError::new(
117 config_file,
118 tool,
119 ConfigParseErrorKind::VersionOnlyReadError(error),
120 )
121 })?;
122 let toml_de = toml::de::Deserializer::new(&toml_str);
123 let v: VersionOnlyDeserialize =
124 serde_path_to_error::deserialize(toml_de).map_err(|error| {
125 ConfigParseError::new(
126 config_file,
127 tool,
128 ConfigParseErrorKind::VersionOnlyDeserializeError(Box::new(error)),
129 )
130 })?;
131 if tool.is_some() && !v.experimental.is_empty() {
132 return Err(ConfigParseError::new(
133 config_file,
134 tool,
135 ConfigParseErrorKind::ExperimentalFeaturesInToolConfig {
136 features: v.experimental,
137 },
138 ));
139 }
140
141 Ok(v)
142 }
143}
144
145#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize)]
147#[serde(rename_all = "kebab-case")]
148struct VersionOnlyDeserialize {
149 #[serde(default)]
150 nextest_version: Option<NextestVersionDeserialize>,
151 #[serde(default)]
152 experimental: BTreeSet<String>,
153}
154
155#[derive(Debug, Default, Clone, PartialEq, Eq)]
161pub struct NextestVersionConfig {
162 pub required: NextestVersionReq,
164
165 pub recommended: NextestVersionReq,
170}
171
172impl NextestVersionConfig {
173 pub(crate) fn accumulate(&mut self, v: NextestVersionDeserialize, v_tool: Option<&str>) {
175 if let Some(v) = v.required {
176 self.required.accumulate(v, v_tool);
177 }
178 if let Some(v) = v.recommended {
179 self.recommended.accumulate(v, v_tool);
180 }
181 }
182
183 pub fn eval(
185 &self,
186 current_version: &Version,
187 override_version_check: bool,
188 ) -> NextestVersionEval {
189 match self.required.satisfies(current_version) {
190 Ok(()) => {}
191 Err((required, tool)) => {
192 if override_version_check {
193 return NextestVersionEval::ErrorOverride {
194 required: required.clone(),
195 current: current_version.clone(),
196 tool: tool.map(|s| s.to_owned()),
197 };
198 } else {
199 return NextestVersionEval::Error {
200 required: required.clone(),
201 current: current_version.clone(),
202 tool: tool.map(|s| s.to_owned()),
203 };
204 }
205 }
206 }
207
208 match self.recommended.satisfies(current_version) {
209 Ok(()) => NextestVersionEval::Satisfied,
210 Err((recommended, tool)) => {
211 if override_version_check {
212 NextestVersionEval::WarnOverride {
213 recommended: recommended.clone(),
214 current: current_version.clone(),
215 tool: tool.map(|s| s.to_owned()),
216 }
217 } else {
218 NextestVersionEval::Warn {
219 recommended: recommended.clone(),
220 current: current_version.clone(),
221 tool: tool.map(|s| s.to_owned()),
222 }
223 }
224 }
225 }
226 }
227}
228
229#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Ord, PartialOrd)]
231#[non_exhaustive]
232pub enum ConfigExperimental {
233 SetupScripts,
235 WrapperScripts,
237}
238
239impl ConfigExperimental {
240 fn known() -> impl Iterator<Item = Self> {
241 vec![Self::SetupScripts, Self::WrapperScripts].into_iter()
242 }
243}
244
245impl FromStr for ConfigExperimental {
246 type Err = ();
247
248 fn from_str(s: &str) -> Result<Self, Self::Err> {
249 match s {
250 "setup-scripts" => Ok(Self::SetupScripts),
251 "wrapper-scripts" => Ok(Self::WrapperScripts),
252 _ => Err(()),
253 }
254 }
255}
256
257impl fmt::Display for ConfigExperimental {
258 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
259 match self {
260 Self::SetupScripts => write!(f, "setup-scripts"),
261 Self::WrapperScripts => write!(f, "wrapper-scripts"),
262 }
263 }
264}
265
266#[derive(Debug, Default, Clone, PartialEq, Eq)]
268pub enum NextestVersionReq {
269 Version {
271 version: Version,
273
274 tool: Option<String>,
276 },
277
278 #[default]
280 None,
281}
282
283impl NextestVersionReq {
284 fn accumulate(&mut self, v: Version, v_tool: Option<&str>) {
285 match self {
286 NextestVersionReq::Version { version, tool } => {
287 if &v >= version {
290 *version = v;
291 *tool = v_tool.map(|s| s.to_owned());
292 }
293 }
294 NextestVersionReq::None => {
295 *self = NextestVersionReq::Version {
296 version: v,
297 tool: v_tool.map(|s| s.to_owned()),
298 };
299 }
300 }
301 }
302
303 fn satisfies(&self, version: &Version) -> Result<(), (&Version, Option<&str>)> {
304 match self {
305 NextestVersionReq::Version {
306 version: required,
307 tool,
308 } => {
309 if version >= required {
310 Ok(())
311 } else {
312 Err((required, tool.as_deref()))
313 }
314 }
315 NextestVersionReq::None => Ok(()),
316 }
317 }
318}
319
320#[derive(Debug, Clone, PartialEq, Eq)]
324pub enum NextestVersionEval {
325 Satisfied,
327
328 Error {
330 required: Version,
332 current: Version,
334 tool: Option<String>,
336 },
337
338 Warn {
340 recommended: Version,
342 current: Version,
344 tool: Option<String>,
346 },
347
348 ErrorOverride {
350 required: Version,
352 current: Version,
354 tool: Option<String>,
356 },
357
358 WarnOverride {
360 recommended: Version,
362 current: Version,
364 tool: Option<String>,
366 },
367}
368
369#[derive(Debug, Clone, PartialEq, Eq)]
375pub(crate) struct NextestVersionDeserialize {
376 required: Option<Version>,
378
379 recommended: Option<Version>,
381}
382
383impl<'de> Deserialize<'de> for NextestVersionDeserialize {
384 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
385 where
386 D: Deserializer<'de>,
387 {
388 struct V;
389
390 impl<'de2> serde::de::Visitor<'de2> for V {
391 type Value = NextestVersionDeserialize;
392
393 fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
394 formatter.write_str(
395 "a table ({{ required = \"0.9.20\", recommended = \"0.9.30\" }}) or a string (\"0.9.50\")",
396 )
397 }
398
399 fn visit_str<E>(self, s: &str) -> std::result::Result<Self::Value, E>
400 where
401 E: serde::de::Error,
402 {
403 let required = parse_version::<E>(s.to_owned())?;
404 Ok(NextestVersionDeserialize {
405 required: Some(required),
406 recommended: None,
407 })
408 }
409
410 fn visit_map<A>(self, map: A) -> std::result::Result<Self::Value, A::Error>
411 where
412 A: serde::de::MapAccess<'de2>,
413 {
414 #[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
415 struct NextestVersionMap {
416 #[serde(default, deserialize_with = "deserialize_version_opt")]
417 required: Option<Version>,
418 #[serde(default, deserialize_with = "deserialize_version_opt")]
419 recommended: Option<Version>,
420 }
421
422 let NextestVersionMap {
423 required,
424 recommended,
425 } = NextestVersionMap::deserialize(serde::de::value::MapAccessDeserializer::new(
426 map,
427 ))?;
428
429 if let (Some(required), Some(recommended)) = (&required, &recommended) {
430 if required > recommended {
431 return Err(serde::de::Error::custom(format!(
432 "required version ({required}) must not be greater than recommended version ({recommended})"
433 )));
434 }
435 }
436
437 Ok(NextestVersionDeserialize {
438 required,
439 recommended,
440 })
441 }
442 }
443
444 deserializer.deserialize_any(V)
445 }
446}
447
448fn deserialize_version_opt<'de, D>(
453 deserializer: D,
454) -> std::result::Result<Option<Version>, D::Error>
455where
456 D: Deserializer<'de>,
457{
458 let s = Option::<String>::deserialize(deserializer)?;
459 s.map(parse_version::<D::Error>).transpose()
460}
461
462fn parse_version<E>(mut s: String) -> std::result::Result<Version, E>
463where
464 E: serde::de::Error,
465{
466 for ch in s.chars() {
467 if ch == '-' {
468 return Err(E::custom(
469 "pre-release identifiers are not supported in nextest-version",
470 ));
471 } else if ch == '+' {
472 return Err(E::custom(
473 "build metadata is not supported in nextest-version",
474 ));
475 }
476 }
477
478 if s.matches('.').count() == 1 {
481 s.push_str(".0");
483 }
484
485 Version::parse(&s).map_err(E::custom)
486}
487
488#[cfg(test)]
489mod tests {
490 use super::*;
491 use test_case::test_case;
492
493 #[test_case(
494 r#"
495 nextest-version = "0.9"
496 "#,
497 NextestVersionDeserialize { required: Some("0.9.0".parse().unwrap()), recommended: None } ; "basic"
498 )]
499 #[test_case(
500 r#"
501 nextest-version = "0.9.30"
502 "#,
503 NextestVersionDeserialize { required: Some("0.9.30".parse().unwrap()), recommended: None } ; "basic with patch"
504 )]
505 #[test_case(
506 r#"
507 nextest-version = { recommended = "0.9.20" }
508 "#,
509 NextestVersionDeserialize { required: None, recommended: Some("0.9.20".parse().unwrap()) } ; "with warning"
510 )]
511 #[test_case(
512 r#"
513 nextest-version = { required = "0.9.20", recommended = "0.9.25" }
514 "#,
515 NextestVersionDeserialize {
516 required: Some("0.9.20".parse().unwrap()),
517 recommended: Some("0.9.25".parse().unwrap()),
518 } ; "with error and warning"
519 )]
520 fn test_valid_nextest_version(input: &str, expected: NextestVersionDeserialize) {
521 let actual: VersionOnlyDeserialize = toml::from_str(input).unwrap();
522 assert_eq!(actual.nextest_version.unwrap(), expected);
523 }
524
525 #[test_case(
526 r#"
527 nextest-version = 42
528 "#,
529 "a table ({{ required = \"0.9.20\", recommended = \"0.9.30\" }}) or a string (\"0.9.50\")" ; "empty"
530 )]
531 #[test_case(
532 r#"
533 nextest-version = "0.9.30-rc.1"
534 "#,
535 "pre-release identifiers are not supported in nextest-version" ; "pre-release"
536 )]
537 #[test_case(
538 r#"
539 nextest-version = "0.9.40+mybuild"
540 "#,
541 "build metadata is not supported in nextest-version" ; "build metadata"
542 )]
543 #[test_case(
544 r#"
545 nextest-version = { required = "0.9.20", recommended = "0.9.10" }
546 "#,
547 "required version (0.9.20) must not be greater than recommended version (0.9.10)" ; "error greater than warning"
548 )]
549 fn test_invalid_nextest_version(input: &str, error_message: &str) {
550 let err = toml::from_str::<VersionOnlyDeserialize>(input).unwrap_err();
551 assert!(
552 err.to_string().contains(error_message),
553 "error `{err}` contains `{error_message}`"
554 );
555 }
556
557 #[test]
558 fn test_accumulate() {
559 let mut nextest_version = NextestVersionConfig::default();
560 nextest_version.accumulate(
561 NextestVersionDeserialize {
562 required: Some("0.9.20".parse().unwrap()),
563 recommended: None,
564 },
565 Some("tool1"),
566 );
567 nextest_version.accumulate(
568 NextestVersionDeserialize {
569 required: Some("0.9.30".parse().unwrap()),
570 recommended: Some("0.9.35".parse().unwrap()),
571 },
572 Some("tool2"),
573 );
574 nextest_version.accumulate(
575 NextestVersionDeserialize {
576 required: None,
577 recommended: Some("0.9.25".parse().unwrap()),
580 },
581 Some("tool3"),
582 );
583 nextest_version.accumulate(
584 NextestVersionDeserialize {
585 required: Some("0.9.30".parse().unwrap()),
588 recommended: None,
589 },
590 Some("tool4"),
591 );
592
593 assert_eq!(
594 nextest_version,
595 NextestVersionConfig {
596 required: NextestVersionReq::Version {
597 version: "0.9.30".parse().unwrap(),
598 tool: Some("tool4".to_owned()),
599 },
600 recommended: NextestVersionReq::Version {
601 version: "0.9.35".parse().unwrap(),
602 tool: Some("tool2".to_owned()),
603 },
604 }
605 );
606 }
607}