mod install;
mod registry;
pub mod version;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use anyhow::{bail, Result};
use indicatif::{ProgressBar, ProgressDrawTarget, ProgressState, ProgressStyle};
use owo_colors::OwoColorize;
use serde::{Deserialize, Serialize};
use tokio::task::JoinHandle;
use crate::config::config_path;
use crate::render::theme;
use install::{Meter, Progress, SelfInstaller};
struct OnStderr;
struct OnStdout;
impl Progress for OnStderr {
fn say(&self, message: &str) {
eprintln!(" {}", message.style(theme::muted()));
}
fn downloading(&self, bytes: Option<u64>) -> Box<dyn Meter> {
download_bar(ProgressDrawTarget::stderr(), bytes)
}
fn working(&self, message: &str) -> Box<dyn Meter> {
spinner(ProgressDrawTarget::stderr(), message)
}
}
impl Progress for OnStdout {
fn say(&self, message: &str) {
println!(" {}", message.style(theme::muted()));
}
fn downloading(&self, bytes: Option<u64>) -> Box<dyn Meter> {
download_bar(ProgressDrawTarget::stdout(), bytes)
}
fn working(&self, message: &str) -> Box<dyn Meter> {
spinner(ProgressDrawTarget::stdout(), message)
}
}
struct Silently;
impl Progress for Silently {
fn say(&self, _message: &str) {}
fn downloading(&self, _bytes: Option<u64>) -> Box<dyn Meter> {
Box::new(Unwatched)
}
fn working(&self, _message: &str) -> Box<dyn Meter> {
Box::new(Unwatched)
}
}
struct Drawn {
bar: ProgressBar,
}
impl Meter for Drawn {
fn advanced(&self, bytes: u64) {
self.bar.inc(bytes);
}
fn finished(&self) {
self.bar.finish_and_clear();
}
}
struct Unwatched;
impl Meter for Unwatched {
fn advanced(&self, _bytes: u64) {}
fn finished(&self) {}
}
const TICK: Duration = Duration::from_millis(100);
fn download_bar(target: ProgressDrawTarget, bytes: Option<u64>) -> Box<dyn Meter> {
let bar = ProgressBar::with_draw_target(bytes, target);
bar.set_style(style(download_template(bytes)));
bar.set_message(format!("{}", "descargando".style(theme::muted())));
if bytes.is_none() {
bar.enable_steady_tick(TICK);
}
Box::new(Drawn { bar })
}
fn spinner(target: ProgressDrawTarget, message: &str) -> Box<dyn Meter> {
let bar = ProgressBar::with_draw_target(None, target);
bar.set_style(style(SPINNER));
bar.set_message(format!("{}", message.style(theme::muted())));
bar.enable_steady_tick(TICK);
Box::new(Drawn { bar })
}
const MEASURED: &str = " {msg} {bar:28} {bytes}/{total_bytes} · {binary_bytes_per_sec}{eta}";
const UNMEASURED: &str = " {spinner} {msg} {bytes} · {binary_bytes_per_sec}";
const SPINNER: &str = " {spinner} {msg} · {elapsed}";
fn eta_words(seconds: u64) -> String {
match seconds {
0 => String::new(),
1 => " · falta 1s".to_owned(),
few if few < 60 => format!(" · faltan {few}s"),
many => format!(" · faltan {}m {}s", many / 60, many % 60),
}
}
fn download_template(bytes: Option<u64>) -> &'static str {
match bytes {
Some(_) => MEASURED,
None => UNMEASURED,
}
}
fn style(template: &str) -> ProgressStyle {
ProgressStyle::with_template(template)
.unwrap_or_else(|_| ProgressStyle::default_bar())
.progress_chars("━━╌")
.with_key(
"eta",
|state: &ProgressState, out: &mut dyn std::fmt::Write| {
let _ = out.write_str(&eta_words(state.eta().as_secs()));
},
)
}
const CACHE_LIFETIME: Duration = Duration::from_secs(24 * 60 * 60);
const FETCH_TIMEOUT: Duration = Duration::from_secs(2);
const ASKED_TIMEOUT: Duration = Duration::from_secs(15);
const TAIL_WAIT: Duration = Duration::from_millis(400);
const OPT_OUT: &str = "FUNDAIA_NO_UPDATE_CHECK";
const NO_SELF_UPDATE: &str = "FUNDAIA_NO_SELF_UPDATE";
pub struct UpdateCheck {
current: &'static str,
quiet: bool,
announced: bool,
pending: Option<Pending>,
}
struct Pending {
known: String,
fetch: JoinHandle<Option<String>>,
}
impl UpdateCheck {
pub fn start(current: &'static str, quiet: bool) -> Self {
let mut check = Self {
current,
quiet,
announced: false,
pending: None,
};
if switched_off(OPT_OUT) {
return check;
}
let Some(path) = cache_path() else {
return check;
};
let cached = Cache::read(&path);
if let Some(newer) = version::newer(current, &cached.latest) {
if !quiet {
announce(current, newer);
}
check.announced = true;
}
if is_fresh(cached.checked_at, now_seconds()) {
return check;
}
check.pending = Some(Pending {
known: cached.latest,
fetch: tokio::spawn(registry::latest_version(FETCH_TIMEOUT)),
});
check
}
pub async fn finish(self) {
let Some(pending) = self.pending else {
return;
};
let found = match tokio::time::timeout(TAIL_WAIT, pending.fetch).await {
Ok(Ok(Some(latest))) => Some(latest),
_ => None,
};
let latest = found.unwrap_or(pending.known);
remember(&latest);
let Some(newer) = version::newer(self.current, &latest) else {
return;
};
if !self.announced && !self.quiet {
announce(self.current, newer);
}
replace_this_binary(newer, self.quiet).await;
}
}
pub async fn upgrade(current: &'static str, check_only: bool) -> Result<()> {
let Some(latest) = registry::latest_version(ASKED_TIMEOUT).await else {
bail!("no se ha podido consultar crates.io");
};
remember(&latest);
let Some(newer) = version::newer(current, &latest) else {
println!(
" {} fundaia {current} ya es la versión más reciente.",
theme::CHECK.style(theme::success()),
);
return Ok(());
};
println!(
" Hay una versión nueva: {current} {} {}",
theme::ARROW,
newer.style(theme::strong()),
);
if check_only {
return Ok(());
}
let installer = SelfInstaller::prepare(newer)?;
installer.install(&OnStdout).await?;
println!(
" {} Instalada en {}",
theme::CHECK.style(theme::success()),
installer.destination().display().style(theme::muted()),
);
Ok(())
}
async fn replace_this_binary(version: &str, quiet: bool) {
if switched_off(NO_SELF_UPDATE) || running_in_ci() {
if !quiet {
eprintln!(" {}", manual_route().style(theme::muted()));
}
return;
}
let installed = match SelfInstaller::prepare(version) {
Ok(installer) if quiet => installer.install(&Silently).await,
Ok(installer) => installer.install(&OnStderr).await,
Err(error) => Err(error),
};
if quiet {
return;
}
match installed {
Ok(()) => eprintln!(
" {} {}",
theme::CHECK.style(theme::success()),
format!("Actualizado. La siguiente orden ya usa {version}.").style(theme::muted()),
),
Err(error) => {
eprintln!(
" {}",
format!("no se ha podido actualizar: {error}").style(theme::muted())
);
eprintln!(" {}", manual_route().style(theme::muted()));
}
}
}
fn running_in_ci() -> bool {
truthy_env("CI")
}
fn manual_route() -> &'static str {
"Actualiza con `cargo install fundaia`."
}
fn announce(current: &str, latest: &str) {
eprintln!(
" {} Hay una versión nueva de fundaia: {current} {} {latest}",
"!".style(theme::warning()),
theme::ARROW,
);
}
fn remember(latest: &str) {
let Some(path) = cache_path() else {
return;
};
let _ = Cache::write(
&path,
&Cache {
checked_at: now_seconds(),
latest: latest.to_owned(),
},
);
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct Cache {
#[serde(default)]
checked_at: u64,
#[serde(default)]
latest: String,
}
impl Cache {
fn read(path: &Path) -> Self {
std::fs::read_to_string(path)
.ok()
.and_then(|text| toml::from_str(&text).ok())
.unwrap_or_default()
}
fn write(path: &Path, cache: &Self) -> Option<()> {
let directory = path.parent()?;
std::fs::create_dir_all(directory).ok()?;
std::fs::write(path, toml::to_string(cache).ok()?).ok()
}
}
fn cache_path() -> Option<PathBuf> {
Some(beside(&config_path().ok()?))
}
fn beside(config: &Path) -> PathBuf {
config.with_file_name("update-check.toml")
}
fn now_seconds() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|since| since.as_secs())
.unwrap_or(0)
}
fn is_fresh(checked_at: u64, now: u64) -> bool {
checked_at != 0 && now.saturating_sub(checked_at) < CACHE_LIFETIME.as_secs()
}
fn switched_off(variable: &str) -> bool {
truthy_env(variable)
}
fn truthy_env(variable: &str) -> bool {
std::env::var(variable).is_ok_and(|value| silences(&value))
}
fn silences(value: &str) -> bool {
let value = value.trim();
!value.is_empty() && value != "0"
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch(name: &str) -> PathBuf {
std::env::temp_dir().join(format!("fundaia-update-{name}.toml"))
}
#[test]
fn it_should_draw_a_bar_when_the_server_declared_a_size() {
assert_eq!(download_template(Some(4_000_000)), MEASURED);
}
#[test]
fn it_should_count_rather_than_fill_when_no_size_arrived() {
assert_eq!(download_template(None), UNMEASURED);
}
#[test]
fn it_should_write_a_measured_download_in_a_template_indicatif_accepts() {
assert!(ProgressStyle::with_template(MEASURED).is_ok());
}
#[test]
fn it_should_write_an_unmeasured_download_in_a_template_indicatif_accepts() {
assert!(ProgressStyle::with_template(UNMEASURED).is_ok());
}
#[test]
fn it_should_write_the_compile_spinner_in_a_template_indicatif_accepts() {
assert!(ProgressStyle::with_template(SPINNER).is_ok());
}
#[test]
fn it_should_say_nothing_about_an_estimate_under_a_second() {
assert_eq!(eta_words(0), "");
}
#[test]
fn it_should_count_one_second_in_the_singular() {
assert_eq!(eta_words(1), " · falta 1s");
}
#[test]
fn it_should_count_the_seconds_of_a_slow_connection() {
assert_eq!(eta_words(42), " · faltan 42s");
}
#[test]
fn it_should_reach_for_minutes_once_there_are_enough_seconds() {
assert_eq!(eta_words(154), " · faltan 2m 34s");
}
#[test]
fn it_should_treat_a_stamp_from_a_minute_ago_as_fresh() {
assert!(is_fresh(1_000_000, 1_000_060));
}
#[test]
fn it_should_treat_a_stamp_from_two_days_ago_as_stale() {
assert!(!is_fresh(1_000_000, 1_000_000 + 2 * 24 * 60 * 60));
}
#[test]
fn it_should_treat_a_missing_stamp_as_stale() {
assert!(!is_fresh(0, 1_000_000));
}
#[test]
fn it_should_treat_a_stamp_from_the_future_as_fresh() {
assert!(is_fresh(2_000_000, 1_000_000));
}
#[test]
fn it_should_remember_the_version_it_last_found() {
let path = scratch("remembers");
Cache::write(
&path,
&Cache {
checked_at: 42,
latest: "9.9.9".to_owned(),
},
)
.expect("the temporary directory is writable");
let read = Cache::read(&path);
let _ = std::fs::remove_file(&path);
assert_eq!(read.latest, "9.9.9");
}
#[test]
fn it_should_remember_when_it_last_asked() {
let path = scratch("stamps");
Cache::write(
&path,
&Cache {
checked_at: 42,
latest: String::new(),
},
)
.expect("the temporary directory is writable");
let read = Cache::read(&path);
let _ = std::fs::remove_file(&path);
assert_eq!(read.checked_at, 42);
}
#[test]
fn it_should_treat_a_cache_that_is_not_there_as_empty() {
assert_eq!(Cache::read(&scratch("never-written")).checked_at, 0);
}
#[test]
fn it_should_treat_a_corrupt_cache_as_empty() {
let path = scratch("corrupt");
std::fs::write(&path, "this is not toml {{").expect("the temporary directory is writable");
let read = Cache::read(&path);
let _ = std::fs::remove_file(&path);
assert_eq!(read.checked_at, 0);
}
#[test]
fn it_should_be_silenced_by_the_environment_variable() {
assert!(silences("1"));
}
#[test]
fn it_should_read_a_true_as_on() {
assert!(silences("true"));
}
#[test]
fn it_should_not_be_silenced_by_an_empty_value() {
assert!(!silences(""));
}
#[test]
fn it_should_not_be_silenced_by_a_zero() {
assert!(!silences("0"));
}
#[test]
fn it_should_keep_the_cache_beside_the_profiles() {
assert_eq!(
beside(Path::new("/somewhere/fundaia/config.toml")),
PathBuf::from("/somewhere/fundaia/update-check.toml"),
);
}
}