Skip to main content

lingxia_update/
lib.rs

1use lingxia_provider::{BoxFuture, ProviderError};
2use serde::{Deserialize, Serialize};
3use std::cmp::Ordering;
4use std::fmt;
5use std::str::FromStr;
6
7#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
8#[serde(rename_all = "lowercase")]
9pub enum ReleaseType {
10    #[default]
11    Release,
12    Preview,
13    Developer,
14}
15
16impl ReleaseType {
17    pub fn as_str(self) -> &'static str {
18        match self {
19            Self::Release => "release",
20            Self::Preview => "preview",
21            Self::Developer => "developer",
22        }
23    }
24}
25
26impl fmt::Display for ReleaseType {
27    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
28        f.write_str(self.as_str())
29    }
30}
31
32/// A semantic version representation (`major.minor.patch`) shared by update policy
33/// and lxapp metadata persistence.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct Version {
36    pub major: u32,
37    pub minor: u32,
38    pub patch: u32,
39}
40
41impl Version {
42    pub fn parse(version_str: &str) -> Result<Self, VersionError> {
43        let parts: Vec<&str> = version_str.split('.').collect();
44        if parts.len() != 3 {
45            return Err(VersionError::InvalidFormat);
46        }
47
48        let major = parts[0]
49            .parse()
50            .map_err(|_| VersionError::InvalidComponent)?;
51        let minor = parts.get(1).map_or(Ok(0), |s| {
52            s.parse().map_err(|_| VersionError::InvalidComponent)
53        })?;
54        let patch = parts.get(2).map_or(Ok(0), |s| {
55            s.parse().map_err(|_| VersionError::InvalidComponent)
56        })?;
57
58        Ok(Self {
59            major,
60            minor,
61            patch,
62        })
63    }
64}
65
66impl FromStr for Version {
67    type Err = VersionError;
68
69    fn from_str(s: &str) -> Result<Self, Self::Err> {
70        Self::parse(s)
71    }
72}
73
74impl fmt::Display for Version {
75    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76        write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
77    }
78}
79
80impl PartialOrd for Version {
81    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
82        Some(self.cmp(other))
83    }
84}
85
86impl Ord for Version {
87    fn cmp(&self, other: &Self) -> Ordering {
88        match self.major.cmp(&other.major) {
89            Ordering::Equal => match self.minor.cmp(&other.minor) {
90                Ordering::Equal => self.patch.cmp(&other.patch),
91                ordering => ordering,
92            },
93            ordering => ordering,
94        }
95    }
96}
97
98#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
99pub enum VersionError {
100    #[error("invalid version format, expected 'major.minor.patch'")]
101    InvalidFormat,
102    #[error("invalid version component, expected unsigned integer")]
103    InvalidComponent,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
107pub struct SemanticVersion {
108    pub major: u32,
109    pub minor: u32,
110    pub patch: u32,
111}
112
113impl SemanticVersion {
114    pub fn from_version(version: &Version) -> Self {
115        Self {
116            major: version.major,
117            minor: version.minor,
118            patch: version.patch,
119        }
120    }
121
122    pub fn to_version_string(&self) -> String {
123        format!("{}.{}.{}", self.major, self.minor, self.patch)
124    }
125}
126
127impl fmt::Display for SemanticVersion {
128    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
129        write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
130    }
131}
132
133#[derive(Clone, Debug, PartialEq, Eq)]
134pub enum LxAppUpdateQuery {
135    Latest { current_version: Option<String> },
136    TargetVersion(String),
137}
138
139impl LxAppUpdateQuery {
140    pub fn latest(current_version: Option<impl Into<String>>) -> Self {
141        Self::Latest {
142            current_version: current_version.map(Into::into),
143        }
144    }
145
146    pub fn target_version(version: impl Into<String>) -> Self {
147        Self::TargetVersion(version.into())
148    }
149}
150
151#[derive(Clone, Debug, PartialEq, Eq)]
152pub enum UpdateTarget {
153    App {
154        current_version: Option<String>,
155    },
156    LxApp {
157        id: String,
158        release_type: ReleaseType,
159        query: LxAppUpdateQuery,
160    },
161    Plugin {
162        id: String,
163        version: String,
164    },
165}
166
167impl UpdateTarget {
168    pub fn app(current_version: Option<impl Into<String>>) -> Self {
169        Self::App {
170            current_version: current_version.map(Into::into),
171        }
172    }
173
174    pub fn lxapp(
175        id: impl Into<String>,
176        release_type: ReleaseType,
177        query: LxAppUpdateQuery,
178    ) -> Self {
179        Self::LxApp {
180            id: id.into(),
181            release_type,
182            query,
183        }
184    }
185
186    pub fn plugin(id: impl Into<String>, version: impl Into<String>) -> Self {
187        Self::Plugin {
188            id: id.into(),
189            version: version.into(),
190        }
191    }
192
193    /// Stable routing key for cooldowns, metrics, and diagnostics.
194    pub fn scope_key(&self) -> String {
195        match self {
196            Self::App { .. } => "app".to_string(),
197            Self::LxApp {
198                id, release_type, ..
199            } => format!("lxapp:{id}@{}", release_type.as_str()),
200            Self::Plugin { id, version } => format!("plugin:{id}@{version}"),
201        }
202    }
203}
204
205#[derive(Clone, Debug)]
206pub struct UpdatePackageInfo {
207    pub version: String,
208    pub url: String,
209    pub checksum_sha256: String,
210    pub size: Option<u64>,
211    pub release_notes: Option<Vec<String>>,
212    pub is_force_update: bool,
213    pub required_runtime_version: Option<String>,
214}
215
216impl UpdatePackageInfo {
217    pub fn should_replace_version(
218        candidate_version: &str,
219        installed_version: Option<&str>,
220    ) -> bool {
221        installed_version != Some(candidate_version)
222    }
223
224    pub fn should_replace_installed_version(&self, installed_version: Option<&str>) -> bool {
225        Self::should_replace_version(&self.version, installed_version)
226    }
227
228    pub fn required_runtime_version_trimmed(&self) -> Option<&str> {
229        self.required_runtime_version
230            .as_deref()
231            .map(str::trim)
232            .filter(|value| !value.is_empty())
233    }
234
235    pub fn ensure_runtime_compatible(
236        &self,
237        current_runtime_version: &str,
238        target_name: &str,
239    ) -> Result<(), RuntimeCompatibilityError> {
240        let Some(required_runtime_version) = self.required_runtime_version_trimmed() else {
241            return Ok(());
242        };
243
244        let current = Version::parse(current_runtime_version).map_err(|_| {
245            RuntimeCompatibilityError::InvalidCurrentRuntimeVersion {
246                runtime_version: current_runtime_version.to_string(),
247            }
248        })?;
249        let required = Version::parse(required_runtime_version).map_err(|_| {
250            RuntimeCompatibilityError::InvalidRequiredRuntimeVersion {
251                target: target_name.to_string(),
252                update_version: self.version.clone(),
253                runtime_version: required_runtime_version.to_string(),
254            }
255        })?;
256
257        if current < required {
258            return Err(RuntimeCompatibilityError::RequiresRuntimeUpgrade {
259                target: target_name.to_string(),
260                update_version: self.version.clone(),
261                required_runtime_version: required.to_string(),
262                current_runtime_version: current.to_string(),
263            });
264        }
265
266        Ok(())
267    }
268}
269
270#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
271pub enum RuntimeCompatibilityError {
272    #[error("invalid SDK runtime version '{runtime_version}'")]
273    InvalidCurrentRuntimeVersion { runtime_version: String },
274    #[error(
275        "invalid minRuntimeVersion '{runtime_version}' from update metadata for {target}@{update_version}"
276    )]
277    InvalidRequiredRuntimeVersion {
278        target: String,
279        update_version: String,
280        runtime_version: String,
281    },
282    #[error(
283        "{target} update {update_version} requires runtime >= {required_runtime_version}, current SDK runtime is {current_runtime_version}; update host app first"
284    )]
285    RequiresRuntimeUpgrade {
286        target: String,
287        update_version: String,
288        required_runtime_version: String,
289        current_runtime_version: String,
290    },
291}
292
293/// Update contract shared by app and lxapp update implementations.
294pub trait UpdateProvider: Send + Sync + 'static {
295    /// Returns `Some(package)` when an update package exists and `None` when the target
296    /// is already up to date or no matching package is available.
297    fn check_update<'a>(
298        &'a self,
299        target: UpdateTarget,
300    ) -> BoxFuture<'a, Result<Option<UpdatePackageInfo>, ProviderError>>;
301}
302
303#[cfg(test)]
304mod tests {
305    use super::Version;
306
307    #[test]
308    fn version_parse_accepts_full_semver_only() {
309        assert!(Version::parse("1.2.3").is_ok());
310        assert!(Version::parse("1").is_err());
311        assert!(Version::parse("1.2").is_err());
312        assert!(Version::parse("1.2.3.4").is_err());
313    }
314}