use std::path::{Path, PathBuf};
use std::process::Command;
use codewhale_release::InstallMethod;
use crate::commands::CommandResult;
use crate::commands::traits::{CommandInfo, RegisterCommand};
use crate::localization::MessageId;
use crate::tui::app::App;
pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo {
name: "update",
aliases: &["upgrade"],
usage: "/update [check|install]",
description_id: MessageId::CmdUpdateDescription,
};
pub(in crate::commands) struct UpdateCmd;
impl RegisterCommand for UpdateCmd {
fn info() -> &'static CommandInfo {
&COMMAND_INFO
}
fn execute(app: &mut App, arg: Option<&str>) -> CommandResult {
update(app, arg)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum UpdateMode {
Check,
Install,
}
fn parse_mode(arg: Option<&str>) -> Result<UpdateMode, String> {
match arg
.map(str::trim)
.unwrap_or("")
.to_ascii_lowercase()
.as_str()
{
"" | "check" | "status" => Ok(UpdateMode::Check),
"install" | "now" | "apply" | "yes" => Ok(UpdateMode::Install),
other => Err(format!(
"Unknown /update argument {other:?}. Usage: {}",
COMMAND_INFO.usage
)),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum UpdaterPlan {
Run(PathBuf),
Managed(InstallMethod),
NoUpdater { exe: PathBuf },
}
const UPDATER_BIN_STEM: &str = "codewhale";
pub(super) fn resolve_updater(
exe: Option<&Path>,
method: InstallMethod,
exists: &dyn Fn(&Path) -> bool,
) -> UpdaterPlan {
if !method.supports_self_update() {
return UpdaterPlan::Managed(method);
}
let Some(exe) = exe else {
return UpdaterPlan::NoUpdater {
exe: PathBuf::from(UPDATER_BIN_STEM),
};
};
if exe.file_stem().and_then(|stem| stem.to_str()) == Some(UPDATER_BIN_STEM) {
return UpdaterPlan::Run(exe.to_path_buf());
}
if let Some(dir) = exe.parent() {
let extension = exe.extension().and_then(|ext| ext.to_str());
let mut sibling = dir.join(UPDATER_BIN_STEM);
if let Some(extension) = extension {
sibling.set_extension(extension);
}
if exists(&sibling) {
return UpdaterPlan::Run(sibling);
}
}
UpdaterPlan::NoUpdater {
exe: exe.to_path_buf(),
}
}
pub(super) fn managed_install_message(method: InstallMethod) -> String {
format!(
"CodeWhale was installed with {label}, which owns this binary.\n\
Run `{command}` in a shell, then restart CodeWhale.\n\n\
/update deliberately will not self-update here: replacing a binary {label} manages \
leaves its metadata describing a version that is no longer on disk, and the next \
upgrade silently reverts you.",
label = method.label(),
command = method.update_command(),
)
}
pub(super) fn no_updater_message(exe: &Path) -> String {
format!(
"No `{UPDATER_BIN_STEM}` updater was found for this install ({exe}).\n\
The updater ships in the `{UPDATER_BIN_STEM}` CLI. Install or locate it, run \
`{UPDATER_BIN_STEM} update` in a shell, then restart CodeWhale.",
exe = exe.display(),
)
}
fn install_preamble(updater: &Path) -> String {
format!(
"`/update install` will run `{updater} update`: it resolves the latest release, \
downloads the binary for this platform, verifies its SHA256 checksum, and atomically \
replaces {updater}.\n\
It does not restart CodeWhale — you will need to do that yourself once it finishes. \
The UI is paused while the updater runs.",
updater = updater.display(),
)
}
fn update(_app: &mut App, arg: Option<&str>) -> CommandResult {
let mode = match parse_mode(arg) {
Ok(mode) => mode,
Err(message) => return CommandResult::error(message),
};
let exe = std::env::current_exe().ok();
let method = match exe.as_deref() {
Some(path) => InstallMethod::detect(path),
None => InstallMethod::Binary,
};
match resolve_updater(exe.as_deref(), method, &|path: &Path| path.is_file()) {
UpdaterPlan::Managed(method) => CommandResult::message(managed_install_message(method)),
UpdaterPlan::NoUpdater { exe } => CommandResult::message(no_updater_message(&exe)),
UpdaterPlan::Run(updater) => run_updater(&updater, mode),
}
}
fn run_updater(updater: &Path, mode: UpdateMode) -> CommandResult {
let mut command = Command::new(updater);
command.arg("update");
if mode == UpdateMode::Check {
command.arg("--check");
}
let output = match command.output() {
Ok(output) => output,
Err(err) => {
return CommandResult::error(format!(
"Failed to run `{} update`: {err}\nRun it in a shell instead, then restart CodeWhale.",
updater.display()
));
}
};
let transcript = updater_transcript(&output.stdout, &output.stderr);
if !output.status.success() {
return CommandResult::error(format!(
"`{updater} update` failed.\n{transcript}",
updater = updater.display()
));
}
match mode {
UpdateMode::Check => {
CommandResult::message(format!("{}\n\n{transcript}", install_preamble(updater)))
}
UpdateMode::Install => CommandResult::message(format!(
"{transcript}\n\nRestart CodeWhale to run the updated binary."
)),
}
}
fn updater_transcript(stdout: &[u8], stderr: &[u8]) -> String {
const MAX_CHARS: usize = 4_000;
let mut merged = String::new();
for stream in [stdout, stderr] {
let text = String::from_utf8_lossy(stream);
let text = text.trim();
if text.is_empty() {
continue;
}
if !merged.is_empty() {
merged.push('\n');
}
merged.push_str(text);
}
if merged.is_empty() {
return "(the updater printed nothing)".to_string();
}
if merged.chars().count() > MAX_CHARS {
let kept: String = merged.chars().take(MAX_CHARS).collect();
return format!("{kept}\n… output truncated.");
}
merged
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bare_and_explicit_modes_parse() {
assert_eq!(parse_mode(None), Ok(UpdateMode::Check));
assert_eq!(parse_mode(Some(" ")), Ok(UpdateMode::Check));
assert_eq!(parse_mode(Some("Check")), Ok(UpdateMode::Check));
assert_eq!(parse_mode(Some("install")), Ok(UpdateMode::Install));
assert_eq!(parse_mode(Some("NOW")), Ok(UpdateMode::Install));
assert!(parse_mode(Some("--force")).is_err());
}
#[test]
fn a_package_managed_install_gets_its_managers_command_not_an_updater_run() {
for method in [
InstallMethod::Npm,
InstallMethod::Homebrew,
InstallMethod::Cargo,
] {
let plan = resolve_updater(
Some(Path::new("/opt/whatever/codewhale")),
method,
&|_: &Path| true,
);
assert_eq!(plan, UpdaterPlan::Managed(method), "{method:?}");
let message = managed_install_message(method);
assert!(
message.contains(method.update_command()),
"{method:?} message must name its own update command: {message}"
);
assert!(
message.contains("restart CodeWhale"),
"{method:?} message must tell the user to restart: {message}"
);
}
}
#[test]
fn a_tui_only_build_without_a_sibling_cli_falls_back_to_instructions() {
let exe = Path::new("/usr/local/bin/codewhale-tui");
let plan = resolve_updater(Some(exe), InstallMethod::Binary, &|_: &Path| false);
assert_eq!(
plan,
UpdaterPlan::NoUpdater {
exe: exe.to_path_buf()
}
);
let message = no_updater_message(exe);
assert!(message.contains("codewhale update"), "{message}");
assert!(message.contains("restart CodeWhale"), "{message}");
assert!(message.contains("codewhale-tui"), "{message}");
}
#[test]
fn a_tui_build_next_to_the_cli_runs_the_sibling_updater() {
let plan = resolve_updater(
Some(Path::new("/usr/local/bin/codewhale-tui")),
InstallMethod::Binary,
&|path: &Path| path == Path::new("/usr/local/bin/codewhale"),
);
assert_eq!(
plan,
UpdaterPlan::Run(PathBuf::from("/usr/local/bin/codewhale"))
);
}
#[test]
fn a_cli_install_runs_itself_including_the_windows_extension() {
assert_eq!(
resolve_updater(
Some(Path::new("/usr/local/bin/codewhale")),
InstallMethod::Binary,
&|_: &Path| false
),
UpdaterPlan::Run(PathBuf::from("/usr/local/bin/codewhale"))
);
assert_eq!(
resolve_updater(
Some(Path::new("C:/tools/codewhale.exe")),
InstallMethod::Binary,
&|_: &Path| false
),
UpdaterPlan::Run(PathBuf::from("C:/tools/codewhale.exe"))
);
}
#[test]
fn a_windows_tui_build_finds_the_sibling_cli_with_its_extension() {
assert_eq!(
resolve_updater(
Some(Path::new("C:/tools/codewhale-tui.exe")),
InstallMethod::Binary,
&|path: &Path| path == Path::new("C:/tools/codewhale.exe"),
),
UpdaterPlan::Run(PathBuf::from("C:/tools/codewhale.exe"))
);
}
#[test]
fn an_unresolvable_executable_reports_no_updater_instead_of_guessing() {
assert_eq!(
resolve_updater(None, InstallMethod::Binary, &|_: &Path| true),
UpdaterPlan::NoUpdater {
exe: PathBuf::from("codewhale")
}
);
}
#[test]
fn the_check_preamble_states_what_install_would_do() {
let preamble = install_preamble(Path::new("/usr/local/bin/codewhale"));
assert!(
preamble.contains("/usr/local/bin/codewhale update"),
"{preamble}"
);
assert!(preamble.contains("SHA256"), "{preamble}");
assert!(preamble.contains("does not restart"), "{preamble}");
}
#[test]
fn updater_output_is_merged_and_bounded() {
assert_eq!(updater_transcript(b" out ", b""), "out");
assert_eq!(updater_transcript(b"out", b"err"), "out\nerr");
assert_eq!(
updater_transcript(b"", b" "),
"(the updater printed nothing)"
);
let flood = "x".repeat(9_000);
let bounded = updater_transcript(flood.as_bytes(), b"");
assert!(bounded.ends_with("… output truncated."));
assert!(bounded.chars().count() < flood.chars().count());
}
}