Skip to main content

epicenter_cli/
config.rs

1//! Editing Cargo's global `config.toml` — the single source of truth.
2//!
3//! epicenter keeps NO state file of its own: the shared-cache decision lives
4//! in `$CARGO_HOME/config.toml` as the `[build] build-dir` key, where cargo
5//! (and any other tool) can read it. epicenter writes that key (`setup`),
6//! reads it back, and verifies it (`doctor`).
7
8use anyhow::{Context, Result};
9use std::fs;
10use std::path::{Path, PathBuf};
11
12use crate::paths::{self, DEFAULT_BUILD_DIR};
13
14/// The effective shared-cache root: cargo's own runtime answer
15/// (`cargo metadata` merges every config layer exactly like a real build),
16/// falling back to the configured build-dir value (or the canonical default)
17/// when cargo cannot be asked.
18pub 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
27/// Cargo's *global* config file: `$CARGO_HOME/config.toml`.
28/// Settings there apply to every project on the machine.
29pub fn cargo_config_path() -> Result<PathBuf> {
30    Ok(paths::cargo_home()?.join("config.toml"))
31}
32
33/// What an edit to Cargo's config changed.
34#[derive(Debug, PartialEq, Eq)]
35pub enum CargoEdit {
36    /// The file did not exist and was created.
37    Created,
38    /// The key was added to an existing `[build]` table.
39    Inserted,
40    /// The key existed with a different value and was replaced.
41    Replaced,
42    /// The key existed and was deleted.
43    Removed,
44    /// Nothing needed changing.
45    AlreadySet,
46    /// The key was appendend at the end of the toml file
47    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
63/// Sets `key = 'value'` inside the `[build]` table of a Cargo config,
64/// as pure text surgery so user comments and formatting survive untouched.
65fn 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    // Exact header match (whitespace-insensitive) — `.contains("[build]")`
70    // would also match comments or sub-table names.
71    let header = lines
72        .iter()
73        .position(|l| l.trim().replace([' ', '\t'], "") == "[build]");
74
75    match header {
76        Some(i) => {
77            // Scan to the next table header: that is the whole [build] table.
78            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
108/// delete `key = ...` from the `[build]` table, if present.
109fn 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
119/// Parse the value of `key` out of the `[build]` table.
120fn read_build_key(text: &str, key: &str) -> Option<String> {
121    read_table_key(text, "build", key)
122}
123
124/// Parse `key = value` out of any `[table]` of a cargo config text.
125///
126/// Only the exact table is scanned (until the next header line); a trailing
127/// comment is stripped and surrounding quotes are removed.
128pub 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
151/// Look up a key in any table of Cargo's *global* config file.
152pub 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
161/// Look up a `[build]` key in Cargo's *global* config file on disk.
162pub 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
171/// Read a `[build]` key from ANY cargo config file — doctor uses this to scan
172/// the ancestor `.cargo/config.toml` chain for shadowing values. Read-only.
173pub 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    /// Where the shared cache now lives.
180    pub root: PathBuf,
181    /// What happened to the `build-dir` setting.
182    pub build_dir_edit: CargoEdit,
183    /// What happened to the `rustc-wrapper` (kache/sccache) setting, if touched.
184    pub wrapper_edit: Option<CargoEdit>,
185    /// Backup of the previous cargo config, if it was modified.
186    pub backup: Option<PathBuf>,
187    /// Is the override mode active (vs the canonical template)?
188    pub using_override: bool,
189}
190
191/// The full `epicenter setup` orchestration: compute the decision, make the
192/// directory, and write/merge the cargo config.
193/// Normalize a user-provided `--cache-root` value.
194///
195/// - cargo template strings (`{…}`) pass through untouched — they resolve at
196///   build time via cargo, exactly like the default template;
197/// - `~` expands to the home directory;
198/// - relative paths resolve against the current directory;
199/// - absolute paths stay as-is.
200pub 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)); // template passthrough
205    }
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
219/// Priority: CLI root > stored override (`~/.epicenter/config.toml`) > default
220/// template. `--cache-root` and `--use-default` rewrite the stored decision;
221/// without them the stored decision (if any) stays untouched.
222pub fn setup(
223    root_arg: Option<&str>,
224    use_default: bool,
225    with_wrapper: Option<&str>,
226    without_wrapper: bool,
227) -> Result<SetupReport> {
228    // Decision precedence: CLI root > the value already in cargo's config >
229    // the canonical default template. `--cache-root` / `--use-default`
230    // REWRITE the cargo-config decision; without them it stays untouched.
231    // There is no epicenter side-file — `~/.cargo/config.toml` is the state.
232    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    // The filesystem view must always be RESOLVED (a template is not a path).
241    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    // No epicenter side-file: the decision itself is the cargo config below.
250    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
301/// Copy `path` to `path.epicenter-bak` before modifying it.
302fn 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        // Regression test for the split_inclusive `Replaced` bug: the kache
317        // line and the [net] table had to survive the replacement.
318        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        // Regression test for the collect::<String> `Appended` bug: joining
334        // without separators fused `[net]` and `foo = 1` into one line.
335        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        // build-dir must land inside [build], i.e. after the header but before [net].
360        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        // A build-dir under some OTHER table must not be picked up.
407        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        // A key mentioned in a comment elsewhere must not leak.
426        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        // Cargo templates pass through untouched.
435        assert_eq!(
436            normalize_root_input("{cargo-cache-home}/custom").unwrap(),
437            PathBuf::from("{cargo-cache-home}/custom"),
438        );
439        // Absolute stays absolute.
440        assert_eq!(
441            normalize_root_input("/tmp/cache").unwrap(),
442            PathBuf::from("/tmp/cache"),
443        );
444        // `~` expands to the home directory.
445        let home = paths::home().unwrap();
446        assert_eq!(normalize_root_input("~/cache").unwrap(), home.join("cache"));
447        // Relative resolves against the current directory.
448        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}