@@ -241,6 +241,14 @@ pub struct DevConfig {
241241 #[ serde( default , skip_serializing_if = "Vec::is_empty" ) ]
242242 pub volumes : Vec < DevVolume > ,
243243
244+ /// Environment variables exported into the `wash dev` process before the
245+ /// host is built. Surfaces values to plugins and runtime crates that read
246+ /// from `std::env` (e.g. `RUST_LOG`, `OTEL_*`, libpq's `PG*` family).
247+ /// Distinct from `workload.environment`, which is delivered to the
248+ /// component via `wasi:cli/env`.
249+ #[ serde( default , skip_serializing_if = "HashMap::is_empty" ) ]
250+ pub environment : HashMap < String , String > ,
251+
244252 /// Host interfaces configuration
245253 #[ serde( default , skip_serializing_if = "Vec::is_empty" ) ]
246254 pub host_interfaces : Vec < WitInterface > ,
@@ -434,3 +442,115 @@ pub async fn generate_default_config(path: &Path, force: bool) -> Result<()> {
434442 info ! ( config_path = %path. display( ) , "Generated default configuration" ) ;
435443 Ok ( ( ) )
436444}
445+
446+ #[ cfg( test) ]
447+ mod tests {
448+ use super :: * ;
449+
450+ #[ test]
451+ fn dev_environment_deserializes_from_yaml ( ) {
452+ // Locks in the YAML contract surfaced to users:
453+ //
454+ // dev:
455+ // environment:
456+ // KEY: value
457+ //
458+ // A regression here (e.g. someone adding `rename_all = "camelCase"`
459+ // to `DevConfig`, or moving the field) would silently drop user-
460+ // configured env vars at `wash dev` startup.
461+ let yaml = r#"
462+ dev:
463+ environment:
464+ RUST_LOG: debug
465+ OTEL_EXPORTER_OTLP_ENDPOINT: http://localhost:4317
466+ "# ;
467+ let config: Config = serde_yaml_ng:: from_str ( yaml) . unwrap ( ) ;
468+ let env = config. dev ( ) . environment ;
469+ assert_eq ! ( env. get( "RUST_LOG" ) . map( String :: as_str) , Some ( "debug" ) ) ;
470+ assert_eq ! (
471+ env. get( "OTEL_EXPORTER_OTLP_ENDPOINT" ) . map( String :: as_str) ,
472+ Some ( "http://localhost:4317" )
473+ ) ;
474+ }
475+
476+ #[ test]
477+ fn workload_yaml_uses_camel_case_for_renamed_fields ( ) {
478+ // `WorkloadConfig`, `EnvironmentLayer`, and `ConfigSource` carry
479+ // `rename_all = "camelCase"`. Users write `configFrom` / `secretFrom`
480+ // / `allowedHosts` / `fromEnv` in YAML; if a refactor drops one of
481+ // those `rename_all` attributes, the camelCase keys get silently
482+ // dropped (parses fine, fields stay default). Pin the contract.
483+ let yaml = r#"
484+ workload:
485+ environment:
486+ config:
487+ INLINE_KEY: inline_value
488+ configFrom:
489+ - app
490+ secretFrom:
491+ - creds
492+ config:
493+ flag: "on"
494+ allowedHosts:
495+ - https://api.example.com
496+ "# ;
497+ let config: Config = serde_yaml_ng:: from_str ( yaml) . unwrap ( ) ;
498+ let workload = config. workload . expect ( "workload should parse" ) ;
499+
500+ let env = workload
501+ . environment
502+ . expect ( "environment layer should parse" ) ;
503+ assert_eq ! ( env. config. get( "INLINE_KEY" ) . unwrap( ) , "inline_value" ) ;
504+ assert_eq ! ( env. config_from, vec![ "app" . to_string( ) ] ) ;
505+ assert_eq ! ( env. secret_from, vec![ "creds" . to_string( ) ] ) ;
506+
507+ assert_eq ! ( workload. config. get( "flag" ) . unwrap( ) , "on" ) ;
508+ assert_eq ! (
509+ workload. allowed_hosts,
510+ vec![ "https://api.example.com" . to_string( ) ]
511+ ) ;
512+ }
513+
514+ #[ test]
515+ fn configs_and_secrets_named_map_with_camel_case_source_fields ( ) {
516+ // The top-level `configs:` and `secrets:` blocks are name -> ConfigSource
517+ // maps, and ConfigSource's `from_env` field is `fromEnv` in YAML.
518+ // `secrets:` shares the same struct as `configs:` — pin both so a
519+ // future split into separate types doesn't silently lose schema parity.
520+ let yaml = r#"
521+ configs:
522+ app:
523+ inline:
524+ APP_FOO: app_foo_value
525+ file: ./app.env
526+ secrets:
527+ creds:
528+ fromEnv:
529+ - DB_PASSWORD
530+ inline:
531+ DB_USER: alice
532+ "# ;
533+ let config: Config = serde_yaml_ng:: from_str ( yaml) . unwrap ( ) ;
534+
535+ let app = config. configs . get ( "app" ) . expect ( "configs.app should parse" ) ;
536+ assert_eq ! ( app. inline. get( "APP_FOO" ) . unwrap( ) , "app_foo_value" ) ;
537+ assert_eq ! ( app. file. as_deref( ) , Some ( Path :: new( "./app.env" ) ) ) ;
538+
539+ let creds = config
540+ . secrets
541+ . get ( "creds" )
542+ . expect ( "secrets.creds should parse" ) ;
543+ assert_eq ! ( creds. from_env, vec![ "DB_PASSWORD" . to_string( ) ] ) ;
544+ assert_eq ! ( creds. inline. get( "DB_USER" ) . unwrap( ) , "alice" ) ;
545+ }
546+
547+ #[ test]
548+ fn dev_environment_defaults_to_empty ( ) {
549+ // `dev.environment` is optional — a `dev:` block without it must
550+ // not fail to parse, and must produce an empty map (not panic on
551+ // the `set_var` loop reading a None).
552+ let yaml = "dev: {}\n " ;
553+ let config: Config = serde_yaml_ng:: from_str ( yaml) . unwrap ( ) ;
554+ assert ! ( config. dev( ) . environment. is_empty( ) ) ;
555+ }
556+ }
0 commit comments