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}