use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{anyhow, Context, Result};
pub const INSTALLER_URL: &str =
"https://github.com/QLangstaff/qli/releases/latest/download/qli-installer.sh";
pub const PLUGIN_NOTE: &str = "Claude Code plugin? Refresh it with \
`/plugin marketplace update qli-plugins` from inside Claude Code.";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum InstallMethod {
Cargo,
Homebrew,
Curl,
Unknown,
}
impl InstallMethod {
pub fn as_str(self) -> &'static str {
match self {
Self::Cargo => "cargo",
Self::Homebrew => "homebrew",
Self::Curl => "curl",
Self::Unknown => "unknown",
}
}
pub fn upgrade_command(self) -> Option<&'static str> {
match self {
Self::Cargo => Some("cargo install qli --force"),
Self::Homebrew => Some("brew upgrade qli"),
Self::Curl => Some(
"curl -LsSf \
https://github.com/QLangstaff/qli/releases/latest/download/qli-installer.sh | sh",
),
Self::Unknown => None,
}
}
}
pub struct Detection {
pub method: InstallMethod,
pub binary_path: PathBuf,
}
pub fn detect() -> Detection {
let binary_path = match std::env::current_exe() {
Ok(p) => std::fs::canonicalize(&p).unwrap_or(p),
Err(_) => {
return Detection {
method: InstallMethod::Unknown,
binary_path: PathBuf::new(),
}
}
};
let home = std::env::var_os("HOME")
.map(PathBuf::from)
.map(|h| std::fs::canonicalize(&h).unwrap_or(h));
let method = detect_method(&binary_path, home.as_deref());
Detection {
method,
binary_path,
}
}
fn detect_method(canonical: &Path, home: Option<&Path>) -> InstallMethod {
if path_contains_segment(canonical, "Cellar") {
return InstallMethod::Homebrew;
}
if let Some(home) = home {
let cargo_bin = home.join(".cargo").join("bin");
if canonical.starts_with(&cargo_bin) {
return if cargo_registry_contains_qli(home) {
InstallMethod::Cargo
} else {
InstallMethod::Curl
};
}
let local_bin = home.join(".local").join("bin");
if canonical.starts_with(&local_bin) {
return InstallMethod::Curl;
}
}
InstallMethod::Unknown
}
fn path_contains_segment(p: &Path, segment: &str) -> bool {
p.components()
.any(|c| c.as_os_str().to_string_lossy() == segment)
}
fn cargo_registry_contains_qli(home: &Path) -> bool {
let path = home.join(".cargo").join(".crates.toml");
let Ok(content) = std::fs::read_to_string(&path) else {
return false;
};
content
.lines()
.any(|line| line.trim_start().starts_with("\"qli "))
}
pub fn run_curl_installer() -> Result<()> {
if cfg!(target_os = "windows") {
return Err(anyhow!(
"automated upgrade is not supported on Windows; \
re-run the .ps1 installer manually"
));
}
let cmd = format!("curl -LsSf {INSTALLER_URL} | sh");
let status = Command::new("sh")
.arg("-c")
.arg(&cmd)
.status()
.with_context(|| format!("failed to spawn `sh -c '{cmd}'`"))?;
if !status.success() {
let code = status
.code()
.map_or_else(|| "signal".into(), |c| c.to_string());
return Err(anyhow!("installer exited with status {code}"));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn write_crates_toml(home: &Path, body: &str) {
let dir = home.join(".cargo");
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join(".crates.toml"), body).unwrap();
}
#[test]
fn homebrew_apple_silicon_path() {
let p = PathBuf::from("/opt/homebrew/Cellar/qli/0.1.1/bin/qli");
assert_eq!(detect_method(&p, None), InstallMethod::Homebrew);
}
#[test]
fn homebrew_intel_path() {
let p = PathBuf::from("/usr/local/Cellar/qli/0.1.1/bin/qli");
assert_eq!(detect_method(&p, None), InstallMethod::Homebrew);
}
#[test]
fn cargo_install_path_with_registry_entry() {
let home = TempDir::new().unwrap();
write_crates_toml(
home.path(),
"[v1]\n\"qli 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)\" = [\"qli\"]\n",
);
let p = home.path().join(".cargo/bin/qli");
assert_eq!(detect_method(&p, Some(home.path())), InstallMethod::Cargo);
}
#[test]
fn cargo_bin_without_registry_entry_is_curl() {
let home = TempDir::new().unwrap();
let p = home.path().join(".cargo/bin/qli");
assert_eq!(detect_method(&p, Some(home.path())), InstallMethod::Curl);
}
#[test]
fn cargo_bin_with_registry_missing_qli_entry_is_curl() {
let home = TempDir::new().unwrap();
write_crates_toml(
home.path(),
"[v1]\n\"ripgrep 14.0.0 (registry+https://github.com/rust-lang/crates.io-index)\" = [\"rg\"]\n",
);
let p = home.path().join(".cargo/bin/qli");
assert_eq!(detect_method(&p, Some(home.path())), InstallMethod::Curl);
}
#[test]
fn local_bin_is_curl() {
let home = TempDir::new().unwrap();
let p = home.path().join(".local/bin/qli");
assert_eq!(detect_method(&p, Some(home.path())), InstallMethod::Curl);
}
#[test]
fn unknown_path_yields_unknown() {
let home = TempDir::new().unwrap();
let p = PathBuf::from("/opt/random/qli");
assert_eq!(detect_method(&p, Some(home.path())), InstallMethod::Unknown);
}
#[test]
fn registry_check_handles_qli_prefix_not_qli_substring() {
let home = TempDir::new().unwrap();
write_crates_toml(
home.path(),
"[v1]\n\"qli-ext 0.1.1 (path+file:///somewhere)\" = [\"qli-ext\"]\n",
);
assert!(!cargo_registry_contains_qli(home.path()));
}
#[test]
fn upgrade_commands_present_for_known_methods() {
assert!(InstallMethod::Cargo.upgrade_command().is_some());
assert!(InstallMethod::Homebrew.upgrade_command().is_some());
assert!(InstallMethod::Curl.upgrade_command().is_some());
assert!(InstallMethod::Unknown.upgrade_command().is_none());
}
}