corundum_framework_package/
cli_path.rs1use std::ffi::OsStr;
10use std::path::{Path, PathBuf};
11
12use crate::error::Error;
13
14#[cfg(unix)]
15mod unix;
16#[cfg(windows)]
17mod windows;
18
19#[derive(Clone, Copy, Debug)]
21pub struct CliSpec {
22 pub command: &'static str,
27}
28
29#[derive(Clone, Copy, Debug, PartialEq, Eq)]
31pub enum CliStatus {
32 Installed,
34 Missing,
36 Broken,
38 Taken,
42 Unsupported,
44}
45
46pub fn status(spec: &CliSpec) -> CliStatus {
48 #[cfg(windows)]
49 return windows::status(spec);
50 #[cfg(unix)]
51 return unix::status(spec);
52}
53
54pub fn install(spec: &CliSpec) -> Result<(), Error> {
57 #[cfg(windows)]
58 return windows::install(spec);
59 #[cfg(unix)]
60 return unix::install(spec);
61}
62
63pub fn uninstall(spec: &CliSpec) -> Result<(), Error> {
65 #[cfg(windows)]
66 return windows::uninstall(spec);
67 #[cfg(unix)]
68 return unix::uninstall(spec);
69}
70
71pub fn on_path(dir: &Path) -> bool {
74 on_path_in(dir, &std::env::var_os("PATH").unwrap_or_default())
75}
76
77#[cfg(unix)]
84pub fn on_login_path(dir: &Path) -> Option<bool> {
85 login_path().map(|path_var| on_path_in(dir, &path_var))
86}
87
88#[cfg(unix)]
91pub const LOGIN_PATH_WAIT: std::time::Duration = std::time::Duration::from_secs(20);
92
93#[cfg(unix)]
97fn login_path() -> Option<std::ffi::OsString> {
98 use std::io::Read;
99 use std::process::{Command, Stdio};
100 use std::time::{Duration, Instant};
101
102 let shell = std::env::var_os("SHELL").unwrap_or_else(|| "/bin/sh".into());
103 let mut child = Command::new(shell)
104 .args(["-l", "-i", "-c", r#"printf '\n%s%s\n' "$1" "$PATH""#, "sh"])
105 .arg(LOGIN_PATH_MARKER)
106 .stdin(Stdio::null())
107 .stdout(Stdio::piped())
108 .stderr(Stdio::null())
109 .spawn()
110 .ok()?;
111 let deadline = Instant::now() + LOGIN_PATH_WAIT;
112 loop {
113 match child.try_wait() {
114 Ok(Some(_)) => break,
115 Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(20)),
116 _ => {
117 let _ = child.kill();
118 let _ = child.wait();
119 return None;
120 }
121 }
122 }
123 let mut out = Vec::new();
124 child.stdout.take()?.read_to_end(&mut out).ok()?;
125 parse_login_path(&out)
126}
127
128#[cfg(unix)]
131const LOGIN_PATH_MARKER: &str = "corundum-login-path:";
132
133#[cfg(unix)]
135fn parse_login_path(out: &[u8]) -> Option<std::ffi::OsString> {
136 use std::os::unix::ffi::OsStringExt;
137
138 let marker = LOGIN_PATH_MARKER.as_bytes();
139 let start = out
140 .windows(marker.len())
141 .rposition(|window| window == marker)?
142 + marker.len();
143 let line = out[start..].split(|&b| b == b'\n').next()?;
144 (!line.is_empty()).then(|| std::ffi::OsString::from_vec(line.to_vec()))
145}
146
147pub(crate) fn on_path_in(dir: &Path, path_var: &OsStr) -> bool {
149 let dir = clean(dir);
150 std::env::split_paths(path_var)
151 .filter(|entry| !entry.as_os_str().is_empty())
152 .any(|entry| clean(&entry) == dir)
153}
154
155fn clean(path: &Path) -> PathBuf {
158 path.canonicalize()
159 .unwrap_or_else(|_| path.components().collect())
160}
161
162#[cfg_attr(not(windows), allow(dead_code))]
165pub(crate) fn path_with(value: &str, dir: &str) -> Option<String> {
166 if value.split(';').any(|entry| same_entry(entry, dir)) {
167 return None;
168 }
169 Some(if value.is_empty() {
170 dir.to_owned()
171 } else if let Some(rest) = value.strip_suffix(';') {
172 format!("{rest};{dir};")
173 } else {
174 format!("{value};{dir}")
175 })
176}
177
178#[cfg_attr(not(windows), allow(dead_code))]
181pub(crate) fn path_without(value: &str, dir: &str) -> Option<String> {
182 let entries: Vec<&str> = value.split(';').collect();
183 let kept: Vec<&str> = entries
184 .iter()
185 .copied()
186 .filter(|entry| !same_entry(entry, dir))
187 .collect();
188 (kept.len() != entries.len()).then(|| kept.join(";"))
189}
190
191fn same_entry(entry: &str, dir: &str) -> bool {
194 let norm = |s: &str| {
195 let s = s
196 .strip_prefix('"')
197 .and_then(|s| s.strip_suffix('"'))
198 .unwrap_or(s);
199 s.strip_suffix('\\').unwrap_or(s).to_lowercase()
200 };
201 !entry.is_empty() && norm(entry) == norm(dir)
202}
203
204#[cfg(test)]
205mod tests {
206 use std::ffi::OsString;
207
208 use super::*;
209
210 const DIR: &str = "C:\\x\\current";
211
212 #[cfg(unix)]
213 #[test]
214 fn the_login_path_is_read_after_the_marker() {
215 let out = b"Welcome back!\ncorundum-login-path: in a greeting\n\ncorundum-login-path:/home/u/.local/bin:/usr/bin\n";
216 assert_eq!(
217 parse_login_path(out),
218 Some(OsString::from("/home/u/.local/bin:/usr/bin"))
219 );
220 assert_eq!(parse_login_path(b"no marker here\n"), None);
221 assert_eq!(parse_login_path(b"\ncorundum-login-path:\n"), None);
222 }
223
224 #[cfg(unix)]
225 #[test]
226 fn the_login_shell_is_asked_for_its_path() {
227 let dir = tempfile::tempdir().unwrap();
229 assert_ne!(on_login_path(dir.path()), Some(true));
230 assert_ne!(on_login_path(Path::new("/usr/bin")), Some(false));
231 }
232
233 #[test]
234 fn a_dir_is_added_at_the_end_of_a_path_without_it() {
235 let cases: &[(&str, Option<&str>)] = &[
236 ("C:\\a;C:\\b", Some("C:\\a;C:\\b;C:\\x\\current")),
237 ("", Some("C:\\x\\current")),
238 ("C:\\a;", Some("C:\\a;C:\\x\\current;")),
239 ("C:\\a;;C:\\b", Some("C:\\a;;C:\\b;C:\\x\\current")),
240 ("C:\\a;C:\\X\\CURRENT\\", None),
241 ("\"C:\\x\\current\";C:\\b", None),
242 ("C:\\x\\current", None),
243 ];
244 for (value, want) in cases {
245 assert_eq!(path_with(value, DIR).as_deref(), *want, "{value:?}");
246 }
247 }
248
249 #[test]
250 fn a_dir_is_removed_from_a_path_keeping_the_other_entries() {
251 let cases: &[(&str, Option<&str>)] = &[
252 (
253 "%USERPROFILE%\\bin;C:\\x\\current;C:\\b",
254 Some("%USERPROFILE%\\bin;C:\\b"),
255 ),
256 ("C:\\a;C:\\x\\current;", Some("C:\\a;")),
257 ("C:\\a;;C:\\X\\Current\\;C:\\b", Some("C:\\a;;C:\\b")),
258 ("\"C:\\x\\current\"", Some("")),
259 ("C:\\x\\current;C:\\a;C:\\x\\current", Some("C:\\a")),
260 ("C:\\a;C:\\b", None),
261 ("", None),
262 ("C:\\x\\current2", None),
263 ];
264 for (value, want) in cases {
265 assert_eq!(path_without(value, DIR).as_deref(), *want, "{value:?}");
266 }
267 }
268
269 #[test]
270 fn a_dir_is_on_a_path_naming_it_with_or_without_a_trailing_separator() {
271 let tmp = tempfile::tempdir().unwrap();
272 let dir = tmp.path().join("bin");
273 std::fs::create_dir(&dir).unwrap();
274 let other = tmp.path().join("other");
275 let sep = std::path::MAIN_SEPARATOR;
276 let path_of = |entries: &[PathBuf]| -> OsString { std::env::join_paths(entries).unwrap() };
277
278 assert!(on_path_in(&dir, &path_of(&[other.clone(), dir.clone()])));
279 let trailing = PathBuf::from(format!("{}{sep}", dir.display()));
280 assert!(on_path_in(&dir, &path_of(&[trailing])));
281 let dotted = tmp.path().join(".").join("bin");
282 assert!(on_path_in(&dir, &path_of(&[dotted])));
283 assert!(!on_path_in(&dir, &path_of(&[other])));
284 assert!(!on_path_in(&dir, OsStr::new("")));
285 }
286}