nextest_runner/config/
test_group.rs

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