use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use crate::error::Error;
#[cfg(unix)]
mod unix;
#[cfg(windows)]
mod windows;
#[derive(Clone, Copy, Debug)]
pub struct CliSpec {
pub command: &'static str,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CliStatus {
Installed,
Missing,
Broken,
Taken,
Unsupported,
}
pub fn status(spec: &CliSpec) -> CliStatus {
#[cfg(windows)]
return windows::status(spec);
#[cfg(unix)]
return unix::status(spec);
}
pub fn install(spec: &CliSpec) -> Result<(), Error> {
#[cfg(windows)]
return windows::install(spec);
#[cfg(unix)]
return unix::install(spec);
}
pub fn uninstall(spec: &CliSpec) -> Result<(), Error> {
#[cfg(windows)]
return windows::uninstall(spec);
#[cfg(unix)]
return unix::uninstall(spec);
}
pub fn on_path(dir: &Path) -> bool {
on_path_in(dir, &std::env::var_os("PATH").unwrap_or_default())
}
#[cfg(unix)]
pub fn on_login_path(dir: &Path) -> Option<bool> {
login_path().map(|path_var| on_path_in(dir, &path_var))
}
#[cfg(unix)]
pub const LOGIN_PATH_WAIT: std::time::Duration = std::time::Duration::from_secs(20);
#[cfg(unix)]
fn login_path() -> Option<std::ffi::OsString> {
use std::io::Read;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
let shell = std::env::var_os("SHELL").unwrap_or_else(|| "/bin/sh".into());
let mut child = Command::new(shell)
.args(["-l", "-i", "-c", r#"printf '\n%s%s\n' "$1" "$PATH""#, "sh"])
.arg(LOGIN_PATH_MARKER)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.ok()?;
let deadline = Instant::now() + LOGIN_PATH_WAIT;
loop {
match child.try_wait() {
Ok(Some(_)) => break,
Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(20)),
_ => {
let _ = child.kill();
let _ = child.wait();
return None;
}
}
}
let mut out = Vec::new();
child.stdout.take()?.read_to_end(&mut out).ok()?;
parse_login_path(&out)
}
#[cfg(unix)]
const LOGIN_PATH_MARKER: &str = "corundum-login-path:";
#[cfg(unix)]
fn parse_login_path(out: &[u8]) -> Option<std::ffi::OsString> {
use std::os::unix::ffi::OsStringExt;
let marker = LOGIN_PATH_MARKER.as_bytes();
let start = out
.windows(marker.len())
.rposition(|window| window == marker)?
+ marker.len();
let line = out[start..].split(|&b| b == b'\n').next()?;
(!line.is_empty()).then(|| std::ffi::OsString::from_vec(line.to_vec()))
}
pub(crate) fn on_path_in(dir: &Path, path_var: &OsStr) -> bool {
let dir = clean(dir);
std::env::split_paths(path_var)
.filter(|entry| !entry.as_os_str().is_empty())
.any(|entry| clean(&entry) == dir)
}
fn clean(path: &Path) -> PathBuf {
path.canonicalize()
.unwrap_or_else(|_| path.components().collect())
}
#[cfg_attr(not(windows), allow(dead_code))]
pub(crate) fn path_with(value: &str, dir: &str) -> Option<String> {
if value.split(';').any(|entry| same_entry(entry, dir)) {
return None;
}
Some(if value.is_empty() {
dir.to_owned()
} else if let Some(rest) = value.strip_suffix(';') {
format!("{rest};{dir};")
} else {
format!("{value};{dir}")
})
}
#[cfg_attr(not(windows), allow(dead_code))]
pub(crate) fn path_without(value: &str, dir: &str) -> Option<String> {
let entries: Vec<&str> = value.split(';').collect();
let kept: Vec<&str> = entries
.iter()
.copied()
.filter(|entry| !same_entry(entry, dir))
.collect();
(kept.len() != entries.len()).then(|| kept.join(";"))
}
fn same_entry(entry: &str, dir: &str) -> bool {
let norm = |s: &str| {
let s = s
.strip_prefix('"')
.and_then(|s| s.strip_suffix('"'))
.unwrap_or(s);
s.strip_suffix('\\').unwrap_or(s).to_lowercase()
};
!entry.is_empty() && norm(entry) == norm(dir)
}
#[cfg(test)]
mod tests {
use std::ffi::OsString;
use super::*;
const DIR: &str = "C:\\x\\current";
#[cfg(unix)]
#[test]
fn the_login_path_is_read_after_the_marker() {
let out = b"Welcome back!\ncorundum-login-path: in a greeting\n\ncorundum-login-path:/home/u/.local/bin:/usr/bin\n";
assert_eq!(
parse_login_path(out),
Some(OsString::from("/home/u/.local/bin:/usr/bin"))
);
assert_eq!(parse_login_path(b"no marker here\n"), None);
assert_eq!(parse_login_path(b"\ncorundum-login-path:\n"), None);
}
#[cfg(unix)]
#[test]
fn the_login_shell_is_asked_for_its_path() {
let dir = tempfile::tempdir().unwrap();
assert_ne!(on_login_path(dir.path()), Some(true));
assert_ne!(on_login_path(Path::new("/usr/bin")), Some(false));
}
#[test]
fn a_dir_is_added_at_the_end_of_a_path_without_it() {
let cases: &[(&str, Option<&str>)] = &[
("C:\\a;C:\\b", Some("C:\\a;C:\\b;C:\\x\\current")),
("", Some("C:\\x\\current")),
("C:\\a;", Some("C:\\a;C:\\x\\current;")),
("C:\\a;;C:\\b", Some("C:\\a;;C:\\b;C:\\x\\current")),
("C:\\a;C:\\X\\CURRENT\\", None),
("\"C:\\x\\current\";C:\\b", None),
("C:\\x\\current", None),
];
for (value, want) in cases {
assert_eq!(path_with(value, DIR).as_deref(), *want, "{value:?}");
}
}
#[test]
fn a_dir_is_removed_from_a_path_keeping_the_other_entries() {
let cases: &[(&str, Option<&str>)] = &[
(
"%USERPROFILE%\\bin;C:\\x\\current;C:\\b",
Some("%USERPROFILE%\\bin;C:\\b"),
),
("C:\\a;C:\\x\\current;", Some("C:\\a;")),
("C:\\a;;C:\\X\\Current\\;C:\\b", Some("C:\\a;;C:\\b")),
("\"C:\\x\\current\"", Some("")),
("C:\\x\\current;C:\\a;C:\\x\\current", Some("C:\\a")),
("C:\\a;C:\\b", None),
("", None),
("C:\\x\\current2", None),
];
for (value, want) in cases {
assert_eq!(path_without(value, DIR).as_deref(), *want, "{value:?}");
}
}
#[test]
fn a_dir_is_on_a_path_naming_it_with_or_without_a_trailing_separator() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("bin");
std::fs::create_dir(&dir).unwrap();
let other = tmp.path().join("other");
let sep = std::path::MAIN_SEPARATOR;
let path_of = |entries: &[PathBuf]| -> OsString { std::env::join_paths(entries).unwrap() };
assert!(on_path_in(&dir, &path_of(&[other.clone(), dir.clone()])));
let trailing = PathBuf::from(format!("{}{sep}", dir.display()));
assert!(on_path_in(&dir, &path_of(&[trailing])));
let dotted = tmp.path().join(".").join("bin");
assert!(on_path_in(&dir, &path_of(&[dotted])));
assert!(!on_path_in(&dir, &path_of(&[other])));
assert!(!on_path_in(&dir, OsStr::new("")));
}
}