use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use linkme::distributed_slice;
use miette::{miette, IntoDiagnostic};
use rtb_app::app::App;
use rtb_app::command::{PreRunFuture, BUILTIN_PRERUN_HOOKS};
use crate::options::RunOptions;
use crate::policy::{evaluate, PolicyDecision, UpdatePolicy};
use crate::updater::Updater;
#[distributed_slice(BUILTIN_PRERUN_HOOKS)]
static UPDATE_POLICY_HOOK: fn(App) -> PreRunFuture = |app| Box::pin(run(app));
fn current_unix() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX))
}
fn state_path(app: &App) -> miette::Result<PathBuf> {
let dirs = directories::ProjectDirs::from("dev", "", &app.metadata.name)
.ok_or_else(|| miette!("update: could not resolve a config directory for state"))?;
Ok(dirs.config_dir().join("update.toml"))
}
async fn run(app: App) -> miette::Result<()> {
let policy = app.metadata.update_policy;
if policy == UpdatePolicy::Disabled || app.metadata.release_source.is_none() {
return Ok(());
}
let interval = app.metadata.update_check_interval;
let path = state_path(&app)?;
let now = current_unix();
let current = app.version.version.clone();
let provider = match crate::command::build_provider(&app).await {
Ok(p) => p,
Err(e) if policy == UpdatePolicy::Prompt => {
tracing::debug!(error = %e, "update policy: provider unavailable; skipping check");
return Ok(());
}
Err(e) => return Err(e),
};
let decision = match evaluate(&*provider, ¤t, policy, interval, &path, now).await {
Ok(d) => d,
Err(e) if policy == UpdatePolicy::Prompt => {
tracing::debug!(error = %e, "update policy: check failed; skipping");
return Ok(());
}
Err(e) => return Err(e).into_diagnostic(),
};
let PolicyDecision::UpdateAvailable { current, latest, .. } = decision else {
return Ok(());
};
match policy {
UpdatePolicy::Prompt => {
eprintln!("{}: a newer version is available: {current} -> {latest}", app.metadata.name);
eprintln!(" run `{} update run` to install", app.metadata.name);
Ok(())
}
UpdatePolicy::Enabled => {
eprintln!("{}: updating {current} -> {latest} before running…", app.metadata.name);
let updater = Updater::builder().app(&app).provider(provider).build();
updater.run(RunOptions::default()).await.into_diagnostic()?;
Ok(())
}
UpdatePolicy::Disabled => Ok(()),
}
}
#[cfg(test)]
mod tests {
use super::{current_unix, run, state_path};
use rtb_app::app::App;
use rtb_app::metadata::{ToolMetadata, UpdatePolicy};
use rtb_app::version::VersionInfo;
fn app_with(policy: UpdatePolicy, with_source: bool) -> App {
let source = with_source.then(|| rtb_app::metadata::ReleaseSource::Gitlab {
project: "owner/repo".into(),
host: "gitlab.com".into(),
});
let metadata = ToolMetadata::builder()
.name("hooktool")
.summary("s")
.update_policy(policy)
.maybe_release_source(source)
.build();
let version = VersionInfo {
version: semver::Version::parse("1.0.0").unwrap(),
commit: None,
date: None,
};
App::for_testing(metadata, version)
}
#[tokio::test]
async fn disabled_is_a_noop() {
run(app_with(UpdatePolicy::Disabled, true)).await.expect("disabled is inert");
}
#[tokio::test]
async fn no_release_source_is_a_noop() {
run(app_with(UpdatePolicy::Prompt, false)).await.expect("no source is inert");
}
#[test]
fn state_path_is_beside_consent() {
let path = state_path(&app_with(UpdatePolicy::Prompt, true)).expect("state path");
assert!(path.ends_with("update.toml"));
assert!(path.to_string_lossy().contains("hooktool"));
}
#[test]
fn current_unix_is_positive() {
assert!(current_unix() > 0);
}
}