use super::{ConfigPath, ConfigPaths, ToolConfigFile, ToolName};
use crate::errors::{ConfigParseError, ConfigParseErrorKind};
use camino::Utf8Path;
use std::{
collections::{HashMap, hash_map::Entry},
fs, io,
};
use tracing::debug;
#[derive(Clone, Copy, Debug)]
pub struct ConfigFileSelection<'a> {
pub config_file: Option<&'a Utf8Path>,
}
impl<'a> ConfigFileSelection<'a> {
pub fn new(config_file: Option<&'a Utf8Path>) -> Self {
Self { config_file }
}
pub fn explicit_config_file(self) -> Option<&'a Utf8Path> {
self.config_file
}
pub(super) fn repo_config_path(
self,
paths: &ConfigPaths,
) -> Result<ConfigPath, ConfigParseError> {
match self.config_file {
Some(path) => Ok(paths.resolve_input(path)?),
None => Ok(paths.shared_config()),
}
}
pub(super) fn sources<'t>(
self,
paths: &ConfigPaths,
tool_config_files_rev: impl Iterator<Item = &'t ToolConfigFile>,
) -> Result<Vec<ConfigSource>, ConfigParseError> {
let tool_sources = tool_config_files_rev
.map(|ToolConfigFile { config_file, tool }| {
let source = ConfigSource {
path: paths.resolve_input(config_file)?,
kind: ConfigSourceKind::Tool(tool.clone()),
};
Ok((tool, source))
})
.collect::<Result<Vec<_>, ConfigParseError>>()?;
let mut first_path_by_tool = HashMap::new();
for (tool, source) in tool_sources.iter().rev() {
match first_path_by_tool.entry(*tool) {
Entry::Vacant(entry) => {
entry.insert(source.path());
}
Entry::Occupied(entry) => {
return Err(ConfigParseError::new(
source,
ConfigParseErrorKind::DuplicateToolConfigFile {
tool: (*tool).clone(),
first: (*entry.get()).clone(),
},
));
}
}
}
let mut sources: Vec<_> = tool_sources.into_iter().map(|(_, source)| source).collect();
let kind = match self.config_file {
Some(_) => ConfigSourceKind::ExplicitRepository,
None => ConfigSourceKind::DiscoveredRepository,
};
sources.push(ConfigSource {
path: self.repo_config_path(paths)?,
kind,
});
Ok(sources)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct ConfigSource {
path: ConfigPath,
kind: ConfigSourceKind,
}
impl ConfigSource {
pub fn path(&self) -> &ConfigPath {
&self.path
}
pub fn kind(&self) -> &ConfigSourceKind {
&self.kind
}
pub fn tool(&self) -> Option<&ToolName> {
match &self.kind {
ConfigSourceKind::Tool(tool) => Some(tool),
ConfigSourceKind::ExplicitRepository | ConfigSourceKind::DiscoveredRepository => None,
}
}
pub(super) fn required(&self) -> bool {
match self.kind {
ConfigSourceKind::Tool(_) | ConfigSourceKind::ExplicitRepository => true,
ConfigSourceKind::DiscoveredRepository => false,
}
}
pub(super) fn read(&self) -> Result<Option<String>, ConfigParseError> {
match fs::read_to_string(self.path.absolute_path()) {
Ok(contents) => {
debug!(
config_file = %self.path.display(),
tool = self.tool().map(ToolName::as_str),
"read config file",
);
Ok(Some(contents))
}
Err(error) if !self.required() => match error.kind() {
io::ErrorKind::NotFound | io::ErrorKind::NotADirectory => {
debug!(config_file = %self.path.display(), "config file not found, skipping");
Ok(None)
}
_ => Err(self.read_error(error)),
},
Err(error) => Err(self.read_error(error)),
}
}
fn read_error(&self, error: io::Error) -> ConfigParseError {
ConfigParseError::new(self, ConfigParseErrorKind::ReadError(error))
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum ConfigSourceKind {
Tool(ToolName),
ExplicitRepository,
DiscoveredRepository,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{
core::{NextestConfig, VersionOnlyConfig},
utils::test_helpers::*,
};
use camino_tempfile::{Utf8TempDir, tempdir};
use camino_tempfile_ext::prelude::*;
use nextest_filtering::ParseContext;
use std::collections::BTreeSet;
use test_case::test_case;
#[derive(Clone, Copy, Debug)]
enum Loader {
VersionOnly,
Full,
}
#[derive(Clone, Copy, Debug)]
enum Scenario {
AbsentRepoConfig,
DotConfigIsFile,
RepoConfigIsDirectory,
MissingToolConfig,
MalformedToolConfig,
MissingExplicitConfig,
}
fn tool_name(s: &str) -> ToolName {
ToolName::new(s.into()).unwrap()
}
fn workspace_without_repo_config(dir: &Utf8TempDir) -> guppy::graph::PackageGraph {
let graph = temp_workspace(dir, "");
fs::remove_dir_all(dir.child(".config")).unwrap();
graph
}
fn load(
loader: Loader,
dir: &Utf8TempDir,
graph: &guppy::graph::PackageGraph,
config_file: Option<&Utf8Path>,
tool_config_files: &[ToolConfigFile],
) -> Result<(), ConfigParseError> {
match loader {
Loader::VersionOnly => {
VersionOnlyConfig::from_sources(dir.path(), config_file, tool_config_files)
.map(|_| ())
}
Loader::Full => NextestConfig::from_sources(
dir.path(),
&ParseContext::new(graph),
config_file,
tool_config_files,
&BTreeSet::new(),
)
.map(|_| ()),
}
}
#[test_case(Loader::VersionOnly, Scenario::AbsentRepoConfig; "version only, absent repo config")]
#[test_case(Loader::Full, Scenario::AbsentRepoConfig; "full, absent repo config")]
#[test_case(Loader::VersionOnly, Scenario::DotConfigIsFile; "version only, .config is a file")]
#[test_case(Loader::Full, Scenario::DotConfigIsFile; "full, .config is a file")]
#[test_case(Loader::VersionOnly, Scenario::RepoConfigIsDirectory; "version only, repo config is a directory")]
#[test_case(Loader::Full, Scenario::RepoConfigIsDirectory; "full, repo config is a directory")]
#[test_case(Loader::VersionOnly, Scenario::MissingToolConfig; "version only, missing tool config")]
#[test_case(Loader::Full, Scenario::MissingToolConfig; "full, missing tool config")]
#[test_case(Loader::VersionOnly, Scenario::MalformedToolConfig; "version only, malformed tool config")]
#[test_case(Loader::Full, Scenario::MalformedToolConfig; "full, malformed tool config")]
#[test_case(Loader::VersionOnly, Scenario::MissingExplicitConfig; "version only, missing explicit config")]
#[test_case(Loader::Full, Scenario::MissingExplicitConfig; "full, missing explicit config")]
fn read_errors_and_absent_files(loader: Loader, scenario: Scenario) {
let dir = tempdir().unwrap();
let graph = workspace_without_repo_config(&dir);
let repo_config = dir.child(NextestConfig::CONFIG_PATH);
match scenario {
Scenario::AbsentRepoConfig => {
load(loader, &dir, &graph, None, &[]).expect("absent repo config is optional");
}
Scenario::DotConfigIsFile => {
dir.child(".config").write_str("not a directory").unwrap();
load(loader, &dir, &graph, None, &[])
.expect("a regular file named .config means the repo config is absent");
}
Scenario::RepoConfigIsDirectory => {
repo_config.create_dir_all().unwrap();
let error = load(loader, &dir, &graph, None, &[]).unwrap_err();
assert_eq!(error.config_file(), repo_config.as_path());
assert_eq!(error.tool(), None);
let ConfigParseErrorKind::ReadError(_) = error.kind() else {
panic!("a directory at the repo config path is a read error, got {error:?}");
};
}
Scenario::MissingToolConfig => {
let tool = ToolConfigFile {
tool: tool_name("missing-tool"),
config_file: dir.child("missing-tool.toml").to_path_buf(),
};
let error =
load(loader, &dir, &graph, None, std::slice::from_ref(&tool)).unwrap_err();
assert_eq!(error.config_file(), tool.config_file);
assert_eq!(error.tool(), Some(&tool.tool));
let ConfigParseErrorKind::ReadError(_) = error.kind() else {
panic!("a missing tool config file is a read error, got {error:?}");
};
}
Scenario::MalformedToolConfig => {
let tool_config = dir.child("malformed-tool.toml");
tool_config.write_str("invalid = [").unwrap();
let tool = ToolConfigFile {
tool: tool_name("malformed-tool"),
config_file: tool_config.to_path_buf(),
};
let error =
load(loader, &dir, &graph, None, std::slice::from_ref(&tool)).unwrap_err();
assert_eq!(error.config_file(), tool.config_file);
assert_eq!(error.tool(), Some(&tool.tool));
match (loader, error.kind()) {
(Loader::VersionOnly, ConfigParseErrorKind::TomlParseError(_))
| (Loader::Full, ConfigParseErrorKind::BuildError(_)) => {}
(Loader::VersionOnly | Loader::Full, _) => {
panic!("malformed TOML in a tool config is a parse error, got {error:?}");
}
}
}
Scenario::MissingExplicitConfig => {
let explicit = dir.child("missing.toml");
let error = load(loader, &dir, &graph, Some(explicit.as_path()), &[]).unwrap_err();
assert_eq!(error.config_file(), explicit.as_path());
assert_eq!(error.tool(), None);
let ConfigParseErrorKind::ReadError(_) = error.kind() else {
panic!("a missing explicit config file is a read error, got {error:?}");
};
}
}
}
#[test_case(Loader::VersionOnly; "version only")]
#[test_case(Loader::Full; "full")]
fn duplicate_tool_config_files_are_rejected(loader: Loader) {
let dir = tempdir().unwrap();
let graph = workspace_without_repo_config(&dir);
let tool_config_files = [
ToolConfigFile {
tool: tool_name("my-tool"),
config_file: dir.child("first.toml").to_path_buf(),
},
ToolConfigFile {
tool: tool_name("other-tool"),
config_file: dir.child("other.toml").to_path_buf(),
},
ToolConfigFile {
tool: tool_name("my-tool"),
config_file: dir.child("second.toml").to_path_buf(),
},
];
let error = load(loader, &dir, &graph, None, &tool_config_files).unwrap_err();
assert_eq!(error.config_file(), tool_config_files[2].config_file);
assert_eq!(error.tool(), Some(&tool_name("my-tool")));
let ConfigParseErrorKind::DuplicateToolConfigFile { tool, first } = error.kind() else {
panic!("a second config file for the same tool is rejected, got {error:?}");
};
assert_eq!(tool, &tool_name("my-tool"));
assert_eq!(first.absolute_path(), tool_config_files[0].config_file);
}
}