use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{Context, Result};
use crate::platform::{self, BIN_NAME};
use crate::release::Version;
use crate::ui::Ui;
pub fn binary_in(dir: &Path) -> PathBuf {
dir.join(BIN_NAME)
}
pub fn installed_version(dir: &Path) -> Option<Version> {
let output = Command::new(binary_in(dir))
.arg("--version")
.output()
.ok()?;
if !output.status.success() {
return None;
}
let text = String::from_utf8_lossy(&output.stdout);
let token = text.split_whitespace().nth(1)?;
Version::parse(token).ok()
}
pub fn place(staged: &Path, dir: &Path) -> Result<PathBuf> {
std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
let target = binary_in(dir);
let pending = dir.join(format!(".{BIN_NAME}.new"));
std::fs::copy(staged, &pending).with_context(|| format!("writing {}", pending.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&pending, std::fs::Permissions::from_mode(0o755))
.with_context(|| format!("making {} executable", pending.display()))?;
}
#[cfg(windows)]
if target.exists() {
let backup = dir.join(format!(".{BIN_NAME}.old"));
let _ = std::fs::remove_file(&backup);
std::fs::rename(&target, &backup)
.context("could not replace the running binary — close Accent CMS and retry")?;
}
std::fs::rename(&pending, &target).with_context(|| {
let _ = std::fs::remove_file(&pending);
format!("installing {}", target.display())
})?;
Ok(target)
}
pub fn remove(dir: &Path) -> Result<bool> {
let binary = binary_in(dir);
if !binary.exists() {
return Ok(false);
}
std::fs::remove_file(&binary).with_context(|| format!("removing {}", binary.display()))?;
Ok(true)
}
pub fn purge_data(ui: &Ui) -> Result<()> {
for dir in platform::data_dirs() {
if dir.exists() {
std::fs::remove_dir_all(&dir).with_context(|| format!("removing {}", dir.display()))?;
ui.say(format!("Removed {}", dir.display()));
}
}
Ok(())
}
pub fn check_path(dir: &Path, ui: &Ui) {
if on_path(dir) {
return;
}
ui.blank();
ui.warn(format!("Note: {} is not on your PATH.", dir.display()));
if cfg!(windows) {
ui.warn("To add it, run:");
ui.warn("");
ui.warn(" $path = [Environment]::GetEnvironmentVariable('Path', 'User')");
ui.warn(format!(
" [Environment]::SetEnvironmentVariable('Path', \"$path;{}\", 'User')",
dir.display()
));
ui.warn("");
ui.warn("Then restart your terminal.");
return;
}
let shell = std::env::var("SHELL").unwrap_or_default();
let dir = dir.display();
if shell.ends_with("fish") {
ui.warn("Add it by appending this to ~/.config/fish/config.fish:");
ui.warn(format!(" fish_add_path {dir}"));
} else {
let profile = if shell.ends_with("zsh") {
"~/.zshrc"
} else {
"~/.bashrc"
};
ui.warn(format!("Add it by appending this to {profile}:"));
ui.warn(format!(" export PATH=\"{dir}:$PATH\""));
}
ui.warn("Then restart your shell.");
}
pub fn check_shadowing(dir: &Path, ui: &Ui) {
let installed = binary_in(dir);
let Some(resolved) = resolve_on_path(BIN_NAME) else {
return;
};
if same_file(&resolved, &installed) {
return;
}
let version = Command::new(&resolved)
.arg("--version")
.output()
.ok()
.filter(|out| out.status.success())
.map(|out| String::from_utf8_lossy(&out.stdout).trim().to_string())
.filter(|text| !text.is_empty())
.unwrap_or_else(|| "unknown version".to_string());
ui.blank();
ui.warn("Warning: 'accent' currently resolves to a different binary:");
ui.warn(format!(" {} ({version})", resolved.display()));
ui.warn(format!(
"which shadows the up-to-date binary at {}.",
installed.display()
));
if cfg!(windows) {
ui.warn(format!(
"Remove the shadowing binary or move {} earlier in your PATH,",
dir.display()
));
ui.warn("then start a new terminal.");
} else {
ui.warn(format!(
"Remove the shadowing binary or move {} earlier in your PATH,",
dir.display()
));
ui.warn("then run 'hash -r' (bash/zsh) or restart your shell.");
}
}
pub fn on_path(dir: &Path) -> bool {
std::env::var_os("PATH")
.map(|path| std::env::split_paths(&path).any(|entry| entry == dir))
.unwrap_or(false)
}
pub fn resolve_on_path(name: &str) -> Option<PathBuf> {
let path = std::env::var_os("PATH")?;
std::env::split_paths(&path)
.map(|dir| dir.join(name))
.find(|candidate| is_executable(candidate))
}
fn is_executable(path: &Path) -> bool {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(path)
.map(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0)
.unwrap_or(false)
}
#[cfg(not(unix))]
{
path.is_file()
}
}
fn same_file(a: &Path, b: &Path) -> bool {
match (a.canonicalize(), b.canonicalize()) {
(Ok(a), Ok(b)) => a == b,
_ => a == b,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
fn stub(dir: &Path, name: &str, body: &str) -> PathBuf {
use std::os::unix::fs::PermissionsExt;
let path = dir.join(name);
std::fs::write(&path, body).unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
path
}
#[test]
fn places_a_binary_and_replaces_it_in_place() {
let staging = tempfile::tempdir().unwrap();
let target = tempfile::tempdir().unwrap();
let dir = target.path().join("bin");
let first = staging.path().join("accent");
std::fs::write(&first, b"version one").unwrap();
let installed = place(&first, &dir).unwrap();
assert_eq!(std::fs::read(&installed).unwrap(), b"version one");
std::fs::write(&first, b"version two").unwrap();
place(&first, &dir).unwrap();
assert_eq!(std::fs::read(&installed).unwrap(), b"version two");
let leftovers: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.map(|e| e.unwrap().file_name())
.filter(|name| name != BIN_NAME)
.collect();
assert!(leftovers.is_empty(), "{leftovers:?}");
}
#[cfg(unix)]
#[test]
fn placed_binaries_are_executable() {
use std::os::unix::fs::PermissionsExt;
let staging = tempfile::tempdir().unwrap();
let target = tempfile::tempdir().unwrap();
let staged = staging.path().join("accent");
std::fs::write(&staged, b"#!/bin/sh\n").unwrap();
std::fs::set_permissions(&staged, std::fs::Permissions::from_mode(0o600)).unwrap();
let installed = place(&staged, target.path()).unwrap();
let mode = std::fs::metadata(&installed).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o755);
}
#[test]
fn remove_reports_whether_anything_was_there() {
let dir = tempfile::tempdir().unwrap();
assert!(!remove(dir.path()).unwrap());
std::fs::write(binary_in(dir.path()), b"binary").unwrap();
assert!(remove(dir.path()).unwrap());
assert!(!binary_in(dir.path()).exists());
}
#[cfg(unix)]
#[test]
fn reads_the_version_an_installed_binary_reports() {
let dir = tempfile::tempdir().unwrap();
stub(
dir.path(),
BIN_NAME,
"#!/bin/sh\necho 'accent 0.25.0 (abc1234 2026-08-21)'\n",
);
assert_eq!(
installed_version(dir.path()).unwrap(),
Version::parse("0.25.0").unwrap()
);
}
#[cfg(unix)]
#[test]
fn an_unparseable_version_is_unknown_not_a_match() {
let dir = tempfile::tempdir().unwrap();
stub(dir.path(), BIN_NAME, "#!/bin/sh\necho garbage\n");
assert_eq!(installed_version(dir.path()), None);
let empty = tempfile::tempdir().unwrap();
assert_eq!(installed_version(empty.path()), None);
}
}