@@ -283,7 +283,7 @@ fn github_api_base() -> String {
283283
284284fn resolve_release_info ( client : & Client , owner : & str , repo : & str ) -> Result < ReleaseInfo > {
285285 let release = fetch_latest_release ( client, owner, repo) ?;
286- let asset = pick_asset ( & release. assets ) ?;
286+ let asset = pick_asset ( & release. assets , repo ) ?;
287287 let tag = release. tag_name . as_deref ( ) . unwrap_or ( "unknown" ) . to_string ( ) ;
288288
289289 Ok ( ReleaseInfo {
@@ -310,7 +310,7 @@ fn fetch_latest_release(client: &Client, owner: &str, repo: &str) -> Result<Rele
310310 . with_context ( || format ! ( "parse release for {owner}/{repo}" ) )
311311}
312312
313- fn pick_asset ( assets : & [ Asset ] ) -> Result < Asset > {
313+ fn pick_asset ( assets : & [ Asset ] , repo_name : & str ) -> Result < Asset > {
314314 if assets. is_empty ( ) {
315315 bail ! ( "release has no assets" )
316316 }
@@ -323,21 +323,38 @@ fn pick_asset(assets: &[Asset]) -> Result<Asset> {
323323 candidates = assets. iter ( ) . collect ( ) ;
324324 }
325325
326+ let mut prefer_shorter = false ;
327+ let repo_tokens = tokenize_name ( repo_name) ;
328+ if !repo_tokens. is_empty ( ) {
329+ let repo_candidates: Vec < & Asset > = candidates
330+ . iter ( )
331+ . copied ( )
332+ . filter ( |asset| asset_matches_repo ( & asset. name , & repo_tokens) )
333+ . collect ( ) ;
334+ if !repo_candidates. is_empty ( ) {
335+ candidates = repo_candidates;
336+ prefer_shorter = true ;
337+ }
338+ }
339+
326340 let os_tokens = os_tokens ( ) ;
327341 let arch_tokens = arch_tokens ( ) ;
328342
329- let mut best: Option < ( & Asset , i32 ) > = None ;
343+ let mut best: Option < ( & Asset , i32 , usize ) > = None ;
330344 for asset in candidates {
331345 let score = asset_score ( & asset. name , & os_tokens, & arch_tokens) ;
346+ let stem_len = asset_stem ( & asset. name ) . len ( ) ;
332347 if best
333- . map ( |( _, best_score) | score > best_score)
348+ . map ( |( _, best_score, best_len) | {
349+ score > best_score || ( prefer_shorter && score == best_score && stem_len < best_len)
350+ } )
334351 . unwrap_or ( true )
335352 {
336- best = Some ( ( asset, score) ) ;
353+ best = Some ( ( asset, score, stem_len ) ) ;
337354 }
338355 }
339356
340- best. map ( |( asset, _) | asset. clone ( ) )
357+ best. map ( |( asset, _, _ ) | asset. clone ( ) )
341358 . context ( "no suitable assets" )
342359}
343360
@@ -364,6 +381,35 @@ fn contains_any(haystack: &str, tokens: &[&str]) -> bool {
364381 tokens. iter ( ) . any ( |token| haystack. contains ( token) )
365382}
366383
384+ fn tokenize_name ( name : & str ) -> Vec < String > {
385+ name. to_lowercase ( )
386+ . split ( |c : char | !c. is_ascii_alphanumeric ( ) )
387+ . filter ( |token| !token. is_empty ( ) )
388+ . map ( |token| token. to_string ( ) )
389+ . collect ( )
390+ }
391+
392+ fn asset_matches_repo ( asset_name : & str , repo_tokens : & [ String ] ) -> bool {
393+ let stem = asset_stem ( asset_name) ;
394+ let tokens = tokenize_name ( stem) ;
395+ tokens_match_sequence ( & tokens, repo_tokens)
396+ }
397+
398+ fn tokens_match_sequence ( haystack : & [ String ] , needle : & [ String ] ) -> bool {
399+ if needle. is_empty ( ) {
400+ return false ;
401+ }
402+ if needle. len ( ) > haystack. len ( ) {
403+ return false ;
404+ }
405+ for start in 0 ..=( haystack. len ( ) - needle. len ( ) ) {
406+ if haystack[ start..start + needle. len ( ) ] == * needle {
407+ return true ;
408+ }
409+ }
410+ false
411+ }
412+
367413fn os_tokens ( ) -> Vec < & ' static str > {
368414 match env:: consts:: OS {
369415 "macos" => vec ! [ "darwin" , "macos" , "osx" , "mac" , "apple-darwin" ] ,
@@ -409,6 +455,32 @@ fn is_gzip_name(name: &str) -> bool {
409455 lower. ends_with ( ".gz" ) && !lower. ends_with ( ".tar.gz" )
410456}
411457
458+ fn asset_stem ( name : & str ) -> & str {
459+ let lower = name. to_lowercase ( ) ;
460+ if lower. ends_with ( ".tar.gz" ) {
461+ return & name[ ..name. len ( ) . saturating_sub ( 7 ) ] ;
462+ }
463+ if lower. ends_with ( ".tar.xz" ) {
464+ return & name[ ..name. len ( ) . saturating_sub ( 7 ) ] ;
465+ }
466+ if lower. ends_with ( ".tar.bz2" ) {
467+ return & name[ ..name. len ( ) . saturating_sub ( 8 ) ] ;
468+ }
469+ if lower. ends_with ( ".zip" ) {
470+ return & name[ ..name. len ( ) . saturating_sub ( 4 ) ] ;
471+ }
472+ if lower. ends_with ( ".tgz" ) {
473+ return & name[ ..name. len ( ) . saturating_sub ( 4 ) ] ;
474+ }
475+ if lower. ends_with ( ".gz" ) {
476+ return & name[ ..name. len ( ) . saturating_sub ( 3 ) ] ;
477+ }
478+ if lower. ends_with ( ".exe" ) {
479+ return & name[ ..name. len ( ) . saturating_sub ( 4 ) ] ;
480+ }
481+ name
482+ }
483+
412484fn download_asset ( client : & Client , url : & str , dest : & Path ) -> Result < ( ) > {
413485 let mut response = client
414486 . get ( url)
@@ -1078,7 +1150,7 @@ mod tests {
10781150 browser_download_url: "http://example.com/tool-best" . to_string( ) ,
10791151 } ,
10801152 ] ;
1081- let picked = pick_asset ( & assets) . expect ( "pick asset" ) ;
1153+ let picked = pick_asset ( & assets, "tool" ) . expect ( "pick asset" ) ;
10821154 assert_eq ! ( picked. name, best_name) ;
10831155 assert ! ( is_ignored_asset( "foo.sha256" ) ) ;
10841156 assert ! ( is_archive_name( "foo.tar.gz" ) ) ;
@@ -1088,7 +1160,7 @@ mod tests {
10881160
10891161 #[ test]
10901162 fn pick_asset_errors_on_empty_assets ( ) {
1091- assert ! ( pick_asset( & [ ] ) . is_err( ) ) ;
1163+ assert ! ( pick_asset( & [ ] , "tool" ) . is_err( ) ) ;
10921164 }
10931165
10941166 #[ test]
@@ -1103,10 +1175,30 @@ mod tests {
11031175 browser_download_url: "http://example.com/tool.sig" . to_string( ) ,
11041176 } ,
11051177 ] ;
1106- let picked = pick_asset ( & assets) . expect ( "pick asset" ) ;
1178+ let picked = pick_asset ( & assets, "tool" ) . expect ( "pick asset" ) ;
11071179 assert ! ( picked. name. ends_with( ".sha256" ) || picked. name. ends_with( ".sig" ) ) ;
11081180 }
11091181
1182+ #[ test]
1183+ fn pick_asset_prefers_repo_stem ( ) {
1184+ let os = os_tokens ( ) ;
1185+ let arch = arch_tokens ( ) ;
1186+ let name = format ! ( "bun-{}-{}.zip" , os[ 0 ] , arch[ 0 ] ) ;
1187+ let profile = format ! ( "bun-profile-{}-{}.zip" , os[ 0 ] , arch[ 0 ] ) ;
1188+ let assets = vec ! [
1189+ Asset {
1190+ name: profile. clone( ) ,
1191+ browser_download_url: "http://example.com/bun-profile" . to_string( ) ,
1192+ } ,
1193+ Asset {
1194+ name: name. clone( ) ,
1195+ browser_download_url: "http://example.com/bun" . to_string( ) ,
1196+ } ,
1197+ ] ;
1198+ let picked = pick_asset ( & assets, "bun" ) . expect ( "pick asset" ) ;
1199+ assert_eq ! ( picked. name, name) ;
1200+ }
1201+
11101202 #[ test]
11111203 fn asset_score_counts_exe ( ) {
11121204 assert_eq ! ( asset_score( "tool.exe" , & [ ] , & [ ] ) , 1 ) ;
0 commit comments