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}