use std::fmt;
use std::str::FromStr;
use serde::Serialize;
use crate::error::{Error, Result};
use crate::github::Release;
use crate::proxy::Proxy;
use crate::shell::Shell;
use crate::target::{self, ProgramCoverage};
pub const DEFAULT_INSTALL_DIR: &str = "~/.ei";
pub const DEFAULT_MIN_DISK_SPACE_MB: u64 = 10;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Resource {
Release,
File {
reference: String,
},
}
impl Resource {
pub fn as_str(&self) -> &'static str {
match self {
Resource::Release => "release",
Resource::File { .. } => "file",
}
}
pub fn reference(&self) -> Option<&str> {
match self {
Resource::Release => None,
Resource::File { reference } => Some(reference),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct AssetEntry {
pub target: String,
pub filename: String,
pub size: u64,
pub program: String,
}
#[derive(Debug, Clone)]
pub struct InstallSpec {
pub owner: String,
pub repo: String,
pub tag: String,
pub resolved_tag: Option<String>,
pub binary: Option<String>,
pub name: Option<String>,
pub shell: Shell,
pub proxy: Proxy,
pub resource: Resource,
pub install_dir: String,
pub min_disk_space_mb: u64,
pub assets: Vec<AssetEntry>,
pub coverage: Vec<ProgramCoverage>,
pub selected_program: Option<String>,
pub default_target: Option<String>,
pub invocation: Option<String>,
}
impl InstallSpec {
pub fn new(owner: impl Into<String>, repo: impl Into<String>) -> Self {
Self {
owner: owner.into(),
repo: repo.into(),
tag: "latest".to_string(),
resolved_tag: None,
binary: None,
name: None,
shell: Shell::default(),
proxy: Proxy::default(),
resource: Resource::Release,
install_dir: DEFAULT_INSTALL_DIR.to_string(),
min_disk_space_mb: DEFAULT_MIN_DISK_SPACE_MB,
assets: Vec::new(),
coverage: Vec::new(),
selected_program: None,
default_target: None,
invocation: None,
}
}
#[must_use]
pub fn with_invocation(mut self, invocation: impl Into<String>) -> Self {
let invocation = invocation.into();
self.invocation = (!invocation.trim().is_empty()).then(|| invocation.trim().to_string());
self
}
pub fn slug(&self) -> String {
format!("{}/{}", self.owner, self.repo)
}
pub fn binary_name(&self) -> &str {
self.binary.as_deref().unwrap_or(&self.repo)
}
#[must_use]
pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
let tag = tag.into();
if !tag.is_empty() {
self.tag = tag;
}
self
}
#[must_use]
pub fn with_shell(mut self, shell: Shell) -> Self {
self.shell = shell;
self
}
#[must_use]
pub fn with_proxy(mut self, proxy: Proxy) -> Self {
self.proxy = proxy;
self
}
#[must_use]
pub fn with_binary(mut self, binary: impl Into<String>) -> Self {
self.binary = Some(binary.into());
self
}
#[must_use]
pub fn with_name(mut self, name: Option<String>) -> Self {
self.name = name.filter(|name| !name.trim().is_empty());
self
}
#[must_use]
pub fn with_install_dir(mut self, dir: impl Into<String>) -> Self {
self.install_dir = dir.into();
self
}
#[must_use]
pub fn with_min_disk_space_mb(mut self, megabytes: u64) -> Self {
self.min_disk_space_mb = megabytes;
self
}
#[must_use]
pub fn with_resource(mut self, resource: Resource) -> Self {
self.resource = resource;
self
}
#[must_use]
pub fn with_default_target(mut self, target: Option<String>) -> Self {
self.default_target = target;
self
}
pub fn apply_assets<'a>(&mut self, file_names: impl IntoIterator<Item = &'a str>) -> usize {
let files: Vec<(String, u64)> = file_names
.into_iter()
.map(|name| (name.to_string(), 0))
.collect();
self.fill_assets(&files, 1)
}
pub fn apply_release(&mut self, release: &Release) -> usize {
self.resolved_tag = Some(release.tag(&self.tag));
let files: Vec<(String, u64)> = release
.assets
.iter()
.map(|asset| (asset.name.clone(), asset.size))
.collect();
self.fill_assets(&files, target::MIN_RANK)
}
fn fill_assets(&mut self, files: &[(String, u64)], min_rank: u32) -> usize {
let all: Vec<&str> = files.iter().map(|(name, _)| name.as_str()).collect();
self.coverage = target::program_coverage(all.iter().copied());
self.selected_program = match &self.name {
Some(name) => Some(name.clone()),
None if self.coverage.len() == 1 => Some(self.coverage[0].name.clone()),
None => None,
};
let names: Vec<&str> = match &self.selected_program {
Some(program) => all
.iter()
.copied()
.filter(|file| target::belongs_to(file, program))
.collect(),
None if self.coverage.len() > 1 => Vec::new(),
None => all.clone(),
};
for target in target::KNOWN_TARGETS {
let Some((filename, _rank)) =
target::best_asset(names.iter().copied(), target, min_rank)
else {
continue;
};
let size = files
.iter()
.find(|(name, _)| name == filename)
.map_or(0, |(_, size)| *size);
self.assets.push(AssetEntry {
target: (*target).to_string(),
filename: filename.to_string(),
size,
program: self.selected_program.clone().unwrap_or_default(),
});
}
self.assets.sort_by(|a, b| a.target.cmp(&b.target));
if self.binary.is_none() {
self.binary = self
.selected_program
.as_deref()
.and_then(normalise_program_name)
.or_else(|| infer_binary_name(names.iter().copied()));
}
self.assets.len()
}
pub fn apply_release_checked(&mut self, release: &Release) -> Result<()> {
if self.apply_release(release) == 0 {
return Err(self.no_assets_error());
}
Ok(())
}
pub fn no_assets_error(&self) -> Error {
match &self.name {
Some(name) => Error::UnknownProgram {
name: name.clone(),
available: self.available_programs(),
},
None if self.needs_program_choice() => self.ambiguous_program_error(),
None => Error::NoAssets {
repo: self.slug(),
tag: self
.resolved_tag
.clone()
.unwrap_or_else(|| self.tag.clone()),
},
}
}
fn ambiguous_program_error(&self) -> Error {
Error::AmbiguousProgram {
repo: self.slug(),
programs: self
.coverage
.iter()
.map(|entry| format!("{} ({} platforms)", entry.name, entry.targets))
.collect(),
}
}
pub fn validate(&self) -> Result<()> {
if self.resource == Resource::Release && !self.proxy.supports_release_assets() {
return Err(Error::UnsupportedProxy {
proxy: self.proxy.as_str(),
});
}
if self.assets.is_empty() {
return Err(self.no_assets_error());
}
if let Some(target) = &self.default_target {
if !self.assets.iter().any(|entry| &entry.target == target) {
return Err(Error::UnknownTarget {
target: target.clone(),
available: self.available_targets(),
});
}
}
Ok(())
}
pub fn available_programs(&self) -> String {
self.coverage
.iter()
.map(|entry| entry.name.as_str())
.collect::<Vec<_>>()
.join(" ")
}
pub fn needs_program_choice(&self) -> bool {
self.name.is_none() && self.coverage.len() > 1
}
pub fn available_targets(&self) -> String {
let mut targets: Vec<&str> = self.assets.iter().map(|a| a.target.as_str()).collect();
targets.sort_unstable();
targets.join(" ")
}
pub fn render(&self) -> Result<String> {
crate::render::render(self)
}
pub fn regenerate_command(&self) -> String {
if let Some(invocation) = &self.invocation {
return invocation.clone();
}
self.reconstructed_command()
}
fn reconstructed_command(&self) -> String {
let mut parts = vec!["eish".to_string()];
let mut repo = self.slug();
if self.tag != "latest" {
repo.push('@');
repo.push_str(&self.tag);
}
parts.push(repo);
parts.push(format!("--shell {}", self.shell));
if self.proxy != Proxy::default() {
parts.push(format!("--proxy {}", self.proxy));
}
if let Some(binary) = &self.binary {
parts.push(format!("--binary {}", quote(binary)));
}
if let Some(name) = &self.name {
parts.push(format!("--name {}", quote(name)));
}
if let Some(target) = &self.default_target {
parts.push(format!("--target {}", quote(target)));
}
if self.install_dir != DEFAULT_INSTALL_DIR {
parts.push(format!("--dir {}", quote(&self.install_dir)));
}
if let Resource::File { reference } = &self.resource {
parts.push("--type file".to_string());
parts.push(format!("--ref {}", quote(reference)));
}
if self.min_disk_space_mb != DEFAULT_MIN_DISK_SPACE_MB {
parts.push(format!("--min-disk-space {}", self.min_disk_space_mb));
}
parts.join(" ")
}
}
fn quote(value: &str) -> String {
let safe = !value.is_empty()
&& value
.chars()
.all(|c| c.is_ascii_alphanumeric() || "._-/:+@=,".contains(c));
if safe {
value.to_string()
} else {
format!("'{}'", value.replace('\'', r"'\''"))
}
}
fn infer_binary_name<'a>(files: impl IntoIterator<Item = &'a str>) -> Option<String> {
let mut candidates: Vec<(String, usize)> = Vec::new();
for file in files {
let Some(reported) = target::guess_binary_name(file) else {
continue;
};
let Some(candidate) = normalise_binary_candidate(&reported) else {
continue;
};
match candidates
.iter_mut()
.find(|(name, _)| name.eq_ignore_ascii_case(&candidate))
{
Some((_, votes)) => *votes += 1,
None => candidates.push((candidate, 1)),
}
}
candidates
.into_iter()
.max_by_key(|(_, votes)| *votes)
.map(|(name, _)| name)
}
const PLATFORM_WORDS: &[&str] = &[
"unknown",
"linux",
"linuxstatic",
"darwin",
"macos",
"mac",
"osx",
"apple",
"windows",
"win",
"win32",
"win64",
"unix",
"freebsd",
"netbsd",
"openbsd",
"android",
"musl",
"gnu",
"msvc",
"gnullvm",
"mingw",
"eabi",
"eabihf",
"hf",
"universal",
"universal2",
"portable",
"static",
"dynamic",
"dynamically",
"pc",
"vendor",
];
const PACKAGING_WORDS: &[&str] = &[
"target", "release", "debug", "bin", "dist", "install", "setup", "full", "minimal",
];
const ARCH_WORDS: &[&str] = &[
"amd64",
"x86",
"x86-64",
"x64",
"i386",
"i686",
"x86_64",
"aarch64",
"arm64",
"armv6",
"armv7",
"armv7l",
"armv8",
"armhf",
"armel",
"arm",
"riscv64",
"riscv64gc",
"riscv",
"loongarch64",
"loongarch",
"powerpc64le",
"ppc64le",
"ppc64",
"s390x",
"32",
"64",
];
fn strip_noise(raw: &str, extra: &[&str]) -> Option<String> {
let is_noise = |token: &str| {
let lower = token.to_ascii_lowercase();
PLATFORM_WORDS.contains(&lower.as_str())
|| ARCH_WORDS.contains(&lower.as_str())
|| extra.contains(&lower.as_str())
|| is_version_like(&lower)
};
let kept: Vec<&str> = raw
.split(['-', '_'])
.filter(|token| !token.is_empty() && !is_noise(token))
.collect();
if kept.is_empty() {
return None;
}
let candidate = kept.join("-");
if candidate.contains(['/', '\\']) || candidate.starts_with('.') {
return None;
}
Some(candidate)
}
fn normalise_program_name(program: &str) -> Option<String> {
strip_noise(program, &[])
}
fn normalise_binary_candidate(raw: &str) -> Option<String> {
strip_noise(raw, PACKAGING_WORDS)
}
fn is_version_like(token: &str) -> bool {
let trimmed = token.strip_prefix('v').unwrap_or(token);
!trimmed.is_empty()
&& trimmed
.chars()
.all(|c| c.is_ascii_digit() || c == '.' || c == '-')
&& trimmed.contains(|c: char| c.is_ascii_digit())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RepoSpec {
pub owner: String,
pub repo: String,
pub tag: Option<String>,
}
impl RepoSpec {
pub fn slug(&self) -> String {
format!("{}/{}", self.owner, self.repo)
}
pub fn parse(input: &str) -> Result<Self> {
let invalid = || Error::InvalidSpec {
input: input.to_string(),
};
let trimmed = input.trim();
let without_scheme = trimmed
.strip_prefix("https://")
.or_else(|| trimmed.strip_prefix("http://"))
.unwrap_or(trimmed);
let path = without_scheme
.strip_prefix("github.com/")
.unwrap_or(without_scheme)
.trim_end_matches('/');
let (path, tag) = match path.split_once('@') {
Some((path, tag)) => (path, Some(tag.trim())),
None => (path, None),
};
let mut parts = path.splitn(2, '/');
let owner = parts.next().unwrap_or_default().trim();
let repo = parts.next().unwrap_or_default().trim();
let repo = repo.trim_end_matches(".git");
if owner.is_empty() || repo.is_empty() || repo.contains('/') {
return Err(invalid());
}
Ok(Self {
owner: owner.to_string(),
repo: repo.to_string(),
tag: tag.filter(|tag| !tag.is_empty()).map(str::to_string),
})
}
}
impl FromStr for RepoSpec {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::parse(s)
}
}
impl fmt::Display for RepoSpec {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}/{}", self.owner, self.repo)?;
if let Some(tag) = &self.tag {
write!(f, "@{tag}")?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::github::Asset;
fn release_with(names: &[&str]) -> Release {
Release {
tag_name: Some("v2.0.0".to_string()),
name: None,
assets: names
.iter()
.map(|name| Asset {
name: (*name).to_string(),
size: 10,
})
.collect(),
}
}
#[test]
fn parses_repo_specs() {
let spec = RepoSpec::parse("rust-lang/rust").unwrap();
assert_eq!(spec.owner, "rust-lang");
assert_eq!(spec.repo, "rust");
assert_eq!(spec.tag, None);
let spec = RepoSpec::parse("owner/repo@v1.2.3").unwrap();
assert_eq!(spec.tag.as_deref(), Some("v1.2.3"));
let spec = RepoSpec::parse("https://github.com/owner/repo.git").unwrap();
assert_eq!(spec.slug(), "owner/repo");
}
#[test]
fn rejects_bad_repo_specs() {
for input in ["", "owner", "owner/", "/repo", "a/b/c"] {
assert!(RepoSpec::parse(input).is_err(), "should reject {input:?}");
}
}
#[test]
fn builds_the_asset_table() {
let mut spec = InstallSpec::new("easy-install", "easy-install");
let matched = spec.apply_release(&release_with(&[
"ei-x86_64-pc-windows-gnu.zip",
"ei-x86_64-unknown-linux-musl.tar.gz",
"ei-aarch64-apple-darwin.tar.gz",
"checksums.txt",
]));
assert_eq!(matched, 3);
assert_eq!(spec.resolved_tag.as_deref(), Some("v2.0.0"));
assert_eq!(spec.binary_name(), "ei");
assert_eq!(spec.assets.len(), 3);
}
#[test]
fn keeps_the_first_asset_for_duplicate_targets() {
let mut spec = InstallSpec::new("o", "r");
spec.apply_release(&release_with(&[
"tool-x86_64-unknown-linux-musl.tar.gz",
"tool-x86_64-unknown-linux-musl.zip",
]));
assert_eq!(spec.assets.len(), 1);
assert_eq!(
spec.assets[0].filename,
"tool-x86_64-unknown-linux-musl.tar.gz"
);
}
#[test]
fn prefers_the_explicit_binary_name() {
let mut spec = InstallSpec::new("o", "r").with_binary("custom");
spec.apply_release(&release_with(&["tool-x86_64-unknown-linux-musl.tar.gz"]));
assert_eq!(spec.binary_name(), "custom");
}
#[test]
fn rejects_release_assets_with_file_only_proxies() {
let spec = InstallSpec::new("o", "r").with_proxy(Proxy::Jsdelivr);
assert!(spec.validate().is_err());
}
#[test]
fn infers_the_binary_name_from_loosely_named_assets() {
let mut spec = InstallSpec::new("jqlang", "jq");
spec.apply_release(&release_with(&[
"jq-linux-amd64",
"jq-linux-arm64",
"jq-macos-amd64",
"jq-windows-amd64.exe",
]));
assert_eq!(spec.binary_name(), "jq");
}
#[test]
fn infers_the_binary_name_from_underscored_assets() {
let mut spec = InstallSpec::new("owner", "name");
spec.apply_release(&release_with(&[
"mytool_1.2.3_linux_x86_64.tar.gz",
"mytool_1.2.3_windows_x86_64.zip",
]));
assert_eq!(spec.binary_name(), "mytool");
}
#[test]
fn falls_back_to_the_repo_name_without_assets() {
let spec = InstallSpec::new("owner", "some-tool");
assert_eq!(spec.binary_name(), "some-tool");
}
fn multi_program_release() -> Release {
release_with(&[
"crash-x86_64-unknown-linux-gnu.tar.gz",
"crash-x86_64-pc-windows-msvc.tar.gz",
"crash-aarch64-apple-darwin.tar.gz",
"crash-full-x86_64-unknown-linux-gnu.tar.xz",
"crash-full-x86_64-pc-windows-msvc.tar.xz",
"clash-linux-amd64.tar.gz",
"mihomo-linux-amd64.tar.gz",
"country.mmdb.tar.gz",
"yacd.tar.gz",
])
}
#[test]
fn a_release_can_publish_several_programs() {
let mut spec = InstallSpec::new("ahaoboy", "crash-assets");
spec.apply_release(&multi_program_release());
assert_eq!(spec.available_programs(), "crash clash crash-full mihomo");
assert!(spec.needs_program_choice());
}
#[test]
fn an_ambiguous_release_is_refused_rather_than_guessed() {
let mut spec = InstallSpec::new("ahaoboy", "crash-assets");
spec.apply_release(&multi_program_release());
assert!(spec.assets.is_empty());
assert!(spec.selected_program.is_none());
let error = spec.validate().unwrap_err().to_string();
assert!(error.contains("4 programs"), "{error}");
for name in ["crash", "clash", "crash-full", "mihomo"] {
assert!(error.contains(name), "{name} missing from {error}");
}
assert!(!error.contains("--binary"), "{error}");
let release = multi_program_release();
assert_eq!(
spec.apply_release_checked(&release)
.unwrap_err()
.to_string(),
error
);
assert!(spec.render().is_err());
}
#[test]
fn a_single_program_release_needs_no_choice() {
let mut spec = InstallSpec::new("acme", "tool");
spec.apply_release(&release_with(&[
"tool-x86_64-unknown-linux-gnu.tar.gz",
"tool-aarch64-apple-darwin.tar.gz",
]));
assert!(!spec.needs_program_choice());
assert_eq!(spec.selected_program.as_deref(), Some("tool"));
assert!(!spec.assets.is_empty());
assert!(spec.validate().is_ok());
assert!(spec.render().is_ok());
}
#[test]
fn name_restricts_the_table_to_one_program() {
let mut spec =
InstallSpec::new("ahaoboy", "crash-assets").with_name(Some("crash-full".to_string()));
spec.apply_release(&multi_program_release());
assert!(!spec.assets.is_empty());
assert!(
spec.assets.iter().all(|a| a.program == "crash-full"),
"{:?}",
spec.assets
);
assert!(
!spec
.assets
.iter()
.any(|a| a.filename.starts_with("crash-x86")),
"{:?}",
spec.assets
);
}
#[test]
fn name_can_select_the_plain_program_too() {
let mut spec =
InstallSpec::new("ahaoboy", "crash-assets").with_name(Some("crash".to_string()));
spec.apply_release(&multi_program_release());
assert!(!spec.assets.is_empty());
assert!(spec.assets.iter().all(|a| a.program == "crash"));
assert!(
!spec
.assets
.iter()
.any(|a| a.filename.contains("crash-full")),
"{:?}",
spec.assets
);
}
#[test]
fn an_unknown_name_reports_the_ones_that_work() {
let mut spec =
InstallSpec::new("ahaoboy", "crash-assets").with_name(Some("nope".to_string()));
spec.apply_release(&multi_program_release());
let error = spec
.apply_release_checked(&multi_program_release())
.unwrap_err()
.to_string();
assert!(error.contains("nope"), "{error}");
assert!(error.contains("crash-full"), "{error}");
assert!(error.contains("clash"), "{error}");
assert!(spec.validate().is_err());
assert_eq!(spec.no_assets_error().to_string(), error);
}
#[test]
fn a_blank_name_is_treated_as_absent() {
let spec = InstallSpec::new("acme", "tool")
.with_name(Some(String::new()))
.with_name(Some(" ".to_string()));
assert!(spec.name.is_none());
}
#[test]
fn name_and_binary_are_independent() {
let mut spec = InstallSpec::new("ahaoboy", "crash-assets")
.with_name(Some("crash-full".to_string()))
.with_binary("crash");
spec.apply_release(&multi_program_release());
assert_eq!(spec.binary_name(), "crash");
assert!(spec.assets.iter().all(|a| a.program == "crash-full"));
}
#[test]
fn name_appears_in_the_reconstructed_command() {
let spec =
InstallSpec::new("ahaoboy", "crash-assets").with_name(Some("crash-full".to_string()));
assert!(
spec.regenerate_command().contains("--name crash-full"),
"{}",
spec.regenerate_command()
);
}
#[test]
fn an_installer_without_assets_cannot_be_rendered() {
let spec = InstallSpec::new("acme", "tool");
assert!(spec.validate().is_err());
assert!(spec.render().is_err());
}
#[test]
fn reconstructs_a_minimal_command() {
let spec = InstallSpec::new("acme", "tool");
assert_eq!(spec.regenerate_command(), "eish acme/tool --shell bash");
}
#[test]
fn reconstructs_the_tag_and_options() {
let spec = InstallSpec::new("acme", "tool")
.with_tag("v1.2.3")
.with_shell(Shell::Fish)
.with_proxy(Proxy::Xget)
.with_binary("mytool")
.with_default_target(Some("x86_64-unknown-linux-gnu".to_string()))
.with_install_dir("/opt/tool")
.with_min_disk_space_mb(200);
let command = spec.regenerate_command();
for expected in [
"eish acme/tool@v1.2.3",
"--shell fish",
"--proxy xget",
"--binary mytool",
"--target x86_64-unknown-linux-gnu",
"--dir /opt/tool",
"--min-disk-space 200",
] {
assert!(
command.contains(expected),
"{expected:?} missing from {command}"
);
}
}
#[test]
fn reconstructs_the_file_resource() {
let spec = InstallSpec::new("acme", "tool").with_resource(Resource::File {
reference: "dev".to_string(),
});
let command = spec.regenerate_command();
assert!(command.contains("--type file"), "{command}");
assert!(command.contains("--ref dev"), "{command}");
}
#[test]
fn quotes_values_that_need_it() {
let spec = InstallSpec::new("acme", "tool").with_install_dir("/opt/my tools");
assert!(
spec.regenerate_command().contains("--dir '/opt/my tools'"),
"{}",
spec.regenerate_command()
);
let spec = InstallSpec::new("acme", "tool").with_install_dir("~/bin");
assert!(
spec.regenerate_command().contains("--dir '~/bin'"),
"{}",
spec.regenerate_command()
);
}
#[test]
fn an_explicit_invocation_wins() {
let spec = InstallSpec::new("acme", "tool")
.with_invocation("eish acme/tool --release-json release.json --quiet");
assert_eq!(
spec.regenerate_command(),
"eish acme/tool --release-json release.json --quiet"
);
}
#[test]
fn a_blank_invocation_is_ignored() {
let spec = InstallSpec::new("acme", "tool").with_invocation(" ");
assert_eq!(spec.regenerate_command(), "eish acme/tool --shell bash");
}
#[test]
fn the_command_appears_in_every_rendered_script() {
for shell in Shell::ALL {
let mut spec = InstallSpec::new("acme", "tool")
.with_shell(shell)
.with_invocation("eish acme/tool --shell bash --proxy xget");
spec.apply_assets(["tool-x86_64-unknown-linux-musl.tar.gz"]);
let script = spec.render().unwrap();
assert!(
script.contains("eish acme/tool --shell bash --proxy xget"),
"{shell} header is missing the command"
);
assert!(
script.contains("not meant to be"),
"{shell} header is missing the do-not-edit notice"
);
}
}
}