@@ -43,7 +43,7 @@ pub enum Backend {
4343/// the host first.
4444///
4545/// Priority:
46- /// 1. `override_url` (e.g. the `HARMONT_APP_URL ` env override) when non-empty,
46+ /// 1. `override_url` (e.g. the `HM_APP_URL ` env override) when non-empty,
4747/// 2. heuristic mapping of `api.` → `app.` on the API host,
4848/// 3. the API base itself (last-resort dev fallback for hosts like
4949/// `localhost` that have no `api.`/`app.` split).
@@ -139,6 +139,14 @@ impl Config {
139139
140140 /// Testable core: build a `Config` from explicit file paths.
141141 ///
142+ /// Layering, lowest to highest precedence: defaults -> user file ->
143+ /// project file -> env.
144+ ///
145+ /// Env precedence (highest): both the `HM_`-prefixed split form
146+ /// (`HM_CLOUD__ORG`, `HM_CLOUD__API_URL`) and the documented
147+ /// `HM_ORG` / `HM_API_URL` are honored; the latter map onto
148+ /// `cloud.org` / `cloud.api_url`.
149+ ///
142150 /// # Errors
143151 ///
144152 /// Returns an error if figment extraction fails (malformed TOML, type mismatches).
@@ -152,7 +160,9 @@ impl Config {
152160 figment = figment. merge ( Toml :: file ( p) ) ;
153161 }
154162
155- figment = figment. merge ( Env :: prefixed ( "HM_" ) . split ( "__" ) ) ;
163+ figment = figment
164+ . merge ( Env :: prefixed ( "HM_" ) . split ( "__" ) )
165+ . merge ( hm_alias_env ( ) ) ;
156166
157167 Ok ( figment. extract ( ) ?)
158168 }
@@ -183,11 +193,45 @@ impl Config {
183193 }
184194}
185195
196+ /// Figment env provider mapping the friendly `HM_ORG` / `HM_API_URL`
197+ /// variables onto the nested `cloud` config keys.
198+ ///
199+ /// The cloud settings docs and `hm`'s error messages tell users to
200+ /// `set HM_ORG=<slug>` / `HM_API_URL=<url>`, so those flat names must feed
201+ /// the config. This binds them to `cloud.org` / `cloud.api_url` alongside the
202+ /// generic `HM_`-prefixed split layer (`HM_CLOUD__ORG`, …).
203+ fn hm_alias_env ( ) -> Env {
204+ Env :: raw ( )
205+ . only ( & [ "HM_ORG" , "HM_API_URL" ] )
206+ . map ( |key| match key. as_str ( ) {
207+ "HM_ORG" => "cloud.org" . into ( ) ,
208+ "HM_API_URL" => "cloud.api_url" . into ( ) ,
209+ other => other. into ( ) ,
210+ } )
211+ . split ( "." )
212+ }
213+
186214#[ cfg( test) ]
187215#[ allow( clippy:: unwrap_used) ]
188216mod tests {
189217 use super :: * ;
190218 use std:: io:: Write as _;
219+ use std:: sync:: { Mutex , MutexGuard } ;
220+
221+ /// Serializes every test that resolves config through `load_*`.
222+ ///
223+ /// All `load_*` paths merge the process environment as their top layer, so
224+ /// a test that sets `HM_*` (via `figment::Jail`, which mutates the
225+ /// real process env for the duration of its closure) would otherwise leak
226+ /// into a concurrently-running file-layering test. Holding this lock for
227+ /// the whole body of any env-or-load test makes them mutually exclusive.
228+ static ENV_LOCK : Mutex < ( ) > = Mutex :: new ( ( ) ) ;
229+
230+ fn env_guard ( ) -> MutexGuard < ' static , ( ) > {
231+ ENV_LOCK
232+ . lock ( )
233+ . unwrap_or_else ( std:: sync:: PoisonError :: into_inner)
234+ }
191235
192236 #[ test]
193237 fn app_url_maps_prod_api_to_app ( ) {
@@ -250,6 +294,7 @@ auto_watch = true
250294
251295 #[ test]
252296 fn deserialize_sparse_toml ( ) {
297+ let _g = env_guard ( ) ;
253298 let toml_str = r#"
254299[cloud]
255300org = "sparse-co"
@@ -266,6 +311,7 @@ org = "sparse-co"
266311
267312 #[ test]
268313 fn deserialize_empty_toml ( ) {
314+ let _g = env_guard ( ) ;
269315 let mut f = tempfile:: NamedTempFile :: new ( ) . unwrap ( ) ;
270316 f. write_all ( b"" ) . unwrap ( ) ;
271317
@@ -278,6 +324,7 @@ org = "sparse-co"
278324
279325 #[ test]
280326 fn figment_project_overrides_user ( ) {
327+ let _g = env_guard ( ) ;
281328 let user_toml = r#"
282329[cloud]
283330org = "user-org"
@@ -313,6 +360,7 @@ org = "project-org"
313360
314361 #[ test]
315362 fn backend_defaults_docker_and_parses_and_layers ( ) {
363+ let _g = env_guard ( ) ;
316364 // default
317365 assert_eq ! ( Config :: default ( ) . backend, Backend :: Docker ) ;
318366
@@ -334,6 +382,7 @@ org = "project-org"
334382
335383 #[ tokio:: test( flavor = "multi_thread" , worker_threads = 1 ) ]
336384 async fn save_and_reload_roundtrip ( ) {
385+ let _g = env_guard ( ) ;
337386 let tmp = tempfile:: tempdir ( ) . unwrap ( ) ;
338387 let path = tmp. path ( ) . join ( "config.toml" ) ;
339388 let cfg = Config {
@@ -351,8 +400,47 @@ org = "project-org"
351400 assert_eq ! ( loaded. preferences. format, "human" ) ;
352401 }
353402
403+ #[ test]
404+ #[ allow( clippy:: result_large_err) ] // figment::Error is the Jail closure's error type.
405+ fn hm_env_overrides_cloud_keys ( ) {
406+ let _g = env_guard ( ) ;
407+ // `Jail` isolates env mutation from concurrently-running tests.
408+ figment:: Jail :: expect_with ( |jail| {
409+ jail. set_env ( "HM_ORG" , "env-org" ) ;
410+ jail. set_env ( "HM_API_URL" , "https://env.api" ) ;
411+
412+ let cfg = Config :: load_from_paths ( None , None ) . unwrap ( ) ;
413+ assert_eq ! ( cfg. cloud. org. as_deref( ) , Some ( "env-org" ) ) ;
414+ assert_eq ! ( cfg. cloud. api_url, "https://env.api" ) ;
415+ Ok ( ( ) )
416+ } ) ;
417+ }
418+
419+ #[ test]
420+ #[ allow( clippy:: result_large_err) ] // figment::Error is the Jail closure's error type.
421+ fn hm_env_overrides_user_file ( ) {
422+ let _g = env_guard ( ) ;
423+ // Env is the highest-precedence layer: it wins over a user file.
424+ figment:: Jail :: expect_with ( |jail| {
425+ jail. set_env ( "HM_ORG" , "env-org" ) ;
426+
427+ jail. create_file (
428+ "config.toml" ,
429+ "[cloud]\n org = \" file-org\" \n api_url = \" https://file.api\" \n " ,
430+ ) ?;
431+ let user = jail. directory ( ) . join ( "config.toml" ) ;
432+
433+ let cfg = Config :: load_from_paths ( Some ( & user) , None ) . unwrap ( ) ;
434+ assert_eq ! ( cfg. cloud. org. as_deref( ) , Some ( "env-org" ) ) ;
435+ // Unset env keys still come from the file.
436+ assert_eq ! ( cfg. cloud. api_url, "https://file.api" ) ;
437+ Ok ( ( ) )
438+ } ) ;
439+ }
440+
354441 #[ test]
355442 fn figment_missing_files_still_resolve ( ) {
443+ let _g = env_guard ( ) ;
356444 let nonexistent_user = Path :: new ( "/tmp/harmont-test-nonexistent-user/config.toml" ) ;
357445 let nonexistent_project = Path :: new ( "/tmp/harmont-test-nonexistent-project/config.toml" ) ;
358446
0 commit comments