use std::path::PathBuf;
use std::process::{Command, Stdio};
use rust_i18n::t;
use serde::Serialize;
use crate::error::{Result, ScoopError};
use crate::output::Output;
const CRATE_NAME: &str = "scoop-uv";
const BINARY_NAME: &str = "scoop";
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
#[derive(Debug, Serialize, PartialEq, Eq)]
#[serde(tag = "status", rename_all = "snake_case")]
enum VerifyOutcome {
Skipped,
Passed,
Warned,
Errored,
LaunchFailed { error: String },
}
#[derive(Debug, Serialize)]
struct UpdateData {
from: &'static str,
to: String,
skipped: bool,
verify: VerifyOutcome,
}
pub fn execute(output: &Output, force: bool, version: Option<&str>, no_verify: bool) -> Result<()> {
let requested_explicit = version.is_some();
let target = resolve_target_version(output, version)?;
if target == CURRENT_VERSION && !force {
emit_skip(output, &target, requested_explicit);
return Ok(());
}
output.info(&t!(
"selfupdate.installing",
from = CURRENT_VERSION,
to = target.as_str()
));
run_cargo_install(&target, output.is_json())?;
output.success(&t!("selfupdate.installed", version = target.as_str()));
let verify = if no_verify {
VerifyOutcome::Skipped
} else {
output.info(&t!("selfupdate.verifying"));
verify_with_new_binary(output.is_json())
};
emit_verify(output, &verify);
if output.is_json() {
output.json_success(
"self update",
UpdateData {
from: CURRENT_VERSION,
to: target,
skipped: false,
verify,
},
);
}
Ok(())
}
fn emit_skip(output: &Output, target: &str, requested_explicit: bool) {
if output.is_json() {
output.json_success(
"self update",
UpdateData {
from: CURRENT_VERSION,
to: target.to_string(),
skipped: true,
verify: VerifyOutcome::Skipped,
},
);
return;
}
let key = if requested_explicit {
"selfupdate.already_on_requested"
} else {
"selfupdate.already_latest"
};
output.success(&t!(key, version = CURRENT_VERSION));
}
fn emit_verify(output: &Output, outcome: &VerifyOutcome) {
match outcome {
VerifyOutcome::Skipped | VerifyOutcome::Passed => {}
VerifyOutcome::Warned => output.warn(&t!("selfupdate.verify_doctor_warnings")),
VerifyOutcome::Errored => output.warn(&t!("selfupdate.verify_doctor_errors")),
VerifyOutcome::LaunchFailed { error } => output.warn(&t!(
"selfupdate.verify_launch_failed",
error = error.as_str()
)),
}
}
fn resolve_target_version(output: &Output, requested: Option<&str>) -> Result<String> {
if let Some(v) = requested {
return Ok(v.to_string());
}
output.info(&t!("selfupdate.checking_latest"));
latest_version_from_cargo_search()
}
fn latest_version_from_cargo_search() -> Result<String> {
let out = Command::new("cargo")
.args(["search", "--limit", "1", CRATE_NAME])
.output()
.map_err(|e| ScoopError::SelfUpdateFailed {
message: format!("could not invoke `cargo search`: {e}"),
})?;
if !out.status.success() {
return Err(ScoopError::SelfUpdateFailed {
message: format!(
"`cargo search` exited with {}: {}",
out.status,
String::from_utf8_lossy(&out.stderr).trim()
),
});
}
let stdout = String::from_utf8_lossy(&out.stdout);
parse_cargo_search_version(&stdout).ok_or_else(|| ScoopError::SelfUpdateFailed {
message: format!("could not parse `cargo search` output for `{CRATE_NAME}`: {stdout}"),
})
}
fn parse_cargo_search_version(stdout: &str) -> Option<String> {
let prefix = format!("{CRATE_NAME} = \"");
stdout
.lines()
.filter_map(|line| line.trim_start().strip_prefix(&prefix))
.find_map(|rest| rest.find('"').map(|end| rest[..end].to_string()))
}
fn run_cargo_install(version: &str, json_mode: bool) -> Result<()> {
let mut cmd = Command::new("cargo");
cmd.args([
"install",
"--force",
"--locked",
CRATE_NAME,
"--version",
version,
]);
if json_mode {
cmd.arg("--quiet");
cmd.stdout(Stdio::null());
}
let status = cmd.status().map_err(|e| ScoopError::SelfUpdateFailed {
message: format!("could not invoke `cargo install`: {e}"),
})?;
if !status.success() {
return Err(ScoopError::SelfUpdateFailed {
message: format!("`cargo install` exited with {status}"),
});
}
Ok(())
}
fn verify_with_new_binary(json_mode: bool) -> VerifyOutcome {
let Some(new_bin) = installed_binary_path() else {
return VerifyOutcome::LaunchFailed {
error: format!(
"could not locate the freshly installed `{BINARY_NAME}` binary in CARGO_INSTALL_ROOT/CARGO_HOME/PATH"
),
};
};
let mut cmd = Command::new(&new_bin);
cmd.arg("doctor");
if json_mode {
cmd.stdout(Stdio::null());
cmd.stderr(Stdio::null());
}
match cmd.status() {
Ok(s) if s.success() => VerifyOutcome::Passed,
Ok(s) => match s.code() {
Some(1) => VerifyOutcome::Warned,
_ => VerifyOutcome::Errored,
},
Err(e) => VerifyOutcome::LaunchFailed {
error: e.to_string(),
},
}
}
fn installed_binary_path() -> Option<PathBuf> {
let filename = binary_filename();
cargo_install_root()
.map(|root| root.join("bin").join(&filename))
.filter(|p| p.exists())
.or_else(|| which::which(BINARY_NAME).ok())
}
fn cargo_install_root() -> Option<PathBuf> {
std::env::var_os("CARGO_INSTALL_ROOT")
.map(PathBuf::from)
.or_else(|| std::env::var_os("CARGO_HOME").map(PathBuf::from))
.or_else(|| dirs::home_dir().map(|h| h.join(".cargo")))
}
fn binary_filename() -> String {
if cfg!(windows) {
format!("{BINARY_NAME}.exe")
} else {
BINARY_NAME.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_cargo_search_extracts_first_match() {
let stdout = "\
scoop-uv = \"0.9.0\" # Scoop up your Python envs ...
scoop = \"5.0.0\" # Decentralized command-line installer ...
";
assert_eq!(
parse_cargo_search_version(stdout),
Some("0.9.0".to_string())
);
}
#[test]
fn parse_cargo_search_handles_indented_line() {
let stdout = " scoop-uv = \"1.2.3\"\n";
assert_eq!(
parse_cargo_search_version(stdout),
Some("1.2.3".to_string())
);
}
#[test]
fn parse_cargo_search_returns_none_when_absent() {
let stdout = "other-crate = \"0.1.0\"\n";
assert_eq!(parse_cargo_search_version(stdout), None);
}
#[test]
fn parse_cargo_search_returns_none_when_malformed() {
let stdout = "scoop-uv = malformed\n";
assert_eq!(parse_cargo_search_version(stdout), None);
}
#[test]
fn parse_cargo_search_skips_unrelated_crate_with_same_prefix() {
let stdout = "scoop-uv-something = \"9.9.9\"\nscoop-uv = \"0.1.0\"\n";
assert_eq!(
parse_cargo_search_version(stdout),
Some("0.1.0".to_string())
);
}
#[test]
fn verify_outcome_skipped_serializes_with_status_tag() {
let v = serde_json::to_value(VerifyOutcome::Skipped).unwrap();
assert_eq!(v["status"], "skipped");
}
#[test]
fn verify_outcome_passed_warned_errored_have_distinct_tags() {
assert_eq!(
serde_json::to_value(VerifyOutcome::Passed).unwrap()["status"],
"passed"
);
assert_eq!(
serde_json::to_value(VerifyOutcome::Warned).unwrap()["status"],
"warned"
);
assert_eq!(
serde_json::to_value(VerifyOutcome::Errored).unwrap()["status"],
"errored"
);
}
#[test]
fn verify_outcome_launch_failed_carries_error_message() {
let v = serde_json::to_value(VerifyOutcome::LaunchFailed {
error: "permission denied".into(),
})
.unwrap();
assert_eq!(v["status"], "launch_failed");
assert_eq!(v["error"], "permission denied");
}
#[test]
fn binary_filename_matches_platform() {
let f = binary_filename();
if cfg!(windows) {
assert!(f.ends_with(".exe"));
} else {
assert_eq!(f, BINARY_NAME);
}
}
}