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}