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