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