nextest_runner/config/core/
tool_config.rs

1// Copyright (c) The nextest Contributors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use super::ToolName;
5use crate::errors::ToolConfigFileParseError;
6use camino::{Utf8Path, Utf8PathBuf};
7use std::str::FromStr;
8
9/// A tool-specific config file.
10///
11/// Tool-specific config files are lower priority than repository configs, but higher priority than
12/// the default config shipped with nextest.
13#[derive(Clone, Debug, Eq, PartialEq)]
14pub struct ToolConfigFile {
15    /// The name of the tool.
16    pub tool: ToolName,
17
18    /// The path to the config file.
19    pub config_file: Utf8PathBuf,
20}
21
22impl FromStr for ToolConfigFile {
23    type Err = ToolConfigFileParseError;
24
25    fn from_str(input: &str) -> Result<Self, Self::Err> {
26        match input.split_once(':') {
27            Some((tool, config_file)) => {
28                let tool = ToolName::new(tool.into()).map_err(|error| {
29                    ToolConfigFileParseError::InvalidToolName {
30                        input: input.to_owned(),
31                        error,
32                    }
33                })?;
34                if config_file.is_empty() {
35                    Err(ToolConfigFileParseError::EmptyConfigFile {
36                        input: input.to_owned(),
37                    })
38                } else {
39                    let config_file = Utf8Path::new(config_file);
40                    if config_file.is_absolute() {
41                        Ok(Self {
42                            tool,
43                            config_file: Utf8PathBuf::from(config_file),
44                        })
45                    } else {
46                        Err(ToolConfigFileParseError::ConfigFileNotAbsolute {
47                            config_file: config_file.to_owned(),
48                        })
49                    }
50                }
51            }
52            None => Err(ToolConfigFileParseError::InvalidFormat {
53                input: input.to_owned(),
54            }),
55        }
56    }
57}
58
59#[cfg(test)]
60mod tests {
61    use super::*;
62    use crate::{
63        config::{
64            core::{NextestConfig, NextestVersionConfig, NextestVersionReq, VersionOnlyConfig},
65            elements::{RetryPolicy, TestGroup},
66            utils::test_helpers::*,
67        },
68        run_mode::NextestRunMode,
69    };
70    use camino_tempfile::tempdir;
71    use camino_tempfile_ext::prelude::*;
72    use guppy::graph::cargo::BuildPlatform;
73    use nextest_filtering::{ParseContext, TestQuery};
74    use nextest_metadata::TestCaseName;
75
76    fn tool_name(s: &str) -> ToolName {
77        ToolName::new(s.into()).unwrap()
78    }
79
80    #[test]
81    fn parse_tool_config_file() {
82        use crate::errors::{InvalidToolName, ToolConfigFileParseError};
83
84        cfg_if::cfg_if! {
85            if #[cfg(windows)] {
86                let valid = ["tool:C:\\foo\\bar", "tool:\\\\?\\C:\\foo\\bar"];
87            } else {
88                let valid = ["tool:/foo/bar"];
89            }
90        }
91
92        for valid_input in valid {
93            valid_input.parse::<ToolConfigFile>().unwrap_or_else(|err| {
94                panic!("valid input {valid_input} should parse correctly: {err}")
95            });
96        }
97
98        cfg_if::cfg_if! {
99            if #[cfg(windows)] {
100                let invalid: &[(&str, ToolConfigFileParseError)] = &[
101                    ("no-colon-here", ToolConfigFileParseError::InvalidFormat {
102                        input: "no-colon-here".to_owned(),
103                    }),
104                    ("tool:\\foo\\bar", ToolConfigFileParseError::ConfigFileNotAbsolute {
105                        config_file: "\\foo\\bar".into(),
106                    }),
107                    ("tool:foo/bar", ToolConfigFileParseError::ConfigFileNotAbsolute {
108                        config_file: "foo/bar".into(),
109                    }),
110                ];
111            } else {
112                let invalid: &[(&str, ToolConfigFileParseError)] = &[
113                    ("/foo/bar", ToolConfigFileParseError::InvalidFormat {
114                        input: "/foo/bar".to_owned(),
115                    }),
116                    ("tool:foo/bar", ToolConfigFileParseError::ConfigFileNotAbsolute {
117                        config_file: "foo/bar".into(),
118                    }),
119                    ("tool:./foo", ToolConfigFileParseError::ConfigFileNotAbsolute {
120                        config_file: "./foo".into(),
121                    }),
122                ];
123            }
124        }
125
126        // Common invalid cases for all platforms.
127        let common_invalid: &[(&str, ToolConfigFileParseError)] = &[
128            (
129                ":/foo/bar",
130                ToolConfigFileParseError::InvalidToolName {
131                    input: ":/foo/bar".to_owned(),
132                    error: InvalidToolName::Empty,
133                },
134            ),
135            (
136                "_invalid:/foo/bar",
137                ToolConfigFileParseError::InvalidToolName {
138                    input: "_invalid:/foo/bar".to_owned(),
139                    error: InvalidToolName::InvalidXid("_invalid".into()),
140                },
141            ),
142            // Tool names starting with "@tool" are rejected with a specific error.
143            (
144                "@tool:/path",
145                ToolConfigFileParseError::InvalidToolName {
146                    input: "@tool:/path".to_owned(),
147                    error: InvalidToolName::StartsWithToolPrefix("@tool".into()),
148                },
149            ),
150            (
151                "tool:",
152                ToolConfigFileParseError::EmptyConfigFile {
153                    input: "tool:".to_owned(),
154                },
155            ),
156        ];
157
158        for (input, expected) in invalid.iter().chain(common_invalid.iter()) {
159            let actual = input.parse::<ToolConfigFile>().unwrap_err();
160            assert_eq!(&actual, expected, "for input {input:?}");
161        }
162    }
163
164    #[test]
165    fn tool_config_basic() {
166        let config_contents = r#"
167        nextest-version = "0.9.50"
168
169        [profile.default]
170        retries = 3
171
172        [[profile.default.overrides]]
173        filter = 'test(test_foo)'
174        retries = 20
175        test-group = 'foo'
176
177        [[profile.default.overrides]]
178        filter = 'test(test_quux)'
179        test-group = '@tool:tool1:group1'
180
181        [test-groups.foo]
182        max-threads = 2
183        "#;
184
185        let tool1_config_contents = r#"
186        nextest-version = { required = "0.9.51", recommended = "0.9.52" }
187
188        [profile.default]
189        retries = 4
190
191        [[profile.default.overrides]]
192        filter = 'test(test_bar)'
193        retries = 21
194
195        [profile.tool]
196        retries = 12
197
198        [[profile.tool.overrides]]
199        filter = 'test(test_baz)'
200        retries = 22
201        test-group = '@tool:tool1:group1'
202
203        [[profile.tool.overrides]]
204        filter = 'test(test_quux)'
205        retries = 22
206        test-group = '@tool:tool2:group2'
207
208        [test-groups.'@tool:tool1:group1']
209        max-threads = 2
210        "#;
211
212        let tool2_config_contents = r#"
213        nextest-version = { recommended = "0.9.49" }
214
215        [profile.default]
216        retries = 5
217
218        [[profile.default.overrides]]
219        filter = 'test(test_)'
220        retries = 23
221
222        [profile.tool]
223        retries = 16
224
225        [[profile.tool.overrides]]
226        filter = 'test(test_ba)'
227        retries = 24
228        test-group = '@tool:tool2:group2'
229
230        [[profile.tool.overrides]]
231        filter = 'test(test_)'
232        retries = 25
233        test-group = '@global'
234
235        [profile.tool2]
236        retries = 18
237
238        [[profile.tool2.overrides]]
239        filter = 'all()'
240        retries = 26
241
242        [test-groups.'@tool:tool2:group2']
243        max-threads = 4
244        "#;
245
246        let workspace_dir = tempdir().unwrap();
247
248        let graph = temp_workspace(&workspace_dir, config_contents);
249        let tool1_path = workspace_dir.child(".config/tool1.toml");
250        let tool2_path = workspace_dir.child(".config/tool2.toml");
251        tool1_path.write_str(tool1_config_contents).unwrap();
252        tool2_path.write_str(tool2_config_contents).unwrap();
253
254        let workspace_root = graph.workspace().root();
255
256        let tool_config_files = [
257            ToolConfigFile {
258                tool: tool_name("tool1"),
259                config_file: tool1_path.to_path_buf(),
260            },
261            ToolConfigFile {
262                tool: tool_name("tool2"),
263                config_file: tool2_path.to_path_buf(),
264            },
265        ];
266
267        let version_only_config =
268            VersionOnlyConfig::from_sources(workspace_root, None, &tool_config_files).unwrap();
269        let nextest_version = version_only_config.nextest_version();
270        assert_eq!(
271            nextest_version,
272            &NextestVersionConfig {
273                required: NextestVersionReq::Version {
274                    version: "0.9.51".parse().unwrap(),
275                    tool: Some(tool_name("tool1"))
276                },
277                recommended: NextestVersionReq::Version {
278                    version: "0.9.52".parse().unwrap(),
279                    tool: Some(tool_name("tool1"))
280                }
281            },
282        );
283
284        let pcx = ParseContext::new(&graph);
285        let config = NextestConfig::from_sources(
286            workspace_root,
287            &pcx,
288            None,
289            &tool_config_files,
290            &Default::default(),
291        )
292        .expect("config is valid");
293
294        let default_profile = config
295            .profile(NextestConfig::DEFAULT_PROFILE)
296            .expect("default profile is present")
297            .apply_build_platforms(&build_platforms());
298        // This is present in .config/nextest.toml and is the highest priority
299        assert_eq!(default_profile.retries(), RetryPolicy::new_without_delay(3));
300
301        let package_id = graph.workspace().iter().next().unwrap().id();
302
303        let binary_query = binary_query(
304            &graph,
305            package_id,
306            "lib",
307            "my-binary",
308            BuildPlatform::Target,
309        );
310        let test_foo = TestCaseName::new("test_foo");
311        let test_foo_query = TestQuery {
312            binary_query: binary_query.to_query(),
313            test_name: &test_foo,
314        };
315        let test_bar = TestCaseName::new("test_bar");
316        let test_bar_query = TestQuery {
317            binary_query: binary_query.to_query(),
318            test_name: &test_bar,
319        };
320        let test_baz = TestCaseName::new("test_baz");
321        let test_baz_query = TestQuery {
322            binary_query: binary_query.to_query(),
323            test_name: &test_baz,
324        };
325        let test_quux = TestCaseName::new("test_quux");
326        let test_quux_query = TestQuery {
327            binary_query: binary_query.to_query(),
328            test_name: &test_quux,
329        };
330
331        assert_eq!(
332            default_profile
333                .settings_for(NextestRunMode::Test, &test_foo_query)
334                .retries(),
335            RetryPolicy::new_without_delay(20),
336            "retries for test_foo/default profile"
337        );
338        assert_eq!(
339            default_profile
340                .settings_for(NextestRunMode::Test, &test_foo_query)
341                .test_group(),
342            &test_group("foo"),
343            "test_group for test_foo/default profile"
344        );
345        assert_eq!(
346            default_profile
347                .settings_for(NextestRunMode::Test, &test_bar_query)
348                .retries(),
349            RetryPolicy::new_without_delay(21),
350            "retries for test_bar/default profile"
351        );
352        assert_eq!(
353            default_profile
354                .settings_for(NextestRunMode::Test, &test_bar_query)
355                .test_group(),
356            &TestGroup::Global,
357            "test_group for test_bar/default profile"
358        );
359        assert_eq!(
360            default_profile
361                .settings_for(NextestRunMode::Test, &test_baz_query)
362                .retries(),
363            RetryPolicy::new_without_delay(23),
364            "retries for test_baz/default profile"
365        );
366        assert_eq!(
367            default_profile
368                .settings_for(NextestRunMode::Test, &test_quux_query)
369                .test_group(),
370            &test_group("@tool:tool1:group1"),
371            "test group for test_quux/default profile"
372        );
373
374        let tool_profile = config
375            .profile("tool")
376            .expect("tool profile is present")
377            .apply_build_platforms(&build_platforms());
378        assert_eq!(tool_profile.retries(), RetryPolicy::new_without_delay(12));
379        assert_eq!(
380            tool_profile
381                .settings_for(NextestRunMode::Test, &test_foo_query)
382                .retries(),
383            RetryPolicy::new_without_delay(25),
384            "retries for test_foo/default profile"
385        );
386        assert_eq!(
387            tool_profile
388                .settings_for(NextestRunMode::Test, &test_bar_query)
389                .retries(),
390            RetryPolicy::new_without_delay(24),
391            "retries for test_bar/default profile"
392        );
393        assert_eq!(
394            tool_profile
395                .settings_for(NextestRunMode::Test, &test_baz_query)
396                .retries(),
397            RetryPolicy::new_without_delay(22),
398            "retries for test_baz/default profile"
399        );
400
401        let tool2_profile = config
402            .profile("tool2")
403            .expect("tool2 profile is present")
404            .apply_build_platforms(&build_platforms());
405        assert_eq!(tool2_profile.retries(), RetryPolicy::new_without_delay(18));
406        assert_eq!(
407            tool2_profile
408                .settings_for(NextestRunMode::Test, &test_foo_query)
409                .retries(),
410            RetryPolicy::new_without_delay(26),
411            "retries for test_foo/default profile"
412        );
413        assert_eq!(
414            tool2_profile
415                .settings_for(NextestRunMode::Test, &test_bar_query)
416                .retries(),
417            RetryPolicy::new_without_delay(26),
418            "retries for test_bar/default profile"
419        );
420        assert_eq!(
421            tool2_profile
422                .settings_for(NextestRunMode::Test, &test_baz_query)
423                .retries(),
424            RetryPolicy::new_without_delay(26),
425            "retries for test_baz/default profile"
426        );
427    }
428}