@@ -189,26 +189,99 @@ impl OtaManager {
189189 }
190190}
191191
192- /// Compare semver strings. Returns true if `latest` is newer than `current`.
193- fn version_is_newer ( latest : & str , current : & str ) -> bool {
194- let parse = |s : & str | -> ( u32 , u32 , u32 ) {
195- let clean = s
196- . strip_prefix ( 'v' )
197- . unwrap_or ( s)
198- . split ( '-' )
199- . next ( )
200- . unwrap_or ( s) ;
201- let parts: Vec < u32 > = clean. split ( '.' ) . filter_map ( |p| p. parse ( ) . ok ( ) ) . collect ( ) ;
202- (
203- parts. first ( ) . copied ( ) . unwrap_or ( 0 ) ,
204- parts. get ( 1 ) . copied ( ) . unwrap_or ( 0 ) ,
205- parts. get ( 2 ) . copied ( ) . unwrap_or ( 0 ) ,
206- )
192+ /// A parsed semantic version: the numeric `major.minor.patch` core plus any
193+ /// pre-release identifiers.
194+ #[ derive( Debug , PartialEq , Eq ) ]
195+ struct SemVer {
196+ core : ( u32 , u32 , u32 ) ,
197+ /// Dot-separated pre-release identifiers (the part after `-`). Empty for a
198+ /// normal release. A release outranks any pre-release of the same core.
199+ pre : Vec < String > ,
200+ }
201+
202+ /// Parse a version string into a [`SemVer`].
203+ ///
204+ /// Tolerant of a leading `v`. Build metadata (`+...`) is ignored for
205+ /// precedence, per SemVer §10. Missing core components default to 0.
206+ fn parse_semver ( s : & str ) -> SemVer {
207+ let s = s. trim ( ) ;
208+ let s = s. strip_prefix ( 'v' ) . unwrap_or ( s) ;
209+ // Build metadata never affects precedence — drop anything from '+' on.
210+ let s = s. split ( '+' ) . next ( ) . unwrap_or ( s) ;
211+ // Core is everything before the first '-'; the rest is the pre-release.
212+ let ( core_str, pre_str) = match s. split_once ( '-' ) {
213+ Some ( ( core, pre) ) => ( core, Some ( pre) ) ,
214+ None => ( s, None ) ,
207215 } ;
208216
209- let l = parse ( latest) ;
210- let c = parse ( current) ;
211- l > c
217+ // Positional parse: an unparseable component becomes 0 without shifting the
218+ // ones after it (so "1.x.2" is (1, 0, 2), not (1, 2, 0)).
219+ let mut nums = core_str
220+ . split ( '.' )
221+ . map ( |p| p. trim ( ) . parse :: < u32 > ( ) . unwrap_or ( 0 ) ) ;
222+ let core = (
223+ nums. next ( ) . unwrap_or ( 0 ) ,
224+ nums. next ( ) . unwrap_or ( 0 ) ,
225+ nums. next ( ) . unwrap_or ( 0 ) ,
226+ ) ;
227+
228+ let pre = pre_str
229+ . filter ( |p| !p. is_empty ( ) )
230+ . map ( |p| p. split ( '.' ) . map ( |id| id. to_string ( ) ) . collect ( ) )
231+ . unwrap_or_default ( ) ;
232+
233+ SemVer { core, pre }
234+ }
235+
236+ /// Compare a single pair of pre-release identifiers per SemVer §11:
237+ /// numeric identifiers compare numerically and always rank below alphanumeric
238+ /// ones; alphanumeric identifiers compare in ASCII order.
239+ fn compare_pre_identifier ( a : & str , b : & str ) -> std:: cmp:: Ordering {
240+ use std:: cmp:: Ordering ;
241+ match ( a. parse :: < u64 > ( ) , b. parse :: < u64 > ( ) ) {
242+ ( Ok ( an) , Ok ( bn) ) => an. cmp ( & bn) ,
243+ ( Ok ( _) , Err ( _) ) => Ordering :: Less ,
244+ ( Err ( _) , Ok ( _) ) => Ordering :: Greater ,
245+ ( Err ( _) , Err ( _) ) => a. cmp ( b) ,
246+ }
247+ }
248+
249+ /// Full SemVer §11 precedence ordering between two parsed versions.
250+ fn compare_semver ( a : & SemVer , b : & SemVer ) -> std:: cmp:: Ordering {
251+ use std:: cmp:: Ordering ;
252+
253+ // 1. Numeric core dominates.
254+ match a. core . cmp ( & b. core ) {
255+ Ordering :: Equal => { }
256+ non_eq => return non_eq,
257+ }
258+
259+ // 2. A release (no pre-release) outranks any pre-release of the same core.
260+ match ( a. pre . is_empty ( ) , b. pre . is_empty ( ) ) {
261+ ( true , true ) => return Ordering :: Equal ,
262+ ( true , false ) => return Ordering :: Greater ,
263+ ( false , true ) => return Ordering :: Less ,
264+ ( false , false ) => { }
265+ }
266+
267+ // 3. Compare pre-release identifiers left to right.
268+ for ( ai, bi) in a. pre . iter ( ) . zip ( b. pre . iter ( ) ) {
269+ match compare_pre_identifier ( ai, bi) {
270+ Ordering :: Equal => { }
271+ non_eq => return non_eq,
272+ }
273+ }
274+
275+ // 4. When all shared identifiers match, the longer set has higher precedence.
276+ a. pre . len ( ) . cmp ( & b. pre . len ( ) )
277+ }
278+
279+ /// Compare semver strings. Returns true if `latest` is strictly newer than
280+ /// `current`, with full SemVer §11 pre-release precedence — so
281+ /// `1.0.0-alpha.12` is newer than `1.0.0-alpha.11`, and `1.0.0` is newer than
282+ /// any `1.0.0-alpha.N`.
283+ fn version_is_newer ( latest : & str , current : & str ) -> bool {
284+ compare_semver ( & parse_semver ( latest) , & parse_semver ( current) ) == std:: cmp:: Ordering :: Greater
212285}
213286
214287/// GET request to GitHub API (api.github.com).
@@ -286,9 +359,66 @@ mod tests {
286359
287360 #[ test]
288361 fn version_comparison_with_prerelease ( ) {
289- // Pre-release suffix is stripped for comparison .
362+ // A higher numeric core wins regardless of pre-release tags .
290363 assert ! ( version_is_newer( "1.1.0-alpha.1" , "1.0.0-alpha.1" ) ) ;
291- assert ! ( !version_is_newer( "1.0.0-alpha.2" , "1.0.0-alpha.1" ) ) ;
364+ // Pre-releases of the SAME core order by their identifiers (SemVer §11),
365+ // so a later alpha IS newer than an earlier one. This is the case the
366+ // OTA checker depends on during the whole `1.0.0-alpha.N` release line —
367+ // the previous "strip the suffix" logic made every alpha compare equal,
368+ // so the device never saw a new alpha.
369+ assert ! ( version_is_newer( "1.0.0-alpha.2" , "1.0.0-alpha.1" ) ) ;
370+ assert ! ( version_is_newer( "1.0.0-alpha.11" , "1.0.0-alpha.9" ) ) ;
371+ assert ! ( !version_is_newer( "1.0.0-alpha.1" , "1.0.0-alpha.2" ) ) ;
372+ }
373+
374+ #[ test]
375+ fn prerelease_numeric_identifiers_compare_numerically ( ) {
376+ // The exact regression: alpha.11 must beat alpha.9 (string compare would
377+ // say "11" < "9"; numeric compare says 11 > 9).
378+ assert ! ( version_is_newer( "1.0.0-alpha.12" , "1.0.0-alpha.11" ) ) ;
379+ assert ! ( version_is_newer( "1.0.0-alpha.100" , "1.0.0-alpha.99" ) ) ;
380+ assert ! ( !version_is_newer( "1.0.0-alpha.9" , "1.0.0-alpha.11" ) ) ;
381+ }
382+
383+ #[ test]
384+ fn release_outranks_its_prerelease ( ) {
385+ // 1.0.0 final is newer than any 1.0.0 pre-release...
386+ assert ! ( version_is_newer( "1.0.0" , "1.0.0-alpha.11" ) ) ;
387+ assert ! ( version_is_newer( "1.0.0" , "1.0.0-rc.1" ) ) ;
388+ // ...and a pre-release is NOT newer than the matching final release.
389+ assert ! ( !version_is_newer( "1.0.0-alpha.1" , "1.0.0" ) ) ;
390+ assert ! ( !version_is_newer( "1.0.0-rc.1" , "1.0.0" ) ) ;
391+ }
392+
393+ #[ test]
394+ fn prerelease_stage_ordering ( ) {
395+ // alpha < beta < rc (ASCII lexical for alphanumeric identifiers).
396+ assert ! ( version_is_newer( "1.0.0-beta.1" , "1.0.0-alpha.11" ) ) ;
397+ assert ! ( version_is_newer( "1.0.0-rc.1" , "1.0.0-beta.9" ) ) ;
398+ assert ! ( !version_is_newer( "1.0.0-alpha.99" , "1.0.0-beta.1" ) ) ;
399+ }
400+
401+ #[ test]
402+ fn numeric_identifier_ranks_below_alphanumeric ( ) {
403+ // SemVer §11: a numeric identifier has lower precedence than an
404+ // alphanumeric one in the same position.
405+ assert ! ( version_is_newer( "1.0.0-alpha" , "1.0.0-1" ) ) ;
406+ assert ! ( !version_is_newer( "1.0.0-1" , "1.0.0-alpha" ) ) ;
407+ }
408+
409+ #[ test]
410+ fn longer_prerelease_set_wins_when_prefixes_match ( ) {
411+ // SemVer §11: a larger set of pre-release fields outranks a smaller one
412+ // when all preceding identifiers are equal.
413+ assert ! ( version_is_newer( "1.0.0-alpha.1.1" , "1.0.0-alpha.1" ) ) ;
414+ assert ! ( !version_is_newer( "1.0.0-alpha.1" , "1.0.0-alpha.1.1" ) ) ;
415+ }
416+
417+ #[ test]
418+ fn build_metadata_is_ignored ( ) {
419+ // Build metadata (`+...`) does not affect precedence (SemVer §10).
420+ assert ! ( !version_is_newer( "1.0.0+build.5" , "1.0.0+build.1" ) ) ;
421+ assert ! ( version_is_newer( "1.0.1+build.1" , "1.0.0+build.9" ) ) ;
292422 }
293423
294424 #[ test]
0 commit comments