Skip to main content

relay_knowledge/application/
update.rs

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/// Supported upstream sources for release metadata.
23#[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/// Runtime update-check policy resolved from environment and project defaults.
48#[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/// Update-check runtime configuration error.
90#[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/// Machine-readable result for `relay-knowledge version check`.
119#[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/// Source-specific version-check diagnostic safe for CLI output.
132#[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}