Skip to content

Commit a77c393

Browse files
committed
[nextest-runner] add a --tool-config-file option
Add an option for tools that integrate with nextest to specify configs that are lower than .config/nextest.toml in priority, but higher than the default config.
1 parent 88bc403 commit a77c393

File tree

6 files changed

+246
-24
lines changed

6 files changed

+246
-24
lines changed

cargo-nextest/src/dispatch.rs

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ use nextest_filtering::FilteringExpr;
1515
use nextest_metadata::{BinaryListSummary, BuildPlatform};
1616
use nextest_runner::{
1717
cargo_config::{CargoConfigs, TargetTriple},
18-
config::{NextestConfig, NextestProfile, TestThreads},
18+
config::{NextestConfig, NextestProfile, TestThreads, ToolConfigFile},
1919
errors::WriteTestListError,
2020
list::{BinaryList, OutputFormat, RustTestArtifact, SerializableFormat, TestList},
2121
partition::PartitionerBuilder,
@@ -164,10 +164,27 @@ impl AppOpts {
164164
}
165165

166166
#[derive(Debug, Args)]
167+
#[clap(next_help_heading = "CONFIG OPTIONS")]
167168
struct ConfigOpts {
168169
/// Config file [default: workspace-root/.config/nextest.toml]
169170
#[clap(long, global = true, value_name = "PATH")]
170171
pub config_file: Option<Utf8PathBuf>,
172+
173+
/// Tool-specific config files
174+
///
175+
/// Some tools on top of nextest may want to set up their own default configuration but
176+
/// prioritize user configuration on top. Use this argument to insert configuration
177+
/// that's lower than --config-file in priority but above the default config shipped with
178+
/// nextest.
179+
///
180+
/// Arguments are specified in the format "tool:abs_path", for example
181+
/// "my-tool:/path/to/nextest.toml" (or "my-tool:C:\\path\\to\\nextest.toml" on Windows).
182+
/// Paths must be absolute.
183+
///
184+
/// This argument may be specified multiple times. Files that come later are lower priority
185+
/// than those that come earlier.
186+
#[clap(long = "tool-config-file", global = true, value_name = "TOOL:ABS_PATH")]
187+
pub tool_config_files: Vec<ToolConfigFile>,
171188
}
172189

173190
impl ConfigOpts {
@@ -177,8 +194,13 @@ impl ConfigOpts {
177194
workspace_root: &Utf8Path,
178195
graph: &PackageGraph,
179196
) -> Result<NextestConfig> {
180-
NextestConfig::from_sources(workspace_root, graph, self.config_file.as_deref())
181-
.map_err(ExpectedError::config_parse_error)
197+
NextestConfig::from_sources(
198+
workspace_root,
199+
graph,
200+
self.config_file.as_deref(),
201+
&self.tool_config_files,
202+
)
203+
.map_err(ExpectedError::config_parse_error)
182204
}
183205
}
184206

nextest-runner/src/config.rs

Lines changed: 173 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
use crate::{
77
errors::{
88
ConfigParseError, ConfigParseErrorKind, ConfigParseOverrideError, ProfileNotFound,
9-
TestThreadsParseError,
9+
TestThreadsParseError, ToolConfigFileParseError,
1010
},
1111
reporter::{FinalStatusLevel, StatusLevel, TestOutputDisplay},
1212
};
@@ -54,17 +54,29 @@ impl NextestConfig {
5454
/// Reads the nextest config from the given file, or if not specified from `.config/nextest.toml`
5555
/// in the workspace root.
5656
///
57-
/// If the file isn't specified and the directory doesn't have `.config/nextest.toml`, uses the
57+
/// `tool_config_files` are lower priority than `config_file` but higher priority than the
58+
/// default config. Files in `tool_config_files` that come earlier are higher priority than those
59+
/// that come later.
60+
///
61+
/// If no config files are specified and this file doesn't have `.config/nextest.toml`, uses the
5862
/// default config options.
59-
pub fn from_sources(
63+
pub fn from_sources<'a, I>(
6064
workspace_root: impl Into<Utf8PathBuf>,
6165
graph: &PackageGraph,
6266
config_file: Option<&Utf8Path>,
63-
) -> Result<Self, ConfigParseError> {
67+
tool_config_files: impl IntoIterator<IntoIter = I>,
68+
) -> Result<Self, ConfigParseError>
69+
where
70+
I: Iterator<Item = &'a ToolConfigFile> + DoubleEndedIterator,
71+
{
6472
let workspace_root = workspace_root.into();
65-
let (config_file, config) = Self::read_from_sources(&workspace_root, config_file)?;
73+
let tool_config_files_rev = tool_config_files.into_iter().rev();
74+
let (config_file, config) =
75+
Self::read_from_sources(&workspace_root, config_file, tool_config_files_rev)?;
6676
let inner: NextestConfigImpl =
6777
serde_path_to_error::deserialize(config).map_err(|error| {
78+
// TODO: now that lowpri configs exist, we need better attribution for the exact path at which
79+
// an error occurred.
6880
ConfigParseError::new(
6981
config_file.clone(),
7082
ConfigParseErrorKind::DeserializeError(error),
@@ -109,12 +121,22 @@ impl NextestConfig {
109121
// Helper methods
110122
// ---
111123

112-
fn read_from_sources(
124+
fn read_from_sources<'a>(
113125
workspace_root: &Utf8Path,
114126
file: Option<&Utf8Path>,
127+
tool_config_files_rev: impl Iterator<Item = &'a ToolConfigFile>,
115128
) -> Result<(Utf8PathBuf, Config), ConfigParseError> {
116129
// First, get the default config.
117-
let builder = Self::make_default_config();
130+
let mut builder = Self::make_default_config();
131+
132+
// Next, merge in tool configs.
133+
for ToolConfigFile {
134+
config_file,
135+
tool: _,
136+
} in tool_config_files_rev
137+
{
138+
builder = builder.add_source(File::new(config_file.as_str(), FileFormat::Toml));
139+
}
118140

119141
// Next, merge in the config from the given file.
120142
let (builder, config_path) = match file {
@@ -169,6 +191,54 @@ impl NextestConfig {
169191
}
170192
}
171193

194+
/// A tool-specific config file.
195+
///
196+
/// Tool-specific config files are lower priority than repository configs, but higher priority than
197+
/// the default config shipped with nextest.
198+
#[derive(Clone, Debug, Eq, PartialEq)]
199+
pub struct ToolConfigFile {
200+
/// The name of the tool.
201+
pub tool: String,
202+
203+
/// The path to the config file.
204+
pub config_file: Utf8PathBuf,
205+
}
206+
207+
impl FromStr for ToolConfigFile {
208+
type Err = ToolConfigFileParseError;
209+
210+
fn from_str(input: &str) -> Result<Self, Self::Err> {
211+
match input.split_once(':') {
212+
Some((tool, config_file)) => {
213+
if tool.is_empty() {
214+
Err(ToolConfigFileParseError::EmptyToolName {
215+
input: input.to_owned(),
216+
})
217+
} else if config_file.is_empty() {
218+
Err(ToolConfigFileParseError::EmptyConfigFile {
219+
input: input.to_owned(),
220+
})
221+
} else {
222+
let config_file = Utf8Path::new(config_file);
223+
if config_file.is_absolute() {
224+
Ok(Self {
225+
tool: tool.to_owned(),
226+
config_file: Utf8PathBuf::from(config_file),
227+
})
228+
} else {
229+
Err(ToolConfigFileParseError::ConfigFileNotAbsolute {
230+
config_file: config_file.to_owned(),
231+
})
232+
}
233+
}
234+
}
235+
None => Err(ToolConfigFileParseError::InvalidFormat {
236+
input: input.to_owned(),
237+
}),
238+
}
239+
}
240+
}
241+
172242
/// A configuration profile for nextest. Contains most configuration used by the nextest runner.
173243
///
174244
/// Returned by [`NextestConfig::profile`].
@@ -790,7 +860,7 @@ mod tests {
790860
let graph = temp_workspace(workspace_path, config_contents);
791861

792862
let nextest_config_result =
793-
NextestConfig::from_sources(graph.workspace().root(), &graph, None);
863+
NextestConfig::from_sources(graph.workspace().root(), &graph, None, []);
794864

795865
match expected_default {
796866
Ok(expected_default) => {
@@ -918,7 +988,8 @@ mod tests {
918988
let graph = temp_workspace(workspace_path, config_contents);
919989
let package_id = graph.workspace().iter().next().unwrap().id();
920990

921-
let config = NextestConfig::from_sources(graph.workspace().root(), &graph, None).unwrap();
991+
let config =
992+
NextestConfig::from_sources(graph.workspace().root(), &graph, None, []).unwrap();
922993
let query = TestQuery {
923994
binary_query: BinaryQuery {
924995
package_id,
@@ -939,6 +1010,99 @@ mod tests {
9391010
);
9401011
}
9411012

1013+
#[test]
1014+
fn parse_tool_config_file() {
1015+
cfg_if::cfg_if! {
1016+
if #[cfg(windows)] {
1017+
let valid = ["tool:/foo/bar", "tool:C:\\foo\\bar", "tool:\\\\?\\C:\\foo\\bar"];
1018+
let invalid = ["C:\\foo\\bar", "tool:\\foo\\bar", "tool:", ":/foo/bar"];
1019+
} else {
1020+
let valid = ["tool:/foo/bar"];
1021+
let invalid = ["/foo/bar", "tool:", ":/foo/bar", "tool:foo/bar"];
1022+
}
1023+
}
1024+
1025+
for valid_input in valid {
1026+
valid_input.parse::<ToolConfigFile>().unwrap_or_else(|err| {
1027+
panic!("valid input {valid_input} should parse correctly: {err}")
1028+
});
1029+
}
1030+
1031+
for invalid_input in invalid {
1032+
invalid_input
1033+
.parse::<ToolConfigFile>()
1034+
.expect_err(&format!("invalid input {invalid_input} should error out"));
1035+
}
1036+
}
1037+
1038+
#[test]
1039+
fn lowpri_config() {
1040+
let config_contents = r#"
1041+
[profile.default]
1042+
retries = 3
1043+
"#;
1044+
1045+
let lowpri1_config_contents = r#"
1046+
[profile.default]
1047+
retries = 4
1048+
1049+
[profile.lowpri]
1050+
retries = 12
1051+
"#;
1052+
1053+
let lowpri2_config_contents = r#"
1054+
[profile.default]
1055+
retries = 5
1056+
1057+
[profile.lowpri]
1058+
retries = 16
1059+
1060+
[profile.lowpri2]
1061+
retries = 18
1062+
"#;
1063+
1064+
let workspace_dir = tempdir().unwrap();
1065+
let workspace_path: &Utf8Path = workspace_dir.path().try_into().unwrap();
1066+
1067+
let graph = temp_workspace(workspace_path, config_contents);
1068+
let workspace_root = graph.workspace().root();
1069+
let lowpri1_path = workspace_root.join(".config/lowpri1.toml");
1070+
let lowpri2_path = workspace_root.join(".config/lowpri2.toml");
1071+
std::fs::write(&lowpri1_path, lowpri1_config_contents).unwrap();
1072+
std::fs::write(&lowpri2_path, lowpri2_config_contents).unwrap();
1073+
1074+
let config = NextestConfig::from_sources(
1075+
workspace_root,
1076+
&graph,
1077+
None,
1078+
&[
1079+
ToolConfigFile {
1080+
tool: "lowpri1".to_owned(),
1081+
config_file: lowpri1_path,
1082+
},
1083+
ToolConfigFile {
1084+
tool: "lowpri2".to_owned(),
1085+
config_file: lowpri2_path,
1086+
},
1087+
],
1088+
)
1089+
.expect("parsing config failed");
1090+
1091+
let default_profile = config
1092+
.profile(NextestConfig::DEFAULT_PROFILE)
1093+
.expect("default profile is present");
1094+
// This is present in .config/nextest.toml and is the highest priority
1095+
assert_eq!(default_profile.retries(), 3);
1096+
1097+
let lowpri_profile = config.profile("lowpri").expect("lowpri profile is present");
1098+
assert_eq!(lowpri_profile.retries(), 12);
1099+
1100+
let lowpri2_profile = config
1101+
.profile("lowpri2")
1102+
.expect("lowpri2 profile is present");
1103+
assert_eq!(lowpri2_profile.retries(), 18);
1104+
}
1105+
9421106
fn temp_workspace(temp_dir: &Utf8Path, config_contents: &str) -> PackageGraph {
9431107
Command::new(cargo_path())
9441108
.args(["init", "--lib", "--name=test-package"])

nextest-runner/src/errors.rs

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,13 +136,48 @@ impl StatusLevelParseError {
136136
}
137137
}
138138

139+
/// Error returned while parsing a [`ToolConfigFile`](crate::config::ToolConfigFile) value.
140+
#[derive(Clone, Debug, Error)]
141+
pub enum ToolConfigFileParseError {
142+
#[error(
143+
"tool-config-file has invalid format: {input}\n(hint: tool configs must be in the format <tool-name>:<path>)"
144+
)]
145+
/// The input was not in the format "tool:path".
146+
InvalidFormat {
147+
/// The input that failed to parse.
148+
input: String,
149+
},
150+
151+
/// The tool name was empty.
152+
#[error("tool-config-file has empty tool name: {input}")]
153+
EmptyToolName {
154+
/// The input that failed to parse.
155+
input: String,
156+
},
157+
158+
/// The config file path was empty.
159+
#[error("tool-config-file has empty config file path: {input}")]
160+
EmptyConfigFile {
161+
/// The input that failed to parse.
162+
input: String,
163+
},
164+
165+
/// The config file was not an absolute path.
166+
#[error("tool-config-file is not an absolute path: {config_file}")]
167+
ConfigFileNotAbsolute {
168+
/// The file name that wasn't absolute.
169+
config_file: Utf8PathBuf,
170+
},
171+
}
172+
139173
/// Error returned while parsing a [`TestThreads`](crate::config::TestThreads) value.
140174
#[derive(Clone, Debug, Error)]
141175
#[error(
142-
"unrecognized value for test-threads: {input}\n(expected either an integer or \"num-cpus\")"
176+
"unrecognized value for test-threads: {input}\n(hint: expected either an integer or \"num-cpus\")"
143177
)]
144178
pub struct TestThreadsParseError {
145-
input: String,
179+
/// The input that failed to parse.
180+
pub input: String,
146181
}
147182

148183
impl TestThreadsParseError {

nextest-runner/tests/integration/basic.rs

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,7 @@ fn test_run() -> Result<()> {
8888

8989
let test_filter = TestFilterBuilder::any(RunIgnored::Default);
9090
let test_list = FIXTURE_TARGETS.make_test_list(&test_filter, &TargetRunner::empty());
91-
let config = NextestConfig::from_sources(workspace_root(), &*PACKAGE_GRAPH, None)
92-
.expect("loaded fixture config");
91+
let config = load_config();
9392
let profile = config
9493
.profile(NextestConfig::DEFAULT_PROFILE)
9594
.expect("default config is valid");
@@ -180,8 +179,7 @@ fn test_run_ignored() -> Result<()> {
180179

181180
let test_filter = TestFilterBuilder::any(RunIgnored::IgnoredOnly);
182181
let test_list = FIXTURE_TARGETS.make_test_list(&test_filter, &TargetRunner::empty());
183-
let config = NextestConfig::from_sources(workspace_root(), &*PACKAGE_GRAPH, None)
184-
.expect("loaded fixture config");
182+
let config = load_config();
185183
let profile = config
186184
.profile(NextestConfig::DEFAULT_PROFILE)
187185
.expect("default config is valid");
@@ -368,8 +366,7 @@ fn test_retries(retries: Option<usize>) -> Result<()> {
368366

369367
let test_filter = TestFilterBuilder::any(RunIgnored::Default);
370368
let test_list = FIXTURE_TARGETS.make_test_list(&test_filter, &TargetRunner::empty());
371-
let config = NextestConfig::from_sources(workspace_root(), &*PACKAGE_GRAPH, None)
372-
.expect("loaded fixture config");
369+
let config = load_config();
373370
let profile = config
374371
.profile("with-retries")
375372
.expect("with-retries config is valid");
@@ -507,8 +504,7 @@ fn test_termination() -> Result<()> {
507504
);
508505

509506
let test_list = FIXTURE_TARGETS.make_test_list(&test_filter, &TargetRunner::empty());
510-
let config = NextestConfig::from_sources(workspace_root(), &*PACKAGE_GRAPH, None)
511-
.expect("loaded fixture config");
507+
let config = load_config();
512508
let profile = config
513509
.profile("with-termination")
514510
.expect("with-termination config is valid");

0 commit comments

Comments
 (0)