nextest_runner/cargo_config/
discovery.rs1use crate::errors::{CargoConfigError, CargoConfigParseError, InvalidCargoCliConfigReason};
5use camino::{Utf8Path, Utf8PathBuf};
6use serde::Deserialize;
7use std::collections::BTreeMap;
8use toml_edit::Item;
9use tracing::debug;
10
11#[derive(Clone, Debug, Eq, PartialEq)]
16pub enum CargoConfigSource {
17 CliOption,
19
20 File(Utf8PathBuf),
22}
23
24impl CargoConfigSource {
25 pub(crate) fn resolve_dir<'a>(&'a self, cwd: &'a Utf8Path) -> &'a Utf8Path {
27 match self {
28 CargoConfigSource::CliOption => {
29 cwd
31 }
32 CargoConfigSource::File(file) => {
33 file.parent()
35 .expect("got to .cargo")
36 .parent()
37 .expect("got to cwd")
38 }
39 }
40 }
41}
42
43#[derive(Debug)]
48pub struct CargoConfigs {
49 cli_configs: Vec<(CargoConfigSource, CargoConfig)>,
50 cwd: Utf8PathBuf,
51 discovered: Vec<(CargoConfigSource, CargoConfig)>,
52 target_paths: Vec<Utf8PathBuf>,
53}
54
55impl CargoConfigs {
56 pub fn new(
58 cli_configs: impl IntoIterator<Item = impl AsRef<str>>,
59 ) -> Result<Self, CargoConfigError> {
60 let cwd = std::env::current_dir()
61 .map_err(CargoConfigError::GetCurrentDir)
62 .and_then(|cwd| {
63 Utf8PathBuf::try_from(cwd).map_err(CargoConfigError::CurrentDirInvalidUtf8)
64 })?;
65 let cli_configs = parse_cli_configs(&cwd, cli_configs.into_iter())?;
66 let discovered = discover_impl(&cwd, None)?;
67
68 let mut target_paths = Vec::new();
70 let target_path_env = std::env::var_os("RUST_TARGET_PATH").unwrap_or_default();
71 for path in std::env::split_paths(&target_path_env) {
72 match Utf8PathBuf::try_from(path) {
73 Ok(path) => target_paths.push(path),
74 Err(error) => {
75 debug!("for RUST_TARGET_PATH, {error}");
76 }
77 }
78 }
79
80 Ok(Self {
81 cli_configs,
82 cwd,
83 discovered,
84 target_paths,
85 })
86 }
87
88 #[doc(hidden)]
92 pub fn new_with_isolation(
93 cli_configs: impl IntoIterator<Item = impl AsRef<str>>,
94 cwd: &Utf8Path,
95 terminate_search_at: &Utf8Path,
96 target_paths: Vec<Utf8PathBuf>,
97 ) -> Result<Self, CargoConfigError> {
98 let cli_configs = parse_cli_configs(cwd, cli_configs.into_iter())?;
99 let discovered = discover_impl(cwd, Some(terminate_search_at))?;
100
101 Ok(Self {
102 cli_configs,
103 cwd: cwd.to_owned(),
104 discovered,
105 target_paths,
106 })
107 }
108
109 pub(crate) fn cwd(&self) -> &Utf8Path {
110 &self.cwd
111 }
112
113 pub(crate) fn discovered_configs(
114 &self,
115 ) -> impl DoubleEndedIterator<Item = DiscoveredConfig<'_>> + '_ {
116 let cli_option_iter = self
125 .cli_configs
126 .iter()
127 .filter(|(source, _)| matches!(source, CargoConfigSource::CliOption))
128 .map(|(source, config)| DiscoveredConfig::CliOption { config, source });
129
130 let cli_file_iter = self
131 .cli_configs
132 .iter()
133 .filter(|(source, _)| matches!(source, CargoConfigSource::File(_)))
134 .map(|(source, config)| DiscoveredConfig::File { config, source });
135
136 let cargo_config_file_iter = self
137 .discovered
138 .iter()
139 .map(|(source, config)| DiscoveredConfig::File { config, source });
140
141 cli_option_iter
142 .chain(cli_file_iter)
143 .chain(std::iter::once(DiscoveredConfig::Env))
144 .chain(cargo_config_file_iter)
145 }
146
147 pub(crate) fn target_paths(&self) -> &[Utf8PathBuf] {
148 &self.target_paths
149 }
150}
151
152pub(crate) enum DiscoveredConfig<'a> {
153 CliOption {
154 config: &'a CargoConfig,
155 source: &'a CargoConfigSource,
156 },
157 Env,
159 File {
160 config: &'a CargoConfig,
161 source: &'a CargoConfigSource,
162 },
163}
164
165fn parse_cli_configs(
166 cwd: &Utf8Path,
167 cli_configs: impl Iterator<Item = impl AsRef<str>>,
168) -> Result<Vec<(CargoConfigSource, CargoConfig)>, CargoConfigError> {
169 cli_configs
170 .into_iter()
171 .map(|config_str| {
172 let config_str = config_str.as_ref();
174
175 let as_path = cwd.join(config_str);
176 if as_path.exists() {
177 load_file(as_path)
179 } else {
180 let config = parse_cli_config(config_str)?;
181 Ok((CargoConfigSource::CliOption, config))
182 }
183 })
184 .collect()
185}
186
187fn parse_cli_config(config_str: &str) -> Result<CargoConfig, CargoConfigError> {
188 let doc: toml_edit::DocumentMut =
196 config_str
197 .parse()
198 .map_err(|error| CargoConfigError::CliConfigParseError {
199 config_str: config_str.to_owned(),
200 error,
201 })?;
202
203 fn non_empty(d: Option<&toml_edit::RawString>) -> bool {
204 d.is_some_and(|p| !p.as_str().unwrap_or_default().trim().is_empty())
205 }
206 fn non_empty_decor(d: &toml_edit::Decor) -> bool {
207 non_empty(d.prefix()) || non_empty(d.suffix())
208 }
209 fn non_empty_key_decor(k: &toml_edit::Key) -> bool {
210 non_empty_decor(k.leaf_decor()) || non_empty_decor(k.dotted_decor())
211 }
212
213 let ok = {
214 let mut got_to_value = false;
215 let mut table = doc.as_table();
216 let mut is_root = true;
217 while table.is_dotted() || is_root {
218 is_root = false;
219 if table.len() != 1 {
220 break;
221 }
222 let (k, n) = table.iter().next().expect("len() == 1 above");
223 match n {
224 Item::Table(nt) => {
225 if table.key(k).is_some_and(non_empty_key_decor) || non_empty_decor(nt.decor())
226 {
227 return Err(CargoConfigError::InvalidCliConfig {
228 config_str: config_str.to_owned(),
229 reason: InvalidCargoCliConfigReason::IncludesNonWhitespaceDecoration,
230 });
231 }
232 table = nt;
233 }
234 Item::Value(v) if v.is_inline_table() => {
235 return Err(CargoConfigError::InvalidCliConfig {
236 config_str: config_str.to_owned(),
237 reason: InvalidCargoCliConfigReason::SetsValueToInlineTable,
238 });
239 }
240 Item::Value(v) => {
241 if table
242 .key(k)
243 .is_some_and(|k| non_empty(k.leaf_decor().prefix()))
244 || non_empty_decor(v.decor())
245 {
246 return Err(CargoConfigError::InvalidCliConfig {
247 config_str: config_str.to_owned(),
248 reason: InvalidCargoCliConfigReason::IncludesNonWhitespaceDecoration,
249 });
250 }
251 got_to_value = true;
252 break;
253 }
254 Item::ArrayOfTables(_) => {
255 return Err(CargoConfigError::InvalidCliConfig {
256 config_str: config_str.to_owned(),
257 reason: InvalidCargoCliConfigReason::SetsValueToArrayOfTables,
258 });
259 }
260 Item::None => {
261 return Err(CargoConfigError::InvalidCliConfig {
262 config_str: config_str.to_owned(),
263 reason: InvalidCargoCliConfigReason::DoesntProvideValue,
264 });
265 }
266 }
267 }
268 got_to_value
269 };
270 if !ok {
271 return Err(CargoConfigError::InvalidCliConfig {
272 config_str: config_str.to_owned(),
273 reason: InvalidCargoCliConfigReason::NotDottedKv,
274 });
275 }
276
277 let cargo_config: CargoConfig =
278 toml_edit::de::from_document(doc).map_err(|error| CargoConfigError::CliConfigDeError {
279 config_str: config_str.to_owned(),
280 error,
281 })?;
282
283 Ok(cargo_config)
288}
289
290fn discover_impl(
291 start_search_at: &Utf8Path,
292 terminate_search_at: Option<&Utf8Path>,
293) -> Result<Vec<(CargoConfigSource, CargoConfig)>, CargoConfigError> {
294 fn read_config_dir(dir: &mut Utf8PathBuf) -> Option<Utf8PathBuf> {
295 dir.push("config");
297
298 if !dir.exists() {
299 dir.set_extension("toml");
300 }
301
302 let ret = if dir.exists() {
303 Some(dir.clone())
304 } else {
305 None
306 };
307
308 dir.pop();
309 ret
310 }
311
312 let mut dir = start_search_at.canonicalize_utf8().map_err(|error| {
313 CargoConfigError::FailedPathCanonicalization {
314 path: start_search_at.to_owned(),
315 error,
316 }
317 })?;
318
319 let mut config_paths = Vec::new();
320
321 for _ in 0..dir.ancestors().count() {
322 dir.push(".cargo");
323
324 if !dir.exists() {
325 dir.pop();
326 dir.pop();
327 continue;
328 }
329
330 if let Some(path) = read_config_dir(&mut dir) {
331 config_paths.push(path);
332 }
333
334 dir.pop();
335 if Some(dir.as_path()) == terminate_search_at {
336 break;
337 }
338 dir.pop();
339 }
340
341 if terminate_search_at.is_none() {
342 let mut cargo_home_path = home::cargo_home_with_cwd(start_search_at.as_std_path())
345 .map_err(CargoConfigError::GetCargoHome)
346 .and_then(|home| Utf8PathBuf::try_from(home).map_err(CargoConfigError::NonUtf8Path))?;
347
348 if let Some(home_config) = read_config_dir(&mut cargo_home_path) {
349 if !config_paths.iter().any(|path| path == &home_config) {
352 config_paths.push(home_config);
353 }
354 }
355 }
356
357 let configs = config_paths
358 .into_iter()
359 .map(load_file)
360 .collect::<Result<Vec<_>, CargoConfigError>>()?;
361
362 Ok(configs)
363}
364
365fn load_file(
366 path: impl Into<Utf8PathBuf>,
367) -> Result<(CargoConfigSource, CargoConfig), CargoConfigError> {
368 let path = path.into();
369 let path = path
370 .canonicalize_utf8()
371 .map_err(|error| CargoConfigError::FailedPathCanonicalization { path, error })?;
372
373 let config_contents =
374 std::fs::read_to_string(&path).map_err(|error| CargoConfigError::ConfigReadError {
375 path: path.clone(),
376 error,
377 })?;
378 let config: CargoConfig = toml::from_str(&config_contents).map_err(|error| {
379 CargoConfigError::from(Box::new(CargoConfigParseError {
380 path: path.clone(),
381 error,
382 }))
383 })?;
384 Ok((CargoConfigSource::File(path), config))
385}
386
387#[derive(Clone, Deserialize, Debug)]
388#[serde(untagged)]
389pub(crate) enum CargoConfigEnv {
390 Value(String),
391 Fields {
392 value: String,
393 force: Option<bool>,
394 relative: Option<bool>,
395 },
396}
397
398impl CargoConfigEnv {
399 pub(super) fn into_value(self) -> String {
400 match self {
401 Self::Value(v) => v,
402 Self::Fields { value, .. } => value,
403 }
404 }
405
406 pub(super) fn force(&self) -> Option<bool> {
407 match self {
408 Self::Value(_) => None,
409 Self::Fields { force, .. } => *force,
410 }
411 }
412
413 pub(super) fn relative(&self) -> Option<bool> {
414 match self {
415 Self::Value(_) => None,
416 Self::Fields { relative, .. } => *relative,
417 }
418 }
419}
420
421#[derive(Deserialize, Debug)]
422pub(crate) struct CargoConfig {
423 #[serde(default)]
424 pub(crate) build: CargoConfigBuild,
425 pub(crate) target: Option<BTreeMap<String, CargoConfigRunner>>,
426 #[serde(default)]
427 pub(crate) env: BTreeMap<String, CargoConfigEnv>,
428}
429
430#[derive(Deserialize, Default, Debug)]
431pub(crate) struct CargoConfigBuild {
432 pub(crate) target: Option<String>,
433}
434
435#[derive(Deserialize, Debug)]
436pub(crate) struct CargoConfigRunner {
437 #[serde(default)]
438 pub(crate) runner: Option<Runner>,
439}
440
441#[derive(Clone, Deserialize, Debug, Eq, PartialEq)]
442#[serde(untagged)]
443pub(crate) enum Runner {
444 Simple(String),
445 List(Vec<String>),
446}
447
448#[cfg(test)]
449mod tests {
450 use super::*;
451 use test_case::test_case;
452
453 #[test]
454 fn test_cli_kv_accepted() {
455 let config = parse_cli_config("build.target=\"aarch64-unknown-linux-gnu\"")
457 .expect("dotted config should parse correctly");
458 assert_eq!(
459 config.build.target.as_deref(),
460 Some("aarch64-unknown-linux-gnu")
461 );
462
463 let config = parse_cli_config(" target.\"aarch64-unknown-linux-gnu\".runner = 'test' ")
464 .expect("dotted config should parse correctly");
465 assert_eq!(
466 config.target.as_ref().unwrap()["aarch64-unknown-linux-gnu"].runner,
467 Some(Runner::Simple("test".to_owned()))
468 );
469
470 let _ = parse_cli_config("[a] foo=true").unwrap_err();
472 let _ = parse_cli_config("a = true\nb = true").unwrap_err();
473
474 let _ = parse_cli_config("a = { first = true, second = false }").unwrap_err();
476 let _ = parse_cli_config("a = { first = true }").unwrap_err();
477 }
478
479 #[test_case(
480 "",
481 InvalidCargoCliConfigReason::NotDottedKv
482
483 ; "empty input")]
484 #[test_case(
485 "a.b={c = \"d\"}",
486 InvalidCargoCliConfigReason::SetsValueToInlineTable
487
488 ; "no inline table value")]
489 #[test_case(
490 "[[a.b]]\nc = \"d\"",
491 InvalidCargoCliConfigReason::NotDottedKv
492
493 ; "no array of tables")]
494 #[test_case(
495 "a.b = \"c\" # exactly",
496 InvalidCargoCliConfigReason::IncludesNonWhitespaceDecoration
497
498 ; "no comments after")]
499 #[test_case(
500 "# exactly\na.b = \"c\"",
501 InvalidCargoCliConfigReason::IncludesNonWhitespaceDecoration
502
503 ; "no comments before")]
504 fn test_invalid_cli_config_reason(arg: &str, expected_reason: InvalidCargoCliConfigReason) {
505 let err = parse_cli_config(arg).unwrap_err();
507 let actual_reason = match err {
508 CargoConfigError::InvalidCliConfig { reason, .. } => reason,
509 other => panic!(
510 "expected input {arg} to fail with InvalidCliConfig, actual failure: {other}"
511 ),
512 };
513
514 assert_eq!(
515 expected_reason, actual_reason,
516 "expected reason for failure doesn't match actual reason"
517 );
518 }
519}