1use 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(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#[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 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}