use std::sync::Arc;
use crate::backends::common::{CommonBuilderConfig, CommonConfig};
use crate::errors::*;
use crate::update::{Release, ReleaseSource, ReleaseUpdate, Releases};
#[must_use]
#[derive(Clone, Default)]
pub struct UpdateBuilder {
source: Option<Arc<dyn ReleaseSource>>,
common: CommonBuilderConfig,
}
impl std::fmt::Debug for UpdateBuilder {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("UpdateBuilder")
.field("source", &self.source.as_ref().map(|_| "<source>"))
.field("common", &self.common)
.finish()
}
}
impl UpdateBuilder {
pub fn new() -> Self {
Default::default()
}
pub fn source(&mut self, source: impl ReleaseSource + 'static) -> &mut Self {
self.source = Some(Arc::new(source));
self
}
impl_common_builder_setters!(no_auth_token);
pub fn build(&self) -> Result<Update> {
let source = self
.source
.clone()
.ok_or(Error::MissingField { field: "source" })?;
Ok(Update {
source,
common: self.common.build()?,
})
}
}
#[non_exhaustive]
pub struct Update {
source: Arc<dyn ReleaseSource>,
common: CommonConfig,
}
impl std::fmt::Debug for Update {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Update")
.field("source", &"<source>")
.field("common", &self.common)
.finish()
}
}
impl Update {
pub fn configure() -> UpdateBuilder {
UpdateBuilder::new()
}
}
impl crate::update::sealed::Sealed for Update {}
impl_update_config_accessors!(Update);
impl ReleaseUpdate for Update {
fn get_latest_release(&self) -> Result<Releases> {
let current_version = crate::update::UpdateConfig::current_version(self).to_owned();
let release = self.source.get_latest_release()?;
Ok(Releases::new(vec![release], current_version))
}
fn get_newer_releases(&self) -> Result<Releases> {
let current_version = crate::update::UpdateConfig::current_version(self).to_owned();
let releases = self
.source
.get_latest_releases()?
.into_iter()
.filter(|r| {
crate::version::bump_is_greater(¤t_version, r.version()).unwrap_or(false)
})
.collect();
Ok(Releases::new(releases, current_version))
}
fn get_release_version(&self, ver: &str) -> Result<Release> {
self.source.get_release_version(ver)
}
}
impl_sync_update_verbs!(Update);
#[cfg(feature = "async")]
#[must_use]
pub struct AsyncUpdateBuilder<S: crate::update::AsyncReleaseSource> {
source: Option<Arc<S>>,
common: CommonBuilderConfig,
}
#[cfg(feature = "async")]
impl<S: crate::update::AsyncReleaseSource> Default for AsyncUpdateBuilder<S> {
fn default() -> Self {
Self {
source: None,
common: CommonBuilderConfig::default(),
}
}
}
#[cfg(feature = "async")]
impl<S: crate::update::AsyncReleaseSource> std::fmt::Debug for AsyncUpdateBuilder<S> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AsyncUpdateBuilder")
.field("source", &self.source.as_ref().map(|_| "<source>"))
.field("common", &self.common)
.finish()
}
}
#[cfg(feature = "async")]
impl<S: crate::update::AsyncReleaseSource> AsyncUpdateBuilder<S> {
pub fn new() -> Self {
Default::default()
}
pub fn source(&mut self, source: S) -> &mut Self {
self.source = Some(Arc::new(source));
self
}
impl_common_builder_setters!(no_auth_token);
pub fn build_async(&self) -> Result<AsyncUpdate<S>> {
let source = self
.source
.clone()
.ok_or(Error::MissingField { field: "source" })?;
Ok(AsyncUpdate {
source,
common: self.common.build()?,
})
}
}
#[cfg(feature = "async")]
#[non_exhaustive]
pub struct AsyncUpdate<S: crate::update::AsyncReleaseSource> {
source: Arc<S>,
common: CommonConfig,
}
#[cfg(feature = "async")]
impl<S: crate::update::AsyncReleaseSource> std::fmt::Debug for AsyncUpdate<S> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AsyncUpdate")
.field("source", &"<source>")
.field("common", &self.common)
.finish()
}
}
#[cfg(feature = "async")]
impl<S: crate::update::AsyncReleaseSource> AsyncUpdate<S> {
pub fn configure() -> AsyncUpdateBuilder<S> {
AsyncUpdateBuilder::new()
}
pub async fn is_update_available_async(&self) -> Result<Option<Release>> {
Ok(
crate::update::AsyncReleaseUpdate::get_newer_releases_async(self)
.await?
.into_vec()
.into_iter()
.next(),
)
}
}
#[cfg(feature = "async")]
impl<S: crate::update::AsyncReleaseSource> crate::update::sealed::Sealed for AsyncUpdate<S> {}
#[cfg(feature = "async")]
impl_update_config_accessors!(AsyncUpdate<S>, where (S: crate::update::AsyncReleaseSource));
#[cfg(feature = "async")]
impl<S: crate::update::AsyncReleaseSource> crate::update::AsyncReleaseUpdate for AsyncUpdate<S> {
async fn get_latest_release_async(&self) -> Result<Releases> {
let current_version = crate::update::UpdateConfig::current_version(self).to_owned();
let release = self.source.get_latest_release().await?;
Ok(Releases::new(vec![release], current_version))
}
async fn get_newer_releases_async(&self) -> Result<Releases> {
let current_version = crate::update::UpdateConfig::current_version(self).to_owned();
let releases = self
.source
.get_latest_releases()
.await?
.into_iter()
.filter(|r| {
crate::version::bump_is_greater(¤t_version, r.version()).unwrap_or(false)
})
.collect();
Ok(Releases::new(releases, current_version))
}
async fn get_release_version_async(&self, ver: &str) -> Result<Release> {
self.source.get_release_version(ver).await
}
}
#[cfg(feature = "async")]
pub struct Blocking<S> {
source: S,
}
#[cfg(feature = "async")]
impl<S> Blocking<S> {
pub fn new(source: S) -> Self {
Self { source }
}
pub fn into_inner(self) -> S {
self.source
}
pub fn as_inner(&self) -> &S {
&self.source
}
}
#[cfg(feature = "async")]
impl<S: ReleaseSource + Clone + 'static> crate::update::AsyncReleaseSource for Blocking<S> {
async fn get_latest_release(&self) -> Result<Release> {
let s = self.source.clone();
tokio::task::spawn_blocking(move || s.get_latest_release())
.await
.map_err(|e| Error::Internal {
message: "blocking task failed".to_string(),
source: Some(Box::new(e)),
})?
}
async fn get_latest_releases(&self) -> Result<Vec<Release>> {
let s = self.source.clone();
tokio::task::spawn_blocking(move || s.get_latest_releases())
.await
.map_err(|e| Error::Internal {
message: "blocking task failed".to_string(),
source: Some(Box::new(e)),
})?
}
async fn get_release_version(&self, ver: &str) -> Result<Release> {
let s = self.source.clone();
let ver = ver.to_owned();
tokio::task::spawn_blocking(move || s.get_release_version(&ver))
.await
.map_err(|e| Error::Internal {
message: "blocking task failed".to_string(),
source: Some(Box::new(e)),
})?
}
}
#[cfg(test)]
mod tests {
use super::Update;
use crate::update::{Release, ReleaseAsset, ReleaseSource, UpdateConfig};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
struct FakeSource {
latest_calls: Arc<AtomicUsize>,
}
impl ReleaseSource for FakeSource {
fn get_latest_release(&self) -> crate::errors::Result<Release> {
self.latest_calls.fetch_add(1, Ordering::SeqCst);
Release::builder()
.version("2.0.0")
.asset(ReleaseAsset::new(
"app-x86_64-unknown-linux-gnu.tar.gz",
"https://example/app-2.0.0.tar.gz",
))
.build()
}
fn get_latest_releases(&self) -> crate::errors::Result<Vec<Release>> {
Ok(vec![self.get_latest_release()?])
}
fn get_release_version(&self, ver: &str) -> crate::errors::Result<Release> {
Release::builder().version(ver).build()
}
}
fn configured(calls: Arc<AtomicUsize>) -> super::Update {
Update::configure()
.source(FakeSource {
latest_calls: calls,
})
.bin_name("app")
.target("x86_64-unknown-linux-gnu")
.current_version("1.0.0")
.build()
.unwrap()
}
#[test]
fn build_requires_a_source() {
let res = Update::configure()
.bin_name("app")
.current_version("1.0.0")
.build();
assert!(res.is_err(), "build must fail without a source");
}
#[test]
fn build_is_repeatable() {
let mut builder = Update::configure();
builder
.source(FakeSource {
latest_calls: Arc::new(AtomicUsize::new(0)),
})
.bin_name("app")
.current_version("1.0.0");
builder.build().expect("first build");
builder.build().expect("second build");
}
#[test]
fn fetches_delegate_to_the_source() {
let calls = Arc::new(AtomicUsize::new(0));
let upd = configured(calls.clone());
let latest = upd.get_latest_release().unwrap();
let rel = latest.latest().expect("one-element Releases");
assert_eq!(rel.version(), "2.0.0");
assert_eq!(rel.assets.len(), 1);
assert_eq!(
latest.all().len(),
1,
"get_latest_release yields one element"
);
let rels = upd.get_newer_releases().unwrap();
assert_eq!(rels.all().len(), 1);
let tagged = upd.get_release_version("1.5.0").unwrap();
assert_eq!(tagged.version(), "1.5.0");
assert_eq!(calls.load(Ordering::SeqCst), 2);
}
#[test]
fn shared_accessors_are_wired() {
let upd = configured(Arc::new(AtomicUsize::new(0)));
assert_eq!(upd.target(), "x86_64-unknown-linux-gnu");
let expected_bin = format!("app{}", std::env::consts::EXE_SUFFIX);
assert_eq!(upd.bin_name(), expected_bin);
assert_eq!(upd.current_version(), "1.0.0");
assert_eq!(upd.auth_token(), None);
}
#[test]
fn is_update_available_true_when_latest_is_newer() {
let upd = configured(Arc::new(AtomicUsize::new(0)));
assert!(
upd.get_newer_releases()
.unwrap()
.is_update_available()
.unwrap(),
"2.0.0 > 1.0.0 => update available"
);
}
#[test]
fn is_update_available_false_when_latest_not_newer() {
let upd = Update::configure()
.source(FakeSource {
latest_calls: Arc::new(AtomicUsize::new(0)),
})
.bin_name("app")
.target("x86_64-unknown-linux-gnu")
.current_version("2.0.0")
.build()
.unwrap();
assert!(
!upd.get_newer_releases()
.unwrap()
.is_update_available()
.unwrap(),
"latest (2.0.0) is not newer than current (2.0.0) => no update"
);
}
struct MultiVersionSource;
impl ReleaseSource for MultiVersionSource {
fn get_latest_release(&self) -> crate::errors::Result<Release> {
Release::builder().version("3.0.0").build()
}
fn get_latest_releases(&self) -> crate::errors::Result<Vec<Release>> {
Ok(vec![
Release::builder().version("3.0.0").build()?,
Release::builder().version("2.0.0").build()?,
Release::builder().version("1.5.0").build()?,
Release::builder().version("1.0.0").build()?,
Release::builder().version("0.9.0").build()?,
])
}
fn get_release_version(&self, ver: &str) -> crate::errors::Result<Release> {
Release::builder().version(ver).build()
}
}
#[test]
fn get_newer_releases_excludes_equal_and_older_releases() {
let upd = Update::configure()
.source(MultiVersionSource)
.bin_name("app")
.target("x86_64-unknown-linux-gnu")
.current_version("1.0.0")
.build()
.unwrap();
let rels = upd.get_newer_releases().unwrap();
let versions: Vec<&str> = rels.all().iter().map(|r| r.version()).collect();
assert_eq!(
versions,
vec!["3.0.0", "2.0.0", "1.5.0"],
"get_newer_releases must exclude equal-to-current (1.0.0) and older (0.9.0)"
);
}
#[test]
fn get_newer_releases_returns_empty_when_all_releases_are_current_or_older() {
let upd = Update::configure()
.source(MultiVersionSource)
.bin_name("app")
.target("x86_64-unknown-linux-gnu")
.current_version("3.0.0")
.build()
.unwrap();
let rels = upd.get_newer_releases().unwrap();
assert!(
rels.all().is_empty(),
"get_newer_releases must return an empty list when nothing is strictly newer; got {:?}",
rels.all().iter().map(|r| r.version()).collect::<Vec<_>>()
);
}
#[test]
fn get_latest_release_carries_current_version_for_the_precheck() {
let upd = configured(Arc::new(AtomicUsize::new(0)));
let releases = upd.get_latest_release().unwrap();
assert_eq!(releases.all().len(), 1);
assert!(
releases.is_update_available().unwrap(),
"2.0.0 > 1.0.0 via the one-element Releases pre-check"
);
}
#[test]
fn selects_asset_from_a_source_release() {
let upd = configured(Arc::new(AtomicUsize::new(0)));
let releases = upd.get_latest_release().unwrap();
let rel = releases.latest().expect("one-element Releases");
let asset = rel
.asset_for("x86_64-unknown-linux-gnu", None)
.expect("asset matches the target");
assert_eq!(asset.download_url(), "https://example/app-2.0.0.tar.gz");
}
struct SyncOrchestratedSource;
fn versioned_release_sync(v: &str) -> Release {
Release::builder()
.version(v)
.asset(ReleaseAsset::new(
format!("app-{}.bin", v),
format!("https://example/app-{}.bin", v),
))
.build()
.unwrap()
}
impl ReleaseSource for SyncOrchestratedSource {
fn get_latest_release(&self) -> crate::errors::Result<Release> {
self.get_release_version("9.9.9")
}
fn get_latest_releases(&self) -> crate::errors::Result<Vec<Release>> {
Ok(vec![
versioned_release_sync("1.0.0"),
versioned_release_sync("2.0.0"),
versioned_release_sync("0.9.0"),
versioned_release_sync("1.4.0"),
versioned_release_sync("1.2.0"),
])
}
fn get_release_version(&self, ver: &str) -> crate::errors::Result<Release> {
Ok(versioned_release_sync(ver))
}
}
#[test]
fn update_extended_resolves_explicit_tag_then_selects_that_release() {
let seen: Arc<std::sync::Mutex<Vec<String>>> = Arc::new(std::sync::Mutex::new(vec![]));
let seen_cb = seen.clone();
let upd = Update::configure()
.source(SyncOrchestratedSource)
.bin_name("app")
.target("x86_64-unknown-linux-gnu")
.current_version("1.0.0")
.release_tag("7.7.7")
.no_confirm(true)
.show_output(false)
.asset_matcher(move |assets| {
let mut s = seen_cb.lock().unwrap();
for a in assets {
s.push(a.name().to_string());
}
None
})
.build()
.unwrap();
let err = upd
.update_extended()
.expect_err("matcher returning None must fail the update");
assert!(
matches!(err, crate::errors::Error::NoReleaseFound { .. }),
"explicit-tag path still runs asset selection, got {:?}",
err
);
assert_eq!(
*seen.lock().unwrap(),
vec!["app-7.7.7.bin".to_string()],
"the explicit-tag path must resolve and select the tagged release (7.7.7)"
);
}
#[test]
fn update_extended_selects_newest_compatible_then_fails_at_missing_asset() {
let seen: Arc<std::sync::Mutex<Vec<String>>> = Arc::new(std::sync::Mutex::new(vec![]));
let seen_cb = seen.clone();
let upd = Update::configure()
.source(SyncOrchestratedSource)
.bin_name("app")
.target("x86_64-unknown-linux-gnu")
.current_version("1.0.0")
.no_confirm(true)
.show_output(false)
.asset_matcher(move |assets| {
let mut s = seen_cb.lock().unwrap();
for a in assets {
s.push(a.name().to_string());
}
None
})
.build()
.unwrap();
let err = upd
.update_extended()
.expect_err("matcher returning None must fail the update");
assert!(
matches!(err, crate::errors::Error::NoReleaseFound { .. }),
"no asset selected -> Error::NoReleaseFound, got {:?}",
err
);
assert_eq!(
*seen.lock().unwrap(),
vec!["app-1.4.0.bin".to_string()],
"the sync orchestrator must select the newest compatible release (1.4.0)"
);
}
#[cfg(feature = "async")]
mod async_tests {
use super::super::{AsyncUpdate, Blocking};
use crate::update::{
AsyncReleaseSource, AsyncReleaseUpdate, Release, ReleaseAsset, ReleaseSource,
};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
struct NativeAsyncSource {
latest_calls: Arc<AtomicUsize>,
releases_calls: Arc<AtomicUsize>,
version_calls: Arc<AtomicUsize>,
}
impl AsyncReleaseSource for NativeAsyncSource {
async fn get_latest_release(&self) -> crate::errors::Result<Release> {
tokio::task::yield_now().await;
self.latest_calls.fetch_add(1, Ordering::SeqCst);
Release::builder()
.version("2.0.0")
.asset(ReleaseAsset::new(
"app-x86_64-unknown-linux-gnu.tar.gz",
"https://example/app-2.0.0.tar.gz",
))
.build()
}
async fn get_latest_releases(&self) -> crate::errors::Result<Vec<Release>> {
tokio::task::yield_now().await;
self.releases_calls.fetch_add(1, Ordering::SeqCst);
Ok(vec![Release::builder().version("2.0.0").build()?])
}
async fn get_release_version(&self, ver: &str) -> crate::errors::Result<Release> {
tokio::task::yield_now().await;
self.version_calls.fetch_add(1, Ordering::SeqCst);
Release::builder().version(ver).build()
}
}
#[tokio::test]
async fn build_async_requires_a_source() {
let res = AsyncUpdate::<NativeAsyncSource>::configure()
.bin_name("app")
.current_version("1.0.0")
.build_async();
assert!(res.is_err(), "build_async must fail without a source");
}
#[tokio::test]
async fn build_async_is_repeatable() {
let mut builder = AsyncUpdate::configure();
builder
.source(NativeAsyncSource {
latest_calls: Arc::new(AtomicUsize::new(0)),
releases_calls: Arc::new(AtomicUsize::new(0)),
version_calls: Arc::new(AtomicUsize::new(0)),
})
.bin_name("app")
.current_version("1.0.0");
builder.build_async().expect("first build_async");
builder.build_async().expect("second build_async");
}
#[tokio::test]
async fn async_fetches_delegate_to_the_native_source() {
let latest = Arc::new(AtomicUsize::new(0));
let releases = Arc::new(AtomicUsize::new(0));
let version = Arc::new(AtomicUsize::new(0));
let upd = AsyncUpdate::configure()
.source(NativeAsyncSource {
latest_calls: latest.clone(),
releases_calls: releases.clone(),
version_calls: version.clone(),
})
.bin_name("app")
.target("x86_64-unknown-linux-gnu")
.current_version("1.0.0")
.build_async()
.unwrap();
let latest_releases = upd.get_latest_release_async().await.unwrap();
let rel = latest_releases.latest().expect("one-element Releases");
assert_eq!(rel.version(), "2.0.0");
assert_eq!(rel.assets.len(), 1);
let rels = AsyncReleaseUpdate::get_newer_releases_async(&upd)
.await
.unwrap();
assert_eq!(rels.all().len(), 1);
let tagged = AsyncReleaseUpdate::get_release_version_async(&upd, "1.5.0")
.await
.unwrap();
assert_eq!(tagged.version(), "1.5.0");
assert_eq!(latest.load(Ordering::SeqCst), 1);
assert_eq!(releases.load(Ordering::SeqCst), 1);
assert_eq!(version.load(Ordering::SeqCst), 1);
}
#[derive(Clone)]
struct SyncSource {
calls: Arc<AtomicUsize>,
}
impl ReleaseSource for SyncSource {
fn get_latest_release(&self) -> crate::errors::Result<Release> {
self.calls.fetch_add(1, Ordering::SeqCst);
Release::builder().version("3.0.0").build()
}
fn get_latest_releases(&self) -> crate::errors::Result<Vec<Release>> {
self.calls.fetch_add(1, Ordering::SeqCst);
Ok(vec![Release::builder().version("3.0.0").build()?])
}
fn get_release_version(&self, ver: &str) -> crate::errors::Result<Release> {
self.calls.fetch_add(1, Ordering::SeqCst);
Release::builder().version(ver).build()
}
}
#[tokio::test]
async fn blocking_adapter_drives_async_update_from_a_sync_source() {
let calls = Arc::new(AtomicUsize::new(0));
let upd = AsyncUpdate::configure()
.source(Blocking::new(SyncSource {
calls: calls.clone(),
}))
.bin_name("app")
.target("x86_64-unknown-linux-gnu")
.current_version("1.0.0")
.build_async()
.unwrap();
assert_eq!(
upd.get_latest_release_async()
.await
.unwrap()
.latest()
.unwrap()
.version(),
"3.0.0"
);
assert_eq!(
AsyncReleaseUpdate::get_newer_releases_async(&upd)
.await
.unwrap()
.all()
.len(),
1
);
assert_eq!(
AsyncReleaseUpdate::get_release_version_async(&upd, "9.9.9")
.await
.unwrap()
.version(),
"9.9.9"
);
assert_eq!(
calls.load(Ordering::SeqCst),
3,
"each async fetch must delegate to the sync source exactly once"
);
}
struct OrchestratedSource;
fn versioned_release(v: &str) -> Release {
Release::builder()
.version(v)
.asset(ReleaseAsset::new(
format!("app-{}.bin", v),
format!("https://example/app-{}.bin", v),
))
.build()
.unwrap()
}
impl AsyncReleaseSource for OrchestratedSource {
async fn get_latest_release(&self) -> crate::errors::Result<Release> {
self.get_release_version("9.9.9").await
}
async fn get_latest_releases(&self) -> crate::errors::Result<Vec<Release>> {
tokio::task::yield_now().await;
Ok(vec![
versioned_release("1.0.0"),
versioned_release("2.0.0"),
versioned_release("0.9.0"),
versioned_release("1.4.0"),
versioned_release("1.2.0"),
])
}
async fn get_release_version(&self, ver: &str) -> crate::errors::Result<Release> {
tokio::task::yield_now().await;
Ok(versioned_release(ver))
}
}
#[tokio::test]
async fn update_extended_async_reports_up_to_date_through_orchestrator() {
struct OldOnly;
impl AsyncReleaseSource for OldOnly {
async fn get_latest_release(&self) -> crate::errors::Result<Release> {
Release::builder().version("1.0.0").build()
}
async fn get_latest_releases(&self) -> crate::errors::Result<Vec<Release>> {
tokio::task::yield_now().await;
Ok(vec![
Release::builder().version("1.0.0").build()?,
Release::builder().version("0.9.0").build()?,
])
}
async fn get_release_version(&self, ver: &str) -> crate::errors::Result<Release> {
Release::builder().version(ver).build()
}
}
let upd = AsyncUpdate::configure()
.source(OldOnly)
.bin_name("app")
.target("x86_64-unknown-linux-gnu")
.current_version("1.0.0")
.no_confirm(true)
.show_output(false)
.build_async()
.unwrap();
let status = upd.update_extended_async().await.unwrap();
assert!(
status.is_up_to_date(),
"only current/older releases -> up-to-date through the async orchestrator"
);
}
#[tokio::test]
async fn update_extended_async_selects_newest_compatible_then_fails_at_missing_asset() {
let seen: Arc<std::sync::Mutex<Vec<String>>> = Arc::new(std::sync::Mutex::new(vec![]));
let seen_cb = seen.clone();
let upd = AsyncUpdate::configure()
.source(OrchestratedSource)
.bin_name("app")
.target("x86_64-unknown-linux-gnu")
.current_version("1.0.0")
.no_confirm(true)
.show_output(false)
.asset_matcher(move |assets| {
let mut s = seen_cb.lock().unwrap();
for a in assets {
s.push(a.name().to_string());
}
None
})
.build_async()
.unwrap();
let err = upd
.update_extended_async()
.await
.expect_err("matcher returning None must fail the update");
assert!(
matches!(err, crate::errors::Error::NoReleaseFound { .. }),
"no asset selected -> Error::NoReleaseFound, got {:?}",
err
);
assert_eq!(
*seen.lock().unwrap(),
vec!["app-1.4.0.bin".to_string()],
"the async orchestrator must select the newest compatible release (1.4.0)"
);
}
#[tokio::test]
async fn update_extended_async_resolves_explicit_tag_then_selects_that_release() {
let seen: Arc<std::sync::Mutex<Vec<String>>> = Arc::new(std::sync::Mutex::new(vec![]));
let seen_cb = seen.clone();
let mut builder = AsyncUpdate::configure();
builder
.source(OrchestratedSource)
.bin_name("app")
.target("x86_64-unknown-linux-gnu")
.current_version("1.0.0")
.release_tag("7.7.7")
.no_confirm(true)
.show_output(false)
.asset_matcher(move |assets| {
let mut s = seen_cb.lock().unwrap();
for a in assets {
s.push(a.name().to_string());
}
None
});
let upd = builder.build_async().unwrap();
let err = upd
.update_extended_async()
.await
.expect_err("matcher returning None must fail the update");
assert!(
matches!(err, crate::errors::Error::NoReleaseFound { .. }),
"explicit-tag path still runs asset selection, got {:?}",
err
);
assert_eq!(
*seen.lock().unwrap(),
vec!["app-7.7.7.bin".to_string()],
"the explicit-tag path must resolve and select the tagged release (7.7.7)"
);
}
#[tokio::test]
async fn async_update_works_with_a_non_clone_source() {
struct NonClone {
_lock: std::sync::Mutex<()>,
}
impl AsyncReleaseSource for NonClone {
async fn get_latest_release(&self) -> crate::errors::Result<Release> {
Release::builder().version("1.0.0").build()
}
async fn get_latest_releases(&self) -> crate::errors::Result<Vec<Release>> {
Ok(vec![Release::builder().version("1.0.0").build()?])
}
async fn get_release_version(&self, ver: &str) -> crate::errors::Result<Release> {
Release::builder().version(ver).build()
}
}
fn assert_not_requiring_clone<S: AsyncReleaseSource>(_: &AsyncUpdate<S>) {}
let upd = AsyncUpdate::configure()
.source(NonClone {
_lock: std::sync::Mutex::new(()),
})
.bin_name("app")
.target("x86_64-unknown-linux-gnu")
.current_version("2.0.0")
.no_confirm(true)
.show_output(false)
.build_async()
.unwrap();
assert_not_requiring_clone(&upd);
let status = upd.update_extended_async().await.unwrap();
assert!(status.is_up_to_date());
}
#[tokio::test]
async fn blocking_adapter_propagates_sync_error() {
#[derive(Clone)]
struct FailingSource;
impl ReleaseSource for FailingSource {
fn get_latest_release(&self) -> crate::errors::Result<Release> {
Err(crate::errors::Error::NoReleaseFound { target: None })
}
fn get_latest_releases(&self) -> crate::errors::Result<Vec<Release>> {
Err(crate::errors::Error::HttpStatus {
status: 503,
url: "u".into(),
})
}
fn get_release_version(&self, _ver: &str) -> crate::errors::Result<Release> {
Err(crate::errors::Error::MissingField { field: "cfg" })
}
}
let blk = Blocking::new(FailingSource);
assert!(matches!(
AsyncReleaseSource::get_latest_release(&blk).await,
Err(crate::errors::Error::NoReleaseFound { .. })
));
assert!(matches!(
AsyncReleaseSource::get_latest_releases(&blk).await,
Err(crate::errors::Error::HttpStatus { .. })
));
assert!(matches!(
AsyncReleaseSource::get_release_version(&blk, "1.0.0").await,
Err(crate::errors::Error::MissingField { .. })
));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn blocking_adapter_join_failure_chains_source() {
use std::error::Error as _;
#[derive(Clone)]
struct PanickingSource;
impl ReleaseSource for PanickingSource {
fn get_latest_release(&self) -> crate::errors::Result<Release> {
panic!("boom in blocking task");
}
fn get_latest_releases(&self) -> crate::errors::Result<Vec<Release>> {
unreachable!()
}
fn get_release_version(&self, _ver: &str) -> crate::errors::Result<Release> {
unreachable!()
}
}
let blk = Blocking::new(PanickingSource);
let err = AsyncReleaseSource::get_latest_release(&blk)
.await
.expect_err("a panicking blocking task must fail the join");
assert!(
matches!(err, crate::errors::Error::Internal { .. }),
"join failure must surface as Error::Internal, got {:?}",
err
);
assert!(
err.source().is_some(),
"Internal from a JoinError must chain a non-None source()"
);
}
#[test]
fn blocking_new_as_inner_and_into_inner_round_trip() {
#[derive(Clone, PartialEq, Debug)]
struct Marker(u32);
impl ReleaseSource for Marker {
fn get_latest_release(&self) -> crate::errors::Result<Release> {
Release::builder().version("1.0.0").build()
}
fn get_latest_releases(&self) -> crate::errors::Result<Vec<Release>> {
Ok(vec![])
}
fn get_release_version(&self, v: &str) -> crate::errors::Result<Release> {
Release::builder().version(v).build()
}
}
let blk = Blocking::new(Marker(7));
assert_eq!(blk.as_inner(), &Marker(7), "as_inner borrows the source");
assert_eq!(blk.into_inner(), Marker(7), "into_inner returns the source");
}
fn assert_fetch_future_is_send<S: AsyncReleaseSource>(s: &S) {
fn is_send<T: Send>(_: &T) {}
is_send(&s.get_latest_release());
}
#[tokio::test]
async fn async_release_source_future_is_send() {
let src = NativeAsyncSource {
latest_calls: Arc::new(AtomicUsize::new(0)),
releases_calls: Arc::new(AtomicUsize::new(0)),
version_calls: Arc::new(AtomicUsize::new(0)),
};
assert_fetch_future_is_send(&src);
assert_eq!(src.get_latest_release().await.unwrap().version(), "2.0.0");
}
#[tokio::test]
async fn is_update_available_async_true_then_false() {
let mk = |cur: &str| {
AsyncUpdate::configure()
.source(NativeAsyncSource {
latest_calls: Arc::new(AtomicUsize::new(0)),
releases_calls: Arc::new(AtomicUsize::new(0)),
version_calls: Arc::new(AtomicUsize::new(0)),
})
.bin_name("app")
.target("x86_64-unknown-linux-gnu")
.current_version(cur)
.build_async()
.unwrap()
};
assert!(
mk("1.0.0")
.get_newer_releases_async()
.await
.unwrap()
.is_update_available()
.unwrap(),
"2.0.0 > 1.0.0 => update available"
);
assert!(
!mk("2.0.0")
.get_newer_releases_async()
.await
.unwrap()
.is_update_available()
.unwrap(),
"2.0.0 not newer than 2.0.0 => no update"
);
}
#[tokio::test]
async fn is_update_available_async_verb_returns_newest_newer_or_none() {
let mk = |cur: &str| {
AsyncUpdate::configure()
.source(NativeAsyncSource {
latest_calls: Arc::new(AtomicUsize::new(0)),
releases_calls: Arc::new(AtomicUsize::new(0)),
version_calls: Arc::new(AtomicUsize::new(0)),
})
.bin_name("app")
.target("x86_64-unknown-linux-gnu")
.current_version(cur)
.build_async()
.unwrap()
};
let newer = mk("1.0.0").is_update_available_async().await.unwrap();
assert_eq!(
newer.map(|r| r.version().to_string()),
Some("2.0.0".to_string()),
"from 1.0.0 the 2.0.0 release is available"
);
assert!(
mk("2.0.0")
.is_update_available_async()
.await
.unwrap()
.is_none(),
"from 2.0.0 nothing newer => None"
);
}
struct MultiVersionAsyncSource;
impl AsyncReleaseSource for MultiVersionAsyncSource {
async fn get_latest_release(&self) -> crate::errors::Result<Release> {
Release::builder().version("3.0.0").build()
}
async fn get_latest_releases(&self) -> crate::errors::Result<Vec<Release>> {
Ok(vec![
Release::builder().version("3.0.0").build()?,
Release::builder().version("2.0.0").build()?,
Release::builder().version("1.5.0").build()?,
Release::builder().version("1.0.0").build()?,
Release::builder().version("0.9.0").build()?,
])
}
async fn get_release_version(&self, ver: &str) -> crate::errors::Result<Release> {
Release::builder().version(ver).build()
}
}
#[tokio::test]
async fn get_newer_releases_async_excludes_equal_and_older_releases() {
let upd = AsyncUpdate::configure()
.source(MultiVersionAsyncSource)
.bin_name("app")
.target("x86_64-unknown-linux-gnu")
.current_version("1.0.0")
.build_async()
.unwrap();
let rels = AsyncReleaseUpdate::get_newer_releases_async(&upd)
.await
.unwrap();
let versions: Vec<&str> = rels.all().iter().map(|r| r.version()).collect();
assert_eq!(
versions,
vec!["3.0.0", "2.0.0", "1.5.0"],
"get_newer_releases_async must exclude equal-to-current (1.0.0) and older (0.9.0)"
);
}
#[tokio::test]
async fn get_newer_releases_async_returns_empty_when_all_current_or_older() {
let upd = AsyncUpdate::configure()
.source(MultiVersionAsyncSource)
.bin_name("app")
.target("x86_64-unknown-linux-gnu")
.current_version("3.0.0")
.build_async()
.unwrap();
let rels = AsyncReleaseUpdate::get_newer_releases_async(&upd)
.await
.unwrap();
assert!(
rels.all().is_empty(),
"get_newer_releases_async must return empty when nothing is strictly newer; got {:?}",
rels.all().iter().map(|r| r.version()).collect::<Vec<_>>()
);
}
#[cfg(all(feature = "archive-tar", feature = "compression-tar-gz"))]
#[tokio::test]
async fn update_extended_async_downloads_and_installs_through_the_spawn_blocking_tail() {
use std::io::{Read as _, Write as _};
use std::net::TcpListener;
let archive_bytes: Vec<u8> = {
let mut tar = tar::Builder::new(Vec::new());
let contents = b"installed-binary-payload";
let mut header = tar::Header::new_gnu();
header.set_path("app").unwrap();
header.set_size(contents.len() as u64);
header.set_mode(0o755);
header.set_cksum();
tar.append(&header, &contents[..]).unwrap();
let tar_bytes = tar.into_inner().unwrap();
let mut enc =
flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
enc.write_all(&tar_bytes).unwrap();
enc.finish().unwrap()
};
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
let body = archive_bytes.clone();
std::thread::spawn(move || {
if let Ok((mut stream, _)) = listener.accept() {
let mut buf = [0u8; 4096];
let _ = stream.read(&mut buf);
let header = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
);
let _ = stream.write_all(header.as_bytes());
let _ = stream.write_all(&body);
let _ = stream.flush();
}
});
struct ServingSource {
url: String,
}
impl AsyncReleaseSource for ServingSource {
async fn get_latest_release(&self) -> crate::errors::Result<Release> {
self.get_release_version("9.9.9").await
}
async fn get_latest_releases(&self) -> crate::errors::Result<Vec<Release>> {
tokio::task::yield_now().await;
Ok(vec![
Release::builder()
.version("2.0.0")
.asset(ReleaseAsset::new("app.tar.gz", self.url.clone()))
.build()?,
])
}
async fn get_release_version(&self, ver: &str) -> crate::errors::Result<Release> {
tokio::task::yield_now().await;
Release::builder()
.version(ver)
.asset(ReleaseAsset::new("app.tar.gz", self.url.clone()))
.build()
}
}
let install_dir = tempfile::tempdir().unwrap();
let install_path = install_dir.path().join("installed-app");
let upd = AsyncUpdate::configure()
.source(ServingSource {
url: format!("{base}/app.tar.gz"),
})
.bin_name("app")
.target("x86_64-unknown-linux-gnu")
.current_version("1.0.0")
.bin_install_path(&install_path)
.no_confirm(true)
.show_output(false)
.asset_matcher(|assets| assets.first().cloned())
.build_async()
.unwrap();
let status = upd.update_extended_async().await.expect(
"the async update must download and install through the spawn_blocking tail",
);
assert!(
status.is_updated(),
"a newer release served as a real tar.gz must install -> Updated, got {:?}",
status
);
assert_eq!(status.updated_release().map(|r| r.version()), Some("2.0.0"));
assert!(
install_path.exists(),
"the spawn_blocking finish tail must have installed the binary to {:?}",
install_path
);
assert_eq!(
std::fs::read(&install_path).unwrap(),
b"installed-binary-payload",
"the installed file must be the binary extracted from the archive"
);
}
}
}