1use std::path::Path;
20
21use crate::output;
22use crate::setup::Outcome;
23
24fn 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
38fn 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#[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 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 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(¤t, &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 pub fn is_reachable(bin_dir: &Path) -> bool {
130 read_user_path()
131 .is_some_and(|current| path_value_contains(¤t, &bin_dir.display().to_string()))
132 }
133
134 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(¤t, &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 let _ = fs::remove_file(&link);
181 }
182 Ok(_) => continue, Err(_) if link.exists() => continue, 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 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 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 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}