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/// epicenter's (former) own directory — only kept for the doctor's
54/// legacy-file advisory; the shared-cache decision lives in
55/// `~/.cargo/config.toml` now.
56pub fn epicenter_dir() -> Result<PathBuf> {
57    Ok(home()?.join(".epicenter"))
58}
59
60/// The canonical build-dir template, exactly as it is written into cargo's
61/// config. This is not a filesystem path; commands that touch files use
62/// [`cargo_resolved_build_dir`].
63pub fn default_cache_root() -> Result<PathBuf> {
64    Ok(PathBuf::from(DEFAULT_BUILD_DIR))
65}
66
67/// The directory cargo actually uses for the shared build cache — resolved
68/// from cargo itself, at runtime.
69///
70/// Order:
71/// 1. `cargo metadata` (run in the current context — it merges every config
72///    layer exactly like a real build and reports `build_directory`).
73///    Its reported separators are then normalized to the HOST separator,
74///    because cargo's on-disk layout is host-normalized while the metadata
75///    string can carry template-literal backslashes (verified on Linux).
76/// 2. Fallback when cargo can't answer (outside any project, broken env):
77///    local expansion of [`DEFAULT_BUILD_DIR`], same normalization — never
78///    written back into any config.
79pub fn cargo_resolved_build_dir() -> Result<PathBuf> {
80    if let Some(dir) = cargo_metadata_build_dir() {
81        return Ok(normalize_host_separators(dir));
82    }
83    resolve_template(DEFAULT_BUILD_DIR)
84}
85
86/// Ask cargo for its `build_directory` (`None` when cargo metadata fails,
87/// e.g. when not inside any Cargo workspace).
88pub fn cargo_metadata_build_dir() -> Option<PathBuf> {
89    let output = std::process::Command::new("cargo")
90        .arg("metadata")
91        .arg("--format-version")
92        .arg("1")
93        .output()
94        .ok()?;
95    if !output.status.success() {
96        return None;
97    }
98    let text = String::from_utf8(output.stdout).ok()?;
99    let parsed: serde_json::Value = serde_json::from_str(&text).ok()?;
100    parsed.get("build_directory")?.as_str().map(PathBuf::from)
101}
102
103/// Template-literal backslashes -> host separator (see [`cargo_resolved_build_dir`]).
104pub fn normalize_host_separators(path: PathBuf) -> PathBuf {
105    PathBuf::from(
106        path.to_string_lossy()
107            .replace('\\', &std::path::MAIN_SEPARATOR.to_string()),
108    )
109}
110
111/// Expand a cargo-style build-dir template value into a concrete path —
112/// for epicenter's local file operations only (never for writing configs).
113///
114/// - `{cargo-cache-home}` expands to `$CARGO_HOME` (or `~/.cargo`).
115/// - Separators are normalized to the HOST separator, mirroring cargo's own
116///   template expansion (verified: `...\epicenter\build` under Linux expands
117///   to `.../epicenter/build`) — so one canonical value serves both OSes.
118/// - Other template tokens (`{workspace-root}`, `{workspace-path-hash}`) are
119///   per-invocation in cargo: epicenter cannot resolve them in a *global*
120///   setting and errors out with an explanation instead of guessing.
121pub fn resolve_template(value: &str) -> Result<PathBuf> {
122    for token in brace_tokens(value) {
123        if token != "cargo-cache-home" {
124            anyhow::bail!(
125                "unsupported template variable {{{token}}} in {value:?}: \
126                 epicenter can only resolve {{cargo-cache-home}}"
127            );
128        }
129    }
130    let expanded = value.replace("{cargo-cache-home}", &cargo_home()?.to_string_lossy());
131    Ok(PathBuf::from(
132        expanded.replace('\\', &std::path::MAIN_SEPARATOR.to_string()),
133    ))
134}
135
136/// Collect the `{...}` token names inside a template value.
137fn brace_tokens(value: &str) -> Vec<String> {
138    let mut tokens = Vec::new();
139    let mut rest = value;
140    while let Some(start) = rest.find('{') {
141        match rest[start..].find('}') {
142            Some(end) => {
143                tokens.push(rest[start + 1..start + end].to_string());
144                rest = &rest[start + end + 1..];
145            }
146            None => break,
147        }
148    }
149    tokens
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn expands_the_default_template() {
158        assert_eq!(
159            resolve_template(DEFAULT_BUILD_DIR).unwrap(),
160            cargo_home().unwrap().join("epicenter").join("build")
161        );
162    }
163
164    #[test]
165    fn plain_paths_pass_through_untouched() {
166        assert_eq!(
167            resolve_template("/tmp/cache").unwrap(),
168            PathBuf::from("/tmp/cache")
169        );
170    }
171
172    #[test]
173    fn backslash_template_mirrors_cargo_normalization() {
174        // Cargo normalizes `\` separators at template-expansion time (the
175        // same string is the canonical config line on EVERY OS) — the
176        // resolver must land on the same directory cargo builds into.
177        assert_eq!(
178            resolve_template(r"{cargo-cache-home}\epicenter\build").unwrap(),
179            cargo_home().unwrap().join("epicenter").join("build")
180        );
181    }
182
183    #[test]
184    fn unknown_template_tokens_are_rejected() {
185        assert!(resolve_template("{workspace-root}/build").is_err());
186    }
187}