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