1use anyhow::{Context, Result};
9use std::fs;
10use std::path::{Path, PathBuf};
11
12use crate::paths::{self, DEFAULT_BUILD_DIR};
13
14pub fn cache_root() -> Result<PathBuf> {
19 if let Some(dir) = paths::cargo_metadata_build_dir() {
20 return Ok(paths::normalize_host_separators(dir));
21 }
22 let value = cargo_configured("build-dir")?
23 .unwrap_or_else(|| DEFAULT_BUILD_DIR.to_string());
24 paths::resolve_template(&value)
25}
26
27pub fn cargo_config_path() -> Result<PathBuf> {
30 Ok(paths::cargo_home()?.join("config.toml"))
31}
32
33#[derive(Debug, PartialEq, Eq)]
35pub enum CargoEdit {
36 Created,
38 Inserted,
40 Replaced,
42 Removed,
44 AlreadySet,
46 Appended,
48}
49
50impl CargoEdit {
51 pub fn describe(&self) -> &'static str {
52 match self {
53 CargoEdit::Created => "created the file",
54 CargoEdit::Inserted => "added",
55 CargoEdit::Replaced => "updated",
56 CargoEdit::Removed => "removed",
57 CargoEdit::AlreadySet => "already set, unchanged",
58 CargoEdit::Appended => "appended line at the EOF",
59 }
60 }
61}
62
63fn set_build_key(text: &str, key: &str, value: &str) -> (String, CargoEdit) {
66 let wanted = format!("{key} = '{value}'");
67 let mut lines: Vec<String> = text.lines().map(str::to_string).collect();
68
69 let header = lines
72 .iter()
73 .position(|l| l.trim().replace([' ', '\t'], "") == "[build]");
74
75 match header {
76 Some(i) => {
77 let mut j = i + 1;
79 while j < lines.len() {
80 let t = lines[j].trim();
81 if t.starts_with('[') && t.ends_with(']') {
82 break;
83 }
84 if t.split_once('=').map_or(false, |(k, _)| k.trim() == key) {
85 if t == wanted {
86 return (text.to_string(), CargoEdit::AlreadySet);
87 }
88 lines[j] = wanted;
89 return (lines.join("\n") + "\n", CargoEdit::Replaced);
90 }
91 j += 1;
92 }
93 lines.insert(i + 1, wanted);
94 (lines.join("\n") + "\n", CargoEdit::Inserted)
95 }
96 None => {
97 let needs_blank = lines.last().map_or(false, |l| !l.trim().is_empty());
98 if needs_blank {
99 lines.push(String::new());
100 }
101 lines.push("[build]".to_string());
102 lines.push(wanted);
103 (lines.join("\n") + "\n", CargoEdit::Appended)
104 }
105 }
106}
107
108fn remove_build_key(text: &str, key: &str) -> (String, CargoEdit) {
110 if text.contains("[build]") && text.contains(key) {
111 let mut lines: Vec<String> = text.lines().map(str::to_string).collect();
112 lines.retain(|line| !line.contains(key));
113 (lines.join("\n") + "\n", CargoEdit::Removed)
114 } else {
115 (text.to_string(), CargoEdit::AlreadySet)
116 }
117}
118
119fn read_build_key(text: &str, key: &str) -> Option<String> {
121 read_table_key(text, "build", key)
122}
123
124pub fn read_table_key(text: &str, table: &str, key: &str) -> Option<String> {
129 let header = format!("[{table}]");
130 let mut in_table = false;
131 for line in text.lines() {
132 let trimmed = line.trim();
133 if trimmed.starts_with('[') && trimmed.ends_with(']') {
134 in_table = trimmed.replace([' ', '\t'], "") == header;
135 continue;
136 }
137 if !in_table {
138 continue;
139 }
140 if let Some((candidate, value)) = trimmed.split_once('=') {
141 if candidate.trim() != key {
142 continue;
143 }
144 let value = value.split('#').next().unwrap_or("").trim();
145 return Some(value.trim_matches(['"', '\'']).trim().to_string());
146 }
147 }
148 None
149}
150
151pub fn cargo_configured_in(table: &str, key: &str) -> Result<Option<String>> {
153 let path = cargo_config_path()?;
154 if !path.exists() {
155 return Ok(None);
156 }
157 let text = fs::read_to_string(&path)?;
158 Ok(read_table_key(&text, table, key))
159}
160
161pub fn cargo_configured(key: &str) -> Result<Option<String>> {
163 let path = cargo_config_path()?;
164 if !path.exists() {
165 return Ok(None);
166 }
167 let text = fs::read_to_string(&path)?;
168 Ok(read_build_key(&text, key))
169}
170
171pub fn read_build_table_key(path: &Path, key: &str) -> Option<String> {
174 let text = fs::read_to_string(path).ok()?;
175 read_build_key(&text, key)
176}
177
178pub struct SetupReport {
179 pub root: PathBuf,
181 pub build_dir_edit: CargoEdit,
183 pub wrapper_edit: Option<CargoEdit>,
185 pub backup: Option<PathBuf>,
187 pub using_override: bool,
189}
190
191pub fn normalize_root_input(raw: &str) -> Result<PathBuf> {
201 let raw = raw.trim();
202 anyhow::ensure!(!raw.is_empty(), "--cache-root must not be empty");
203 if raw.contains('{') && raw.contains('}') {
204 return Ok(PathBuf::from(raw)); }
206 let home = paths::home()?;
207 if raw == "~" || raw.starts_with("~/") || raw.starts_with("~\\") {
208 let rest = raw[1..].trim_start_matches(['/', '\\']);
209 return Ok(home.join(rest));
210 }
211 let path = PathBuf::from(raw);
212 if path.is_absolute() {
213 return Ok(path);
214 }
215 let cwd = std::env::current_dir().context("resolving a relative --cache-root")?;
216 Ok(cwd.join(path))
217}
218
219pub fn setup(
223 root_arg: Option<&str>,
224 use_default: bool,
225 with_wrapper: Option<&str>,
226 without_wrapper: bool,
227) -> Result<SetupReport> {
228 let build_dir_value: String = if let Some(raw) = root_arg {
233 normalize_root_input(raw)?.to_string_lossy().into_owned()
234 } else if use_default {
235 DEFAULT_BUILD_DIR.to_string()
236 } else {
237 cargo_configured("build-dir")?.unwrap_or_else(|| DEFAULT_BUILD_DIR.to_string())
238 };
239
240 let root = if root_arg.is_some() || use_default {
242 paths::resolve_template(&build_dir_value)?
243 } else {
244 paths::resolve_template(&build_dir_value)
245 .or_else(|_| paths::cargo_resolved_build_dir())?
246 };
247 fs::create_dir_all(&root).with_context(|| format!("creating cache dir {}", root.display()))?;
248
249 let cargo_cfg = cargo_config_path()?;
251 let (build_dir_edit, wrapper_edit, backup) = if !cargo_cfg.exists() {
252 if let Some(parent) = cargo_cfg.parent() {
253 fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
254 }
255 let mut text = format!("[build]\nbuild-dir = '{build_dir_value}'\n");
256 let wrapper_edit = if let Some(name) = with_wrapper {
257 text.push_str(&format!("rustc-wrapper = '{name}'\n"));
258 Some(CargoEdit::Inserted)
259 } else {
260 None
261 };
262 fs::write(&cargo_cfg, text)?;
263 (CargoEdit::Created, wrapper_edit, None)
264 } else {
265 let text = fs::read_to_string(&cargo_cfg)
266 .with_context(|| format!("reading {}", cargo_cfg.display()))?;
267 let (text, build_dir_edit) = set_build_key(&text, "build-dir", &build_dir_value);
268 let (text, wrapper_edit) = match with_wrapper {
269 Some(name) => {
270 let (text, edit) = set_build_key(&text, "rustc-wrapper", name);
271 (text, Some(edit))
272 }
273 None if without_wrapper => {
274 let (text, edit) = remove_build_key(&text, "rustc-wrapper");
275 (text, Some(edit))
276 }
277 None => (text, None),
278 };
279 let mut backup = None;
280 let changed = build_dir_edit != CargoEdit::AlreadySet
281 || wrapper_edit
282 .as_ref()
283 .is_some_and(|e| *e != CargoEdit::AlreadySet);
284 if changed {
285 backup = Some(backup_file(&cargo_cfg)?);
286 fs::write(&cargo_cfg, text)
287 .with_context(|| format!("writing {}", cargo_cfg.display()))?;
288 }
289 (build_dir_edit, wrapper_edit, backup)
290 };
291
292 Ok(SetupReport {
293 root,
294 build_dir_edit,
295 wrapper_edit,
296 backup,
297 using_override: build_dir_value != DEFAULT_BUILD_DIR,
298 })
299}
300
301fn backup_file(path: &Path) -> Result<PathBuf> {
303 let mut name = path.file_name().unwrap_or_default().to_os_string();
304 name.push(".epicenter-bak");
305 let backup = path.with_file_name(name);
306 fs::copy(path, &backup).with_context(|| format!("backing up {}", path.display()))?;
307 Ok(backup)
308}
309
310#[cfg(test)]
311mod tests {
312 use super::*;
313
314 #[test]
315 fn replaced_line_keeps_the_rest_of_the_file() {
316 let src = "[build]\nbuild-dir = '/old'\nrustc-wrapper = \"kache\"\n\n[net]\nx = 1\n";
319 let (out, edit) = set_build_key(src, "build-dir", "/cache");
320 assert_eq!(edit, CargoEdit::Replaced);
321 assert!(
322 out.contains("rustc-wrapper = \"kache\""),
323 "tail deleted:\n{out}"
324 );
325 assert!(out.contains("[net]"), "tail deleted:\n{out}");
326 let dir = out.find("build-dir = '/cache'").unwrap();
327 let net = out.find("[net]").unwrap();
328 assert!(dir < net, "replaced value must stay inside [build]:\n{out}");
329 }
330
331 #[test]
332 fn appends_table_without_merging_lines() {
333 let (out, edit) = set_build_key("[net]\nfoo = 1\n", "build-dir", "/cache");
336 assert_eq!(edit, CargoEdit::Appended);
337 assert!(!out.contains("[net]foo"), "lines merged:\n{out}");
338 assert!(out.contains("foo = 1"));
339 let net = out.find("[net]").unwrap();
340 let build = out.rfind("[build]").unwrap();
341 assert!(net < build);
342 }
343
344 #[test]
345 fn appends_build_table_when_missing() {
346 let (out, edit) = set_build_key("[net]\nfoo = 1\n", "build-dir", "/cache");
347 assert_eq!(edit, CargoEdit::Appended);
348 assert!(out.contains("[build]\nbuild-dir = '/cache'"), "got:\n{out}");
349 }
350
351 #[test]
352 fn inserts_into_existing_build_table() {
353 let (out, edit) = set_build_key(
354 "[build]\nrustflags = []\n\n[net]\nx = 1\n",
355 "build-dir",
356 "/cache",
357 );
358 assert_eq!(edit, CargoEdit::Inserted);
359 let build = out.find("[build]").unwrap();
361 let dir = out.find("build-dir").unwrap();
362 let net = out.find("[net]").unwrap();
363 assert!(build < dir && dir < net, "got:\n{out}");
364 }
365
366 #[test]
367 fn replaces_old_value() {
368 let (out, edit) = set_build_key("[build]\nbuild-dir = '/old'\n", "build-dir", "/cache");
369 assert_eq!(edit, CargoEdit::Replaced);
370 assert!(out.contains("build-dir = '/cache'"));
371 assert!(!out.contains("/old"));
372 }
373
374 #[test]
375 fn already_set_is_a_no_op() {
376 let src = "[build]\nbuild-dir = '/cache'\n";
377 let (out, edit) = set_build_key(src, "build-dir", "/cache");
378 assert_eq!(edit, CargoEdit::AlreadySet);
379 assert_eq!(out, src);
380 }
381
382 #[test]
383 fn removes_wrapper_line() {
384 let src = "[build]\nbuild-dir = '/cache'\nrustc-wrapper = 'sccache'\n";
385 let (out, edit) = remove_build_key(src, "rustc-wrapper");
386 assert_eq!(edit, CargoEdit::Removed);
387 assert!(out.contains("build-dir"), "build-dir must survive:\n{out}");
388 assert!(!out.contains("rustc-wrapper"));
389 }
390
391 #[test]
392 fn remove_is_a_no_op_when_absent() {
393 let src = "[build]\nbuild-dir = '/cache'\n";
394 let (out, edit) = remove_build_key(src, "rustc-wrapper");
395 assert_eq!(edit, CargoEdit::AlreadySet);
396 assert_eq!(out, src);
397 }
398
399 #[test]
400 fn reads_key_from_build_table() {
401 assert_eq!(
402 read_build_key("[build]\nbuild-dir = '/cache' # comment\n", "build-dir"),
403 Some("/cache".to_string()),
404 );
405 assert_eq!(read_build_key("[build]\nother = 1\n", "build-dir"), None);
406 assert_eq!(
408 read_build_key("[net]\nbuild-dir = '/x'\n", "build-dir"),
409 None
410 );
411 }
412
413 #[test]
414 fn reads_generic_table_keys() {
415 let text = "[unstable]\nfine-grain-locking = true # concurrent builds\n\n[build]\nbuild-dir = '/x'\n";
416 assert_eq!(
417 read_table_key(text, "unstable", "fine-grain-locking"),
418 Some("true".to_string())
419 );
420 assert_eq!(read_table_key(text, "build", "fine-grain-locking"), None);
421 assert_eq!(
422 read_table_key(text, "build", "build-dir"),
423 Some("/x".to_string())
424 );
425 assert_eq!(
427 read_table_key("[other]\n# fine-grain-locking = true\n", "unstable", "fine-grain-locking"),
428 None
429 );
430 }
431
432 #[test]
433 fn cache_root_input_is_normalized() {
434 assert_eq!(
436 normalize_root_input("{cargo-cache-home}/custom").unwrap(),
437 PathBuf::from("{cargo-cache-home}/custom"),
438 );
439 assert_eq!(
441 normalize_root_input("/tmp/cache").unwrap(),
442 PathBuf::from("/tmp/cache"),
443 );
444 let home = paths::home().unwrap();
446 assert_eq!(normalize_root_input("~/cache").unwrap(), home.join("cache"));
447 let cwd = std::env::current_dir().unwrap();
449 assert_eq!(
450 normalize_root_input("cache/build").unwrap(),
451 cwd.join("cache/build"),
452 );
453 }
454}