Skip to main content

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
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        // This should stay in sync with deserialize_fail_fast.
56        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        // Check for N:mode syntax
87        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        // Parse and validate count
94        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/// Mode for terminating running tests when max-fail is exceeded.
128#[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    /// Wait for running tests to complete (default)
133    #[default]
134    Wait,
135    /// Terminate running tests immediately
136    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
163/// Deserializes a fail-fast configuration.
164pub(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/// A deserializer for `{ max-fail = xyz, terminate = "..." }`.
205#[derive(Deserialize)]
206struct FailFastMap {
207    #[serde(rename = "max-fail")]
208    max_fail_count: MaxFailCount,
209    #[serde(default)]
210    terminate: TerminateMode,
211}
212
213/// Represents the max-fail count or "all".
214#[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 v is a string that represents a number, suggest using the
243                // integer form.
244                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}