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/// Expand a cargo-style build-dir template value into a concrete path —
68/// for epicenter's local file operations only (never for writing configs).
69///
70/// - `{cargo-cache-home}` expands to `$CARGO_HOME` (or `~/.cargo`).
71/// - Separators are normalized to the HOST separator, mirroring cargo's own
72/// template expansion (verified: `...\epicenter\build` under Linux expands
73/// to `.../epicenter/build`) — so one canonical value serves both OSes.
74/// - Other template tokens (`{workspace-root}`, `{workspace-path-hash}`) are
75/// per-invocation in cargo: epicenter cannot resolve them in a *global*
76/// setting and errors out with an explanation instead of guessing.
77pub fn resolve_template(value: &str) -> Result<PathBuf> {
78 for token in brace_tokens(value) {
79 if token != "cargo-cache-home" {
80 anyhow::bail!(
81 "unsupported template variable {{{token}}} in {value:?}: \
82 epicenter can only resolve {{cargo-cache-home}}"
83 );
84 }
85 }
86 let expanded = value.replace("{cargo-cache-home}", &cargo_home()?.to_string_lossy());
87 Ok(PathBuf::from(
88 expanded.replace('\\', &std::path::MAIN_SEPARATOR.to_string()),
89 ))
90}
91
92/// Collect the `{...}` token names inside a template value.
93fn brace_tokens(value: &str) -> Vec<String> {
94 let mut tokens = Vec::new();
95 let mut rest = value;
96 while let Some(start) = rest.find('{') {
97 match rest[start..].find('}') {
98 Some(end) => {
99 tokens.push(rest[start + 1..start + end].to_string());
100 rest = &rest[start + end + 1..];
101 }
102 None => break,
103 }
104 }
105 tokens
106}
107
108#[cfg(test)]
109mod tests {
110 use super::*;
111
112 #[test]
113 fn expands_the_default_template() {
114 assert_eq!(
115 resolve_template(DEFAULT_BUILD_DIR).unwrap(),
116 cargo_home().unwrap().join("epicenter").join("build")
117 );
118 }
119
120 #[test]
121 fn plain_paths_pass_through_untouched() {
122 assert_eq!(
123 resolve_template("/tmp/cache").unwrap(),
124 PathBuf::from("/tmp/cache")
125 );
126 }
127
128 #[test]
129 fn backslash_template_mirrors_cargo_normalization() {
130 // Cargo normalizes `\` separators at template-expansion time (the
131 // same string is the canonical config line on EVERY OS) — the
132 // resolver must land on the same directory cargo builds into.
133 assert_eq!(
134 resolve_template(r"{cargo-cache-home}\epicenter\build").unwrap(),
135 cargo_home().unwrap().join("epicenter").join("build")
136 );
137 }
138
139 #[test]
140 fn unknown_template_tokens_are_rejected() {
141 assert!(resolve_template("{workspace-root}/build").is_err());
142 }
143}