Skip to main content

dev_prune/
pathenv.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// Making the managed binaries reachable from a fresh shell, and undoing it.
5//
6// pip in a virtualenv, `npx`, `uv tool run` — every one of those puts the binary
7// somewhere that stops existing, or stops being on PATH, the moment the environment
8// closes. The managed pair under `<config>/bin` already outlives them (the scheduler
9// and the git hooks are registered against it for exactly that reason); this module
10// closes the last gap by making the *user's own shell* find that copy too.
11//
12// On Windows that means one entry in the user PATH (`HKCU\Environment`), written
13// through the same .NET API `install.ps1` uses so the two writers behave identically.
14// Everywhere else it means symlinks in `~/.local/bin`, the XDG-conventional user
15// executable directory — no shell profile is ever edited, because a profile has no
16// safe "remove exactly what I added" operation and an uninstall that leaves edits
17// behind is worse than an install that asks the user to add one line.
18
19use std::path::Path;
20
21use crate::output;
22use crate::setup::Outcome;
23
24/// Whether one PATH entry names the same directory as another.
25///
26/// Windows treats `C:\x\bin` and `C:\x\bin\` as the same entry and compares without
27/// case; Unix does neither. Trailing-separator trimming is safe on both.
28fn entries_equal(a: &str, b: &str) -> bool {
29    let a = a.trim().trim_end_matches(['\\', '/']);
30    let b = b.trim().trim_end_matches(['\\', '/']);
31    if cfg!(windows) {
32        a.eq_ignore_ascii_case(b)
33    } else {
34        a == b
35    }
36}
37
38/// Whether `path_value` (a `;`- or `:`-joined PATH string) already contains `dir`.
39fn path_value_contains(path_value: &str, dir: &str) -> bool {
40    let sep = if cfg!(windows) { ';' } else { ':' };
41    path_value.split(sep).any(|entry| entries_equal(entry, dir))
42}
43
44/// `path_value` with every entry naming `dir` removed, or `None` when nothing matched.
45///
46/// Empty entries are dropped too — on Windows an empty PATH entry means "search the
47/// current directory", which nobody wants and which a naive join could introduce.
48// Only the Windows uninstall path rewrites a PATH string; on Unix removal is deleting
49// symlinks. The function still compiles (and is unit-tested) on both.
50#[cfg_attr(unix, allow(dead_code))]
51fn path_value_without(path_value: &str, dir: &str) -> Option<String> {
52    let sep = if cfg!(windows) { ";" } else { ":" };
53    if !path_value_contains(path_value, dir) {
54        return None;
55    }
56    Some(
57        path_value
58            .split(sep)
59            .filter(|entry| !entry.trim().is_empty() && !entries_equal(entry, dir))
60            .collect::<Vec<_>>()
61            .join(sep),
62    )
63}
64
65#[cfg(windows)]
66mod imp {
67    use super::*;
68    use std::process::Command;
69
70    /// Read the *user* PATH — the persisted value under `HKCU\Environment`, not this
71    /// process's inherited one, which also carries the machine PATH.
72    fn read_user_path() -> Option<String> {
73        let out = Command::new("powershell")
74            .args([
75                "-NoProfile",
76                "-NonInteractive",
77                "-Command",
78                "[Environment]::GetEnvironmentVariable('Path','User')",
79            ])
80            .output()
81            .ok()?;
82        out.status
83            .success()
84            .then(|| String::from_utf8_lossy(&out.stdout).trim_end().to_string())
85    }
86
87    /// Persist a new user PATH. Same API as `install.ps1`, so the broadcast that tells
88    /// Explorer and new shells about the change happens here too — .NET sends
89    /// `WM_SETTINGCHANGE` on the caller's behalf.
90    fn write_user_path(value: &str) -> bool {
91        let escaped = value.replace('\'', "''");
92        Command::new("powershell")
93            .args([
94                "-NoProfile",
95                "-NonInteractive",
96                "-Command",
97                &format!("[Environment]::SetEnvironmentVariable('Path','{escaped}','User')"),
98            ])
99            .status()
100            .map(|s| s.success())
101            .unwrap_or(false)
102    }
103
104    pub fn ensure_reachable(bin_dir: &Path) -> Outcome {
105        let dir = bin_dir.display().to_string();
106        let Some(current) = read_user_path() else {
107            return Outcome::Failed("could not read the user PATH".to_string());
108        };
109        if path_value_contains(&current, &dir) {
110            return Outcome::AlreadyPresent;
111        }
112        let new_value = if current.trim().is_empty() {
113            dir.clone()
114        } else {
115            format!("{};{}", current.trim_end_matches(';'), dir)
116        };
117        if write_user_path(&new_value) {
118            output::print_notice(&format!(
119                "`{}` was added to your user PATH — terminals opened from now on will find `devp`.",
120                output::clean_path(bin_dir)
121            ));
122            Outcome::Installed
123        } else {
124            Outcome::Failed("could not write the user PATH".to_string())
125        }
126    }
127
128    /// Read-only: whether `bin_dir` is on the persisted user PATH.
129    pub fn is_reachable(bin_dir: &Path) -> bool {
130        read_user_path()
131            .is_some_and(|current| path_value_contains(&current, &bin_dir.display().to_string()))
132    }
133
134    /// Take the managed directory back out of the user PATH. `Ok(true)` when an entry
135    /// was actually removed.
136    pub fn remove_reachability(bin_dir: &Path) -> anyhow::Result<bool> {
137        let dir = bin_dir.display().to_string();
138        let Some(current) = read_user_path() else {
139            anyhow::bail!("could not read the user PATH");
140        };
141        let Some(new_value) = path_value_without(&current, &dir) else {
142            return Ok(false);
143        };
144        if write_user_path(&new_value) {
145            Ok(true)
146        } else {
147            anyhow::bail!("could not write the user PATH")
148        }
149    }
150}
151
152#[cfg(unix)]
153mod imp {
154    use super::*;
155    use std::fs;
156
157    fn local_bin() -> Option<std::path::PathBuf> {
158        Some(dirs::home_dir()?.join(".local").join("bin"))
159    }
160
161    pub fn ensure_reachable(bin_dir: &Path) -> Outcome {
162        let Some(local_bin) = local_bin() else {
163            return Outcome::Skipped("could not determine the home directory".to_string());
164        };
165        if fs::create_dir_all(&local_bin).is_err() {
166            return Outcome::Failed(format!(
167                "could not create {}",
168                output::clean_path(&local_bin)
169            ));
170        }
171
172        let mut created_any = false;
173        for name in ["dev-prune", "devp"] {
174            let link = local_bin.join(name);
175            let target = bin_dir.join(name);
176            match fs::read_link(&link) {
177                Ok(existing) if existing == target => continue,
178                Ok(existing) if existing.starts_with(bin_dir) => {
179                    // Our own link, pointing at a name that moved. Repoint it.
180                    let _ = fs::remove_file(&link);
181                }
182                Ok(_) => continue, // someone else's link — leave it, it resolves
183                Err(_) if link.exists() => continue, // a real file the user put there
184                Err(_) => {}
185            }
186            if std::os::unix::fs::symlink(&target, &link).is_ok() {
187                created_any = true;
188            }
189        }
190
191        let on_path = std::env::var("PATH")
192            .map(|p| path_value_contains(&p, &local_bin.display().to_string()))
193            .unwrap_or(false);
194        if !on_path {
195            return Outcome::Skipped(format!(
196                "linked into `{}`, which is not on your PATH — add it in your shell profile",
197                output::clean_path(&local_bin)
198            ));
199        }
200        if created_any {
201            Outcome::Installed
202        } else {
203            Outcome::AlreadyPresent
204        }
205    }
206
207    /// Read-only: whether the `~/.local/bin` links exist and point into `bin_dir`.
208    pub fn is_reachable(bin_dir: &Path) -> bool {
209        local_bin().is_some_and(|local_bin| {
210            fs::read_link(local_bin.join("devp")).is_ok_and(|target| target.starts_with(bin_dir))
211        })
212    }
213
214    /// Remove the `~/.local/bin` links, but only the ones that point into `bin_dir` —
215    /// a binary the user placed there themselves is theirs.
216    pub fn remove_reachability(bin_dir: &Path) -> anyhow::Result<bool> {
217        let Some(local_bin) = local_bin() else {
218            return Ok(false);
219        };
220        let mut removed_any = false;
221        for name in ["dev-prune", "devp"] {
222            let link = local_bin.join(name);
223            if let Ok(target) = fs::read_link(&link) {
224                if target.starts_with(bin_dir) {
225                    fs::remove_file(&link)?;
226                    removed_any = true;
227                }
228            }
229        }
230        Ok(removed_any)
231    }
232}
233
234pub use imp::{ensure_reachable, is_reachable, remove_reachability};
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239
240    #[test]
241    fn a_path_entry_matches_with_and_without_a_trailing_separator() {
242        if cfg!(windows) {
243            assert!(path_value_contains(r"C:\a;C:\x\bin\;C:\b", r"C:\x\bin"));
244            assert!(path_value_contains(r"c:\X\BIN", r"C:\x\bin"));
245            assert!(!path_value_contains(r"C:\x\binx", r"C:\x\bin"));
246        } else {
247            assert!(path_value_contains("/a:/x/bin/:/b", "/x/bin"));
248            assert!(!path_value_contains("/x/BIN", "/x/bin"));
249            assert!(!path_value_contains("/x/binx", "/x/bin"));
250        }
251    }
252
253    #[test]
254    fn removal_strips_the_entry_and_reports_no_change_when_absent() {
255        if cfg!(windows) {
256            assert_eq!(
257                path_value_without(r"C:\a;C:\x\bin;C:\b", r"C:\x\bin"),
258                Some(r"C:\a;C:\b".to_string())
259            );
260            assert_eq!(path_value_without(r"C:\a;C:\b", r"C:\x\bin"), None);
261            // Empty entries mean "search the current directory" on Windows; a removal
262            // must never leave one behind.
263            assert_eq!(
264                path_value_without(r"C:\a;;C:\x\bin", r"C:\x\bin"),
265                Some(r"C:\a".to_string())
266            );
267        } else {
268            assert_eq!(
269                path_value_without("/a:/x/bin:/b", "/x/bin"),
270                Some("/a:/b".to_string())
271            );
272            assert_eq!(path_value_without("/a:/b", "/x/bin"), None);
273        }
274    }
275}