nextest_runner/config/elements/
test_group.rs1use crate::{
5 config::{core::ConfigIdentifier, elements::TestThreads},
6 errors::InvalidCustomTestGroupName,
7};
8use serde::Deserialize;
9use smol_str::SmolStr;
10use std::{fmt, str::FromStr};
11
12#[derive(Clone, Debug, Eq, PartialEq, Hash, PartialOrd, Ord)]
14pub enum TestGroup {
15 Custom(CustomTestGroup),
17
18 Global,
20}
21
22impl TestGroup {
23 pub const GLOBAL_STR: &'static str = "@global";
25
26 pub fn custom_name(&self) -> Option<&str> {
28 match self {
29 TestGroup::Custom(group) => Some(group.as_str()),
30 TestGroup::Global => None,
31 }
32 }
33
34 pub(crate) fn make_all_groups(
35 custom_groups: impl IntoIterator<Item = CustomTestGroup>,
36 ) -> impl Iterator<Item = Self> {
37 custom_groups
38 .into_iter()
39 .map(TestGroup::Custom)
40 .chain(std::iter::once(TestGroup::Global))
41 }
42}
43
44impl<'de> Deserialize<'de> for TestGroup {
45 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
46 where
47 D: serde::Deserializer<'de>,
48 {
49 let group = SmolStr::deserialize(deserializer)?;
52 if group == Self::GLOBAL_STR {
53 Ok(TestGroup::Global)
54 } else {
55 Ok(TestGroup::Custom(
56 CustomTestGroup::new(group).map_err(serde::de::Error::custom)?,
57 ))
58 }
59 }
60}
61
62impl FromStr for TestGroup {
63 type Err = InvalidCustomTestGroupName;
64
65 fn from_str(s: &str) -> Result<Self, Self::Err> {
66 if s == Self::GLOBAL_STR {
67 Ok(TestGroup::Global)
68 } else {
69 Ok(TestGroup::Custom(CustomTestGroup::new(s.into())?))
70 }
71 }
72}
73
74impl fmt::Display for TestGroup {
75 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
76 match self {
77 TestGroup::Global => write!(f, "@global"),
78 TestGroup::Custom(group) => write!(f, "{}", group.as_str()),
79 }
80 }
81}
82
83#[derive(Clone, Debug, Eq, PartialEq, Hash, PartialOrd, Ord)]
85pub struct CustomTestGroup(ConfigIdentifier);
86
87impl CustomTestGroup {
88 pub fn new(name: SmolStr) -> Result<Self, InvalidCustomTestGroupName> {
90 let identifier = ConfigIdentifier::new(name).map_err(InvalidCustomTestGroupName)?;
91 Ok(Self(identifier))
92 }
93
94 pub fn from_identifier(identifier: ConfigIdentifier) -> Self {
96 Self(identifier)
97 }
98
99 pub fn as_identifier(&self) -> &ConfigIdentifier {
101 &self.0
102 }
103
104 pub fn as_str(&self) -> &str {
106 self.0.as_str()
107 }
108}
109
110impl<'de> Deserialize<'de> for CustomTestGroup {
111 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
112 where
113 D: serde::Deserializer<'de>,
114 {
115 let identifier = SmolStr::deserialize(deserializer)?;
117 Self::new(identifier).map_err(serde::de::Error::custom)
118 }
119}
120
121impl fmt::Display for CustomTestGroup {
122 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
123 write!(f, "{}", self.0)
124 }
125}
126
127#[derive(Clone, Debug, Deserialize)]
129#[serde(rename_all = "kebab-case")]
130pub struct TestGroupConfig {
131 pub max_threads: TestThreads,
133}
134
135#[cfg(test)]
136mod tests {
137 use super::*;
138 use crate::{
139 config::{
140 core::{NextestConfig, ToolConfigFile, ToolName},
141 utils::test_helpers::*,
142 },
143 errors::{ConfigParseErrorKind, UnknownTestGroupError},
144 };
145 use camino_tempfile::tempdir;
146 use camino_tempfile_ext::prelude::*;
147 use indoc::indoc;
148 use maplit::btreeset;
149 use nextest_filtering::ParseContext;
150 use std::collections::BTreeSet;
151 use test_case::test_case;
152
153 fn tool_name(s: &str) -> ToolName {
154 ToolName::new(s.into()).unwrap()
155 }
156
157 #[derive(Debug)]
158 enum GroupExpectedError {
159 DeserializeError(&'static str),
160 InvalidTestGroups(BTreeSet<CustomTestGroup>),
161 }
162
163 #[test_case(
164 indoc!{r#"
165 [test-groups."@tool:my-tool:foo"]
166 max-threads = 1
167 "#},
168 Ok(btreeset! {custom_test_group("user-group"), custom_test_group("@tool:my-tool:foo")})
169 ; "group name valid")]
170 #[test_case(
171 indoc!{r#"
172 [test-groups.foo]
173 max-threads = 1
174 "#},
175 Err(GroupExpectedError::InvalidTestGroups(btreeset! {custom_test_group("foo")}))
176 ; "group name doesn't start with @tool:")]
177 #[test_case(
178 indoc!{r#"
179 [test-groups."@tool:moo:test"]
180 max-threads = 1
181 "#},
182 Err(GroupExpectedError::InvalidTestGroups(btreeset! {custom_test_group("@tool:moo:test")}))
183 ; "group name doesn't start with tool name")]
184 #[test_case(
185 indoc!{r#"
186 [test-groups."@tool:my-tool"]
187 max-threads = 1
188 "#},
189 Err(GroupExpectedError::DeserializeError("test-groups.@tool:my-tool: invalid custom test group name: tool identifier not of the form \"@tool:tool-name:identifier\": `@tool:my-tool`"))
190 ; "group name missing suffix colon")]
191 #[test_case(
192 indoc!{r#"
193 [test-groups.'@global']
194 max-threads = 1
195 "#},
196 Err(GroupExpectedError::DeserializeError("test-groups.@global: invalid custom test group name: invalid identifier `@global`"))
197 ; "group name is @global")]
198 #[test_case(
199 indoc!{r#"
200 [test-groups.'@foo']
201 max-threads = 1
202 "#},
203 Err(GroupExpectedError::DeserializeError("test-groups.@foo: invalid custom test group name: invalid identifier `@foo`"))
204 ; "group name starts with @")]
205 fn tool_config_define_groups(
206 input: &str,
207 expected: Result<BTreeSet<CustomTestGroup>, GroupExpectedError>,
208 ) {
209 let config_contents = indoc! {r#"
210 [profile.default]
211 test-group = "user-group"
212
213 [test-groups.user-group]
214 max-threads = 1
215 "#};
216 let workspace_dir = tempdir().unwrap();
217
218 let graph = temp_workspace(&workspace_dir, config_contents);
219 let tool_path = workspace_dir.child(".config/tool.toml");
220 tool_path.write_str(input).unwrap();
221
222 let workspace_root = graph.workspace().root();
223
224 let pcx = ParseContext::new(&graph);
225 let config_res = NextestConfig::from_sources(
226 workspace_root,
227 &pcx,
228 None,
229 &[ToolConfigFile {
230 tool: tool_name("my-tool"),
231 config_file: tool_path.to_path_buf(),
232 }][..],
233 &Default::default(),
234 );
235 match expected {
236 Ok(expected_groups) => {
237 let config = config_res.expect("config is valid");
238 let profile = config.profile("default").expect("default profile is known");
239 let profile = profile.apply_build_platforms(&build_platforms());
240 assert_eq!(
241 profile
242 .test_group_config()
243 .keys()
244 .cloned()
245 .collect::<BTreeSet<_>>(),
246 expected_groups
247 );
248 }
249 Err(expected_error) => {
250 let error = config_res.expect_err("config is invalid");
251 assert_eq!(error.config_file(), tool_path);
252 assert_eq!(error.tool(), Some(&tool_name("my-tool")));
253 match &expected_error {
254 GroupExpectedError::InvalidTestGroups(expected_groups) => {
255 assert!(
256 matches!(
257 error.kind(),
258 ConfigParseErrorKind::InvalidTestGroupsDefinedByTool(groups)
259 if groups == expected_groups
260 ),
261 "expected config.kind ({}) to be {:?}",
262 error.kind(),
263 expected_error,
264 );
265 }
266 GroupExpectedError::DeserializeError(error_str) => {
267 assert!(
268 matches!(
269 error.kind(),
270 ConfigParseErrorKind::DeserializeError(error)
271 if error.to_string() == *error_str
272 ),
273 "expected config.kind ({}) to be {:?}",
274 error.kind(),
275 expected_error,
276 );
277 }
278 }
279 }
280 }
281 }
282
283 #[test_case(
284 indoc!{r#"
285 [test-groups."my-group"]
286 max-threads = 1
287 "#},
288 Ok(btreeset! {custom_test_group("my-group")})
289 ; "group name valid")]
290 #[test_case(
291 indoc!{r#"
292 [test-groups."@tool:"]
293 max-threads = 1
294 "#},
295 Err(GroupExpectedError::DeserializeError("test-groups.@tool:: invalid custom test group name: tool identifier not of the form \"@tool:tool-name:identifier\": `@tool:`"))
296 ; "group name starts with @tool:")]
297 #[test_case(
298 indoc!{r#"
299 [test-groups.'@global']
300 max-threads = 1
301 "#},
302 Err(GroupExpectedError::DeserializeError("test-groups.@global: invalid custom test group name: invalid identifier `@global`"))
303 ; "group name is @global")]
304 #[test_case(
305 indoc!{r#"
306 [test-groups.'@foo']
307 max-threads = 1
308 "#},
309 Err(GroupExpectedError::DeserializeError("test-groups.@foo: invalid custom test group name: invalid identifier `@foo`"))
310 ; "group name starts with @")]
311 fn user_config_define_groups(
312 config_contents: &str,
313 expected: Result<BTreeSet<CustomTestGroup>, GroupExpectedError>,
314 ) {
315 let workspace_dir = tempdir().unwrap();
316
317 let graph = temp_workspace(&workspace_dir, config_contents);
318 let workspace_root = graph.workspace().root();
319
320 let pcx = ParseContext::new(&graph);
321 let config_res =
322 NextestConfig::from_sources(workspace_root, &pcx, None, &[][..], &Default::default());
323 match expected {
324 Ok(expected_groups) => {
325 let config = config_res.expect("config is valid");
326 let profile = config.profile("default").expect("default profile is known");
327 let profile = profile.apply_build_platforms(&build_platforms());
328 assert_eq!(
329 profile
330 .test_group_config()
331 .keys()
332 .cloned()
333 .collect::<BTreeSet<_>>(),
334 expected_groups
335 );
336 }
337 Err(expected_error) => {
338 let error = config_res.expect_err("config is invalid");
339 assert_eq!(error.tool(), None);
340 match &expected_error {
341 GroupExpectedError::InvalidTestGroups(expected_groups) => {
342 assert!(
343 matches!(
344 error.kind(),
345 ConfigParseErrorKind::InvalidTestGroupsDefined(groups)
346 if groups == expected_groups
347 ),
348 "expected config.kind ({}) to be {:?}",
349 error.kind(),
350 expected_error,
351 );
352 }
353 GroupExpectedError::DeserializeError(error_str) => {
354 assert!(
355 matches!(
356 error.kind(),
357 ConfigParseErrorKind::DeserializeError(error)
358 if error.to_string() == *error_str
359 ),
360 "expected config.kind ({}) to be {:?}",
361 error.kind(),
362 expected_error,
363 );
364 }
365 }
366 }
367 }
368 }
369
370 #[test_case(
371 indoc!{r#"
372 [[profile.default.overrides]]
373 filter = 'all()'
374 test-group = "foo"
375 "#},
376 "",
377 "",
378 Some(tool_name("tool1")),
379 vec![UnknownTestGroupError {
380 profile_name: "default".to_owned(),
381 name: test_group("foo"),
382 }],
383 btreeset! { TestGroup::Global }
384 ; "unknown group in tool config")]
385 #[test_case(
386 "",
387 "",
388 indoc!{r#"
389 [[profile.default.overrides]]
390 filter = 'all()'
391 test-group = "foo"
392 "#},
393 None,
394 vec![UnknownTestGroupError {
395 profile_name: "default".to_owned(),
396 name: test_group("foo"),
397 }],
398 btreeset! { TestGroup::Global }
399 ; "unknown group in user config")]
400 #[test_case(
401 indoc!{r#"
402 [[profile.default.overrides]]
403 filter = 'all()'
404 test-group = "@tool:tool1:foo"
405
406 [test-groups."@tool:tool1:foo"]
407 max-threads = 1
408 "#},
409 indoc!{r#"
410 [[profile.default.overrides]]
411 filter = 'all()'
412 test-group = "@tool:tool1:foo"
413 "#},
414 indoc!{r#"
415 [[profile.default.overrides]]
416 filter = 'all()'
417 test-group = "foo"
418 "#},
419 Some(tool_name("tool2")),
420 vec![UnknownTestGroupError {
421 profile_name: "default".to_owned(),
422 name: test_group("@tool:tool1:foo"),
423 }],
424 btreeset! { TestGroup::Global }
425 ; "depends on downstream tool config")]
426 #[test_case(
427 indoc!{r#"
428 [[profile.default.overrides]]
429 filter = 'all()'
430 test-group = "foo"
431 "#},
432 "",
433 indoc!{r#"
434 [[profile.default.overrides]]
435 filter = 'all()'
436 test-group = "foo"
437
438 [test-groups.foo]
439 max-threads = 1
440 "#},
441 Some(tool_name("tool1")),
442 vec![UnknownTestGroupError {
443 profile_name: "default".to_owned(),
444 name: test_group("foo"),
445 }],
446 btreeset! { TestGroup::Global }
447 ; "depends on user config")]
448 fn unknown_groups(
449 tool1_config: &str,
450 tool2_config: &str,
451 user_config: &str,
452 tool: Option<ToolName>,
453 expected_errors: Vec<UnknownTestGroupError>,
454 expected_known_groups: BTreeSet<TestGroup>,
455 ) {
456 let workspace_dir = tempdir().unwrap();
457
458 let graph = temp_workspace(&workspace_dir, user_config);
459 let tool1_path = workspace_dir.child(".config/tool1.toml");
460 tool1_path.write_str(tool1_config).unwrap();
461 let tool2_path = workspace_dir.child(".config/tool2.toml");
462 tool2_path.write_str(tool2_config).unwrap();
463 let workspace_root = graph.workspace().root();
464
465 let pcx = ParseContext::new(&graph);
466 let config = NextestConfig::from_sources(
467 workspace_root,
468 &pcx,
469 None,
470 &[
471 ToolConfigFile {
472 tool: tool_name("tool1"),
473 config_file: tool1_path.to_path_buf(),
474 },
475 ToolConfigFile {
476 tool: tool_name("tool2"),
477 config_file: tool2_path.to_path_buf(),
478 },
479 ][..],
480 &Default::default(),
481 )
482 .expect_err("config is invalid");
483 assert_eq!(config.tool(), tool.as_ref());
484 match config.kind() {
485 ConfigParseErrorKind::UnknownTestGroups {
486 errors,
487 known_groups,
488 } => {
489 assert_eq!(errors, &expected_errors, "expected errors match");
490 assert_eq!(known_groups, &expected_known_groups, "known groups match");
491 }
492 other => {
493 panic!("expected ConfigParseErrorKind::UnknownTestGroups, got {other}");
494 }
495 }
496 }
497}