nextest_runner/config/elements/
max_fail.rs1use crate::errors::MaxFailParseError;
5use serde::Deserialize;
6use std::{fmt, str::FromStr};
7
8#[derive(Clone, Copy, Debug, Eq, PartialEq)]
10pub enum MaxFail {
11 Count(usize),
13
14 All,
16}
17
18impl MaxFail {
19 pub fn from_fail_fast(fail_fast: bool) -> Self {
21 if fail_fast { Self::Count(1) } else { Self::All }
22 }
23
24 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
58pub(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#[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 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}