Skip to main content

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::{
65                ConfigFileSelection, ConfigPaths, ConfigSource, ConfigSourceKind, NextestConfig,
66                NextestVersionConfig, NextestVersionReq, VersionOnlyConfig,
67            },
68            elements::{RetryPolicy, TestGroup},
69            utils::test_helpers::*,
70        },
71        run_mode::NextestRunMode,
72    };
73    use camino_tempfile::tempdir;
74    use camino_tempfile_ext::prelude::*;
75    use guppy::graph::cargo::BuildPlatform;
76    use nextest_filtering::{ParseContext, TestQuery};
77    use nextest_metadata::TestCaseName;
78    use std::collections::HashSet;
79
80    fn tool_name(s: &str) -> ToolName {
81        ToolName::new(s.into()).unwrap()
82    }
83
84    #[test]
85    fn parse_tool_config_file() {
86        use crate::errors::{InvalidToolName, ToolConfigFileParseError};
87
88        cfg_if::cfg_if! {
89            if #[cfg(windows)] {
90                let valid = ["tool:C:\\foo\\bar", "tool:\\\\?\\C:\\foo\\bar"];
91            } else {
92                let valid = ["tool:/foo/bar"];
93            }
94        }
95
96        for valid_input in valid {
97            valid_input.parse::<ToolConfigFile>().unwrap_or_else(|err| {
98                panic!("valid input {valid_input} should parse correctly: {err}")
99            });
100        }
101
102        cfg_if::cfg_if! {
103            if #[cfg(windows)] {
104                let invalid: &[(&str, ToolConfigFileParseError)] = &[
105                    ("no-colon-here", ToolConfigFileParseError::InvalidFormat {
106                        input: "no-colon-here".to_owned(),
107                    }),
108                    ("tool:\\foo\\bar", ToolConfigFileParseError::ConfigFileNotAbsolute {
109                        config_file: "\\foo\\bar".into(),
110                    }),
111                    ("tool:foo/bar", ToolConfigFileParseError::ConfigFileNotAbsolute {
112                        config_file: "foo/bar".into(),
113                    }),
114                ];
115            } else {
116                let invalid: &[(&str, ToolConfigFileParseError)] = &[
117                    ("/foo/bar", ToolConfigFileParseError::InvalidFormat {
118                        input: "/foo/bar".to_owned(),
119                    }),
120                    ("tool:foo/bar", ToolConfigFileParseError::ConfigFileNotAbsolute {
121                        config_file: "foo/bar".into(),
122                    }),
123                    ("tool:./foo", ToolConfigFileParseError::ConfigFileNotAbsolute {
124                        config_file: "./foo".into(),
125                    }),
126                ];
127            }
128        }
129
130        // Common invalid cases for all platforms.
131        let common_invalid: &[(&str, ToolConfigFileParseError)] = &[
132            (
133                ":/foo/bar",
134                ToolConfigFileParseError::InvalidToolName {
135                    input: ":/foo/bar".to_owned(),
136                    error: InvalidToolName::Empty,
137                },
138            ),
139            (
140                "_invalid:/foo/bar",
141                ToolConfigFileParseError::InvalidToolName {
142                    input: "_invalid:/foo/bar".to_owned(),
143                    error: InvalidToolName::InvalidXid("_invalid".into()),
144                },
145            ),
146            // Tool names starting with "@tool" are rejected with a specific error.
147            (
148                "@tool:/path",
149                ToolConfigFileParseError::InvalidToolName {
150                    input: "@tool:/path".to_owned(),
151                    error: InvalidToolName::StartsWithToolPrefix("@tool".into()),
152                },
153            ),
154            (
155                "tool:",
156                ToolConfigFileParseError::EmptyConfigFile {
157                    input: "tool:".to_owned(),
158                },
159            ),
160        ];
161
162        for (input, expected) in invalid.iter().chain(common_invalid.iter()) {
163            let actual = input.parse::<ToolConfigFile>().unwrap_err();
164            assert_eq!(&actual, expected, "for input {input:?}");
165        }
166    }
167
168    #[test]
169    fn tool_config_basic() {
170        let config_contents = r#"
171        nextest-version = "0.9.50"
172
173        [profile.default]
174        retries = 3
175
176        [[profile.default.overrides]]
177        filter = 'test(test_foo)'
178        retries = 20
179        test-group = 'foo'
180
181        [[profile.default.overrides]]
182        filter = 'test(test_quux)'
183        test-group = '@tool:tool1:group1'
184
185        [test-groups.foo]
186        max-threads = 2
187        "#;
188
189        let tool1_config_contents = r#"
190        nextest-version = { required = "0.9.51", recommended = "0.9.52" }
191
192        [profile.default]
193        retries = 4
194
195        [[profile.default.overrides]]
196        filter = 'test(test_bar)'
197        retries = 21
198
199        [profile.tool]
200        retries = 12
201
202        [[profile.tool.overrides]]
203        filter = 'test(test_baz)'
204        retries = 22
205        test-group = '@tool:tool1:group1'
206
207        [[profile.tool.overrides]]
208        filter = 'test(test_quux)'
209        retries = 22
210        test-group = '@tool:tool2:group2'
211
212        [test-groups.'@tool:tool1:group1']
213        max-threads = 2
214        "#;
215
216        let tool2_config_contents = r#"
217        nextest-version = { recommended = "0.9.49" }
218
219        [profile.default]
220        retries = 5
221
222        [[profile.default.overrides]]
223        filter = 'test(test_)'
224        retries = 23
225
226        [profile.tool]
227        retries = 16
228
229        [[profile.tool.overrides]]
230        filter = 'test(test_ba)'
231        retries = 24
232        test-group = '@tool:tool2:group2'
233
234        [[profile.tool.overrides]]
235        filter = 'test(test_)'
236        retries = 25
237        test-group = '@global'
238
239        [profile.tool2]
240        retries = 18
241
242        [[profile.tool2.overrides]]
243        filter = 'all()'
244        retries = 26
245
246        [test-groups.'@tool:tool2:group2']
247        max-threads = 4
248        "#;
249
250        let workspace_dir = tempdir().unwrap();
251
252        let graph = temp_workspace(&workspace_dir, config_contents);
253        let tool1_path = workspace_dir.child(".config/tool1.toml");
254        let tool2_path = workspace_dir.child(".config/tool2.toml");
255        tool1_path.write_str(tool1_config_contents).unwrap();
256        tool2_path.write_str(tool2_config_contents).unwrap();
257
258        let workspace_root = graph.workspace().root();
259
260        let tool_config_files = [
261            ToolConfigFile {
262                tool: tool_name("tool1"),
263                config_file: tool1_path.to_path_buf(),
264            },
265            ToolConfigFile {
266                tool: tool_name("tool2"),
267                config_file: tool2_path.to_path_buf(),
268            },
269        ];
270
271        let version_only_config =
272            VersionOnlyConfig::from_sources(workspace_root, None, &tool_config_files).unwrap();
273        let nextest_version = version_only_config.nextest_version();
274        let sources = ConfigFileSelection::new(None)
275            .sources(
276                &ConfigPaths::capture(workspace_root).unwrap(),
277                tool_config_files.iter().rev(),
278            )
279            .unwrap();
280        let [_tool2_source, tool1_source, _repo_source] = <[ConfigSource; 3]>::try_from(sources)
281            .expect("two tool sources followed by the repository source");
282        match tool1_source.kind() {
283            ConfigSourceKind::Tool(tool) => assert_eq!(tool, &tool_name("tool1")),
284            ConfigSourceKind::ExplicitRepository | ConfigSourceKind::DiscoveredRepository => {
285                panic!("expected tool1's source, got {tool1_source:?}")
286            }
287        }
288        assert_eq!(
289            nextest_version,
290            &NextestVersionConfig {
291                required: NextestVersionReq::Version {
292                    version: "0.9.51".parse().unwrap(),
293                    source: tool1_source.clone(),
294                },
295                recommended: NextestVersionReq::Version {
296                    version: "0.9.52".parse().unwrap(),
297                    source: tool1_source,
298                },
299            },
300        );
301
302        let pcx = ParseContext::new(&graph);
303        let config = NextestConfig::from_sources(
304            workspace_root,
305            &pcx,
306            None,
307            &tool_config_files,
308            &Default::default(),
309        )
310        .expect("config is valid");
311
312        let default_profile = config
313            .profile(NextestConfig::DEFAULT_PROFILE)
314            .expect("default profile is present")
315            .apply_build_platforms(&build_platforms());
316        // This is present in .config/nextest.toml and is the highest priority
317        assert_eq!(default_profile.retries(), RetryPolicy::new_without_delay(3));
318
319        let repo_path = workspace_root.join(NextestConfig::CONFIG_PATH);
320        let override_sources: Vec<_> = default_profile
321            .compiled_data
322            .overrides
323            .iter()
324            .map(|override_| {
325                let id = override_.id();
326                (
327                    id.config_source.path().absolute_path(),
328                    id.config_source.kind(),
329                )
330            })
331            .collect();
332        assert_eq!(
333            override_sources,
334            [
335                (repo_path.as_path(), &ConfigSourceKind::DiscoveredRepository),
336                (repo_path.as_path(), &ConfigSourceKind::DiscoveredRepository),
337                (
338                    tool1_path.as_path(),
339                    &ConfigSourceKind::Tool(tool_name("tool1"))
340                ),
341                (
342                    tool2_path.as_path(),
343                    &ConfigSourceKind::Tool(tool_name("tool2"))
344                ),
345            ],
346            "overrides record their source, highest priority first"
347        );
348        let override_ids: HashSet<_> = default_profile
349            .compiled_data
350            .overrides
351            .iter()
352            .map(|override_| override_.id())
353            .collect();
354        assert_eq!(
355            override_ids.len(),
356            default_profile.compiled_data.overrides.len(),
357            "override ids are pairwise distinct"
358        );
359
360        let package_id = graph.workspace().iter().next().unwrap().id();
361
362        let binary_query = binary_query(
363            &graph,
364            package_id,
365            "lib",
366            "my-binary",
367            BuildPlatform::Target,
368        );
369        let test_foo = TestCaseName::new("test_foo");
370        let test_foo_query = TestQuery {
371            binary_query: binary_query.to_query(),
372            test_name: &test_foo,
373        };
374        let test_bar = TestCaseName::new("test_bar");
375        let test_bar_query = TestQuery {
376            binary_query: binary_query.to_query(),
377            test_name: &test_bar,
378        };
379        let test_baz = TestCaseName::new("test_baz");
380        let test_baz_query = TestQuery {
381            binary_query: binary_query.to_query(),
382            test_name: &test_baz,
383        };
384        let test_quux = TestCaseName::new("test_quux");
385        let test_quux_query = TestQuery {
386            binary_query: binary_query.to_query(),
387            test_name: &test_quux,
388        };
389
390        assert_eq!(
391            default_profile
392                .settings_for(NextestRunMode::Test, &test_foo_query)
393                .retries(),
394            RetryPolicy::new_without_delay(20),
395            "retries for test_foo/default profile"
396        );
397        assert_eq!(
398            default_profile
399                .settings_for(NextestRunMode::Test, &test_foo_query)
400                .test_group(),
401            &test_group("foo"),
402            "test_group for test_foo/default profile"
403        );
404        assert_eq!(
405            default_profile
406                .settings_for(NextestRunMode::Test, &test_bar_query)
407                .retries(),
408            RetryPolicy::new_without_delay(21),
409            "retries for test_bar/default profile"
410        );
411        assert_eq!(
412            default_profile
413                .settings_for(NextestRunMode::Test, &test_bar_query)
414                .test_group(),
415            &TestGroup::Global,
416            "test_group for test_bar/default profile"
417        );
418        assert_eq!(
419            default_profile
420                .settings_for(NextestRunMode::Test, &test_baz_query)
421                .retries(),
422            RetryPolicy::new_without_delay(23),
423            "retries for test_baz/default profile"
424        );
425        assert_eq!(
426            default_profile
427                .settings_for(NextestRunMode::Test, &test_quux_query)
428                .test_group(),
429            &test_group("@tool:tool1:group1"),
430            "test group for test_quux/default profile"
431        );
432
433        let tool_profile = config
434            .profile("tool")
435            .expect("tool profile is present")
436            .apply_build_platforms(&build_platforms());
437        assert_eq!(tool_profile.retries(), RetryPolicy::new_without_delay(12));
438
439        let tool_override_sources: Vec<_> = tool_profile
440            .compiled_data
441            .overrides
442            .iter()
443            .map(|override_| {
444                let id = override_.id();
445                (
446                    id.config_source.path().absolute_path(),
447                    id.config_source.kind(),
448                    id.profile_name.as_str(),
449                )
450            })
451            .collect();
452        let tool1_kind = ConfigSourceKind::Tool(tool_name("tool1"));
453        let tool2_kind = ConfigSourceKind::Tool(tool_name("tool2"));
454        let repo_kind = ConfigSourceKind::DiscoveredRepository;
455        assert_eq!(
456            tool_override_sources,
457            [
458                (tool1_path.as_path(), &tool1_kind, "tool"),
459                (tool1_path.as_path(), &tool1_kind, "tool"),
460                (tool2_path.as_path(), &tool2_kind, "tool"),
461                (tool2_path.as_path(), &tool2_kind, "tool"),
462                (repo_path.as_path(), &repo_kind, "default"),
463                (repo_path.as_path(), &repo_kind, "default"),
464                (tool1_path.as_path(), &tool1_kind, "default"),
465                (tool2_path.as_path(), &tool2_kind, "default"),
466            ],
467            "tool profile overrides record their source, highest priority first"
468        );
469        assert_eq!(
470            tool_profile
471                .settings_for(NextestRunMode::Test, &test_foo_query)
472                .retries(),
473            RetryPolicy::new_without_delay(25),
474            "retries for test_foo/default profile"
475        );
476        assert_eq!(
477            tool_profile
478                .settings_for(NextestRunMode::Test, &test_bar_query)
479                .retries(),
480            RetryPolicy::new_without_delay(24),
481            "retries for test_bar/default profile"
482        );
483        assert_eq!(
484            tool_profile
485                .settings_for(NextestRunMode::Test, &test_baz_query)
486                .retries(),
487            RetryPolicy::new_without_delay(22),
488            "retries for test_baz/default profile"
489        );
490
491        let tool2_profile = config
492            .profile("tool2")
493            .expect("tool2 profile is present")
494            .apply_build_platforms(&build_platforms());
495        assert_eq!(tool2_profile.retries(), RetryPolicy::new_without_delay(18));
496        assert_eq!(
497            tool2_profile
498                .settings_for(NextestRunMode::Test, &test_foo_query)
499                .retries(),
500            RetryPolicy::new_without_delay(26),
501            "retries for test_foo/default profile"
502        );
503        assert_eq!(
504            tool2_profile
505                .settings_for(NextestRunMode::Test, &test_bar_query)
506                .retries(),
507            RetryPolicy::new_without_delay(26),
508            "retries for test_bar/default profile"
509        );
510        assert_eq!(
511            tool2_profile
512                .settings_for(NextestRunMode::Test, &test_baz_query)
513                .retries(),
514            RetryPolicy::new_without_delay(26),
515            "retries for test_baz/default profile"
516        );
517    }
518}