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},
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 #[derive(Debug)]
154 enum GroupExpectedError {
155 DeserializeError(&'static str),
156 InvalidTestGroups(BTreeSet<CustomTestGroup>),
157 }
158
159 #[test_case(
160 indoc!{r#"
161 [test-groups."@tool:my-tool:foo"]
162 max-threads = 1
163 "#},
164 Ok(btreeset! {custom_test_group("user-group"), custom_test_group("@tool:my-tool:foo")})
165 ; "group name valid")]
166 #[test_case(
167 indoc!{r#"
168 [test-groups.foo]
169 max-threads = 1
170 "#},
171 Err(GroupExpectedError::InvalidTestGroups(btreeset! {custom_test_group("foo")}))
172 ; "group name doesn't start with @tool:")]
173 #[test_case(
174 indoc!{r#"
175 [test-groups."@tool:moo:test"]
176 max-threads = 1
177 "#},
178 Err(GroupExpectedError::InvalidTestGroups(btreeset! {custom_test_group("@tool:moo:test")}))
179 ; "group name doesn't start with tool name")]
180 #[test_case(
181 indoc!{r#"
182 [test-groups."@tool:my-tool"]
183 max-threads = 1
184 "#},
185 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`"))
186 ; "group name missing suffix colon")]
187 #[test_case(
188 indoc!{r#"
189 [test-groups.'@global']
190 max-threads = 1
191 "#},
192 Err(GroupExpectedError::DeserializeError("test-groups.@global: invalid custom test group name: invalid identifier `@global`"))
193 ; "group name is @global")]
194 #[test_case(
195 indoc!{r#"
196 [test-groups.'@foo']
197 max-threads = 1
198 "#},
199 Err(GroupExpectedError::DeserializeError("test-groups.@foo: invalid custom test group name: invalid identifier `@foo`"))
200 ; "group name starts with @")]
201 fn tool_config_define_groups(
202 input: &str,
203 expected: Result<BTreeSet<CustomTestGroup>, GroupExpectedError>,
204 ) {
205 let config_contents = indoc! {r#"
206 [profile.default]
207 test-group = "user-group"
208
209 [test-groups.user-group]
210 max-threads = 1
211 "#};
212 let workspace_dir = tempdir().unwrap();
213
214 let graph = temp_workspace(&workspace_dir, config_contents);
215 let tool_path = workspace_dir.child(".config/tool.toml");
216 tool_path.write_str(input).unwrap();
217
218 let workspace_root = graph.workspace().root();
219
220 let pcx = ParseContext::new(&graph);
221 let config_res = NextestConfig::from_sources(
222 workspace_root,
223 &pcx,
224 None,
225 &[ToolConfigFile {
226 tool: "my-tool".to_owned(),
227 config_file: tool_path.to_path_buf(),
228 }][..],
229 &Default::default(),
230 );
231 match expected {
232 Ok(expected_groups) => {
233 let config = config_res.expect("config is valid");
234 let profile = config.profile("default").expect("default profile is known");
235 let profile = profile.apply_build_platforms(&build_platforms());
236 assert_eq!(
237 profile
238 .test_group_config()
239 .keys()
240 .cloned()
241 .collect::<BTreeSet<_>>(),
242 expected_groups
243 );
244 }
245 Err(expected_error) => {
246 let error = config_res.expect_err("config is invalid");
247 assert_eq!(error.config_file(), tool_path);
248 assert_eq!(error.tool(), Some("my-tool"));
249 match &expected_error {
250 GroupExpectedError::InvalidTestGroups(expected_groups) => {
251 assert!(
252 matches!(
253 error.kind(),
254 ConfigParseErrorKind::InvalidTestGroupsDefinedByTool(groups)
255 if groups == expected_groups
256 ),
257 "expected config.kind ({}) to be {:?}",
258 error.kind(),
259 expected_error,
260 );
261 }
262 GroupExpectedError::DeserializeError(error_str) => {
263 assert!(
264 matches!(
265 error.kind(),
266 ConfigParseErrorKind::DeserializeError(error)
267 if error.to_string() == *error_str
268 ),
269 "expected config.kind ({}) to be {:?}",
270 error.kind(),
271 expected_error,
272 );
273 }
274 }
275 }
276 }
277 }
278
279 #[test_case(
280 indoc!{r#"
281 [test-groups."my-group"]
282 max-threads = 1
283 "#},
284 Ok(btreeset! {custom_test_group("my-group")})
285 ; "group name valid")]
286 #[test_case(
287 indoc!{r#"
288 [test-groups."@tool:"]
289 max-threads = 1
290 "#},
291 Err(GroupExpectedError::DeserializeError("test-groups.@tool:: invalid custom test group name: tool identifier not of the form \"@tool:tool-name:identifier\": `@tool:`"))
292 ; "group name starts with @tool:")]
293 #[test_case(
294 indoc!{r#"
295 [test-groups.'@global']
296 max-threads = 1
297 "#},
298 Err(GroupExpectedError::DeserializeError("test-groups.@global: invalid custom test group name: invalid identifier `@global`"))
299 ; "group name is @global")]
300 #[test_case(
301 indoc!{r#"
302 [test-groups.'@foo']
303 max-threads = 1
304 "#},
305 Err(GroupExpectedError::DeserializeError("test-groups.@foo: invalid custom test group name: invalid identifier `@foo`"))
306 ; "group name starts with @")]
307 fn user_config_define_groups(
308 config_contents: &str,
309 expected: Result<BTreeSet<CustomTestGroup>, GroupExpectedError>,
310 ) {
311 let workspace_dir = tempdir().unwrap();
312
313 let graph = temp_workspace(&workspace_dir, config_contents);
314 let workspace_root = graph.workspace().root();
315
316 let pcx = ParseContext::new(&graph);
317 let config_res =
318 NextestConfig::from_sources(workspace_root, &pcx, None, &[][..], &Default::default());
319 match expected {
320 Ok(expected_groups) => {
321 let config = config_res.expect("config is valid");
322 let profile = config.profile("default").expect("default profile is known");
323 let profile = profile.apply_build_platforms(&build_platforms());
324 assert_eq!(
325 profile
326 .test_group_config()
327 .keys()
328 .cloned()
329 .collect::<BTreeSet<_>>(),
330 expected_groups
331 );
332 }
333 Err(expected_error) => {
334 let error = config_res.expect_err("config is invalid");
335 assert_eq!(error.tool(), None);
336 match &expected_error {
337 GroupExpectedError::InvalidTestGroups(expected_groups) => {
338 assert!(
339 matches!(
340 error.kind(),
341 ConfigParseErrorKind::InvalidTestGroupsDefined(groups)
342 if groups == expected_groups
343 ),
344 "expected config.kind ({}) to be {:?}",
345 error.kind(),
346 expected_error,
347 );
348 }
349 GroupExpectedError::DeserializeError(error_str) => {
350 assert!(
351 matches!(
352 error.kind(),
353 ConfigParseErrorKind::DeserializeError(error)
354 if error.to_string() == *error_str
355 ),
356 "expected config.kind ({}) to be {:?}",
357 error.kind(),
358 expected_error,
359 );
360 }
361 }
362 }
363 }
364 }
365
366 #[test_case(
367 indoc!{r#"
368 [[profile.default.overrides]]
369 filter = 'all()'
370 test-group = "foo"
371 "#},
372 "",
373 "",
374 Some("tool1"),
375 vec![UnknownTestGroupError {
376 profile_name: "default".to_owned(),
377 name: test_group("foo"),
378 }],
379 btreeset! { TestGroup::Global }
380 ; "unknown group in tool config")]
381 #[test_case(
382 "",
383 "",
384 indoc!{r#"
385 [[profile.default.overrides]]
386 filter = 'all()'
387 test-group = "foo"
388 "#},
389 None,
390 vec![UnknownTestGroupError {
391 profile_name: "default".to_owned(),
392 name: test_group("foo"),
393 }],
394 btreeset! { TestGroup::Global }
395 ; "unknown group in user config")]
396 #[test_case(
397 indoc!{r#"
398 [[profile.default.overrides]]
399 filter = 'all()'
400 test-group = "@tool:tool1:foo"
401
402 [test-groups."@tool:tool1:foo"]
403 max-threads = 1
404 "#},
405 indoc!{r#"
406 [[profile.default.overrides]]
407 filter = 'all()'
408 test-group = "@tool:tool1:foo"
409 "#},
410 indoc!{r#"
411 [[profile.default.overrides]]
412 filter = 'all()'
413 test-group = "foo"
414 "#},
415 Some("tool2"),
416 vec![UnknownTestGroupError {
417 profile_name: "default".to_owned(),
418 name: test_group("@tool:tool1:foo"),
419 }],
420 btreeset! { TestGroup::Global }
421 ; "depends on downstream tool config")]
422 #[test_case(
423 indoc!{r#"
424 [[profile.default.overrides]]
425 filter = 'all()'
426 test-group = "foo"
427 "#},
428 "",
429 indoc!{r#"
430 [[profile.default.overrides]]
431 filter = 'all()'
432 test-group = "foo"
433
434 [test-groups.foo]
435 max-threads = 1
436 "#},
437 Some("tool1"),
438 vec![UnknownTestGroupError {
439 profile_name: "default".to_owned(),
440 name: test_group("foo"),
441 }],
442 btreeset! { TestGroup::Global }
443 ; "depends on user config")]
444 fn unknown_groups(
445 tool1_config: &str,
446 tool2_config: &str,
447 user_config: &str,
448 tool: Option<&str>,
449 expected_errors: Vec<UnknownTestGroupError>,
450 expected_known_groups: BTreeSet<TestGroup>,
451 ) {
452 let workspace_dir = tempdir().unwrap();
453
454 let graph = temp_workspace(&workspace_dir, user_config);
455 let tool1_path = workspace_dir.child(".config/tool1.toml");
456 tool1_path.write_str(tool1_config).unwrap();
457 let tool2_path = workspace_dir.child(".config/tool2.toml");
458 tool2_path.write_str(tool2_config).unwrap();
459 let workspace_root = graph.workspace().root();
460
461 let pcx = ParseContext::new(&graph);
462 let config = NextestConfig::from_sources(
463 workspace_root,
464 &pcx,
465 None,
466 &[
467 ToolConfigFile {
468 tool: "tool1".to_owned(),
469 config_file: tool1_path.to_path_buf(),
470 },
471 ToolConfigFile {
472 tool: "tool2".to_owned(),
473 config_file: tool2_path.to_path_buf(),
474 },
475 ][..],
476 &Default::default(),
477 )
478 .expect_err("config is invalid");
479 assert_eq!(config.tool(), tool);
480 match config.kind() {
481 ConfigParseErrorKind::UnknownTestGroups {
482 errors,
483 known_groups,
484 } => {
485 assert_eq!(errors, &expected_errors, "expected errors match");
486 assert_eq!(known_groups, &expected_known_groups, "known groups match");
487 }
488 other => {
489 panic!("expected ConfigParseErrorKind::UnknownTestGroups, got {other}");
490 }
491 }
492 }
493}