Skip to main content

usage_config/
layer.rs

1//! Where values come from, as an interface.
2//!
3//! usage supplies the command line, the environment and declared defaults. Everything else a
4//! CLI reads — a git config, a pkl file, an `.npmrc`, a keyring — is a layer the CLI writes,
5//! and it writes it against this trait plus [`Registry::bindings`], which is why hk's git
6//! layer is about twenty lines rather than a second resolution system.
7//!
8//! [`Registry::bindings`]: crate::Registry::bindings
9
10use crate::registry::{PropId, Registry};
11use crate::source::{Origin, SourceKind};
12use crate::value::Value;
13
14/// One value a layer supplies.
15#[derive(Debug, Clone, PartialEq)]
16pub struct Entry {
17    pub prop: PropId,
18    pub value: Value,
19    /// The exact place it came from — the variable's name, the file's path.
20    pub origin: Origin,
21    /// The key the user actually wrote, when it was an old name for `prop`.
22    ///
23    /// A layer that looks a key up gets the id of the setting that *replaced* it, so without
24    /// this the resolver cannot tell that anybody used the old name — and the warning that a
25    /// deprecated key is in somebody's config file never fires.
26    pub renamed_from: Option<&'static str>,
27    /// The exact canonical key or supported alias a keyed layer matched.
28    pub written_key: Option<&'static str>,
29}
30
31impl Entry {
32    pub fn new(prop: PropId, value: Value, origin: Origin) -> Self {
33        Self {
34            prop,
35            value,
36            origin,
37            renamed_from: None,
38            written_key: None,
39        }
40    }
41}
42
43/// Something a user should know about, which is not bad enough to stop for.
44///
45/// Returned rather than printed. mise queues these until its logging is up, and a library
46/// that writes to stderr on its own cannot be used by anything that has an opinion about
47/// output.
48///
49/// Built through [`Warning::new`] or [`Warning::at`] rather than as a literal: this has gained a
50/// field once already, and a warning is something a layer *reports* rather than a shape anything
51/// downstream should be pattern-matched against exhaustively. Reading the fields, and matching with
52/// `..`, are unaffected.
53#[derive(Debug, Clone, PartialEq)]
54#[non_exhaustive]
55pub struct Warning {
56    pub message: String,
57    /// Where the value that caused it came from, when there was one.
58    pub origin: Option<Origin>,
59    /// What sort of thing happened, for a caller that wants to treat them differently.
60    pub kind: WarningKind,
61}
62
63/// The kinds of thing a resolution has to say.
64///
65/// The message is for a person and its wording is nobody's contract; this is what a *program* can
66/// act on. mise wants its deprecations queued and printed once its logging is up while a bad value
67/// goes to stderr immediately; a `--strict` mode wants to exit on anything but a deprecation; the
68/// conformance corpus wants to pin what happened without pinning how it was worded, since that is a
69/// quality-of-implementation concern and differs between implementations by design.
70#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
71#[non_exhaustive]
72pub enum WarningKind {
73    /// A key no setting declares. A config file written for a newer binary read by an older one.
74    UnknownSetting,
75    /// A value the declared type cannot read.
76    WrongType,
77    /// A value the declared `choice` nodes do not allow.
78    NotAllowed,
79    /// A place that may not set this setting: a `scope="global"` setting from a checkout.
80    OutOfScope,
81    /// A setting whose spec says not to use it any more.
82    Deprecated,
83    /// A configured value for a setting whose removal milestone has been reached, and which was
84    /// therefore ignored.
85    Removed,
86    /// A value that arrived under an old name and was read as the setting that replaced it.
87    Renamed,
88    /// A value that was passed over because another name for the same setting won.
89    NotRead,
90    /// Something a layer of the CLI's own says, which this crate has no name for.
91    #[default]
92    Other,
93}
94
95impl Warning {
96    /// A warning of no particular kind, which is what a custom layer's own complaints are.
97    pub fn new(message: impl Into<String>) -> Self {
98        Self {
99            message: message.into(),
100            origin: None,
101            kind: WarningKind::Other,
102        }
103    }
104
105    /// The same, about a value that came from somewhere nameable.
106    pub fn at(message: impl Into<String>, origin: Origin) -> Self {
107        Self {
108            message: message.into(),
109            origin: Some(origin),
110            kind: WarningKind::Other,
111        }
112    }
113
114    /// This warning, classified.
115    ///
116    /// Chained rather than an argument so the two constructors keep reading as they did, and so a
117    /// layer that has nothing useful to say about the kind is not made to invent one.
118    pub fn of(mut self, kind: WarningKind) -> Self {
119        self.kind = kind;
120        self
121    }
122}
123
124/// What a layer found.
125#[derive(Debug, Default, Clone, PartialEq)]
126pub struct LayerOutput {
127    pub entries: Vec<Entry>,
128    pub warnings: Vec<Warning>,
129}
130
131impl LayerOutput {
132    pub fn new() -> Self {
133        Self::default()
134    }
135
136    pub fn push(&mut self, entry: Entry) {
137        self.entries.push(entry);
138    }
139
140    pub fn warn(&mut self, warning: Warning) {
141        self.warnings.push(warning);
142    }
143}
144
145/// Anything that can fail while a layer reads.
146#[derive(Debug, Clone, PartialEq)]
147pub enum LayerError {
148    /// The layer could not read its source at all — a malformed file, a subprocess that
149    /// failed. Unlike an unknown key, this is not something to degrade past.
150    Unreadable { source: String, why: String },
151}
152
153impl std::fmt::Display for LayerError {
154    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
155        match self {
156            Self::Unreadable { source, why } => write!(f, "could not read {source}: {why}"),
157        }
158    }
159}
160
161impl std::error::Error for LayerError {}
162
163/// What a layer is given while it reads.
164///
165/// The registry, and the helpers that keep every layer honest about the same two things: a
166/// key it does not recognize is a warning rather than an error, and a raw string becomes a
167/// value the way the *spec* says rather than the way the layer guesses.
168pub struct LayerCtx {
169    registry: Registry,
170}
171
172impl LayerCtx {
173    pub fn new(registry: Registry) -> Self {
174        Self { registry }
175    }
176
177    pub fn registry(&self) -> Registry {
178        self.registry
179    }
180
181    /// The setting a dotted key names, following renames.
182    pub fn prop(&self, key: &str) -> Option<crate::registry::Lookup> {
183        self.registry.lookup(key)
184    }
185
186    /// The setting an id ends up on, and the name it was declared under if that differs.
187    ///
188    /// `Registry::bindings` yields the *pre*-rename id, so a git or pkl layer hands over an
189    /// alias — and the alias's own metadata is usually bare. Reading `parse` and `ty` from it
190    /// meant a value that should have been split by a declared parser arrived as one unsplit
191    /// string on the replacement's list. What governs a value is the setting it lands on.
192    fn folded(&self, id: PropId) -> (PropId, Option<&'static str>) {
193        let meta = self.registry.get(id);
194        match meta.renamed_to.and_then(|key| self.registry.lookup(key)) {
195            Some(target) if target.id != id => (target.id, Some(meta.key)),
196            _ => (id, None),
197        }
198    }
199
200    /// A raw string read as the setting's declared type, applying its named parser first.
201    ///
202    /// Every layer that reads text should come through here. A layer that decides for itself
203    /// how to split a list is how two sources of the same setting end up disagreeing about
204    /// what a comma means.
205    pub fn parse(&self, id: PropId, raw: &str) -> Result<Value, crate::ty::TypeError> {
206        let (id, _) = self.folded(id);
207        let meta = self.registry.get(id);
208        let value = match meta.parse {
209            Some(parser) => parser.split(raw),
210            None => Value::String(raw.to_string()),
211        };
212        meta.ty.coerce(value)
213    }
214
215    /// An entry for `id`, with `raw` read as the declared type.
216    ///
217    /// The shape almost every layer wants: on a value that cannot be the declared type, the
218    /// entry is dropped and a warning takes its place, naming the origin. A bad value in a
219    /// system-wide file must not stop a CLI from starting.
220    pub fn entry(&self, id: PropId, raw: &str, origin: Origin) -> Result<Entry, Warning> {
221        // Folded here too, and the name that was written kept, so an entry built from a
222        // binding carries the same information as one built from a key.
223        let (id, renamed_from) = self.folded(id);
224        match self.parse(id, raw) {
225            Ok(value) => {
226                let key = renamed_from.unwrap_or(self.registry.get(id).key);
227                if let Some(refused) = self.refused(id, &value, key, &origin) {
228                    return Err(refused);
229                }
230                Ok(Entry {
231                    renamed_from,
232                    ..Entry::new(id, value, origin)
233                })
234            }
235            Err(err) => {
236                // The name that was written, not the one it folded to: a message about a key
237                // the user cannot find in their own file is no help.
238                let key = renamed_from.unwrap_or(self.registry.get(id).key);
239                // Without the origin in the text: the warning carries it, and a renderer that
240                // adds it — as `explain::warnings` does for every warning — printed the place
241                // twice for exactly the warnings that had bothered to name it.
242                Err(Warning::at(
243                    format!("{key} expected {} but has `{}`", err.expected, err.found),
244                    origin,
245                )
246                .of(WarningKind::WrongType))
247            }
248        }
249    }
250}
251
252impl LayerCtx {
253    /// The warning for a value the setting's `choice` nodes do not allow, if it is one.
254    ///
255    /// Beside the type check and for the same reason: a declared type and a declared set of values
256    /// are both the spec saying what may be here, and a value that is neither costs its own key and
257    /// nothing else. Until this, choices reached the docs, the JSON schema and completions, and
258    /// nothing that *resolved* a value — so a CLI documenting three allowed values took a fourth
259    /// without a word, and only failed later, somewhere that could not say why.
260    fn refused(&self, id: PropId, value: &Value, key: &str, origin: &Origin) -> Option<Warning> {
261        let meta = self.registry.get(id);
262        let refused = meta.refuses(value)?;
263        Some(
264            Warning::at(
265                format!(
266                    "{key} expected one of {} but has `{}`",
267                    meta.allowed(),
268                    crate::value::shown(refused)
269                ),
270                origin.clone(),
271            )
272            .of(WarningKind::NotAllowed),
273        )
274    }
275
276    /// An entry for a dotted key, which is what a layer reading a file has in hand.
277    ///
278    /// The path worth taking: it looks the key up, follows a rename while remembering the name
279    /// that was written, reads the value as the declared type, and turns an unknown key into a
280    /// warning rather than an error — everything a layer would otherwise have to remember to
281    /// do, and the reason a deprecated key in somebody's config file gets reported at all.
282    pub fn entry_for_key(&self, key: &str, raw: &str, origin: Origin) -> Result<Entry, Warning> {
283        let Some(found) = self.prop(key) else {
284            return Err(Warning::at(format!("unknown setting `{key}`"), origin)
285                .of(WarningKind::UnknownSetting));
286        };
287        match self.parse(found.id, raw) {
288            Ok(value) => {
289                if let Some(refused) = self.refused(found.id, &value, found.written, &origin) {
290                    return Err(refused);
291                }
292                Ok(Entry {
293                    renamed_from: found.renamed_from,
294                    written_key: Some(found.written),
295                    ..Entry::new(found.id, value, origin)
296                })
297            }
298            Err(err) => Err(Warning::at(
299                format!(
300                    "{} expected {} but has `{}`",
301                    found.written, err.expected, err.found
302                ),
303                origin,
304            )
305            .of(WarningKind::WrongType)),
306        }
307    }
308
309    /// An entry for a dotted key whose value already has a shape.
310    ///
311    /// A file has structure of its own — an array is an array, a table is a table — and there is
312    /// no text a named parser could turn into one, so a layer reading a structured format hands
313    /// the value over as it found it. It still goes through the declared type, which is what
314    /// keeps the promise that a value of the wrong type costs a warning and not a wrong value:
315    /// a `map<string, string>` given a number inside it says so, rather than storing it.
316    pub fn entry_from_value(
317        &self,
318        key: &str,
319        value: Value,
320        origin: Origin,
321    ) -> Result<Entry, Warning> {
322        let Some(found) = self.prop(key) else {
323            return Err(Warning::at(format!("unknown setting `{key}`"), origin)
324                .of(WarningKind::UnknownSetting));
325        };
326        let meta = self.registry.get(found.id);
327        match meta.ty.coerce(value) {
328            Ok(value) => {
329                if let Some(refused) = self.refused(found.id, &value, found.written, &origin) {
330                    return Err(refused);
331                }
332                Ok(Entry {
333                    renamed_from: found.renamed_from,
334                    written_key: Some(found.written),
335                    ..Entry::new(found.id, value, origin)
336                })
337            }
338            Err(err) => Err(Warning::at(
339                format!(
340                    "{} expected {} but has `{}`",
341                    found.written, err.expected, err.found
342                ),
343                origin,
344            )
345            .of(WarningKind::WrongType)),
346        }
347    }
348}
349
350/// A source of configuration values.
351pub trait Layer {
352    /// Which kind of place this reads. Used by the scope check and reported by `explain`.
353    fn source(&self) -> SourceKind;
354
355    /// Everything this layer has to say, in one pass.
356    fn load(&self, ctx: &LayerCtx) -> Result<LayerOutput, LayerError>;
357}
358
359#[cfg(test)]
360mod tests {
361    use super::WarningKind;
362    use super::*;
363    use crate::registry::PropMeta;
364    use crate::ty::{Parser, Ty};
365    use crate::value::Const;
366
367    static PROPS: &[PropMeta] = &[
368        PropMeta {
369            aliases: &["parallelism"],
370            ..PropMeta::new("jobs", Ty::Uint)
371        },
372        PropMeta {
373            parse: Some(Parser::ListByComma),
374            ..PropMeta::new("exclude", Ty::List(&Ty::String))
375        },
376        // A `bool` whose choices are written the way a person says them, which the coercion turns
377        // into the same two values.
378        PropMeta {
379            choices: &[Const::Str("yes"), Const::Str("no")],
380            ..PropMeta::new("colour", Ty::Bool)
381        },
382        // A type usage cannot know — a union — with its choices written as numbers. Nothing is
383        // coerced here by declaration, so a value out of a file stays a string and the choice stays
384        // an integer, and only their written forms can answer whether they are the same.
385        PropMeta {
386            choices: &[Const::Int(1), Const::Int(2)],
387            ..PropMeta::new("level", Ty::Any)
388        },
389        // A setting the spec limits to three values, which is hk's `stash` and mise's nine
390        // enum-valued settings.
391        PropMeta {
392            aliases: &["storage"],
393            choices: &[
394                Const::Str("git"),
395                Const::Str("patch-file"),
396                Const::Str("none"),
397            ],
398            ..PropMeta::new("stash", Ty::String)
399        },
400        // A list whose *items* are booleans, with the choices written as words. Unusual, and the one
401        // shape where the item's own type is the only thing that makes the comparison work: read as
402        // the list it is in, `yes` becomes a one-item list and matches nothing, and read as text it
403        // is `yes` against `true`.
404        PropMeta {
405            parse: Some(Parser::ListByComma),
406            choices: &[Const::Str("yes"), Const::Str("no")],
407            ..PropMeta::new("flags", Ty::List(&Ty::Bool))
408        },
409        // Choices on a list: each *item* is one of them, the way the JSON schema reads it.
410        PropMeta {
411            parse: Some(Parser::ListByComma),
412            choices: &[Const::Str("lint"), Const::Str("test")],
413            ..PropMeta::new("skip", Ty::List(&Ty::String))
414        },
415    ];
416    const REGISTRY: Registry = Registry::new(PROPS);
417
418    #[test]
419    fn a_value_the_spec_does_not_allow_is_refused_with_the_list_of_what_is() {
420        // Choices reached the docs, the JSON schema and completions, and nothing that *resolved* a
421        // value: a CLI documenting three allowed values took a fourth in silence, and failed later
422        // somewhere that could not say why.
423        let ctx = LayerCtx::new(REGISTRY);
424        let origin = Origin::new(SourceKind::ENV, "HK_STASH");
425        let warning = ctx
426            .entry_for_key("stash", "svn", origin.clone())
427            .expect_err("not one of the three");
428        assert_eq!(
429            warning.message,
430            "stash expected one of git, patch-file, none but has `svn`"
431        );
432        // Which is a different sort of thing from a value of the wrong *type*, and a caller that
433        // treats them differently needs to be able to tell.
434        assert_eq!(warning.kind, WarningKind::NotAllowed);
435        let alias_warning = ctx
436            .entry_for_key("storage", "svn", origin.clone())
437            .expect_err("alias should use the same choices");
438        assert_eq!(
439            alias_warning.message,
440            "storage expected one of git, patch-file, none but has `svn`"
441        );
442        assert_eq!(
443            ctx.entry_for_key("jobs", "lots", origin.clone())
444                .expect_err("not a number")
445                .kind,
446            WarningKind::WrongType
447        );
448        // And a value that is one of them is just a value.
449        assert_eq!(
450            ctx.entry_for_key("stash", "git", origin.clone())
451                .map(|entry| entry.value),
452            Ok(Value::from("git"))
453        );
454        let alias = ctx.entry_for_key("storage", "git", origin.clone()).unwrap();
455        assert_eq!(alias.written_key, Some("storage"));
456        assert_eq!(alias.renamed_from, None);
457
458        // A list is checked item by item, and the *item* is what the message quotes — naming the
459        // whole list would leave the user to work out which of five items was the problem.
460        let warning = ctx
461            .entry_for_key("skip", "lint,fmt", origin.clone())
462            .expect_err("`fmt` is not one of them");
463        assert_eq!(
464            warning.message,
465            "skip expected one of lint, test but has `fmt`"
466        );
467        assert!(ctx.entry_for_key("skip", "lint,test", origin).is_ok());
468    }
469
470    #[test]
471    fn a_choice_is_read_the_way_the_declared_type_reads_it() {
472        // `choice "yes"` under `type="bool"`: the value `yes` is coerced to `true` on its way in, so
473        // comparing the two as written refused a value the spec plainly allows. The choice is read
474        // the same way the value was, which is the same question the coercion already answered.
475        let ctx = LayerCtx::new(REGISTRY);
476        let origin = Origin::new(SourceKind::ENV, "HK_COLOUR");
477        assert_eq!(
478            ctx.entry_for_key("colour", "yes", origin.clone())
479                .map(|entry| entry.value),
480            Ok(Value::Bool(true))
481        );
482        // `no` is the other declared choice, and `true` is neither of them written down — but it is
483        // what `yes` reads as, so it is allowed for the same reason.
484        assert_eq!(
485            ctx.entry_for_key("colour", "no", origin.clone())
486                .map(|entry| entry.value),
487            Ok(Value::Bool(false))
488        );
489        assert_eq!(
490            ctx.entry_for_key("colour", "true", origin.clone())
491                .map(|entry| entry.value),
492            Ok(Value::Bool(true))
493        );
494
495        // And inside a collection it is the *item's* type that reads the choice, not the list's:
496        // read as the list, `yes` becomes a one-item list and matches nothing at all.
497        assert_eq!(
498            ctx.entry_for_key("flags", "yes,no", origin.clone())
499                .map(|entry| entry.value),
500            Ok(Value::List(vec![Value::Bool(true), Value::Bool(false)]))
501        );
502        // The type is asked first, which is why this says what it says: `maybe` is not a boolean, and
503        // there is nothing useful to say about which *choice* it is not. With both spellings of a
504        // boolean declared as choices, every boolean is one of them — so for this setting the choices
505        // can only refuse what the type has already refused.
506        let warning = ctx
507            .entry_for_key("flags", "yes,maybe", origin)
508            .expect_err("`maybe` is not a boolean");
509        assert_eq!(warning.message, "flags expected a boolean but has `maybe`");
510    }
511
512    #[test]
513    fn a_float_choice_is_the_number_the_spec_wrote() {
514        // Rendered without its point, a whole-number float was the same text as an integer — so a
515        // `choice 1.0` under a *string* type accepted `1` and refused the `1.0` the spec had written.
516        static PROPS: &[PropMeta] = &[
517            PropMeta {
518                choices: &[Const::Float(1.0), Const::Float(1.5)],
519                ..PropMeta::new("scale", Ty::String)
520            },
521            // And where the type *is* a float, the coercion settles it before any text is compared.
522            PropMeta {
523                choices: &[Const::Int(1), Const::Float(1.5)],
524                ..PropMeta::new("ratio", Ty::Float)
525            },
526        ];
527        const REGISTRY: Registry = Registry::new(PROPS);
528        let ctx = LayerCtx::new(REGISTRY);
529        let origin = Origin::new(SourceKind::ENV, "HK_SCALE");
530
531        assert_eq!(
532            ctx.entry_for_key("scale", "1.0", origin.clone())
533                .map(|entry| entry.value),
534            Ok(Value::from("1.0"))
535        );
536        let warning = ctx
537            .entry_for_key("scale", "1", origin.clone())
538            .expect_err("`1` is not `1.0`");
539        assert_eq!(
540            warning.message,
541            "scale expected one of 1.0, 1.5 but has `1`"
542        );
543
544        // `choice 1` under `type="float"` is the float one, because that is what the type reads it
545        // as — the case the point-less rendering used to settle by accident.
546        assert_eq!(
547            ctx.entry_for_key("ratio", "1", origin)
548                .map(|entry| entry.value),
549            Ok(Value::Float(1.0))
550        );
551    }
552
553    #[test]
554    fn a_type_nothing_coerces_compares_its_choices_as_written() {
555        // `any` coerces nothing, by declaration — so a value arrives as the string a file or an
556        // environment variable wrote, the choice stays the integer the spec wrote, and comparing them
557        // after a coercion that did nothing refuses a value the spec allows.
558        let ctx = LayerCtx::new(REGISTRY);
559        let origin = Origin::new(SourceKind::ENV, "HK_LEVEL");
560        assert_eq!(
561            ctx.entry_for_key("level", "2", origin.clone())
562                .map(|entry| entry.value),
563            Ok(Value::from("2"))
564        );
565        let warning = ctx
566            .entry_for_key("level", "3", origin.clone())
567            .expect_err("not one of them");
568        assert_eq!(warning.message, "level expected one of 1, 2 but has `3`");
569
570        // And a *list* of them is not one of them. Nothing declared this setting to have items, so
571        // there is nothing to walk into: following the value's shape instead, `[1]` was accepted for
572        // `choice 1` — and for a type the spec left open, a value's shape is whatever a file wrote.
573        let warning = ctx
574            .entry_from_value("level", Value::List(vec![Value::Int(1)]), origin)
575            .expect_err("a list of one choice is not that choice");
576        assert_eq!(warning.message, "level expected one of 1, 2 but has `1`");
577    }
578
579    #[test]
580    fn a_setting_with_no_choices_takes_what_its_type_takes() {
581        // Most settings say nothing about their values, and the check has to cost them nothing and
582        // refuse them nothing.
583        let ctx = LayerCtx::new(REGISTRY);
584        let origin = Origin::new(SourceKind::ENV, "HK_JOBS");
585        assert_eq!(
586            ctx.entry_for_key("jobs", "8", origin).map(|e| e.value),
587            Ok(Value::Int(8))
588        );
589    }
590
591    #[test]
592    fn a_structured_value_is_held_to_the_same_choices() {
593        // The other way a value arrives — a table or a list out of a file, which never passes
594        // through a parser. Checking one path and not the other is how a rule ends up applying to
595        // the environment and not to the file beside it.
596        let ctx = LayerCtx::new(REGISTRY);
597        let origin = Origin::new(SourceKind::FILE, "hk.toml");
598        let warning = ctx
599            .entry_from_value(
600                "skip",
601                Value::List(vec![Value::from("test"), Value::from("deploy")]),
602                origin.clone(),
603            )
604            .expect_err("`deploy` is not one of them");
605        assert_eq!(
606            warning.message,
607            "skip expected one of lint, test but has `deploy`"
608        );
609        assert!(ctx
610            .entry_from_value("skip", Value::List(vec![Value::from("test")]), origin)
611            .is_ok());
612    }
613
614    #[test]
615    fn a_raw_string_is_read_the_way_the_spec_says() {
616        let ctx = LayerCtx::new(REGISTRY);
617        let jobs = ctx.prop("jobs").expect("declared").id;
618        assert_eq!(ctx.parse(jobs, "4"), Ok(Value::Int(4)));
619
620        // The declared parser runs before the type does, so one string becomes a list and
621        // no layer has to know that this setting is comma-separated.
622        let exclude = ctx.prop("exclude").expect("declared").id;
623        assert_eq!(
624            ctx.parse(exclude, "target,node_modules"),
625            Ok(Value::List(vec![
626                Value::from("target"),
627                Value::from("node_modules")
628            ]))
629        );
630    }
631
632    #[test]
633    fn an_alias_is_read_with_the_metadata_of_the_setting_it_became() {
634        // `Registry::bindings` yields the pre-rename id, so a git or pkl layer hands over an
635        // alias — whose own metadata is usually bare. Reading `parse` and `ty` from it meant a
636        // comma-separated value arrived as one unsplit string on the replacement's list.
637        static PROPS: &[PropMeta] = &[
638            PropMeta {
639                parse: Some(Parser::ListByComma),
640                ..PropMeta::new("exclude", Ty::List(&Ty::String))
641            },
642            // The alias: no parser, no list type of its own.
643            PropMeta {
644                renamed_to: Some("exclude"),
645                ..PropMeta::new("excludes", Ty::String)
646            },
647        ];
648        const REGISTRY: Registry = Registry::new(PROPS);
649        let ctx = LayerCtx::new(REGISTRY);
650        let alias = PropId(1);
651
652        assert_eq!(
653            ctx.parse(alias, "target,vendor"),
654            Ok(Value::List(vec![
655                Value::from("target"),
656                Value::from("vendor")
657            ]))
658        );
659        // And the entry lands on the replacement while remembering the name it came in under,
660        // so the deprecation warning still has something to say.
661        let entry = ctx
662            .entry(
663                alias,
664                "target",
665                Origin::new(SourceKind::new("git"), "hk.excludes"),
666            )
667            .expect("should parse");
668        assert_eq!(entry.prop, PropId(0));
669        assert_eq!(entry.renamed_from, Some("excludes"));
670    }
671
672    #[test]
673    fn a_bad_value_becomes_a_warning_that_names_where_it_came_from() {
674        // A CLI has to start even when a file it does not own has nonsense in it, and the
675        // warning has to say which file, because otherwise the user cannot find it.
676        let ctx = LayerCtx::new(REGISTRY);
677        let jobs = ctx.prop("jobs").expect("declared").id;
678        let origin = Origin::new(SourceKind::ENV, "HK_JOBS");
679        let warning = ctx
680            .entry(jobs, "lots", origin.clone())
681            .expect_err("should not be an entry");
682        // The message says what is wrong; the `origin` says where. Naming the place in both
683        // meant every renderer that shows the origin — as `explain::warnings` does — printed it
684        // twice, for exactly the warnings that had bothered to be specific.
685        assert_eq!(
686            warning.message,
687            "jobs expected a non-negative integer but has `lots`"
688        );
689        assert_eq!(warning.origin, Some(origin));
690
691        let alias_warning = ctx
692            .entry_for_key(
693                "parallelism",
694                "lots",
695                Origin::new(SourceKind::FILE, "config.toml"),
696            )
697            .expect_err("alias value should still be checked");
698        assert_eq!(
699            alias_warning.message,
700            "parallelism expected a non-negative integer but has `lots`"
701        );
702
703        let structured_alias_warning = ctx
704            .entry_from_value(
705                "parallelism",
706                Value::from("lots"),
707                Origin::new(SourceKind::FILE, "config.toml"),
708            )
709            .expect_err("structured alias value should still be checked");
710        assert_eq!(structured_alias_warning.message, alias_warning.message);
711
712        // And under an old name, the message says the name that was written — a complaint about
713        // a key the user cannot find in their own file is no help at all.
714        static RENAMED: &[PropMeta] = &[
715            PropMeta::new("jobs", Ty::Uint),
716            PropMeta {
717                renamed_to: Some("jobs"),
718                ..PropMeta::new("concurrency", Ty::Uint)
719            },
720        ];
721        const WITH_ALIAS: Registry = Registry::new(RENAMED);
722        let ctx = LayerCtx::new(WITH_ALIAS);
723        let warning = ctx
724            .entry(
725                PropId(1),
726                "lots",
727                Origin::new(SourceKind::ENV, "HK_CONCURRENCY"),
728            )
729            .expect_err("should not be an entry");
730        assert!(
731            warning.message.starts_with("concurrency expected"),
732            "{}",
733            warning.message
734        );
735    }
736}