use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
pub const ALIAS: &str = "tn";
pub const PRIMARY: &str = "turnout";
#[cfg(test)]
pub fn path_beside(exe: &Path) -> PathBuf {
sibling_named(exe, ALIAS)
}
pub fn counterpart(exe: &Path) -> Option<PathBuf> {
let stem = exe.file_stem()?.to_str()?;
let other = match stem {
ALIAS => PRIMARY,
PRIMARY => ALIAS,
_ => return None,
};
Some(sibling_named(exe, other))
}
fn sibling_named(exe: &Path, name: &str) -> PathBuf {
let mut sibling = exe.with_file_name(name);
if let Some(extension) = exe.extension() {
sibling.set_extension(extension);
}
sibling
}
pub fn link(exe: &Path, alias: &Path) -> Result<()> {
if alias == exe {
anyhow::bail!("refusing to link {} to itself", alias.display());
}
let _ = std::fs::remove_file(alias);
#[cfg(windows)]
let result = std::fs::hard_link(exe, alias);
#[cfg(unix)]
let result = {
let target = exe.file_name().unwrap_or(exe.as_os_str());
std::os::unix::fs::symlink(target, alias)
};
result.with_context(|| format!("cannot link {} to {}", alias.display(), exe.display()))
}
pub fn refresh(exe: &Path) -> Outcome {
let Some(other) = counterpart(exe) else {
return Outcome::Absent;
};
if !other.exists() {
return Outcome::Absent;
}
match link(exe, &other) {
Ok(()) => Outcome::Relinked(other),
Err(err) => Outcome::Failed(other, err.to_string()),
}
}
#[derive(Debug, PartialEq)]
pub enum Outcome {
Absent,
Relinked(PathBuf),
Failed(PathBuf, String),
}
impl Outcome {
pub fn message(&self) -> Option<String> {
match self {
Self::Absent => None,
Self::Relinked(other) => Some(format!("{} updated too.", other.display())),
Self::Failed(other, err) => Some(format!(
"Warning: {} still points at the previous version and could not be relinked ({err}).\n\
Re-run the installer to fix it.",
other.display()
)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[cfg(windows)]
fn the_alias_sits_beside_the_binary_with_the_same_extension() {
let exe = PathBuf::from(r"C:\Users\dev\AppData\Local\Programs\turnout\turnout.exe");
assert_eq!(path_beside(&exe), PathBuf::from(r"C:\Users\dev\AppData\Local\Programs\turnout\tn.exe"));
}
#[test]
#[cfg(unix)]
fn the_alias_sits_beside_the_binary() {
let exe = PathBuf::from("/home/dev/.local/bin/turnout");
assert_eq!(path_beside(&exe), PathBuf::from("/home/dev/.local/bin/tn"));
}
#[test]
fn the_alias_is_a_sibling_that_keeps_the_extension() {
let dir = tempfile::tempdir().unwrap();
let exe = dir.path().join(if cfg!(windows) { "turnout.exe" } else { "turnout" });
let alias = path_beside(&exe);
assert_eq!(alias.parent(), exe.parent());
assert_eq!(alias.extension(), exe.extension());
assert_eq!(alias.file_stem().unwrap(), ALIAS);
}
#[test]
fn a_linked_alias_shares_the_binary_it_points_at() {
let dir = tempfile::tempdir().unwrap();
let exe = dir.path().join("turnout");
std::fs::write(&exe, b"version one").unwrap();
let alias = path_beside(&exe);
link(&exe, &alias).unwrap();
assert_eq!(std::fs::read(&alias).unwrap(), b"version one");
std::fs::write(&exe, b"version two").unwrap();
assert_eq!(
std::fs::read(&alias).unwrap(),
b"version two",
"the alias must be the same file as the binary, not a copy of it"
);
}
#[test]
fn the_counterpart_is_whichever_name_is_not_running() {
let dir = tempfile::tempdir().unwrap();
let primary = dir.path().join(if cfg!(windows) { "turnout.exe" } else { "turnout" });
let alias = path_beside(&primary);
assert_eq!(counterpart(&primary).unwrap(), alias);
assert_eq!(counterpart(&alias).unwrap(), primary);
}
#[test]
fn a_renamed_binary_has_no_counterpart() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(counterpart(&dir.path().join("my-turnout")), None);
}
#[test]
fn linking_a_name_to_itself_is_refused_rather_than_destroying_it() {
let dir = tempfile::tempdir().unwrap();
let exe = dir.path().join("tn");
std::fs::write(&exe, b"the only copy").unwrap();
assert!(link(&exe, &exe).is_err());
assert!(exe.exists(), "the binary must survive a self-link attempt");
assert_eq!(std::fs::read(&exe).unwrap(), b"the only copy");
}
#[test]
fn refreshing_from_the_alias_repairs_the_primary_name() {
let dir = tempfile::tempdir().unwrap();
let primary = dir.path().join("turnout");
let alias = dir.path().join("tn");
std::fs::write(&alias, b"new version").unwrap();
std::fs::write(&primary, b"old version").unwrap();
assert!(matches!(refresh(&alias), Outcome::Relinked(_)));
assert!(alias.exists(), "the running name must not be removed");
assert_eq!(std::fs::read(&primary).unwrap(), b"new version");
}
#[test]
fn refresh_leaves_an_aliasless_install_alone() {
let dir = tempfile::tempdir().unwrap();
let exe = dir.path().join("turnout");
std::fs::write(&exe, b"binary").unwrap();
assert_eq!(refresh(&exe), Outcome::Absent);
assert!(!path_beside(&exe).exists());
}
#[test]
fn refresh_relinks_an_alias_left_behind_by_an_update() {
let dir = tempfile::tempdir().unwrap();
let exe = dir.path().join("turnout");
let alias = path_beside(&exe);
std::fs::write(&exe, b"old version").unwrap();
std::fs::copy(&exe, &alias).unwrap();
std::fs::write(&exe, b"new version").unwrap();
assert_eq!(std::fs::read(&alias).unwrap(), b"old version");
assert!(matches!(refresh(&exe), Outcome::Relinked(_)));
assert_eq!(std::fs::read(&alias).unwrap(), b"new version");
std::fs::write(&exe, b"newer still").unwrap();
assert_eq!(std::fs::read(&alias).unwrap(), b"newer still");
}
#[test]
fn refreshing_a_healthy_alias_leaves_it_working() {
let dir = tempfile::tempdir().unwrap();
let exe = dir.path().join("turnout");
std::fs::write(&exe, b"binary").unwrap();
let alias = path_beside(&exe);
link(&exe, &alias).unwrap();
assert!(matches!(refresh(&exe), Outcome::Relinked(_)));
std::fs::write(&exe, b"next release").unwrap();
assert_eq!(std::fs::read(&alias).unwrap(), b"next release");
}
#[test]
fn a_failed_relink_names_the_alias_and_a_way_out() {
let outcome = Outcome::Failed(PathBuf::from("/home/dev/.local/bin/tn"), "permission denied".to_string());
let message = outcome.message().expect("a failure has to be reported");
assert!(message.contains("/home/dev/.local/bin/tn"));
assert!(message.contains("previous version"));
assert!(message.contains("installer"));
}
#[test]
fn a_successful_relink_says_so() {
let message = Outcome::Relinked(PathBuf::from("/home/dev/.local/bin/tn")).message().unwrap();
assert!(message.contains("/home/dev/.local/bin/tn"));
}
}