use std::{
env,
path::{Path, PathBuf},
process::Command,
time::Duration
};
use rust_i18n::t;
use crate::error::TwcError;
const REPO: &str = "RAprogramm/twc-rs";
const HTTP_TIMEOUT: Duration = Duration::from_secs(10);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InstallChannel {
Pacman,
Cargo,
Deb,
Installer,
Unknown
}
pub async fn run(check_only: bool) -> Result<(), TwcError> {
let current = env!("CARGO_PKG_VERSION");
let latest = fetch_latest_version().await?;
println!(
"{}",
t!("cli.update_current", current => current, latest => latest.as_str())
);
if !is_newer(&latest, current) {
println!("{}", t!("cli.update_up_to_date"));
return Ok(());
}
println!(
"{}",
t!("cli.update_available", current => current, latest => latest.as_str())
);
let exe = env::current_exe()
.and_then(std::fs::canonicalize)
.map_err(|e| TwcError::Io(e.to_string()))?;
let channel = detect_channel(&exe);
println!(
"{}",
t!("cli.update_channel", channel => format!("{channel:?}"))
);
match update_plan(channel, &latest, &exe) {
UpdatePlan::Run(argv) => {
let shown = argv.join(" ");
if check_only {
println!("{}", t!("cli.update_do", command => shown.as_str()));
return Ok(());
}
println!("{}", t!("cli.update_running", command => shown.as_str()));
let status = Command::new(&argv[0])
.args(&argv[1..])
.status()
.map_err(|e| TwcError::Io(e.to_string()))?;
if status.success() {
println!("{}", t!("cli.update_done"));
} else {
return Err(TwcError::Api(
t!("cli.update_failed", command => shown.as_str()).into_owned()
));
}
}
UpdatePlan::Instruct(message) => println!("{message}")
}
Ok(())
}
enum UpdatePlan {
Run(Vec<String>),
Instruct(String)
}
fn update_plan(channel: InstallChannel, latest: &str, exe: &Path) -> UpdatePlan {
match channel {
InstallChannel::Pacman => aur_helper().map_or_else(
|| UpdatePlan::Instruct(t!("cli.update_no_aur_helper").into_owned()),
|helper| UpdatePlan::Run(vec![helper, "-Sy".to_owned(), "twc-rs-bin".to_owned()])
),
InstallChannel::Cargo => UpdatePlan::Run(vec![
"cargo".to_owned(),
"install".to_owned(),
"twc-rs".to_owned(),
]),
InstallChannel::Installer => UpdatePlan::Run(vec![
"sh".to_owned(),
"-c".to_owned(),
format!("curl -fsSL https://raw.githubusercontent.com/{REPO}/main/install.sh | sh"),
]),
InstallChannel::Deb => {
UpdatePlan::Instruct(t!("cli.update_instruction_deb", latest => latest).into_owned())
}
InstallChannel::Unknown => UpdatePlan::Instruct(
t!("cli.update_instruction_unknown", path => exe.display().to_string()).into_owned()
)
}
}
fn aur_helper() -> Option<String> {
["paru", "yay"]
.into_iter()
.find(|helper| which(helper))
.map(ToOwned::to_owned)
}
fn which(binary: &str) -> bool {
env::var_os("PATH")
.is_some_and(|paths| env::split_paths(&paths).any(|dir| dir.join(binary).is_file()))
}
fn detect_channel(exe: &Path) -> InstallChannel {
if owned_by(exe, "pacman", &["-Qo"]) {
return InstallChannel::Pacman;
}
if owned_by(exe, "dpkg", &["-S"]) {
return InstallChannel::Deb;
}
channel_from_path(exe, env::var_os("HOME").map(PathBuf::from).as_deref())
}
fn owned_by(exe: &Path, manager: &str, args: &[&str]) -> bool {
Command::new(manager)
.args(args)
.arg(exe)
.output()
.is_ok_and(|out| out.status.success())
}
fn channel_from_path(exe: &Path, home: Option<&Path>) -> InstallChannel {
if let Some(home) = home {
if exe.starts_with(home.join(".cargo").join("bin")) {
return InstallChannel::Cargo;
}
if exe.starts_with(home.join(".local").join("bin")) {
return InstallChannel::Installer;
}
}
if exe.starts_with("/usr/local/bin") {
return InstallChannel::Installer;
}
InstallChannel::Unknown
}
async fn fetch_latest_version() -> Result<String, TwcError> {
let response = reqwest::Client::new()
.get(format!(
"https://api.github.com/repos/{REPO}/releases/latest"
))
.header("User-Agent", concat!("twc-rs/", env!("CARGO_PKG_VERSION")))
.header("Accept", "application/vnd.github+json")
.timeout(HTTP_TIMEOUT)
.send()
.await
.and_then(reqwest::Response::error_for_status)
.map_err(|e| TwcError::Api(e.to_string()))?;
let payload: serde_json::Value = response
.json()
.await
.map_err(|e| TwcError::Api(e.to_string()))?;
payload
.get("tag_name")
.and_then(serde_json::Value::as_str)
.map(|tag| tag.trim_start_matches('v').to_owned())
.ok_or_else(|| TwcError::Api("release payload without tag_name".to_owned()))
}
fn parse_version(version: &str) -> Option<(u64, u64, u64)> {
let mut parts = version.trim().trim_start_matches('v').splitn(3, '.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next()?.parse().ok()?;
let patch = parts.next()?.parse().ok()?;
Some((major, minor, patch))
}
fn is_newer(latest: &str, current: &str) -> bool {
match (parse_version(latest), parse_version(current)) {
(Some(l), Some(c)) => l > c,
_ => false
}
}
#[cfg(test)]
mod tests;