@@ -7,6 +7,18 @@ use anyhow::{Context, Result};
77use indexmap:: IndexMap ;
88use serde:: { Deserialize , Serialize } ;
99
10+ /// Platform-native path-list separator, exposed in yaml as `${PATHSEP}`.
11+ #[ cfg( target_os = "windows" ) ]
12+ pub const PATHSEP : & str = ";" ;
13+ #[ cfg( not( target_os = "windows" ) ) ]
14+ pub const PATHSEP : & str = ":" ;
15+
16+ /// Platform-native executable suffix, exposed in yaml as `${EXE_SUFFIX}`.
17+ #[ cfg( target_os = "windows" ) ]
18+ pub const EXE_SUFFIX : & str = ".exe" ;
19+ #[ cfg( not( target_os = "windows" ) ) ]
20+ pub const EXE_SUFFIX : & str = "" ;
21+
1022/// A package definition
1123#[ derive( Debug , Clone , Serialize , Deserialize ) ]
1224pub struct Package {
@@ -133,6 +145,11 @@ impl Package {
133145 // Replace ${NAME} with package name
134146 result = result. replace ( "${NAME}" , & self . name ) ;
135147
148+ // Platform-aware builtins so a single yaml line can compose path
149+ // lists or binary names without a `variants:` fork per platform.
150+ result = result. replace ( "${PATHSEP}" , PATHSEP ) ;
151+ result = result. replace ( "${EXE_SUFFIX}" , EXE_SUFFIX ) ;
152+
136153 // Replace other ${VAR} references
137154 for ( key, val) in env {
138155 result = result. replace ( & format ! ( "${{{}}}" , key) , val) ;
@@ -145,11 +162,19 @@ impl Package {
145162 std:: env:: var ( var) . unwrap_or_default ( )
146163 } ) . to_string ( ) ;
147164
148- // Expand ~ to home directory
149- if result. starts_with ( "~/" ) {
150- if let Ok ( home) = std:: env:: var ( "HOME" ) {
151- result = format ! ( "{}{}" , home, & result[ 1 ..] ) ;
152- }
165+ // Expand `~/` everywhere it appears at a segment boundary
166+ // (start-of-value, or after `:` / `;`). Path-list values like
167+ // `~/USD/bin;~/USD/lib` need every occurrence expanded, not just
168+ // the first. `dirs::home_dir()` resolves via `USERPROFILE` on
169+ // Windows when `HOME` is unset (PowerShell sessions).
170+ if let Some ( home) = dirs:: home_dir ( ) {
171+ let home_str = home. to_string_lossy ( ) ;
172+ let tilde_re = regex:: Regex :: new ( r"(^|[:;])~/" ) . unwrap ( ) ;
173+ result = tilde_re
174+ . replace_all ( & result, |caps : & regex:: Captures | {
175+ format ! ( "{}{}/" , & caps[ 1 ] , home_str)
176+ } )
177+ . to_string ( ) ;
153178 }
154179
155180 result
@@ -453,6 +478,108 @@ mod tests {
453478 assert_eq ! ( pkg. expand_env_value( "${NAME}-${VERSION}" , & env) , "maya-2024" ) ;
454479 }
455480
481+ #[ test]
482+ fn expand_pathsep_builtin ( ) {
483+ let pkg = Package {
484+ name : "test" . into ( ) ,
485+ version : "1.0" . into ( ) ,
486+ description : None ,
487+ requires : vec ! [ ] ,
488+ environment : IndexMap :: new ( ) ,
489+ commands : HashMap :: new ( ) ,
490+ variants : vec ! [ ] ,
491+ root : PathBuf :: from ( "/tmp" ) ,
492+ } ;
493+ let env = HashMap :: new ( ) ;
494+ let expected = if cfg ! ( target_os = "windows" ) {
495+ "/a;/b;/c"
496+ } else {
497+ "/a:/b:/c"
498+ } ;
499+ assert_eq ! (
500+ pkg. expand_env_value( "/a${PATHSEP}/b${PATHSEP}/c" , & env) ,
501+ expected
502+ ) ;
503+ }
504+
505+ #[ test]
506+ fn expand_exe_suffix_builtin ( ) {
507+ let pkg = Package {
508+ name : "test" . into ( ) ,
509+ version : "1.0" . into ( ) ,
510+ description : None ,
511+ requires : vec ! [ ] ,
512+ environment : IndexMap :: new ( ) ,
513+ commands : HashMap :: new ( ) ,
514+ variants : vec ! [ ] ,
515+ root : PathBuf :: from ( "/tmp" ) ,
516+ } ;
517+ let env = HashMap :: new ( ) ;
518+ let expected = if cfg ! ( target_os = "windows" ) {
519+ "blender.exe"
520+ } else {
521+ "blender"
522+ } ;
523+ assert_eq ! ( pkg. expand_env_value( "blender${EXE_SUFFIX}" , & env) , expected) ;
524+ }
525+
526+ #[ test]
527+ fn expand_tilde_at_every_segment ( ) {
528+ // `~` should expand at the start of every path segment, not just the
529+ // first occurrence in the value. Path-list values like
530+ // `~/USD/bin;~/USD/lib` were leaving the second `~` literal before.
531+ let pkg = Package {
532+ name : "test" . into ( ) ,
533+ version : "1.0" . into ( ) ,
534+ description : None ,
535+ requires : vec ! [ ] ,
536+ environment : IndexMap :: new ( ) ,
537+ commands : HashMap :: new ( ) ,
538+ variants : vec ! [ ] ,
539+ root : PathBuf :: from ( "/tmp" ) ,
540+ } ;
541+ let env = HashMap :: new ( ) ;
542+ let home = dirs:: home_dir ( ) . expect ( "test needs a HOME" ) ;
543+ let home_str = home. to_string_lossy ( ) ;
544+
545+ // Unix-style separator
546+ let unix_in = "~/a:~/b:~/c" ;
547+ let unix_out = pkg. expand_env_value ( unix_in, & env) ;
548+ assert_eq ! (
549+ unix_out,
550+ format!( "{h}/a:{h}/b:{h}/c" , h = home_str) ,
551+ "Unix-style path list should expand every ~"
552+ ) ;
553+
554+ // Windows-style separator
555+ let win_in = "~/a;~/b;~/c" ;
556+ let win_out = pkg. expand_env_value ( win_in, & env) ;
557+ assert_eq ! (
558+ win_out,
559+ format!( "{h}/a;{h}/b;{h}/c" , h = home_str) ,
560+ "Windows-style path list should expand every ~"
561+ ) ;
562+ }
563+
564+ #[ test]
565+ fn expand_tilde_only_at_segment_boundary ( ) {
566+ // A `~` that's not at a segment boundary (e.g. embedded in a word)
567+ // should be left alone.
568+ let pkg = Package {
569+ name : "test" . into ( ) ,
570+ version : "1.0" . into ( ) ,
571+ description : None ,
572+ requires : vec ! [ ] ,
573+ environment : IndexMap :: new ( ) ,
574+ commands : HashMap :: new ( ) ,
575+ variants : vec ! [ ] ,
576+ root : PathBuf :: from ( "/tmp" ) ,
577+ } ;
578+ let env = HashMap :: new ( ) ;
579+ // No `~/` at start or after `:` / `;`, so nothing should change.
580+ assert_eq ! ( pkg. expand_env_value( "backup~/file" , & env) , "backup~/file" ) ;
581+ }
582+
456583 #[ test]
457584 fn expand_from_env_map ( ) {
458585 let pkg = Package {
0 commit comments