1use std::path::Path;
25
26use crate::output;
27use crate::setup::Outcome;
28
29pub(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
43fn 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#[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 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 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 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 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(¤t, &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 pub fn is_reachable(bin_dir: &Path) -> bool {
195 read_user_path()
196 .is_some_and(|current| path_value_contains(¤t, &bin_dir.display().to_string()))
197 }
198
199 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(¤t, &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 let _ = fs::remove_file(&link);
246 }
247 Ok(_) => continue, Err(_) if link.exists() => continue, 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 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 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 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}