@@ -4,6 +4,7 @@ use std::process::Command;
44use std:: time:: { SystemTime , UNIX_EPOCH } ;
55
66use chrono:: { DateTime , Utc } ;
7+ use semver:: { BuildMetadata , Prerelease , Version } ;
78use n0_error:: { Result , StackResultExt , StdResultExt } ;
89use serde:: { Deserialize , Serialize } ;
910
@@ -13,6 +14,37 @@ const GITHUB_API_BASE: &str = "https://api.github.com";
1314const REPO_OWNER : & str = "datum-cloud" ;
1415const REPO_NAME : & str = "app" ;
1516
17+ /// Parse a tag or Cargo-style version into a [`Version`] for ordering.
18+ /// Accepts optional `v` prefix, optional minor/patch (default 0), and optional pre-release after `-`.
19+ /// Strips build metadata (`+...`). Invalid numeric core or invalid pre-release identifiers return `None`.
20+ fn parse_update_version ( raw : & str ) -> Option < Version > {
21+ let s = raw. trim ( ) . trim_start_matches ( 'v' ) ;
22+ let s = s. split ( '+' ) . next ( ) ?. trim ( ) ;
23+ let ( core, pre_str) = match s. split_once ( '-' ) {
24+ Some ( ( c, p) ) => ( c. trim ( ) , p. trim ( ) ) ,
25+ None => ( s, "" ) ,
26+ } ;
27+ if core. is_empty ( ) {
28+ return None ;
29+ }
30+ let nums: Vec < & str > = core. split ( '.' ) . collect ( ) ;
31+ let major = nums. first ( ) ?. parse :: < u64 > ( ) . ok ( ) ?;
32+ let minor = nums. get ( 1 ) . and_then ( |s| s. parse ( ) . ok ( ) ) . unwrap_or ( 0 ) ;
33+ let patch = nums. get ( 2 ) . and_then ( |s| s. parse ( ) . ok ( ) ) . unwrap_or ( 0 ) ;
34+ let pre = if pre_str. is_empty ( ) {
35+ Prerelease :: EMPTY
36+ } else {
37+ Prerelease :: new ( pre_str) . ok ( ) ?
38+ } ;
39+ Some ( Version {
40+ major,
41+ minor,
42+ patch,
43+ pre,
44+ build : BuildMetadata :: EMPTY ,
45+ } )
46+ }
47+
1648/// Which GitHub release stream to follow for automatic updates.
1749#[ derive( Debug , Clone , Copy , PartialEq , Eq , Serialize , Deserialize ) ]
1850#[ serde( rename_all = "lowercase" ) ]
@@ -121,12 +153,6 @@ pub struct UpdateInfo {
121153 pub download_size : u64 ,
122154}
123155
124- struct VersionParts {
125- major : u32 ,
126- minor : u32 ,
127- patch : u32 ,
128- }
129-
130156fn release_eligible_for_channel ( release : & GitHubRelease , channel : UpdateChannel ) -> bool {
131157 if release. draft || release. tag_name == "rolling" {
132158 return false ;
@@ -306,60 +332,15 @@ impl UpdateChecker {
306332 tag. trim_start_matches ( 'v' ) . to_string ( )
307333 }
308334
309- /// Compare semantic version strings - returns true if version1 > version2
310- /// Handles semantic versions like "0.0.3", "0.1.0", "1.0.0", etc .
335+ /// Compare semantic version strings — true if ` version1` is strictly newer than ` version2`.
336+ /// Uses full SemVer 2.0 ordering (including pre-release); build metadata is ignored .
311337 pub ( crate ) fn is_newer_version ( version1 : & str , version2 : & str ) -> bool {
312- let v1_parts = Self :: parse_semantic_version ( version1) ;
313- let v2_parts = Self :: parse_semantic_version ( version2) ;
314-
315- // Compare major, minor, patch versions
316- match v1_parts. major . cmp ( & v2_parts. major ) {
317- std:: cmp:: Ordering :: Greater => return true ,
318- std:: cmp:: Ordering :: Less => return false ,
319- std:: cmp:: Ordering :: Equal => { }
320- }
321-
322- match v1_parts. minor . cmp ( & v2_parts. minor ) {
323- std:: cmp:: Ordering :: Greater => return true ,
324- std:: cmp:: Ordering :: Less => return false ,
325- std:: cmp:: Ordering :: Equal => { }
326- }
327-
328- match v1_parts. patch . cmp ( & v2_parts. patch ) {
329- std:: cmp:: Ordering :: Greater => true ,
330- std:: cmp:: Ordering :: Less => false ,
331- std:: cmp:: Ordering :: Equal => {
332- // If versions are equal, check for pre-release/build metadata
333- // For now, if versions are equal, consider it not newer
334- false
335- }
336- }
337- }
338-
339- /// Parse semantic version string (e.g., "0.0.3" or "0.0.3-beta")
340- fn parse_semantic_version ( version : & str ) -> VersionParts {
341- // Remove any pre-release or build metadata (everything after '-')
342- let version = version. split ( '-' ) . next ( ) . unwrap_or ( version) ;
343-
344- let parts: Vec < & str > = version. split ( '.' ) . collect ( ) ;
345-
346- let major = parts
347- . get ( 0 )
348- . and_then ( |s| s. parse :: < u32 > ( ) . ok ( ) )
349- . unwrap_or ( 0 ) ;
350- let minor = parts
351- . get ( 1 )
352- . and_then ( |s| s. parse :: < u32 > ( ) . ok ( ) )
353- . unwrap_or ( 0 ) ;
354- let patch = parts
355- . get ( 2 )
356- . and_then ( |s| s. parse :: < u32 > ( ) . ok ( ) )
357- . unwrap_or ( 0 ) ;
358-
359- VersionParts {
360- major,
361- minor,
362- patch,
338+ match (
339+ parse_update_version ( version1) ,
340+ parse_update_version ( version2) ,
341+ ) {
342+ ( Some ( v1) , Some ( v2) ) => v1 > v2,
343+ _ => false ,
363344 }
364345 }
365346
@@ -874,4 +855,24 @@ mod tests {
874855 let picked = select_release_for_channel ( releases, UpdateChannel :: Stable , "2.0.0" , |_| true ) ;
875856 assert ! ( picked. is_none( ) ) ;
876857 }
858+
859+ #[ test]
860+ fn newer_orders_prerelease_suffixes ( ) {
861+ assert ! ( UpdateChecker :: is_newer_version( "1.0.0-beta.2" , "1.0.0-beta.1" ) ) ;
862+ assert ! ( !UpdateChecker :: is_newer_version( "1.0.0-beta.1" , "1.0.0-beta.2" ) ) ;
863+ assert ! ( UpdateChecker :: is_newer_version( "1.0.0-rc.1" , "1.0.0-beta.99" ) ) ;
864+ }
865+
866+ #[ test]
867+ fn release_newer_than_prerelease_same_core ( ) {
868+ assert ! ( UpdateChecker :: is_newer_version( "1.0.0" , "1.0.0-beta.99" ) ) ;
869+ assert ! ( !UpdateChecker :: is_newer_version( "1.0.0-beta.99" , "1.0.0" ) ) ;
870+ }
871+
872+ #[ test]
873+ fn newer_accepts_v_prefix_build_metadata_ignored ( ) {
874+ assert ! ( UpdateChecker :: is_newer_version( "v1.2.4" , "1.2.3" ) ) ;
875+ // SemVer: build metadata does not affect precedence
876+ assert ! ( !UpdateChecker :: is_newer_version( "1.2.3+build2" , "1.2.3+build1" ) ) ;
877+ }
877878}
0 commit comments