@@ -320,7 +320,7 @@ pub fn env_file(args: SecretsEnvFileSubCmdArgs) -> anyhow::Result<()> {
320320 }
321321
322322 // Expand variable inside values
323- let merged = expand_env_map ( & merged) ? ;
323+ let merged = expand_env_map ( & merged) ;
324324
325325 // Sort the env vars for deterministic ordering
326326 let mut entries: Vec < ( String , String ) > = merged. into_iter ( ) . collect ( ) ;
@@ -593,29 +593,50 @@ fn confirm_push() -> anyhow::Result<bool> {
593593 Ok ( answer == "y" || answer == "yes" )
594594}
595595
596- /// Expand ${VAR} placeholders in a merged env map using dotenvy
597- fn expand_env_map ( merged : & HashMap < String , String > ) -> anyhow:: Result < HashMap < String , String > > {
598- // Seed process env so dotenvy can expand ${VAR} from our map
599- for ( key, value) in merged {
600- std:: env:: set_var ( key, value) ;
601- }
602- // build a buffer buffer from the merged map
603- let mut keys: Vec < _ > = merged. keys ( ) . cloned ( ) . collect ( ) ;
604- keys. sort ( ) ;
605- let mut buf = String :: new ( ) ;
606- for key in & keys {
607- let val = & merged[ key] ;
608- writeln ! ( & mut buf, "{key}={val}" ) ?;
609- }
610- // Parse with dotenvy with expansion enabled
611- let iter = dotenvy:: from_read_iter ( buf. as_bytes ( ) ) ;
612- let mut expanded: HashMap < String , String > = HashMap :: new ( ) ;
613- for item in iter {
614- let ( key, value) = item?;
615- expanded. insert ( key, value) ;
596+ /// Expand `${VAR}` placeholders in values using the given map.
597+ fn expand_value ( input : & str , vars : & HashMap < String , String > ) -> String {
598+ let mut out = String :: new ( ) ;
599+ let mut rest = input;
600+
601+ while let Some ( start) = rest. find ( "${" ) {
602+ // keep everything before the placeholder
603+ out. push_str ( & rest[ ..start] ) ;
604+ let after = & rest[ start + 2 ..] ;
605+ // find the closing brace
606+ if let Some ( end_rel) = after. find ( '}' ) {
607+ let var_name = & after[ ..end_rel] ;
608+ if let Some ( val) = vars. get ( var_name) {
609+ // known var: substitute
610+ out. push_str ( val) ;
611+ } else {
612+ // unknown var: keep the placeholder as-is
613+ out. push_str ( "${" ) ;
614+ out. push_str ( var_name) ;
615+ out. push ( '}' ) ;
616+ }
617+ // continue after the closing brace
618+ rest = & after[ end_rel + 1 ..] ;
619+ } else {
620+ // no closing brace, keep the rest as-is
621+ out. push_str ( & rest[ start..] ) ;
622+ rest = "" ;
623+ break ;
624+ }
616625 }
626+ // trailing part without placeholders
627+ out. push_str ( rest) ;
628+ out
629+ }
617630
618- Ok ( expanded)
631+ /// Expand `${VAR}` placeholders in a merged env map.
632+ /// Note: this is a single pass expansion, preserving quotes and formatting.
633+ fn expand_env_map ( merged : & HashMap < String , String > ) -> HashMap < String , String > {
634+ let mut expanded = HashMap :: new ( ) ;
635+ for ( key, value) in merged {
636+ let new_val = expand_value ( value, merged) ;
637+ expanded. insert ( key. clone ( ) , new_val) ;
638+ }
639+ expanded
619640}
620641
621642#[ cfg( test) ]
@@ -627,7 +648,7 @@ mod tests {
627648 #[ test]
628649 #[ serial]
629650 fn test_expand_env_map_simple_expansion ( ) {
630- // Clean any prior values
651+ // Clean any prior values that might confuse debugging
631652 env:: remove_var ( "LOG_LEVEL_TEST" ) ;
632653 env:: remove_var ( "RUST_LOG_TEST" ) ;
633654
@@ -638,8 +659,7 @@ mod tests {
638659 "xtask=${LOG_LEVEL_TEST},server=${LOG_LEVEL_TEST}" . to_string ( ) ,
639660 ) ;
640661
641- let expanded =
642- expand_env_map ( & merged) . expect ( "expand_env_map should succeed for simple map" ) ;
662+ let expanded = expand_env_map ( & merged) ;
643663
644664 let log_level = expanded
645665 . get ( "LOG_LEVEL_TEST" )
@@ -648,19 +668,14 @@ mod tests {
648668 . get ( "RUST_LOG_TEST" )
649669 . expect ( "RUST_LOG_TEST should be present after expansion" ) ;
650670
651- // 1) LOG_LEVEL_TEST should be unchanged
652671 assert_eq ! (
653672 log_level, "info" ,
654673 "LOG_LEVEL_TEST should keep its literal value after expansion"
655674 ) ;
656-
657- // 2) RUST_LOG_TEST should not contain the raw placeholder anymore
658675 assert ! (
659676 !rust_log. contains( "${LOG_LEVEL_TEST}" ) ,
660677 "RUST_LOG_TEST should not contain the raw placeholder '${{LOG_LEVEL_TEST}}', got: {rust_log}"
661678 ) ;
662-
663- // 3) RUST_LOG_TEST should contain the expanded value
664679 assert ! (
665680 rust_log. contains( log_level) ,
666681 "RUST_LOG_TEST should contain the expanded LOG_LEVEL_TEST value; LOG_LEVEL_TEST={log_level}, RUST_LOG_TEST={rust_log}"
@@ -682,8 +697,7 @@ mod tests {
682697 ) ;
683698 merged. insert ( "PLAIN_KEY_TEST" . to_string ( ) , "no_placeholders" . to_string ( ) ) ;
684699
685- let expanded =
686- expand_env_map ( & merged) . expect ( "expand_env_map should succeed for mixed map" ) ;
700+ let expanded = expand_env_map ( & merged) ;
687701
688702 let log_level = expanded
689703 . get ( "LOG_LEVEL_TEST" )
@@ -695,13 +709,7 @@ mod tests {
695709 . get ( "PLAIN_KEY_TEST" )
696710 . expect ( "PLAIN_KEY_TEST should be present after expansion" ) ;
697711
698- // LOG_LEVEL_TEST should be unchanged
699- assert_eq ! (
700- log_level, "debug" ,
701- "LOG_LEVEL_TEST should keep its literal value after expansion"
702- ) ;
703-
704- // RUST_LOG_TEST should be expanded and not contain placeholder
712+ assert_eq ! ( log_level, "debug" ) ;
705713 assert ! (
706714 !rust_log. contains( "${LOG_LEVEL_TEST}" ) ,
707715 "RUST_LOG_TEST should not contain the raw placeholder '${{LOG_LEVEL_TEST}}', got: {rust_log}"
@@ -710,8 +718,6 @@ mod tests {
710718 rust_log. contains( log_level) ,
711719 "RUST_LOG_TEST should contain the expanded LOG_LEVEL_TEST value; LOG_LEVEL_TEST={log_level}, RUST_LOG_TEST={rust_log}"
712720 ) ;
713-
714- // PLAIN_KEY_TEST should be unchanged
715721 assert_eq ! (
716722 plain, "no_placeholders" ,
717723 "PLAIN_KEY_TEST should remain unchanged when there are no placeholders"
@@ -730,20 +736,60 @@ mod tests {
730736 "value=${UNKNOWN_PLACEHOLDER_TEST}" . to_string ( ) ,
731737 ) ;
732738
733- let expanded = expand_env_map ( & merged)
734- . expect ( "expand_env_map should succeed with unknown placeholder" ) ;
739+ let expanded = expand_env_map ( & merged) ;
735740
736741 let uses_unknown = expanded
737742 . get ( "USES_UNKNOWN_TEST" )
738743 . expect ( "USES_UNKNOWN_TEST should be present after expansion" ) ;
739744
740- // dotenvy expansion will simply leave unknown ${VAR} as-is
741- // this assertion documents the current behaviour we rely on:
742- // we do not require unknown vars to expand.
745+ // Unknown placeholders should be preserved exactly
746+ assert_eq ! (
747+ uses_unknown, "value=${UNKNOWN_PLACEHOLDER_TEST}" ,
748+ "Unknown placeholder should be left intact"
749+ ) ;
750+ }
751+
752+ #[ test]
753+ #[ serial]
754+ fn test_expand_env_map_preserves_quotes_around_values ( ) {
755+ env:: remove_var ( "LOG_LEVEL_TEST" ) ;
756+ env:: remove_var ( "RUST_LOG_QUOTED_TEST" ) ;
757+ env:: remove_var ( "CRON_TEST" ) ;
758+
759+ let mut merged: HashMap < String , String > = HashMap :: new ( ) ;
760+ merged. insert ( "LOG_LEVEL_TEST" . to_string ( ) , "info" . to_string ( ) ) ;
761+ // placeholder inside double quotes
762+ merged. insert (
763+ "RUST_LOG_QUOTED_TEST" . to_string ( ) ,
764+ " \" xtask=${LOG_LEVEL_TEST},server=${LOG_LEVEL_TEST}\" " . to_string ( ) ,
765+ ) ;
766+ // value that contains spaces and is already quoted
767+ merged. insert ( "CRON_TEST" . to_string ( ) , "'0 0 0 * * *'" . to_string ( ) ) ;
768+
769+ let expanded = expand_env_map ( & merged) ;
770+
771+ let rust_log = expanded
772+ . get ( "RUST_LOG_QUOTED_TEST" )
773+ . expect ( "RUST_LOG_QUOTED_TEST should be present after expansion" ) ;
774+ let cron = expanded
775+ . get ( "CRON_TEST" )
776+ . expect ( "CRON_TEST should be present after expansion" ) ;
777+
778+ // RUST_LOG_QUOTED_TEST should still start and end with a double quote (after trimming)
779+ let rust_trimmed = rust_log. trim ( ) ;
743780 assert ! (
744- uses_unknown. contains( "${UNKNOWN_PLACEHOLDER_TEST}" )
745- || !uses_unknown. contains( "UNKNOWN_PLACEHOLDER_TEST" ) ,
746- "USES_UNKNOWN_TEST should not crash expansion; got: {uses_unknown}"
781+ rust_trimmed. starts_with( '"' ) && rust_trimmed. ends_with( '"' ) ,
782+ "RUST_LOG_QUOTED_TEST should still be double-quoted, got: {rust_log}"
783+ ) ;
784+ assert ! (
785+ rust_trimmed. contains( "xtask=info" ) ,
786+ "RUST_LOG_QUOTED_TEST should contain the expanded value; got: {rust_trimmed}"
787+ ) ;
788+
789+ // CRON_TEST should be unchanged, quotes preserved
790+ assert_eq ! (
791+ cron, "'0 0 0 * * *'" ,
792+ "CRON_TEST should keep its single quotes and content"
747793 ) ;
748794 }
749795}
0 commit comments