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`), read and
13// written as raw registry data. The obvious .NET call —
14// `[Environment]::GetEnvironmentVariable('Path','User')` — hands back the *expanded*
15// value, and writing that back bakes `%USERPROFILE%`-style entries into literal paths
16// for good; going through the registry API with `DoNotExpandEnvironmentNames`, and
17// preserving the value's `REG_EXPAND_SZ`/`REG_SZ` kind, leaves every entry exactly as
18// its owner spelled it.
19// Everywhere else it means symlinks in `~/.local/bin`, the XDG-conventional user
20// executable directory — no shell profile is ever edited, because a profile has no
21// safe "remove exactly what I added" operation and an uninstall that leaves edits
22// behind is worse than an install that asks the user to add one line.
23
24use std::path::Path;
25
26use crate::output;
27use crate::setup::Outcome;
28
29/// Whether one PATH entry names the same directory as another.
30///
31/// Windows treats `C:\x\bin` and `C:\x\bin\` as the same entry and compares without
32/// case; Unix does neither. Trailing-separator trimming is safe on both.
33pub(crate) fn entries_equal(a: &str, b: &str) -> bool {
34    let a = a.trim().trim_end_matches(['\\', '/']);
35    let b = b.trim().trim_end_matches(['\\', '/']);
36    if cfg!(windows) {
37        a.eq_ignore_ascii_case(b)
38    } else {
39        a == b
40    }
41}
42
43/// Whether `path_value` (a `;`- or `:`-joined PATH string) already contains `dir`.
44fn path_value_contains(path_value: &str, dir: &str) -> bool {
45    let sep = if cfg!(windows) { ';' } else { ':' };
46    path_value.split(sep).any(|entry| entries_equal(entry, dir))
47}
48
49/// `path_value` with every entry naming `dir` removed, or `None` when nothing matched.
50///
51/// Empty entries are dropped too — on Windows an empty PATH entry means "search the
52/// current directory", which nobody wants and which a naive join could introduce.
53// Only the Windows uninstall path rewrites a PATH string; on Unix removal is deleting
54// symlinks. The function still compiles (and is unit-tested) on both.
55#[cfg_attr(unix, allow(dead_code))]
56fn path_value_without(path_value: &str, dir: &str) -> Option<String> {
57    let sep = if cfg!(windows) { ";" } else { ":" };
58    if !path_value_contains(path_value, dir) {
59        return None;
60    }
61    Some(
62        path_value
63            .split(sep)
64            .filter(|entry| !entry.trim().is_empty() && !entries_equal(entry, dir))
65            .collect::<Vec<_>>()
66            .join(sep),
67    )
68}
69
70#[cfg(windows)]
71mod imp {
72    use super::*;
73    use std::process::Command;
74
75    /// A PowerShell script as `-EncodedCommand` base64 (UTF-16LE). The encoded form
76    /// exists for two reasons: no quoting layer between here and the interpreter (the
77    /// scripts carry both quote styles), and no codepage — a console in an OEM
78    /// codepage would otherwise mangle every non-ASCII character in a PATH entry.
79    fn encoded_command(script: &str) -> String {
80        const TABLE: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
81        let bytes: Vec<u8> = script
82            .encode_utf16()
83            .flat_map(|u| u.to_le_bytes())
84            .collect();
85        let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
86        for chunk in bytes.chunks(3) {
87            let n = (u32::from(chunk[0]) << 16)
88                | (u32::from(chunk.get(1).copied().unwrap_or(0)) << 8)
89                | u32::from(chunk.get(2).copied().unwrap_or(0));
90            out.push(TABLE[(n >> 18) as usize & 63] as char);
91            out.push(TABLE[(n >> 12) as usize & 63] as char);
92            out.push(if chunk.len() > 1 {
93                TABLE[(n >> 6) as usize & 63] as char
94            } else {
95                '='
96            });
97            out.push(if chunk.len() > 2 {
98                TABLE[n as usize & 63] as char
99            } else {
100                '='
101            });
102        }
103        out
104    }
105
106    fn powershell(script: &str) -> Command {
107        // Absolute first: these registry edits are what runs while the user PATH is
108        // in flux, so resolving the interpreter *through* `PATH` is the one thing
109        // this function must not do. Windows PowerShell has lived at this path since
110        // Vista; if it is somehow gone, fall back to whatever lookup finds.
111        let exe = crate::spawn::system32(r"WindowsPowerShell\v1.0\powershell.exe");
112        let program = if std::path::Path::new(&exe).exists() {
113            exe
114        } else {
115            String::from("powershell")
116        };
117        let mut cmd = crate::spawn::command(program);
118        cmd.args([
119            "-NoProfile",
120            "-NonInteractive",
121            "-EncodedCommand",
122            &encoded_command(script),
123        ]);
124        cmd
125    }
126
127    /// Read the *user* PATH — the persisted value under `HKCU\Environment`, not this
128    /// process's inherited one, which also carries the machine PATH. Read raw:
129    /// `DoNotExpandEnvironmentNames` keeps `%USERPROFILE%`-style entries as their
130    /// owner spelled them, so a later write cannot bake them into literal paths.
131    fn read_user_path() -> Option<String> {
132        let script = "\
133            [Console]::OutputEncoding=[System.Text.Encoding]::UTF8\n\
134            $k=[Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment')\n\
135            if($null -eq $k){exit 1}\n\
136            $v=$k.GetValue('Path','',[Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)\n\
137            [Console]::Out.Write([string]$v)";
138        let out = powershell(script).output().ok()?;
139        out.status
140            .success()
141            .then(|| String::from_utf8_lossy(&out.stdout).trim_end().to_string())
142    }
143
144    /// Persist a new user PATH, keeping the registry value's kind — flattening
145    /// `REG_EXPAND_SZ` to `REG_SZ` would stop every `%VAR%` entry expanding — and
146    /// broadcasting `WM_SETTINGCHANGE` so Explorer and new shells pick it up (the raw
147    /// registry write does not send it the way the .NET environment API did).
148    fn write_user_path(value: &str) -> bool {
149        let escaped = value.replace('\'', "''");
150        let script = format!(
151            "$v='{escaped}'\n\
152             $k=[Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment',$true)\n\
153             if($null -eq $k){{exit 1}}\n\
154             $kind=[Microsoft.Win32.RegistryValueKind]::ExpandString\n\
155             try{{$kind=$k.GetValueKind('Path')}}catch{{}}\n\
156             if($kind -ne [Microsoft.Win32.RegistryValueKind]::String){{$kind=[Microsoft.Win32.RegistryValueKind]::ExpandString}}\n\
157             $k.SetValue('Path',$v,$kind)\n\
158             $sig='[DllImport(\"user32.dll\",SetLastError=true,CharSet=CharSet.Auto)]public static extern IntPtr SendMessageTimeout(IntPtr hWnd,uint Msg,UIntPtr wParam,string lParam,uint fuFlags,uint uTimeout,out UIntPtr lpdwResult);'\n\
159             $t=Add-Type -MemberDefinition $sig -Name 'NativeBroadcast' -Namespace DevPrune -PassThru\n\
160             $r=[UIntPtr]::Zero\n\
161             [void]$t::SendMessageTimeout([IntPtr]0xffff,0x1A,[UIntPtr]::Zero,'Environment',2,5000,[ref]$r)"
162        );
163        powershell(&script)
164            .status()
165            .map(|s| s.success())
166            .unwrap_or(false)
167    }
168
169    pub fn ensure_reachable(bin_dir: &Path) -> Outcome {
170        let dir = bin_dir.display().to_string();
171        let Some(current) = read_user_path() else {
172            return Outcome::Failed("could not read the user PATH".to_string());
173        };
174        if path_value_contains(&current, &dir) {
175            return Outcome::AlreadyPresent;
176        }
177        let new_value = if current.trim().is_empty() {
178            dir.clone()
179        } else {
180            format!("{};{}", current.trim_end_matches(';'), dir)
181        };
182        if write_user_path(&new_value) {
183            output::print_notice(&format!(
184                "`{}` was added to your user PATH — terminals opened from now on will find `devp`.",
185                output::clean_path(bin_dir)
186            ));
187            Outcome::Installed
188        } else {
189            Outcome::Failed("could not write the user PATH".to_string())
190        }
191    }
192
193    /// Read-only: whether `bin_dir` is on the persisted user PATH.
194    pub fn is_reachable(bin_dir: &Path) -> bool {
195        read_user_path()
196            .is_some_and(|current| path_value_contains(&current, &bin_dir.display().to_string()))
197    }
198
199    /// Take the managed directory back out of the user PATH. `Ok(true)` when an entry
200    /// was actually removed.
201    pub fn remove_reachability(bin_dir: &Path) -> anyhow::Result<bool> {
202        let dir = bin_dir.display().to_string();
203        let Some(current) = read_user_path() else {
204            anyhow::bail!("could not read the user PATH");
205        };
206        let Some(new_value) = path_value_without(&current, &dir) else {
207            return Ok(false);
208        };
209        if write_user_path(&new_value) {
210            Ok(true)
211        } else {
212            anyhow::bail!("could not write the user PATH")
213        }
214    }
215}
216
217#[cfg(unix)]
218mod imp {
219    use super::*;
220    use std::fs;
221
222    fn local_bin() -> Option<std::path::PathBuf> {
223        Some(dirs::home_dir()?.join(".local").join("bin"))
224    }
225
226    pub fn ensure_reachable(bin_dir: &Path) -> Outcome {
227        let Some(local_bin) = local_bin() else {
228            return Outcome::Skipped("could not determine the home directory".to_string());
229        };
230        if fs::create_dir_all(&local_bin).is_err() {
231            return Outcome::Failed(format!(
232                "could not create {}",
233                output::clean_path(&local_bin)
234            ));
235        }
236
237        let mut created_any = false;
238        for name in ["dev-prune", "devp"] {
239            let link = local_bin.join(name);
240            let target = bin_dir.join(name);
241            match fs::read_link(&link) {
242                Ok(existing) if existing == target => continue,
243                Ok(existing) if existing.starts_with(bin_dir) => {
244                    // Our own link, pointing at a name that moved. Repoint it.
245                    let _ = fs::remove_file(&link);
246                }
247                Ok(_) => continue, // someone else's link — leave it, it resolves
248                Err(_) if link.exists() => continue, // a real file the user put there
249                Err(_) => {}
250            }
251            if std::os::unix::fs::symlink(&target, &link).is_ok() {
252                created_any = true;
253            }
254        }
255
256        let on_path = std::env::var("PATH")
257            .map(|p| path_value_contains(&p, &local_bin.display().to_string()))
258            .unwrap_or(false);
259        if !on_path {
260            return Outcome::Skipped(format!(
261                "linked into `{}`, which is not on your PATH — add it in your shell profile",
262                output::clean_path(&local_bin)
263            ));
264        }
265        if created_any {
266            Outcome::Installed
267        } else {
268            Outcome::AlreadyPresent
269        }
270    }
271
272    /// Read-only: whether the `~/.local/bin` links exist and point into `bin_dir`.
273    pub fn is_reachable(bin_dir: &Path) -> bool {
274        local_bin().is_some_and(|local_bin| {
275            fs::read_link(local_bin.join("devp")).is_ok_and(|target| target.starts_with(bin_dir))
276        })
277    }
278
279    /// Remove the `~/.local/bin` links, but only the ones that point into `bin_dir` —
280    /// a binary the user placed there themselves is theirs.
281    pub fn remove_reachability(bin_dir: &Path) -> anyhow::Result<bool> {
282        let Some(local_bin) = local_bin() else {
283            return Ok(false);
284        };
285        let mut removed_any = false;
286        for name in ["dev-prune", "devp"] {
287            let link = local_bin.join(name);
288            if let Ok(target) = fs::read_link(&link)
289                && target.starts_with(bin_dir)
290            {
291                fs::remove_file(&link)?;
292                removed_any = true;
293            }
294        }
295        Ok(removed_any)
296    }
297}
298
299pub use imp::{ensure_reachable, is_reachable, remove_reachability};
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304
305    #[test]
306    fn a_path_entry_matches_with_and_without_a_trailing_separator() {
307        if cfg!(windows) {
308            assert!(path_value_contains(r"C:\a;C:\x\bin\;C:\b", r"C:\x\bin"));
309            assert!(path_value_contains(r"c:\X\BIN", r"C:\x\bin"));
310            assert!(!path_value_contains(r"C:\x\binx", r"C:\x\bin"));
311        } else {
312            assert!(path_value_contains("/a:/x/bin/:/b", "/x/bin"));
313            assert!(!path_value_contains("/x/BIN", "/x/bin"));
314            assert!(!path_value_contains("/x/binx", "/x/bin"));
315        }
316    }
317
318    #[test]
319    fn removal_strips_the_entry_and_reports_no_change_when_absent() {
320        if cfg!(windows) {
321            assert_eq!(
322                path_value_without(r"C:\a;C:\x\bin;C:\b", r"C:\x\bin"),
323                Some(r"C:\a;C:\b".to_string())
324            );
325            assert_eq!(path_value_without(r"C:\a;C:\b", r"C:\x\bin"), None);
326            // Empty entries mean "search the current directory" on Windows; a removal
327            // must never leave one behind.
328            assert_eq!(
329                path_value_without(r"C:\a;;C:\x\bin", r"C:\x\bin"),
330                Some(r"C:\a".to_string())
331            );
332        } else {
333            assert_eq!(
334                path_value_without("/a:/x/bin:/b", "/x/bin"),
335                Some("/a:/b".to_string())
336            );
337            assert_eq!(path_value_without("/a:/b", "/x/bin"), None);
338        }
339    }
340}