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    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        // Try and deserialize the group as a string. (Note: we don't deserialize a
42        // `CustomTestGroup` directly because that errors out on None.
43        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/// Represents a custom test group.
76#[derive(Clone, Debug, Eq, PartialEq, Hash, PartialOrd, Ord)]
77pub struct CustomTestGroup(ConfigIdentifier);
78
79impl CustomTestGroup {
80    /// Creates a new custom test group, returning an error if it is invalid.
81    pub fn new(name: SmolStr) -> Result<Self, InvalidCustomTestGroupName> {
82        let identifier = ConfigIdentifier::new(name).map_err(InvalidCustomTestGroupName)?;
83        Ok(Self(identifier))
84    }
85
86    /// Creates a new custom test group from an identifier.
87    pub fn from_identifier(identifier: ConfigIdentifier) -> Self {
88        Self(identifier)
89    }
90
91    /// Returns the test group as a [`ConfigIdentifier`].
92    pub fn as_identifier(&self) -> &ConfigIdentifier {
93        &self.0
94    }
95
96    /// Returns the test group as a string.
97    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        // Try and deserialize as a string.
108        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/// Configuration for a test group.
120#[derive(Clone, Debug, Deserialize)]
121#[serde(rename_all = "kebab-case")]
122pub struct TestGroupConfig {
123    /// The maximum number of threads allowed for this test group.
124    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}