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