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(usize),
13
14    /// Run all tests. Equivalent to --no-fast-fail.
15    All,
16}
17
18impl MaxFail {
19    /// Returns the max-fail corresponding to the fail-fast.
20    pub fn from_fail_fast(fail_fast: bool) -> Self {
21        if fail_fast { Self::Count(1) } else { Self::All }
22    }
23
24    /// Returns true if the max-fail has been exceeded.
25    pub fn is_exceeded(&self, failed: usize) -> bool {
26        match self {
27            Self::Count(n) => failed >= *n,
28            Self::All => false,
29        }
30    }
31}
32
33impl FromStr for MaxFail {
34    type Err = MaxFailParseError;
35
36    fn from_str(s: &str) -> Result<Self, Self::Err> {
37        if s.to_lowercase() == "all" {
38            return Ok(Self::All);
39        }
40
41        match s.parse::<isize>() {
42            Err(e) => Err(MaxFailParseError::new(format!("Error: {e} parsing {s}"))),
43            Ok(j) if j <= 0 => Err(MaxFailParseError::new("max-fail may not be <= 0")),
44            Ok(j) => Ok(MaxFail::Count(j as usize)),
45        }
46    }
47}
48
49impl fmt::Display for MaxFail {
50    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51        match self {
52            Self::All => write!(f, "all"),
53            Self::Count(n) => write!(f, "{n}"),
54        }
55    }
56}
57
58/// Deserializes a fail-fast configuration.
59pub(in crate::config) fn deserialize_fail_fast<'de, D>(
60    deserializer: D,
61) -> Result<Option<MaxFail>, D::Error>
62where
63    D: serde::Deserializer<'de>,
64{
65    struct V;
66
67    impl<'de2> serde::de::Visitor<'de2> for V {
68        type Value = Option<MaxFail>;
69
70        fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
71            write!(formatter, "a boolean or {{ max-fail = ... }}")
72        }
73
74        fn visit_bool<E>(self, v: bool) -> Result<Self::Value, E>
75        where
76            E: serde::de::Error,
77        {
78            Ok(Some(MaxFail::from_fail_fast(v)))
79        }
80
81        fn visit_map<A>(self, map: A) -> Result<Self::Value, A::Error>
82        where
83            A: serde::de::MapAccess<'de2>,
84        {
85            let de = serde::de::value::MapAccessDeserializer::new(map);
86            FailFastMap::deserialize(de).map(|helper| Some(helper.max_fail))
87        }
88    }
89
90    deserializer.deserialize_any(V)
91}
92
93/// A deserializer for `{ max-fail = xyz }`.
94#[derive(Deserialize)]
95struct FailFastMap {
96    #[serde(rename = "max-fail", deserialize_with = "deserialize_max_fail")]
97    max_fail: MaxFail,
98}
99
100fn deserialize_max_fail<'de, D>(deserializer: D) -> Result<MaxFail, D::Error>
101where
102    D: serde::Deserializer<'de>,
103{
104    struct V;
105
106    impl serde::de::Visitor<'_> for V {
107        type Value = MaxFail;
108
109        fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
110            write!(formatter, "a positive integer or the string \"all\"")
111        }
112
113        fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
114        where
115            E: serde::de::Error,
116        {
117            if v == "all" {
118                return Ok(MaxFail::All);
119            }
120
121            // If v is a string that represents a number, suggest using the
122            // integer form.
123            if let Ok(val) = v.parse::<i64>() {
124                if val > 0 {
125                    return Err(serde::de::Error::invalid_value(
126                        serde::de::Unexpected::Str(v),
127                        &"the string \"all\" (numbers must be specified without quotes)",
128                    ));
129                } else {
130                    return Err(serde::de::Error::invalid_value(
131                        serde::de::Unexpected::Str(v),
132                        &"the string \"all\" (numbers must be positive and without quotes)",
133                    ));
134                }
135            }
136
137            Err(serde::de::Error::invalid_value(
138                serde::de::Unexpected::Str(v),
139                &"the string \"all\" or a positive integer",
140            ))
141        }
142
143        fn visit_i64<E>(self, v: i64) -> Result<Self::Value, E>
144        where
145            E: serde::de::Error,
146        {
147            if v > 0 {
148                Ok(MaxFail::Count(v as usize))
149            } else {
150                Err(serde::de::Error::invalid_value(
151                    serde::de::Unexpected::Signed(v),
152                    &"a positive integer or the string \"all\"",
153                ))
154            }
155        }
156    }
157
158    deserializer.deserialize_any(V)
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164    use crate::{
165        config::{core::NextestConfig, utils::test_helpers::*},
166        errors::ConfigParseErrorKind,
167    };
168    use camino_tempfile::tempdir;
169    use indoc::indoc;
170    use nextest_filtering::ParseContext;
171    use test_case::test_case;
172
173    #[test]
174    fn maxfail_builder_from_str() {
175        let successes = vec![
176            ("all", MaxFail::All),
177            ("ALL", MaxFail::All),
178            ("1", MaxFail::Count(1)),
179        ];
180
181        let failures = vec!["-1", "0", "foo"];
182
183        for (input, output) in successes {
184            assert_eq!(
185                MaxFail::from_str(input).unwrap_or_else(|err| panic!(
186                    "expected input '{input}' to succeed, failed with: {err}"
187                )),
188                output,
189                "success case '{input}' matches",
190            );
191        }
192
193        for input in failures {
194            MaxFail::from_str(input).expect_err(&format!("expected input '{input}' to fail"));
195        }
196    }
197
198    #[test_case(
199        indoc! {r#"
200            [profile.custom]
201            fail-fast = true
202        "#},
203        MaxFail::Count(1)
204        ; "boolean true"
205    )]
206    #[test_case(
207        indoc! {r#"
208            [profile.custom]
209            fail-fast = false
210        "#},
211        MaxFail::All
212        ; "boolean false"
213    )]
214    #[test_case(
215        indoc! {r#"
216            [profile.custom]
217            fail-fast = { max-fail = 1 }
218        "#},
219        MaxFail::Count(1)
220        ; "max-fail 1"
221    )]
222    #[test_case(
223        indoc! {r#"
224            [profile.custom]
225            fail-fast = { max-fail = 2 }
226        "#},
227        MaxFail::Count(2)
228        ; "max-fail 2"
229    )]
230    #[test_case(
231        indoc! {r#"
232            [profile.custom]
233            fail-fast = { max-fail = "all" }
234        "#},
235        MaxFail::All
236        ; "max-fail all"
237    )]
238    fn parse_fail_fast(config_contents: &str, expected: MaxFail) {
239        let workspace_dir = tempdir().unwrap();
240        let graph = temp_workspace(&workspace_dir, config_contents);
241
242        let pcx = ParseContext::new(&graph);
243
244        let config = NextestConfig::from_sources(
245            graph.workspace().root(),
246            &pcx,
247            None,
248            [],
249            &Default::default(),
250        )
251        .expect("expected parsing to succeed");
252
253        let profile = config
254            .profile("custom")
255            .unwrap()
256            .apply_build_platforms(&build_platforms());
257
258        assert_eq!(profile.max_fail(), expected);
259    }
260
261    #[test_case(
262        indoc! {r#"
263            [profile.custom]
264            fail-fast = { max-fail = 0 }
265        "#},
266        "profile.custom.fail-fast.max-fail: invalid value: integer `0`, expected a positive integer or the string \"all\""
267        ; "invalid zero max-fail"
268    )]
269    #[test_case(
270        indoc! {r#"
271            [profile.custom]
272            fail-fast = { max-fail = -1 }
273        "#},
274        "profile.custom.fail-fast.max-fail: invalid value: integer `-1`, expected a positive integer or the string \"all\""
275        ; "invalid negative max-fail"
276    )]
277    #[test_case(
278        indoc! {r#"
279            [profile.custom]
280            fail-fast = { max-fail = "" }
281        "#},
282        "profile.custom.fail-fast.max-fail: invalid value: string \"\", expected the string \"all\" or a positive integer"
283        ; "empty string max-fail"
284    )]
285    #[test_case(
286        indoc! {r#"
287            [profile.custom]
288            fail-fast = { max-fail = "1" }
289        "#},
290        "profile.custom.fail-fast.max-fail: invalid value: string \"1\", expected the string \"all\" (numbers must be specified without quotes)"
291        ; "string as positive integer"
292    )]
293    #[test_case(
294        indoc! {r#"
295            [profile.custom]
296            fail-fast = { max-fail = "0" }
297        "#},
298        "profile.custom.fail-fast.max-fail: invalid value: string \"0\", expected the string \"all\" (numbers must be positive and without quotes)"
299        ; "zero string"
300    )]
301    #[test_case(
302        indoc! {r#"
303            [profile.custom]
304            fail-fast = { max-fail = "invalid" }
305        "#},
306        "profile.custom.fail-fast.max-fail: invalid value: string \"invalid\", expected the string \"all\" or a positive integer"
307        ; "invalid string max-fail"
308    )]
309    #[test_case(
310        indoc! {r#"
311            [profile.custom]
312            fail-fast = { max-fail = true }
313        "#},
314        "profile.custom.fail-fast.max-fail: invalid type: boolean `true`, expected a positive integer or the string \"all\""
315        ; "invalid max-fail type"
316    )]
317    #[test_case(
318        indoc! {r#"
319            [profile.custom]
320            fail-fast = { invalid-key = 1 }
321        "#},
322        "profile.custom.fail-fast: missing field `max-fail`"
323        ; "invalid map key"
324    )]
325    #[test_case(
326        indoc! {r#"
327            [profile.custom]
328            fail-fast = "true"
329        "#},
330        "profile.custom.fail-fast: invalid type: string \"true\", expected a boolean or { max-fail = ... }"
331        ; "string boolean not allowed"
332    )]
333    fn invalid_fail_fast(config_contents: &str, error_str: &str) {
334        let workspace_dir = tempdir().unwrap();
335        let graph = temp_workspace(&workspace_dir, config_contents);
336        let pcx = ParseContext::new(&graph);
337
338        let error = NextestConfig::from_sources(
339            graph.workspace().root(),
340            &pcx,
341            None,
342            [],
343            &Default::default(),
344        )
345        .expect_err("expected parsing to fail");
346
347        let error = match error.kind() {
348            ConfigParseErrorKind::DeserializeError(d) => d,
349            _ => panic!("expected deserialize error, found {error:?}"),
350        };
351
352        assert_eq!(
353            error.to_string(),
354            error_str,
355            "actual error matches expected"
356        );
357    }
358}