nextest_runner/cargo_config/
discovery.rs

1// Copyright (c) The nextest Contributors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use 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/// The source of a Cargo config.
12///
13/// A Cargo config can be specified as a CLI option (unstable) or a `.cargo/config.toml` file on
14/// disk.
15#[derive(Clone, Debug, Eq, PartialEq)]
16pub enum CargoConfigSource {
17    /// A Cargo config provided as a CLI option.
18    CliOption,
19
20    /// A Cargo config provided as a file on disk.
21    File(Utf8PathBuf),
22}
23
24impl CargoConfigSource {
25    /// Returns the directory against which relative paths should be resolved.
26    pub(crate) fn resolve_dir<'a>(&'a self, cwd: &'a Utf8Path) -> &'a Utf8Path {
27        match self {
28            CargoConfigSource::CliOption => {
29                // Use the cwd as specified.
30                cwd
31            }
32            CargoConfigSource::File(file) => {
33                // The file is e.g. .cargo/config.toml -- go up two levels.
34                file.parent()
35                    .expect("got to .cargo")
36                    .parent()
37                    .expect("got to cwd")
38            }
39        }
40    }
41}
42
43/// A store for Cargo config files discovered from disk.
44///
45/// This is required by [`TargetRunner`](crate::target_runner::TargetRunner) and for target triple
46/// discovery.
47#[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    /// Discover Cargo config files using the same algorithm that Cargo uses.
57    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        // Used for target discovery.
69        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    /// Discover Cargo config files with isolation.
89    ///
90    /// Not part of the public API, for testing only.
91    #[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        // NOTE: The order is:
117        // 1. --config k=v
118        // 2. --config <file>
119        // 3. Environment variables
120        // 4. .cargo/configs.
121        //
122        // 2 and 3 used to be reversed in older versions of Rust, but this has been fixed as of Rust
123        // 1.68 (https://github.com/rust-lang/cargo/pull/11077).
124        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    // Sentinel value to indicate to users that they should look up their config in the environment.
158    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            // Each cargo config is expected to be a valid TOML file.
173            let config_str = config_str.as_ref();
174
175            let as_path = cwd.join(config_str);
176            if as_path.exists() {
177                // Read this config as a file.
178                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    // This implementation is copied over from https://github.com/rust-lang/cargo/pull/10176.
189
190    // We only want to allow "dotted key" (see https://toml.io/en/v1.0.0#keys)
191    // expressions followed by a value that's not an "inline table"
192    // (https://toml.io/en/v1.0.0#inline-table). Easiest way to check for that is to
193    // parse the value as a toml_edit::DocumentMut, and check that the (single)
194    // inner-most table is set via dotted keys.
195    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    // Note: environment variables parsed from CLI configs can't be relative. However, this isn't
284    // necessary to check because the only way to specify that is as an inline table, which is
285    // rejected above.
286
287    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        // Check for config before config.toml, same as cargo does
296        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        // Attempt lookup the $CARGO_HOME directory from the cwd, as that can
343        // contain a default config.toml
344        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            // Ensure we don't add a duplicate if the current directory is underneath
350            // the same root as $CARGO_HOME
351            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        // These dotted key expressions should all be fine.
456        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        // But anything that's not a dotted key expression should be disallowed.
471        let _ = parse_cli_config("[a] foo=true").unwrap_err();
472        let _ = parse_cli_config("a = true\nb = true").unwrap_err();
473
474        // We also disallow overwriting with tables since it makes merging unclear.
475        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        // Disallow inline tables
506        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}