use std::cmp::Ordering;
use std::collections::BTreeMap;
use crate::layer::{Layer, LayerCtx, LayerError, Warning, WarningKind};
use crate::registry::{Merge, PropId, Registry, Scope};
use crate::source::{Origin, SourceKind, Trust};
use crate::value::Value;
#[derive(Debug, Clone)]
pub struct Resolved {
values: Vec<Option<Value>>,
provenance: Vec<Option<Origin>>,
contributors: BTreeMap<PropId, Vec<Origin>>,
pub warnings: Vec<Warning>,
registry: Registry,
}
impl Resolved {
pub fn get(&self, id: PropId) -> Option<&Value> {
self.values.get(id.index()).and_then(Option::as_ref)
}
pub fn get_key(&self, key: &str) -> Option<&Value> {
self.get(self.registry.lookup(key)?.id)
}
pub fn origin(&self, id: PropId) -> Option<&Origin> {
self.provenance.get(id.index()).and_then(Option::as_ref)
}
pub fn origin_key(&self, key: &str) -> Option<&Origin> {
self.origin(self.registry.lookup(key)?.id)
}
pub fn contributors(&self, id: PropId) -> &[Origin] {
self.contributors
.get(&id)
.map(Vec::as_slice)
.unwrap_or_default()
}
pub fn contributors_key(&self, key: &str) -> &[Origin] {
match self.registry.lookup(key) {
Some(found) => self.contributors(found.id),
None => &[],
}
}
pub fn registry(&self) -> Registry {
self.registry
}
pub fn coerced(&mut self, id: PropId, value: Value, why: impl Into<String>) {
let index = id.index();
if index >= self.values.len() {
return;
}
let origin = Origin::new(SourceKind::COERCED, why);
self.values[index] = Some(value);
self.provenance[index] = Some(origin.clone());
self.contributors.entry(id).or_default().push(origin);
}
}
#[derive(Default)]
pub struct Layers<'a> {
layers: Vec<&'a dyn Layer>,
}
impl<'a> Layers<'a> {
pub fn new() -> Self {
Self::default()
}
pub fn then(mut self, layer: &'a dyn Layer) -> Self {
self.layers.push(layer);
self
}
pub fn len(&self) -> usize {
self.layers.len()
}
pub fn is_empty(&self) -> bool {
self.layers.is_empty()
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct ResolutionContext<'a> {
cli_version: Option<&'a str>,
}
impl<'a> ResolutionContext<'a> {
pub const fn new() -> Self {
Self { cli_version: None }
}
pub const fn for_cli_version(version: &'a str) -> Self {
Self {
cli_version: Some(version),
}
}
pub const fn cli_version(self) -> Option<&'a str> {
self.cli_version
}
}
pub fn resolve(registry: Registry, layers: Layers<'_>) -> Result<Resolved, LayerError> {
resolve_with_context(registry, layers, ResolutionContext::new())
}
pub fn resolve_with_context(
registry: Registry,
layers: Layers<'_>,
context: ResolutionContext<'_>,
) -> Result<Resolved, LayerError> {
let ctx = LayerCtx::new(registry);
let count = registry.props.len();
let mut resolved = Resolved {
values: vec![None; count],
provenance: vec![None; count],
contributors: BTreeMap::new(),
warnings: Vec::new(),
registry,
};
for id in registry.ids() {
if registry.get(id).renamed_to.is_some() {
continue;
}
if let Some(default) = registry.get(id).default {
let index = id.index();
resolved.values[index] = Some(default.to_value());
resolved.provenance[index] = Some(Origin::declared_default());
resolved
.contributors
.entry(id)
.or_default()
.push(Origin::declared_default());
}
}
let mut outputs = Vec::with_capacity(layers.len());
for layer in layers.layers.iter().rev() {
outputs.push(layer.load(&ctx)?);
}
for output in outputs {
resolved.warnings.extend(output.warnings);
for entry in output.entries {
let written = registry.get(entry.prop);
let (prop, meta) = match written
.renamed_to
.and_then(|new_key| registry.lookup(new_key))
{
Some(target) => (target.id, registry.get(target.id)),
None => (entry.prop, written),
};
let written_key = entry
.written_key
.or(entry.renamed_from)
.unwrap_or(written.key);
if let Some(refusal) = refuse(meta.scope, &entry.origin) {
resolved.warnings.push(
Warning::at(format!("{written_key} {refusal}"), entry.origin)
.of(WarningKind::OutOfScope),
);
continue;
}
if let Some(deprecated) = registry.deprecation_meta(written_key) {
let why = deprecated.deprecated.expect("deprecated declaration");
if milestone_reached(context.cli_version, deprecated.deprecated_remove_at)
== Some(true)
{
let at = deprecated
.deprecated_remove_at
.expect("reached removal milestone");
resolved.warnings.push(
Warning::at(
format!("{written_key} was removed at {at}: {why}"),
entry.origin,
)
.of(WarningKind::Removed),
);
continue;
}
if milestone_reached(context.cli_version, deprecated.deprecated_warn_at)
!= Some(false)
{
resolved.warnings.push(
Warning::at(
format!("{written_key} is deprecated: {why}"),
entry.origin.clone(),
)
.of(WarningKind::Deprecated),
);
}
}
if entry.renamed_from.is_some() || prop != entry.prop {
resolved.warnings.push(
Warning::at(
format!("{written_key} was read as {}", meta.key),
entry.origin.clone(),
)
.of(WarningKind::Renamed),
);
}
let index = prop.index();
let merged = match meta.merge {
Merge::Replace => entry.value,
Merge::Union => union(
resolved.values[index]
.take()
.unwrap_or(Value::List(Vec::new())),
entry.value,
meta.ty,
),
Merge::Deep => match resolved.values[index].take() {
Some(existing) => deep(existing, entry.value),
None => entry.value,
},
};
resolved.values[index] = Some(merged);
resolved.provenance[index] = Some(entry.origin.clone());
resolved
.contributors
.entry(prop)
.or_default()
.push(entry.origin);
}
}
Ok(resolved)
}
fn milestone_reached(current: Option<&str>, milestone: Option<&str>) -> Option<bool> {
let ordering = compare_versions(current?, milestone?)?;
Some(ordering != Ordering::Less)
}
fn compare_versions(a: &str, b: &str) -> Option<Ordering> {
let (a_core, a_pre) = split_version(a);
let (b_core, b_pre) = split_version(b);
let mut a_segments = a_core.split('.');
let mut b_segments = b_core.split('.');
loop {
let (a_next, b_next) = (a_segments.next(), b_segments.next());
if a_next.is_none() && b_next.is_none() {
break;
}
match version_segment(a_next)?.cmp(&version_segment(b_next)?) {
Ordering::Equal => continue,
ordering => return Some(ordering),
}
}
Some(match (a_pre, b_pre) {
(None, None) => Ordering::Equal,
(Some(_), None) => Ordering::Less,
(None, Some(_)) => Ordering::Greater,
(Some(a), Some(b)) => a.cmp(b),
})
}
fn split_version(version: &str) -> (&str, Option<&str>) {
let version = version.split('+').next().unwrap_or(version);
match version.split_once('-') {
Some((core, pre)) => (core, Some(pre)),
None => (version, None),
}
}
fn version_segment(segment: Option<&str>) -> Option<u64> {
match segment {
None => Some(0),
Some(text) => text.parse().ok(),
}
}
fn refuse(scope: Scope, origin: &Origin) -> Option<&'static str> {
match scope {
Scope::Any => None,
Scope::Global if origin.trust < Trust::Operator => {
Some("cannot be set by anything a project can carry")
}
Scope::Env if origin.trust < Trust::Invocation => {
Some("can only be set in the environment or on the command line")
}
_ => None,
}
}
fn union(existing: Value, incoming: Value, ty: crate::ty::Ty) -> Value {
if matches!(&incoming, Value::List(items) if items.is_empty()) {
return Value::List(Vec::new());
}
let mut items = match existing {
Value::List(items) => items,
single => vec![single],
};
match incoming {
Value::List(more) => items.extend(more),
single => items.push(single),
}
if matches!(ty.inner(), crate::ty::Ty::Set(_)) {
let mut seen: Vec<Value> = Vec::with_capacity(items.len());
items.retain(|item| {
let fresh = !seen.contains(item);
if fresh {
seen.push(item.clone());
}
fresh
});
}
Value::List(items)
}
fn deep(existing: Value, incoming: Value) -> Value {
match (existing, incoming) {
(Value::Map(mut base), Value::Map(overlay)) => {
for (key, value) in overlay {
let merged = match base.remove(&key) {
Some(existing @ Value::Map(_)) => deep(existing, value),
_ => value,
};
base.insert(key, merged);
}
Value::Map(base)
}
(_, incoming) => incoming,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::layer::{Entry, LayerOutput};
use crate::registry::PropMeta;
use crate::source::{FileScope, Trust};
use crate::ty::Ty;
use crate::value::Const;
static PROPS: &[PropMeta] = &[
PropMeta {
default: Some(Const::Int(4)),
..PropMeta::new("jobs", Ty::Uint)
},
PropMeta {
merge: Merge::Union,
..PropMeta::new("exclude", Ty::List(&Ty::String))
},
PropMeta {
merge: Merge::Union,
..PropMeta::new("tags", Ty::Set(&Ty::String))
},
PropMeta {
merge: Merge::Union,
default: Some(Const::List(&[Const::Str("target")])),
..PropMeta::new("excluded", Ty::List(&Ty::String))
},
PropMeta {
merge: Merge::Deep,
..PropMeta::new("urls", Ty::Map(&Ty::String))
},
PropMeta {
scope: Scope::Global,
..PropMeta::new("trusted", Ty::Bool)
},
PropMeta {
renamed_to: Some("trusted"),
..PropMeta::new("old_trusted", Ty::Bool)
},
PropMeta {
scope: Scope::Env,
..PropMeta::new("config_file", Ty::Path)
},
PropMeta {
deprecated: Some("Use jobs instead."),
..PropMeta::new("old_jobs", Ty::Uint)
},
PropMeta {
deprecated: Some("Use jobs instead."),
renamed_to: Some("jobs"),
default: Some(Const::Int(7)),
..PropMeta::new("renamed_jobs", Ty::Uint)
},
PropMeta::new("undeclared_default", Ty::String),
];
const REGISTRY: Registry = Registry::new(PROPS);
#[test]
fn every_warning_says_what_sort_of_thing_it_is() {
let ctx = LayerCtx::new(REGISTRY);
let file = Origin::file("hk.toml", FileScope::Project);
let unknown = ctx
.entry_for_key("nonesuch", "1", file.clone())
.expect_err("no such setting");
assert_eq!(unknown.kind, WarningKind::UnknownSetting);
let wrong = ctx
.entry_for_key("jobs", "lots", file.clone())
.expect_err("not a number");
assert_eq!(wrong.kind, WarningKind::WrongType);
let shaped = ctx
.entry_from_value("jobs", Value::from("lots"), file.clone())
.expect_err("still not a number");
assert_eq!(shaped.kind, WarningKind::WrongType);
let layer = Fixed {
kind: SourceKind::FILE,
entries: vec![
Entry::new(id("trusted"), Value::Bool(true), file.clone()),
Entry::new(raw_id("renamed_jobs"), Value::Int(8), file),
],
};
let resolved = resolve(REGISTRY, Layers::new().then(&layer)).expect("resolves");
let kinds: Vec<WarningKind> = resolved.warnings.iter().map(|w| w.kind).collect();
assert_eq!(
kinds,
vec![
WarningKind::OutOfScope,
WarningKind::Deprecated,
WarningKind::Renamed
],
"{:?}",
resolved.warnings
);
assert_eq!(
Warning::new("the git config could not be read").kind,
WarningKind::Other
);
}
struct Fixed {
kind: SourceKind,
entries: Vec<Entry>,
}
impl Layer for Fixed {
fn source(&self) -> SourceKind {
self.kind
}
fn load(&self, _ctx: &LayerCtx) -> Result<LayerOutput, LayerError> {
Ok(LayerOutput {
entries: self.entries.clone(),
warnings: Vec::new(),
})
}
}
fn id(key: &str) -> PropId {
REGISTRY.lookup(key).expect("declared").id
}
fn raw_id(key: &str) -> PropId {
let index = PROPS
.iter()
.position(|meta| meta.key == key)
.expect("declared");
PropId(index as u16)
}
fn layer(kind: SourceKind, entries: Vec<(&str, Value, Origin)>) -> Fixed {
Fixed {
kind,
entries: entries
.into_iter()
.map(|(key, value, origin)| Entry::new(id(key), value, origin))
.collect(),
}
}
#[test]
fn the_highest_layer_wins_and_says_so() {
let cli = layer(
SourceKind::CLI,
vec![(
"jobs",
Value::Int(1),
Origin::new(SourceKind::CLI, "--jobs"),
)],
);
let env = layer(
SourceKind::ENV,
vec![(
"jobs",
Value::Int(2),
Origin::new(SourceKind::ENV, "HK_JOBS"),
)],
);
let file = layer(
SourceKind::FILE,
vec![(
"jobs",
Value::Int(3),
Origin::file("hk.toml", FileScope::Project),
)],
);
let resolved = resolve(REGISTRY, Layers::new().then(&cli).then(&env).then(&file))
.expect("should resolve");
assert_eq!(resolved.get_key("jobs"), Some(&Value::Int(1)));
assert_eq!(resolved.origin(id("jobs")).unwrap().describe(), "--jobs");
let contributors: Vec<_> = resolved
.contributors(id("jobs"))
.iter()
.map(|o| o.describe().to_string())
.collect();
assert_eq!(
contributors,
["the default", "hk.toml", "HK_JOBS", "--jobs"]
);
}
#[test]
fn a_declared_default_is_the_floor_and_is_marked_as_one() {
let resolved = resolve(REGISTRY, Layers::new()).expect("should resolve");
assert_eq!(resolved.get_key("jobs"), Some(&Value::Int(4)));
assert_eq!(
resolved.origin(id("jobs")).unwrap().kind,
SourceKind::DEFAULTS
);
assert_eq!(resolved.get_key("undeclared_default"), None);
assert_eq!(resolved.origin(id("undeclared_default")), None);
}
#[test]
fn a_union_setting_takes_from_every_layer_lowest_first() {
let env = layer(
SourceKind::ENV,
vec![(
"exclude",
Value::List(vec![Value::from("target")]),
Origin::new(SourceKind::ENV, "HK_EXCLUDE"),
)],
);
let file = layer(
SourceKind::FILE,
vec![(
"exclude",
Value::List(vec![Value::from("vendor")]),
Origin::file("hk.toml", FileScope::Project),
)],
);
let resolved =
resolve(REGISTRY, Layers::new().then(&env).then(&file)).expect("should resolve");
assert_eq!(
resolved.get_key("exclude"),
Some(&Value::List(vec![
Value::from("vendor"),
Value::from("target")
])),
"lower precedence first, so the most specific reads last"
);
assert_eq!(resolved.contributors(id("exclude")).len(), 2);
}
#[test]
fn a_set_keeps_the_first_of_each() {
let a = layer(
SourceKind::ENV,
vec![(
"tags",
Value::List(vec![Value::from("x"), Value::from("y")]),
Origin::new(SourceKind::ENV, "TAGS"),
)],
);
let b = layer(
SourceKind::FILE,
vec![(
"tags",
Value::List(vec![Value::from("y"), Value::from("z")]),
Origin::file("hk.toml", FileScope::Project),
)],
);
let resolved = resolve(REGISTRY, Layers::new().then(&a).then(&b)).expect("should resolve");
assert_eq!(
resolved.get_key("tags"),
Some(&Value::List(vec![
Value::from("y"),
Value::from("z"),
Value::from("x")
])),
"y was first mentioned by the file, so it stays where it was"
);
}
#[test]
fn a_deep_setting_merges_tables_key_by_key() {
let map = |pairs: &[(&str, &str)]| {
Value::Map(
pairs
.iter()
.map(|(k, v)| ((*k).to_string(), Value::from(*v)))
.collect(),
)
};
let env = layer(
SourceKind::ENV,
vec![(
"urls",
map(&[("a", "from-env")]),
Origin::new(SourceKind::ENV, "URLS"),
)],
);
let file = layer(
SourceKind::FILE,
vec![(
"urls",
map(&[("a", "from-file"), ("b", "only-in-file")]),
Origin::file("hk.toml", FileScope::Project),
)],
);
let resolved =
resolve(REGISTRY, Layers::new().then(&env).then(&file)).expect("should resolve");
assert_eq!(
resolved.get_key("urls"),
Some(&map(&[("a", "from-env"), ("b", "only-in-file")])),
"the higher layer wins its own key without dropping the other's"
);
}
#[test]
fn a_scope_refuses_what_it_says_it_refuses() {
let project = layer(
SourceKind::FILE,
vec![
(
"trusted",
Value::Bool(true),
Origin::file("hk.toml", FileScope::Project),
),
(
"config_file",
Value::from("/tmp/x"),
Origin::file("hk.toml", FileScope::Project),
),
],
);
let resolved = resolve(REGISTRY, Layers::new().then(&project)).expect("should resolve");
assert_eq!(resolved.get_key("trusted"), None);
assert_eq!(resolved.get_key("config_file"), None);
let messages: Vec<_> = resolved
.warnings
.iter()
.map(|w| w.message.clone())
.collect();
assert_eq!(
messages,
[
"trusted cannot be set by anything a project can carry",
"config_file can only be set in the environment or on the command line",
]
);
let global = layer(
SourceKind::FILE,
vec![(
"trusted",
Value::Bool(true),
Origin::file("~/.config/hk.toml", FileScope::Global),
)],
);
let env = layer(
SourceKind::ENV,
vec![(
"config_file",
Value::from("/tmp/x"),
Origin::new(SourceKind::ENV, "HK_CONFIG_FILE"),
)],
);
let resolved =
resolve(REGISTRY, Layers::new().then(&env).then(&global)).expect("should resolve");
assert_eq!(resolved.get_key("trusted"), Some(&Value::Bool(true)));
assert_eq!(
resolved.get_key("config_file"),
Some(&Value::from("/tmp/x"))
);
assert!(resolved.warnings.is_empty(), "{:?}", resolved.warnings);
}
#[test]
fn using_a_deprecated_setting_says_so_once_per_place_it_was_set() {
let file = layer(
SourceKind::FILE,
vec![(
"old_jobs",
Value::Int(2),
Origin::file("hk.toml", FileScope::Project),
)],
);
let resolved = resolve(REGISTRY, Layers::new().then(&file)).expect("should resolve");
assert_eq!(resolved.get_key("old_jobs"), Some(&Value::Int(2)));
assert_eq!(
resolved.warnings[0].message,
"old_jobs is deprecated: Use jobs instead."
);
assert_eq!(
resolved.warnings[0].origin.as_ref().unwrap().describe(),
"hk.toml"
);
}
static GATED_PROPS: &[PropMeta] = &[PropMeta {
default: Some(Const::Int(1)),
deprecated: Some("Use modern instead."),
deprecated_warn_at: Some("2.0.0"),
deprecated_remove_at: Some("3.0.0"),
..PropMeta::new("legacy", Ty::Uint)
}];
const GATED: Registry = Registry::new(GATED_PROPS);
fn gated_layer() -> Fixed {
Fixed {
kind: SourceKind::FILE,
entries: vec![Entry::new(
PropId(0),
Value::Int(8),
Origin::file("app.toml", FileScope::Project),
)],
}
}
#[test]
fn deprecation_versions_gate_warnings_and_configured_values_at_boundaries() {
let cases = [
("1.9.9", Some(8), None),
("2.0.0-rc.1", Some(8), None),
("2", Some(8), Some(WarningKind::Deprecated)),
("2.9.9", Some(8), Some(WarningKind::Deprecated)),
("3.0.0-rc.1", Some(8), Some(WarningKind::Deprecated)),
("3.0.0", Some(1), Some(WarningKind::Removed)),
("4.0.0", Some(1), Some(WarningKind::Removed)),
];
for (version, expected, kind) in cases {
let layer = gated_layer();
let resolved = resolve_with_context(
GATED,
Layers::new().then(&layer),
ResolutionContext::for_cli_version(version),
)
.expect(version);
assert_eq!(
resolved.get_key("legacy"),
expected.map(Value::Int).as_ref(),
"{version}"
);
assert_eq!(
resolved.warnings.iter().map(|warning| warning.kind).next(),
kind,
"{version}: {:?}",
resolved.warnings
);
}
}
#[test]
fn a_reached_removal_is_ignored_out_loud_and_falls_back_to_the_default() {
let layer = gated_layer();
let resolved = resolve_with_context(
GATED,
Layers::new().then(&layer),
ResolutionContext::for_cli_version("3.0.0+build.7"),
)
.expect("resolves");
assert_eq!(resolved.get_key("legacy"), Some(&Value::Int(1)));
assert_eq!(
resolved.origin_key("legacy").unwrap().describe(),
"the default"
);
assert_eq!(resolved.contributors_key("legacy").len(), 1);
assert_eq!(resolved.warnings[0].kind, WarningKind::Removed);
assert_eq!(
resolved.warnings[0].message,
"legacy was removed at 3.0.0: Use modern instead."
);
assert_eq!(
resolved.warnings[0].origin.as_ref().unwrap().describe(),
"app.toml"
);
}
#[test]
fn no_or_unreadable_version_warns_without_discarding_configuration() {
for context in [
ResolutionContext::new(),
ResolutionContext::for_cli_version("nightly"),
ResolutionContext::for_cli_version(""),
] {
let layer = gated_layer();
let resolved =
resolve_with_context(GATED, Layers::new().then(&layer), context).expect("resolves");
assert_eq!(resolved.get_key("legacy"), Some(&Value::Int(8)));
assert_eq!(resolved.warnings.len(), 1);
assert_eq!(resolved.warnings[0].kind, WarningKind::Deprecated);
assert_eq!(
resolved.warnings[0].message,
"legacy is deprecated: Use modern instead."
);
}
let layer = gated_layer();
let compatible = resolve(GATED, Layers::new().then(&layer)).expect("resolves");
assert_eq!(compatible.get_key("legacy"), Some(&Value::Int(8)));
assert_eq!(compatible.warnings[0].kind, WarningKind::Deprecated);
}
#[test]
fn unreadable_milestones_warn_but_never_remove() {
static INVALID_PROPS: &[PropMeta] = &[PropMeta {
deprecated: Some("Use modern instead."),
deprecated_warn_at: Some("next"),
deprecated_remove_at: Some("eventually"),
..PropMeta::new("legacy", Ty::Uint)
}];
const INVALID: Registry = Registry::new(INVALID_PROPS);
let layer = Fixed {
kind: SourceKind::FILE,
entries: vec![Entry::new(
PropId(0),
Value::Int(8),
Origin::file("app.toml", FileScope::Project),
)],
};
let resolved = resolve_with_context(
INVALID,
Layers::new().then(&layer),
ResolutionContext::for_cli_version("99.0.0"),
)
.expect("resolves");
assert_eq!(resolved.get_key("legacy"), Some(&Value::Int(8)));
assert_eq!(resolved.warnings[0].kind, WarningKind::Deprecated);
}
#[test]
fn version_comparison_matches_the_argv_spec_rule() {
assert_eq!(
compare_versions("2026.12", "2026.12.0"),
Some(Ordering::Equal)
);
assert_eq!(
compare_versions("2027.1.0", "2026.12.99"),
Some(Ordering::Greater)
);
assert_eq!(
compare_versions("1.0.0-rc.1", "1.0.0"),
Some(Ordering::Less)
);
assert_eq!(
compare_versions("1.0.0+one", "1.0.0+two"),
Some(Ordering::Equal)
);
assert_eq!(compare_versions("nightly", "2.0.0"), None);
assert_eq!(compare_versions("2.0.0", "whenever"), None);
assert_eq!(compare_versions("", "2.0.0"), None);
}
#[test]
fn a_value_the_cli_rewrote_says_it_was_rewritten() {
let env = layer(
SourceKind::ENV,
vec![(
"jobs",
Value::Int(8),
Origin::new(SourceKind::ENV, "HK_JOBS"),
)],
);
let mut resolved = resolve(REGISTRY, Layers::new().then(&env)).expect("should resolve");
resolved.coerced(id("jobs"), Value::Int(1), "raw implies one job");
assert_eq!(resolved.get_key("jobs"), Some(&Value::Int(1)));
let origin = resolved.origin(id("jobs")).unwrap();
assert_eq!(origin.kind, SourceKind::COERCED);
assert_eq!(origin.describe(), "raw implies one job");
}
#[test]
fn a_refusal_names_the_key_that_was_written() {
struct FileLike;
impl Layer for FileLike {
fn source(&self) -> SourceKind {
SourceKind::FILE
}
fn load(&self, ctx: &LayerCtx) -> Result<LayerOutput, LayerError> {
let mut out = LayerOutput::new();
let origin = Origin::file("hk.toml", FileScope::Project);
match ctx.entry_for_key("old_trusted", "true", origin) {
Ok(entry) => out.push(entry),
Err(warning) => out.warn(warning),
}
Ok(out)
}
}
let file = FileLike;
let resolved = resolve(REGISTRY, Layers::new().then(&file)).expect("should resolve");
assert_eq!(resolved.get_key("trusted"), None);
let messages: Vec<_> = resolved
.warnings
.iter()
.map(|w| w.message.clone())
.collect();
assert!(
messages
.iter()
.any(|m| m.starts_with("old_trusted cannot be set")),
"the refusal should name the key in the file: {messages:?}"
);
}
#[test]
fn a_refused_custom_source_is_not_called_a_config_file() {
let pkl = layer(
SourceKind::new("pkl"),
vec![(
"trusted",
Value::Bool(true),
Origin::new(SourceKind::new("pkl"), "hk.pkl"),
)],
);
let resolved = resolve(REGISTRY, Layers::new().then(&pkl)).expect("should resolve");
assert_eq!(
resolved.warnings[0].message,
"trusted cannot be set by anything a project can carry"
);
assert_eq!(
resolved.warnings[0].origin.as_ref().unwrap().describe(),
"hk.pkl"
);
}
#[test]
fn a_custom_source_is_held_to_the_same_scope_as_a_file() {
let pkl = layer(
SourceKind::new("pkl"),
vec![
(
"trusted",
Value::Bool(true),
Origin::new(SourceKind::new("pkl"), "hk.pkl"),
),
(
"config_file",
Value::from("/tmp/x"),
Origin::new(SourceKind::new("pkl"), "hk.pkl"),
),
],
);
let resolved = resolve(REGISTRY, Layers::new().then(&pkl)).expect("should resolve");
assert_eq!(resolved.get_key("trusted"), None);
assert_eq!(resolved.get_key("config_file"), None);
assert_eq!(resolved.warnings.len(), 2, "{:?}", resolved.warnings);
let global_pkl = layer(
SourceKind::new("pkl"),
vec![
(
"trusted",
Value::Bool(true),
Origin::new(SourceKind::new("pkl"), "~/.config/hk.pkl")
.trusted_as(Trust::Operator),
),
(
"config_file",
Value::from("/tmp/x"),
Origin::new(SourceKind::new("pkl"), "~/.config/hk.pkl")
.trusted_as(Trust::Operator),
),
],
);
let resolved = resolve(REGISTRY, Layers::new().then(&global_pkl)).expect("should resolve");
assert_eq!(resolved.get_key("trusted"), Some(&Value::Bool(true)));
assert_eq!(resolved.get_key("config_file"), None);
}
#[test]
fn a_value_written_under_an_old_key_lands_on_the_new_one() {
let git = Fixed {
kind: SourceKind::new("git"),
entries: vec![Entry::new(
raw_id("renamed_jobs"),
Value::Int(3),
Origin::new(SourceKind::new("git"), "hk.renamedJobs"),
)],
};
let resolved = resolve(REGISTRY, Layers::new().then(&git)).expect("should resolve");
assert_eq!(
resolved.get_key("jobs"),
Some(&Value::Int(3)),
"the old key's value should land on the setting that replaced it"
);
let messages: Vec<_> = resolved
.warnings
.iter()
.map(|w| w.message.clone())
.collect();
assert!(
messages.contains(&"renamed_jobs was read as jobs".to_string()),
"{messages:?}"
);
assert!(
messages.iter().any(|m| m.contains("is deprecated")),
"{messages:?}"
);
}
#[test]
fn a_set_drops_a_repeat_from_one_source_too() {
let one = layer(
SourceKind::ENV,
vec![(
"tags",
Value::List(vec![Value::from("a"), Value::from("b"), Value::from("a")]),
Origin::new(SourceKind::ENV, "TAGS"),
)],
);
let resolved = resolve(REGISTRY, Layers::new().then(&one)).expect("should resolve");
assert_eq!(
resolved.get_key("tags"),
Some(&Value::List(vec![Value::from("a"), Value::from("b")]))
);
}
#[test]
fn a_collection_default_takes_part_in_the_merge() {
let env = layer(
SourceKind::ENV,
vec![(
"excluded",
Value::List(vec![Value::from("from-env")]),
Origin::new(SourceKind::ENV, "EXCLUDED"),
)],
);
let resolved = resolve(REGISTRY, Layers::new().then(&env)).expect("should resolve");
assert_eq!(
resolved.get_key("excluded"),
Some(&Value::List(vec![
Value::from("target"),
Value::from("from-env")
])),
"the default's items are the lowest-precedence contribution, not a fallback"
);
assert_eq!(
resolved.contributors(id("excluded"))[0].describe(),
"the default"
);
}
#[test]
fn the_winning_origin_is_always_the_last_contributor() {
let cli = layer(
SourceKind::CLI,
vec![(
"jobs",
Value::Int(1),
Origin::new(SourceKind::CLI, "--jobs"),
)],
);
let env = layer(
SourceKind::ENV,
vec![
(
"jobs",
Value::Int(2),
Origin::new(SourceKind::ENV, "HK_JOBS"),
),
(
"excluded",
Value::List(vec![Value::from("from-env")]),
Origin::new(SourceKind::ENV, "EXCLUDED"),
),
(
"tags",
Value::List(vec![Value::from("a"), Value::from("a")]),
Origin::new(SourceKind::ENV, "TAGS"),
),
],
);
let renamed = Fixed {
kind: SourceKind::new("git"),
entries: vec![Entry::new(
raw_id("renamed_jobs"),
Value::Int(9),
Origin::new(SourceKind::new("git"), "hk.renamedJobs"),
)],
};
let resolved = resolve(REGISTRY, Layers::new().then(&cli).then(&env).then(&renamed))
.expect("should resolve");
for id in REGISTRY.ids() {
let key = REGISTRY.get(id).key;
match (resolved.origin(id), resolved.contributors(id).last()) {
(Some(winner), Some(last)) => assert_eq!(
winner, last,
"{key}: the winning origin is not the last contributor"
),
(None, None) => {}
(winner, last) => panic!("{key}: origin {winner:?} but contributors end {last:?}"),
}
}
let contributors: Vec<_> = resolved
.contributors(id("jobs"))
.iter()
.map(|o| o.describe().to_string())
.collect();
assert!(
contributors.contains(&"hk.renamedJobs".to_string()),
"a value read through a rename contributed and should say so: {contributors:?}"
);
}
#[test]
fn a_deprecated_key_is_reported_however_the_layer_found_it() {
struct FileLike;
impl Layer for FileLike {
fn source(&self) -> SourceKind {
SourceKind::FILE
}
fn load(&self, ctx: &LayerCtx) -> Result<LayerOutput, LayerError> {
let mut out = LayerOutput::new();
let origin = Origin::file("hk.toml", FileScope::Project);
match ctx.entry_for_key("renamed_jobs", "5", origin) {
Ok(entry) => out.push(entry),
Err(warning) => out.warn(warning),
}
Ok(out)
}
}
let file = FileLike;
let resolved = resolve(REGISTRY, Layers::new().then(&file)).expect("should resolve");
assert_eq!(resolved.get_key("jobs"), Some(&Value::Int(5)));
let messages: Vec<_> = resolved
.warnings
.iter()
.map(|w| w.message.clone())
.collect();
assert!(
messages.contains(&"renamed_jobs is deprecated: Use jobs instead.".to_string()),
"{messages:?}"
);
assert!(
messages.contains(&"renamed_jobs was read as jobs".to_string()),
"{messages:?}"
);
}
#[test]
fn a_notice_further_along_a_chain_of_renames_is_still_given() {
static PROPS: &[PropMeta] = &[
PropMeta {
default: Some(Const::Int(1)),
..PropMeta::new("jobs", Ty::Uint)
},
PropMeta {
renamed_to: Some("jobs"),
deprecated: Some("Use jobs instead."),
..PropMeta::new("concurrency", Ty::Uint)
},
PropMeta {
renamed_to: Some("concurrency"),
..PropMeta::new("threads", Ty::Uint)
},
];
const CHAINED: Registry = Registry::new(PROPS);
struct Wrote;
impl Layer for Wrote {
fn source(&self) -> SourceKind {
SourceKind::FILE
}
fn load(&self, ctx: &LayerCtx) -> Result<LayerOutput, LayerError> {
let mut out = LayerOutput::new();
let origin = Origin::file("hk.toml", FileScope::Project);
match ctx.entry_for_key("threads", "8", origin) {
Ok(entry) => out.push(entry),
Err(warning) => out.warn(warning),
}
Ok(out)
}
}
let resolved = resolve(CHAINED, Layers::new().then(&Wrote)).expect("should resolve");
assert_eq!(resolved.get_key("jobs"), Some(&Value::Int(8)));
let kinds: Vec<_> = resolved.warnings.iter().map(|w| w.kind).collect();
assert_eq!(kinds, vec![WarningKind::Deprecated, WarningKind::Renamed]);
assert!(
resolved.warnings[0].message == "threads is deprecated: Use jobs instead.",
"{:?}",
resolved.warnings[0].message
);
}
#[test]
fn an_unknown_key_is_a_warning_rather_than_a_failure() {
struct Stray;
impl Layer for Stray {
fn source(&self) -> SourceKind {
SourceKind::FILE
}
fn load(&self, ctx: &LayerCtx) -> Result<LayerOutput, LayerError> {
let mut out = LayerOutput::new();
let origin = Origin::file("hk.toml", FileScope::Project);
match ctx.entry_for_key("from_the_future", "1", origin) {
Ok(entry) => out.push(entry),
Err(warning) => out.warn(warning),
}
Ok(out)
}
}
let stray = Stray;
let resolved = resolve(REGISTRY, Layers::new().then(&stray)).expect("should resolve");
assert_eq!(
resolved.warnings[0].message,
"unknown setting `from_the_future`"
);
}
#[test]
fn an_alias_does_not_carry_a_default_of_its_own() {
let resolved = resolve(REGISTRY, Layers::new()).expect("should resolve");
assert_eq!(
resolved.get(raw_id("renamed_jobs")),
None,
"an alias should hold nothing at all"
);
assert_eq!(resolved.get_key("jobs"), Some(&Value::Int(4)));
}
#[test]
fn an_explicit_empty_list_clears_a_union_default() {
let env = layer(
SourceKind::ENV,
vec![(
"excluded",
Value::List(Vec::new()),
Origin::new(SourceKind::ENV, "EXCLUDED"),
)],
);
let resolved = resolve(REGISTRY, Layers::new().then(&env)).expect("should resolve");
assert_eq!(resolved.get_key("excluded"), Some(&Value::List(Vec::new())));
}
#[test]
fn a_rewrite_stays_the_last_contributor() {
let env = layer(
SourceKind::ENV,
vec![(
"jobs",
Value::Int(8),
Origin::new(SourceKind::ENV, "HK_JOBS"),
)],
);
let mut resolved = resolve(REGISTRY, Layers::new().then(&env)).expect("should resolve");
resolved.coerced(id("jobs"), Value::Int(1), "raw implies one job");
assert_eq!(
resolved.origin(id("jobs")),
resolved.contributors(id("jobs")).last()
);
}
#[test]
fn a_layer_that_cannot_read_its_source_stops_the_resolution() {
struct Broken;
impl Layer for Broken {
fn source(&self) -> SourceKind {
SourceKind::FILE
}
fn load(&self, _ctx: &LayerCtx) -> Result<LayerOutput, LayerError> {
Err(LayerError::Unreadable {
source: "hk.toml".to_string(),
why: "expected a value at line 3".to_string(),
})
}
}
let broken = Broken;
let err = resolve(REGISTRY, Layers::new().then(&broken)).expect_err("should fail");
assert_eq!(
err.to_string(),
"could not read hk.toml: expected a value at line 3"
);
}
#[test]
fn provenance_answers_to_a_key_the_way_a_value_does() {
let file = layer(
SourceKind::FILE,
vec![(
"jobs",
Value::Int(4),
Origin::file("hk.toml", FileScope::Project),
)],
);
let resolved = resolve(REGISTRY, Layers::new().then(&file)).expect("resolves");
assert_eq!(resolved.get_key("jobs"), Some(&Value::Int(4)));
let origin = resolved.origin_key("jobs").expect("set by the file");
assert_eq!(origin.kind, SourceKind::FILE);
assert!(origin.describe().contains("hk.toml"));
assert_ne!(origin.kind, SourceKind::DEFAULTS);
let untouched = resolve(REGISTRY, Layers::new()).expect("resolves");
assert_eq!(
untouched.origin_key("jobs").map(|o| o.kind),
Some(SourceKind::DEFAULTS)
);
assert!(untouched.origin_key("undeclared_default").is_none());
}
#[test]
fn provenance_by_key_follows_a_rename_like_a_value_does() {
let env = layer(
SourceKind::ENV,
vec![(
"jobs",
Value::Int(8),
Origin::new(SourceKind::ENV, "HK_JOBS"),
)],
);
let resolved = resolve(REGISTRY, Layers::new().then(&env)).expect("resolves");
assert_eq!(resolved.get_key("renamed_jobs"), Some(&Value::Int(8)));
assert_eq!(
resolved.origin_key("renamed_jobs").map(|o| o.describe()),
Some("HK_JOBS")
);
assert_eq!(
resolved.contributors_key("renamed_jobs"),
resolved.contributors_key("jobs")
);
assert!(resolved.origin_key("nonesuch").is_none());
assert!(resolved.contributors_key("nonesuch").is_empty());
}
#[test]
fn contributors_by_key_lists_a_merged_setting_in_merge_order() {
let user = layer(
SourceKind::FILE,
vec![(
"excluded",
Value::List(vec![Value::from("vendor")]),
Origin::file("~/.config/hk.toml", FileScope::Global),
)],
);
let project = layer(
SourceKind::FILE,
vec![(
"excluded",
Value::List(vec![Value::from("dist")]),
Origin::file("hk.toml", FileScope::Project),
)],
);
let resolved =
resolve(REGISTRY, Layers::new().then(&project).then(&user)).expect("resolves");
let described: Vec<&str> = resolved
.contributors_key("excluded")
.iter()
.map(|o| o.describe())
.collect();
assert_eq!(described.len(), 3, "{described:?}");
assert_eq!(described[0], "the default", "{described:?}");
assert!(described[1].contains(".config"), "{described:?}");
assert!(described[2].contains("hk.toml"), "{described:?}");
}
}