Skip to main content

cargo_reclaim/scanner/
cargo_config.rs

1use std::collections::HashSet;
2use std::env;
3use std::path::{Component, Path, PathBuf};
4
5use crate::error::ReclaimResult;
6
7use super::foundation::{CargoOutputKind, TargetDirOverride};
8
9const CONFIG_DIR: &str = ".cargo";
10const EXTENSIONLESS_CONFIG: &str = "config";
11const TOML_CONFIG: &str = "config.toml";
12const WORKSPACE_ROOT_TEMPLATE: &str = "{workspace-root}";
13const CARGO_CACHE_HOME_TEMPLATE: &str = "{cargo-cache-home}";
14const WORKSPACE_PATH_HASH_TEMPLATE: &str = "{workspace-path-hash}";
15
16#[derive(Debug, Clone, PartialEq, Eq, Default)]
17pub struct CargoOutputDirs {
18    pub dirs: Vec<TargetDirOverride>,
19    pub unsupported: Vec<CargoConfigUnsupported>,
20    pub problems: Vec<CargoConfigProblem>,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct CargoConfigUnsupported {
25    pub source: String,
26    pub reason: CargoConfigUnsupportedReason,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum CargoConfigUnsupportedReason {
31    WorkspacePathHashTemplate,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct CargoConfigProblem {
36    pub path: PathBuf,
37    pub message: String,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq, Default)]
41struct CargoBuildConfig {
42    target_dir: Option<ConfigPathValue>,
43    build_dir: Option<ConfigPathValue>,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
47struct ConfigPathValue {
48    path: PathBuf,
49    source: String,
50    relative_base: PathBuf,
51    supports_templates: bool,
52}
53
54pub fn resolve_project_output_dirs(project_root: &Path) -> ReclaimResult<CargoOutputDirs> {
55    resolve_project_output_dirs_with_env(project_root, env::vars_os())
56}
57
58pub fn resolve_project_output_dirs_with_env<I, K, V>(
59    project_root: &Path,
60    env_vars: I,
61) -> ReclaimResult<CargoOutputDirs>
62where
63    I: IntoIterator<Item = (K, V)>,
64    K: Into<std::ffi::OsString>,
65    V: Into<std::ffi::OsString>,
66{
67    let env_vars: Vec<(std::ffi::OsString, std::ffi::OsString)> = env_vars
68        .into_iter()
69        .map(|(key, value)| (key.into(), value.into()))
70        .collect();
71    let cargo_home = cargo_home_from_env(project_root, &env_vars);
72    let mut config = CargoBuildConfig::default();
73    let mut problems = Vec::new();
74
75    if let Some(cargo_home) = cargo_home.as_ref()
76        && let Some(path) = selected_config_file(cargo_home)
77    {
78        merge_config_path(path, &mut config, &mut HashSet::new(), &mut problems)?;
79    }
80
81    for ancestor in config_ancestors(project_root) {
82        if let Some(path) = selected_config_file(&ancestor.join(CONFIG_DIR)) {
83            merge_config_path(path, &mut config, &mut HashSet::new(), &mut problems)?;
84        }
85    }
86
87    apply_env_overrides(project_root, &env_vars, &mut config);
88    let mut output = build_output_dirs(project_root, cargo_home.as_deref(), config)?;
89    output.problems = problems;
90    Ok(output)
91}
92
93fn selected_config_file(cargo_dir: &Path) -> Option<PathBuf> {
94    let extensionless = cargo_dir.join(EXTENSIONLESS_CONFIG);
95    if extensionless.is_file() {
96        return Some(extensionless);
97    }
98
99    let toml = cargo_dir.join(TOML_CONFIG);
100    toml.is_file().then_some(toml)
101}
102
103fn merge_config_path(
104    path: PathBuf,
105    config: &mut CargoBuildConfig,
106    visited: &mut HashSet<PathBuf>,
107    problems: &mut Vec<CargoConfigProblem>,
108) -> ReclaimResult<()> {
109    if !path.is_file() || !visited.insert(lexically_normalize(&path)) {
110        return Ok(());
111    }
112
113    let contents = match std::fs::read_to_string(&path) {
114        Ok(contents) => contents,
115        Err(error) => {
116            problems.push(CargoConfigProblem {
117                path,
118                message: error.to_string(),
119            });
120            return Ok(());
121        }
122    };
123    let value = match toml::from_str::<toml::Value>(&contents) {
124        Ok(value) => value,
125        Err(error) => {
126            problems.push(CargoConfigProblem {
127                path,
128                message: error.to_string(),
129            });
130            return Ok(());
131        }
132    };
133
134    for include in include_paths(&value, &path) {
135        if include.optional && !include.path.is_file() {
136            continue;
137        }
138        if !include.optional && !include.path.is_file() {
139            problems.push(CargoConfigProblem {
140                path: include.path,
141                message: "included Cargo config does not exist".to_string(),
142            });
143            continue;
144        }
145        merge_config_path(include.path, config, visited, problems)?;
146    }
147
148    merge_build_config(&value, &path, config);
149    Ok(())
150}
151
152fn include_paths(value: &toml::Value, config_path: &Path) -> Vec<ConfigInclude> {
153    let Some(include) = value.get("include") else {
154        return Vec::new();
155    };
156
157    let mut includes = Vec::new();
158    match include {
159        toml::Value::String(path) => {
160            includes.push(ConfigInclude::new(
161                resolve_include_path(config_path, path),
162                false,
163            ));
164        }
165        toml::Value::Array(values) => {
166            for value in values {
167                match value {
168                    toml::Value::String(path) => includes.push(ConfigInclude::new(
169                        resolve_include_path(config_path, path),
170                        false,
171                    )),
172                    toml::Value::Table(table) => {
173                        if let Some(toml::Value::String(path)) = table.get("path") {
174                            let optional = table
175                                .get("optional")
176                                .and_then(toml::Value::as_bool)
177                                .unwrap_or(false);
178                            includes.push(ConfigInclude::new(
179                                resolve_include_path(config_path, path),
180                                optional,
181                            ));
182                        }
183                    }
184                    _ => {}
185                }
186            }
187        }
188        toml::Value::Table(table) => {
189            if let Some(toml::Value::String(path)) = table.get("path") {
190                let optional = table
191                    .get("optional")
192                    .and_then(toml::Value::as_bool)
193                    .unwrap_or(false);
194                includes.push(ConfigInclude::new(
195                    resolve_include_path(config_path, path),
196                    optional,
197                ));
198            }
199        }
200        _ => {}
201    }
202
203    includes
204}
205
206#[derive(Debug, Clone, PartialEq, Eq)]
207struct ConfigInclude {
208    path: PathBuf,
209    optional: bool,
210}
211
212impl ConfigInclude {
213    fn new(path: PathBuf, optional: bool) -> Self {
214        Self { path, optional }
215    }
216}
217
218fn resolve_include_path(config_path: &Path, include: &str) -> PathBuf {
219    let path = PathBuf::from(include);
220    if path.is_absolute() {
221        path
222    } else {
223        lexically_normalize(
224            config_path
225                .parent()
226                .unwrap_or_else(|| Path::new(""))
227                .join(path),
228        )
229    }
230}
231
232fn merge_build_config(value: &toml::Value, config_path: &Path, config: &mut CargoBuildConfig) {
233    let Some(build) = value.get("build").and_then(toml::Value::as_table) else {
234        return;
235    };
236    let relative_base = config_relative_base(config_path);
237
238    if let Some(toml::Value::String(path)) = build.get("target-dir") {
239        config.target_dir = Some(ConfigPathValue {
240            path: PathBuf::from(path),
241            source: format!("Cargo config build.target-dir ({})", config_path.display()),
242            relative_base: relative_base.clone(),
243            supports_templates: false,
244        });
245    }
246
247    if let Some(toml::Value::String(path)) = build.get("build-dir") {
248        config.build_dir = Some(ConfigPathValue {
249            path: PathBuf::from(path),
250            source: format!("Cargo config build.build-dir ({})", config_path.display()),
251            relative_base,
252            supports_templates: true,
253        });
254    }
255}
256
257fn config_relative_base(config_path: &Path) -> PathBuf {
258    config_path
259        .parent()
260        .and_then(Path::parent)
261        .map(Path::to_path_buf)
262        .unwrap_or_else(|| PathBuf::from("."))
263}
264
265fn apply_env_overrides(
266    project_root: &Path,
267    env_vars: &[(std::ffi::OsString, std::ffi::OsString)],
268    config: &mut CargoBuildConfig,
269) {
270    let direct_target_dir = env_value(env_vars, "CARGO_BUILD_TARGET_DIR");
271    let legacy_target_dir = env_value(env_vars, "CARGO_TARGET_DIR");
272    if let Some((name, value)) = direct_target_dir
273        .map(|value| ("CARGO_BUILD_TARGET_DIR", value))
274        .or_else(|| legacy_target_dir.map(|value| ("CARGO_TARGET_DIR", value)))
275    {
276        config.target_dir = Some(ConfigPathValue {
277            path: PathBuf::from(value),
278            source: name.to_string(),
279            relative_base: project_root.to_path_buf(),
280            supports_templates: false,
281        });
282    }
283
284    if let Some(value) = env_value(env_vars, "CARGO_BUILD_BUILD_DIR") {
285        config.build_dir = Some(ConfigPathValue {
286            path: PathBuf::from(value),
287            source: "CARGO_BUILD_BUILD_DIR".to_string(),
288            relative_base: project_root.to_path_buf(),
289            supports_templates: true,
290        });
291    }
292}
293
294fn env_value<'a>(
295    env_vars: &'a [(std::ffi::OsString, std::ffi::OsString)],
296    name: &str,
297) -> Option<&'a std::ffi::OsStr> {
298    env_vars
299        .iter()
300        .find(|(key, _)| key == name)
301        .map(|(_, value)| value.as_os_str())
302}
303
304fn build_output_dirs(
305    project_root: &Path,
306    cargo_home: Option<&Path>,
307    config: CargoBuildConfig,
308) -> ReclaimResult<CargoOutputDirs> {
309    let target_is_configured = config.target_dir.is_some();
310    let target = config.target_dir.unwrap_or_else(|| ConfigPathValue {
311        path: PathBuf::from("target"),
312        source: format!("Project default target-dir ({})", project_root.display()),
313        relative_base: project_root.to_path_buf(),
314        supports_templates: false,
315    });
316
317    let mut output = CargoOutputDirs::default();
318    let target_path = resolve_config_path(&target, project_root, cargo_home, &mut output);
319    if target_is_configured && let Some(path) = target_path.as_ref() {
320        output.dirs.push(TargetDirOverride::with_kind(
321            path,
322            target.source.clone(),
323            CargoOutputKind::TargetDir,
324        )?);
325    }
326
327    if let Some(build_dir) = config.build_dir {
328        let build_path = resolve_config_path(&build_dir, project_root, cargo_home, &mut output);
329        if let Some(path) = build_path
330            && target_path.as_ref().is_none_or(|target_path| {
331                lexically_normalize(target_path) != lexically_normalize(&path)
332            })
333        {
334            output.dirs.push(TargetDirOverride::with_kind(
335                path,
336                build_dir.source,
337                CargoOutputKind::BuildDir,
338            )?);
339        }
340    }
341
342    Ok(output)
343}
344
345fn resolve_config_path(
346    value: &ConfigPathValue,
347    project_root: &Path,
348    cargo_home: Option<&Path>,
349    output: &mut CargoOutputDirs,
350) -> Option<PathBuf> {
351    let path_text = value.path.to_string_lossy();
352    if path_text.contains(WORKSPACE_PATH_HASH_TEMPLATE) {
353        output.unsupported.push(CargoConfigUnsupported {
354            source: value.source.clone(),
355            reason: CargoConfigUnsupportedReason::WorkspacePathHashTemplate,
356        });
357        return None;
358    }
359
360    let mut resolved = path_text.to_string();
361    if value.supports_templates {
362        resolved = resolved.replace(WORKSPACE_ROOT_TEMPLATE, &project_root.to_string_lossy());
363        if let Some(cargo_home) = cargo_home {
364            resolved = resolved.replace(CARGO_CACHE_HOME_TEMPLATE, &cargo_home.to_string_lossy());
365        }
366    }
367
368    let path = PathBuf::from(resolved);
369    Some(if path.is_absolute() {
370        lexically_normalize(path)
371    } else {
372        lexically_normalize(value.relative_base.join(path))
373    })
374}
375
376fn config_ancestors(project_root: &Path) -> Vec<PathBuf> {
377    let mut ancestors: Vec<PathBuf> = project_root.ancestors().map(Path::to_path_buf).collect();
378    ancestors.reverse();
379    ancestors
380}
381
382fn cargo_home_from_env(
383    project_root: &Path,
384    env_vars: &[(std::ffi::OsString, std::ffi::OsString)],
385) -> Option<PathBuf> {
386    env_value(env_vars, "CARGO_HOME")
387        .map(PathBuf::from)
388        .or_else(|| env_value(env_vars, "HOME").map(|home| PathBuf::from(home).join(CONFIG_DIR)))
389        .map(|path| {
390            if path.is_absolute() {
391                lexically_normalize(path)
392            } else {
393                lexically_normalize(project_root.join(path))
394            }
395        })
396}
397
398fn lexically_normalize(path: impl AsRef<Path>) -> PathBuf {
399    let path = path.as_ref();
400    let mut normalized = PathBuf::new();
401
402    for component in path.components() {
403        match component {
404            Component::CurDir => {}
405            Component::ParentDir => {
406                if !normalized.pop() {
407                    normalized.push(component.as_os_str());
408                }
409            }
410            _ => normalized.push(component.as_os_str()),
411        }
412    }
413
414    normalized
415}