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