Skip to main content

nextest_runner/config/core/
sources.rs

1// Copyright (c) The nextest Contributors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Configuration file discovery shared by the early and full loaders.
5
6use super::{ConfigPath, ConfigPaths, ToolConfigFile, ToolName};
7use crate::errors::{ConfigParseError, ConfigParseErrorKind};
8use camino::Utf8Path;
9use std::{
10    collections::{HashMap, hash_map::Entry},
11    fs, io,
12};
13use tracing::debug;
14
15/// Selects repository configuration files independently of tool configuration.
16#[derive(Clone, Copy, Debug)]
17pub struct ConfigFileSelection<'a> {
18    /// An explicit, required configuration file, suppressing automatic discovery.
19    pub config_file: Option<&'a Utf8Path>,
20}
21
22impl<'a> ConfigFileSelection<'a> {
23    /// Creates a new `ConfigFileSelection` that uses an explicit file if given,
24    /// otherwise the repository file at the workspace root.
25    pub fn new(config_file: Option<&'a Utf8Path>) -> Self {
26        Self { config_file }
27    }
28
29    /// Returns the explicit repository config file, if one was selected.
30    pub fn explicit_config_file(self) -> Option<&'a Utf8Path> {
31        self.config_file
32    }
33
34    /// Resolves the explicit repository input or discovers the shared file.
35    pub(super) fn repo_config_path(
36        self,
37        paths: &ConfigPaths,
38    ) -> Result<ConfigPath, ConfigParseError> {
39        match self.config_file {
40            Some(path) => Ok(paths.resolve_input(path)?),
41            None => Ok(paths.shared_config()),
42        }
43    }
44
45    /// Returns every config file to load, lowest priority first.
46    ///
47    /// Returns tool files in the given (already assumed to be reversed) order,
48    /// then the repository file.
49    pub(super) fn sources<'t>(
50        self,
51        paths: &ConfigPaths,
52        tool_config_files_rev: impl Iterator<Item = &'t ToolConfigFile>,
53    ) -> Result<Vec<ConfigSource>, ConfigParseError> {
54        let tool_sources = tool_config_files_rev
55            .map(|ToolConfigFile { config_file, tool }| {
56                let source = ConfigSource {
57                    path: paths.resolve_input(config_file)?,
58                    kind: ConfigSourceKind::Tool(tool.clone()),
59                };
60                Ok((tool, source))
61            })
62            .collect::<Result<Vec<_>, ConfigParseError>>()?;
63        // Walk the tools in command-line order so the error lands on the later
64        // argument and names the earlier one. (This runs before any file is
65        // read.)
66        let mut first_path_by_tool = HashMap::new();
67        for (tool, source) in tool_sources.iter().rev() {
68            match first_path_by_tool.entry(*tool) {
69                Entry::Vacant(entry) => {
70                    entry.insert(source.path());
71                }
72                Entry::Occupied(entry) => {
73                    return Err(ConfigParseError::new(
74                        source,
75                        ConfigParseErrorKind::DuplicateToolConfigFile {
76                            tool: (*tool).clone(),
77                            first: (*entry.get()).clone(),
78                        },
79                    ));
80                }
81            }
82        }
83        let mut sources: Vec<_> = tool_sources.into_iter().map(|(_, source)| source).collect();
84        let kind = match self.config_file {
85            Some(_) => ConfigSourceKind::ExplicitRepository,
86            None => ConfigSourceKind::DiscoveredRepository,
87        };
88        sources.push(ConfigSource {
89            path: self.repo_config_path(paths)?,
90            kind,
91        });
92        Ok(sources)
93    }
94}
95
96/// The source of a configuration setting.
97#[derive(Clone, Debug, PartialEq, Eq, Hash)]
98pub struct ConfigSource {
99    path: ConfigPath,
100    kind: ConfigSourceKind,
101}
102
103impl ConfigSource {
104    /// Returns the resolved path of this config file.
105    pub fn path(&self) -> &ConfigPath {
106        &self.path
107    }
108
109    /// Returns how this config file was selected.
110    pub fn kind(&self) -> &ConfigSourceKind {
111        &self.kind
112    }
113
114    /// Returns the tool that provided this config file, if any.
115    pub fn tool(&self) -> Option<&ToolName> {
116        match &self.kind {
117            ConfigSourceKind::Tool(tool) => Some(tool),
118            ConfigSourceKind::ExplicitRepository | ConfigSourceKind::DiscoveredRepository => None,
119        }
120    }
121
122    /// Returns whether this config file is required.
123    pub(super) fn required(&self) -> bool {
124        match self.kind {
125            ConfigSourceKind::Tool(_) | ConfigSourceKind::ExplicitRepository => true,
126            ConfigSourceKind::DiscoveredRepository => false,
127        }
128    }
129
130    /// Reads this configuration file.
131    ///
132    /// Returns `None` only for an absent optional file.
133    pub(super) fn read(&self) -> Result<Option<String>, ConfigParseError> {
134        match fs::read_to_string(self.path.absolute_path()) {
135            Ok(contents) => {
136                debug!(
137                    config_file = %self.path.display(),
138                    tool = self.tool().map(ToolName::as_str),
139                    "read config file",
140                );
141                Ok(Some(contents))
142            }
143            // A regular file named `.config` yields NotADirectory on Unix and
144            // NotFound on Windows -- both mean that the optional file is
145            // absent.
146            Err(error) if !self.required() => match error.kind() {
147                io::ErrorKind::NotFound | io::ErrorKind::NotADirectory => {
148                    debug!(config_file = %self.path.display(), "config file not found, skipping");
149                    Ok(None)
150                }
151                _ => Err(self.read_error(error)),
152            },
153            Err(error) => Err(self.read_error(error)),
154        }
155    }
156
157    fn read_error(&self, error: io::Error) -> ConfigParseError {
158        ConfigParseError::new(self, ConfigParseErrorKind::ReadError(error))
159    }
160}
161
162/// How a config file was selected.
163///
164/// The variants are in priority order from lowest to highest.
165#[derive(Clone, Debug, PartialEq, Eq, Hash)]
166pub enum ConfigSourceKind {
167    /// A tool configuration, passed in via `--tool-config-file`.
168    Tool(ToolName),
169    /// An explicit repository configuration, passed in via `--config-file`.
170    ExplicitRepository,
171    /// The shared repository file found at the workspace root.
172    DiscoveredRepository,
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178    use crate::config::{
179        core::{NextestConfig, VersionOnlyConfig},
180        utils::test_helpers::*,
181    };
182    use camino_tempfile::{Utf8TempDir, tempdir};
183    use camino_tempfile_ext::prelude::*;
184    use nextest_filtering::ParseContext;
185    use std::collections::BTreeSet;
186    use test_case::test_case;
187
188    #[derive(Clone, Copy, Debug)]
189    enum Loader {
190        VersionOnly,
191        Full,
192    }
193
194    #[derive(Clone, Copy, Debug)]
195    enum Scenario {
196        AbsentRepoConfig,
197        DotConfigIsFile,
198        RepoConfigIsDirectory,
199        MissingToolConfig,
200        MalformedToolConfig,
201        MissingExplicitConfig,
202    }
203
204    fn tool_name(s: &str) -> ToolName {
205        ToolName::new(s.into()).unwrap()
206    }
207
208    // temp_workspace always writes .config/nextest.toml, so remove it
209    // afterwards to get a workspace without a repo config.
210    fn workspace_without_repo_config(dir: &Utf8TempDir) -> guppy::graph::PackageGraph {
211        let graph = temp_workspace(dir, "");
212        fs::remove_dir_all(dir.child(".config")).unwrap();
213        graph
214    }
215
216    fn load(
217        loader: Loader,
218        dir: &Utf8TempDir,
219        graph: &guppy::graph::PackageGraph,
220        config_file: Option<&Utf8Path>,
221        tool_config_files: &[ToolConfigFile],
222    ) -> Result<(), ConfigParseError> {
223        match loader {
224            Loader::VersionOnly => {
225                VersionOnlyConfig::from_sources(dir.path(), config_file, tool_config_files)
226                    .map(|_| ())
227            }
228            Loader::Full => NextestConfig::from_sources(
229                dir.path(),
230                &ParseContext::new(graph),
231                config_file,
232                tool_config_files,
233                &BTreeSet::new(),
234            )
235            .map(|_| ()),
236        }
237    }
238
239    #[test_case(Loader::VersionOnly, Scenario::AbsentRepoConfig; "version only, absent repo config")]
240    #[test_case(Loader::Full, Scenario::AbsentRepoConfig; "full, absent repo config")]
241    #[test_case(Loader::VersionOnly, Scenario::DotConfigIsFile; "version only, .config is a file")]
242    #[test_case(Loader::Full, Scenario::DotConfigIsFile; "full, .config is a file")]
243    #[test_case(Loader::VersionOnly, Scenario::RepoConfigIsDirectory; "version only, repo config is a directory")]
244    #[test_case(Loader::Full, Scenario::RepoConfigIsDirectory; "full, repo config is a directory")]
245    #[test_case(Loader::VersionOnly, Scenario::MissingToolConfig; "version only, missing tool config")]
246    #[test_case(Loader::Full, Scenario::MissingToolConfig; "full, missing tool config")]
247    #[test_case(Loader::VersionOnly, Scenario::MalformedToolConfig; "version only, malformed tool config")]
248    #[test_case(Loader::Full, Scenario::MalformedToolConfig; "full, malformed tool config")]
249    #[test_case(Loader::VersionOnly, Scenario::MissingExplicitConfig; "version only, missing explicit config")]
250    #[test_case(Loader::Full, Scenario::MissingExplicitConfig; "full, missing explicit config")]
251    fn read_errors_and_absent_files(loader: Loader, scenario: Scenario) {
252        let dir = tempdir().unwrap();
253        let graph = workspace_without_repo_config(&dir);
254        let repo_config = dir.child(NextestConfig::CONFIG_PATH);
255
256        match scenario {
257            Scenario::AbsentRepoConfig => {
258                load(loader, &dir, &graph, None, &[]).expect("absent repo config is optional");
259            }
260            Scenario::DotConfigIsFile => {
261                dir.child(".config").write_str("not a directory").unwrap();
262                load(loader, &dir, &graph, None, &[])
263                    .expect("a regular file named .config means the repo config is absent");
264            }
265            Scenario::RepoConfigIsDirectory => {
266                repo_config.create_dir_all().unwrap();
267                let error = load(loader, &dir, &graph, None, &[]).unwrap_err();
268                assert_eq!(error.config_file(), repo_config.as_path());
269                assert_eq!(error.tool(), None);
270                let ConfigParseErrorKind::ReadError(_) = error.kind() else {
271                    panic!("a directory at the repo config path is a read error, got {error:?}");
272                };
273            }
274            Scenario::MissingToolConfig => {
275                let tool = ToolConfigFile {
276                    tool: tool_name("missing-tool"),
277                    config_file: dir.child("missing-tool.toml").to_path_buf(),
278                };
279                let error =
280                    load(loader, &dir, &graph, None, std::slice::from_ref(&tool)).unwrap_err();
281                assert_eq!(error.config_file(), tool.config_file);
282                assert_eq!(error.tool(), Some(&tool.tool));
283                let ConfigParseErrorKind::ReadError(_) = error.kind() else {
284                    panic!("a missing tool config file is a read error, got {error:?}");
285                };
286            }
287            Scenario::MalformedToolConfig => {
288                let tool_config = dir.child("malformed-tool.toml");
289                tool_config.write_str("invalid = [").unwrap();
290                let tool = ToolConfigFile {
291                    tool: tool_name("malformed-tool"),
292                    config_file: tool_config.to_path_buf(),
293                };
294                let error =
295                    load(loader, &dir, &graph, None, std::slice::from_ref(&tool)).unwrap_err();
296                assert_eq!(error.config_file(), tool.config_file);
297                assert_eq!(error.tool(), Some(&tool.tool));
298                match (loader, error.kind()) {
299                    (Loader::VersionOnly, ConfigParseErrorKind::TomlParseError(_))
300                    | (Loader::Full, ConfigParseErrorKind::BuildError(_)) => {}
301                    (Loader::VersionOnly | Loader::Full, _) => {
302                        panic!("malformed TOML in a tool config is a parse error, got {error:?}");
303                    }
304                }
305            }
306            Scenario::MissingExplicitConfig => {
307                let explicit = dir.child("missing.toml");
308                let error = load(loader, &dir, &graph, Some(explicit.as_path()), &[]).unwrap_err();
309                assert_eq!(error.config_file(), explicit.as_path());
310                assert_eq!(error.tool(), None);
311                let ConfigParseErrorKind::ReadError(_) = error.kind() else {
312                    panic!("a missing explicit config file is a read error, got {error:?}");
313                };
314            }
315        }
316    }
317
318    #[test_case(Loader::VersionOnly; "version only")]
319    #[test_case(Loader::Full; "full")]
320    fn duplicate_tool_config_files_are_rejected(loader: Loader) {
321        let dir = tempdir().unwrap();
322        let graph = workspace_without_repo_config(&dir);
323        // (None of these files exist, so a read error instead of the duplicate
324        // error would mean the check ran too late.)
325        let tool_config_files = [
326            ToolConfigFile {
327                tool: tool_name("my-tool"),
328                config_file: dir.child("first.toml").to_path_buf(),
329            },
330            ToolConfigFile {
331                tool: tool_name("other-tool"),
332                config_file: dir.child("other.toml").to_path_buf(),
333            },
334            ToolConfigFile {
335                tool: tool_name("my-tool"),
336                config_file: dir.child("second.toml").to_path_buf(),
337            },
338        ];
339
340        let error = load(loader, &dir, &graph, None, &tool_config_files).unwrap_err();
341        assert_eq!(error.config_file(), tool_config_files[2].config_file);
342        assert_eq!(error.tool(), Some(&tool_name("my-tool")));
343        let ConfigParseErrorKind::DuplicateToolConfigFile { tool, first } = error.kind() else {
344            panic!("a second config file for the same tool is rejected, got {error:?}");
345        };
346        assert_eq!(tool, &tool_name("my-tool"));
347        assert_eq!(first.absolute_path(), tool_config_files[0].config_file);
348    }
349}