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