use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use std::process::Command;
use crate::error::{MindError, Result};
use crate::mindfile::version_at_least;
const REPO: &str = "jaemk/mind";
#[derive(Debug, PartialEq, Eq)]
pub enum Decision {
UpToDate,
Update,
}
pub fn target_triple(os: &str, arch: &str) -> Result<&'static str> {
match (os, arch) {
("linux", "x86_64") => Ok("x86_64-unknown-linux-gnu"),
("linux", "aarch64") => Ok("aarch64-unknown-linux-gnu"),
("macos", "aarch64") => Ok("aarch64-apple-darwin"),
_ => Err(MindError::UnsupportedPlatform {
os: os.to_string(),
arch: arch.to_string(),
}),
}
}
pub fn asset_url(version: &str, target: &str) -> String {
format!("https://github.com/{REPO}/releases/download/v{version}/mind-{version}-{target}.tar.gz")
}
fn latest_release_api() -> String {
format!("https://api.github.com/repos/{REPO}/releases/latest")
}
pub fn parse_latest_tag(json: &str) -> Result<String> {
let value: serde_json::Value =
serde_json::from_str(json).map_err(|e| MindError::json("github release", e))?;
let tag = value
.get("tag_name")
.and_then(|t| t.as_str())
.ok_or_else(|| MindError::DownloadFailed {
url: latest_release_api(),
reason: "release JSON has no 'tag_name' field".to_string(),
})?;
Ok(tag.strip_prefix('v').unwrap_or(tag).to_string())
}
pub fn decision(current: &str, target: &str) -> Decision {
if version_at_least(current, target) {
Decision::UpToDate
} else {
Decision::Update
}
}
fn check_report(current: &str, target: &str, decision: &Decision) -> String {
match decision {
Decision::UpToDate => {
format!("mind {current} is up to date (latest is {target})")
}
Decision::Update => {
format!("mind {current} -> {target} available; run `mind evolve` to update")
}
}
}
pub fn run(check: bool, yes: bool, version: Option<String>) -> Result<()> {
let current = env!("CARGO_PKG_VERSION");
let os = std::env::consts::OS;
let arch = std::env::consts::ARCH;
let target = target_triple(os, arch)?;
let target_version = match version {
Some(v) => v.strip_prefix('v').unwrap_or(&v).to_string(),
None => {
let json = fetch_to_string(&latest_release_api())?;
parse_latest_tag(&json)?
}
};
let decision = decision(current, &target_version);
let out = crate::render::ctx();
if check {
if out.json {
let outcome = match decision {
Decision::UpToDate => "up-to-date",
Decision::Update => "available",
};
return print_evolve_json(&target_version, outcome);
}
let marker = match decision {
Decision::UpToDate => out.ok(),
Decision::Update => out.warn(),
};
println!(
"{marker} {}",
check_report(current, &target_version, &decision)
);
return Ok(());
}
if decision == Decision::UpToDate {
if out.json {
return print_evolve_json(&target_version, "up-to-date");
}
println!("{} mind {current} is already up to date", out.ok());
return Ok(());
}
if !yes && !out.json && !crate::commands::confirm(&format!("update mind to {target_version}?"))?
{
println!("aborted; nothing changed");
return Ok(());
}
let url = asset_url(&target_version, target);
download_and_swap(&url, current, &target_version)
}
fn print_evolve_json(version: &str, outcome: &str) -> Result<()> {
crate::render::print_json(&serde_json::json!({
"action": "evolve",
"target": version,
"outcome": outcome,
}))
}
fn download_and_swap(url: &str, current: &str, target_version: &str) -> Result<()> {
let out = crate::render::ctx();
let tmp = mktemp_dir()?;
let archive = tmp.join("mind.tar.gz");
if !out.json {
println!(
"{} downloading mind {target_version} ({})",
out.bullet(),
out.dim(url)
);
}
fetch_to_file(url, &archive)?;
let status = Command::new("tar")
.arg("-xzf")
.arg(&archive)
.arg("-C")
.arg(&tmp)
.status()
.map_err(|e| MindError::io("tar", e))?;
if !status.success() {
let _ = std::fs::remove_dir_all(&tmp);
return Err(MindError::DownloadFailed {
url: url.to_string(),
reason: "could not extract the release archive".to_string(),
});
}
let new_bin = tmp.join("mind");
if !new_bin.is_file() {
let _ = std::fs::remove_dir_all(&tmp);
return Err(MindError::ReleaseAssetEmpty);
}
let current_exe = std::env::current_exe().map_err(|e| MindError::io("<current-exe>", e))?;
let result = swap_in_place(&new_bin, ¤t_exe);
let _ = std::fs::remove_dir_all(&tmp);
result?;
if out.json {
return print_evolve_json(target_version, "updated");
}
println!("{} updated mind {current} -> {target_version}", out.ok());
Ok(())
}
fn swap_in_place(new_bin: &Path, current_exe: &Path) -> Result<()> {
let dir = current_exe
.parent()
.ok_or_else(|| MindError::TargetNotWritable {
path: current_exe.display().to_string(),
})?;
let staged = dir.join(".mind-update.tmp");
if let Err(e) = std::fs::copy(new_bin, &staged) {
return Err(swap_error(e, current_exe, &staged));
}
if let Err(e) = std::fs::set_permissions(&staged, std::fs::Permissions::from_mode(0o755)) {
let _ = std::fs::remove_file(&staged);
return Err(MindError::io(&staged, e));
}
if let Err(e) = std::fs::rename(&staged, current_exe) {
let _ = std::fs::remove_file(&staged);
return Err(swap_error(e, current_exe, current_exe));
}
Ok(())
}
fn swap_error(e: std::io::Error, current_exe: &Path, at: &Path) -> MindError {
if e.kind() == std::io::ErrorKind::PermissionDenied {
MindError::TargetNotWritable {
path: current_exe.display().to_string(),
}
} else {
MindError::io(at, e)
}
}
fn mktemp_dir() -> Result<std::path::PathBuf> {
let base = std::env::temp_dir().join(format!("mind-evolve-{}", std::process::id()));
std::fs::create_dir_all(&base).map_err(|e| MindError::io(&base, e))?;
Ok(base)
}
fn fetch_to_string(url: &str) -> Result<String> {
let output = if have("curl") {
Command::new("curl")
.args([
"--proto",
"=https",
"--proto-redir",
"=https",
"--tlsv1.2",
"-fsSL",
url,
])
.output()
.map_err(|e| MindError::io("curl", e))?
} else if have("wget") {
Command::new("wget")
.args(["--https-only", "-qO-", url])
.output()
.map_err(|e| MindError::io("wget", e))?
} else {
return Err(MindError::DownloadFailed {
url: url.to_string(),
reason: "need curl or wget on PATH".to_string(),
});
};
if !output.status.success() {
return Err(MindError::DownloadFailed {
url: url.to_string(),
reason: String::from_utf8_lossy(&output.stderr).trim().to_string(),
});
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
fn fetch_to_file(url: &str, dest: &Path) -> Result<()> {
let status = if have("curl") {
Command::new("curl")
.args([
"--proto",
"=https",
"--proto-redir",
"=https",
"--tlsv1.2",
"-fsSL",
url,
"-o",
])
.arg(dest)
.status()
.map_err(|e| MindError::io("curl", e))?
} else if have("wget") {
Command::new("wget")
.args(["--https-only", "-qO"])
.arg(dest)
.arg(url)
.status()
.map_err(|e| MindError::io("wget", e))?
} else {
return Err(MindError::DownloadFailed {
url: url.to_string(),
reason: "need curl or wget on PATH".to_string(),
});
};
if !status.success() {
return Err(MindError::DownloadFailed {
url: url.to_string(),
reason: "downloader exited non-zero".to_string(),
});
}
Ok(())
}
fn have(cmd: &str) -> bool {
Command::new("sh")
.arg("-c")
.arg(format!("command -v {cmd}"))
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn have_detects_present_and_absent_commands() {
assert!(have("sh"), "`sh` must be detected on PATH");
assert!(
!have("mind-no-such-binary-xyzzy"),
"a nonexistent command must not be detected"
);
}
#[test]
fn target_triple_maps_supported_platforms() {
assert_eq!(
target_triple("linux", "x86_64").unwrap(),
"x86_64-unknown-linux-gnu"
);
assert_eq!(
target_triple("linux", "aarch64").unwrap(),
"aarch64-unknown-linux-gnu"
);
assert_eq!(
target_triple("macos", "aarch64").unwrap(),
"aarch64-apple-darwin"
);
}
#[test]
fn target_triple_rejects_intel_macos_and_unknown_arch() {
match target_triple("macos", "x86_64") {
Err(MindError::UnsupportedPlatform { os, arch }) => {
assert_eq!(os, "macos");
assert_eq!(arch, "x86_64");
}
other => panic!("expected UnsupportedPlatform, got {other:?}"),
}
assert!(matches!(
target_triple("linux", "riscv64"),
Err(MindError::UnsupportedPlatform { .. })
));
assert!(matches!(
target_triple("windows", "x86_64"),
Err(MindError::UnsupportedPlatform { .. })
));
}
#[test]
fn asset_url_matches_install_sh_shape() {
assert_eq!(
asset_url("0.3.0", "x86_64-unknown-linux-gnu"),
"https://github.com/jaemk/mind/releases/download/v0.3.0/mind-0.3.0-x86_64-unknown-linux-gnu.tar.gz"
);
}
#[test]
fn parse_latest_tag_strips_leading_v() {
let json = r#"{"tag_name":"v0.3.0","name":"0.3.0"}"#;
assert_eq!(parse_latest_tag(json).unwrap(), "0.3.0");
let json = r#"{"tag_name":"1.2.3"}"#;
assert_eq!(parse_latest_tag(json).unwrap(), "1.2.3");
}
#[test]
fn parse_latest_tag_missing_field_is_an_error() {
let json = r#"{"name":"0.3.0"}"#;
match parse_latest_tag(json) {
Err(MindError::DownloadFailed { reason, .. }) => {
assert!(reason.contains("tag_name"), "reason: {reason}");
}
other => panic!("expected DownloadFailed, got {other:?}"),
}
}
#[test]
fn decision_compares_versions() {
assert_eq!(decision("0.3.0", "0.3.0"), Decision::UpToDate);
assert_eq!(decision("0.2.0", "0.3.0"), Decision::Update);
assert_eq!(decision("0.4.0", "0.3.0"), Decision::UpToDate);
}
#[test]
fn check_report_reflects_the_decision_without_network() {
let pending = decision("0.2.0", "0.3.0");
assert_eq!(pending, Decision::Update);
let report = check_report("0.2.0", "0.3.0", &pending);
assert!(report.contains("0.2.0"), "report: {report}");
assert!(report.contains("0.3.0"), "report: {report}");
assert!(report.contains("available"), "report: {report}");
let current = decision("0.3.0", "0.3.0");
assert_eq!(current, Decision::UpToDate);
let report = check_report("0.3.0", "0.3.0", ¤t);
assert!(report.contains("up to date"), "report: {report}");
}
}