1use std::path::PathBuf;
25use std::time::{SystemTime, UNIX_EPOCH};
26
27use linkme::distributed_slice;
28use miette::{miette, IntoDiagnostic};
29use rtb_app::app::App;
30use rtb_app::command::{PreRunFuture, BUILTIN_PRERUN_HOOKS};
31
32use crate::options::RunOptions;
33use crate::policy::{evaluate, PolicyDecision, UpdatePolicy};
34use crate::updater::Updater;
35
36#[distributed_slice(BUILTIN_PRERUN_HOOKS)]
39static UPDATE_POLICY_HOOK: fn(App) -> PreRunFuture = |app| Box::pin(run(app));
40
41fn current_unix() -> i64 {
44 SystemTime::now()
45 .duration_since(UNIX_EPOCH)
46 .map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX))
47}
48
49fn state_path(app: &App) -> miette::Result<PathBuf> {
52 let dirs = directories::ProjectDirs::from("dev", "", &app.metadata.name)
53 .ok_or_else(|| miette!("update: could not resolve a config directory for state"))?;
54 Ok(dirs.config_dir().join("update.toml"))
55}
56
57async fn run(app: App) -> miette::Result<()> {
59 let policy = app.metadata.update_policy;
60 if policy == UpdatePolicy::Disabled || app.metadata.release_source.is_none() {
61 return Ok(());
62 }
63
64 let interval = app.metadata.update_check_interval;
65 let path = state_path(&app)?;
66 let now = current_unix();
67 let current = app.version.version.clone();
68
69 let provider = match crate::command::build_provider(&app).await {
70 Ok(p) => p,
71 Err(e) if policy == UpdatePolicy::Prompt => {
75 tracing::debug!(error = %e, "update policy: provider unavailable; skipping check");
76 return Ok(());
77 }
78 Err(e) => return Err(e),
79 };
80
81 let decision = match evaluate(&*provider, ¤t, policy, interval, &path, now).await {
82 Ok(d) => d,
83 Err(e) if policy == UpdatePolicy::Prompt => {
84 tracing::debug!(error = %e, "update policy: check failed; skipping");
85 return Ok(());
86 }
87 Err(e) => return Err(e).into_diagnostic(),
88 };
89
90 let PolicyDecision::UpdateAvailable { current, latest, .. } = decision else {
91 return Ok(());
92 };
93
94 match policy {
95 UpdatePolicy::Prompt => {
96 eprintln!("{}: a newer version is available: {current} -> {latest}", app.metadata.name);
97 eprintln!(" run `{} update run` to install", app.metadata.name);
98 Ok(())
99 }
100 UpdatePolicy::Enabled => {
101 eprintln!("{}: updating {current} -> {latest} before running…", app.metadata.name);
102 let updater = Updater::builder().app(&app).provider(provider).build();
103 updater.run(RunOptions::default()).await.into_diagnostic()?;
104 Ok(())
105 }
106 UpdatePolicy::Disabled => Ok(()),
108 }
109}
110
111#[cfg(test)]
112mod tests {
113 use super::{current_unix, run, state_path};
114 use rtb_app::app::App;
115 use rtb_app::metadata::{ToolMetadata, UpdatePolicy};
116 use rtb_app::version::VersionInfo;
117
118 fn app_with(policy: UpdatePolicy, with_source: bool) -> App {
119 let source = with_source.then(|| rtb_app::metadata::ReleaseSource::Gitlab {
120 project: "owner/repo".into(),
121 host: "gitlab.com".into(),
122 });
123 let metadata = ToolMetadata::builder()
124 .name("hooktool")
125 .summary("s")
126 .update_policy(policy)
127 .maybe_release_source(source)
128 .build();
129 let version = VersionInfo {
130 version: semver::Version::parse("1.0.0").unwrap(),
131 commit: None,
132 date: None,
133 };
134 App::for_testing(metadata, version)
135 }
136
137 #[tokio::test]
138 async fn disabled_is_a_noop() {
139 run(app_with(UpdatePolicy::Disabled, true)).await.expect("disabled is inert");
141 }
142
143 #[tokio::test]
144 async fn no_release_source_is_a_noop() {
145 run(app_with(UpdatePolicy::Prompt, false)).await.expect("no source is inert");
147 }
148
149 #[test]
150 fn state_path_is_beside_consent() {
151 let path = state_path(&app_with(UpdatePolicy::Prompt, true)).expect("state path");
152 assert!(path.ends_with("update.toml"));
153 assert!(path.to_string_lossy().contains("hooktool"));
154 }
155
156 #[test]
157 fn current_unix_is_positive() {
158 assert!(current_unix() > 0);
159 }
160}