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}
143
144impl CargoEdit {
145    pub fn describe(&self) -> &'static str {
146        match self {
147            CargoEdit::Created => "created the file",
148            CargoEdit::Inserted => "added",
149            CargoEdit::Replaced => "updated",
150            CargoEdit::Removed => "removed",
151            CargoEdit::AlreadySet => "already set, unchanged",
152            CargoEdit::Appended => "appended line at the EOF",
153        }
154    }
155}
156
157/// Sets `key = 'value'` inside the `[build]` table of a Cargo config,
158/// as pure text surgery so user comments and formatting survive untouched.
159fn set_build_key(text: &str, key: &str, value: &str) -> (String, CargoEdit) {
160    let wanted = format!("{key} = '{value}'");
161    let mut lines: Vec<String> = text.lines().map(str::to_string).collect();
162
163    // Exact header match (whitespace-insensitive) — `.contains("[build]")`
164    // would also match comments or sub-table names.
165    let header = lines
166        .iter()
167        .position(|l| l.trim().replace([' ', '\t'], "") == "[build]");
168
169    match header {
170        Some(i) => {
171            // Scan to the next table header: that is the whole [build] table.
172            let mut j = i + 1;
173            while j < lines.len() {
174                let t = lines[j].trim();
175                if t.starts_with('[') && t.ends_with(']') {
176                    break;
177                }
178                if t.split_once('=').map_or(false, |(k, _)| k.trim() == key) {
179                    if t == wanted {
180                        return (text.to_string(), CargoEdit::AlreadySet);
181                    }
182                    lines[j] = wanted;
183                    return (lines.join("\n") + "\n", CargoEdit::Replaced);
184                }
185                j += 1;
186            }
187            lines.insert(i + 1, wanted);
188            (lines.join("\n") + "\n", CargoEdit::Inserted)
189        }
190        None => {
191            let needs_blank = lines.last().map_or(false, |l| !l.trim().is_empty());
192            if needs_blank {
193                lines.push(String::new());
194            }
195            lines.push("[build]".to_string());
196            lines.push(wanted);
197            (lines.join("\n") + "\n", CargoEdit::Appended)
198        }
199    }
200}
201
202/// delete `key = ...` from the `[build]` table, if present.
203fn remove_build_key(text: &str, key: &str) -> (String, CargoEdit) {
204    let mut out: Vec<String> = Vec::new();
205    let mut in_build = false;
206    let mut removed = false;
207    for line in text.lines() {
208        let trimmed = line.trim();
209        if trimmed.starts_with('[') && trimmed.ends_with(']') {
210            in_build = trimmed.replace([' ', '\t'], "") == "[build]";
211            out.push(line.to_string());
212            continue;
213        }
214        if in_build
215            && trimmed
216                .split_once('=')
217                .map_or(false, |(candidate, _)| candidate.trim() == key)
218        {
219            removed = true;
220            continue;
221        }
222        out.push(line.to_string());
223    }
224    if removed {
225        (out.join("\n") + "\n", CargoEdit::Removed)
226    } else {
227        (text.to_string(), CargoEdit::AlreadySet)
228    }
229}
230/// Parse the value of `key` out of the `[build]` table.
231fn read_build_key(text: &str, key: &str) -> Option<String> {
232    read_table_key(text, "build", key)
233}
234
235/// Strip a trailing `#` comment while respecting single/double quoted values.
236fn strip_toml_comment(value: &str) -> &str {
237    let mut quote: Option<char> = None;
238    let mut escaped = false;
239    for (index, ch) in value.char_indices() {
240        if escaped {
241            escaped = false;
242            continue;
243        }
244        match quote {
245            Some(q) => {
246                if ch == '\\' && q == '"' {
247                    escaped = true;
248                } else if ch == q {
249                    quote = None;
250                }
251            }
252            None => {
253                if ch == '#' {
254                    return &value[..index];
255                }
256                if ch == '"' || ch == '\'' {
257                    quote = Some(ch);
258                }
259            }
260        }
261    }
262    value
263}
264
265/// Parse `key = value` out of any `[table]` of a cargo config text.
266///
267/// Only the exact table is scanned (until the next header line); a trailing
268/// comment is stripped and surrounding quotes are removed.
269pub fn read_table_key(text: &str, table: &str, key: &str) -> Option<String> {
270    let header = format!("[{table}]");
271    let dotted = format!("{table}.{key}");
272    let mut in_table = false;
273    let mut at_root = true;
274    for line in text.lines() {
275        let trimmed = line.trim();
276        if trimmed.starts_with('[') && trimmed.ends_with(']') {
277            at_root = false;
278            in_table = trimmed.replace([' ', '\t'], "") == header;
279            continue;
280        }
281        let candidate = trimmed.split_once('=').map(|(k, _)| k.trim());
282        // TOML allows `build.build-dir = ...` at the document root.
283        if at_root && candidate == Some(dotted.as_str()) {
284            if let Some((_, value)) = trimmed.split_once('=') {
285                return Some(clean_toml_value(value));
286            }
287        }
288        if !in_table {
289            continue;
290        }
291        if let Some((candidate, value)) = trimmed.split_once('=') {
292            if candidate.trim() != key {
293                continue;
294            }
295            return Some(clean_toml_value(value));
296        }
297    }
298    None
299}
300
301fn clean_toml_value(value: &str) -> String {
302    let value = strip_toml_comment(value).trim();
303    let value = value
304        .strip_prefix('\'')
305        .and_then(|v| v.strip_suffix('\''))
306        .or_else(|| value.strip_prefix('"').and_then(|v| v.strip_suffix('"')))
307        .unwrap_or(value);
308    value.trim().to_string()
309}
310
311/// Look up a key in any table of Cargo's *global* config file.
312pub fn cargo_configured_in(table: &str, key: &str) -> Result<Option<String>> {
313    let path = cargo_config_path()?;
314    if !path.exists() {
315        return Ok(None);
316    }
317    let text = crate::util::read_trusted_config(&path, 1024 * 1024)?;
318    Ok(read_table_key(&text, table, key))
319}
320
321/// Look up a `[build]` key in Cargo's *global* config file on disk.
322pub fn cargo_configured(key: &str) -> Result<Option<String>> {
323    let path = cargo_config_path()?;
324    if !path.exists() {
325        return Ok(None);
326    }
327    let text = crate::util::read_trusted_config(&path, 1024 * 1024)?;
328    Ok(read_build_key(&text, key))
329}
330
331/// Read a `[build]` key from ANY cargo config file — doctor uses this to scan
332/// the ancestor `.cargo/config.toml` chain for shadowing values. Read-only.
333pub fn read_build_table_key(path: &Path, key: &str) -> Option<String> {
334    let text = crate::util::read_small_text(path, 1024 * 1024).ok()?;
335    read_build_key(&text, key)
336}
337
338pub struct SetupReport {
339    /// Where the shared cache now lives.
340    pub root: PathBuf,
341    /// What happened to the `build-dir` setting.
342    pub build_dir_edit: CargoEdit,
343    /// What happened to the `rustc-wrapper` (kache/sccache) setting, if touched.
344    pub wrapper_edit: Option<CargoEdit>,
345    /// Backup of the previous cargo config, if it was modified.
346    pub backup: Option<PathBuf>,
347    /// Is the override mode active (vs the canonical template)?
348    pub using_override: bool,
349}
350
351/// The full `epicenter setup` orchestration: compute the decision, make the
352/// directory, and write/merge the cargo config.
353/// Normalize a user-provided `--cache-root` value.
354///
355/// - cargo template strings (`{…}`) pass through untouched — they resolve at
356///   build time via cargo, exactly like the default template;
357/// - `~` expands to the home directory;
358/// - relative paths resolve against the current directory;
359/// - absolute paths stay as-is.
360pub fn normalize_root_input(raw: &str) -> Result<PathBuf> {
361    let raw = raw.trim();
362    anyhow::ensure!(!raw.is_empty(), "--cache-root must not be empty");
363    if raw.contains('{') && raw.contains('}') {
364        return Ok(PathBuf::from(raw)); // template passthrough
365    }
366    let home = paths::home()?;
367    if raw == "~" || raw.starts_with("~/") || raw.starts_with("~\\") {
368        let rest = raw[1..].trim_start_matches(['/', '\\']);
369        return Ok(home.join(rest));
370    }
371    let path = PathBuf::from(raw);
372    if path.is_absolute() {
373        return Ok(path);
374    }
375    let cwd = std::env::current_dir().context("resolving a relative --cache-root")?;
376    Ok(cwd.join(path))
377}
378
379/// Priority: CLI root > stored override (`~/.epicenter/config.toml`) > default
380/// template. `--cache-root` and `--use-default` rewrite the stored decision;
381/// without them the stored decision (if any) stays untouched.
382pub fn setup(
383    root_arg: Option<&str>,
384    use_default: bool,
385    with_wrapper: Option<&str>,
386    without_wrapper: bool,
387) -> Result<SetupReport> {
388    // Decision precedence: CLI root > the value already in cargo's config >
389    // the canonical default template. `--cache-root` / `--use-default`
390    // REWRITE the cargo-config decision; without them it stays untouched.
391    // There is no epicenter side-file — `~/.cargo/config.toml` is the state.
392    let build_dir_value: String = if let Some(raw) = root_arg {
393        normalize_root_input(raw)?.to_string_lossy().into_owned()
394    } else if use_default {
395        DEFAULT_BUILD_DIR.to_string()
396    } else {
397        paths::cargo_build_dir()?.unwrap_or_else(|| DEFAULT_BUILD_DIR.to_string())
398    };
399
400    validate_config_value(&build_dir_value, "build-dir")?;
401    if let Some(name) = with_wrapper {
402        validate_config_value(name, "rustc-wrapper")?;
403    }
404
405    // The filesystem view must always be RESOLVED (a template is not a path).
406    let root = paths::resolve_template(&build_dir_value)?;
407    crate::util::ensure_windows_user_path(&root, "cache root")?;
408    crate::util::create_private_dir_all(&root)
409        .with_context(|| format!("creating cache dir {}", root.display()))?;
410
411    // No epicenter side-file: the decision itself is the cargo config below.
412    let cargo_cfg = cargo_config_path()?;
413    let (build_dir_edit, wrapper_edit, backup) = if !cargo_cfg.exists() {
414        if let Some(parent) = cargo_cfg.parent() {
415            crate::util::create_private_dir_all(parent)
416                .with_context(|| format!("creating {}", parent.display()))?;
417        }
418        let mut text = format!("[build]\nbuild-dir = '{build_dir_value}'\n");
419        let wrapper_edit = if let Some(name) = with_wrapper {
420            text.push_str(&format!("rustc-wrapper = '{name}'\n"));
421            Some(CargoEdit::Inserted)
422        } else {
423            None
424        };
425        crate::util::write_atomic_private(&cargo_cfg, text.as_bytes())?;
426        (CargoEdit::Created, wrapper_edit, None)
427    } else {
428        let text = crate::util::read_trusted_config(&cargo_cfg, 1024 * 1024)
429            .with_context(|| format!("reading {}", cargo_cfg.display()))?;
430        let (text, build_dir_edit) = set_build_key(&text, "build-dir", &build_dir_value);
431        let (text, wrapper_edit) = match with_wrapper {
432            Some(name) => {
433                let (text, edit) = set_build_key(&text, "rustc-wrapper", name);
434                (text, Some(edit))
435            }
436            None if without_wrapper => {
437                let (text, edit) = remove_build_key(&text, "rustc-wrapper");
438                (text, Some(edit))
439            }
440            None => (text, None),
441        };
442        let mut backup = None;
443        let changed = build_dir_edit != CargoEdit::AlreadySet
444            || wrapper_edit
445                .as_ref()
446                .is_some_and(|e| *e != CargoEdit::AlreadySet);
447        if changed {
448            backup = Some(backup_file(&cargo_cfg)?);
449            crate::util::write_atomic_private(&cargo_cfg, text.as_bytes())?;
450        }
451        (build_dir_edit, wrapper_edit, backup)
452    };
453
454    Ok(SetupReport {
455        root,
456        build_dir_edit,
457        wrapper_edit,
458        backup,
459        using_override: build_dir_value != DEFAULT_BUILD_DIR,
460    })
461}
462
463/// Copy `path` to `path.epicenter-bak` before modifying it.
464fn backup_file(path: &Path) -> Result<PathBuf> {
465    let mut name = path.file_name().unwrap_or_default().to_os_string();
466    name.push(".epicenter-bak");
467    let base = path.with_file_name(name);
468    // Never write through a pre-existing symlink; pick a fresh name instead.
469    let mut backup = base.clone();
470    let mut suffix = 0u32;
471    while fs::symlink_metadata(&backup).is_ok() {
472        suffix += 1;
473        let mut name = base.file_name().unwrap_or_default().to_os_string();
474        name.push(format!(".{suffix}"));
475        backup = base.with_file_name(name);
476    }
477    // Create the backup with O_EXCL; the source is opened O_NOFOLLOW by
478    // copy_file_hashed so a swapped symlink cannot redirect the copy.
479    let source = path.to_path_buf();
480    crate::util::copy_file_hashed(&source, &backup)
481        .with_context(|| format!("backing up {}", path.display()))?;
482    if let Ok(meta) = fs::metadata(&source) {
483        let _ = fs::set_permissions(&backup, meta.permissions());
484    }
485    Ok(backup)
486}
487
488#[cfg(test)]
489mod tests {
490    use super::*;
491
492    #[test]
493    fn replaced_line_keeps_the_rest_of_the_file() {
494        // Regression test for the split_inclusive `Replaced` bug: the kache
495        // line and the [net] table had to survive the replacement.
496        let src = "[build]\nbuild-dir = '/old'\nrustc-wrapper = \"kache\"\n\n[net]\nx = 1\n";
497        let (out, edit) = set_build_key(src, "build-dir", "/cache");
498        assert_eq!(edit, CargoEdit::Replaced);
499        assert!(
500            out.contains("rustc-wrapper = \"kache\""),
501            "tail deleted:\n{out}"
502        );
503        assert!(out.contains("[net]"), "tail deleted:\n{out}");
504        let dir = out.find("build-dir = '/cache'").unwrap();
505        let net = out.find("[net]").unwrap();
506        assert!(dir < net, "replaced value must stay inside [build]:\n{out}");
507    }
508
509    #[test]
510    fn appends_table_without_merging_lines() {
511        // Regression test for the collect::<String> `Appended` bug: joining
512        // without separators fused `[net]` and `foo = 1` into one line.
513        let (out, edit) = set_build_key("[net]\nfoo = 1\n", "build-dir", "/cache");
514        assert_eq!(edit, CargoEdit::Appended);
515        assert!(!out.contains("[net]foo"), "lines merged:\n{out}");
516        assert!(out.contains("foo = 1"));
517        let net = out.find("[net]").unwrap();
518        let build = out.rfind("[build]").unwrap();
519        assert!(net < build);
520    }
521
522    #[test]
523    fn appends_build_table_when_missing() {
524        let (out, edit) = set_build_key("[net]\nfoo = 1\n", "build-dir", "/cache");
525        assert_eq!(edit, CargoEdit::Appended);
526        assert!(out.contains("[build]\nbuild-dir = '/cache'"), "got:\n{out}");
527    }
528
529    #[test]
530    fn inserts_into_existing_build_table() {
531        let (out, edit) = set_build_key(
532            "[build]\nrustflags = []\n\n[net]\nx = 1\n",
533            "build-dir",
534            "/cache",
535        );
536        assert_eq!(edit, CargoEdit::Inserted);
537        // build-dir must land inside [build], i.e. after the header but before [net].
538        let build = out.find("[build]").unwrap();
539        let dir = out.find("build-dir").unwrap();
540        let net = out.find("[net]").unwrap();
541        assert!(build < dir && dir < net, "got:\n{out}");
542    }
543
544    #[test]
545    fn replaces_old_value() {
546        let (out, edit) = set_build_key("[build]\nbuild-dir = '/old'\n", "build-dir", "/cache");
547        assert_eq!(edit, CargoEdit::Replaced);
548        assert!(out.contains("build-dir = '/cache'"));
549        assert!(!out.contains("/old"));
550    }
551
552    #[test]
553    fn already_set_is_a_no_op() {
554        let src = "[build]\nbuild-dir = '/cache'\n";
555        let (out, edit) = set_build_key(src, "build-dir", "/cache");
556        assert_eq!(edit, CargoEdit::AlreadySet);
557        assert_eq!(out, src);
558    }
559
560    #[test]
561    fn removes_wrapper_line() {
562        let src = "[build]\nbuild-dir = '/cache'\nrustc-wrapper = 'sccache'\n";
563        let (out, edit) = remove_build_key(src, "rustc-wrapper");
564        assert_eq!(edit, CargoEdit::Removed);
565        assert!(out.contains("build-dir"), "build-dir must survive:\n{out}");
566        assert!(!out.contains("rustc-wrapper"));
567    }
568
569    #[test]
570    fn remove_is_a_no_op_when_absent() {
571        let src = "[build]\nbuild-dir = '/cache'\n";
572        let (out, edit) = remove_build_key(src, "rustc-wrapper");
573        assert_eq!(edit, CargoEdit::AlreadySet);
574        assert_eq!(out, src);
575    }
576
577    #[test]
578    fn reads_key_from_build_table() {
579        assert_eq!(
580            read_build_key("[build]\nbuild-dir = '/cache' # comment\n", "build-dir"),
581            Some("/cache".to_string()),
582        );
583        assert_eq!(read_build_key("[build]\nother = 1\n", "build-dir"), None);
584        // A build-dir under some OTHER table must not be picked up.
585        assert_eq!(
586            read_build_key("[net]\nbuild-dir = '/x'\n", "build-dir"),
587            None
588        );
589    }
590
591    #[test]
592    fn reads_generic_table_keys() {
593        let text = "[unstable]\nfine-grain-locking = true # concurrent builds\n\n[build]\nbuild-dir = '/x'\n";
594        assert_eq!(
595            read_table_key(text, "unstable", "fine-grain-locking"),
596            Some("true".to_string())
597        );
598        assert_eq!(read_table_key(text, "build", "fine-grain-locking"), None);
599        assert_eq!(
600            read_table_key(text, "build", "build-dir"),
601            Some("/x".to_string())
602        );
603        // A key mentioned in a comment elsewhere must not leak.
604        assert_eq!(
605            read_table_key(
606                "[other]\n# fine-grain-locking = true\n",
607                "unstable",
608                "fine-grain-locking"
609            ),
610            None
611        );
612    }
613
614    #[test]
615    fn config_writes_reject_injection() {
616        assert!(validate_config_value("x'\nrustc-wrapper = '/tmp/evil' #", "build-dir").is_err());
617        assert!(validate_config_value("x\r\ny", "build-dir").is_err());
618        assert!(validate_config_value("x\0y", "build-dir").is_err());
619        assert!(validate_config_value("/tmp/cache", "build-dir").is_ok());
620    }
621
622    #[test]
623    fn removal_is_scoped_to_the_build_table() {
624        let src = "[build]\nbuild-dir = '/x'\n\n[target.x]\nrustc-wrapper = 'keep'\n";
625        let (out, edit) = remove_build_key(src, "rustc-wrapper");
626        assert_eq!(edit, CargoEdit::AlreadySet);
627        assert!(out.contains("rustc-wrapper = 'keep'"));
628    }
629
630    #[test]
631    fn dotted_build_dir_is_detected() {
632        assert_eq!(
633            read_build_key("build.build-dir = '/tmp/dotted'\n", "build-dir"),
634            Some("/tmp/dotted".to_string())
635        );
636    }
637
638    #[test]
639    fn comments_inside_quoted_values_are_preserved() {
640        assert_eq!(
641            read_build_key("[build]\nbuild-dir = '/tmp/a#b' # tail\n", "build-dir"),
642            Some("/tmp/a#b".to_string())
643        );
644    }
645
646    #[test]
647    fn cache_root_input_is_normalized() {
648        // Cargo templates pass through untouched.
649        assert_eq!(
650            normalize_root_input("{cargo-cache-home}/custom").unwrap(),
651            PathBuf::from("{cargo-cache-home}/custom"),
652        );
653        // Absolute stays absolute (use a host-absolute path).
654        let absolute = if cfg!(windows) {
655            r"C:\cache"
656        } else {
657            "/tmp/cache"
658        };
659        assert_eq!(
660            normalize_root_input(absolute).unwrap(),
661            PathBuf::from(absolute),
662        );
663        // `~` expands to the home directory.
664        let home = paths::home().unwrap();
665        assert_eq!(normalize_root_input("~/cache").unwrap(), home.join("cache"));
666        // Relative resolves against the current directory.
667        let cwd = std::env::current_dir().unwrap();
668        assert_eq!(
669            normalize_root_input("cache/build").unwrap(),
670            cwd.join("cache/build"),
671        );
672    }
673}