1use 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#[derive(Clone, Copy, Debug)]
17pub struct ConfigFileSelection<'a> {
18 pub config_file: Option<&'a Utf8Path>,
20}
21
22impl<'a> ConfigFileSelection<'a> {
23 pub fn new(config_file: Option<&'a Utf8Path>) -> Self {
26 Self { config_file }
27 }
28
29 pub fn explicit_config_file(self) -> Option<&'a Utf8Path> {
31 self.config_file
32 }
33
34 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 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 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#[derive(Clone, Debug, PartialEq, Eq, Hash)]
98pub struct ConfigSource {
99 path: ConfigPath,
100 kind: ConfigSourceKind,
101}
102
103impl ConfigSource {
104 pub fn path(&self) -> &ConfigPath {
106 &self.path
107 }
108
109 pub fn kind(&self) -> &ConfigSourceKind {
111 &self.kind
112 }
113
114 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 pub(super) fn required(&self) -> bool {
124 match self.kind {
125 ConfigSourceKind::Tool(_) | ConfigSourceKind::ExplicitRepository => true,
126 ConfigSourceKind::DiscoveredRepository => false,
127 }
128 }
129
130 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 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#[derive(Clone, Debug, PartialEq, Eq, Hash)]
166pub enum ConfigSourceKind {
167 Tool(ToolName),
169 ExplicitRepository,
171 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 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 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}