Skip to main content

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