Skip to main content

anodizer_core/
path_util.rs

1//! Path-string utilities shared across config loading, env-file reading, and
2//! the template engine.
3
4use crate::EnvSource;
5use std::borrow::Cow;
6use std::path::{Path, PathBuf};
7
8/// Resolve the current user's home directory from the environment.
9///
10/// Prefers `$HOME` (set on every POSIX shell and on Windows under most CI /
11/// MSYS setups), falling back to `%USERPROFILE%` on Windows where `$HOME`
12/// is frequently unset. Empty values are treated as unset so a stray
13/// `HOME=` export does not collapse `~/foo` into `/foo`.
14fn home_dir_with_env<E: EnvSource + ?Sized>(env: &E) -> Option<PathBuf> {
15    if let Some(home) = env.var("HOME").filter(|h| !h.is_empty()) {
16        return Some(PathBuf::from(home));
17    }
18    env.var("USERPROFILE")
19        .filter(|h| !h.is_empty())
20        .map(PathBuf::from)
21}
22
23/// The spelling of `path` relative to the repo root it lives under: what
24/// anodizer prints for a repo-committed file, and what it hands `git add`.
25/// Components are joined with `/` on every platform ([`slash_display`]).
26///
27/// Every path anodizer prints for a repo-committed file is the spelling the
28/// user wrote (or would write) in their config, never the absolute path the
29/// process happened to resolve — an absolute path leaks a runner's scratch
30/// directory into logs a human reads and diffs. `root` is stripped when `path`
31/// is under it; a path outside the root has no relative spelling, so it is
32/// printed as-is. The repo root itself renders as `.`.
33///
34/// The bump commit stages the manifests it rewrote by this same spelling, so
35/// the returned string is a git pathspec as well as a display string: any
36/// formatting added for a reader's benefit would break staging.
37pub fn display_under_root(root: &Path, path: &Path) -> String {
38    // A path that was resolved through the filesystem (`canonicalize`) names
39    // the real directory while `root` keeps the spelling the user gave, so a
40    // symlinked root (macOS puts `$TMPDIR` under `/var`, a symlink to
41    // `/private/var`) strips under neither spelling alone.
42    let relative = match path.strip_prefix(root) {
43        Ok(relative) => relative.to_path_buf(),
44        Err(_) => std::fs::canonicalize(root)
45            .ok()
46            .and_then(|real_root| path.strip_prefix(real_root).ok().map(Path::to_path_buf))
47            .unwrap_or_else(|| path.to_path_buf()),
48    };
49    // A `.` component survives `join` (`<root>/./Cargo.toml`), and a config
50    // declaring the root crate as `.` is the common case — drop it so the
51    // manifest prints as `Cargo.toml`, the spelling a reader would search for.
52    let cleaned: PathBuf = relative
53        .components()
54        .filter(|c| !matches!(c, std::path::Component::CurDir))
55        .collect();
56    let rendered = slash_display(&cleaned);
57    if rendered.is_empty() {
58        ".".to_string()
59    } else {
60        rendered
61    }
62}
63
64/// `path` rendered with `/` between components on every platform: the
65/// spelling a config, a git pathspec and a log line share, so a message or a
66/// structural test compares the same text on Windows as on Unix.
67pub fn slash_display(path: &Path) -> String {
68    let rendered = path.display().to_string();
69    if cfg!(windows) {
70        rendered.replace('\\', "/")
71    } else {
72        rendered
73    }
74}
75
76/// A guaranteed-to-exist working directory for cwd-agnostic subprocess probes.
77///
78/// Detection probes like `rustc -vV`, `<tool> --version`, and
79/// `docker buildx version` read nothing relative to the working directory, but
80/// the spawned process still calls `getcwd()` at startup and aborts ("Could
81/// not locate working directory") if the *inherited* cwd has been removed.
82/// Tests that swap the process-global cwd into a tempdir and tear it down can
83/// leave exactly that state, and a rotated/cleaned scratch dir can do so in
84/// production. Pinning such probes to this directory makes them independent of
85/// the inherited cwd. Returns the system temp dir, which always exists.
86pub fn probe_dir() -> PathBuf {
87    std::env::temp_dir()
88}
89
90/// Expand a leading `~` into the user's home directory.
91///
92/// `~` is rewritten only when it appears at the very start of `path` AND is
93/// followed by `/` (or end-of-string), mirroring the POSIX-shell
94/// word-initial tilde rule; anywhere else the literal `~` is preserved so a
95/// path like `./safe~backup.yaml` survives untouched.
96///
97/// `~user/...` (POSIX user-home form) is NOT supported — resolving an
98/// arbitrary user's home requires a `getpwnam(3)` call (or platform
99/// equivalent) that anodizer deliberately avoids for the security and
100/// cross-platform-portability cost; such a path is returned unchanged.
101///
102/// The home directory is sourced from `$HOME`, falling back to
103/// `%USERPROFILE%` on Windows. When neither is set (or `path` has no leading
104/// `~/`), the input is returned unchanged. A `Cow::Borrowed` is returned for
105/// the non-expanding case to avoid an allocation.
106pub fn expand_tilde(path: &str) -> Cow<'_, str> {
107    expand_tilde_with_env(path, &crate::ProcessEnvSource)
108}
109
110/// [`EnvSource`]-injecting form of [`expand_tilde`].
111///
112/// Resolves the home directory from `env` (`HOME`, then `USERPROFILE`)
113/// instead of the process environment, so callers and tests can drive
114/// tilde expansion deterministically without mutating global env state.
115pub fn expand_tilde_with_env<'p, E: EnvSource + ?Sized>(path: &'p str, env: &E) -> Cow<'p, str> {
116    if let Some(rest) = path.strip_prefix('~')
117        && (rest.is_empty() || rest.starts_with('/'))
118        && let Some(home) = home_dir_with_env(env)
119    {
120        let rest_trimmed = rest.strip_prefix('/').unwrap_or(rest);
121        // `home.join("")` would append a trailing separator, so bare `~` and
122        // `~/` must short-circuit to the home directory itself.
123        let resolved = if rest_trimmed.is_empty() {
124            home
125        } else {
126            home.join(rest_trimmed)
127        };
128        return Cow::Owned(resolved.to_string_lossy().into_owned());
129    }
130    Cow::Borrowed(path)
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136    use crate::MapEnvSource;
137
138    // Home-directory resolution is driven through an injected `MapEnvSource`
139    // so these tests never touch the process environment and run race-free in
140    // parallel with the rest of the crate's suite.
141
142    /// A path resolved through a symlinked root still prints relative to the
143    /// root as the user spelled it: `sync_workspace_deps` walks the canonical
144    /// workspace, and on macOS every tempdir is reached through `/var` →
145    /// `/private/var`.
146    #[cfg(unix)]
147    #[test]
148    fn display_under_root_strips_a_symlinked_root() {
149        let tmp = tempfile::tempdir().unwrap();
150        let real = tmp.path().join("real");
151        std::fs::create_dir_all(real.join("crates/app")).unwrap();
152        let link = tmp.path().join("link");
153        std::os::unix::fs::symlink(&real, &link).unwrap();
154        let resolved = std::fs::canonicalize(&link)
155            .unwrap()
156            .join("crates/app/Cargo.toml");
157        assert_eq!(
158            display_under_root(&link, &resolved),
159            PathBuf::from("crates/app/Cargo.toml").display().to_string()
160        );
161    }
162
163    /// The three spellings a message can face: a nested path under the root, the
164    /// root's own manifest reached through a `.` crate dir, and a path that is
165    /// not under the root at all (nothing to strip, so it prints in full).
166    #[test]
167    fn display_under_root_strips_the_root_and_the_dot() {
168        let root = Path::new("/repo");
169        assert_eq!(
170            display_under_root(root, &root.join("crates/app").join("Cargo.toml")),
171            PathBuf::from("crates/app/Cargo.toml").display().to_string()
172        );
173        assert_eq!(
174            display_under_root(root, &root.join(".").join("Cargo.toml")),
175            "Cargo.toml"
176        );
177        assert_eq!(display_under_root(root, &root.join(".")), ".");
178        assert_eq!(
179            display_under_root(root, Path::new("/elsewhere/Cargo.toml")),
180            PathBuf::from("/elsewhere/Cargo.toml").display().to_string()
181        );
182    }
183
184    #[test]
185    fn expands_leading_tilde_slash() {
186        let env = MapEnvSource::new().with("HOME", "/home/tester");
187        let expected = PathBuf::from("/home/tester")
188            .join("x")
189            .to_string_lossy()
190            .into_owned();
191        assert_eq!(expand_tilde_with_env("~/x", &env), expected);
192    }
193
194    #[test]
195    fn expands_bare_tilde() {
196        let env = MapEnvSource::new().with("HOME", "/home/tester");
197        assert_eq!(expand_tilde_with_env("~", &env), "/home/tester");
198    }
199
200    #[test]
201    fn passes_through_non_tilde_path() {
202        let env = MapEnvSource::new().with("HOME", "/home/tester");
203        assert_eq!(
204            expand_tilde_with_env("/etc/anodizer.yaml", &env),
205            "/etc/anodizer.yaml"
206        );
207        assert_eq!(
208            expand_tilde_with_env("./safe~backup.yaml", &env),
209            "./safe~backup.yaml"
210        );
211    }
212
213    #[test]
214    fn user_home_form_not_expanded() {
215        let env = MapEnvSource::new().with("HOME", "/home/tester");
216        assert_eq!(expand_tilde_with_env("~bob/foo", &env), "~bob/foo");
217        assert_eq!(expand_tilde_with_env("~bob", &env), "~bob");
218    }
219
220    #[test]
221    fn falls_back_to_userprofile() {
222        // HOME unset (absent from the map), USERPROFILE present.
223        let env = MapEnvSource::new().with("USERPROFILE", "/Users/winuser");
224        let got = expand_tilde_with_env("~/docs", &env).into_owned();
225        let expected = PathBuf::from("/Users/winuser")
226            .join("docs")
227            .to_string_lossy()
228            .into_owned();
229        assert_eq!(got, expected);
230    }
231}