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},
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}