use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum LinkAction {
Created(PathBuf),
AlreadyPresent {
path: PathBuf,
text: PathBuf,
},
Replaced(PathBuf),
Removed(PathBuf),
WouldCreate(PathBuf),
WouldReplace(PathBuf),
WouldRemove(PathBuf),
Left(PathBuf, &'static str),
}
impl LinkAction {
pub fn describe(&self, target: &Path) -> String {
let t = target.display();
match self {
LinkAction::Created(p) => format!("created {} -> {t}", p.display()),
LinkAction::AlreadyPresent { path, text } => {
format!("already present {} -> {}", path.display(), text.display())
}
LinkAction::Replaced(p) => format!("replaced {} -> {t}", p.display()),
LinkAction::Removed(p) => format!("removed {}", p.display()),
LinkAction::WouldCreate(p) => format!("would create {} -> {t}", p.display()),
LinkAction::WouldReplace(p) => format!("would replace {} -> {t}", p.display()),
LinkAction::WouldRemove(p) => format!("would remove {}", p.display()),
LinkAction::Left(p, why) => format!("left {}: {why}", p.display()),
}
}
}
pub fn current_target() -> stuffr::Result<PathBuf> {
let exe = std::env::current_exe()?.canonicalize()?;
Ok(stable_target(&exe, |p| p.canonicalize().ok()))
}
pub fn stable_target(exe: &Path, resolve: impl Fn(&Path) -> Option<PathBuf>) -> PathBuf {
use std::path::Component;
let parts: Vec<Component<'_>> = exe.components().collect();
let n = parts.len();
let is = |c: &Component<'_>, s: &str| c.as_os_str() == s;
if n >= 5
&& is(&parts[n - 5], "Cellar")
&& is(&parts[n - 4], "stuffr")
&& matches!(parts[n - 3], Component::Normal(_))
&& is(&parts[n - 2], "bin")
&& is(&parts[n - 1], "stuffr")
{
let prefix: PathBuf = parts[..n - 5].iter().collect();
let opt = prefix.join("opt/stuffr/bin/stuffr");
if resolve(&opt).is_some_and(|r| r == exe) {
return opt;
}
}
exe.to_path_buf()
}
#[cfg(unix)]
pub fn install_links(
dir: &Path,
names: &[&str],
force: bool,
remove: bool,
dry_run: bool,
target: &Path,
) -> stuffr::Result<Vec<LinkAction>> {
use std::io::ErrorKind;
use stuffr::Error;
let mut seen = std::collections::HashSet::new();
let names: Vec<&str> = names.iter().copied().filter(|n| seen.insert(*n)).collect();
for n in &names {
if !super::names().contains(n) {
return Err(Error::Usage(format!(
"`{n}` is not a compatibility name (known: {})",
super::names().join(", ")
)));
}
}
let dir = match dir.canonicalize() {
Ok(d) if d.is_dir() => d,
Ok(_) => {
return Err(Error::Usage(format!(
"{} is not a directory",
dir.display()
)));
}
Err(e) if e.kind() == ErrorKind::NotFound => {
return Err(Error::Usage(format!(
"directory {} does not exist",
dir.display()
)));
}
Err(e) => return Err(at(dir, e)),
};
let file = target.canonicalize().ok();
enum Kind {
Live,
Stale,
Foreign,
}
let classify = |p: &Path| -> Kind {
let Ok(text) = std::fs::read_link(p) else {
return Kind::Foreign;
};
if text == target {
return Kind::Live;
}
match p.canonicalize() {
Ok(c) => {
if file.as_ref() == Some(&c) {
Kind::Live
} else {
Kind::Foreign
}
}
Err(e)
if e.kind() == ErrorKind::NotFound
&& text.file_name().is_some_and(|f| f == "stuffr") =>
{
Kind::Stale
}
Err(_) => Kind::Foreign,
}
};
enum Step {
Create,
Keep,
Replace,
Remove,
Leave(&'static str),
}
let mut plan = Vec::new();
for &name in &names {
let path = dir.join(name);
let meta = match std::fs::symlink_metadata(&path) {
Ok(m) => Some(m),
Err(e) if e.kind() == ErrorKind::NotFound => None,
Err(e) => return Err(at(&path, e)),
};
let kind = match &meta {
Some(m) if m.file_type().is_symlink() => Some(classify(&path)),
_ => None,
};
let step = match (&meta, remove) {
(None, true) => continue,
(None, false) => Step::Create,
(Some(_), true) => match kind {
Some(Kind::Live | Kind::Stale) => Step::Remove,
Some(Kind::Foreign) => Step::Leave("not a stuffr link"),
None => Step::Leave("not a link"),
},
(Some(m), false) => match kind {
Some(Kind::Live) => Step::Keep,
Some(Kind::Stale) => Step::Replace,
_ if m.is_dir() => {
return Err(Error::Usage(format!(
"{} is a directory; refusing to replace it",
path.display()
)));
}
_ if force => Step::Replace,
_ => {
return Err(Error::Usage(format!(
"{} already exists and is not a link to this stuffr; \
pass --force to replace it",
path.display()
)));
}
},
};
plan.push((path, step));
}
let mut actions = Vec::new();
for (path, step) in plan {
actions.push(match step {
Step::Keep => {
let text = std::fs::read_link(&path).map_err(|e| at(&path, e))?;
LinkAction::AlreadyPresent { path, text }
}
Step::Leave(why) => LinkAction::Left(path, why),
Step::Create if dry_run => LinkAction::WouldCreate(path),
Step::Replace if dry_run => LinkAction::WouldReplace(path),
Step::Remove if dry_run => LinkAction::WouldRemove(path),
Step::Create => {
std::os::unix::fs::symlink(target, &path).map_err(|e| at(&path, e))?;
LinkAction::Created(path)
}
Step::Replace => {
let name = path.file_name().unwrap_or_default().to_string_lossy();
let tmp = path.with_file_name(format!(".{name}.stuffr-tmp-{}", std::process::id()));
let _ = std::fs::remove_file(&tmp);
std::os::unix::fs::symlink(target, &tmp).map_err(|e| at(&tmp, e))?;
if let Err(e) = std::fs::rename(&tmp, &path) {
let _ = std::fs::remove_file(&tmp);
return Err(at(&path, e));
}
LinkAction::Replaced(path)
}
Step::Remove => {
std::fs::remove_file(&path).map_err(|e| at(&path, e))?;
LinkAction::Removed(path)
}
});
}
Ok(actions)
}
#[cfg(unix)]
fn at(path: &Path, e: std::io::Error) -> stuffr::Error {
stuffr::Error::Io(std::io::Error::new(
e.kind(),
format!("{}: {e}", path.display()),
))
}
#[cfg(not(unix))]
pub fn install_links(
_dir: &Path,
_names: &[&str],
_force: bool,
_remove: bool,
_dry_run: bool,
_target: &Path,
) -> stuffr::Result<Vec<LinkAction>> {
Err(stuffr::Error::Unsupported(
"install-links needs symlinks; Windows is not supported".into(),
))
}
#[cfg(test)]
mod tests {
use super::stable_target;
use std::path::{Path, PathBuf};
const CELLAR: &str = "/opt/homebrew/Cellar/stuffr/0.11.0/bin/stuffr";
const OPT: &str = "/opt/homebrew/opt/stuffr/bin/stuffr";
#[test]
fn a_cellar_path_whose_opt_resolves_to_it_targets_opt() {
let got = stable_target(Path::new(CELLAR), |p| {
(p == Path::new(OPT)).then(|| PathBuf::from(CELLAR))
});
assert_eq!(got, Path::new(OPT));
}
#[test]
fn a_cellar_path_whose_opt_points_elsewhere_stays_canonical() {
let elsewhere = |_: &Path| {
Some(PathBuf::from(
"/opt/homebrew/Cellar/stuffr/0.10.4/bin/stuffr",
))
};
assert_eq!(
stable_target(Path::new(CELLAR), elsewhere),
Path::new(CELLAR)
);
assert_eq!(
stable_target(Path::new(CELLAR), |_| None),
Path::new(CELLAR)
);
}
#[test]
fn a_non_homebrew_path_stays_canonical() {
let any = |_: &Path| Some(PathBuf::from("/usr/local/bin/stuffr"));
for p in [
"/usr/local/bin/stuffr",
"/home/u/.cargo/bin/stuffr",
"/x/Cellar/stuffr/bin/stuffr",
"/x/Cellar/other/0.1/bin/stuffr",
"/x/NotCellar/stuffr/0.1/bin/stuffr",
"/x/Cellar/stuffr/0.1/libexec/stuffr",
] {
assert_eq!(stable_target(Path::new(p), any), Path::new(p), "{p}");
}
}
#[test]
fn the_prefix_is_whatever_precedes_cellar() {
let exe = "/usr/local/Cellar/stuffr/1.0/bin/stuffr";
let opt = "/usr/local/opt/stuffr/bin/stuffr";
let got = stable_target(Path::new(exe), |p| {
(p == Path::new(opt)).then(|| PathBuf::from(exe))
});
assert_eq!(got, Path::new(opt));
}
}