1use std::{
2 cmp::Ordering,
3 error::Error,
4 fmt,
5 path::Path,
6 time::{Duration, SystemTime, UNIX_EPOCH},
7};
8
9use reqwest::{StatusCode, header};
10use serde::{Deserialize, Serialize, de::DeserializeOwned};
11
12use crate::{
13 env::{RELAY_KNOWLEDGE_UPDATE_GITHUB_REPO, RELAY_KNOWLEDGE_UPDATE_SOURCES, UpdateEnvOverrides},
14 net::{NetworkRuntime, http},
15 paths::RuntimePaths,
16 project::{GITHUB_REPOSITORY_FULL_NAME, PROJECT_NAME},
17};
18
19pub const DEFAULT_UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60);
20const VERSION_CHECK_REQUEST_TIMEOUT: Duration = Duration::from_secs(3);
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "kebab-case")]
25pub enum UpdateSource {
26 Github,
27 CratesIo,
28}
29
30impl UpdateSource {
31 pub fn as_str(self) -> &'static str {
32 match self {
33 Self::Github => "github",
34 Self::CratesIo => "crates.io",
35 }
36 }
37
38 fn parse(value: &str) -> Result<Self, UpdateRuntimeConfigError> {
39 match value.trim().to_ascii_lowercase().as_str() {
40 "github" | "github-releases" => Ok(Self::Github),
41 "crates" | "crates.io" | "crates-io" => Ok(Self::CratesIo),
42 other => Err(UpdateRuntimeConfigError::InvalidSource(other.to_owned())),
43 }
44 }
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct UpdateRuntimeConfig {
50 pub enabled: bool,
51 pub sources: Vec<UpdateSource>,
52 pub check_interval: Duration,
53 pub github_repo: String,
54}
55
56impl UpdateRuntimeConfig {
57 pub fn from_environment(
58 overrides: &UpdateEnvOverrides,
59 ) -> Result<Self, UpdateRuntimeConfigError> {
60 let enabled = overrides.enabled.unwrap_or(true);
61 let check_interval = Duration::from_millis(
62 overrides
63 .check_interval_ms
64 .unwrap_or(duration_millis(DEFAULT_UPDATE_CHECK_INTERVAL)),
65 );
66 if !enabled {
67 return Ok(Self {
68 enabled,
69 sources: default_update_sources(),
70 check_interval,
71 github_repo: GITHUB_REPOSITORY_FULL_NAME.to_owned(),
72 });
73 }
74
75 Ok(Self {
76 enabled,
77 sources: parse_update_sources(overrides.sources.as_deref())?,
78 check_interval,
79 github_repo: validate_github_repo(
80 overrides
81 .github_repo
82 .as_deref()
83 .unwrap_or(GITHUB_REPOSITORY_FULL_NAME),
84 )?,
85 })
86 }
87}
88
89#[derive(Debug, Clone, PartialEq, Eq)]
91pub enum UpdateRuntimeConfigError {
92 EmptySourceList,
93 InvalidSource(String),
94 InvalidGithubRepo(String),
95}
96
97impl fmt::Display for UpdateRuntimeConfigError {
98 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
99 match self {
100 Self::EmptySourceList => write!(
101 formatter,
102 "{RELAY_KNOWLEDGE_UPDATE_SOURCES} must include github or crates.io"
103 ),
104 Self::InvalidSource(value) => write!(
105 formatter,
106 "invalid {RELAY_KNOWLEDGE_UPDATE_SOURCES} value '{value}', expected github or crates.io"
107 ),
108 Self::InvalidGithubRepo(value) => write!(
109 formatter,
110 "{RELAY_KNOWLEDGE_UPDATE_GITHUB_REPO} must be owner/name, got '{value}'"
111 ),
112 }
113 }
114}
115
116impl Error for UpdateRuntimeConfigError {}
117
118#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
120pub struct VersionCheckResponse {
121 pub project_name: String,
122 pub current_version: String,
123 pub latest_version: Option<String>,
124 pub update_available: bool,
125 pub source: Option<String>,
126 pub release_url: Option<String>,
127 pub checked_at_unix_ms: u64,
128 pub diagnostics: Vec<VersionCheckDiagnostic>,
129}
130
131#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
133pub struct VersionCheckDiagnostic {
134 pub source: Option<String>,
135 pub code: String,
136 pub message: String,
137 pub retryable: bool,
138}
139
140#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
141struct VersionCheckCache {
142 cache_key: String,
143 response: VersionCheckResponse,
144}
145
146#[derive(Debug, Clone, PartialEq, Eq)]
147struct ReleaseCandidate {
148 source: UpdateSource,
149 version: StableVersion,
150 release_url: String,
151}
152
153#[derive(Debug, Clone, PartialEq, Eq)]
154struct StableVersion {
155 major: u64,
156 minor: u64,
157 patch: u64,
158 prerelease: bool,
159}
160
161impl StableVersion {
162 const fn new(major: u64, minor: u64, patch: u64) -> Self {
163 Self::from_parts(major, minor, patch, false)
164 }
165
166 const fn prerelease(major: u64, minor: u64, patch: u64) -> Self {
167 Self::from_parts(major, minor, patch, true)
168 }
169
170 const fn from_parts(major: u64, minor: u64, patch: u64, prerelease: bool) -> Self {
171 Self {
172 major,
173 minor,
174 patch,
175 prerelease,
176 }
177 }
178}
179
180impl Ord for StableVersion {
181 fn cmp(&self, other: &Self) -> Ordering {
182 (
183 self.major,
184 self.minor,
185 self.patch,
186 release_precedence(self.prerelease),
187 )
188 .cmp(&(
189 other.major,
190 other.minor,
191 other.patch,
192 release_precedence(other.prerelease),
193 ))
194 }
195}
196
197impl PartialOrd for StableVersion {
198 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
199 Some(self.cmp(other))
200 }
201}
202
203impl fmt::Display for StableVersion {
204 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
205 write!(formatter, "{}.{}.{}", self.major, self.minor, self.patch)
206 }
207}
208
209const fn release_precedence(prerelease: bool) -> u8 {
210 if prerelease { 0 } else { 1 }
211}
212
213pub async fn check_for_updates(
214 paths: &RuntimePaths,
215 network: &NetworkRuntime,
216 config: &UpdateRuntimeConfig,
217 force_refresh: bool,
218) -> VersionCheckResponse {
219 let now_ms = current_time_millis();
220 let cache_path = paths.version_check_cache_file();
221 if !force_refresh
222 && let Some(cached) =
223 read_fresh_cache(&cache_path, now_ms, config.check_interval, config).await
224 {
225 return cached;
226 }
227
228 let response = fetch_latest_version(network, config, now_ms).await;
229 let _ = write_cache(&cache_path, &response, config).await;
230 response
231}
232
233pub async fn update_notice(
234 paths: &RuntimePaths,
235 network: &NetworkRuntime,
236 config: &UpdateRuntimeConfig,
237) -> Option<String> {
238 if !config.enabled {
239 return None;
240 }
241 let response = check_for_updates(paths, network, config, false).await;
242 if !response.update_available {
243 return None;
244 }
245
246 Some(format!(
247 "{} {} is available; current {}. Run `relay-knowledge version check` for details.\n",
248 PROJECT_NAME,
249 response
250 .latest_version
251 .unwrap_or_else(|| "unknown".to_owned()),
252 response.current_version
253 ))
254}
255
256async fn fetch_latest_version(
257 network: &NetworkRuntime,
258 config: &UpdateRuntimeConfig,
259 checked_at_unix_ms: u64,
260) -> VersionCheckResponse {
261 let current_version = current_version();
262 let network_config = network.current();
263 let client = match http::outbound_json_client(&network_config.http) {
264 Ok(client) => client,
265 Err(error) => {
266 return response_from_candidates(
267 current_version,
268 Vec::new(),
269 vec![diagnostic(
270 None,
271 "client_build_failed",
272 error.to_string(),
273 false,
274 )],
275 checked_at_unix_ms,
276 );
277 }
278 };
279
280 let mut candidates = Vec::new();
281 let mut diagnostics = Vec::new();
282 let max_response_bytes = network_config.http.max_request_body_bytes;
283 for source in &config.sources {
284 match fetch_source(&client, config, *source, max_response_bytes).await {
285 Ok(candidate) => candidates.push(candidate),
286 Err(diagnostic) => diagnostics.push(diagnostic),
287 }
288 }
289
290 response_from_candidates(current_version, candidates, diagnostics, checked_at_unix_ms)
291}
292
293async fn fetch_source(
294 client: &reqwest::Client,
295 config: &UpdateRuntimeConfig,
296 source: UpdateSource,
297 max_response_bytes: u64,
298) -> Result<ReleaseCandidate, VersionCheckDiagnostic> {
299 match source {
300 UpdateSource::Github => {
301 fetch_github_release(client, &config.github_repo, max_response_bytes).await
302 }
303 UpdateSource::CratesIo => fetch_crates_release(client, max_response_bytes).await,
304 }
305}
306
307async fn fetch_github_release(
308 client: &reqwest::Client,
309 repo: &str,
310 max_response_bytes: u64,
311) -> Result<ReleaseCandidate, VersionCheckDiagnostic> {
312 let url = format!("https://api.github.com/repos/{repo}/releases/latest");
313 let response = send_json_request(client, &url)
314 .await
315 .map_err(|error| transport_diagnostic(UpdateSource::Github, error))?;
316 let status = response.status();
317 if !status.is_success() {
318 return Err(status_diagnostic(UpdateSource::Github, status));
319 }
320
321 let payload = read_json_response::<GithubLatestRelease>(
322 response,
323 UpdateSource::Github,
324 max_response_bytes,
325 )
326 .await?;
327 github_candidate(payload)
328}
329
330async fn fetch_crates_release(
331 client: &reqwest::Client,
332 max_response_bytes: u64,
333) -> Result<ReleaseCandidate, VersionCheckDiagnostic> {
334 let url = format!("https://crates.io/api/v1/crates/{PROJECT_NAME}");
335 let response = send_json_request(client, &url)
336 .await
337 .map_err(|error| transport_diagnostic(UpdateSource::CratesIo, error))?;
338 let status = response.status();
339 if !status.is_success() {
340 return Err(status_diagnostic(UpdateSource::CratesIo, status));
341 }
342
343 let payload = read_json_response::<CratesPackageResponse>(
344 response,
345 UpdateSource::CratesIo,
346 max_response_bytes,
347 )
348 .await?;
349 crates_candidate(payload)
350}
351
352async fn read_json_response<T>(
353 response: reqwest::Response,
354 source: UpdateSource,
355 max_response_bytes: u64,
356) -> Result<T, VersionCheckDiagnostic>
357where
358 T: DeserializeOwned,
359{
360 if response
361 .content_length()
362 .is_some_and(|length| length > max_response_bytes)
363 {
364 return Err(response_body_too_large_diagnostic(
365 source,
366 max_response_bytes,
367 ));
368 }
369
370 let max_response_bytes = max_response_bytes.try_into().unwrap_or(usize::MAX);
371 let mut body = Vec::new();
372 let mut response = response;
373 while let Some(chunk) = response
374 .chunk()
375 .await
376 .map_err(|error| transport_diagnostic(source, error))?
377 {
378 append_limited_response_body(source, &mut body, &chunk, max_response_bytes)?;
379 }
380
381 serde_json::from_slice(&body).map_err(|error| {
382 diagnostic(
383 Some(source),
384 "invalid_response_json",
385 error.to_string(),
386 false,
387 )
388 })
389}
390
391fn append_limited_response_body(
392 source: UpdateSource,
393 body: &mut Vec<u8>,
394 chunk: &[u8],
395 max_response_bytes: usize,
396) -> Result<(), VersionCheckDiagnostic> {
397 let Some(next_len) = body
398 .len()
399 .checked_add(chunk.len())
400 .filter(|next_len| *next_len <= max_response_bytes)
401 else {
402 let max_response_bytes = max_response_bytes.try_into().unwrap_or(u64::MAX);
403 return Err(response_body_too_large_diagnostic(
404 source,
405 max_response_bytes,
406 ));
407 };
408 body.reserve(next_len.saturating_sub(body.len()));
409 body.extend_from_slice(chunk);
410 Ok(())
411}
412
413async fn send_json_request(
414 client: &reqwest::Client,
415 url: &str,
416) -> Result<reqwest::Response, reqwest::Error> {
417 client
418 .get(url)
419 .header(
420 header::USER_AGENT,
421 format!("{PROJECT_NAME}/{}", env!("CARGO_PKG_VERSION")),
422 )
423 .timeout(VERSION_CHECK_REQUEST_TIMEOUT)
424 .send()
425 .await
426}
427
428#[derive(Debug, Deserialize)]
429struct GithubLatestRelease {
430 tag_name: String,
431 html_url: String,
432 prerelease: bool,
433}
434
435fn github_candidate(
436 release: GithubLatestRelease,
437) -> Result<ReleaseCandidate, VersionCheckDiagnostic> {
438 if release.prerelease {
439 return Err(diagnostic(
440 Some(UpdateSource::Github),
441 "prerelease_ignored",
442 format!("GitHub release '{}' is a prerelease", release.tag_name),
443 false,
444 ));
445 }
446 let version = stable_version(&release.tag_name).map_err(|message| {
447 diagnostic(
448 Some(UpdateSource::Github),
449 "invalid_version",
450 message,
451 false,
452 )
453 })?;
454
455 Ok(ReleaseCandidate {
456 source: UpdateSource::Github,
457 version,
458 release_url: release.html_url,
459 })
460}
461
462#[derive(Debug, Deserialize)]
463struct CratesPackageResponse {
464 #[serde(rename = "crate")]
465 package: CratesPackage,
466}
467
468#[derive(Debug, Deserialize)]
469struct CratesPackage {
470 max_stable_version: Option<String>,
471}
472
473fn crates_candidate(
474 response: CratesPackageResponse,
475) -> Result<ReleaseCandidate, VersionCheckDiagnostic> {
476 let Some(max_stable_version) = response.package.max_stable_version else {
477 return Err(diagnostic(
478 Some(UpdateSource::CratesIo),
479 "stable_version_unavailable",
480 "crates.io response did not include a stable release version",
481 false,
482 ));
483 };
484 let version = stable_version(&max_stable_version).map_err(|message| {
485 diagnostic(
486 Some(UpdateSource::CratesIo),
487 "invalid_version",
488 message,
489 false,
490 )
491 })?;
492
493 Ok(ReleaseCandidate {
494 source: UpdateSource::CratesIo,
495 version,
496 release_url: format!("https://crates.io/crates/{PROJECT_NAME}"),
497 })
498}
499
500fn response_from_candidates(
501 current_version: StableVersion,
502 candidates: Vec<ReleaseCandidate>,
503 diagnostics: Vec<VersionCheckDiagnostic>,
504 checked_at_unix_ms: u64,
505) -> VersionCheckResponse {
506 let latest = candidates
507 .into_iter()
508 .max_by(|left, right| left.version.cmp(&right.version));
509 let update_available = latest
510 .as_ref()
511 .is_some_and(|candidate| candidate.version > current_version);
512
513 VersionCheckResponse {
514 project_name: PROJECT_NAME.to_owned(),
515 current_version: env!("CARGO_PKG_VERSION").to_owned(),
516 latest_version: latest
517 .as_ref()
518 .map(|candidate| candidate.version.to_string()),
519 update_available,
520 source: latest
521 .as_ref()
522 .map(|candidate| candidate.source.as_str().to_owned()),
523 release_url: latest
524 .as_ref()
525 .map(|candidate| candidate.release_url.clone()),
526 checked_at_unix_ms,
527 diagnostics,
528 }
529}
530
531fn stable_version(value: &str) -> Result<StableVersion, String> {
532 let trimmed = value.trim().trim_start_matches('v');
533 if trimmed.split('+').next().unwrap_or(trimmed).contains('-') {
534 return Err(format!("release version '{value}' is a prerelease"));
535 }
536 comparable_version(value)
537}
538
539fn comparable_version(value: &str) -> Result<StableVersion, String> {
540 let trimmed = value.trim().trim_start_matches('v');
541 let without_build = trimmed.split('+').next().unwrap_or(trimmed);
542 let prerelease = without_build.contains('-');
543 let core = trimmed
544 .split('+')
545 .next()
546 .unwrap_or(trimmed)
547 .split('-')
548 .next()
549 .unwrap_or(trimmed);
550 let mut parts = core.split('.');
551 let Some(major) = parts.next() else {
552 return Err(format!("release version '{value}' is not semver"));
553 };
554 let Some(minor) = parts.next() else {
555 return Err(format!("release version '{value}' is not semver"));
556 };
557 let Some(patch) = parts.next() else {
558 return Err(format!("release version '{value}' is not semver"));
559 };
560 if parts.next().is_some() {
561 return Err(format!("release version '{value}' is not semver"));
562 }
563
564 let major = parse_version_component(value, major)?;
565 let minor = parse_version_component(value, minor)?;
566 let patch = parse_version_component(value, patch)?;
567 if prerelease {
568 Ok(StableVersion::prerelease(major, minor, patch))
569 } else {
570 Ok(StableVersion::new(major, minor, patch))
571 }
572}
573
574fn parse_version_component(value: &str, component: &str) -> Result<u64, String> {
575 if component.is_empty()
576 || !component
577 .chars()
578 .all(|character| character.is_ascii_digit())
579 {
580 return Err(format!("release version '{value}' is not semver"));
581 }
582
583 component
584 .parse::<u64>()
585 .map_err(|_| format!("release version '{value}' is not semver"))
586}
587
588fn current_version() -> StableVersion {
589 comparable_version(env!("CARGO_PKG_VERSION")).expect("Cargo package version must be semver")
590}
591
592fn diagnostic(
593 source: Option<UpdateSource>,
594 code: impl Into<String>,
595 message: impl Into<String>,
596 retryable: bool,
597) -> VersionCheckDiagnostic {
598 VersionCheckDiagnostic {
599 source: source.map(|value| value.as_str().to_owned()),
600 code: code.into(),
601 message: message.into(),
602 retryable,
603 }
604}
605
606fn transport_diagnostic(source: UpdateSource, error: reqwest::Error) -> VersionCheckDiagnostic {
607 diagnostic(Some(source), "transport_failed", error.to_string(), true)
608}
609
610fn status_diagnostic(source: UpdateSource, status: StatusCode) -> VersionCheckDiagnostic {
611 diagnostic(
612 Some(source),
613 "http_status",
614 format!("release metadata request returned HTTP {}", status.as_u16()),
615 status.is_server_error()
616 || status == StatusCode::REQUEST_TIMEOUT
617 || status == StatusCode::TOO_MANY_REQUESTS,
618 )
619}
620
621fn response_body_too_large_diagnostic(
622 source: UpdateSource,
623 max_response_bytes: u64,
624) -> VersionCheckDiagnostic {
625 diagnostic(
626 Some(source),
627 "response_body_too_large",
628 format!("release metadata response exceeded {max_response_bytes} bytes"),
629 false,
630 )
631}
632
633async fn read_fresh_cache(
634 path: &Path,
635 now_ms: u64,
636 interval: Duration,
637 config: &UpdateRuntimeConfig,
638) -> Option<VersionCheckResponse> {
639 let bytes = tokio::fs::read(path).await.ok()?;
640 let cache = serde_json::from_slice::<VersionCheckCache>(&bytes).ok()?;
641 if cache_is_usable(&cache, now_ms, interval, config) {
642 Some(cache.response)
643 } else {
644 None
645 }
646}
647
648fn cache_is_usable(
649 cache: &VersionCheckCache,
650 now_ms: u64,
651 interval: Duration,
652 config: &UpdateRuntimeConfig,
653) -> bool {
654 cache.cache_key == version_cache_key(config)
655 && cache.response.current_version == env!("CARGO_PKG_VERSION")
656 && cache_is_fresh(&cache.response, now_ms, interval)
657}
658
659fn cache_is_fresh(response: &VersionCheckResponse, now_ms: u64, interval: Duration) -> bool {
660 now_ms
661 .checked_sub(response.checked_at_unix_ms)
662 .is_some_and(|age| age <= duration_millis(interval))
663}
664
665async fn write_cache(
666 path: &Path,
667 response: &VersionCheckResponse,
668 config: &UpdateRuntimeConfig,
669) -> std::io::Result<()> {
670 if let Some(parent) = path.parent() {
671 tokio::fs::create_dir_all(parent).await?;
672 }
673 let cache = VersionCheckCache {
674 cache_key: version_cache_key(config),
675 response: response.clone(),
676 };
677 let bytes = serde_json::to_vec(&cache)?;
678 tokio::fs::write(path, bytes).await
679}
680
681fn parse_update_sources(
682 value: Option<&str>,
683) -> Result<Vec<UpdateSource>, UpdateRuntimeConfigError> {
684 let Some(raw_sources) = value else {
685 return Ok(default_update_sources());
686 };
687 let mut sources = Vec::new();
688 for raw_source in raw_sources.split(',') {
689 let trimmed = raw_source.trim();
690 if trimmed.is_empty() {
691 return Err(UpdateRuntimeConfigError::EmptySourceList);
692 }
693 let source = UpdateSource::parse(trimmed)?;
694 if !sources.contains(&source) {
695 sources.push(source);
696 }
697 }
698 if sources.is_empty() {
699 return Err(UpdateRuntimeConfigError::EmptySourceList);
700 }
701
702 Ok(sources)
703}
704
705fn default_update_sources() -> Vec<UpdateSource> {
706 vec![UpdateSource::Github, UpdateSource::CratesIo]
707}
708
709fn version_cache_key(config: &UpdateRuntimeConfig) -> String {
710 let sources = config
711 .sources
712 .iter()
713 .map(|source| source.as_str())
714 .collect::<Vec<_>>()
715 .join(",");
716 format!("sources={sources};github_repo={}", config.github_repo)
717}
718
719fn validate_github_repo(value: &str) -> Result<String, UpdateRuntimeConfigError> {
720 let trimmed = value.trim();
721 let parts = trimmed.split('/').collect::<Vec<_>>();
722 if parts.len() != 2
723 || parts.iter().any(|part| part.is_empty())
724 || trimmed.contains(char::is_whitespace)
725 {
726 return Err(UpdateRuntimeConfigError::InvalidGithubRepo(
727 value.to_owned(),
728 ));
729 }
730
731 Ok(trimmed.to_owned())
732}
733
734fn current_time_millis() -> u64 {
735 SystemTime::now()
736 .duration_since(UNIX_EPOCH)
737 .unwrap_or_default()
738 .as_millis()
739 .try_into()
740 .unwrap_or(u64::MAX)
741}
742
743fn duration_millis(duration: Duration) -> u64 {
744 duration.as_millis().try_into().unwrap_or(u64::MAX)
745}
746
747#[cfg(test)]
748mod tests {
749 use super::*;
750
751 #[test]
752 fn parses_configured_update_sources_with_aliases_and_deduplication() {
753 let sources =
754 parse_update_sources(Some("github,crates,crates.io")).expect("sources should parse");
755
756 assert_eq!(sources, vec![UpdateSource::Github, UpdateSource::CratesIo]);
757 }
758
759 #[test]
760 fn rejects_empty_update_sources_and_invalid_github_repo() {
761 assert_eq!(
762 parse_update_sources(Some("github,,crates")).expect_err("empty source should fail"),
763 UpdateRuntimeConfigError::EmptySourceList
764 );
765 assert_eq!(
766 validate_github_repo("relay-knowledge").expect_err("repo should require owner"),
767 UpdateRuntimeConfigError::InvalidGithubRepo("relay-knowledge".to_owned())
768 );
769 }
770
771 #[test]
772 fn disabled_update_config_ignores_unused_source_and_repo_overrides() {
773 let config = UpdateRuntimeConfig::from_environment(&UpdateEnvOverrides {
774 enabled: Some(false),
775 sources: Some("not-a-source".to_owned()),
776 check_interval_ms: None,
777 github_repo: Some("not-owner-repo".to_owned()),
778 })
779 .expect("disabled update checks should ignore unused source settings");
780
781 assert!(!config.enabled);
782 assert_eq!(
783 config.sources,
784 vec![UpdateSource::Github, UpdateSource::CratesIo]
785 );
786 assert_eq!(config.github_repo, GITHUB_REPOSITORY_FULL_NAME);
787 }
788
789 #[test]
790 fn parses_stable_versions_and_rejects_prereleases() {
791 assert_eq!(
792 stable_version("v1.2.3").expect("version should parse"),
793 StableVersion::new(1, 2, 3)
794 );
795 assert_eq!(
796 comparable_version("1.2.3-rc.1").expect("current prerelease should compare"),
797 StableVersion::prerelease(1, 2, 3)
798 );
799 assert!(StableVersion::new(1, 2, 3) > StableVersion::prerelease(1, 2, 3));
800 assert!(stable_version("1.2.3-rc.1").is_err());
801 }
802
803 #[test]
804 fn selects_highest_stable_candidate() {
805 let response = response_from_candidates(
806 StableVersion::new(1, 0, 4),
807 vec![
808 ReleaseCandidate {
809 source: UpdateSource::Github,
810 version: StableVersion::new(1, 0, 5),
811 release_url: "https://github.example/release".to_owned(),
812 },
813 ReleaseCandidate {
814 source: UpdateSource::CratesIo,
815 version: StableVersion::new(1, 0, 6),
816 release_url: "https://crates.example/release".to_owned(),
817 },
818 ],
819 Vec::new(),
820 42,
821 );
822
823 assert!(response.update_available);
824 assert_eq!(response.latest_version, Some("1.0.6".to_owned()));
825 assert_eq!(response.source, Some("crates.io".to_owned()));
826 }
827
828 #[test]
829 fn prerelease_current_version_is_older_than_matching_stable_candidate() {
830 let response = response_from_candidates(
831 StableVersion::prerelease(1, 0, 5),
832 vec![ReleaseCandidate {
833 source: UpdateSource::Github,
834 version: StableVersion::new(1, 0, 5),
835 release_url: "https://github.example/release".to_owned(),
836 }],
837 Vec::new(),
838 42,
839 );
840
841 assert!(response.update_available);
842 assert_eq!(response.latest_version, Some("1.0.5".to_owned()));
843 }
844
845 #[test]
846 fn parses_release_payloads_into_candidates() {
847 let github = github_candidate(GithubLatestRelease {
848 tag_name: "v1.2.3".to_owned(),
849 html_url: "https://github.example/release".to_owned(),
850 prerelease: false,
851 })
852 .expect("GitHub release should parse");
853 let crates = crates_candidate(CratesPackageResponse {
854 package: CratesPackage {
855 max_stable_version: Some("1.2.4".to_owned()),
856 },
857 })
858 .expect("crates release should parse");
859
860 assert_eq!(github.version, StableVersion::new(1, 2, 3));
861 assert_eq!(crates.version, StableVersion::new(1, 2, 4));
862 }
863
864 #[test]
865 fn crates_candidate_uses_stable_version_field() {
866 let crates = crates_candidate(CratesPackageResponse {
867 package: CratesPackage {
868 max_stable_version: Some("2.0.0".to_owned()),
869 },
870 })
871 .expect("stable crates release should parse");
872 let missing_stable = crates_candidate(CratesPackageResponse {
873 package: CratesPackage {
874 max_stable_version: None,
875 },
876 })
877 .expect_err("missing stable version should be diagnostic");
878
879 assert_eq!(crates.version, StableVersion::new(2, 0, 0));
880 assert_eq!(missing_stable.code, "stable_version_unavailable");
881 }
882
883 #[test]
884 fn response_body_limit_rejects_oversized_chunks() {
885 let mut body = b"{}".to_vec();
886
887 append_limited_response_body(UpdateSource::Github, &mut body, b"\n", 3)
888 .expect("boundary-sized body should pass");
889 let diagnostic = append_limited_response_body(UpdateSource::Github, &mut body, b"x", 3)
890 .expect_err("body over the configured limit should fail");
891
892 assert_eq!(diagnostic.code, "response_body_too_large");
893 }
894
895 #[test]
896 fn cache_freshness_uses_interval_boundary() {
897 let response = sample_version_response(env!("CARGO_PKG_VERSION"), 100);
898
899 assert!(cache_is_fresh(&response, 200, Duration::from_millis(100)));
900 assert!(!cache_is_fresh(&response, 201, Duration::from_millis(100)));
901 }
902
903 #[test]
904 fn cache_usability_requires_current_binary_and_source_configuration() {
905 let config = UpdateRuntimeConfig::from_environment(&UpdateEnvOverrides::default())
906 .expect("default config should parse");
907 let cache = VersionCheckCache {
908 cache_key: version_cache_key(&config),
909 response: sample_version_response(env!("CARGO_PKG_VERSION"), 100),
910 };
911
912 assert!(cache_is_usable(
913 &cache,
914 200,
915 Duration::from_millis(100),
916 &config
917 ));
918
919 let mut previous_binary_cache = cache.clone();
920 previous_binary_cache.response.current_version = "0.0.1".to_owned();
921 assert!(!cache_is_usable(
922 &previous_binary_cache,
923 200,
924 Duration::from_millis(100),
925 &config
926 ));
927
928 let mut changed_source_cache = cache;
929 changed_source_cache.cache_key = "sources=crates.io;github_repo=example/repo".to_owned();
930 assert!(!cache_is_usable(
931 &changed_source_cache,
932 200,
933 Duration::from_millis(100),
934 &config
935 ));
936 }
937
938 #[test]
939 fn cache_format_requires_configuration_key_wrapper() {
940 let raw_response =
941 serde_json::to_vec(&sample_version_response(env!("CARGO_PKG_VERSION"), 100))
942 .expect("sample response should serialize");
943
944 assert!(serde_json::from_slice::<VersionCheckCache>(&raw_response).is_err());
945 }
946
947 fn sample_version_response(
948 current_version: &str,
949 checked_at_unix_ms: u64,
950 ) -> VersionCheckResponse {
951 VersionCheckResponse {
952 project_name: PROJECT_NAME.to_owned(),
953 current_version: current_version.to_owned(),
954 latest_version: Some("1.0.5".to_owned()),
955 update_available: true,
956 source: Some("github".to_owned()),
957 release_url: None,
958 checked_at_unix_ms,
959 diagnostics: Vec::new(),
960 }
961 }
962}