1use crate::commands::{Commands, TaskCommands};
2use crate::{config::Config, version};
3use anyhow::{Context, Result, anyhow, bail};
4use clap::ValueEnum;
5use semver::Version;
6use serde::{Deserialize, Serialize};
7use std::path::{Path, PathBuf};
8use std::time::{Duration, SystemTime, UNIX_EPOCH};
9use tokio::fs;
10
11mod github;
12mod upgrade;
13
14use github::{current_auth_mode, fetch_latest_release_for_version_check};
15pub use upgrade::upgrade_to_release;
16
17const GITHUB_LATEST_RELEASE_URL: &str =
18 "https://api.github.com/repos/biulight/shine/releases/latest";
19const GITHUB_PREVIEW_RELEASE_URL: &str =
20 "https://api.github.com/repos/biulight/shine/releases/tags/preview";
21const UPDATE_CACHE_FILE: &str = "update-check.json";
22const UPDATE_CACHE_TTL: Duration = Duration::from_secs(24 * 60 * 60);
23
24#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)]
25pub enum ReleaseChannel {
26 Stable,
27 Preview,
28}
29
30impl ReleaseChannel {
31 pub fn as_str(self) -> &'static str {
32 match self {
33 Self::Stable => "stable",
34 Self::Preview => "preview",
35 }
36 }
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum UpdateStatus {
41 UpToDate,
42 UpdateAvailable { latest: Version },
43 UpdateRequired { latest: Version },
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub enum UpgradeResult {
48 AlreadyUpToDate {
49 channel: ReleaseChannel,
50 latest: String,
51 },
52 Upgraded {
53 channel: ReleaseChannel,
54 previous: Version,
55 previous_display: String,
56 release_tag: String,
57 installed_version: String,
58 installed_path: PathBuf,
59 },
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
63struct UpdateCache {
64 #[serde(default, skip_serializing_if = "Option::is_none")]
65 latest_version: Option<String>,
66 #[serde(default, skip_serializing_if = "Option::is_none")]
67 checked_at_unix_secs: Option<u64>,
68 #[serde(default, skip_serializing_if = "Option::is_none")]
69 rate_limited_until_unix_secs: Option<u64>,
70 #[serde(default, skip_serializing_if = "Option::is_none")]
71 rate_limited_auth_mode: Option<AuthMode>,
72}
73
74#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
75#[serde(rename_all = "lowercase")]
76enum AuthMode {
77 Anonymous,
78 Token,
79}
80
81pub async fn check_for_update_forced(config: &Config) -> Result<UpdateStatus> {
83 let current = Version::parse(env!("CARGO_PKG_VERSION"))
84 .context("current package version must be valid semver")?;
85 let now_secs = unix_timestamp_now()?;
86 let cache_path = config.shine_dir().join(UPDATE_CACHE_FILE);
87
88 guard_rate_limit_cooldown(&cache_path, now_secs, current_auth_mode()).await?;
89 let release = fetch_latest_release_for_version_check(&cache_path, now_secs).await?;
90 let latest = parse_release_tag(&release.tag_name)?;
91 store_cache_if_possible(&cache_path, &latest, now_secs).await;
92
93 Ok(compare_versions(¤t, &latest))
94}
95
96pub async fn check_for_update(config: &Config) -> Result<UpdateStatus> {
97 let current = Version::parse(env!("CARGO_PKG_VERSION"))
98 .context("current package version must be valid semver")?;
99 let now_secs = unix_timestamp_now()?;
100 let cache_path = config.shine_dir().join(UPDATE_CACHE_FILE);
101
102 let latest = match load_cached_version_if_fresh(&cache_path, now_secs).await? {
103 Some(version) => version,
104 None => {
105 guard_rate_limit_cooldown(&cache_path, now_secs, current_auth_mode()).await?;
106 let release = fetch_latest_release_for_version_check(&cache_path, now_secs).await?;
107 let fetched = parse_release_tag(&release.tag_name)?;
108 store_cache_if_possible(&cache_path, &fetched, now_secs).await;
109 fetched
110 }
111 };
112
113 Ok(compare_versions(¤t, &latest))
114}
115
116pub async fn maybe_notify(config: &Config, command: &Commands) -> Result<()> {
125 let skip_background_update_check = matches!(
129 command,
130 Commands::Update(..)
131 | Commands::Preset { .. }
132 | Commands::State { .. }
133 | Commands::Self_ { .. }
134 | Commands::Serve { .. }
135 | Commands::Env { .. }
136 | Commands::Run(..)
137 ) || matches!(command, Commands::Upgrade(cmd) if cmd.pull)
138 || matches!(
139 command,
140 Commands::Task {
141 command: TaskCommands::Run(..)
142 }
143 );
144 if !skip_background_update_check {
145 match check_for_update(config).await {
146 Ok(UpdateStatus::UpToDate) => {}
147 Ok(UpdateStatus::UpdateAvailable { latest }) => {
148 eprintln!(
149 "A newer version of shine is available: {} -> {}. Run `shine self upgrade` when convenient.",
150 version::semver(),
151 latest
152 );
153 }
154 Ok(UpdateStatus::UpdateRequired { latest }) => {
155 bail!(
156 "A newer patch release of shine is required: {} -> {}. Run `shine self upgrade` before continuing.",
157 version::semver(),
158 latest
159 );
160 }
161 Err(_) => {}
162 }
163 }
164 Ok(())
165}
166
167fn compare_versions(current: &Version, latest: &Version) -> UpdateStatus {
168 if latest <= current {
169 return UpdateStatus::UpToDate;
170 }
171
172 if current.major == latest.major && current.minor == latest.minor {
173 return UpdateStatus::UpdateRequired {
174 latest: latest.clone(),
175 };
176 }
177
178 UpdateStatus::UpdateAvailable {
179 latest: latest.clone(),
180 }
181}
182
183fn parse_release_tag(tag_name: &str) -> Result<Version> {
184 let normalized = tag_name.trim().trim_start_matches('v');
185 let version = Version::parse(normalized)
186 .with_context(|| format!("invalid release tag version: {tag_name}"))?;
187
188 if !version.pre.is_empty() {
189 return Err(anyhow!(
190 "pre-release tags are not eligible for update checks"
191 ));
192 }
193
194 Ok(version)
195}
196
197async fn load_cached_version_if_fresh(cache_path: &Path, now_secs: u64) -> Result<Option<Version>> {
198 let cache = match fs::read_to_string(cache_path).await {
199 Ok(content) => serde_json::from_str::<UpdateCache>(&content).ok(),
200 Err(err) if err.kind() == std::io::ErrorKind::NotFound => None,
201 Err(err) => return Err(err).context("failed to read update cache"),
202 };
203
204 let Some(cache) = cache else {
205 return Ok(None);
206 };
207
208 let Some(checked_at_unix_secs) = cache.checked_at_unix_secs else {
209 return Ok(None);
210 };
211 let Some(latest_version) = cache.latest_version else {
212 return Ok(None);
213 };
214
215 if checked_at_unix_secs > now_secs {
216 return Ok(None);
217 }
218
219 if now_secs - checked_at_unix_secs >= UPDATE_CACHE_TTL.as_secs() {
220 return Ok(None);
221 }
222
223 Ok(parse_release_tag(&latest_version).ok())
224}
225
226async fn store_cache(cache_path: &Path, latest: &Version, checked_at_unix_secs: u64) -> Result<()> {
227 let cache = UpdateCache {
228 latest_version: Some(latest.to_string()),
229 checked_at_unix_secs: Some(checked_at_unix_secs),
230 rate_limited_until_unix_secs: None,
231 rate_limited_auth_mode: None,
232 };
233 write_cache(cache_path, &cache).await
234}
235
236async fn store_rate_limit_cache(
237 cache_path: &Path,
238 rate_limited_until_unix_secs: u64,
239 auth_mode: AuthMode,
240) -> Result<()> {
241 let mut cache = load_cache(cache_path).await?.unwrap_or(UpdateCache {
242 latest_version: None,
243 checked_at_unix_secs: None,
244 rate_limited_until_unix_secs: None,
245 rate_limited_auth_mode: None,
246 });
247 cache.rate_limited_until_unix_secs = Some(rate_limited_until_unix_secs);
248 cache.rate_limited_auth_mode = Some(auth_mode);
249 write_cache(cache_path, &cache).await
250}
251
252async fn write_cache(cache_path: &Path, cache: &UpdateCache) -> Result<()> {
253 if let Some(parent) = cache_path.parent() {
254 fs::create_dir_all(parent)
255 .await
256 .with_context(|| format!("failed to create update cache dir {}", parent.display()))?;
257 }
258
259 let encoded = serde_json::to_vec_pretty(&cache).context("failed to serialize update cache")?;
260 fs::write(cache_path, encoded)
261 .await
262 .context("failed to write update cache")?;
263 Ok(())
264}
265
266async fn store_cache_if_possible(cache_path: &Path, latest: &Version, checked_at_unix_secs: u64) {
267 if let Err(e) = store_cache(cache_path, latest, checked_at_unix_secs).await {
268 eprintln!("warning: failed to write update cache: {e:#}");
269 }
270}
271
272async fn store_rate_limit_cache_if_possible(
273 cache_path: &Path,
274 rate_limited_until_unix_secs: u64,
275 auth_mode: AuthMode,
276) {
277 if let Err(e) =
278 store_rate_limit_cache(cache_path, rate_limited_until_unix_secs, auth_mode).await
279 {
280 eprintln!("warning: failed to write update rate-limit cache: {e:#}");
281 }
282}
283
284async fn load_cache(cache_path: &Path) -> Result<Option<UpdateCache>> {
285 match fs::read_to_string(cache_path).await {
286 Ok(content) => Ok(serde_json::from_str::<UpdateCache>(&content).ok()),
287 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
288 Err(err) => Err(err).context("failed to read update cache"),
289 }
290}
291
292async fn guard_rate_limit_cooldown(
293 cache_path: &Path,
294 now_secs: u64,
295 auth_mode: AuthMode,
296) -> Result<()> {
297 let Some(cache) = load_cache(cache_path).await? else {
298 return Ok(());
299 };
300 let Some(rate_limited_until) = cache.rate_limited_until_unix_secs else {
301 return Ok(());
302 };
303 let Some(rate_limited_auth_mode) = cache.rate_limited_auth_mode else {
304 return Ok(());
305 };
306
307 if rate_limited_auth_mode == auth_mode && rate_limited_until > now_secs {
308 bail!(
309 "GitHub version check skipped until Unix timestamp {rate_limited_until} due to rate limiting"
310 );
311 }
312
313 Ok(())
314}
315
316pub async fn invalidate_update_cache(config: &Config) {
319 let cache_path = config.shine_dir().join(UPDATE_CACHE_FILE);
320 if let Err(e) = fs::remove_file(&cache_path).await
321 && e.kind() != std::io::ErrorKind::NotFound
322 {
323 eprintln!("warning: failed to remove update cache: {e:#}");
324 }
325}
326
327fn unix_timestamp_now() -> Result<u64> {
328 Ok(SystemTime::now()
329 .duration_since(UNIX_EPOCH)
330 .context("system clock is before unix epoch")?
331 .as_secs())
332}
333
334#[cfg(test)]
335mod tests {
336 use super::*;
337 use std::path::PathBuf;
338
339 async fn make_temp_dir() -> PathBuf {
340 crate::test_support::make_temp_dir("shine-update-check").await
341 }
342
343 #[test]
344 fn compare_versions_is_up_to_date_when_latest_is_not_newer() {
345 let current = Version::parse("0.2.0").unwrap();
346 let latest = Version::parse("0.2.0").unwrap();
347
348 assert_eq!(compare_versions(¤t, &latest), UpdateStatus::UpToDate);
349 }
350
351 #[test]
352 fn compare_versions_requires_update_for_newer_patch_release() {
353 let current = Version::parse("0.2.0").unwrap();
354 let latest = Version::parse("0.2.1").unwrap();
355
356 assert_eq!(
357 compare_versions(¤t, &latest),
358 UpdateStatus::UpdateRequired { latest }
359 );
360 }
361
362 #[test]
363 fn compare_versions_warns_for_newer_minor_release() {
364 let current = Version::parse("0.2.0").unwrap();
365 let latest = Version::parse("0.3.0").unwrap();
366
367 assert_eq!(
368 compare_versions(¤t, &latest),
369 UpdateStatus::UpdateAvailable { latest }
370 );
371 }
372
373 #[test]
374 fn parse_release_tag_accepts_v_prefix() {
375 let version = parse_release_tag("v1.2.3").unwrap();
376 assert_eq!(version, Version::parse("1.2.3").unwrap());
377 }
378
379 #[test]
380 fn parse_release_tag_rejects_prerelease_versions() {
381 assert!(parse_release_tag("v1.2.3-beta.1").is_err());
382 }
383
384 #[tokio::test]
385 async fn load_cached_version_returns_none_when_cache_missing() {
386 let dir = make_temp_dir().await;
387 let cache_path = dir.join(UPDATE_CACHE_FILE);
388
389 let cached = load_cached_version_if_fresh(&cache_path, UPDATE_CACHE_TTL.as_secs())
390 .await
391 .unwrap();
392 assert_eq!(cached, None);
393
394 fs::remove_dir_all(dir).await.unwrap();
395 }
396
397 #[tokio::test]
398 async fn load_cached_version_uses_fresh_cache() {
399 let dir = make_temp_dir().await;
400 let cache_path = dir.join(UPDATE_CACHE_FILE);
401 store_cache(&cache_path, &Version::parse("0.2.3").unwrap(), 1_000)
402 .await
403 .unwrap();
404
405 let cached =
406 load_cached_version_if_fresh(&cache_path, 1_000 + UPDATE_CACHE_TTL.as_secs() - 1)
407 .await
408 .unwrap();
409 assert_eq!(cached, Some(Version::parse("0.2.3").unwrap()));
410
411 fs::remove_dir_all(dir).await.unwrap();
412 }
413
414 #[tokio::test]
415 async fn load_cached_version_supports_legacy_cache_shape() {
416 let dir = make_temp_dir().await;
417 let cache_path = dir.join(UPDATE_CACHE_FILE);
418 fs::write(
419 &cache_path,
420 br#"{"latest_version":"0.2.3","checked_at_unix_secs":1000}"#,
421 )
422 .await
423 .unwrap();
424
425 let cached = load_cached_version_if_fresh(&cache_path, 1_001)
426 .await
427 .unwrap();
428 assert_eq!(cached, Some(Version::parse("0.2.3").unwrap()));
429
430 fs::remove_dir_all(dir).await.unwrap();
431 }
432
433 #[tokio::test]
434 async fn load_cached_version_ignores_stale_cache() {
435 let dir = make_temp_dir().await;
436 let cache_path = dir.join(UPDATE_CACHE_FILE);
437 store_cache(&cache_path, &Version::parse("0.2.3").unwrap(), 1_000)
438 .await
439 .unwrap();
440
441 let cached = load_cached_version_if_fresh(&cache_path, 1_000 + UPDATE_CACHE_TTL.as_secs())
442 .await
443 .unwrap();
444 assert_eq!(cached, None);
445
446 fs::remove_dir_all(dir).await.unwrap();
447 }
448
449 #[tokio::test]
450 async fn load_cached_version_ignores_invalid_cache_contents() {
451 let dir = make_temp_dir().await;
452 let cache_path = dir.join(UPDATE_CACHE_FILE);
453 fs::write(&cache_path, b"{not valid json").await.unwrap();
454
455 let cached = load_cached_version_if_fresh(&cache_path, UPDATE_CACHE_TTL.as_secs())
456 .await
457 .unwrap();
458 assert_eq!(cached, None);
459
460 fs::remove_dir_all(dir).await.unwrap();
461 }
462
463 #[tokio::test]
464 async fn store_cache_creates_missing_parent_directory() {
465 let dir = make_temp_dir().await;
466 let cache_path = dir.join("nested").join(UPDATE_CACHE_FILE);
467
468 store_cache(&cache_path, &Version::parse("0.2.3").unwrap(), 1_000)
469 .await
470 .unwrap();
471
472 let cached = load_cached_version_if_fresh(&cache_path, 1_000 + 1)
473 .await
474 .unwrap();
475 assert_eq!(cached, Some(Version::parse("0.2.3").unwrap()));
476
477 fs::remove_dir_all(dir).await.unwrap();
478 }
479
480 #[tokio::test]
481 async fn rate_limit_cooldown_skips_same_auth_mode() {
482 let dir = make_temp_dir().await;
483 let cache_path = dir.join(UPDATE_CACHE_FILE);
484 store_rate_limit_cache(&cache_path, 2_000, AuthMode::Anonymous)
485 .await
486 .unwrap();
487
488 let err = guard_rate_limit_cooldown(&cache_path, 1_000, AuthMode::Anonymous)
489 .await
490 .unwrap_err();
491 assert!(
492 err.to_string()
493 .contains("GitHub version check skipped until Unix timestamp 2000")
494 );
495
496 fs::remove_dir_all(dir).await.unwrap();
497 }
498
499 #[tokio::test]
500 async fn rate_limit_cooldown_allows_changed_auth_mode() {
501 let dir = make_temp_dir().await;
502 let cache_path = dir.join(UPDATE_CACHE_FILE);
503 store_rate_limit_cache(&cache_path, 2_000, AuthMode::Anonymous)
504 .await
505 .unwrap();
506
507 guard_rate_limit_cooldown(&cache_path, 1_000, AuthMode::Token)
508 .await
509 .unwrap();
510
511 fs::remove_dir_all(dir).await.unwrap();
512 }
513
514 #[tokio::test]
515 async fn rate_limit_cooldown_allows_expired_reset() {
516 let dir = make_temp_dir().await;
517 let cache_path = dir.join(UPDATE_CACHE_FILE);
518 store_rate_limit_cache(&cache_path, 2_000, AuthMode::Token)
519 .await
520 .unwrap();
521
522 guard_rate_limit_cooldown(&cache_path, 2_001, AuthMode::Token)
523 .await
524 .unwrap();
525
526 fs::remove_dir_all(dir).await.unwrap();
527 }
528
529 #[tokio::test]
530 async fn successful_cache_write_clears_rate_limit_cooldown() {
531 let dir = make_temp_dir().await;
532 let cache_path = dir.join(UPDATE_CACHE_FILE);
533 store_rate_limit_cache(&cache_path, 2_000, AuthMode::Anonymous)
534 .await
535 .unwrap();
536 store_cache(&cache_path, &Version::parse("0.2.3").unwrap(), 1_000)
537 .await
538 .unwrap();
539
540 let cache = load_cache(&cache_path).await.unwrap().unwrap();
541 assert_eq!(cache.latest_version.as_deref(), Some("0.2.3"));
542 assert_eq!(cache.rate_limited_until_unix_secs, None);
543 assert_eq!(cache.rate_limited_auth_mode, None);
544
545 fs::remove_dir_all(dir).await.unwrap();
546 }
547
548 #[tokio::test]
549 async fn invalidate_update_cache_removes_existing_cache_file() {
550 use crate::config::Config;
551
552 let dir = make_temp_dir().await;
553 let config = Config::new_for_test(&dir);
554 let cache_path = dir.join(UPDATE_CACHE_FILE);
555
556 store_cache(&cache_path, &Version::parse("0.2.3").unwrap(), 1_000)
557 .await
558 .unwrap();
559 assert!(
560 cache_path.exists(),
561 "cache file should exist before invalidation"
562 );
563
564 invalidate_update_cache(&config).await;
565 assert!(
566 !cache_path.exists(),
567 "cache file should be removed after invalidation"
568 );
569
570 fs::remove_dir_all(dir).await.unwrap();
571 }
572
573 #[tokio::test]
574 async fn invalidate_update_cache_is_a_no_op_when_cache_absent() {
575 use crate::config::Config;
576
577 let dir = make_temp_dir().await;
578 let config = Config::new_for_test(&dir);
579
580 invalidate_update_cache(&config).await;
582
583 fs::remove_dir_all(dir).await.unwrap();
584 }
585}