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