1use std::cmp::Ordering;
13use std::collections::BTreeMap;
14
15use crate::layer::{Layer, LayerCtx, LayerError, Warning, WarningKind};
16use crate::registry::{Merge, PropId, Registry, Scope};
17use crate::source::{Origin, SourceKind, Trust};
18use crate::value::Value;
19
20#[derive(Debug, Clone)]
22pub struct Resolved {
23 values: Vec<Option<Value>>,
26 provenance: Vec<Option<Origin>>,
28 contributors: BTreeMap<PropId, Vec<Origin>>,
30 pub warnings: Vec<Warning>,
32 registry: Registry,
33}
34
35impl Resolved {
36 pub fn get(&self, id: PropId) -> Option<&Value> {
38 self.values.get(id.index()).and_then(Option::as_ref)
39 }
40
41 pub fn get_key(&self, key: &str) -> Option<&Value> {
43 self.get(self.registry.lookup(key)?.id)
44 }
45
46 pub fn origin(&self, id: PropId) -> Option<&Origin> {
48 self.provenance.get(id.index()).and_then(Option::as_ref)
49 }
50
51 pub fn origin_key(&self, key: &str) -> Option<&Origin> {
60 self.origin(self.registry.lookup(key)?.id)
61 }
62
63 pub fn contributors(&self, id: PropId) -> &[Origin] {
68 self.contributors
69 .get(&id)
70 .map(Vec::as_slice)
71 .unwrap_or_default()
72 }
73
74 pub fn contributors_key(&self, key: &str) -> &[Origin] {
80 match self.registry.lookup(key) {
81 Some(found) => self.contributors(found.id),
82 None => &[],
83 }
84 }
85
86 pub fn registry(&self) -> Registry {
87 self.registry
88 }
89
90 pub fn coerced(&mut self, id: PropId, value: Value, why: impl Into<String>) {
97 let index = id.index();
98 if index >= self.values.len() {
99 return;
100 }
101 let origin = Origin::new(SourceKind::COERCED, why);
102 self.values[index] = Some(value);
103 self.provenance[index] = Some(origin.clone());
104 self.contributors.entry(id).or_default().push(origin);
108 }
109}
110
111#[derive(Default)]
117pub struct Layers<'a> {
118 layers: Vec<&'a dyn Layer>,
119}
120
121impl<'a> Layers<'a> {
122 pub fn new() -> Self {
123 Self::default()
124 }
125
126 pub fn then(mut self, layer: &'a dyn Layer) -> Self {
128 self.layers.push(layer);
129 self
130 }
131
132 pub fn len(&self) -> usize {
133 self.layers.len()
134 }
135
136 pub fn is_empty(&self) -> bool {
137 self.layers.is_empty()
138 }
139}
140
141#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
148pub struct ResolutionContext<'a> {
149 cli_version: Option<&'a str>,
150}
151
152impl<'a> ResolutionContext<'a> {
153 pub const fn new() -> Self {
159 Self { cli_version: None }
160 }
161
162 pub const fn for_cli_version(version: &'a str) -> Self {
164 Self {
165 cli_version: Some(version),
166 }
167 }
168
169 pub const fn cli_version(self) -> Option<&'a str> {
171 self.cli_version
172 }
173}
174
175pub fn resolve(registry: Registry, layers: Layers<'_>) -> Result<Resolved, LayerError> {
184 resolve_with_context(registry, layers, ResolutionContext::new())
185}
186
187pub fn resolve_with_context(
199 registry: Registry,
200 layers: Layers<'_>,
201 context: ResolutionContext<'_>,
202) -> Result<Resolved, LayerError> {
203 let ctx = LayerCtx::new(registry);
204 let count = registry.props.len();
205 let mut resolved = Resolved {
206 values: vec![None; count],
207 provenance: vec![None; count],
208 contributors: BTreeMap::new(),
209 warnings: Vec::new(),
210 registry,
211 };
212
213 for id in registry.ids() {
219 if registry.get(id).renamed_to.is_some() {
223 continue;
224 }
225 if let Some(default) = registry.get(id).default {
226 let index = id.index();
227 resolved.values[index] = Some(default.to_value());
228 resolved.provenance[index] = Some(Origin::declared_default());
229 resolved
230 .contributors
231 .entry(id)
232 .or_default()
233 .push(Origin::declared_default());
234 }
235 }
236
237 let mut outputs = Vec::with_capacity(layers.len());
241 for layer in layers.layers.iter().rev() {
242 outputs.push(layer.load(&ctx)?);
243 }
244
245 for output in outputs {
246 resolved.warnings.extend(output.warnings);
247 for entry in output.entries {
248 let written = registry.get(entry.prop);
249 let (prop, meta) = match written
254 .renamed_to
255 .and_then(|new_key| registry.lookup(new_key))
256 {
257 Some(target) => (target.id, registry.get(target.id)),
258 None => (entry.prop, written),
259 };
260 let written_key = entry
264 .written_key
265 .or(entry.renamed_from)
266 .unwrap_or(written.key);
267 if let Some(refusal) = refuse(meta.scope, &entry.origin) {
268 resolved.warnings.push(
273 Warning::at(format!("{written_key} {refusal}"), entry.origin)
274 .of(WarningKind::OutOfScope),
275 );
276 continue;
277 }
278 if let Some(deprecated) = registry.deprecation_meta(written_key) {
283 let why = deprecated.deprecated.expect("deprecated declaration");
284 if milestone_reached(context.cli_version, deprecated.deprecated_remove_at)
285 == Some(true)
286 {
287 let at = deprecated
288 .deprecated_remove_at
289 .expect("reached removal milestone");
290 resolved.warnings.push(
291 Warning::at(
292 format!("{written_key} was removed at {at}: {why}"),
293 entry.origin,
294 )
295 .of(WarningKind::Removed),
296 );
297 continue;
298 }
299 if milestone_reached(context.cli_version, deprecated.deprecated_warn_at)
300 != Some(false)
301 {
302 resolved.warnings.push(
303 Warning::at(
304 format!("{written_key} is deprecated: {why}"),
305 entry.origin.clone(),
306 )
307 .of(WarningKind::Deprecated),
308 );
309 }
310 }
311 if entry.renamed_from.is_some() || prop != entry.prop {
312 resolved.warnings.push(
314 Warning::at(
315 format!("{written_key} was read as {}", meta.key),
316 entry.origin.clone(),
317 )
318 .of(WarningKind::Renamed),
319 );
320 }
321 let index = prop.index();
322 let merged = match meta.merge {
323 Merge::Replace => entry.value,
324 Merge::Union => union(
328 resolved.values[index]
329 .take()
330 .unwrap_or(Value::List(Vec::new())),
331 entry.value,
332 meta.ty,
333 ),
334 Merge::Deep => match resolved.values[index].take() {
335 Some(existing) => deep(existing, entry.value),
336 None => entry.value,
337 },
338 };
339 resolved.values[index] = Some(merged);
340 resolved.provenance[index] = Some(entry.origin.clone());
343 resolved
348 .contributors
349 .entry(prop)
350 .or_default()
351 .push(entry.origin);
352 }
353 }
354
355 Ok(resolved)
356}
357
358fn milestone_reached(current: Option<&str>, milestone: Option<&str>) -> Option<bool> {
363 let ordering = compare_versions(current?, milestone?)?;
364 Some(ordering != Ordering::Less)
365}
366
367fn compare_versions(a: &str, b: &str) -> Option<Ordering> {
372 let (a_core, a_pre) = split_version(a);
373 let (b_core, b_pre) = split_version(b);
374 let mut a_segments = a_core.split('.');
375 let mut b_segments = b_core.split('.');
376 loop {
377 let (a_next, b_next) = (a_segments.next(), b_segments.next());
378 if a_next.is_none() && b_next.is_none() {
379 break;
380 }
381 match version_segment(a_next)?.cmp(&version_segment(b_next)?) {
382 Ordering::Equal => continue,
383 ordering => return Some(ordering),
384 }
385 }
386 Some(match (a_pre, b_pre) {
387 (None, None) => Ordering::Equal,
388 (Some(_), None) => Ordering::Less,
389 (None, Some(_)) => Ordering::Greater,
390 (Some(a), Some(b)) => a.cmp(b),
391 })
392}
393
394fn split_version(version: &str) -> (&str, Option<&str>) {
395 let version = version.split('+').next().unwrap_or(version);
396 match version.split_once('-') {
397 Some((core, pre)) => (core, Some(pre)),
398 None => (version, None),
399 }
400}
401
402fn version_segment(segment: Option<&str>) -> Option<u64> {
403 match segment {
404 None => Some(0),
405 Some(text) => text.parse().ok(),
406 }
407}
408
409fn refuse(scope: Scope, origin: &Origin) -> Option<&'static str> {
414 match scope {
415 Scope::Any => None,
416 Scope::Global if origin.trust < Trust::Operator => {
424 Some("cannot be set by anything a project can carry")
425 }
426 Scope::Env if origin.trust < Trust::Invocation => {
427 Some("can only be set in the environment or on the command line")
428 }
429 _ => None,
430 }
431}
432
433fn union(existing: Value, incoming: Value, ty: crate::ty::Ty) -> Value {
435 if matches!(&incoming, Value::List(items) if items.is_empty()) {
440 return Value::List(Vec::new());
441 }
442 let mut items = match existing {
443 Value::List(items) => items,
444 single => vec![single],
445 };
446 match incoming {
447 Value::List(more) => items.extend(more),
448 single => items.push(single),
449 }
450 if matches!(ty.inner(), crate::ty::Ty::Set(_)) {
451 let mut seen: Vec<Value> = Vec::with_capacity(items.len());
454 items.retain(|item| {
455 let fresh = !seen.contains(item);
456 if fresh {
457 seen.push(item.clone());
458 }
459 fresh
460 });
461 }
462 Value::List(items)
463}
464
465fn deep(existing: Value, incoming: Value) -> Value {
467 match (existing, incoming) {
468 (Value::Map(mut base), Value::Map(overlay)) => {
469 for (key, value) in overlay {
470 let merged = match base.remove(&key) {
471 Some(existing @ Value::Map(_)) => deep(existing, value),
474 _ => value,
475 };
476 base.insert(key, merged);
477 }
478 Value::Map(base)
479 }
480 (_, incoming) => incoming,
483 }
484}
485
486#[cfg(test)]
487mod tests {
488 use super::*;
489 use crate::layer::{Entry, LayerOutput};
490 use crate::registry::PropMeta;
491 use crate::source::{FileScope, Trust};
492 use crate::ty::Ty;
493 use crate::value::Const;
494
495 static PROPS: &[PropMeta] = &[
496 PropMeta {
497 default: Some(Const::Int(4)),
498 ..PropMeta::new("jobs", Ty::Uint)
499 },
500 PropMeta {
501 merge: Merge::Union,
502 ..PropMeta::new("exclude", Ty::List(&Ty::String))
503 },
504 PropMeta {
505 merge: Merge::Union,
506 ..PropMeta::new("tags", Ty::Set(&Ty::String))
507 },
508 PropMeta {
509 merge: Merge::Union,
510 default: Some(Const::List(&[Const::Str("target")])),
511 ..PropMeta::new("excluded", Ty::List(&Ty::String))
512 },
513 PropMeta {
514 merge: Merge::Deep,
515 ..PropMeta::new("urls", Ty::Map(&Ty::String))
516 },
517 PropMeta {
518 scope: Scope::Global,
519 ..PropMeta::new("trusted", Ty::Bool)
520 },
521 PropMeta {
524 renamed_to: Some("trusted"),
525 ..PropMeta::new("old_trusted", Ty::Bool)
526 },
527 PropMeta {
528 scope: Scope::Env,
529 ..PropMeta::new("config_file", Ty::Path)
530 },
531 PropMeta {
532 deprecated: Some("Use jobs instead."),
533 ..PropMeta::new("old_jobs", Ty::Uint)
534 },
535 PropMeta {
537 deprecated: Some("Use jobs instead."),
538 renamed_to: Some("jobs"),
539 default: Some(Const::Int(7)),
542 ..PropMeta::new("renamed_jobs", Ty::Uint)
543 },
544 PropMeta::new("undeclared_default", Ty::String),
545 ];
546 const REGISTRY: Registry = Registry::new(PROPS);
547
548 #[test]
549 fn every_warning_says_what_sort_of_thing_it_is() {
550 let ctx = LayerCtx::new(REGISTRY);
555 let file = Origin::file("hk.toml", FileScope::Project);
556
557 let unknown = ctx
559 .entry_for_key("nonesuch", "1", file.clone())
560 .expect_err("no such setting");
561 assert_eq!(unknown.kind, WarningKind::UnknownSetting);
562 let wrong = ctx
563 .entry_for_key("jobs", "lots", file.clone())
564 .expect_err("not a number");
565 assert_eq!(wrong.kind, WarningKind::WrongType);
566 let shaped = ctx
567 .entry_from_value("jobs", Value::from("lots"), file.clone())
568 .expect_err("still not a number");
569 assert_eq!(shaped.kind, WarningKind::WrongType);
570
571 let layer = Fixed {
574 kind: SourceKind::FILE,
575 entries: vec![
576 Entry::new(id("trusted"), Value::Bool(true), file.clone()),
577 Entry::new(raw_id("renamed_jobs"), Value::Int(8), file),
580 ],
581 };
582 let resolved = resolve(REGISTRY, Layers::new().then(&layer)).expect("resolves");
583 let kinds: Vec<WarningKind> = resolved.warnings.iter().map(|w| w.kind).collect();
584 assert_eq!(
585 kinds,
586 vec![
587 WarningKind::OutOfScope,
588 WarningKind::Deprecated,
589 WarningKind::Renamed
590 ],
591 "{:?}",
592 resolved.warnings
593 );
594
595 assert_eq!(
597 Warning::new("the git config could not be read").kind,
598 WarningKind::Other
599 );
600 }
601
602 struct Fixed {
604 kind: SourceKind,
605 entries: Vec<Entry>,
606 }
607
608 impl Layer for Fixed {
609 fn source(&self) -> SourceKind {
610 self.kind
611 }
612
613 fn load(&self, _ctx: &LayerCtx) -> Result<LayerOutput, LayerError> {
614 Ok(LayerOutput {
615 entries: self.entries.clone(),
616 warnings: Vec::new(),
617 })
618 }
619 }
620
621 fn id(key: &str) -> PropId {
622 REGISTRY.lookup(key).expect("declared").id
623 }
624
625 fn raw_id(key: &str) -> PropId {
630 let index = PROPS
631 .iter()
632 .position(|meta| meta.key == key)
633 .expect("declared");
634 PropId(index as u16)
635 }
636
637 fn layer(kind: SourceKind, entries: Vec<(&str, Value, Origin)>) -> Fixed {
638 Fixed {
639 kind,
640 entries: entries
641 .into_iter()
642 .map(|(key, value, origin)| Entry::new(id(key), value, origin))
643 .collect(),
644 }
645 }
646
647 #[test]
648 fn the_highest_layer_wins_and_says_so() {
649 let cli = layer(
650 SourceKind::CLI,
651 vec![(
652 "jobs",
653 Value::Int(1),
654 Origin::new(SourceKind::CLI, "--jobs"),
655 )],
656 );
657 let env = layer(
658 SourceKind::ENV,
659 vec![(
660 "jobs",
661 Value::Int(2),
662 Origin::new(SourceKind::ENV, "HK_JOBS"),
663 )],
664 );
665 let file = layer(
666 SourceKind::FILE,
667 vec![(
668 "jobs",
669 Value::Int(3),
670 Origin::file("hk.toml", FileScope::Project),
671 )],
672 );
673
674 let resolved = resolve(REGISTRY, Layers::new().then(&cli).then(&env).then(&file))
675 .expect("should resolve");
676
677 assert_eq!(resolved.get_key("jobs"), Some(&Value::Int(1)));
678 assert_eq!(resolved.origin(id("jobs")).unwrap().describe(), "--jobs");
681 let contributors: Vec<_> = resolved
684 .contributors(id("jobs"))
685 .iter()
686 .map(|o| o.describe().to_string())
687 .collect();
688 assert_eq!(
689 contributors,
690 ["the default", "hk.toml", "HK_JOBS", "--jobs"]
691 );
692 }
693
694 #[test]
695 fn a_declared_default_is_the_floor_and_is_marked_as_one() {
696 let resolved = resolve(REGISTRY, Layers::new()).expect("should resolve");
697 assert_eq!(resolved.get_key("jobs"), Some(&Value::Int(4)));
698 assert_eq!(
699 resolved.origin(id("jobs")).unwrap().kind,
700 SourceKind::DEFAULTS
701 );
702 assert_eq!(resolved.get_key("undeclared_default"), None);
705 assert_eq!(resolved.origin(id("undeclared_default")), None);
706 }
707
708 #[test]
709 fn a_union_setting_takes_from_every_layer_lowest_first() {
710 let env = layer(
711 SourceKind::ENV,
712 vec![(
713 "exclude",
714 Value::List(vec![Value::from("target")]),
715 Origin::new(SourceKind::ENV, "HK_EXCLUDE"),
716 )],
717 );
718 let file = layer(
719 SourceKind::FILE,
720 vec![(
721 "exclude",
722 Value::List(vec![Value::from("vendor")]),
723 Origin::file("hk.toml", FileScope::Project),
724 )],
725 );
726 let resolved =
727 resolve(REGISTRY, Layers::new().then(&env).then(&file)).expect("should resolve");
728 assert_eq!(
729 resolved.get_key("exclude"),
730 Some(&Value::List(vec![
731 Value::from("vendor"),
732 Value::from("target")
733 ])),
734 "lower precedence first, so the most specific reads last"
735 );
736 assert_eq!(resolved.contributors(id("exclude")).len(), 2);
738 }
739
740 #[test]
741 fn a_set_keeps_the_first_of_each() {
742 let a = layer(
743 SourceKind::ENV,
744 vec![(
745 "tags",
746 Value::List(vec![Value::from("x"), Value::from("y")]),
747 Origin::new(SourceKind::ENV, "TAGS"),
748 )],
749 );
750 let b = layer(
751 SourceKind::FILE,
752 vec![(
753 "tags",
754 Value::List(vec![Value::from("y"), Value::from("z")]),
755 Origin::file("hk.toml", FileScope::Project),
756 )],
757 );
758 let resolved = resolve(REGISTRY, Layers::new().then(&a).then(&b)).expect("should resolve");
759 assert_eq!(
760 resolved.get_key("tags"),
761 Some(&Value::List(vec![
762 Value::from("y"),
763 Value::from("z"),
764 Value::from("x")
765 ])),
766 "y was first mentioned by the file, so it stays where it was"
767 );
768 }
769
770 #[test]
771 fn a_deep_setting_merges_tables_key_by_key() {
772 let map = |pairs: &[(&str, &str)]| {
773 Value::Map(
774 pairs
775 .iter()
776 .map(|(k, v)| ((*k).to_string(), Value::from(*v)))
777 .collect(),
778 )
779 };
780 let env = layer(
781 SourceKind::ENV,
782 vec![(
783 "urls",
784 map(&[("a", "from-env")]),
785 Origin::new(SourceKind::ENV, "URLS"),
786 )],
787 );
788 let file = layer(
789 SourceKind::FILE,
790 vec![(
791 "urls",
792 map(&[("a", "from-file"), ("b", "only-in-file")]),
793 Origin::file("hk.toml", FileScope::Project),
794 )],
795 );
796 let resolved =
797 resolve(REGISTRY, Layers::new().then(&env).then(&file)).expect("should resolve");
798 assert_eq!(
799 resolved.get_key("urls"),
800 Some(&map(&[("a", "from-env"), ("b", "only-in-file")])),
801 "the higher layer wins its own key without dropping the other's"
802 );
803 }
804
805 #[test]
806 fn a_scope_refuses_what_it_says_it_refuses() {
807 let project = layer(
811 SourceKind::FILE,
812 vec![
813 (
814 "trusted",
815 Value::Bool(true),
816 Origin::file("hk.toml", FileScope::Project),
817 ),
818 (
819 "config_file",
820 Value::from("/tmp/x"),
821 Origin::file("hk.toml", FileScope::Project),
822 ),
823 ],
824 );
825 let resolved = resolve(REGISTRY, Layers::new().then(&project)).expect("should resolve");
826 assert_eq!(resolved.get_key("trusted"), None);
827 assert_eq!(resolved.get_key("config_file"), None);
828 let messages: Vec<_> = resolved
831 .warnings
832 .iter()
833 .map(|w| w.message.clone())
834 .collect();
835 assert_eq!(
836 messages,
837 [
838 "trusted cannot be set by anything a project can carry",
839 "config_file can only be set in the environment or on the command line",
840 ]
841 );
842
843 let global = layer(
845 SourceKind::FILE,
846 vec![(
847 "trusted",
848 Value::Bool(true),
849 Origin::file("~/.config/hk.toml", FileScope::Global),
850 )],
851 );
852 let env = layer(
853 SourceKind::ENV,
854 vec![(
855 "config_file",
856 Value::from("/tmp/x"),
857 Origin::new(SourceKind::ENV, "HK_CONFIG_FILE"),
858 )],
859 );
860 let resolved =
861 resolve(REGISTRY, Layers::new().then(&env).then(&global)).expect("should resolve");
862 assert_eq!(resolved.get_key("trusted"), Some(&Value::Bool(true)));
863 assert_eq!(
864 resolved.get_key("config_file"),
865 Some(&Value::from("/tmp/x"))
866 );
867 assert!(resolved.warnings.is_empty(), "{:?}", resolved.warnings);
868 }
869
870 #[test]
871 fn using_a_deprecated_setting_says_so_once_per_place_it_was_set() {
872 let file = layer(
873 SourceKind::FILE,
874 vec![(
875 "old_jobs",
876 Value::Int(2),
877 Origin::file("hk.toml", FileScope::Project),
878 )],
879 );
880 let resolved = resolve(REGISTRY, Layers::new().then(&file)).expect("should resolve");
881 assert_eq!(resolved.get_key("old_jobs"), Some(&Value::Int(2)));
883 assert_eq!(
884 resolved.warnings[0].message,
885 "old_jobs is deprecated: Use jobs instead."
886 );
887 assert_eq!(
888 resolved.warnings[0].origin.as_ref().unwrap().describe(),
889 "hk.toml"
890 );
891 }
892
893 static GATED_PROPS: &[PropMeta] = &[PropMeta {
894 default: Some(Const::Int(1)),
895 deprecated: Some("Use modern instead."),
896 deprecated_warn_at: Some("2.0.0"),
897 deprecated_remove_at: Some("3.0.0"),
898 ..PropMeta::new("legacy", Ty::Uint)
899 }];
900 const GATED: Registry = Registry::new(GATED_PROPS);
901
902 fn gated_layer() -> Fixed {
903 Fixed {
904 kind: SourceKind::FILE,
905 entries: vec![Entry::new(
906 PropId(0),
907 Value::Int(8),
908 Origin::file("app.toml", FileScope::Project),
909 )],
910 }
911 }
912
913 #[test]
914 fn deprecation_versions_gate_warnings_and_configured_values_at_boundaries() {
915 let cases = [
916 ("1.9.9", Some(8), None),
917 ("2.0.0-rc.1", Some(8), None),
918 ("2", Some(8), Some(WarningKind::Deprecated)),
919 ("2.9.9", Some(8), Some(WarningKind::Deprecated)),
920 ("3.0.0-rc.1", Some(8), Some(WarningKind::Deprecated)),
921 ("3.0.0", Some(1), Some(WarningKind::Removed)),
922 ("4.0.0", Some(1), Some(WarningKind::Removed)),
923 ];
924
925 for (version, expected, kind) in cases {
926 let layer = gated_layer();
927 let resolved = resolve_with_context(
928 GATED,
929 Layers::new().then(&layer),
930 ResolutionContext::for_cli_version(version),
931 )
932 .expect(version);
933 assert_eq!(
934 resolved.get_key("legacy"),
935 expected.map(Value::Int).as_ref(),
936 "{version}"
937 );
938 assert_eq!(
939 resolved.warnings.iter().map(|warning| warning.kind).next(),
940 kind,
941 "{version}: {:?}",
942 resolved.warnings
943 );
944 }
945 }
946
947 #[test]
948 fn a_reached_removal_is_ignored_out_loud_and_falls_back_to_the_default() {
949 let layer = gated_layer();
950 let resolved = resolve_with_context(
951 GATED,
952 Layers::new().then(&layer),
953 ResolutionContext::for_cli_version("3.0.0+build.7"),
954 )
955 .expect("resolves");
956
957 assert_eq!(resolved.get_key("legacy"), Some(&Value::Int(1)));
958 assert_eq!(
959 resolved.origin_key("legacy").unwrap().describe(),
960 "the default"
961 );
962 assert_eq!(resolved.contributors_key("legacy").len(), 1);
963 assert_eq!(resolved.warnings[0].kind, WarningKind::Removed);
964 assert_eq!(
965 resolved.warnings[0].message,
966 "legacy was removed at 3.0.0: Use modern instead."
967 );
968 assert_eq!(
969 resolved.warnings[0].origin.as_ref().unwrap().describe(),
970 "app.toml"
971 );
972 }
973
974 #[test]
975 fn no_or_unreadable_version_warns_without_discarding_configuration() {
976 for context in [
977 ResolutionContext::new(),
978 ResolutionContext::for_cli_version("nightly"),
979 ResolutionContext::for_cli_version(""),
980 ] {
981 let layer = gated_layer();
982 let resolved =
983 resolve_with_context(GATED, Layers::new().then(&layer), context).expect("resolves");
984 assert_eq!(resolved.get_key("legacy"), Some(&Value::Int(8)));
985 assert_eq!(resolved.warnings.len(), 1);
986 assert_eq!(resolved.warnings[0].kind, WarningKind::Deprecated);
987 assert_eq!(
988 resolved.warnings[0].message,
989 "legacy is deprecated: Use modern instead."
990 );
991 }
992
993 let layer = gated_layer();
994 let compatible = resolve(GATED, Layers::new().then(&layer)).expect("resolves");
995 assert_eq!(compatible.get_key("legacy"), Some(&Value::Int(8)));
996 assert_eq!(compatible.warnings[0].kind, WarningKind::Deprecated);
997 }
998
999 #[test]
1000 fn unreadable_milestones_warn_but_never_remove() {
1001 static INVALID_PROPS: &[PropMeta] = &[PropMeta {
1002 deprecated: Some("Use modern instead."),
1003 deprecated_warn_at: Some("next"),
1004 deprecated_remove_at: Some("eventually"),
1005 ..PropMeta::new("legacy", Ty::Uint)
1006 }];
1007 const INVALID: Registry = Registry::new(INVALID_PROPS);
1008 let layer = Fixed {
1009 kind: SourceKind::FILE,
1010 entries: vec![Entry::new(
1011 PropId(0),
1012 Value::Int(8),
1013 Origin::file("app.toml", FileScope::Project),
1014 )],
1015 };
1016 let resolved = resolve_with_context(
1017 INVALID,
1018 Layers::new().then(&layer),
1019 ResolutionContext::for_cli_version("99.0.0"),
1020 )
1021 .expect("resolves");
1022
1023 assert_eq!(resolved.get_key("legacy"), Some(&Value::Int(8)));
1024 assert_eq!(resolved.warnings[0].kind, WarningKind::Deprecated);
1025 }
1026
1027 #[test]
1028 fn version_comparison_matches_the_argv_spec_rule() {
1029 assert_eq!(
1030 compare_versions("2026.12", "2026.12.0"),
1031 Some(Ordering::Equal)
1032 );
1033 assert_eq!(
1034 compare_versions("2027.1.0", "2026.12.99"),
1035 Some(Ordering::Greater)
1036 );
1037 assert_eq!(
1038 compare_versions("1.0.0-rc.1", "1.0.0"),
1039 Some(Ordering::Less)
1040 );
1041 assert_eq!(
1042 compare_versions("1.0.0+one", "1.0.0+two"),
1043 Some(Ordering::Equal)
1044 );
1045 assert_eq!(compare_versions("nightly", "2.0.0"), None);
1046 assert_eq!(compare_versions("2.0.0", "whenever"), None);
1047 assert_eq!(compare_versions("", "2.0.0"), None);
1048 }
1049
1050 #[test]
1051 fn a_value_the_cli_rewrote_says_it_was_rewritten() {
1052 let env = layer(
1056 SourceKind::ENV,
1057 vec![(
1058 "jobs",
1059 Value::Int(8),
1060 Origin::new(SourceKind::ENV, "HK_JOBS"),
1061 )],
1062 );
1063 let mut resolved = resolve(REGISTRY, Layers::new().then(&env)).expect("should resolve");
1064 resolved.coerced(id("jobs"), Value::Int(1), "raw implies one job");
1065 assert_eq!(resolved.get_key("jobs"), Some(&Value::Int(1)));
1066 let origin = resolved.origin(id("jobs")).unwrap();
1067 assert_eq!(origin.kind, SourceKind::COERCED);
1068 assert_eq!(origin.describe(), "raw implies one job");
1069 }
1070
1071 #[test]
1072 fn a_refusal_names_the_key_that_was_written() {
1073 struct FileLike;
1077 impl Layer for FileLike {
1078 fn source(&self) -> SourceKind {
1079 SourceKind::FILE
1080 }
1081 fn load(&self, ctx: &LayerCtx) -> Result<LayerOutput, LayerError> {
1082 let mut out = LayerOutput::new();
1083 let origin = Origin::file("hk.toml", FileScope::Project);
1084 match ctx.entry_for_key("old_trusted", "true", origin) {
1085 Ok(entry) => out.push(entry),
1086 Err(warning) => out.warn(warning),
1087 }
1088 Ok(out)
1089 }
1090 }
1091 let file = FileLike;
1092 let resolved = resolve(REGISTRY, Layers::new().then(&file)).expect("should resolve");
1093 assert_eq!(resolved.get_key("trusted"), None);
1094 let messages: Vec<_> = resolved
1095 .warnings
1096 .iter()
1097 .map(|w| w.message.clone())
1098 .collect();
1099 assert!(
1100 messages
1101 .iter()
1102 .any(|m| m.starts_with("old_trusted cannot be set")),
1103 "the refusal should name the key in the file: {messages:?}"
1104 );
1105 }
1106
1107 #[test]
1108 fn a_refused_custom_source_is_not_called_a_config_file() {
1109 let pkl = layer(
1113 SourceKind::new("pkl"),
1114 vec![(
1115 "trusted",
1116 Value::Bool(true),
1117 Origin::new(SourceKind::new("pkl"), "hk.pkl"),
1118 )],
1119 );
1120 let resolved = resolve(REGISTRY, Layers::new().then(&pkl)).expect("should resolve");
1121 assert_eq!(
1122 resolved.warnings[0].message,
1123 "trusted cannot be set by anything a project can carry"
1124 );
1125 assert_eq!(
1127 resolved.warnings[0].origin.as_ref().unwrap().describe(),
1128 "hk.pkl"
1129 );
1130 }
1131
1132 #[test]
1133 fn a_custom_source_is_held_to_the_same_scope_as_a_file() {
1134 let pkl = layer(
1139 SourceKind::new("pkl"),
1140 vec![
1141 (
1142 "trusted",
1143 Value::Bool(true),
1144 Origin::new(SourceKind::new("pkl"), "hk.pkl"),
1145 ),
1146 (
1147 "config_file",
1148 Value::from("/tmp/x"),
1149 Origin::new(SourceKind::new("pkl"), "hk.pkl"),
1150 ),
1151 ],
1152 );
1153 let resolved = resolve(REGISTRY, Layers::new().then(&pkl)).expect("should resolve");
1154 assert_eq!(resolved.get_key("trusted"), None);
1155 assert_eq!(resolved.get_key("config_file"), None);
1156 assert_eq!(resolved.warnings.len(), 2, "{:?}", resolved.warnings);
1157
1158 let global_pkl = layer(
1161 SourceKind::new("pkl"),
1162 vec![
1163 (
1164 "trusted",
1165 Value::Bool(true),
1166 Origin::new(SourceKind::new("pkl"), "~/.config/hk.pkl")
1167 .trusted_as(Trust::Operator),
1168 ),
1169 (
1170 "config_file",
1171 Value::from("/tmp/x"),
1172 Origin::new(SourceKind::new("pkl"), "~/.config/hk.pkl")
1173 .trusted_as(Trust::Operator),
1174 ),
1175 ],
1176 );
1177 let resolved = resolve(REGISTRY, Layers::new().then(&global_pkl)).expect("should resolve");
1178 assert_eq!(resolved.get_key("trusted"), Some(&Value::Bool(true)));
1179 assert_eq!(resolved.get_key("config_file"), None);
1180 }
1181
1182 #[test]
1183 fn a_value_written_under_an_old_key_lands_on_the_new_one() {
1184 let git = Fixed {
1189 kind: SourceKind::new("git"),
1190 entries: vec![Entry::new(
1191 raw_id("renamed_jobs"),
1192 Value::Int(3),
1193 Origin::new(SourceKind::new("git"), "hk.renamedJobs"),
1194 )],
1195 };
1196 let resolved = resolve(REGISTRY, Layers::new().then(&git)).expect("should resolve");
1197 assert_eq!(
1198 resolved.get_key("jobs"),
1199 Some(&Value::Int(3)),
1200 "the old key's value should land on the setting that replaced it"
1201 );
1202 let messages: Vec<_> = resolved
1204 .warnings
1205 .iter()
1206 .map(|w| w.message.clone())
1207 .collect();
1208 assert!(
1209 messages.contains(&"renamed_jobs was read as jobs".to_string()),
1210 "{messages:?}"
1211 );
1212 assert!(
1213 messages.iter().any(|m| m.contains("is deprecated")),
1214 "{messages:?}"
1215 );
1216 }
1217
1218 #[test]
1219 fn a_set_drops_a_repeat_from_one_source_too() {
1220 let one = layer(
1223 SourceKind::ENV,
1224 vec![(
1225 "tags",
1226 Value::List(vec![Value::from("a"), Value::from("b"), Value::from("a")]),
1227 Origin::new(SourceKind::ENV, "TAGS"),
1228 )],
1229 );
1230 let resolved = resolve(REGISTRY, Layers::new().then(&one)).expect("should resolve");
1231 assert_eq!(
1232 resolved.get_key("tags"),
1233 Some(&Value::List(vec![Value::from("a"), Value::from("b")]))
1234 );
1235 }
1236
1237 #[test]
1238 fn a_collection_default_takes_part_in_the_merge() {
1239 let env = layer(
1243 SourceKind::ENV,
1244 vec![(
1245 "excluded",
1246 Value::List(vec![Value::from("from-env")]),
1247 Origin::new(SourceKind::ENV, "EXCLUDED"),
1248 )],
1249 );
1250 let resolved = resolve(REGISTRY, Layers::new().then(&env)).expect("should resolve");
1251 assert_eq!(
1252 resolved.get_key("excluded"),
1253 Some(&Value::List(vec![
1254 Value::from("target"),
1255 Value::from("from-env")
1256 ])),
1257 "the default's items are the lowest-precedence contribution, not a fallback"
1258 );
1259 assert_eq!(
1261 resolved.contributors(id("excluded"))[0].describe(),
1262 "the default"
1263 );
1264 }
1265
1266 #[test]
1267 fn the_winning_origin_is_always_the_last_contributor() {
1268 let cli = layer(
1272 SourceKind::CLI,
1273 vec![(
1274 "jobs",
1275 Value::Int(1),
1276 Origin::new(SourceKind::CLI, "--jobs"),
1277 )],
1278 );
1279 let env = layer(
1280 SourceKind::ENV,
1281 vec![
1282 (
1283 "jobs",
1284 Value::Int(2),
1285 Origin::new(SourceKind::ENV, "HK_JOBS"),
1286 ),
1287 (
1288 "excluded",
1289 Value::List(vec![Value::from("from-env")]),
1290 Origin::new(SourceKind::ENV, "EXCLUDED"),
1291 ),
1292 (
1293 "tags",
1294 Value::List(vec![Value::from("a"), Value::from("a")]),
1295 Origin::new(SourceKind::ENV, "TAGS"),
1296 ),
1297 ],
1298 );
1299 let renamed = Fixed {
1300 kind: SourceKind::new("git"),
1301 entries: vec![Entry::new(
1302 raw_id("renamed_jobs"),
1303 Value::Int(9),
1304 Origin::new(SourceKind::new("git"), "hk.renamedJobs"),
1305 )],
1306 };
1307 let resolved = resolve(REGISTRY, Layers::new().then(&cli).then(&env).then(&renamed))
1308 .expect("should resolve");
1309
1310 for id in REGISTRY.ids() {
1311 let key = REGISTRY.get(id).key;
1312 match (resolved.origin(id), resolved.contributors(id).last()) {
1313 (Some(winner), Some(last)) => assert_eq!(
1314 winner, last,
1315 "{key}: the winning origin is not the last contributor"
1316 ),
1317 (None, None) => {}
1318 (winner, last) => panic!("{key}: origin {winner:?} but contributors end {last:?}"),
1319 }
1320 }
1321 let contributors: Vec<_> = resolved
1323 .contributors(id("jobs"))
1324 .iter()
1325 .map(|o| o.describe().to_string())
1326 .collect();
1327 assert!(
1328 contributors.contains(&"hk.renamedJobs".to_string()),
1329 "a value read through a rename contributed and should say so: {contributors:?}"
1330 );
1331 }
1332
1333 #[test]
1334 fn a_deprecated_key_is_reported_however_the_layer_found_it() {
1335 struct FileLike;
1340 impl Layer for FileLike {
1341 fn source(&self) -> SourceKind {
1342 SourceKind::FILE
1343 }
1344 fn load(&self, ctx: &LayerCtx) -> Result<LayerOutput, LayerError> {
1345 let mut out = LayerOutput::new();
1346 let origin = Origin::file("hk.toml", FileScope::Project);
1347 match ctx.entry_for_key("renamed_jobs", "5", origin) {
1348 Ok(entry) => out.push(entry),
1349 Err(warning) => out.warn(warning),
1350 }
1351 Ok(out)
1352 }
1353 }
1354 let file = FileLike;
1355 let resolved = resolve(REGISTRY, Layers::new().then(&file)).expect("should resolve");
1356 assert_eq!(resolved.get_key("jobs"), Some(&Value::Int(5)));
1357 let messages: Vec<_> = resolved
1358 .warnings
1359 .iter()
1360 .map(|w| w.message.clone())
1361 .collect();
1362 assert!(
1363 messages.contains(&"renamed_jobs is deprecated: Use jobs instead.".to_string()),
1364 "{messages:?}"
1365 );
1366 assert!(
1367 messages.contains(&"renamed_jobs was read as jobs".to_string()),
1368 "{messages:?}"
1369 );
1370 }
1371
1372 #[test]
1373 fn a_notice_further_along_a_chain_of_renames_is_still_given() {
1374 static PROPS: &[PropMeta] = &[
1380 PropMeta {
1381 default: Some(Const::Int(1)),
1382 ..PropMeta::new("jobs", Ty::Uint)
1383 },
1384 PropMeta {
1385 renamed_to: Some("jobs"),
1386 deprecated: Some("Use jobs instead."),
1387 ..PropMeta::new("concurrency", Ty::Uint)
1388 },
1389 PropMeta {
1390 renamed_to: Some("concurrency"),
1391 ..PropMeta::new("threads", Ty::Uint)
1392 },
1393 ];
1394 const CHAINED: Registry = Registry::new(PROPS);
1395
1396 struct Wrote;
1397 impl Layer for Wrote {
1398 fn source(&self) -> SourceKind {
1399 SourceKind::FILE
1400 }
1401 fn load(&self, ctx: &LayerCtx) -> Result<LayerOutput, LayerError> {
1402 let mut out = LayerOutput::new();
1403 let origin = Origin::file("hk.toml", FileScope::Project);
1404 match ctx.entry_for_key("threads", "8", origin) {
1405 Ok(entry) => out.push(entry),
1406 Err(warning) => out.warn(warning),
1407 }
1408 Ok(out)
1409 }
1410 }
1411 let resolved = resolve(CHAINED, Layers::new().then(&Wrote)).expect("should resolve");
1412 assert_eq!(resolved.get_key("jobs"), Some(&Value::Int(8)));
1413 let kinds: Vec<_> = resolved.warnings.iter().map(|w| w.kind).collect();
1414 assert_eq!(kinds, vec![WarningKind::Deprecated, WarningKind::Renamed]);
1415 assert!(
1417 resolved.warnings[0].message == "threads is deprecated: Use jobs instead.",
1418 "{:?}",
1419 resolved.warnings[0].message
1420 );
1421 }
1422
1423 #[test]
1424 fn an_unknown_key_is_a_warning_rather_than_a_failure() {
1425 struct Stray;
1428 impl Layer for Stray {
1429 fn source(&self) -> SourceKind {
1430 SourceKind::FILE
1431 }
1432 fn load(&self, ctx: &LayerCtx) -> Result<LayerOutput, LayerError> {
1433 let mut out = LayerOutput::new();
1434 let origin = Origin::file("hk.toml", FileScope::Project);
1435 match ctx.entry_for_key("from_the_future", "1", origin) {
1436 Ok(entry) => out.push(entry),
1437 Err(warning) => out.warn(warning),
1438 }
1439 Ok(out)
1440 }
1441 }
1442 let stray = Stray;
1443 let resolved = resolve(REGISTRY, Layers::new().then(&stray)).expect("should resolve");
1444 assert_eq!(
1445 resolved.warnings[0].message,
1446 "unknown setting `from_the_future`"
1447 );
1448 }
1449
1450 #[test]
1451 fn an_alias_does_not_carry_a_default_of_its_own() {
1452 let resolved = resolve(REGISTRY, Layers::new()).expect("should resolve");
1455 assert_eq!(
1456 resolved.get(raw_id("renamed_jobs")),
1457 None,
1458 "an alias should hold nothing at all"
1459 );
1460 assert_eq!(resolved.get_key("jobs"), Some(&Value::Int(4)));
1461 }
1462
1463 #[test]
1464 fn an_explicit_empty_list_clears_a_union_default() {
1465 let env = layer(
1469 SourceKind::ENV,
1470 vec![(
1471 "excluded",
1472 Value::List(Vec::new()),
1473 Origin::new(SourceKind::ENV, "EXCLUDED"),
1474 )],
1475 );
1476 let resolved = resolve(REGISTRY, Layers::new().then(&env)).expect("should resolve");
1477 assert_eq!(resolved.get_key("excluded"), Some(&Value::List(Vec::new())));
1478 }
1479
1480 #[test]
1481 fn a_rewrite_stays_the_last_contributor() {
1482 let env = layer(
1486 SourceKind::ENV,
1487 vec![(
1488 "jobs",
1489 Value::Int(8),
1490 Origin::new(SourceKind::ENV, "HK_JOBS"),
1491 )],
1492 );
1493 let mut resolved = resolve(REGISTRY, Layers::new().then(&env)).expect("should resolve");
1494 resolved.coerced(id("jobs"), Value::Int(1), "raw implies one job");
1495 assert_eq!(
1496 resolved.origin(id("jobs")),
1497 resolved.contributors(id("jobs")).last()
1498 );
1499 }
1500
1501 #[test]
1502 fn a_layer_that_cannot_read_its_source_stops_the_resolution() {
1503 struct Broken;
1507 impl Layer for Broken {
1508 fn source(&self) -> SourceKind {
1509 SourceKind::FILE
1510 }
1511 fn load(&self, _ctx: &LayerCtx) -> Result<LayerOutput, LayerError> {
1512 Err(LayerError::Unreadable {
1513 source: "hk.toml".to_string(),
1514 why: "expected a value at line 3".to_string(),
1515 })
1516 }
1517 }
1518 let broken = Broken;
1519 let err = resolve(REGISTRY, Layers::new().then(&broken)).expect_err("should fail");
1520 assert_eq!(
1521 err.to_string(),
1522 "could not read hk.toml: expected a value at line 3"
1523 );
1524 }
1525
1526 #[test]
1527 fn provenance_answers_to_a_key_the_way_a_value_does() {
1528 let file = layer(
1533 SourceKind::FILE,
1534 vec![(
1535 "jobs",
1536 Value::Int(4),
1538 Origin::file("hk.toml", FileScope::Project),
1539 )],
1540 );
1541 let resolved = resolve(REGISTRY, Layers::new().then(&file)).expect("resolves");
1542
1543 assert_eq!(resolved.get_key("jobs"), Some(&Value::Int(4)));
1544 let origin = resolved.origin_key("jobs").expect("set by the file");
1545 assert_eq!(origin.kind, SourceKind::FILE);
1546 assert!(origin.describe().contains("hk.toml"));
1547 assert_ne!(origin.kind, SourceKind::DEFAULTS);
1549
1550 let untouched = resolve(REGISTRY, Layers::new()).expect("resolves");
1552 assert_eq!(
1553 untouched.origin_key("jobs").map(|o| o.kind),
1554 Some(SourceKind::DEFAULTS)
1555 );
1556 assert!(untouched.origin_key("undeclared_default").is_none());
1558 }
1559
1560 #[test]
1561 fn provenance_by_key_follows_a_rename_like_a_value_does() {
1562 let env = layer(
1565 SourceKind::ENV,
1566 vec![(
1567 "jobs",
1568 Value::Int(8),
1569 Origin::new(SourceKind::ENV, "HK_JOBS"),
1570 )],
1571 );
1572 let resolved = resolve(REGISTRY, Layers::new().then(&env)).expect("resolves");
1573
1574 assert_eq!(resolved.get_key("renamed_jobs"), Some(&Value::Int(8)));
1575 assert_eq!(
1576 resolved.origin_key("renamed_jobs").map(|o| o.describe()),
1577 Some("HK_JOBS")
1578 );
1579 assert_eq!(
1580 resolved.contributors_key("renamed_jobs"),
1581 resolved.contributors_key("jobs")
1582 );
1583
1584 assert!(resolved.origin_key("nonesuch").is_none());
1587 assert!(resolved.contributors_key("nonesuch").is_empty());
1588 }
1589
1590 #[test]
1591 fn contributors_by_key_lists_a_merged_setting_in_merge_order() {
1592 let user = layer(
1593 SourceKind::FILE,
1594 vec![(
1595 "excluded",
1596 Value::List(vec![Value::from("vendor")]),
1597 Origin::file("~/.config/hk.toml", FileScope::Global),
1598 )],
1599 );
1600 let project = layer(
1601 SourceKind::FILE,
1602 vec![(
1603 "excluded",
1604 Value::List(vec![Value::from("dist")]),
1605 Origin::file("hk.toml", FileScope::Project),
1606 )],
1607 );
1608 let resolved =
1609 resolve(REGISTRY, Layers::new().then(&project).then(&user)).expect("resolves");
1610
1611 let described: Vec<&str> = resolved
1612 .contributors_key("excluded")
1613 .iter()
1614 .map(|o| o.describe())
1615 .collect();
1616 assert_eq!(described.len(), 3, "{described:?}");
1618 assert_eq!(described[0], "the default", "{described:?}");
1619 assert!(described[1].contains(".config"), "{described:?}");
1620 assert!(described[2].contains("hk.toml"), "{described:?}");
1621 }
1622}