Skip to main content

epicenter_cli/
paths.rs

1//! Where things live: `$HOME`, `$CARGO_HOME`, and the shared cache root.
2//!
3//! Everything epicenter touches derives from two roots:
4//! - Cargo's home (`$CARGO_HOME` or `~/.cargo`): global `config.toml`,
5//!   the already-shared source registry — and the shared build cache.
6//! - epicenter's own dir (`~/.epicenter`): reserved for epicenter files.
7//!
8//! The default cache path is expressed ONCE as a cargo template
9//! (`{cargo-cache-home}/epicenter/build`): cargo expands it at *build* time,
10//! and `resolve_template` expands it identically for epicenter's own file
11//! operations — so the two mechanisms can never drift apart. Same story on
12//! WSL (ext4) and native Windows (NTFS).
13
14use std::path::PathBuf;
15
16use anyhow::{Context, Result};
17
18/// The shared-build-dir template, as written into Cargo's global config —
19/// THE canonical value, identical on every OS, written with FORWARD slashes:
20/// it is the one form verified to work on Linux (backslash variants expand
21/// into paths whose separators cargo treats inconsistently on Linux — the
22/// artifacts never materialize on disk, basename-level `listdir` proves it)
23/// and forward slashes are legal path separators on Windows, too. Never
24/// write an expanded path into a config file: cargo expands
25/// `{cargo-cache-home}` itself at every build, per platform.
26pub const DEFAULT_BUILD_DIR: &str = "{cargo-cache-home}/epicenter/build";
27
28/// The user's home directory (`$HOME`, with a portable fallback).
29pub fn home() -> Result<PathBuf> {
30    if let Some(home) = std::env::var_os("HOME") {
31        if !home.is_empty() {
32            return Ok(PathBuf::from(home));
33        }
34    }
35    // Fallback (also correct when running natively on Windows).
36    std::env::home_dir()
37        .filter(|p| !p.as_os_str().is_empty())
38        .with_context(|| "could not determine the user home directory")
39}
40
41/// Cargo's home dir: `$CARGO_HOME`, or `~/.cargo`. Holds the global
42/// `config.toml`, the already-shared source cache (`registry/`) — and,
43/// and the shared build cache.
44pub fn cargo_home() -> Result<PathBuf> {
45    if let Some(dir) = std::env::var_os("CARGO_HOME") {
46        if !dir.is_empty() {
47            return Ok(PathBuf::from(dir));
48        }
49    }
50    Ok(home()?.join(".cargo"))
51}
52
53/// The `[build] build-dir` value from the **global**
54/// `$CARGO_HOME/config.toml`. This is the only trusted source for epicenter
55/// state. A relative config path, or a symlinked `config.toml`, is refused.
56pub fn cargo_build_dir() -> Result<Option<String>> {
57    let config_path = crate::config::cargo_config_path()?;
58    if !config_path.exists() {
59        return Ok(None);
60    }
61    let text = crate::util::read_trusted_config(&config_path, 1024 * 1024)
62        .with_context(|| format!("reading {}", config_path.display()))?;
63    Ok(crate::config::read_table_key(&text, "build", "build-dir")
64        .filter(|value| !value.trim().is_empty()))
65}
66
67/// epicenter's (former) own directory — only kept for the doctor's
68/// legacy-file advisory; the shared-cache decision lives in
69/// `~/.cargo/config.toml` now.
70pub fn epicenter_dir() -> Result<PathBuf> {
71    Ok(home()?.join(".epicenter"))
72}
73
74/// The canonical build-dir template, exactly as it is written into cargo's
75/// config. This is not a filesystem path; commands that touch files use
76/// [`cargo_resolved_build_dir`].
77pub fn default_cache_root() -> Result<PathBuf> {
78    Ok(PathBuf::from(DEFAULT_BUILD_DIR))
79}
80
81/// Expand a cargo-style build-dir template value into a concrete path —
82/// for epicenter's local file operations only (never for writing configs).
83///
84/// - `{cargo-cache-home}` expands to `$CARGO_HOME` (or `~/.cargo`).
85/// - Separators are normalized to the HOST separator, mirroring cargo's own
86///   template expansion (verified: `...\epicenter\build` under Linux expands
87///   to `.../epicenter/build`) — so one canonical value serves both OSes.
88/// - Other template tokens (`{workspace-root}`, `{workspace-path-hash}`) are
89///   per-invocation in cargo: epicenter cannot resolve them in a *global*
90///   setting and errors out with an explanation instead of guessing.
91pub fn resolve_template(value: &str) -> Result<PathBuf> {
92    for token in brace_tokens(value) {
93        if token != "cargo-cache-home" {
94            anyhow::bail!(
95                "unsupported template variable {{{token}}} in {value:?}: \
96                 epicenter can only resolve {{cargo-cache-home}}"
97            );
98        }
99    }
100    let expanded = value.replace("{cargo-cache-home}", &cargo_home()?.to_string_lossy());
101    Ok(PathBuf::from(
102        expanded.replace('\\', &std::path::MAIN_SEPARATOR.to_string()),
103    ))
104}
105
106/// Collect the `{...}` token names inside a template value.
107fn brace_tokens(value: &str) -> Vec<String> {
108    let mut tokens = Vec::new();
109    let mut rest = value;
110    while let Some(start) = rest.find('{') {
111        match rest[start..].find('}') {
112            Some(end) => {
113                tokens.push(rest[start + 1..start + end].to_string());
114                rest = &rest[start + end + 1..];
115            }
116            None => break,
117        }
118    }
119    tokens
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    #[test]
127    fn expands_the_default_template() {
128        assert_eq!(
129            resolve_template(DEFAULT_BUILD_DIR).unwrap(),
130            cargo_home().unwrap().join("epicenter").join("build")
131        );
132    }
133
134    #[test]
135    fn plain_paths_pass_through_untouched() {
136        assert_eq!(
137            resolve_template("/tmp/cache").unwrap(),
138            PathBuf::from("/tmp/cache")
139        );
140    }
141
142    #[test]
143    fn backslash_template_mirrors_cargo_normalization() {
144        // Cargo normalizes `\` separators at template-expansion time (the
145        // same string is the canonical config line on EVERY OS) — the
146        // resolver must land on the same directory cargo builds into.
147        assert_eq!(
148            resolve_template(r"{cargo-cache-home}\epicenter\build").unwrap(),
149            cargo_home().unwrap().join("epicenter").join("build")
150        );
151    }
152
153    #[test]
154    fn unknown_template_tokens_are_rejected() {
155        assert!(resolve_template("{workspace-root}/build").is_err());
156    }
157}