nextest_runner/config/elements/
test_group.rs

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