1use std::collections::BTreeMap;
14use std::ffi::OsStr;
15
16use crate::layer::{Layer, LayerCtx, LayerError, LayerOutput, Warning, WarningKind};
17use crate::registry::PropId;
18use crate::source::{Origin, SourceKind};
19
20pub struct EnvLayer {
22 vars: BTreeMap<String, (String, String)>,
26}
27
28impl EnvLayer {
29 pub fn from_process() -> Self {
36 Self::new(std::env::vars_os().filter_map(|(name, value)| readable(&name, &value)))
37 }
38
39 pub fn new(vars: impl IntoIterator<Item = (String, String)>) -> Self {
41 Self {
42 vars: vars
43 .into_iter()
44 .map(|(name, value)| (normalize(&name), (name, value)))
45 .collect(),
46 }
47 }
48
49 pub fn get(&self, name: &str) -> Option<&str> {
51 self.vars
52 .get(&normalize(name))
53 .map(|(_, value)| value.as_str())
54 }
55
56 fn set_as(&self, name: &str) -> Option<&str> {
58 self.vars
59 .get(&normalize(name))
60 .map(|(set_as, _)| set_as.as_str())
61 }
62}
63
64fn readable(name: &OsStr, value: &OsStr) -> Option<(String, String)> {
71 Some((name.to_str()?.to_string(), value.to_str()?.to_string()))
72}
73
74fn normalize(name: &str) -> String {
81 if cfg!(windows) {
82 name.to_uppercase()
83 } else {
84 name.to_string()
85 }
86}
87
88impl Layer for EnvLayer {
89 fn source(&self) -> SourceKind {
90 SourceKind::ENV
91 }
92
93 fn load(&self, ctx: &LayerCtx) -> Result<LayerOutput, LayerError> {
94 let registry = ctx.registry();
95 let mut out = LayerOutput::new();
96
97 let mut found: BTreeMap<PropId, Vec<(PropId, &str, &str, bool)>> = BTreeMap::new();
100 for id in registry.ids() {
101 let meta = registry.get(id);
102 for (name, deprecated) in meta
106 .envs
107 .iter()
108 .map(|name| (*name, false))
109 .chain(meta.deprecated_envs.iter().map(|name| (*name, true)))
110 {
111 let Some(raw) = self.get(name) else {
112 continue;
113 };
114 let set_as = self.set_as(name).unwrap_or(name);
118 let target = meta
119 .renamed_to
120 .and_then(|to| registry.lookup(to))
121 .map_or(id, |found| found.id);
122 found
123 .entry(target)
124 .or_default()
125 .push((id, set_as, raw, deprecated));
126 break;
127 }
128 }
129
130 for (target, mut candidates) in found {
131 candidates.sort_by_key(|(id, _, _, deprecated_env)| (*deprecated_env, *id != target));
141 let mut read_by: Option<&str> = None;
142 for (id, set_as, raw, deprecated_env) in candidates {
143 let origin = Origin::new(SourceKind::ENV, set_as);
144 if let Some(first) = read_by {
145 out.warn(
146 Warning::at(
147 format!(
148 "{set_as} was not read: {first} also sets {}",
149 registry.get(target).key
150 ),
151 origin,
152 )
153 .of(WarningKind::NotRead),
154 );
155 continue;
156 }
157 match ctx.entry(id, raw, origin) {
158 Ok(entry) => {
161 out.push(entry);
162 read_by = Some(set_as);
163 if deprecated_env {
164 let target_meta = registry.get(target);
165 out.warn(
166 Warning::at(
167 format!(
168 "{set_as} is deprecated; use {} for {}",
169 target_meta
170 .envs
171 .first()
172 .copied()
173 .unwrap_or(target_meta.key),
174 target_meta.key
175 ),
176 Origin::new(SourceKind::ENV, set_as),
177 )
178 .of(WarningKind::Deprecated),
179 );
180 }
181 }
182 Err(warning) => out.warn(warning),
186 }
187 }
188 }
189 Ok(out)
190 }
191}
192
193#[cfg(test)]
194mod tests {
195 use super::*;
196 use crate::registry::{PropMeta, Registry, Scope};
197 use crate::resolve::{resolve, Layers};
198 use crate::ty::{Parser, Ty};
199 use crate::value::{Const, Value};
200
201 static PROPS: &[PropMeta] = &[
202 PropMeta {
203 default: Some(Const::Int(4)),
204 envs: &["HK_JOBS", "HK_JOB"],
206 deprecated_envs: &["HK_JOBS_OLD"],
207 ..PropMeta::new("jobs", Ty::Uint)
208 },
209 PropMeta {
210 parse: Some(Parser::ListByComma),
211 envs: &["HK_EXCLUDE"],
212 ..PropMeta::new("exclude", Ty::List(&Ty::String))
213 },
214 PropMeta {
215 scope: Scope::Global,
216 envs: &["HK_TRUSTED"],
217 ..PropMeta::new("trusted", Ty::Bool)
218 },
219 PropMeta::new("stash", Ty::String),
221 PropMeta {
222 envs: &["HK_CONCURRENCY"],
223 deprecated_envs: &["HK_CONCURRENCY_OLD"],
224 deprecated: Some("Use jobs instead."),
225 renamed_to: Some("jobs"),
226 ..PropMeta::new("concurrency", Ty::Uint)
227 },
228 PropMeta {
231 merge: crate::registry::Merge::Union,
232 parse: Some(Parser::ListByComma),
233 envs: &["HK_SKIP"],
234 ..PropMeta::new("skip", Ty::List(&Ty::String))
235 },
236 PropMeta {
237 merge: crate::registry::Merge::Union,
238 parse: Some(Parser::ListByComma),
239 envs: &["HK_SKIP_STEPS"],
240 deprecated: Some("Use skip instead."),
241 renamed_to: Some("skip"),
242 ..PropMeta::new("skip_steps", Ty::List(&Ty::String))
243 },
244 PropMeta {
247 envs: &["HK_THREADS"],
248 deprecated: Some("Use jobs instead."),
249 renamed_to: Some("jobs"),
250 ..PropMeta::new("threads", Ty::Uint)
251 },
252 ];
253 const REGISTRY: Registry = Registry::new(PROPS);
254
255 fn env(vars: &[(&str, &str)]) -> EnvLayer {
256 EnvLayer::new(
257 vars.iter()
258 .map(|(k, v)| ((*k).to_string(), (*v).to_string())),
259 )
260 }
261
262 #[test]
263 fn a_setting_is_read_from_the_variables_it_declares() {
264 let layer = env(&[
265 ("HK_JOBS", "8"),
266 ("HK_EXCLUDE", "target,dist"),
267 ("PATH", "/bin"),
268 ]);
269 let resolved = resolve(REGISTRY, Layers::new().then(&layer)).expect("resolves");
270
271 assert_eq!(resolved.get_key("jobs"), Some(&Value::Int(8)));
272 assert_eq!(
273 resolved.get_key("exclude"),
274 Some(&Value::List(vec![
275 Value::from("target"),
276 Value::from("dist")
277 ]))
278 );
279 assert!(resolved.warnings.is_empty(), "{:?}", resolved.warnings);
282 assert_eq!(
284 resolved.origin_key("jobs").map(|o| o.describe()),
285 Some("HK_JOBS")
286 );
287 }
288
289 #[test]
290 fn the_first_name_a_setting_declares_is_the_one_that_wins() {
291 let layer = env(&[("HK_JOB", "2"), ("HK_JOBS", "8")]);
295 let resolved = resolve(REGISTRY, Layers::new().then(&layer)).expect("resolves");
296 assert_eq!(resolved.get_key("jobs"), Some(&Value::Int(8)));
297 assert_eq!(
298 resolved.contributors_key("jobs").len(),
299 2,
300 "the default and one variable, not both variables: {:?}",
301 resolved.contributors_key("jobs")
302 );
303
304 let layer = env(&[("HK_JOB", "2")]);
306 let resolved = resolve(REGISTRY, Layers::new().then(&layer)).expect("resolves");
307 assert_eq!(resolved.get_key("jobs"), Some(&Value::Int(2)));
308 assert_eq!(
309 resolved.origin_key("jobs").map(|o| o.describe()),
310 Some("HK_JOB"),
311 "named as the user set it"
312 );
313 }
314
315 #[test]
316 fn a_deprecated_environment_alias_is_read_last_and_warned_about() {
317 let resolved =
318 resolve(REGISTRY, Layers::new().then(&env(&[("HK_JOBS_OLD", "3")]))).expect("resolves");
319 assert_eq!(resolved.get_key("jobs"), Some(&Value::Int(3)));
320 let warnings = crate::explain::warnings(&resolved);
321 assert_eq!(warnings.len(), 1, "{warnings:?}");
322 assert!(
323 warnings[0].contains("HK_JOBS_OLD is deprecated"),
324 "{warnings:?}"
325 );
326 assert!(warnings[0].contains("use HK_JOBS"), "{warnings:?}");
327
328 let resolved = resolve(
329 REGISTRY,
330 Layers::new().then(&env(&[("HK_JOBS", "8"), ("HK_JOBS_OLD", "3")])),
331 )
332 .expect("resolves");
333 assert_eq!(resolved.get_key("jobs"), Some(&Value::Int(8)));
334 assert!(resolved.warnings.is_empty(), "{:?}", resolved.warnings);
335 }
336
337 #[test]
338 fn a_renamed_settings_deprecated_environment_alias_names_the_replacement() {
339 let resolved = resolve(
340 REGISTRY,
341 Layers::new().then(&env(&[("HK_CONCURRENCY_OLD", "6")])),
342 )
343 .expect("resolves");
344 assert_eq!(resolved.get_key("jobs"), Some(&Value::Int(6)));
345 let warnings = crate::explain::warnings(&resolved);
346 assert!(
347 warnings.iter().any(|warning| warning
348 .starts_with("HK_CONCURRENCY_OLD is deprecated; use HK_JOBS for jobs")),
349 "{warnings:?}"
350 );
351 assert!(
352 !warnings
353 .iter()
354 .any(|warning| warning.contains("use HK_CONCURRENCY for concurrency")),
355 "the deprecated alias should direct the user to the folded replacement: {warnings:?}"
356 );
357 }
358
359 #[test]
360 fn a_current_renamed_variable_beats_a_deprecated_target_alias() {
361 let resolved = resolve(
362 REGISTRY,
363 Layers::new().then(&env(&[("HK_JOBS_OLD", "3"), ("HK_CONCURRENCY", "6")])),
364 )
365 .expect("resolves");
366 assert_eq!(resolved.get_key("jobs"), Some(&Value::Int(6)));
367 assert_eq!(
368 resolved.origin_key("jobs").map(|origin| origin.describe()),
369 Some("HK_CONCURRENCY")
370 );
371 let warnings = crate::explain::warnings(&resolved);
372 assert!(
373 warnings
374 .iter()
375 .any(|warning| warning.starts_with("HK_JOBS_OLD was not read: HK_CONCURRENCY")),
376 "{warnings:?}"
377 );
378 assert!(
379 !warnings
380 .iter()
381 .any(|warning| warning.contains("HK_JOBS_OLD is deprecated")),
382 "an unused deprecated alias should not produce an actionable warning: {warnings:?}"
383 );
384 }
385
386 #[test]
387 fn a_variable_that_is_not_set_leaves_the_default_alone() {
388 let layer = env(&[]);
389 let resolved = resolve(REGISTRY, Layers::new().then(&layer)).expect("resolves");
390 assert_eq!(resolved.get_key("jobs"), Some(&Value::Int(4)));
391 assert_eq!(resolved.get_key("exclude"), None);
392 assert!(resolved.warnings.is_empty());
393 }
394
395 #[test]
396 fn a_value_of_the_wrong_type_costs_its_own_variable_and_nothing_else() {
397 let layer = env(&[("HK_JOBS", "lots"), ("HK_EXCLUDE", "target")]);
400 let resolved = resolve(REGISTRY, Layers::new().then(&layer)).expect("resolves");
401 assert_eq!(
402 resolved.get_key("jobs"),
403 Some(&Value::Int(4)),
404 "the default"
405 );
406 assert_eq!(
407 resolved.get_key("exclude"),
408 Some(&Value::List(vec![Value::from("target")]))
409 );
410 let warnings = crate::explain::warnings(&resolved);
411 assert_eq!(warnings.len(), 1, "{warnings:?}");
412 assert!(warnings[0].contains("HK_JOBS"), "{warnings:?}");
413 assert!(warnings[0].contains("but has `lots`"), "{warnings:?}");
414 }
415
416 #[test]
417 fn an_environment_variable_may_set_a_global_scoped_setting() {
418 let layer = env(&[("HK_TRUSTED", "yes")]);
421 let resolved = resolve(REGISTRY, Layers::new().then(&layer)).expect("resolves");
422 assert_eq!(resolved.get_key("trusted"), Some(&Value::Bool(true)));
423 assert!(resolved.warnings.is_empty(), "{:?}", resolved.warnings);
424 }
425
426 #[test]
427 fn an_old_name_in_the_environment_is_folded_and_reported() {
428 let layer = env(&[("HK_CONCURRENCY", "6")]);
431 let resolved = resolve(REGISTRY, Layers::new().then(&layer)).expect("resolves");
432 assert_eq!(resolved.get_key("jobs"), Some(&Value::Int(6)));
433 let warnings = crate::explain::warnings(&resolved);
436 assert_eq!(warnings.len(), 2, "{warnings:?}");
437 assert!(
438 warnings[0].starts_with("concurrency is deprecated: Use jobs instead."),
439 "{warnings:?}"
440 );
441 assert!(
442 warnings[1].starts_with("concurrency was read as jobs"),
443 "{warnings:?}"
444 );
445 assert!(
446 warnings.iter().all(|w| w.contains("HK_CONCURRENCY")),
447 "{warnings:?}"
448 );
449 }
450
451 #[test]
452 fn a_setting_own_variable_beats_the_one_it_replaced() {
453 let layer = env(&[("HK_CONCURRENCY", "6"), ("HK_JOBS", "8")]);
457 let resolved = resolve(REGISTRY, Layers::new().then(&layer)).expect("resolves");
458 assert_eq!(resolved.get_key("jobs"), Some(&Value::Int(8)));
459 assert_eq!(
460 resolved.origin_key("jobs").map(|o| o.describe()),
461 Some("HK_JOBS"),
462 "the name that is not deprecated"
463 );
464 let warnings = crate::explain::warnings(&resolved);
469 assert_eq!(
470 warnings,
471 vec!["HK_CONCURRENCY was not read: HK_JOBS also sets jobs (HK_CONCURRENCY)"]
472 );
473 }
474
475 #[test]
476 fn one_of_two_old_names_is_chosen_and_the_other_is_reported() {
477 let layer = env(&[("HK_CONCURRENCY", "6"), ("HK_THREADS", "9")]);
481 let resolved = resolve(REGISTRY, Layers::new().then(&layer)).expect("resolves");
482 assert_eq!(resolved.get_key("jobs"), Some(&Value::Int(6)));
483 let warnings = crate::explain::warnings(&resolved);
484 assert!(
485 warnings
486 .iter()
487 .any(|w| w.starts_with("HK_THREADS was not read: HK_CONCURRENCY also sets jobs")),
488 "{warnings:?}"
489 );
490 assert_eq!(
495 resolved.warnings.iter().map(|w| w.kind).collect::<Vec<_>>(),
496 vec![
497 WarningKind::NotRead,
498 WarningKind::Deprecated,
499 WarningKind::Renamed
500 ]
501 );
502
503 let layer = env(&[
505 ("HK_CONCURRENCY", "6"),
506 ("HK_THREADS", "9"),
507 ("HK_JOBS", "8"),
508 ]);
509 let resolved = resolve(REGISTRY, Layers::new().then(&layer)).expect("resolves");
510 assert_eq!(resolved.get_key("jobs"), Some(&Value::Int(8)));
511 }
512
513 #[test]
514 fn an_old_name_that_does_not_read_does_not_speak_for_the_setting() {
515 let layer = env(&[("HK_CONCURRENCY", "lots"), ("HK_THREADS", "9")]);
520 let resolved = resolve(REGISTRY, Layers::new().then(&layer)).expect("resolves");
521 assert_eq!(resolved.get_key("jobs"), Some(&Value::Int(9)));
522
523 let warnings = crate::explain::warnings(&resolved);
524 assert!(
525 warnings
526 .iter()
527 .any(|w| w.contains("HK_CONCURRENCY") && w.contains("but has `lots`")),
528 "{warnings:?}"
529 );
530 assert!(
531 !warnings.iter().any(|w| w.contains("was not read")),
532 "nothing was passed over: {warnings:?}"
533 );
534 }
535
536 #[test]
537 fn an_old_name_does_not_contribute_to_a_union_beside_the_new_one() {
538 let layer = env(&[("HK_SKIP_STEPS", "lint"), ("HK_SKIP", "test")]);
542 let resolved = resolve(REGISTRY, Layers::new().then(&layer)).expect("resolves");
543 assert_eq!(
544 resolved.get_key("skip"),
545 Some(&Value::List(vec![Value::from("test")])),
546 "only the name that is not deprecated"
547 );
548 let warnings = crate::explain::warnings(&resolved);
549 assert_eq!(
550 warnings,
551 vec!["HK_SKIP_STEPS was not read: HK_SKIP also sets skip (HK_SKIP_STEPS)"]
552 );
553
554 let layer = env(&[("HK_SKIP_STEPS", "lint")]);
556 let resolved = resolve(REGISTRY, Layers::new().then(&layer)).expect("resolves");
557 assert_eq!(
558 resolved.get_key("skip"),
559 Some(&Value::List(vec![Value::from("lint")]))
560 );
561 }
562
563 #[test]
564 fn a_variable_that_is_not_text_is_skipped_rather_than_fatal() {
565 let layer = EnvLayer::from_process();
570 let resolved = resolve(REGISTRY, Layers::new().then(&layer)).expect("resolves");
571 assert_eq!(resolved.get_key("stash"), None);
574 }
575
576 #[cfg(unix)]
577 #[test]
578 fn bytes_that_are_not_text_are_not_a_variable() {
579 use std::os::unix::ffi::OsStrExt;
580 let ok = readable(OsStr::new("HK_JOBS"), OsStr::new("8"));
581 assert_eq!(ok, Some(("HK_JOBS".to_string(), "8".to_string())));
582 let bad_value = readable(OsStr::new("HK_JOBS"), OsStr::from_bytes(&[0xff, 0xfe]));
584 assert_eq!(bad_value, None);
585 let bad_name = readable(OsStr::from_bytes(&[0xff, 0xfe]), OsStr::new("8"));
586 assert_eq!(bad_name, None);
587 }
588
589 #[test]
590 fn the_environment_is_described_rather_than_reached_for() {
591 let layer = env(&[("HK_JOBS", "8")]);
594 assert_eq!(layer.get("HK_JOBS"), Some("8"));
595 assert_eq!(layer.get("HK_NOTHING"), None);
596 let _ = EnvLayer::from_process();
598 }
599
600 #[cfg(windows)]
601 #[test]
602 fn a_name_that_windows_spells_differently_is_still_the_same_name() {
603 let layer = env(&[("Hk_Jobs", "8")]);
606 let resolved = resolve(REGISTRY, Layers::new().then(&layer)).expect("resolves");
607 assert_eq!(resolved.get_key("jobs"), Some(&Value::Int(8)));
608 assert_eq!(
611 resolved.origin_key("jobs").map(|o| o.describe()),
612 Some("Hk_Jobs")
613 );
614 }
615}