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::Utf8Path;
133    use camino_tempfile::tempdir;
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.path(), config_contents);
202        let workspace_root = graph.workspace().root();
203        let tool_path = workspace_root.join(".config/tool.toml");
204        std::fs::write(&tool_path, input).unwrap();
205
206        let pcx = ParseContext::new(&graph);
207        let config_res = NextestConfig::from_sources(
208            workspace_root,
209            &pcx,
210            None,
211            &[ToolConfigFile {
212                tool: "my-tool".to_owned(),
213                config_file: tool_path.clone(),
214            }][..],
215            &Default::default(),
216        );
217        match expected {
218            Ok(expected_groups) => {
219                let config = config_res.expect("config is valid");
220                let profile = config.profile("default").expect("default profile is known");
221                let profile = profile.apply_build_platforms(&build_platforms());
222                assert_eq!(
223                    profile
224                        .test_group_config()
225                        .keys()
226                        .cloned()
227                        .collect::<BTreeSet<_>>(),
228                    expected_groups
229                );
230            }
231            Err(expected_error) => {
232                let error = config_res.expect_err("config is invalid");
233                assert_eq!(error.config_file(), &tool_path);
234                assert_eq!(error.tool(), Some("my-tool"));
235                match &expected_error {
236                    GroupExpectedError::InvalidTestGroups(expected_groups) => {
237                        assert!(
238                            matches!(
239                                error.kind(),
240                                ConfigParseErrorKind::InvalidTestGroupsDefinedByTool(groups)
241                                    if groups == expected_groups
242                            ),
243                            "expected config.kind ({}) to be {:?}",
244                            error.kind(),
245                            expected_error,
246                        );
247                    }
248                    GroupExpectedError::DeserializeError(error_str) => {
249                        assert!(
250                            matches!(
251                                error.kind(),
252                                ConfigParseErrorKind::DeserializeError(error)
253                                    if error.to_string() == *error_str
254                            ),
255                            "expected config.kind ({}) to be {:?}",
256                            error.kind(),
257                            expected_error,
258                        );
259                    }
260                }
261            }
262        }
263    }
264
265    #[test_case(
266        indoc!{r#"
267            [test-groups."my-group"]
268            max-threads = 1
269        "#},
270        Ok(btreeset! {custom_test_group("my-group")})
271        ; "group name valid")]
272    #[test_case(
273        indoc!{r#"
274            [test-groups."@tool:"]
275            max-threads = 1
276        "#},
277        Err(GroupExpectedError::DeserializeError("test-groups.@tool:: invalid custom test group name: tool identifier not of the form \"@tool:tool-name:identifier\": `@tool:`"))
278        ; "group name starts with @tool:")]
279    #[test_case(
280        indoc!{r#"
281            [test-groups.'@global']
282            max-threads = 1
283        "#},
284        Err(GroupExpectedError::DeserializeError("test-groups.@global: invalid custom test group name: invalid identifier `@global`"))
285        ; "group name is @global")]
286    #[test_case(
287        indoc!{r#"
288            [test-groups.'@foo']
289            max-threads = 1
290        "#},
291        Err(GroupExpectedError::DeserializeError("test-groups.@foo: invalid custom test group name: invalid identifier `@foo`"))
292        ; "group name starts with @")]
293    fn user_config_define_groups(
294        config_contents: &str,
295        expected: Result<BTreeSet<CustomTestGroup>, GroupExpectedError>,
296    ) {
297        let workspace_dir = tempdir().unwrap();
298        let workspace_path: &Utf8Path = workspace_dir.path();
299
300        let graph = temp_workspace(workspace_path, 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        let workspace_path: &Utf8Path = workspace_dir.path();
441
442        let graph = temp_workspace(workspace_path, user_config);
443        let workspace_root = graph.workspace().root();
444        let tool1_path = workspace_root.join(".config/tool1.toml");
445        std::fs::write(&tool1_path, tool1_config).unwrap();
446        let tool2_path = workspace_root.join(".config/tool2.toml");
447        std::fs::write(&tool2_path, tool2_config).unwrap();
448
449        let pcx = ParseContext::new(&graph);
450        let config = NextestConfig::from_sources(
451            workspace_root,
452            &pcx,
453            None,
454            &[
455                ToolConfigFile {
456                    tool: "tool1".to_owned(),
457                    config_file: tool1_path,
458                },
459                ToolConfigFile {
460                    tool: "tool2".to_owned(),
461                    config_file: tool2_path,
462                },
463            ][..],
464            &Default::default(),
465        )
466        .expect_err("config is invalid");
467        assert_eq!(config.tool(), tool);
468        match config.kind() {
469            ConfigParseErrorKind::UnknownTestGroups {
470                errors,
471                known_groups,
472            } => {
473                assert_eq!(errors, &expected_errors, "expected errors match");
474                assert_eq!(known_groups, &expected_known_groups, "known groups match");
475            }
476            other => {
477                panic!("expected ConfigParseErrorKind::UnknownTestGroups, got {other}");
478            }
479        }
480    }
481}