relay_knowledge/application/update/config/
mod.rs1use 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#[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#[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#[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;