use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
pub fn launcher_path(home: &Path) -> PathBuf {
home.join("bin").join("oxicode")
}
pub fn versions_dir(home: &Path) -> PathBuf {
home.join("versions")
}
pub fn parse_version_dir(name: &str) -> Option<(u64, u64, u64)> {
let mut parts = name.split('.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next()?.parse().ok()?;
let patch = parts.next()?.parse().ok()?;
parts.next().is_none().then_some((major, minor, patch))
}
pub fn version_from_version_output(text: &str) -> Option<String> {
let raw = text.trim().trim_start_matches('v');
let token = raw
.split_whitespace()
.find(|s| parse_version_dir(s).is_some())?;
Some(token.to_string())
}
pub fn adopt_binary(
home: &Path,
binary: &Path,
version: &str,
relink: Option<&Path>,
) -> Result<PathBuf> {
if parse_version_dir(version).is_none() {
anyhow::bail!("unusable version {version:?} — expected MAJOR.MINOR.PATCH");
}
let versions = versions_dir(home);
std::fs::create_dir_all(&versions)
.with_context(|| format!("create versions dir {}", versions.display()))?;
let launcher = launcher_path(home);
let version_dir = versions.join(version);
std::fs::create_dir_all(&version_dir)
.with_context(|| format!("create version dir {}", version_dir.display()))?;
let target = version_dir.join("oxicode");
if let Err(e) = std::fs::rename(binary, &target) {
if e.raw_os_error() == Some(18 ) {
std::fs::copy(binary, &target)
.with_context(|| format!("copy {} → {}", binary.display(), target.display()))?;
let _ = std::fs::remove_file(binary);
} else {
return Err(e)
.with_context(|| format!("move {} → {}", binary.display(), target.display()));
}
}
#[cfg(unix)]
{
let mut perms = std::fs::metadata(&target)?.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&target, perms)
.with_context(|| format!("chmod 0755 {}", target.display()))?;
}
flip_launcher(&launcher, version)?;
if let Some(rel) = relink
&& rel != target
&& rel != binary
{
repoint(rel, &launcher)?;
}
prune_versions(home, version, 2)?;
Ok(launcher)
}
pub(crate) fn flip_launcher(launcher: &Path, version: &str) -> Result<()> {
let bin_dir = launcher
.parent()
.context("launcher path has no parent directory")?;
std::fs::create_dir_all(bin_dir)
.with_context(|| format!("create bin dir {}", bin_dir.display()))?;
let target = PathBuf::from("../versions").join(version).join("oxicode");
let tmp_link = bin_dir.join(".oxicode.link.tmp");
let _ = std::fs::remove_file(&tmp_link);
#[cfg(unix)]
{
std::os::unix::fs::symlink(&target, &tmp_link)
.with_context(|| format!("symlink {} → {}", tmp_link.display(), target.display()))?;
}
#[cfg(not(unix))]
{
let source = bin_dir
.parent()
.context("launcher is not two levels below the app root")?
.join("versions")
.join(version)
.join("oxicode");
std::fs::copy(&source, &tmp_link)
.with_context(|| format!("copy {} → {}", source.display(), tmp_link.display()))?;
}
std::fs::rename(&tmp_link, launcher).with_context(|| {
format!(
"atomic launcher flip {} → {}",
tmp_link.display(),
launcher.display()
)
})?;
Ok(())
}
pub(crate) fn repoint(path: &Path, launcher: &Path) -> Result<()> {
if !path.exists() {
return Ok(());
}
if let Ok(target) = std::fs::read_link(path) {
if launcher
.file_name()
.is_some_and(|name| std::path::Path::new(name) == target.as_path())
{
return Ok(());
}
}
let parent = path.parent().context("relink path has no parent")?;
let tmp = parent.join(".oxicode.link.tmp");
let _ = std::fs::remove_file(&tmp);
#[cfg(unix)]
{
let launcher_name = launcher.file_name().context("launcher has no file name")?;
std::os::unix::fs::symlink(launcher_name, &tmp)
.with_context(|| format!("symlink {} → {}", tmp.display(), launcher_name.display()))?;
}
#[cfg(not(unix))]
{
std::fs::copy(launcher, &tmp)
.with_context(|| format!("copy {} → {}", launcher.display(), tmp.display()))?;
}
std::fs::rename(&tmp, path)
.with_context(|| format!("atomic relink {} → {}", tmp.display(), path.display()))?;
Ok(())
}
pub(crate) fn prune_versions(home: &Path, current: &str, keep: usize) -> Result<Vec<String>> {
let versions = versions_dir(home);
let mut installed: Vec<((u64, u64, u64), String)> = Vec::new();
if !versions.exists() {
return Ok(vec![]);
}
for entry in
std::fs::read_dir(&versions).with_context(|| format!("read_dir {}", versions.display()))?
{
let entry = entry.with_context(|| format!("read_dir entry in {}", versions.display()))?;
if !entry.file_type().is_ok_and(|t| t.is_dir()) {
continue;
}
let name = entry.file_name().to_string_lossy().into_owned();
if name == current {
continue;
}
if let Some(v) = parse_version_dir(&name) {
installed.push((v, name));
}
}
installed.sort();
let mut removed = Vec::new();
while installed.len() >= keep {
let (_, name) = installed.remove(0);
std::fs::remove_dir_all(versions.join(&name))
.with_context(|| format!("prune version dir {}", name))?;
removed.push(name);
}
Ok(removed)
}
pub fn cargo_oxicode_bin() -> Option<PathBuf> {
let home = std::env::var_os("HOME")?;
let cargo_root = if let Some(d) = std::env::var_os("CARGO_HOME") {
if d.is_empty() {
PathBuf::from(home).join(".cargo")
} else {
PathBuf::from(d)
}
} else {
PathBuf::from(home).join(".cargo")
};
Some(cargo_root.join("bin").join("oxicode"))
}
pub fn version_of(binary: &Path) -> Option<String> {
let out = std::process::Command::new(binary)
.arg("--version")
.stdin(std::process::Stdio::null())
.output()
.ok()?;
let s = String::from_utf8_lossy(&out.stdout).into_owned();
version_from_version_output(&s)
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
fn write_exe(dir: &Path, name: &str, body: &[u8]) -> PathBuf {
let p = dir.join(name);
std::fs::write(&p, body).unwrap();
let mut perms = std::fs::metadata(&p).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&p, perms).unwrap();
p
}
#[test]
fn parse_version_dir_round_trip() {
assert_eq!(parse_version_dir("0.79.0"), Some((0, 79, 0)));
assert_eq!(parse_version_dir("1.2.3"), Some((1, 2, 3)));
assert_eq!(parse_version_dir("v0.1.0"), None);
assert_eq!(parse_version_dir("0.1.0-beta"), None);
assert_eq!(parse_version_dir(""), None);
}
#[test]
fn version_from_version_output_handles_clap_banner() {
assert_eq!(
version_from_version_output("oxicode 0.79.0\n"),
Some("0.79.0".to_string())
);
assert_eq!(
version_from_version_output("oxicode 0.79.0 (abc123)"),
Some("0.79.0".to_string())
);
assert_eq!(version_from_version_output("garbage"), None);
}
#[test]
fn adopt_binary_builds_managed_layout_and_relinks() {
let home = tempfile::tempdir().unwrap();
let binaries = tempfile::tempdir().unwrap();
let bin = write_exe(binaries.path(), "oxicode", b"#!/bin/sh\n");
let relink = binaries.path().join("cargo-oxicode");
std::fs::write(&relink, b"placeholder").unwrap();
let launcher = adopt_binary(home.path(), &bin, "0.79.0", Some(&relink)).unwrap();
assert!(home.path().join("versions/0.79.0/oxicode").is_file());
assert!(launcher.is_file());
assert_eq!(
std::fs::read_link(&launcher).unwrap(),
std::path::Path::new("../versions/0.79.0/oxicode")
);
let relink_target = std::fs::read_link(&relink).unwrap();
assert_eq!(
relink_target,
std::path::Path::new("oxicode"),
"relink should target the launcher (same-dir basename)"
);
assert!(!bin.exists(), "the original binary path was moved");
}
#[test]
fn adopt_prunes_older_versions_to_two() {
let home = tempfile::tempdir().unwrap();
let staging = tempfile::tempdir().unwrap();
for v in ["0.78.0", "0.79.0", "1.0.0"] {
let bin = write_exe(staging.path(), "oxicode", b"#!/bin/sh\n");
adopt_binary(home.path(), &bin, v, None).unwrap();
}
assert!(!home.path().join("versions/0.78.0").exists());
assert!(home.path().join("versions/0.79.0").exists());
assert!(home.path().join("versions/1.0.0").exists());
}
#[test]
fn repoint_is_idempotent_on_converged_links() {
let home = tempfile::tempdir().unwrap();
let staging = tempfile::tempdir().unwrap();
let bin = write_exe(staging.path(), "oxicode", b"#!/bin/sh\n");
let relink = staging.path().join("cargo-bin/oxicode");
std::fs::create_dir_all(relink.parent().unwrap()).unwrap();
std::fs::write(&relink, b"old").unwrap();
adopt_binary(home.path(), &bin, "0.1.0", Some(&relink)).unwrap();
let first = std::fs::read_link(&relink).unwrap();
repoint(&relink, &launcher_path(home.path())).unwrap();
let second = std::fs::read_link(&relink).unwrap();
assert_eq!(first, second);
}
#[test]
fn rejects_invalid_version_strings() {
let home = tempfile::tempdir().unwrap();
let staging = tempfile::tempdir().unwrap();
let bin = write_exe(staging.path(), "oxicode", b"#!/bin/sh\n");
assert!(adopt_binary(home.path(), &bin, "v0.1.0", None).is_err());
assert!(adopt_binary(home.path(), &bin, "not-a-version", None).is_err());
}
}