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
74 use windows_sys::Win32::Foundation::{ERROR_FILE_NOT_FOUND, ERROR_SUCCESS};
75 use windows_sys::Win32::System::Registry::{
76 HKEY, HKEY_CURRENT_USER, KEY_READ, KEY_SET_VALUE, REG_EXPAND_SZ, REG_SZ, REG_VALUE_TYPE,
77 RegCloseKey, RegOpenKeyExW, RegQueryValueExW, RegSetValueExW,
78 };
79 use windows_sys::Win32::UI::WindowsAndMessaging::{
80 HWND_BROADCAST, SMTO_ABORTIFHUNG, SendMessageTimeoutW, WM_SETTINGCHANGE,
81 };
82
83 fn wide(s: &str) -> Vec<u16> {
94 s.encode_utf16().chain(std::iter::once(0)).collect()
95 }
96
97 struct Key(HKEY);
99
100 impl Drop for Key {
101 fn drop(&mut self) {
102 unsafe { RegCloseKey(self.0) };
105 }
106 }
107
108 fn open_environment(access: u32) -> Option<Key> {
110 let subkey = wide("Environment");
111 let mut hkey: HKEY = std::ptr::null_mut();
112 let rc = unsafe { RegOpenKeyExW(HKEY_CURRENT_USER, subkey.as_ptr(), 0, access, &mut hkey) };
115 if rc == ERROR_SUCCESS {
118 Some(Key(hkey))
119 } else {
120 None
121 }
122 }
123
124 fn query_path(key: &Key) -> Option<(String, REG_VALUE_TYPE)> {
133 let name = wide("Path");
134 let mut kind: REG_VALUE_TYPE = 0;
135 let mut len: u32 = 0;
136 let rc = unsafe {
138 RegQueryValueExW(
139 key.0,
140 name.as_ptr(),
141 std::ptr::null(),
142 &mut kind,
143 std::ptr::null_mut(),
144 &mut len,
145 )
146 };
147 if rc == ERROR_FILE_NOT_FOUND {
148 return Some((String::new(), REG_EXPAND_SZ));
149 }
150 if rc != ERROR_SUCCESS {
151 return None;
152 }
153
154 let mut buf = vec![0u8; len as usize];
155 let rc = unsafe {
158 RegQueryValueExW(
159 key.0,
160 name.as_ptr(),
161 std::ptr::null(),
162 &mut kind,
163 buf.as_mut_ptr(),
164 &mut len,
165 )
166 };
167 if rc != ERROR_SUCCESS {
168 return None;
169 }
170 buf.truncate(len as usize);
171 Some((decode_utf16_value(&buf), kind))
172 }
173
174 fn decode_utf16_value(buf: &[u8]) -> String {
180 let units: Vec<u16> = buf
181 .as_chunks::<2>()
182 .0
183 .iter()
184 .map(|pair| u16::from_le_bytes(*pair))
185 .take_while(|&unit| unit != 0)
186 .collect();
187 String::from_utf16_lossy(&units)
188 }
189
190 fn read_user_path() -> Option<String> {
191 let key = open_environment(KEY_READ)?;
192 query_path(&key).map(|(value, _)| value)
193 }
194
195 fn write_user_path(value: &str) -> bool {
200 let Some(key) = open_environment(KEY_READ | KEY_SET_VALUE) else {
201 return false;
202 };
203 let kind = match query_path(&key) {
206 Some((_, REG_SZ)) => REG_SZ,
207 _ => REG_EXPAND_SZ,
208 };
209
210 let data = wide(value);
211 let bytes = std::mem::size_of_val(data.as_slice()) as u32;
212 let name = wide("Path");
213 let rc = unsafe {
216 RegSetValueExW(
217 key.0,
218 name.as_ptr(),
219 0,
220 kind,
221 data.as_ptr().cast::<u8>(),
222 bytes,
223 )
224 };
225 if rc != ERROR_SUCCESS {
226 return false;
227 }
228
229 let environment = wide("Environment");
230 let mut delivered: usize = 0;
231 unsafe {
236 SendMessageTimeoutW(
237 HWND_BROADCAST,
238 WM_SETTINGCHANGE,
239 0,
240 environment.as_ptr() as isize,
241 SMTO_ABORTIFHUNG,
242 5000,
243 &mut delivered,
244 )
245 };
246 true
247 }
248
249 pub fn ensure_reachable(bin_dir: &Path) -> Outcome {
250 let dir = bin_dir.display().to_string();
251 let Some(current) = read_user_path() else {
252 return Outcome::Failed("could not read the user PATH".to_string());
253 };
254 if path_value_contains(¤t, &dir) {
255 return Outcome::AlreadyPresent;
256 }
257 let new_value = if current.trim().is_empty() {
258 dir.clone()
259 } else {
260 format!("{};{}", current.trim_end_matches(';'), dir)
261 };
262 if write_user_path(&new_value) {
263 output::print_notice(&format!(
264 "`{}` was added to your user PATH — terminals opened from now on will find `devp`.",
265 output::clean_path(bin_dir)
266 ));
267 Outcome::Installed
268 } else {
269 Outcome::Failed("could not write the user PATH".to_string())
270 }
271 }
272
273 pub fn is_reachable(bin_dir: &Path) -> bool {
275 read_user_path()
276 .is_some_and(|current| path_value_contains(¤t, &bin_dir.display().to_string()))
277 }
278
279 pub fn remove_reachability(bin_dir: &Path) -> anyhow::Result<bool> {
282 let dir = bin_dir.display().to_string();
283 let Some(current) = read_user_path() else {
284 anyhow::bail!("could not read the user PATH");
285 };
286 let Some(new_value) = path_value_without(¤t, &dir) else {
287 return Ok(false);
288 };
289 if write_user_path(&new_value) {
290 Ok(true)
291 } else {
292 anyhow::bail!("could not write the user PATH")
293 }
294 }
295
296 #[cfg(test)]
297 mod tests {
298 use super::*;
299
300 fn utf16(s: &str) -> Vec<u8> {
301 s.encode_utf16().flat_map(u16::to_le_bytes).collect()
302 }
303
304 #[test]
305 fn a_registry_string_ends_at_its_first_terminator_if_it_has_one() {
306 assert_eq!(decode_utf16_value(&utf16(r"C:\a;C:\b")), r"C:\a;C:\b");
308
309 let mut one = utf16(r"C:\a");
311 one.extend_from_slice(&[0, 0]);
312 assert_eq!(decode_utf16_value(&one), r"C:\a");
313 let mut two = utf16(r"C:\a");
314 two.extend_from_slice(&[0, 0, 0, 0]);
315 assert_eq!(decode_utf16_value(&two), r"C:\a");
316
317 let mut trailing = utf16(r"C:\a");
319 trailing.extend_from_slice(&[0, 0]);
320 trailing.extend_from_slice(&utf16("junk"));
321 assert_eq!(decode_utf16_value(&trailing), r"C:\a");
322
323 let mut odd = utf16(r"C:\a");
325 odd.push(b'x');
326 assert_eq!(decode_utf16_value(&odd), r"C:\a");
327
328 assert_eq!(decode_utf16_value(&[]), "");
329 }
330
331 #[test]
334 fn an_unexpanded_entry_is_read_back_verbatim() {
335 let raw = r"%USERPROFILE%\bin;C:\Windows\System32";
336 assert_eq!(decode_utf16_value(&utf16(raw)), raw);
337 }
338
339 #[test]
343 fn a_non_ascii_entry_survives_decoding() {
344 let raw = r"C:\Users\Müller\bin;C:\Users\日本\bin";
345 assert_eq!(decode_utf16_value(&utf16(raw)), raw);
346 }
347
348 #[test]
352 fn the_user_path_can_be_read_from_the_registry() {
353 assert!(read_user_path().is_some(), "could not read the user PATH");
354 }
355 }
356}
357
358#[cfg(unix)]
359mod imp {
360 use super::*;
361 use std::fs;
362
363 fn local_bin() -> Option<std::path::PathBuf> {
364 Some(dirs::home_dir()?.join(".local").join("bin"))
365 }
366
367 pub fn ensure_reachable(bin_dir: &Path) -> Outcome {
368 let Some(local_bin) = local_bin() else {
369 return Outcome::Skipped("could not determine the home directory".to_string());
370 };
371 if fs::create_dir_all(&local_bin).is_err() {
372 return Outcome::Failed(format!(
373 "could not create {}",
374 output::clean_path(&local_bin)
375 ));
376 }
377
378 let mut created_any = false;
379 for name in ["dev-prune", "devp"] {
380 let link = local_bin.join(name);
381 let target = bin_dir.join(name);
382 match fs::read_link(&link) {
383 Ok(existing) if existing == target => continue,
384 Ok(existing) if existing.starts_with(bin_dir) => {
385 let _ = fs::remove_file(&link);
387 }
388 Ok(_) => continue, Err(_) if link.exists() => continue, Err(_) => {}
391 }
392 if std::os::unix::fs::symlink(&target, &link).is_ok() {
393 created_any = true;
394 }
395 }
396
397 let on_path = std::env::var("PATH")
398 .map(|p| path_value_contains(&p, &local_bin.display().to_string()))
399 .unwrap_or(false);
400 if !on_path {
401 return Outcome::Skipped(format!(
402 "linked into `{}`, which is not on your PATH — add it in your shell profile",
403 output::clean_path(&local_bin)
404 ));
405 }
406 if created_any {
407 Outcome::Installed
408 } else {
409 Outcome::AlreadyPresent
410 }
411 }
412
413 pub fn is_reachable(bin_dir: &Path) -> bool {
415 local_bin().is_some_and(|local_bin| {
416 fs::read_link(local_bin.join("devp")).is_ok_and(|target| target.starts_with(bin_dir))
417 })
418 }
419
420 pub fn remove_reachability(bin_dir: &Path) -> anyhow::Result<bool> {
423 let Some(local_bin) = local_bin() else {
424 return Ok(false);
425 };
426 let mut removed_any = false;
427 for name in ["dev-prune", "devp"] {
428 let link = local_bin.join(name);
429 if let Ok(target) = fs::read_link(&link)
430 && target.starts_with(bin_dir)
431 {
432 fs::remove_file(&link)?;
433 removed_any = true;
434 }
435 }
436 Ok(removed_any)
437 }
438}
439
440pub use imp::{ensure_reachable, is_reachable, remove_reachability};
441
442#[cfg(test)]
443mod tests {
444 use super::*;
445
446 #[test]
447 fn a_path_entry_matches_with_and_without_a_trailing_separator() {
448 if cfg!(windows) {
449 assert!(path_value_contains(r"C:\a;C:\x\bin\;C:\b", r"C:\x\bin"));
450 assert!(path_value_contains(r"c:\X\BIN", r"C:\x\bin"));
451 assert!(!path_value_contains(r"C:\x\binx", r"C:\x\bin"));
452 } else {
453 assert!(path_value_contains("/a:/x/bin/:/b", "/x/bin"));
454 assert!(!path_value_contains("/x/BIN", "/x/bin"));
455 assert!(!path_value_contains("/x/binx", "/x/bin"));
456 }
457 }
458
459 #[test]
460 fn removal_strips_the_entry_and_reports_no_change_when_absent() {
461 if cfg!(windows) {
462 assert_eq!(
463 path_value_without(r"C:\a;C:\x\bin;C:\b", r"C:\x\bin"),
464 Some(r"C:\a;C:\b".to_string())
465 );
466 assert_eq!(path_value_without(r"C:\a;C:\b", r"C:\x\bin"), None);
467 assert_eq!(
470 path_value_without(r"C:\a;;C:\x\bin", r"C:\x\bin"),
471 Some(r"C:\a".to_string())
472 );
473 } else {
474 assert_eq!(
475 path_value_without("/a:/x/bin:/b", "/x/bin"),
476 Some("/a:/b".to_string())
477 );
478 assert_eq!(path_value_without("/a:/b", "/x/bin"), None);
479 }
480 }
481}