Skip to main content

relay_knowledge/application/update/
mod.rs

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