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