1use super::ToolName;
5use crate::errors::ToolConfigFileParseError;
6use camino::{Utf8Path, Utf8PathBuf};
7use std::str::FromStr;
8
9#[derive(Clone, Debug, Eq, PartialEq)]
14pub struct ToolConfigFile {
15 pub tool: ToolName,
17
18 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 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 (
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 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}