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