mod install;
mod registry;
mod version;
use std::io::IsTerminal;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use anyhow::{bail, Result};
use owo_colors::OwoColorize;
use serde::{Deserialize, Serialize};
use tokio::task::JoinHandle;
use crate::config::config_path;
use crate::render::theme;
use install::{Progress, SelfInstaller};
struct OnStderr;
struct OnStdout;
impl Progress for OnStderr {
fn say(&self, message: &str) {
eprintln!(" {}", message.style(theme::muted()));
}
}
impl Progress for OnStdout {
fn say(&self, message: &str) {
println!(" {}", message.style(theme::muted()));
}
}
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,
announced: bool,
pending: Option<Pending>,
}
struct Pending {
known: String,
fetch: JoinHandle<Option<String>>,
}
impl UpdateCheck {
pub fn start(current: &'static str) -> Self {
let mut check = Self {
current,
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) {
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 {
announce(self.current, newer);
}
replace_this_binary(newer).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) {
if switched_off(NO_SELF_UPDATE) || !std::io::stderr().is_terminal() {
eprintln!(" {}", manual_route().style(theme::muted()));
return;
}
let installed = match SelfInstaller::prepare(version) {
Ok(installer) => installer.install(&OnStderr).await,
Err(error) => Err(error),
};
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 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 {
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_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_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"),
);
}
}