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/// The number of failures after which to stop running tests, plus the
9/// termination policy for tests already in flight.
10#[derive(Clone, Copy, Debug, Eq, PartialEq)]
11pub enum MaxFail {
12    /// Allow a specific number of tests to fail before stopping.
13    Count {
14        /// The maximum number of tests that may fail before stopping.
15        max_fail: usize,
16        /// Whether to wait for currently running tests to complete or terminate
17        /// them immediately.
18        terminate: TerminateMode,
19    },
20
21    /// Run all tests regardless of failures. Equivalent to `--no-fail-fast`.
22    All,
23}
24
25impl MaxFail {
26    /// Returns the max-fail corresponding to the fail-fast.
27    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    /// Returns the terminate mode if the max-fail has been exceeded, or None otherwise.
39    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        // This should stay in sync with deserialize_fail_fast.
58        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        // Check for N:mode syntax
89        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        // Parse and validate count
96        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/// Mode for terminating running tests when max-fail is exceeded.
130#[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    /// Wait for running tests to complete naturally.
135    #[default]
136    Wait,
137    /// Send termination signals to running tests immediately.
138    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
165/// Deserializes a fail-fast configuration.
166pub(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/// A deserializer for `{ max-fail = xyz, terminate = "..." }`.
207#[derive(Deserialize)]
208struct FailFastMap {
209    #[serde(rename = "max-fail")]
210    max_fail_count: MaxFailCount,
211    #[serde(default)]
212    terminate: TerminateMode,
213}
214
215/// Represents the max-fail count or "all".
216#[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 v is a string that represents a number, suggest using the
245                // integer form.
246                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}