Skip to main content

usage_config/
resolve.rs

1//! One merge, and the provenance is its output.
2//!
3//! Precedence is fixed and universal: the command line beats the environment, which beats
4//! files, nearest first, which beat the user's own configuration, which beats the machine's,
5//! which beat the declared defaults. *Which* layers a CLI has is its own business; their
6//! relative order is not negotiable, because a fleet where two CLIs disagree about whether
7//! `--jobs` beats `JOBS` is the thing this crate exists to end.
8//!
9//! Layers are given highest precedence first — the order they read in the builder and the
10//! order `--help` describes them — and folded lowest first, so the last writer wins.
11
12use 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/// Everything a resolution produced.
21#[derive(Debug, Clone)]
22pub struct Resolved {
23    /// Indexed by [`PropId`]: the winning value, or `None` where nothing supplied one and no
24    /// default was declared.
25    values: Vec<Option<Value>>,
26    /// Indexed by [`PropId`], alongside the values so the two cannot come apart.
27    provenance: Vec<Option<Origin>>,
28    /// Contributors, in the order they were merged, for a setting that took several.
29    contributors: BTreeMap<PropId, Vec<Origin>>,
30    /// Everything a user should be told, in the order it was found.
31    pub warnings: Vec<Warning>,
32    registry: Registry,
33}
34
35impl Resolved {
36    /// The winning value for a setting.
37    pub fn get(&self, id: PropId) -> Option<&Value> {
38        self.values.get(id.index()).and_then(Option::as_ref)
39    }
40
41    /// The winning value for a dotted key, following renames.
42    pub fn get_key(&self, key: &str) -> Option<&Value> {
43        self.get(self.registry.lookup(key)?.id)
44    }
45
46    /// Where the winning value came from.
47    pub fn origin(&self, id: PropId) -> Option<&Origin> {
48        self.provenance.get(id.index()).and_then(Option::as_ref)
49    }
50
51    /// Where the winning value for a dotted key came from, following renames.
52    ///
53    /// The counterpart to [`Resolved::get_key`], and the reason this exists: a settings
54    /// listing walks keys, and without it the only way to ask about provenance was
55    /// `origin(registry.lookup(key)?.id)`. pitchfork's first port of `settings list` reached
56    /// that wall and fell back to comparing the rendered value against the rendered default,
57    /// which reports an explicit override that happens to equal the default as unset — the
58    /// exact confusion an origin-tracked merge exists to end.
59    pub fn origin_key(&self, key: &str) -> Option<&Origin> {
60        self.origin(self.registry.lookup(key)?.id)
61    }
62
63    /// Every place that contributed to this setting, in merge order.
64    ///
65    /// One entry for a `replace` setting, several for a `union` or `deep` one — which is
66    /// what makes per-item provenance possible for a list assembled from four files.
67    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    /// Every place that contributed to a dotted key, in merge order, following renames.
75    ///
76    /// Empty for a key the registry does not have, which is the same answer as a key nothing
77    /// contributed to. A caller that needs to tell the two apart is asking whether the
78    /// setting exists, and [`Registry::lookup`] is that question.
79    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    /// Record that the CLI rewrote a value after merging.
91    ///
92    /// The typed post-merge hook is where a CLI's own rules live — mise's `raw` implying
93    /// `jobs = 1`, its `ci` implying `yes`. Going through here rather than assigning to the
94    /// struct keeps `explain` honest: the origin becomes [`SourceKind::COERCED`] with the
95    /// reason, instead of continuing to name a file that never said it.
96    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        // On the contributor list too, or `origin()` would name the rewrite while
105        // `contributors().last()` still named whatever the rewrite replaced — the same split
106        // between the two that the merge itself is written to avoid.
107        self.contributors.entry(id).or_default().push(origin);
108    }
109}
110
111/// The layers to resolve, highest precedence first.
112///
113/// Ordered by the caller because only the caller knows which layers it has; the order they
114/// are added in is the order `--help` and the docs describe, so a builder that read
115/// bottom-up would invite exactly the kind of quiet disagreement this replaces.
116#[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    /// Add a layer below every layer added so far.
127    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/// Runtime facts that affect configuration resolution.
142///
143/// The resolver cannot infer the running CLI's version from this crate's package version: a
144/// library release and an adopting CLI release are unrelated, and a CLI may compute its version at
145/// runtime. Pass it explicitly with [`ResolutionContext::for_cli_version`] when lifecycle gates
146/// should be enforced.
147#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
148pub struct ResolutionContext<'a> {
149    cli_version: Option<&'a str>,
150}
151
152impl<'a> ResolutionContext<'a> {
153    /// A context with no CLI version.
154    ///
155    /// This preserves the historical [`resolve`] behavior: deprecations warn immediately, while
156    /// removal milestones do not discard values when the resolver cannot know whether they have
157    /// been reached.
158    pub const fn new() -> Self {
159        Self { cli_version: None }
160    }
161
162    /// A context for the running CLI version.
163    pub const fn for_cli_version(version: &'a str) -> Self {
164        Self {
165            cli_version: Some(version),
166        }
167    }
168
169    /// The running CLI version, if the caller supplied one.
170    pub const fn cli_version(self) -> Option<&'a str> {
171        self.cli_version
172    }
173}
174
175/// Resolve every setting in `registry` from `layers`.
176///
177/// Declared defaults are the bottom layer always, and are not a [`Layer`]: they cost one
178/// `const` conversion per setting that needs one and cannot fail, so making them an
179/// implementation would be ceremony that could also be forgotten.
180///
181/// This compatibility entry point has no CLI version context. Use [`resolve_with_context`] to
182/// enforce `deprecated_warn_at` and `deprecated_remove_at` against the running CLI version.
183pub fn resolve(registry: Registry, layers: Layers<'_>) -> Result<Resolved, LayerError> {
184    resolve_with_context(registry, layers, ResolutionContext::new())
185}
186
187/// Resolve every setting with explicit runtime context.
188///
189/// A configured value before `deprecated_warn_at` is accepted without a deprecation warning. At
190/// that release it is accepted with a warning. At `deprecated_remove_at` it is ignored with a
191/// [`WarningKind::Removed`] warning, just like an unsupported configuration key costs only that
192/// value rather than failing the whole file. Declared defaults remain the floor: the gate removes
193/// external contributions, not the compiled setting from the caller's Rust type.
194///
195/// Missing or unreadable versions are conservative in both directions: they warn, because silence
196/// can hide a deprecation forever, but do not remove a value, because uncertainty must not silently
197/// change configuration.
198pub 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    // Declared defaults are the bottom layer, seeded before anything else rather than applied
214    // afterwards as a floor. As a floor they could not take part in a merge at all: a `union`
215    // list with a declared default and any layer at all lost the default's items, because the
216    // floor only filled in what nothing had set. Being the lowest contributor is also what a
217    // default *is*, so `explain` now says so.
218    for id in registry.ids() {
219        // An old name is an alias, not a setting: seeding its default under its own id put the
220        // value somewhere no reader looks, since every lookup folds to the replacement. The
221        // setting that replaced it declares its own default.
222        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    // Lowest precedence first, so a higher layer overwrites what a lower one put there.
238    // Loaded in this order too, which means a layer's warnings arrive in the order a reader
239    // would look for them.
240    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            // Follow a rename here, not only in `LayerCtx::prop`: a layer that took its ids
250            // from `Registry::bindings` or `ids` supplies the old prop's own id, and storing
251            // the value there left it somewhere `get_key` — which follows the rename — would
252            // never look, so the value was silently dropped.
253            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            // Whichever way the old name arrived: on the entry, because the layer looked the
261            // key up and `LayerCtx` folded it, or as a raw id this loop folded just now.
262            // Keyed on the fold alone, a file layer's deprecated key was folded in silence.
263            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                // `written_key`, like the two warnings below it: after `LayerCtx` folds a
269                // rename, `written.key` is the *replacement's* name, so a refused value was
270                // reported under a key that does not appear in the file the user would go and
271                // edit.
272                resolved.warnings.push(
273                    Warning::at(format!("{written_key} {refusal}"), entry.origin)
274                        .of(WarningKind::OutOfScope),
275                );
276                continue;
277            }
278            // Along the chain rather than off the declaration written, which is what `explain` has
279            // always done: a notice can sit on a name further along, and reading only the one the
280            // user wrote meant `config explain` told them to stop using a key that running the CLI
281            // said nothing about.
282            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                // Both names: the key the user wrote, and the one it was read as.
313                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                // Through `union` even for the first contribution, so a set's deduplication
325                // applies to one layer's list as well as across two — a single `TAGS=a,b,a`
326                // kept its repeat, because dedup lived only on the merge-two path.
327                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            // The winner is whatever came last, which after the reverse above is the
341            // highest-precedence contributor.
342            resolved.provenance[index] = Some(entry.origin.clone());
343            // Keyed by the folded id, like the value and the winning origin beside it. Keyed
344            // by the id the layer supplied, a renamed setting's contributors ended up on a
345            // prop nothing reads while its value and origin were on another — the provenance
346            // split this crate exists to make unreachable, reintroduced by two lines.
347            resolved
348                .contributors
349                .entry(prop)
350                .or_default()
351                .push(entry.origin);
352        }
353    }
354
355    Ok(resolved)
356}
357
358/// Whether both versions are readable and `current` has reached `milestone`.
359///
360/// `None` is deliberately distinct from `false`: callers warn on uncertainty but only remove on
361/// certainty.
362fn milestone_reached(current: Option<&str>, milestone: Option<&str>) -> Option<bool> {
363    let ordering = compare_versions(current?, milestone?)?;
364    Some(ordering != Ordering::Less)
365}
366
367/// Compare dotted numeric versions under the argv/spec lifecycle rule.
368///
369/// Missing numeric segments are zero, prereleases sort before their release, and build metadata is
370/// ignored. This intentionally accepts calver as well as semver.
371fn 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
409/// Why this scope will not take a value from this origin, if it will not.
410///
411/// Enforced here rather than in each layer: mise calls this a security property, and a check
412/// that every layer has to remember to make is one a new layer will forget.
413fn refuse(scope: Scope, origin: &Origin) -> Option<&'static str> {
414    match scope {
415        Scope::Any => None,
416        // Anything a repository can carry, whatever kind of place it is. Asking whether the
417        // origin was a *file* let a pkl file, a git config or an `.npmrc` in the checkout walk
418        // past a check the spec calls a security property.
419        // Not "config file": since the check became one about trust, this refuses a git
420        // config, a pkl file or an `.npmrc` in the checkout too, and telling that user their
421        // *config file* is at fault points them at a file that never held the value. The
422        // warning carries the origin, so whoever renders it can name the place exactly.
423        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
433/// Lower-precedence values first, higher appended, repeats dropped for a set.
434fn union(existing: Value, incoming: Value, ty: crate::ty::Ty) -> Value {
435    // An explicit empty list means "none", and is how a user turns a declared default off:
436    // `HK_EXCLUDE=` parses to an empty list for exactly that reason. Concatenating with it
437    // left every default item in place, so a `union` setting with a default could not be
438    // cleared at all.
439    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        // First occurrence keeps its position, so the order of a set is the order it was
452        // first mentioned rather than something that shifts when a lower layer changes.
453        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
465/// Tables merged key by key, the incoming (higher-precedence) side winning each key.
466fn 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                    // Nested tables merge too, so a `deep` setting is deep all the way down
472                    // rather than only at the top.
473                    Some(existing @ Value::Map(_)) => deep(existing, value),
474                    _ => value,
475                };
476                base.insert(key, merged);
477            }
478            Value::Map(base)
479        }
480        // A `deep` setting given something that is not a table on either side has nothing to
481        // merge; the higher-precedence value stands, as `replace` would have it.
482        (_, 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        // An old name for a scope-restricted setting, which is how a refusal comes to be
522        // reported under a key the user never wrote.
523        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        // Deprecated *and* replaced, which is the pair a rename actually comes as.
536        PropMeta {
537            deprecated: Some("Use jobs instead."),
538            renamed_to: Some("jobs"),
539            // A default on an alias, which is a thing a registry ends up with after a rename
540            // and which must not be seeded anywhere.
541            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        // The message is for a person and its wording is nobody's contract. This is what a program
551        // acts on: mise queues its deprecations until logging is up while a bad value goes to stderr
552        // at once, a `--strict` mode exits on everything but a deprecation, and the conformance
553        // corpus pins what happened without pinning how it was said.
554        let ctx = LayerCtx::new(REGISTRY);
555        let file = Origin::file("hk.toml", FileScope::Project);
556
557        // Every kind this crate produces, from the place that produces it.
558        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        // And the three the *merge* adds, which no layer can know about on its own: whether a place
572        // is allowed to set a setting, and what its name turned out to mean.
573        let layer = Fixed {
574            kind: SourceKind::FILE,
575            entries: vec![
576                Entry::new(id("trusted"), Value::Bool(true), file.clone()),
577                // The *unfolded* id, which is what a layer reading `Registry::ids` hands over —
578                // `lookup` folds a rename, so going through it could not reproduce the case.
579                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        // A layer of the CLI's own says whatever it likes, and is not made to invent a kind for it.
596        assert_eq!(
597            Warning::new("the git config could not be read").kind,
598            WarningKind::Other
599        );
600    }
601
602    /// A layer holding whatever a test hands it.
603    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    /// The id of a key *without* following its rename.
626    ///
627    /// What `Registry::bindings` and `Registry::ids` hand a layer — `lookup` folds renames, so
628    /// a test that went through it could not reproduce the case at all.
629    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        // The identifier, not just the kind: "from the environment" is not something a user
679        // can act on and `--jobs` is.
680        assert_eq!(resolved.origin(id("jobs")).unwrap().describe(), "--jobs");
681        // Every contributor is kept, lowest precedence first, even for a `replace` setting —
682        // and the declared default is the lowest of all, because that is what a default is.
683        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        // A setting with no default and no value is absent rather than guessed at, which is
703        // what makes `option<T>` expressible.
704        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        // Both places are recorded, which is what per-item provenance is built on.
737        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        // The security property: a repository can carry a project file, so a setting that
808        // must not be changeable by a checkout says so and the merge enforces it — not each
809        // layer, which is how a new layer forgets.
810        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        // Refused out loud: silently ignoring what somebody wrote is how they conclude the
829        // setting does not work.
830        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        // The same settings from the places they *do* accept.
844        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        // Still honoured — a warning is not a refusal.
882        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        // mise's `raw` implying `jobs = 1`. Recording this as coming from wherever the
1053        // original value came from is how a user ends up editing a file that has nothing to
1054        // do with what they are seeing.
1055        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        // The deprecation and rename warnings already said the name the user wrote; the refusal
1074        // still said the folded one, so a refused value was reported under a key that does not
1075        // appear anywhere in the file they would go and edit.
1076        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        // The check is about trust now, so it refuses a pkl file or a git config in the checkout
1110        // too — and telling that user their *config file* is at fault points them at a file that
1111        // never held the value.
1112        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        // And the origin travels with it, so a renderer can name the place exactly.
1126        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        // The hole: `refuse` asked whether the origin was a *file*, and every custom source is
1135        // built with `Origin::new`, so a pkl file or a git config in the checkout could set a
1136        // setting the spec says a project must not touch. A pkl file in a repository is as much
1137        // a thing a checkout carries as `hk.toml` is.
1138        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        // And a layer that knows it read from the user's own configuration says so, at which
1159        // point a `global` setting will take it — while an `env` one still will not.
1160        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        // `LayerCtx::prop` follows a rename, but a layer that took its ids from
1185        // `Registry::bindings` or `ids` hands over the *old* prop's id — and storing the value
1186        // there put it somewhere `get_key`, which follows the rename, would never look. The
1187        // value was honoured nowhere and reported nowhere.
1188        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        // Said out loud, in both names: the one written and the one it was read as.
1203        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        // Deduplication lived on the merge-two path, so a single `TAGS=a,b,a` kept its repeat —
1221        // a set that is only a set once two layers disagree is not a set.
1222        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        // As a floor rather than a layer, a default only applied where nothing had been set —
1240        // so a `union` list with a declared default lost every one of the default's items the
1241        // moment any layer supplied anything at all.
1242        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        // And the default is recorded as the contributor it is.
1260        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        // The invariant behind `explain`, asserted as an invariant rather than field by field.
1269        // A rename put the value and the winning origin on the folded prop and its contributors
1270        // on the one the layer named, and every per-field assertion I had still passed.
1271        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        // And specifically for the renamed one, whose contributors used to live elsewhere.
1322        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        // A layer reading a file looks keys up, and `LayerCtx` folds a rename on the way — so
1336        // the entry arrives already carrying the *new* id and the resolver could not tell that
1337        // anybody had written the old name. The deprecated key in somebody's config file was
1338        // honoured in complete silence.
1339        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        // Two releases of renaming: `threads` became `concurrency`, which became `jobs` and carries
1375        // the notice. `explain` walked the chain for it and the merge read only the declaration
1376        // written, so `config explain threads` told a user to stop using a key that running the CLI
1377        // said nothing about — one rule, two implementations, and the quieter one was the one a CLI
1378        // actually surfaces.
1379        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        // Named by what the user wrote, since that is the line in the file they would go and edit.
1416        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        // Newer config read by an older binary: the key it does not know is reported and the
1426        // rest of the file still applies.
1427        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        // Seeded under its own id, a renamed prop's default landed where no reader looks: every
1453        // lookup folds to the replacement. The setting that replaced it declares its own.
1454        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        // How a user turns a declared default off. `HK_EXCLUDE=` parses to an empty list for
1466        // exactly this reason, and with defaults now merging rather than filling in, an empty
1467        // list that concatenated left every default item in place.
1468        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        // The invariant `explain` rests on has to survive the post-merge hook too: rewriting
1483        // the value and the winning origin without touching the contributor list left
1484        // `origin()` naming the rewrite and `contributors().last()` naming what it replaced.
1485        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        // Unlike an unknown key, which degrades to a warning: a file that exists and cannot
1504        // be parsed means the values a user believes are in effect are not, and carrying on
1505        // as though they had never written it is worse than saying so.
1506        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        // The accessor a settings listing needs. Without it the shortest way to ask "did anyone
1529        // set this, or is it just the default" was to render the value and the default and
1530        // compare the strings — which calls an explicit override that happens to equal the
1531        // default unset, and is exactly what an origin-tracked merge exists to avoid.
1532        let file = layer(
1533            SourceKind::FILE,
1534            vec![(
1535                "jobs",
1536                // The declared default, set again on purpose.
1537                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        // The value alone cannot tell these apart; the origin can.
1548        assert_ne!(origin.kind, SourceKind::DEFAULTS);
1549
1550        // And a setting nothing touched still has an origin, which is the default itself.
1551        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        // Except where there is no default either: unset is unset, and says nothing.
1557        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        // `get_key` folds a rename, so provenance that did not would answer about a different
1563        // setting than the value did — the split the one-merge design is written to prevent.
1564        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        // A key the registry does not have is not a setting with no contributors, but a caller
1585        // rendering a list has already looked it up; both answer emptily and neither panics.
1586        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        // Lowest precedence first: the declared default, then the user's file, then the project's.
1617        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}