http-server-rs 0.0.31

Simple, zero-configuration command-line static HTTP server.
use std::sync::atomic::AtomicBool;
use std::sync::atomic::AtomicU64;
use std::sync::atomic::Ordering;

use self_update::backends::github;
use self_update::ReleaseAsset;

const BIN_NAME: &str = "http-server-rs";

pub async fn check_for_update(
  options: &UpgradeOptions<'_>
) -> anyhow::Result<Option<semver::Version>> {
  if is_prerelease(options.current_version)? {
    return Ok(None);
  }

  let releases = configure(options)?
    .build_async()?
    .get_latest_release_async()
    .await?;

  let Some(release) = releases.latest() else {
    return Ok(None);
  };

  let current_version = semver::Version::parse(options.current_version)?;
  let remote_version = normalize_tag(release.version())?;

  if current_version < remote_version {
    return Ok(Some(remote_version));
  }

  Ok(None)
}

#[derive(Debug)]
pub enum UpgradeOutcome {
  Skip,
  Success,
}

#[derive(Debug)]
pub struct UpgradeOptions<'a> {
  pub target_repo: &'a str,
  pub current_version: &'a str,
}

pub async fn try_upgrade(options: &UpgradeOptions<'_>) -> anyhow::Result<UpgradeOutcome> {
  if is_prerelease(options.current_version)? {
    return Ok(UpgradeOutcome::Skip);
  }

  let status = configure(options)?
    .progress_callback(progress())
    .build_async()?
    .update_async()
    .await?;

  match status {
    self_update::VersionStatus::Updated(_) => Ok(UpgradeOutcome::Success),
    _ => Ok(UpgradeOutcome::Skip),
  }
}

fn configure(options: &UpgradeOptions<'_>) -> anyhow::Result<github::UpdateBuilder> {
  let Some((owner, name)) = options.target_repo.split_once('/') else {
    anyhow::bail!("Invalid repo name");
  };

  let mut builder = github::Update::configure();
  builder
    .repo_owner(owner)
    .repo_name(name)
    .bin_name(BIN_NAME)
    .current_version(options.current_version)
    .asset_matcher(select_asset)
    .unattended();

  Ok(builder)
}

fn is_prerelease(version: &str) -> anyhow::Result<bool> {
  Ok(!semver::Version::parse(version)?.pre.is_empty())
}

fn normalize_tag(tag: &str) -> anyhow::Result<semver::Version> {
  let tag = tag.strip_prefix('v').unwrap_or(tag);
  Ok(semver::Version::parse(tag)?)
}

fn select_asset(assets: &[ReleaseAsset]) -> Option<ReleaseAsset> {
  let os = match std::env::consts::OS {
    "windows" => &["win32", "windows", "win"][..],
    "macos" => &["macos", "darwin"][..],
    "linux" => &["linux"][..],
    _ => return None,
  };

  let arch = match std::env::consts::ARCH {
    "x86_64" => &["amd64", "x86_64"][..],
    "aarch64" => &["arm64", "aarch64"][..],
    _ => return None,
  };

  assets
    .iter()
    .find(|asset| {
      let name = asset.name().to_ascii_lowercase();
      name.ends_with(".tar.gz")
        && os.iter().any(|s| name.contains(s))
        && arch.iter().any(|s| name.contains(s))
    })
    .cloned()
}

fn progress() -> impl Fn(u64, Option<u64>) + Send + Sync + 'static {
  let started = AtomicBool::new(false);
  let reported = AtomicU64::new(0);

  move |downloaded, total| {
    if !started.swap(true, Ordering::Relaxed) {
      println!("0%");
    }

    let Some(total) = total.filter(|total| *total > 0) else {
      return;
    };

    let step = (downloaded * 4 / total).min(4);
    if step > reported.load(Ordering::Relaxed) {
      reported.store(step, Ordering::Relaxed);
      println!("{}%", step * 25);
    }
  }
}