nextest_runner/config/elements/
max_fail.rs

1// Copyright (c) The nextest Contributors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use crate::errors::MaxFailParseError;
5use serde::Deserialize;
6use std::{fmt, str::FromStr};
7
8/// Type for the max-fail flag and fail-fast configuration.
9#[derive(Clone, Copy, Debug, Eq, PartialEq)]
10pub enum MaxFail {
11    /// Allow a specific number of tests to fail before exiting.
12    Count {
13        /// The maximum number of tests that can fail before exiting.
14        max_fail: usize,
15        /// Whether to terminate running tests immediately or wait for them to complete.
16        terminate: TerminateMode,
17    },
18
19    /// Run all tests. Equivalent to --no-fast-fail.
20    All,
21}
22
23impl MaxFail {
24    /// Returns the max-fail corresponding to the fail-fast.
25    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    /// Returns the terminate mode if the max-fail has been exceeded, or None otherwise.
37    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        // Check for N:mode syntax
57        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        // Parse and validate count
64        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/// Mode for terminating running tests when max-fail is exceeded.
98#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Deserialize)]
99#[serde(rename_all = "kebab-case")]
100pub enum TerminateMode {
101    /// Wait for running tests to complete (default)
102    #[default]
103    Wait,
104    /// Terminate running tests immediately
105    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
132/// Deserializes a fail-fast configuration.
133pub(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/// A deserializer for `{ max-fail = xyz, terminate = "..." }`.
174#[derive(Deserialize)]
175struct FailFastMap {
176    #[serde(rename = "max-fail")]
177    max_fail_count: MaxFailCount,
178    #[serde(default)]
179    terminate: TerminateMode,
180}
181
182/// Represents the max-fail count or "all".
183#[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 v is a string that represents a number, suggest using the
212                // integer form.
213                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}