Skip to main content

relay_knowledge/application/update/config/
mod.rs

1use std::{error::Error, fmt, time::Duration};
2
3use serde::{Deserialize, Serialize};
4
5use crate::{
6    env::{RELAY_KNOWLEDGE_UPDATE_GITHUB_REPO, RELAY_KNOWLEDGE_UPDATE_SOURCES, UpdateEnvOverrides},
7    project::GITHUB_REPOSITORY_FULL_NAME,
8};
9
10const DEFAULT_UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60);
11
12/// Supported upstream sources for release metadata.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "kebab-case")]
15pub enum UpdateSource {
16    Github,
17    CratesIo,
18}
19
20impl UpdateSource {
21    pub fn as_str(self) -> &'static str {
22        match self {
23            Self::Github => "github",
24            Self::CratesIo => "crates.io",
25        }
26    }
27
28    fn parse(value: &str) -> Result<Self, UpdateRuntimeConfigError> {
29        match value.trim().to_ascii_lowercase().as_str() {
30            "github" | "github-releases" => Ok(Self::Github),
31            "crates" | "crates.io" | "crates-io" => Ok(Self::CratesIo),
32            other => Err(UpdateRuntimeConfigError::InvalidSource(other.to_owned())),
33        }
34    }
35}
36
37/// Runtime update-check policy resolved from environment and project defaults.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct UpdateRuntimeConfig {
40    pub enabled: bool,
41    pub sources: Vec<UpdateSource>,
42    pub check_interval: Duration,
43    pub github_repo: String,
44}
45
46impl UpdateRuntimeConfig {
47    pub fn from_environment(
48        overrides: &UpdateEnvOverrides,
49    ) -> Result<Self, UpdateRuntimeConfigError> {
50        let enabled = overrides.enabled.unwrap_or(true);
51        let check_interval = Duration::from_millis(
52            overrides
53                .check_interval_ms
54                .unwrap_or(duration_millis(DEFAULT_UPDATE_CHECK_INTERVAL)),
55        );
56        if !enabled {
57            return Ok(Self {
58                enabled,
59                sources: default_update_sources(),
60                check_interval,
61                github_repo: GITHUB_REPOSITORY_FULL_NAME.to_owned(),
62            });
63        }
64
65        Ok(Self {
66            enabled,
67            sources: parse_update_sources(overrides.sources.as_deref())?,
68            check_interval,
69            github_repo: validate_github_repo(
70                overrides
71                    .github_repo
72                    .as_deref()
73                    .unwrap_or(GITHUB_REPOSITORY_FULL_NAME),
74            )?,
75        })
76    }
77}
78
79/// Update-check runtime configuration error.
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub enum UpdateRuntimeConfigError {
82    EmptySourceList,
83    InvalidSource(String),
84    InvalidGithubRepo(String),
85}
86
87impl fmt::Display for UpdateRuntimeConfigError {
88    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
89        match self {
90            Self::EmptySourceList => write!(
91                formatter,
92                "{RELAY_KNOWLEDGE_UPDATE_SOURCES} must include github or crates.io"
93            ),
94            Self::InvalidSource(value) => write!(
95                formatter,
96                "invalid {RELAY_KNOWLEDGE_UPDATE_SOURCES} value '{value}', expected github or crates.io"
97            ),
98            Self::InvalidGithubRepo(value) => write!(
99                formatter,
100                "{RELAY_KNOWLEDGE_UPDATE_GITHUB_REPO} must be owner/name, got '{value}'"
101            ),
102        }
103    }
104}
105
106impl Error for UpdateRuntimeConfigError {}
107
108pub(super) fn duration_millis(duration: Duration) -> u64 {
109    duration.as_millis().try_into().unwrap_or(u64::MAX)
110}
111
112fn parse_update_sources(
113    value: Option<&str>,
114) -> Result<Vec<UpdateSource>, UpdateRuntimeConfigError> {
115    let Some(raw_sources) = value else {
116        return Ok(default_update_sources());
117    };
118    let mut sources = Vec::new();
119    for raw_source in raw_sources.split(',') {
120        let trimmed = raw_source.trim();
121        if trimmed.is_empty() {
122            return Err(UpdateRuntimeConfigError::EmptySourceList);
123        }
124        let source = UpdateSource::parse(trimmed)?;
125        if !sources.contains(&source) {
126            sources.push(source);
127        }
128    }
129    if sources.is_empty() {
130        return Err(UpdateRuntimeConfigError::EmptySourceList);
131    }
132
133    Ok(sources)
134}
135
136fn default_update_sources() -> Vec<UpdateSource> {
137    vec![UpdateSource::Github, UpdateSource::CratesIo]
138}
139
140fn validate_github_repo(value: &str) -> Result<String, UpdateRuntimeConfigError> {
141    let trimmed = value.trim();
142    let parts = trimmed.split('/').collect::<Vec<_>>();
143    if parts.len() != 2
144        || parts.iter().any(|part| part.is_empty())
145        || trimmed.contains(char::is_whitespace)
146    {
147        return Err(UpdateRuntimeConfigError::InvalidGithubRepo(
148            value.to_owned(),
149        ));
150    }
151
152    Ok(trimmed.to_owned())
153}
154
155#[cfg(test)]
156#[path = "mod_tests.rs"]
157mod tests;