use crate::utils::warning_text; use anyhow::{Context, Result, bail}; use dunce::canonicalize; use inquire::Confirm; use is_terminal::IsTerminal; use self_update::Status; use self_update::backends::github::Update; use std::fs::OpenOptions; use std::path::Path; use std::{env, fs, io, process}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum InstallSource { Cargo, Homebrew, Manual, } impl InstallSource { fn is_package_managed(self) -> bool { matches!(self, InstallSource::Cargo | InstallSource::Homebrew) } fn label(self) -> &'static str { match self { InstallSource::Cargo => "Cargo", InstallSource::Homebrew => "Homebrew", InstallSource::Manual => "manually-installed", } } } fn classify_install_path(path: &Path) -> InstallSource { let components: Vec<&str> = path .components() .filter_map(|c| c.as_os_str().to_str()) .collect(); if components .windows(2) .any(|w| w[0] == ".cargo" && w[1] == "bin") { return InstallSource::Cargo; } if components.contains(&"Cellar") { return InstallSource::Homebrew; } let path_str = path.to_string_lossy(); if path_str.starts_with("/opt/homebrew/") || path_str.starts_with("/home/linuxbrew/.linuxbrew/") { return InstallSource::Homebrew; } InstallSource::Manual } fn normalize_version(requested: Option) -> Option { let raw = requested?; let trimmed = raw.trim(); if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("latest") { return None; } match trimmed.chars().next() { Some('v' | 'V') => Some(trimmed.to_string()), Some(c) if c.is_ascii_digit() => Some(format!("v{trimmed}")), _ => Some(trimmed.to_string()), } } fn is_dir_writable(dir: &Path) -> bool { let probe = dir.join(format!(".coyote-update-write-test-{}", process::id())); match OpenOptions::new().write(true).create_new(true).open(&probe) { Ok(_) => { let _ = fs::remove_file(&probe); true } Err(_) => false, } } pub fn run_self_update(requested: Option, force: bool) -> Result<()> { let target_tag = normalize_version(requested); let exe_path = env::current_exe() .context("Could not determine the path of the running coyote executable")?; let resolved = canonicalize(&exe_path).unwrap_or_else(|_| exe_path.clone()); let source = classify_install_path(&resolved); if source.is_package_managed() { let body = match source { InstallSource::Homebrew => format!( "Coyote appears to be installed via Homebrew ({}).\n\ Updating in place replaces the binary inside Homebrew's Cellar; `brew` will\n\ then report a version that no longer matches the file on disk, and a later\n\ `brew upgrade`/`brew reinstall` may overwrite it or fail.\n\ The clean way to update is: brew upgrade coyote", exe_path.display() ), InstallSource::Cargo => format!( "Coyote appears to be installed via `cargo install` ({}).\n\ Updating in place leaves Cargo's records out of sync with the binary on disk.\n\ The clean way to update is: cargo install --locked coyote-ai", exe_path.display() ), InstallSource::Manual => unreachable!("Manual installs are not package-managed"), }; println!("{} {body}", warning_text("WARNING:")); if force { println!("--force specified; updating anyway."); } else if io::stdin().is_terminal() { let proceed = Confirm::new("Update anyway?") .with_default(false) .prompt()?; if !proceed { println!("Update cancelled."); return Ok(()); } } else { bail!( "Refusing to update a {} install. Re-run with --force to override.", source.label() ); } } if let Some(parent) = exe_path.parent() && !is_dir_writable(parent) { bail!( "No write permission for '{}'. Re-run with elevated permissions (e.g. sudo), \ or update Coyote through your package manager.", parent.display() ); } let interactive = io::stdin().is_terminal(); let mut builder = Update::configure(); builder .repo_owner("Dark-Alex-17") .repo_name("coyote") .bin_name("coyote") .current_version(env!("CARGO_PKG_VERSION")) .no_confirm(true) .show_download_progress(interactive); if let Some(tag) = &target_tag { builder.target_version_tag(tag.as_str()); } let status = builder .build() .context("Failed to configure the self-update")? .update() .context("Self-update failed")?; match status { Status::UpToDate(version) => { println!("Coyote is already up to date (v{version})."); } Status::Updated(version) => { println!("Coyote updated to v{version}. Restart coyote to use the new version."); } } Ok(()) } #[cfg(test)] mod tests { use super::*; use std::path::PathBuf; #[test] fn classify_cargo_install() { assert_eq!( classify_install_path(&PathBuf::from("/home/u/.cargo/bin/coyote")), InstallSource::Cargo ); } #[test] fn classify_homebrew_opt_prefix() { assert_eq!( classify_install_path(&PathBuf::from("/opt/homebrew/bin/coyote")), InstallSource::Homebrew ); } #[test] fn classify_homebrew_cellar() { assert_eq!( classify_install_path(&PathBuf::from("/usr/local/Cellar/coyote/0.3.0/bin/coyote")), InstallSource::Homebrew ); } #[test] fn classify_homebrew_linuxbrew() { assert_eq!( classify_install_path(&PathBuf::from("/home/linuxbrew/.linuxbrew/bin/coyote")), InstallSource::Homebrew ); } #[test] fn classify_manual_usr_local_bin() { assert_eq!( classify_install_path(&PathBuf::from("/usr/local/bin/coyote")), InstallSource::Manual ); } #[test] fn classify_manual_local_bin() { assert_eq!( classify_install_path(&PathBuf::from("/home/u/.local/bin/coyote")), InstallSource::Manual ); } #[test] fn normalize_version_latest_and_empty_are_none() { assert_eq!(normalize_version(None), None); assert_eq!(normalize_version(Some(String::new())), None); assert_eq!(normalize_version(Some(" ".to_string())), None); assert_eq!(normalize_version(Some("latest".to_string())), None); assert_eq!(normalize_version(Some("LATEST".to_string())), None); } #[test] fn normalize_version_prepends_v_for_bare_semver() { assert_eq!( normalize_version(Some("0.4.0".to_string())), Some("v0.4.0".to_string()) ); assert_eq!( normalize_version(Some("v0.4.0".to_string())), Some("v0.4.0".to_string()) ); assert_eq!( normalize_version(Some(" v0.4.0 ".to_string())), Some("v0.4.0".to_string()) ); } }