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