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