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: **only** the global
15/// `$CARGO_HOME/config.toml` (or the canonical default). Project configs are
16/// never consulted for state; `cargo metadata` is deliberately not executed.
17pub fn cache_root() -> Result<PathBuf> {
18    let root = trusted_cache_root()?;
19    crate::util::ensure_windows_user_path(&root, "cache root")?;
20    Ok(root)
21}
22
23/// The trusted root: the global `$CARGO_HOME/config.toml` `build-dir` value,
24/// or the canonical default template. Never reads a project config and never
25/// executes `cargo metadata`.
26pub fn trusted_cache_root() -> Result<PathBuf> {
27    let value = paths::cargo_build_dir()?.unwrap_or_else(|| DEFAULT_BUILD_DIR.to_string());
28    paths::resolve_template(&value)
29}
30
31/// Config values are written inside single-quoted TOML strings. Reject the
32/// characters that can break out of the literal or start a new key/line.
33fn validate_config_value(value: &str, what: &str) -> Result<()> {
34    if value.contains(['\n', '\r', '\0', '\'']) {
35        anyhow::bail!("{what} contains a forbidden character (newline, NUL or single quote)");
36    }
37    Ok(())
38}
39
40/// Cargo's *global* config file: `$CARGO_HOME/config.toml`.
41/// Settings there apply to every project on the machine.
42pub fn cargo_config_path() -> Result<PathBuf> {
43    let cargo_home = paths::cargo_home()?;
44    if cargo_home.is_relative() {
45        anyhow::bail!(
46            "CARGO_HOME is relative ({}) — set an absolute CARGO_HOME",
47            cargo_home.display()
48        );
49    }
50    // The config file is only as trustworthy as its directory: a
51    // group/world-writable $CARGO_HOME lets another user swap the file after
52    // any path-only check. Check the directory, then the file; the actual
53    // read uses fstat on the opened descriptor (util::read_trusted_config).
54    match fs::symlink_metadata(&cargo_home) {
55        Ok(meta) => {
56            if !meta.is_dir() || meta.file_type().is_symlink() {
57                anyhow::bail!(
58                    "CARGO_HOME {} is a symlink or not a directory",
59                    cargo_home.display()
60                );
61            }
62            if !crate::util::owned_by_us(&meta) {
63                anyhow::bail!(
64                    "CARGO_HOME {} is owned by another user",
65                    cargo_home.display()
66                );
67            }
68            #[cfg(unix)]
69            {
70                use std::os::unix::fs::MetadataExt;
71                if meta.mode() & 0o022 != 0 {
72                    anyhow::bail!(
73                        "CARGO_HOME {} is group/world-writable (mode {:o})",
74                        cargo_home.display(),
75                        meta.mode() & 0o777
76                    );
77                }
78            }
79        }
80        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
81        Err(err) => {
82            return Err(err).with_context(|| format!("inspecting {}", cargo_home.display()));
83        }
84    }
85    let path = cargo_home.join("config.toml");
86    if path.is_relative() {
87        anyhow::bail!(
88            "global cargo config path is relative ({}) — set an absolute CARGO_HOME",
89            path.display()
90        );
91    }
92    crate::util::ensure_windows_user_path(&path, "global cargo config")?;
93    match fs::symlink_metadata(&path) {
94        Ok(meta) => {
95            if meta.file_type().is_symlink() || !meta.is_file() {
96                anyhow::bail!(
97                    "global cargo config {} is a symlink or not a regular file",
98                    path.display()
99                );
100            }
101            if !crate::util::owned_by_us(&meta) {
102                anyhow::bail!(
103                    "global cargo config {} is owned by another user",
104                    path.display()
105                );
106            }
107            #[cfg(unix)]
108            {
109                use std::os::unix::fs::MetadataExt;
110                if meta.mode() & 0o022 != 0 {
111                    anyhow::bail!(
112                        "global cargo config {} is group/world-writable (mode {:o})",
113                        path.display(),
114                        meta.mode() & 0o777
115                    );
116                }
117            }
118        }
119        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
120        Err(err) => {
121            return Err(err).with_context(|| format!("inspecting {}", path.display()));
122        }
123    }
124    Ok(path)
125}
126
127/// What an edit to Cargo's config changed.
128#[derive(Debug, PartialEq, Eq)]
129pub enum CargoEdit {
130    /// The file did not exist and was created.
131    Created,
132    /// The key was added to an existing `[build]` table.
133    Inserted,
134    /// The key existed with a different value and was replaced.
135    Replaced,
136    /// The key existed and was deleted.
137    Removed,
138    /// Nothing needed changing.
139    AlreadySet,
140    /// The key was appendend at the end of the toml file
141    Appended,
142    /// Revert config file to the last backup
143    Reverted,
144}
145
146impl CargoEdit {
147    pub fn describe(&self) -> &'static str {
148        match self {
149            CargoEdit::Created => "created the file",
150            CargoEdit::Inserted => "added",
151            CargoEdit::Replaced => "updated",
152            CargoEdit::Removed => "removed",
153            CargoEdit::AlreadySet => "already set, unchanged",
154            CargoEdit::Appended => "appended line at the EOF",
155            CargoEdit::Reverted => "config file reverted to last backup",
156        }
157    }
158}
159
160/// Sets `key = 'value'` inside the `[build]` table of a Cargo config,
161/// as pure text surgery so user comments and formatting survive untouched.
162fn set_build_key(text: &str, key: &str, value: &str) -> (String, CargoEdit) {
163    let wanted = format!("{key} = '{value}'");
164    let mut lines: Vec<String> = text.lines().map(str::to_string).collect();
165
166    // Exact header match (whitespace-insensitive) — `.contains("[build]")`
167    // would also match comments or sub-table names.
168    let header = lines
169        .iter()
170        .position(|l| l.trim().replace([' ', '\t'], "") == "[build]");
171
172    match header {
173        Some(i) => {
174            // Scan to the next table header: that is the whole [build] table.
175            let mut j = i + 1;
176            while j < lines.len() {
177                let t = lines[j].trim();
178                if t.starts_with('[') && t.ends_with(']') {
179                    break;
180                }
181                if t.split_once('=').map_or(false, |(k, _)| k.trim() == key) {
182                    if t == wanted {
183                        return (text.to_string(), CargoEdit::AlreadySet);
184                    }
185                    lines[j] = wanted;
186                    return (lines.join("\n") + "\n", CargoEdit::Replaced);
187                }
188                j += 1;
189            }
190            lines.insert(i + 1, wanted);
191            (lines.join("\n") + "\n", CargoEdit::Inserted)
192        }
193        None => {
194            let needs_blank = lines.last().map_or(false, |l| !l.trim().is_empty());
195            if needs_blank {
196                lines.push(String::new());
197            }
198            lines.push("[build]".to_string());
199            lines.push(wanted);
200            (lines.join("\n") + "\n", CargoEdit::Appended)
201        }
202    }
203}
204
205/// delete `key = ...` from the `[build]` table, if present.
206fn remove_build_key(text: &str, key: &str) -> (String, CargoEdit) {
207    let mut out: Vec<String> = Vec::new();
208    let mut in_build = false;
209    let mut removed = false;
210    for line in text.lines() {
211        let trimmed = line.trim();
212        if trimmed.starts_with('[') && trimmed.ends_with(']') {
213            in_build = trimmed.replace([' ', '\t'], "") == "[build]";
214            out.push(line.to_string());
215            continue;
216        }
217        if in_build
218            && trimmed
219                .split_once('=')
220                .map_or(false, |(candidate, _)| candidate.trim() == key)
221        {
222            removed = true;
223            continue;
224        }
225        out.push(line.to_string());
226    }
227    if removed {
228        (out.join("\n") + "\n", CargoEdit::Removed)
229    } else {
230        (text.to_string(), CargoEdit::AlreadySet)
231    }
232}
233/// Parse the value of `key` out of the `[build]` table.
234fn read_build_key(text: &str, key: &str) -> Option<String> {
235    read_table_key(text, "build", key)
236}
237
238/// Is this line the `[target.<triple>]` header (plain or TOML-quoted)?
239fn is_target_header(line: &str, target: &str) -> bool {
240    let compact = line.trim().replace([' ', '\t'], "");
241    compact == format!("[target.{target}]")
242        || compact == format!("[target.\"{target}\"]")
243        || compact == format!("[target.'{target}']")
244}
245
246/// Insert `rustflags = ...` into `[target.<target>]`, preserving every other
247/// line and comment. Existing `rustflags` are **never** overwritten: the
248/// third return value reports that conflict instead.
249fn set_target_rustflags(text: &str, target: &str, literal: &str) -> (String, CargoEdit, bool) {
250    let mut lines: Vec<String> = text.lines().map(str::to_string).collect();
251    if let Some(header) = lines.iter().position(|line| is_target_header(line, target)) {
252        let mut end = header + 1;
253        while end < lines.len() {
254            let trimmed = lines[end].trim();
255            if trimmed.starts_with('[') && trimmed.ends_with(']') {
256                break;
257            }
258            if let Some((key, value)) = trimmed.split_once('=') {
259                if key.trim() == "rustflags" {
260                    let same = clean_toml_value(value) == literal;
261                    return (text.to_string(), CargoEdit::AlreadySet, !same);
262                }
263            }
264            end += 1;
265        }
266        lines.insert(end, format!("rustflags = {literal}"));
267        (lines.join("\n") + "\n", CargoEdit::Inserted, false)
268    } else {
269        let needs_blank = lines.last().map_or(false, |line| !line.trim().is_empty());
270        if needs_blank {
271            lines.push(String::new());
272        }
273        lines.push(format!("[target.{target}]"));
274        lines.push(format!("rustflags = {literal}"));
275        (lines.join("\n") + "\n", CargoEdit::Appended, false)
276    }
277}
278
279/// Remove the `--fast` `rustflags` from `[target.<target>]` only when the
280/// value still matches what `--fast` writes. Anything else is left alone.
281fn remove_target_rustflags(text: &str, target: &str, literal: &str) -> (String, CargoEdit, bool) {
282    let mut out: Vec<String> = Vec::new();
283    let mut in_table = false;
284    let mut removed = false;
285    let mut conflict = false;
286    for line in text.lines() {
287        let trimmed = line.trim();
288        if trimmed.starts_with('[') && trimmed.ends_with(']') {
289            in_table = is_target_header(line, target);
290            out.push(line.to_string());
291            continue;
292        }
293        if in_table {
294            if let Some((key, value)) = trimmed.split_once('=') {
295                if key.trim() == "rustflags" {
296                    if clean_toml_value(value) == literal {
297                        removed = true;
298                        continue;
299                    }
300                    conflict = true;
301                }
302            }
303        }
304        out.push(line.to_string());
305    }
306    if removed {
307        (out.join("\n") + "\n", CargoEdit::Removed, false)
308    } else if conflict {
309        (text.to_string(), CargoEdit::AlreadySet, true)
310    } else {
311        (text.to_string(), CargoEdit::AlreadySet, false)
312    }
313}
314
315/// Strip a trailing `#` comment while respecting single/double quoted values.
316fn strip_toml_comment(value: &str) -> &str {
317    let mut quote: Option<char> = None;
318    let mut escaped = false;
319    for (index, ch) in value.char_indices() {
320        if escaped {
321            escaped = false;
322            continue;
323        }
324        match quote {
325            Some(q) => {
326                if ch == '\\' && q == '"' {
327                    escaped = true;
328                } else if ch == q {
329                    quote = None;
330                }
331            }
332            None => {
333                if ch == '#' {
334                    return &value[..index];
335                }
336                if ch == '"' || ch == '\'' {
337                    quote = Some(ch);
338                }
339            }
340        }
341    }
342    value
343}
344
345/// Parse `key = value` out of any `[table]` of a cargo config text.
346///
347/// Only the exact table is scanned (until the next header line); a trailing
348/// comment is stripped and surrounding quotes are removed.
349pub fn read_table_key(text: &str, table: &str, key: &str) -> Option<String> {
350    let header = format!("[{table}]");
351    let dotted = format!("{table}.{key}");
352    let mut in_table = false;
353    let mut at_root = true;
354    for line in text.lines() {
355        let trimmed = line.trim();
356        if trimmed.starts_with('[') && trimmed.ends_with(']') {
357            at_root = false;
358            in_table = trimmed.replace([' ', '\t'], "") == header;
359            continue;
360        }
361        let candidate = trimmed.split_once('=').map(|(k, _)| k.trim());
362        // TOML allows `build.build-dir = ...` at the document root.
363        if at_root && candidate == Some(dotted.as_str()) {
364            if let Some((_, value)) = trimmed.split_once('=') {
365                return Some(clean_toml_value(value));
366            }
367        }
368        if !in_table {
369            continue;
370        }
371        if let Some((candidate, value)) = trimmed.split_once('=') {
372            if candidate.trim() != key {
373                continue;
374            }
375            return Some(clean_toml_value(value));
376        }
377    }
378    None
379}
380
381fn clean_toml_value(value: &str) -> String {
382    let value = strip_toml_comment(value).trim();
383    let value = value
384        .strip_prefix('\'')
385        .and_then(|v| v.strip_suffix('\''))
386        .or_else(|| value.strip_prefix('"').and_then(|v| v.strip_suffix('"')))
387        .unwrap_or(value);
388    value.trim().to_string()
389}
390
391/// Look up a key in any table of Cargo's *global* config file.
392pub fn cargo_configured_in(table: &str, key: &str) -> Result<Option<String>> {
393    let path = cargo_config_path()?;
394    if !path.exists() {
395        return Ok(None);
396    }
397    let text = crate::util::read_trusted_config(&path, 1024 * 1024)?;
398    Ok(read_table_key(&text, table, key))
399}
400
401/// Look up a `[build]` key in Cargo's *global* config file on disk.
402pub fn cargo_configured(key: &str) -> Result<Option<String>> {
403    let path = cargo_config_path()?;
404    if !path.exists() {
405        return Ok(None);
406    }
407    let text = crate::util::read_trusted_config(&path, 1024 * 1024)?;
408    Ok(read_build_key(&text, key))
409}
410
411/// Read a `[build]` key from ANY cargo config file — doctor uses this to scan
412/// the ancestor `.cargo/config.toml` chain for shadowing values. Read-only.
413pub fn read_build_table_key(path: &Path, key: &str) -> Option<String> {
414    let text = crate::util::read_small_text(path, 1024 * 1024).ok()?;
415    read_build_key(&text, key)
416}
417
418pub struct SetupReport {
419    /// Where the shared cache now lives.
420    pub root: PathBuf,
421    /// What happened to the `build-dir` setting.
422    pub build_dir_edit: CargoEdit,
423    /// What happened to the `rustc-wrapper` (kache/sccache) setting, if touched.
424    pub wrapper_edit: Option<CargoEdit>,
425    /// What happened to the zero-install fast linker (`--fast`), if touched.
426    pub fast: Option<FastSetupReport>,
427    /// Backup of the previous cargo config, if it was modified.
428    pub backup: Option<PathBuf>,
429    /// Is the override mode active (vs the canonical template)?
430    pub using_override: bool,
431}
432
433/// The `--fast` outcome: rustup's bundled LLD wired into the host target.
434pub struct FastSetupReport {
435    pub target: String,
436    /// `ld.lld` / `lld-link.exe` that the flags point at.
437    pub lld: PathBuf,
438    pub flags: Vec<String>,
439    pub edit: CargoEdit,
440    /// Existing `rustflags` differ and were left untouched.
441    pub conflict: bool,
442}
443
444/// The full `epicenter setup` orchestration: compute the decision, make the
445/// directory, and write/merge the cargo config.
446/// Normalize a user-provided `--cache-root` value.
447///
448/// - cargo template strings (`{…}`) pass through untouched — they resolve at
449///   build time via cargo, exactly like the default template;
450/// - `~` expands to the home directory;
451/// - relative paths resolve against the current directory;
452/// - absolute paths stay as-is.
453pub fn normalize_root_input(raw: &str) -> Result<PathBuf> {
454    let raw = raw.trim();
455    anyhow::ensure!(!raw.is_empty(), "--cache-root must not be empty");
456    if raw.contains('{') && raw.contains('}') {
457        return Ok(PathBuf::from(raw)); // template passthrough
458    }
459    let home = paths::home()?;
460    if raw == "~" || raw.starts_with("~/") || raw.starts_with("~\\") {
461        let rest = raw[1..].trim_start_matches(['/', '\\']);
462        return Ok(home.join(rest));
463    }
464    let path = PathBuf::from(raw);
465    if path.is_absolute() {
466        return Ok(path);
467    }
468    let cwd = std::env::current_dir().context("resolving a relative --cache-root")?;
469    Ok(cwd.join(path))
470}
471
472/// Priority: CLI root > stored override (`~/.epicenter/config.toml`) > default
473/// template. `--cache-root` and `--use-default` rewrite the stored decision;
474/// without them the stored decision (if any) stays untouched.
475pub fn setup(
476    root_arg: Option<&str>,
477    use_default: bool,
478    with_wrapper: Option<&str>,
479    without_wrapper: bool,
480    fast: bool,
481    without_fast: bool,
482) -> Result<SetupReport> {
483    // Decision precedence: CLI root > the value already in cargo's config >
484    // the canonical default template. `--cache-root` / `--use-default`
485    // REWRITE the cargo-config decision; without them it stays untouched.
486    // There is no epicenter side-file — `~/.cargo/config.toml` is the state.
487    let build_dir_value: String = if let Some(raw) = root_arg {
488        normalize_root_input(raw)?.to_string_lossy().into_owned()
489    } else if use_default {
490        DEFAULT_BUILD_DIR.to_string()
491    } else {
492        paths::cargo_build_dir()?.unwrap_or_else(|| DEFAULT_BUILD_DIR.to_string())
493    };
494
495    validate_config_value(&build_dir_value, "build-dir")?;
496    if let Some(name) = with_wrapper {
497        validate_config_value(name, "rustc-wrapper")?;
498    }
499
500    // The filesystem view must always be RESOLVED (a template is not a path).
501    let root = paths::resolve_template(&build_dir_value)?;
502    crate::util::ensure_windows_user_path(&root, "cache root")?;
503    crate::util::create_private_dir_all(&root)
504        .with_context(|| format!("creating cache dir {}", root.display()))?;
505
506    // No epicenter side-file: the decision itself is the cargo config below.
507    let cargo_cfg = cargo_config_path()?;
508
509    // Detect rustup's bundled LLD only when the user asked for the fast tier;
510    // failure here is a setup error, never a silent no-op.
511    let fast_linker = if fast || without_fast {
512        Some(crate::fastlink::detect()?)
513    } else {
514        None
515    };
516    let (build_dir_edit, wrapper_edit, fast_report, backup) = if !cargo_cfg.exists() {
517        if let Some(parent) = cargo_cfg.parent() {
518            crate::util::create_private_dir_all(parent)
519                .with_context(|| format!("creating {}", parent.display()))?;
520        }
521        let mut text = format!("[build]\nbuild-dir = '{build_dir_value}'\n");
522        let wrapper_edit = if let Some(name) = with_wrapper {
523            text.push_str(&format!("rustc-wrapper = '{name}'\n"));
524            Some(CargoEdit::Inserted)
525        } else {
526            None
527        };
528        let fast_report = match &fast_linker {
529            Some(lld) if fast => {
530                let literal = lld.rustflags_toml();
531                text.push_str(&format!("\n[target.{}]\nrustflags = {literal}\n", lld.host));
532                Some(FastSetupReport {
533                    target: lld.host.clone(),
534                    lld: lld.linker.clone(),
535                    flags: lld.rustflags(),
536                    edit: CargoEdit::Appended,
537                    conflict: false,
538                })
539            }
540            _ => None,
541        };
542        crate::util::write_atomic_private(&cargo_cfg, text.as_bytes())?;
543        (CargoEdit::Created, wrapper_edit, fast_report, None)
544    } else {
545        let text = crate::util::read_trusted_config(&cargo_cfg, 1024 * 1024)
546            .with_context(|| format!("reading {}", cargo_cfg.display()))?;
547        let (text, build_dir_edit) = set_build_key(&text, "build-dir", &build_dir_value);
548        let (text, wrapper_edit) = match with_wrapper {
549            Some(name) => {
550                let (text, edit) = set_build_key(&text, "rustc-wrapper", name);
551                (text, Some(edit))
552            }
553            None if without_wrapper => {
554                let (text, edit) = remove_build_key(&text, "rustc-wrapper");
555                (text, Some(edit))
556            }
557            None => (text, None),
558        };
559        let (text, fast_report) = match &fast_linker {
560            Some(lld) => {
561                let literal = lld.rustflags_toml();
562                let (text, edit, conflict) = if fast {
563                    set_target_rustflags(&text, &lld.host, &literal)
564                } else {
565                    remove_target_rustflags(&text, &lld.host, &literal)
566                };
567                (
568                    text,
569                    Some(FastSetupReport {
570                        target: lld.host.clone(),
571                        lld: lld.linker.clone(),
572                        flags: lld.rustflags(),
573                        edit,
574                        conflict,
575                    }),
576                )
577            }
578            None => (text, None),
579        };
580        let mut backup = None;
581        let changed = build_dir_edit != CargoEdit::AlreadySet
582            || wrapper_edit
583                .as_ref()
584                .is_some_and(|e| *e != CargoEdit::AlreadySet)
585            || fast_report
586                .as_ref()
587                .is_some_and(|report| report.edit != CargoEdit::AlreadySet);
588        if changed {
589            backup = Some(backup_file(&cargo_cfg)?);
590            crate::util::write_atomic_private(&cargo_cfg, text.as_bytes())?;
591        }
592        (build_dir_edit, wrapper_edit, fast_report, backup)
593    };
594
595    Ok(SetupReport {
596        root,
597        build_dir_edit,
598        wrapper_edit,
599        fast: fast_report,
600        backup,
601        using_override: build_dir_value != DEFAULT_BUILD_DIR,
602    })
603}
604
605/// `<config>.epicenter-bak` (suffix 0).
606fn backup_base(path: &Path) -> PathBuf {
607    let mut name = path.file_name().unwrap_or_default().to_os_string();
608    name.push(".epicenter-bak");
609    path.with_file_name(name)
610}
611
612/// Setup backups for a config path, oldest to newest.
613///
614/// Suffixes are monotonic (`…-bak`, `…-bak.1`, `…-bak.2`, …), so the newest
615/// backup is always the highest suffix — never mtime (copies can carry any
616/// timestamp) and never "first free" (a gap would break the ordering).
617fn backups(path: &Path) -> Result<Vec<(u64, PathBuf)>> {
618    let base = backup_base(path);
619    let Some(parent) = base.parent() else {
620        return Ok(Vec::new());
621    };
622    let base_name = base
623        .file_name()
624        .unwrap_or_default()
625        .to_string_lossy()
626        .into_owned();
627    let entries = match fs::read_dir(parent) {
628        Ok(entries) => entries,
629        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
630        Err(err) => return Err(err).with_context(|| format!("reading {}", parent.display())),
631    };
632    let mut out = Vec::new();
633    for entry in entries {
634        let entry = entry?;
635        let name = entry.file_name().to_string_lossy().into_owned();
636        let suffix = if name == base_name {
637            0
638        } else if let Some(rest) = name.strip_prefix(&base_name) {
639            match rest.strip_prefix('.').and_then(|n| n.parse::<u64>().ok()) {
640                Some(number) => number,
641                None => continue, // `…-bak.2.tmp`, `…-bak.old`
642            }
643        } else {
644            continue;
645        };
646        let Ok(meta) = fs::symlink_metadata(entry.path()) else {
647            continue;
648        };
649        if !meta.is_file() || meta.file_type().is_symlink() || crate::util::is_reparse_point(&meta)
650        {
651            continue;
652        }
653        out.push((suffix, entry.path()));
654    }
655    out.sort_by_key(|(suffix, _)| *suffix);
656    Ok(out)
657}
658
659/// The newest setup backup for a config path, if any.
660fn latest_backup(path: &Path) -> Result<Option<PathBuf>> {
661    Ok(backups(path)?.pop().map(|(_, path)| path))
662}
663
664/// Copy `path` to the next numbered backup before modifying it.
665fn backup_file(path: &Path) -> Result<PathBuf> {
666    let base = backup_base(path);
667    let next = backups(path)?
668        .last()
669        .map(|(suffix, _)| suffix + 1)
670        .unwrap_or(0);
671    let mut name = base.file_name().unwrap_or_default().to_os_string();
672    if next > 0 {
673        name.push(format!(".{next}"));
674    }
675    let backup = base.with_file_name(name);
676    // Create the backup with O_EXCL; the source is opened O_NOFOLLOW by
677    // copy_file_hashed so a swapped symlink cannot redirect the copy.
678    let source = path.to_path_buf();
679    crate::util::copy_file_hashed(&source, &backup)
680        .with_context(|| format!("backing up {}", path.display()))?;
681    if let Ok(meta) = fs::metadata(&source) {
682        let _ = fs::set_permissions(&backup, meta.permissions());
683    }
684    Ok(backup)
685}
686
687/// Restored config + the backup it came from.
688#[derive(Debug)]
689pub struct RevertReport {
690    pub config: PathBuf,
691    pub backup: PathBuf,
692}
693
694/// Restore the global cargo config from its newest setup backup.
695pub fn revert_config() -> Result<RevertReport> {
696    let config = cargo_config_path()?;
697    revert_config_at(&config)
698}
699
700/// Restore `config` from its newest backup: trusted read, atomic write.
701fn revert_config_at(config: &Path) -> Result<RevertReport> {
702    let Some(backup) = latest_backup(config)? else {
703        anyhow::bail!(
704            "no setup backup found next to {} — run `epicenter setup` after a change first",
705            config.display()
706        );
707    };
708    let text = crate::util::read_trusted_config(&backup, 1024 * 1024)
709        .with_context(|| format!("reading backup {}", backup.display()))?;
710    crate::util::write_atomic_private(config, text.as_bytes())
711        .with_context(|| format!("restoring {}", config.display()))?;
712    Ok(RevertReport {
713        config: config.to_path_buf(),
714        backup,
715    })
716}
717
718#[cfg(test)]
719mod tests {
720    use super::*;
721
722    #[test]
723    fn replaced_line_keeps_the_rest_of_the_file() {
724        // Regression test for the split_inclusive `Replaced` bug: the kache
725        // line and the [net] table had to survive the replacement.
726        let src = "[build]\nbuild-dir = '/old'\nrustc-wrapper = \"kache\"\n\n[net]\nx = 1\n";
727        let (out, edit) = set_build_key(src, "build-dir", "/cache");
728        assert_eq!(edit, CargoEdit::Replaced);
729        assert!(
730            out.contains("rustc-wrapper = \"kache\""),
731            "tail deleted:\n{out}"
732        );
733        assert!(out.contains("[net]"), "tail deleted:\n{out}");
734        let dir = out.find("build-dir = '/cache'").unwrap();
735        let net = out.find("[net]").unwrap();
736        assert!(dir < net, "replaced value must stay inside [build]:\n{out}");
737    }
738
739    #[test]
740    fn backups_are_numbered_and_latest_wins() -> Result<()> {
741        let dir = tempfile::tempdir()?;
742        let config = dir.path().join("config.toml");
743        fs::write(&config, "[build]\nbuild-dir = '/one'\n")?;
744
745        let first = backup_file(&config)?;
746        assert_eq!(
747            first.file_name().unwrap(),
748            "config.toml.epicenter-bak",
749            "the first backup has no suffix"
750        );
751        fs::write(&config, "[build]\nbuild-dir = '/two'\n")?;
752        let second = backup_file(&config)?;
753        assert_eq!(
754            second.file_name().unwrap(),
755            "config.toml.epicenter-bak.1",
756            "suffixes are monotonic"
757        );
758        assert_eq!(latest_backup(&config)?, Some(second.clone()));
759        assert_eq!(
760            fs::read_to_string(&first)?,
761            "[build]\nbuild-dir = '/one'\n",
762            "the older backup is untouched"
763        );
764        Ok(())
765    }
766
767    #[test]
768    fn revert_restores_the_newest_backup() -> Result<()> {
769        let dir = tempfile::tempdir()?;
770        let config = dir.path().join("config.toml");
771        fs::write(&config, "one\n")?;
772        let first = backup_file(&config)?;
773        fs::write(&config, "two\n")?;
774        let second = backup_file(&config)?;
775        fs::write(&config, "current\n")?;
776
777        let report = revert_config_at(&config)?;
778        assert_eq!(report.config, config.clone());
779        assert_eq!(report.backup, second);
780        assert_eq!(fs::read_to_string(&config)?, "two\n");
781        assert!(first.is_file() && second.is_file(), "backups are kept");
782
783        // Reverting again is idempotent: the newest backup is still the same.
784        let again = revert_config_at(&config)?;
785        assert_eq!(again.backup, second);
786        assert_eq!(fs::read_to_string(&config)?, "two\n");
787        Ok(())
788    }
789
790    #[test]
791    fn revert_without_a_backup_is_a_clear_error() {
792        let dir = tempfile::tempdir().unwrap();
793        let config = dir.path().join("config.toml");
794        let err = revert_config_at(&config).unwrap_err();
795        assert!(err.to_string().contains("no setup backup"), "{err}");
796    }
797
798    #[test]
799    fn inserts_fast_flags_into_an_existing_target_table() {
800        let src = "[build]\nbuild-dir = '/c'\n\n[target.x86_64-unknown-linux-gnu]\nlinker = \"clang\"\n\n[net]\nx = 1\n";
801        let literal = r#"[\"-C\", \"link-arg=-fuse-ld=lld\", \"-C\", \"link-arg=-B/tmp/gcc-ld\"]"#;
802        let (out, edit, conflict) = set_target_rustflags(src, "x86_64-unknown-linux-gnu", literal);
803        assert_eq!(edit, CargoEdit::Inserted);
804        assert!(!conflict);
805        assert!(
806            out.contains("linker = \"clang\""),
807            "existing key lost:\n{out}"
808        );
809        let target = out.find("[target.").unwrap();
810        let flags = out.find("rustflags").unwrap();
811        let net = out.find("[net]").unwrap();
812        assert!(
813            target < flags && flags < net,
814            "flags escaped the table:\n{out}"
815        );
816        assert!(out.contains(literal));
817    }
818
819    #[test]
820    fn fast_flags_are_never_overwritten() {
821        let src = "[target.x86_64-unknown-linux-gnu]\nrustflags = [\"-C\", \"custom\"]\n";
822        let (out, edit, conflict) =
823            set_target_rustflags(src, "x86_64-unknown-linux-gnu", "[\"-C\", \"ours\"]");
824        assert_eq!(edit, CargoEdit::AlreadySet);
825        assert!(conflict);
826        assert_eq!(out, src, "existing flags untouched");
827
828        // Re-running --fast with the same value is a clean no-op.
829        let same = "[target.x86_64-unknown-linux-gnu]\nrustflags = [\"-C\", \"ours\"]\n";
830        let (out, edit, conflict) =
831            set_target_rustflags(same, "x86_64-unknown-linux-gnu", "[\"-C\", \"ours\"]");
832        assert_eq!(edit, CargoEdit::AlreadySet);
833        assert!(!conflict);
834        assert_eq!(out, same);
835    }
836
837    #[test]
838    fn removes_only_our_fast_flags() {
839        let literal = r#"[\"-C\", \"link-arg=-fuse-ld=lld\"]"#;
840        let src = format!(
841            "[target.x86_64-unknown-linux-gnu]\nlinker = \"clang\"\nrustflags = {literal}\n\n[net]\nx = 1\n"
842        );
843        let (out, edit, conflict) =
844            remove_target_rustflags(&src, "x86_64-unknown-linux-gnu", literal);
845        assert_eq!(edit, CargoEdit::Removed);
846        assert!(!conflict);
847        assert!(!out.contains("rustflags"), "flags not removed:\n{out}");
848        assert!(
849            out.contains("linker = \"clang\""),
850            "other keys kept:\n{out}"
851        );
852
853        let custom = "[target.x86_64-unknown-linux-gnu]\nrustflags = [\"-C\", \"custom\"]\n";
854        let (out, edit, conflict) =
855            remove_target_rustflags(custom, "x86_64-unknown-linux-gnu", "[\"-C\", \"ours\"]");
856        assert_eq!(edit, CargoEdit::AlreadySet);
857        assert!(conflict);
858        assert_eq!(out, custom, "custom flags untouched");
859    }
860
861    #[test]
862    fn fast_flags_create_the_target_table_when_missing() {
863        let literal = r#"[\"-C\", \"link-arg=-fuse-ld=lld\"]"#;
864        let (out, edit, conflict) = set_target_rustflags(
865            "[build]\nbuild-dir = '/c'\n",
866            "x86_64-unknown-linux-gnu",
867            literal,
868        );
869        assert_eq!(edit, CargoEdit::Appended);
870        assert!(!conflict);
871        assert!(
872            out.contains("[target.x86_64-unknown-linux-gnu]\nrustflags = "),
873            "got:\n{out}"
874        );
875    }
876
877    #[test]
878    fn appends_table_without_merging_lines() {
879        // Regression test for the collect::<String> `Appended` bug: joining
880        // without separators fused `[net]` and `foo = 1` into one line.
881        let (out, edit) = set_build_key("[net]\nfoo = 1\n", "build-dir", "/cache");
882        assert_eq!(edit, CargoEdit::Appended);
883        assert!(!out.contains("[net]foo"), "lines merged:\n{out}");
884        assert!(out.contains("foo = 1"));
885        let net = out.find("[net]").unwrap();
886        let build = out.rfind("[build]").unwrap();
887        assert!(net < build);
888    }
889
890    #[test]
891    fn appends_build_table_when_missing() {
892        let (out, edit) = set_build_key("[net]\nfoo = 1\n", "build-dir", "/cache");
893        assert_eq!(edit, CargoEdit::Appended);
894        assert!(out.contains("[build]\nbuild-dir = '/cache'"), "got:\n{out}");
895    }
896
897    #[test]
898    fn inserts_into_existing_build_table() {
899        let (out, edit) = set_build_key(
900            "[build]\nrustflags = []\n\n[net]\nx = 1\n",
901            "build-dir",
902            "/cache",
903        );
904        assert_eq!(edit, CargoEdit::Inserted);
905        // build-dir must land inside [build], i.e. after the header but before [net].
906        let build = out.find("[build]").unwrap();
907        let dir = out.find("build-dir").unwrap();
908        let net = out.find("[net]").unwrap();
909        assert!(build < dir && dir < net, "got:\n{out}");
910    }
911
912    #[test]
913    fn replaces_old_value() {
914        let (out, edit) = set_build_key("[build]\nbuild-dir = '/old'\n", "build-dir", "/cache");
915        assert_eq!(edit, CargoEdit::Replaced);
916        assert!(out.contains("build-dir = '/cache'"));
917        assert!(!out.contains("/old"));
918    }
919
920    #[test]
921    fn already_set_is_a_no_op() {
922        let src = "[build]\nbuild-dir = '/cache'\n";
923        let (out, edit) = set_build_key(src, "build-dir", "/cache");
924        assert_eq!(edit, CargoEdit::AlreadySet);
925        assert_eq!(out, src);
926    }
927
928    #[test]
929    fn removes_wrapper_line() {
930        let src = "[build]\nbuild-dir = '/cache'\nrustc-wrapper = 'sccache'\n";
931        let (out, edit) = remove_build_key(src, "rustc-wrapper");
932        assert_eq!(edit, CargoEdit::Removed);
933        assert!(out.contains("build-dir"), "build-dir must survive:\n{out}");
934        assert!(!out.contains("rustc-wrapper"));
935    }
936
937    #[test]
938    fn remove_is_a_no_op_when_absent() {
939        let src = "[build]\nbuild-dir = '/cache'\n";
940        let (out, edit) = remove_build_key(src, "rustc-wrapper");
941        assert_eq!(edit, CargoEdit::AlreadySet);
942        assert_eq!(out, src);
943    }
944
945    #[test]
946    fn reads_key_from_build_table() {
947        assert_eq!(
948            read_build_key("[build]\nbuild-dir = '/cache' # comment\n", "build-dir"),
949            Some("/cache".to_string()),
950        );
951        assert_eq!(read_build_key("[build]\nother = 1\n", "build-dir"), None);
952        // A build-dir under some OTHER table must not be picked up.
953        assert_eq!(
954            read_build_key("[net]\nbuild-dir = '/x'\n", "build-dir"),
955            None
956        );
957    }
958
959    #[test]
960    fn reads_generic_table_keys() {
961        let text = "[unstable]\nfine-grain-locking = true # concurrent builds\n\n[build]\nbuild-dir = '/x'\n";
962        assert_eq!(
963            read_table_key(text, "unstable", "fine-grain-locking"),
964            Some("true".to_string())
965        );
966        assert_eq!(read_table_key(text, "build", "fine-grain-locking"), None);
967        assert_eq!(
968            read_table_key(text, "build", "build-dir"),
969            Some("/x".to_string())
970        );
971        // A key mentioned in a comment elsewhere must not leak.
972        assert_eq!(
973            read_table_key(
974                "[other]\n# fine-grain-locking = true\n",
975                "unstable",
976                "fine-grain-locking"
977            ),
978            None
979        );
980    }
981
982    #[test]
983    fn config_writes_reject_injection() {
984        assert!(validate_config_value("x'\nrustc-wrapper = '/tmp/evil' #", "build-dir").is_err());
985        assert!(validate_config_value("x\r\ny", "build-dir").is_err());
986        assert!(validate_config_value("x\0y", "build-dir").is_err());
987        assert!(validate_config_value("/tmp/cache", "build-dir").is_ok());
988    }
989
990    #[test]
991    fn removal_is_scoped_to_the_build_table() {
992        let src = "[build]\nbuild-dir = '/x'\n\n[target.x]\nrustc-wrapper = 'keep'\n";
993        let (out, edit) = remove_build_key(src, "rustc-wrapper");
994        assert_eq!(edit, CargoEdit::AlreadySet);
995        assert!(out.contains("rustc-wrapper = 'keep'"));
996    }
997
998    #[test]
999    fn dotted_build_dir_is_detected() {
1000        assert_eq!(
1001            read_build_key("build.build-dir = '/tmp/dotted'\n", "build-dir"),
1002            Some("/tmp/dotted".to_string())
1003        );
1004    }
1005
1006    #[test]
1007    fn comments_inside_quoted_values_are_preserved() {
1008        assert_eq!(
1009            read_build_key("[build]\nbuild-dir = '/tmp/a#b' # tail\n", "build-dir"),
1010            Some("/tmp/a#b".to_string())
1011        );
1012    }
1013
1014    #[test]
1015    fn cache_root_input_is_normalized() {
1016        // Cargo templates pass through untouched.
1017        assert_eq!(
1018            normalize_root_input("{cargo-cache-home}/custom").unwrap(),
1019            PathBuf::from("{cargo-cache-home}/custom"),
1020        );
1021        // Absolute stays absolute (use a host-absolute path).
1022        let absolute = if cfg!(windows) {
1023            r"C:\cache"
1024        } else {
1025            "/tmp/cache"
1026        };
1027        assert_eq!(
1028            normalize_root_input(absolute).unwrap(),
1029            PathBuf::from(absolute),
1030        );
1031        // `~` expands to the home directory.
1032        let home = paths::home().unwrap();
1033        assert_eq!(normalize_root_input("~/cache").unwrap(), home.join("cache"));
1034        // Relative resolves against the current directory.
1035        let cwd = std::env::current_dir().unwrap();
1036        assert_eq!(
1037            normalize_root_input("cache/build").unwrap(),
1038            cwd.join("cache/build"),
1039        );
1040    }
1041}