1use crate::errors::MaxFailParseError;
5use serde::Deserialize;
6use std::{fmt, str::FromStr};
7
8#[derive(Clone, Copy, Debug, Eq, PartialEq)]
10pub enum MaxFail {
11 Count {
13 max_fail: usize,
15 terminate: TerminateMode,
17 },
18
19 All,
21}
22
23impl MaxFail {
24 pub fn from_fail_fast(fail_fast: bool) -> Self {
26 if fail_fast {
27 Self::Count {
28 max_fail: 1,
29 terminate: TerminateMode::Wait,
30 }
31 } else {
32 Self::All
33 }
34 }
35
36 pub fn is_exceeded(&self, failed: usize) -> Option<TerminateMode> {
38 match self {
39 Self::Count {
40 max_fail,
41 terminate,
42 } => (failed >= *max_fail).then_some(*terminate),
43 Self::All => None,
44 }
45 }
46}
47
48impl FromStr for MaxFail {
49 type Err = MaxFailParseError;
50
51 fn from_str(s: &str) -> Result<Self, Self::Err> {
52 if s.to_lowercase() == "all" {
53 return Ok(Self::All);
54 }
55
56 let (count_str, terminate) = if let Some((count, mode_str)) = s.split_once(':') {
58 (count, mode_str.parse()?)
59 } else {
60 (s, TerminateMode::default())
61 };
62
63 let max_fail = count_str
65 .parse::<isize>()
66 .map_err(|e| MaxFailParseError::new(format!("{e} parsing '{count_str}'")))?;
67
68 if max_fail <= 0 {
69 return Err(MaxFailParseError::new("max-fail may not be <= 0"));
70 }
71
72 Ok(MaxFail::Count {
73 max_fail: max_fail as usize,
74 terminate,
75 })
76 }
77}
78
79impl fmt::Display for MaxFail {
80 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81 match self {
82 Self::All => write!(f, "all"),
83 Self::Count {
84 max_fail,
85 terminate,
86 } => {
87 if *terminate == TerminateMode::default() {
88 write!(f, "{max_fail}")
89 } else {
90 write!(f, "{max_fail}:{terminate}")
91 }
92 }
93 }
94 }
95}
96
97#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Deserialize)]
99#[serde(rename_all = "kebab-case")]
100pub enum TerminateMode {
101 #[default]
103 Wait,
104 Immediate,
106}
107
108impl fmt::Display for TerminateMode {
109 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
110 match self {
111 Self::Wait => write!(f, "wait"),
112 Self::Immediate => write!(f, "immediate"),
113 }
114 }
115}
116
117impl FromStr for TerminateMode {
118 type Err = MaxFailParseError;
119
120 fn from_str(s: &str) -> Result<Self, Self::Err> {
121 match s {
122 "wait" => Ok(Self::Wait),
123 "immediate" => Ok(Self::Immediate),
124 _ => Err(MaxFailParseError::new(format!(
125 "invalid terminate mode '{}', expected 'wait' or 'immediate'",
126 s
127 ))),
128 }
129 }
130}
131
132pub(in crate::config) fn deserialize_fail_fast<'de, D>(
134 deserializer: D,
135) -> Result<Option<MaxFail>, D::Error>
136where
137 D: serde::Deserializer<'de>,
138{
139 struct V;
140
141 impl<'de2> serde::de::Visitor<'de2> for V {
142 type Value = Option<MaxFail>;
143
144 fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
145 write!(formatter, "a boolean or {{ max-fail = ... }}")
146 }
147
148 fn visit_bool<E>(self, v: bool) -> Result<Self::Value, E>
149 where
150 E: serde::de::Error,
151 {
152 Ok(Some(MaxFail::from_fail_fast(v)))
153 }
154
155 fn visit_map<A>(self, map: A) -> Result<Self::Value, A::Error>
156 where
157 A: serde::de::MapAccess<'de2>,
158 {
159 let de = serde::de::value::MapAccessDeserializer::new(map);
160 FailFastMap::deserialize(de).map(|helper| match helper.max_fail_count {
161 MaxFailCount::Count(n) => Some(MaxFail::Count {
162 max_fail: n,
163 terminate: helper.terminate,
164 }),
165 MaxFailCount::All => Some(MaxFail::All),
166 })
167 }
168 }
169
170 deserializer.deserialize_any(V)
171}
172
173#[derive(Deserialize)]
175struct FailFastMap {
176 #[serde(rename = "max-fail")]
177 max_fail_count: MaxFailCount,
178 #[serde(default)]
179 terminate: TerminateMode,
180}
181
182#[derive(Clone, Copy, Debug, Eq, PartialEq)]
184enum MaxFailCount {
185 Count(usize),
186 All,
187}
188
189impl<'de> Deserialize<'de> for MaxFailCount {
190 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
191 where
192 D: serde::Deserializer<'de>,
193 {
194 struct V;
195
196 impl serde::de::Visitor<'_> for V {
197 type Value = MaxFailCount;
198
199 fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
200 write!(formatter, "a positive integer or the string \"all\"")
201 }
202
203 fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
204 where
205 E: serde::de::Error,
206 {
207 if v == "all" {
208 return Ok(MaxFailCount::All);
209 }
210
211 if let Ok(val) = v.parse::<i64>() {
214 if val > 0 {
215 return Err(serde::de::Error::invalid_value(
216 serde::de::Unexpected::Str(v),
217 &"the string \"all\" (numbers must be specified without quotes)",
218 ));
219 } else {
220 return Err(serde::de::Error::invalid_value(
221 serde::de::Unexpected::Str(v),
222 &"the string \"all\" (numbers must be positive and without quotes)",
223 ));
224 }
225 }
226
227 Err(serde::de::Error::invalid_value(
228 serde::de::Unexpected::Str(v),
229 &"the string \"all\" or a positive integer",
230 ))
231 }
232
233 fn visit_i64<E>(self, v: i64) -> Result<Self::Value, E>
234 where
235 E: serde::de::Error,
236 {
237 if v > 0 {
238 Ok(MaxFailCount::Count(v as usize))
239 } else {
240 Err(serde::de::Error::invalid_value(
241 serde::de::Unexpected::Signed(v),
242 &"a positive integer or the string \"all\"",
243 ))
244 }
245 }
246 }
247
248 deserializer.deserialize_any(V)
249 }
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255 use crate::{
256 config::{core::NextestConfig, utils::test_helpers::*},
257 errors::ConfigParseErrorKind,
258 };
259 use camino_tempfile::tempdir;
260 use indoc::indoc;
261 use nextest_filtering::ParseContext;
262 use test_case::test_case;
263
264 #[test]
265 fn maxfail_builder_from_str() {
266 let successes = vec![
267 ("all", MaxFail::All),
268 ("ALL", MaxFail::All),
269 (
270 "1",
271 MaxFail::Count {
272 max_fail: 1,
273 terminate: TerminateMode::Wait,
274 },
275 ),
276 (
277 "1:wait",
278 MaxFail::Count {
279 max_fail: 1,
280 terminate: TerminateMode::Wait,
281 },
282 ),
283 (
284 "1:immediate",
285 MaxFail::Count {
286 max_fail: 1,
287 terminate: TerminateMode::Immediate,
288 },
289 ),
290 (
291 "5:immediate",
292 MaxFail::Count {
293 max_fail: 5,
294 terminate: TerminateMode::Immediate,
295 },
296 ),
297 ];
298
299 let failures = vec!["-1", "0", "foo", "1:invalid", "1:"];
300
301 for (input, output) in successes {
302 assert_eq!(
303 MaxFail::from_str(input).unwrap_or_else(|err| panic!(
304 "expected input '{input}' to succeed, failed with: {err}"
305 )),
306 output,
307 "success case '{input}' matches",
308 );
309 }
310
311 for input in failures {
312 MaxFail::from_str(input).expect_err(&format!("expected input '{input}' to fail"));
313 }
314 }
315
316 #[test_case(
317 indoc! {r#"
318 [profile.custom]
319 fail-fast = true
320 "#},
321 MaxFail::Count { max_fail: 1, terminate: TerminateMode::Wait }
322 ; "boolean true"
323 )]
324 #[test_case(
325 indoc! {r#"
326 [profile.custom]
327 fail-fast = false
328 "#},
329 MaxFail::All
330 ; "boolean false"
331 )]
332 #[test_case(
333 indoc! {r#"
334 [profile.custom]
335 fail-fast = { max-fail = 1 }
336 "#},
337 MaxFail::Count { max_fail: 1, terminate: TerminateMode::Wait }
338 ; "max-fail 1"
339 )]
340 #[test_case(
341 indoc! {r#"
342 [profile.custom]
343 fail-fast = { max-fail = 2 }
344 "#},
345 MaxFail::Count { max_fail: 2, terminate: TerminateMode::Wait }
346 ; "max-fail 2"
347 )]
348 #[test_case(
349 indoc! {r#"
350 [profile.custom]
351 fail-fast = { max-fail = "all" }
352 "#},
353 MaxFail::All
354 ; "max-fail all"
355 )]
356 #[test_case(
357 indoc! {r#"
358 [profile.custom]
359 fail-fast = { max-fail = 1, terminate = "wait" }
360 "#},
361 MaxFail::Count { max_fail: 1, terminate: TerminateMode::Wait }
362 ; "max-fail 1 with explicit wait"
363 )]
364 #[test_case(
365 indoc! {r#"
366 [profile.custom]
367 fail-fast = { max-fail = 1, terminate = "immediate" }
368 "#},
369 MaxFail::Count { max_fail: 1, terminate: TerminateMode::Immediate }
370 ; "max-fail 1 with immediate"
371 )]
372 #[test_case(
373 indoc! {r#"
374 [profile.custom]
375 fail-fast = { max-fail = 5, terminate = "immediate" }
376 "#},
377 MaxFail::Count { max_fail: 5, terminate: TerminateMode::Immediate }
378 ; "max-fail 5 with immediate"
379 )]
380 fn parse_fail_fast(config_contents: &str, expected: MaxFail) {
381 let workspace_dir = tempdir().unwrap();
382 let graph = temp_workspace(&workspace_dir, config_contents);
383
384 let pcx = ParseContext::new(&graph);
385
386 let config = NextestConfig::from_sources(
387 graph.workspace().root(),
388 &pcx,
389 None,
390 [],
391 &Default::default(),
392 )
393 .expect("expected parsing to succeed");
394
395 let profile = config
396 .profile("custom")
397 .unwrap()
398 .apply_build_platforms(&build_platforms());
399
400 assert_eq!(profile.max_fail(), expected);
401 }
402
403 #[test_case(
404 indoc! {r#"
405 [profile.custom]
406 fail-fast = { max-fail = 0 }
407 "#},
408 "profile.custom.fail-fast.max-fail: invalid value: integer `0`, expected a positive integer or the string \"all\""
409 ; "invalid zero max-fail"
410 )]
411 #[test_case(
412 indoc! {r#"
413 [profile.custom]
414 fail-fast = { max-fail = -1 }
415 "#},
416 "profile.custom.fail-fast.max-fail: invalid value: integer `-1`, expected a positive integer or the string \"all\""
417 ; "invalid negative max-fail"
418 )]
419 #[test_case(
420 indoc! {r#"
421 [profile.custom]
422 fail-fast = { max-fail = "" }
423 "#},
424 "profile.custom.fail-fast.max-fail: invalid value: string \"\", expected the string \"all\" or a positive integer"
425 ; "empty string max-fail"
426 )]
427 #[test_case(
428 indoc! {r#"
429 [profile.custom]
430 fail-fast = { max-fail = "1" }
431 "#},
432 "profile.custom.fail-fast.max-fail: invalid value: string \"1\", expected the string \"all\" (numbers must be specified without quotes)"
433 ; "string as positive integer"
434 )]
435 #[test_case(
436 indoc! {r#"
437 [profile.custom]
438 fail-fast = { max-fail = "0" }
439 "#},
440 "profile.custom.fail-fast.max-fail: invalid value: string \"0\", expected the string \"all\" (numbers must be positive and without quotes)"
441 ; "zero string"
442 )]
443 #[test_case(
444 indoc! {r#"
445 [profile.custom]
446 fail-fast = { max-fail = "invalid" }
447 "#},
448 "profile.custom.fail-fast.max-fail: invalid value: string \"invalid\", expected the string \"all\" or a positive integer"
449 ; "invalid string max-fail"
450 )]
451 #[test_case(
452 indoc! {r#"
453 [profile.custom]
454 fail-fast = { max-fail = true }
455 "#},
456 "profile.custom.fail-fast.max-fail: invalid type: boolean `true`, expected a positive integer or the string \"all\""
457 ; "invalid max-fail type"
458 )]
459 #[test_case(
460 indoc! {r#"
461 [profile.custom]
462 fail-fast = { invalid-key = 1 }
463 "#},
464 r#"profile.custom.fail-fast: missing configuration field "profile.custom.fail-fast.max-fail""#
465 ; "invalid map key"
466 )]
467 #[test_case(
468 indoc! {r#"
469 [profile.custom]
470 fail-fast = "true"
471 "#},
472 "profile.custom.fail-fast: invalid type: string \"true\", expected a boolean or { max-fail = ... }"
473 ; "string boolean not allowed"
474 )]
475 fn invalid_fail_fast(config_contents: &str, error_str: &str) {
476 let workspace_dir = tempdir().unwrap();
477 let graph = temp_workspace(&workspace_dir, config_contents);
478 let pcx = ParseContext::new(&graph);
479
480 let error = NextestConfig::from_sources(
481 graph.workspace().root(),
482 &pcx,
483 None,
484 [],
485 &Default::default(),
486 )
487 .expect_err("expected parsing to fail");
488
489 let error = match error.kind() {
490 ConfigParseErrorKind::DeserializeError(d) => d,
491 _ => panic!("expected deserialize error, found {error:?}"),
492 };
493
494 assert_eq!(
495 error.to_string(),
496 error_str,
497 "actual error matches expected"
498 );
499 }
500}