usage_config/registry.rs
1//! The settings a CLI has, as a generated table.
2//!
3//! `#[derive(usage::Config)]` reads the settings struct and emits a `static` of these, so at
4//! runtime a registry is a slice — no parsing, no map to build, and a [`PropId`] that indexes
5//! it directly. A merge over a hundred settings therefore never hashes a key, which is what
6//! makes resolving the whole struct at once cheap enough to do eagerly.
7//!
8//! Keys are the dotted paths the spec declares. Nesting is a *file's* concern, reconstructed
9//! by whatever reads the file; here a key is one string.
10
11use crate::source::SourceKind;
12use crate::ty::{Parser, Ty};
13use crate::value::{Const, Value};
14
15/// A setting's index in its registry.
16///
17/// Interned so the merge is array indexing rather than string comparison. `u16` because a
18/// registry of 65,000 settings is not a thing that exists — mise, the largest in the fleet,
19/// has 280.
20#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
21pub struct PropId(pub u16);
22
23impl PropId {
24 /// This id as a slice index.
25 pub fn index(self) -> usize {
26 self.0 as usize
27 }
28}
29
30/// How the values for one setting combine when several layers supply them.
31#[derive(Debug, Default, Copy, Clone, PartialEq, Eq)]
32pub enum Merge {
33 /// The highest-precedence value wins outright.
34 #[default]
35 Replace,
36 /// Every layer contributes; a list is the concatenation, lowest precedence first.
37 Union,
38 /// Tables merge key by key, the higher precedence winning each key.
39 Deep,
40}
41
42/// Where a setting will accept a value from.
43///
44/// Enforced by the merge rather than left to each layer, because mise calls this a security
45/// property and a check every layer has to remember is not one.
46#[derive(Debug, Default, Copy, Clone, PartialEq, Eq)]
47pub enum Scope {
48 /// Anywhere.
49 #[default]
50 Any,
51 /// Never from a file a repository can carry — only the user's own configuration, the
52 /// machine's, the environment, or the command line.
53 Global,
54 /// Never from a file at all.
55 Env,
56}
57
58/// What one setting is.
59///
60/// Every field is `const`-constructible, so a generated registry is a `static` with no
61/// initializer to run.
62#[derive(Debug, Copy, Clone)]
63pub struct PropMeta {
64 /// The dotted key, which is also what a config file and `config set` call it.
65 pub key: &'static str,
66 pub ty: Ty,
67 /// The value when no layer supplies one.
68 pub default: Option<Const>,
69 pub merge: Merge,
70 pub scope: Scope,
71 /// How to split a single string into several values, when a layer hands over text.
72 pub parse: Option<Parser>,
73 /// Environment variables that set it, highest precedence first.
74 pub envs: &'static [&'static str],
75 /// Deprecated environment aliases, consulted after every current name.
76 pub deprecated_envs: &'static [&'static str],
77 /// The flags that set it, as the spec's `cli` node declares them: `["--jobs", "-j"]`.
78 ///
79 /// Documentation for the most part — an explanation that lists the environment variables and not
80 /// the flag is answering half the question a user asked. It is also what a generated test can
81 /// hold the *executable* binding against, since a spec that declares `--jobs` and a CLI that
82 /// never reads it into the setting is the shape of hk's thirteen dead `sources.cli` lines.
83 pub cli: &'static [&'static str],
84 /// Its keys in sources usage does not know about: `[("git", "hk.jobs")]`. A custom layer
85 /// asks the registry for its own kind and iterates what it finds, which is the whole
86 /// mechanism behind hk's git and pkl layers and aube's `.npmrc`.
87 pub bindings: &'static [(&'static str, &'static str)],
88 /// The only values this setting accepts, when it says.
89 ///
90 /// Empty means anything the type allows. Declared in the spec as `choice` nodes, where they
91 /// already reach the docs, the JSON schema and completions — and, until this, nothing that
92 /// *resolved* a value, so a CLI documenting three allowed values accepted a fourth in silence.
93 pub choices: &'static [Const],
94 /// Kept out of documentation and completions. Still settable.
95 pub hide: bool,
96 /// Why not to use this any more.
97 pub deprecated: Option<&'static str>,
98 /// The setting that replaces this one. A value found under the old key is folded into the
99 /// new one at the same precedence, with a warning.
100 pub renamed_to: Option<&'static str>,
101 /// Equivalent keys accepted without a rename warning.
102 pub aliases: &'static [&'static str],
103 /// An explicit optionality contract, when the declaration does not use inference.
104 pub optional: Option<bool>,
105 pub help: Option<&'static str>,
106 pub long_help: Option<&'static str>,
107 /// Prose beside the default, for a default the registry cannot hold — one computed at
108 /// runtime, or one whose literal alone would mislead ("0 = one per core").
109 pub default_note: Option<&'static str>,
110 /// The release that introduced this setting, when the declaration says.
111 pub since: Option<&'static str>,
112 /// The CLI version that starts warning about a deprecated setting, and the one after which
113 /// configured values for it are ignored.
114 pub deprecated_warn_at: Option<&'static str>,
115 pub deprecated_remove_at: Option<&'static str>,
116 /// Values worth showing a reader, verbatim.
117 pub examples: &'static [&'static str],
118}
119
120impl PropMeta {
121 /// A setting with nothing but a key and a type, for a generator or a test to build on.
122 pub const fn new(key: &'static str, ty: Ty) -> Self {
123 Self {
124 key,
125 ty,
126 default: None,
127 merge: Merge::Replace,
128 scope: Scope::Any,
129 parse: None,
130 envs: &[],
131 deprecated_envs: &[],
132 cli: &[],
133 bindings: &[],
134 choices: &[],
135 hide: false,
136 deprecated: None,
137 renamed_to: None,
138 aliases: &[],
139 optional: None,
140 help: None,
141 long_help: None,
142 default_note: None,
143 since: None,
144 deprecated_warn_at: None,
145 deprecated_remove_at: None,
146 examples: &[],
147 }
148 }
149}
150
151/// Every setting a CLI has.
152#[derive(Debug, Copy, Clone)]
153pub struct Registry {
154 pub props: &'static [PropMeta],
155}
156
157/// What looking a key up found.
158#[derive(Debug, Copy, Clone, PartialEq)]
159pub struct Lookup {
160 pub id: PropId,
161 /// The canonical key or alias that matched the lookup, so diagnostics can repeat the key the
162 /// user actually wrote without treating a supported alias as a deprecated rename.
163 pub written: &'static str,
164 /// The key that was asked for, when it is not the key that was found — an old name still
165 /// in somebody's config file. Carried so a warning can name it.
166 pub renamed_from: Option<&'static str>,
167}
168
169impl PropMeta {
170 /// The first value here that this setting does not allow, if there is one.
171 ///
172 /// A collection is checked item by item, because choices on a `list<string>` mean each item is
173 /// one of them — the same rule `usage g json-schema` follows, which puts the enum on every value
174 /// position rather than on the container. Returning the offender rather than a bool is what lets
175 /// the warning quote the item that is wrong instead of the whole list it was in.
176 pub fn refuses<'v>(&self, value: &'v Value) -> Option<&'v Value> {
177 if self.choices.is_empty() {
178 return None;
179 }
180 self.refuses_as(self.ty, value)
181 }
182
183 /// The same, for the type this level of the value is held to.
184 ///
185 /// Threaded down through a collection because a choice is compared *as the declared type reads
186 /// it*, and the declared type of an item is not the declared type of the list it is in.
187 fn refuses_as<'v>(&self, ty: Ty, value: &'v Value) -> Option<&'v Value> {
188 // On the *declared* type, not on the shape of what arrived. Following the value instead, a
189 // scalar-choiced setting the spec left open — `any`, a union — accepted `[1]` for `choice 1`,
190 // because the walk went looking for items in something that was never declared to have any.
191 match (ty.inner(), value) {
192 (Ty::List(item) | Ty::Set(item), Value::List(items)) => {
193 items.iter().find_map(|value| self.refuses_as(*item, value))
194 }
195 (Ty::Map(item), Value::Map(entries)) => entries
196 .values()
197 .find_map(|value| self.refuses_as(*item, value)),
198 // Anything else is compared whole, and a scalar choice is not a list however many items
199 // it has: `Const::matches` says as much, and this is where that is asked.
200 _ => match self.choices.iter().any(|choice| allows(ty, choice, value)) {
201 true => None,
202 false => Some(value),
203 },
204 }
205 }
206
207 /// What it allows, written the way the spec declared them, for a message.
208 pub fn allowed(&self) -> String {
209 self.choices
210 .iter()
211 .map(|choice| choice.to_value().display())
212 .collect::<Vec<_>>()
213 .join(", ")
214 }
215}
216
217/// Whether `choice` is `value`, read the way the declared type reads them both.
218///
219/// The value arrived here through `Ty::coerce`, and the choice has not: a spec writes `choice "yes"`
220/// under `type="bool"` and the value `yes` becomes `Bool(true)`, so comparing them as written refuses
221/// a value the spec plainly allows. Reading the choice the same way is what makes the two comparable
222/// — it is the same question the coercion already answered.
223fn allows(ty: Ty, choice: &Const, value: &Value) -> bool {
224 match ty.coerce(choice.to_value()) {
225 Ok(coerced) if coerced == *value => true,
226 // Coercion did not settle it, so the question falls back to what the two are written as.
227 // `any` is where that matters most — a union coerces *nothing*, so a `choice 4` and the
228 // string `4` a file supplied stay an integer and a string — and I had this as the `Err` arm,
229 // which `any` never takes, since coercing there succeeds by doing nothing at all. It is also
230 // the arm for a choice the declared type cannot read, which is one nothing can supply
231 // either.
232 _ => choice.matches(value),
233 }
234}
235
236impl Registry {
237 pub const fn new(props: &'static [PropMeta]) -> Self {
238 Self { props }
239 }
240
241 pub fn get(&self, id: PropId) -> &'static PropMeta {
242 &self.props[id.index()]
243 }
244
245 /// The id of a dotted key, following a rename to the setting that replaced it.
246 ///
247 /// Linear, because a registry is small and a lookup happens once per key a layer
248 /// supplies — not once per key that exists. A binary search over a sorted table would be
249 /// a fine optimization and is not yet worth the invariant it demands of the generator.
250 pub fn lookup(&self, key: &str) -> Option<Lookup> {
251 let (index, written, matched_alias) =
252 self.props.iter().enumerate().find_map(|(index, meta)| {
253 if meta.key == key {
254 Some((index, meta.key, false))
255 } else {
256 meta.aliases
257 .iter()
258 .copied()
259 .find(|alias| *alias == key)
260 .map(|alias| (index, alias, true))
261 }
262 })?;
263 let mut id = PropId(index as u16);
264 // A chain of renames resolves to its end, so two releases of renaming do not leave the
265 // second one unreachable — walked rather than recursed, and bounded by the number of
266 // settings there are. A cycle, which is one mistyped field away in a registry somebody
267 // wrote by hand, overflowed the stack: an abort with no message rather than a lookup
268 // that fails. A chain longer than the registry is a cycle by definition.
269 for _ in 0..self.props.len() {
270 let Some(new_key) = self.props[id.index()].renamed_to else {
271 return Some(Lookup {
272 id,
273 written,
274 renamed_from: (id.index() != index && !matched_alias).then_some(written),
275 });
276 };
277 id = self.lookup_exact(new_key)?;
278 }
279 None
280 }
281
282 /// The id of a dotted key, *without* following a rename.
283 ///
284 /// [`Registry::lookup`] answers "which setting does this key mean", which is what a reader
285 /// wants. This answers "which declaration is this key", which is what a warning wants: the
286 /// deprecation message lives on the old name's own declaration.
287 pub fn lookup_exact(&self, key: &str) -> Option<PropId> {
288 self.props
289 .iter()
290 .position(|meta| meta.key == key)
291 .map(|index| PropId(index as u16))
292 }
293
294 /// Whether a table at `key` is itself a setting value rather than a path to
295 /// more-specific settings.
296 ///
297 /// Aliases participate in file lookup, but an alias that is also the prefix
298 /// of a declared dotted key must not swallow that nested value. A leaf alias
299 /// for a map or object still names the whole table.
300 pub fn names_file_value(&self, key: &str) -> bool {
301 let Some(found) = self.lookup(key) else {
302 return false;
303 };
304 let meta = self.get(found.id);
305 // A canonical table setting owns its value. Another declaration's alias below
306 // that key cannot turn the canonical map into a namespace and make the whole
307 // value disappear during flattening.
308 if meta.key == key && matches!(meta.ty.inner(), Ty::Map(_) | Ty::Object | Ty::Any) {
309 return true;
310 }
311 let prefix = format!("{key}.");
312 !self.props.iter().any(|meta| {
313 meta.key.starts_with(&prefix)
314 || meta.aliases.iter().any(|alias| alias.starts_with(&prefix))
315 })
316 }
317
318 /// The first deprecated declaration along the rename chain that starts at `key`.
319 ///
320 /// The chain, not the declaration named: `a` renamed to `b`, and `b` the one carrying the notice
321 /// that says to use `c`. A user who wrote `a` is being told the same thing either way, and which
322 /// release the notice was attached in is not something they can see.
323 ///
324 /// Bounded by the number of settings there are, so a registry whose renames form a cycle stops
325 /// rather than following them forever — the same guard [`Registry::lookup`] uses, and for the
326 /// same reason: this is an authoring mistake, and hanging is a worse way to report one than
327 /// nothing at all. The derive refuses such a declaration outright.
328 pub fn deprecation_meta(&self, key: &str) -> Option<&'static PropMeta> {
329 let mut current = self
330 .props
331 .iter()
332 .position(|meta| meta.key == key || meta.aliases.contains(&key))
333 .map(|index| PropId(index as u16))?;
334 for _ in 0..self.props.len() {
335 let meta = self.get(current);
336 if meta.deprecated.is_some() {
337 return Some(meta);
338 }
339 current = meta.renamed_to.and_then(|next| self.lookup_exact(next))?;
340 }
341 None
342 }
343
344 /// The first deprecation notice along the rename chain that starts at `key`.
345 ///
346 /// Kept as the message-only counterpart to [`Registry::deprecation_meta`] for callers that do
347 /// not need lifecycle milestones.
348 pub fn deprecation(&self, key: &str) -> Option<&'static str> {
349 self.deprecation_meta(key).and_then(|meta| meta.deprecated)
350 }
351
352 /// The settings an environment variable sets, and the variable that set them.
353 ///
354 /// Several names per setting are aliases in descending precedence, which the env layer
355 /// honours by taking the first one that is present.
356 pub fn ids(&self) -> impl Iterator<Item = PropId> {
357 (0..self.props.len()).map(|i| PropId(i as u16))
358 }
359
360 /// Every way the flags a spec *declares* and the flags a CLI *binds* disagree.
361 ///
362 /// Empty means they agree. This is the check hk needed and did not have: it declares eighteen
363 /// `sources.cli` bindings and reads five, because the declaration lives in a spec and the
364 /// reading lives in a hand-written struct, and nothing has ever compared the two. A spec that
365 /// documents `--jobs` and a CLI that never puts it anywhere is a promise to a user that no test
366 /// could catch.
367 ///
368 /// `bound` is what the CLI actually does: pairs of a flag and the setting it sets. A CLI whose
369 /// flags come from `usage::Cli` can generate that list; one that binds by hand writes it out,
370 /// which is still one list rather than two behaviours.
371 ///
372 /// Both directions are reported, because they are different mistakes. A declared flag nothing
373 /// binds is documentation for something that does not happen. A bound flag the setting does not
374 /// declare happens without being documented — the user cannot discover it, and `explain` cannot
375 /// name it.
376 pub fn drift(&self, bound: &[(&str, &str)]) -> Vec<String> {
377 let mut problems = Vec::new();
378
379 for (flag, key) in bound {
380 let Some(found) = self.lookup(key) else {
381 problems.push(format!(
382 "`{flag}` is bound to `{key}`, which is not a setting"
383 ));
384 continue;
385 };
386 if !self.declares(found.id, flag) {
387 problems.push(format!(
388 "`{flag}` is bound to `{key}`, which does not declare it: add `cli \"{flag}\"` to the spec"
389 ));
390 }
391 }
392
393 for id in self.ids() {
394 let meta = self.get(id);
395 // Asked once, and refused when it is `None`: two keys that resolve to nothing are not
396 // two keys that mean the same setting, and comparing the answers directly made a flag
397 // on a dangling `renamed_to` look bound by any flag bound to any other broken key.
398 let means = self.means(meta.key);
399 for flag in meta.cli {
400 let Some(means) = means else {
401 problems.push(format!(
402 "`{}` says `{flag}` sets it, and it is not a setting anything can reach: \
403 its `renamed_to` names nothing, or the chain it starts loops",
404 meta.key
405 ));
406 continue;
407 };
408 // Compared against the *setting* the binding names rather than the flag alone: a CLI
409 // may bind `--jobs` to something, and binding it to the wrong setting is not the same
410 // as binding it.
411 let bound_here = bound
412 .iter()
413 .any(|(bound_flag, key)| bound_flag == flag && self.means(key) == Some(means));
414 if !bound_here {
415 problems.push(format!(
416 "`{}` says `{flag}` sets it, and nothing does",
417 meta.key
418 ));
419 }
420 }
421 }
422 problems
423 }
424
425 /// Whether any declaration of the setting `id` lists `flag`.
426 ///
427 /// Any, because a rename leaves two declarations of one setting and either may be the one
428 /// carrying the flag: a CLI that has not dropped `--concurrency` yet is bound to a key that
429 /// *means* `jobs`, and the flag is declared where the old name is. Asking only the replacement
430 /// called a live binding drift; asking only the declaration named would miss a flag added to the
431 /// new name and still bound through the old one.
432 fn declares(&self, id: PropId, flag: &str) -> bool {
433 self.ids().any(|candidate| {
434 self.means(self.get(candidate).key) == Some(id)
435 && self.get(candidate).cli.contains(&flag)
436 })
437 }
438
439 /// Which setting a key means, following renames — `None` for a key that is not one.
440 fn means(&self, key: &str) -> Option<PropId> {
441 self.lookup(key).map(|found| found.id)
442 }
443
444 /// Every setting bound to `kind`, with its key in that source.
445 ///
446 /// The generic mechanism a custom layer is written against: a git layer asks for `"git"`
447 /// and reads the keys it gets back, without usage knowing anything about git.
448 pub fn bindings(
449 &self,
450 kind: SourceKind,
451 ) -> impl Iterator<Item = (PropId, &'static str)> + use<'_> {
452 self.ids().flat_map(move |id| {
453 self.get(id)
454 .bindings
455 .iter()
456 .filter(move |(k, _)| *k == kind.name())
457 .map(move |(_, key)| (id, *key))
458 })
459 }
460}
461
462#[cfg(test)]
463mod tests {
464 use super::*;
465
466 static PROPS: &[PropMeta] = &[
467 PropMeta {
468 key: "jobs",
469 aliases: &["parallelism"],
470 envs: &["HK_JOBS", "HK_JOB"],
471 cli: &["--jobs", "-j"],
472 bindings: &[("git", "hk.jobs"), ("pkl", "jobs")],
473 ..PropMeta::new("jobs", Ty::Uint)
474 },
475 PropMeta {
476 renamed_to: Some("jobs"),
477 deprecated: Some("Use jobs instead."),
478 ..PropMeta::new("concurrency", Ty::Uint)
479 },
480 PropMeta {
481 // Two renames deep: this is what a second release of renaming looks like.
482 renamed_to: Some("concurrency"),
483 ..PropMeta::new("threads", Ty::Uint)
484 },
485 PropMeta {
486 // Declares a flag, which is what hk's dead `sources.cli` lines look like from here.
487 cli: &["--check"],
488 bindings: &[("git", "hk.check")],
489 ..PropMeta::new("check", Ty::Bool)
490 },
491 ];
492 const REGISTRY: Registry = Registry::new(PROPS);
493
494 #[test]
495 fn aliases_resolve_without_becoming_renames() {
496 let found = REGISTRY.lookup("parallelism").expect("alias");
497 assert_eq!(found.id, REGISTRY.lookup("jobs").unwrap().id);
498 assert_eq!(found.written, "parallelism");
499 assert_eq!(found.renamed_from, None);
500 assert_eq!(REGISTRY.deprecation("parallelism"), None);
501 }
502
503 #[test]
504 fn an_alias_on_a_renamed_prop_stays_warning_free_but_keeps_deprecation() {
505 static RENAMED: &[PropMeta] = &[
506 PropMeta::new("jobs", Ty::Uint),
507 PropMeta {
508 aliases: &["parallelism"],
509 renamed_to: Some("jobs"),
510 deprecated: Some("Use jobs instead."),
511 ..PropMeta::new("concurrency", Ty::Uint)
512 },
513 ];
514 let registry = Registry::new(RENAMED);
515 let found = registry.lookup("parallelism").unwrap();
516 assert_eq!(found.id, PropId(0));
517 assert_eq!(found.renamed_from, None);
518 assert_eq!(
519 registry.deprecation("parallelism"),
520 Some("Use jobs instead.")
521 );
522 assert_eq!(
523 registry.deprecation_meta("parallelism").unwrap().key,
524 "concurrency"
525 );
526 }
527
528 #[test]
529 fn an_alias_prefix_does_not_swallow_a_nested_file_key() {
530 const PREFIXED: &[PropMeta] = &[
531 PropMeta {
532 aliases: &["old.key"],
533 ..PropMeta::new("replacement", Ty::Map(&Ty::String))
534 },
535 PropMeta::new("old.key.extra", Ty::String),
536 PropMeta {
537 aliases: &["whole.table"],
538 ..PropMeta::new("map", Ty::Map(&Ty::String))
539 },
540 ];
541 let registry = Registry::new(PREFIXED);
542 assert!(!registry.names_file_value("old.key"));
543 assert!(registry.names_file_value("old.key.extra"));
544 assert!(registry.names_file_value("whole.table"));
545 }
546
547 #[test]
548 fn a_canonical_table_value_is_not_turned_into_a_namespace_by_an_alias() {
549 const PROPS: &[PropMeta] = &[
550 PropMeta::new("providers", Ty::Map(&Ty::String)),
551 PropMeta::new("optional", Ty::Option(&Ty::Map(&Ty::String))),
552 PropMeta::new("union", Ty::Any),
553 PropMeta {
554 aliases: &["providers.legacy"],
555 ..PropMeta::new("legacy_provider", Ty::String)
556 },
557 PropMeta {
558 aliases: &["optional.legacy"],
559 ..PropMeta::new("optional_legacy", Ty::String)
560 },
561 PropMeta {
562 aliases: &["union.legacy"],
563 ..PropMeta::new("union_legacy", Ty::String)
564 },
565 ];
566 let registry = Registry::new(PROPS);
567 assert!(registry.names_file_value("providers"));
568 assert!(registry.names_file_value("optional"));
569 assert!(registry.names_file_value("union"));
570 }
571
572 #[test]
573 fn a_cli_that_binds_what_the_spec_declares_has_no_drift() {
574 let bound = [("--jobs", "jobs"), ("-j", "jobs"), ("--check", "check")];
575 assert_eq!(REGISTRY.drift(&bound), Vec::<String>::new());
576 }
577
578 #[test]
579 fn a_declared_flag_nothing_binds_is_reported() {
580 // hk's thirteen dead `sources.cli` lines, and the reason they lasted: the declaration is in a
581 // spec, the reading is in a hand-written struct, and nothing compared them. A user reads
582 // `--check` in the documentation and it does nothing at all.
583 let bound = [("--jobs", "jobs"), ("-j", "jobs")];
584 assert_eq!(
585 REGISTRY.drift(&bound),
586 vec!["`check` says `--check` sets it, and nothing does"]
587 );
588
589 // Every spelling counts. `-j` is as much a promise as `--jobs` is.
590 let bound = [("--jobs", "jobs"), ("--check", "check")];
591 assert_eq!(
592 REGISTRY.drift(&bound),
593 vec!["`jobs` says `-j` sets it, and nothing does"]
594 );
595 }
596
597 #[test]
598 fn a_flag_bound_to_the_wrong_setting_is_not_a_flag_that_is_bound() {
599 // The failure a flag-only comparison would miss: `--check` is bound, so a check that only
600 // asked "is this flag bound anywhere" would pass — while `check` is still set by nothing and
601 // `jobs` is now set by a flag it never declared.
602 let bound = [("--jobs", "jobs"), ("-j", "jobs"), ("--check", "jobs")];
603 assert_eq!(
604 REGISTRY.drift(&bound),
605 vec![
606 "`--check` is bound to `jobs`, which does not declare it: add `cli \"--check\"` to the spec",
607 "`check` says `--check` sets it, and nothing does"
608 ]
609 );
610 }
611
612 #[test]
613 fn a_flag_bound_to_a_setting_that_does_not_exist_is_reported() {
614 let bound = [
615 ("--jobs", "jobs"),
616 ("-j", "jobs"),
617 ("--check", "check"),
618 ("--nonesuch", "nonesuch"),
619 ];
620 assert_eq!(
621 REGISTRY.drift(&bound),
622 vec!["`--nonesuch` is bound to `nonesuch`, which is not a setting"]
623 );
624 }
625
626 #[test]
627 fn a_flag_bound_through_an_old_name_is_bound() {
628 // A CLI written before a rename binds the name it knew. The value lands on the setting that
629 // replaced it, so the binding is real — reporting it as drift would make living through a
630 // rename impossible.
631 let bound = [("--jobs", "jobs"), ("-j", "jobs"), ("--check", "check")];
632 assert_eq!(REGISTRY.drift(&bound), Vec::<String>::new());
633 let through_old_name = [
634 ("--jobs", "concurrency"),
635 ("-j", "jobs"),
636 ("--check", "check"),
637 ];
638 assert_eq!(
639 REGISTRY.drift(&through_old_name),
640 Vec::<String>::new(),
641 "`concurrency` is `jobs`, and `jobs` declares `--jobs`"
642 );
643 }
644
645 // An old name that kept the flag it was documented with — what a rename looks like for a CLI
646 // that has not dropped the old spelling yet. Its own registry, because a declared flag is a
647 // promise: adding it to the shared one would make every other test's bindings incomplete.
648 static RENAMED_PROPS: &[PropMeta] = &[
649 PropMeta {
650 cli: &["--jobs"],
651 ..PropMeta::new("jobs", Ty::Uint)
652 },
653 PropMeta {
654 cli: &["--concurrency"],
655 renamed_to: Some("jobs"),
656 ..PropMeta::new("concurrency", Ty::Uint)
657 },
658 ];
659 const RENAMED: Registry = Registry::new(RENAMED_PROPS);
660
661 #[test]
662 fn an_old_name_that_kept_its_flag_is_not_drift() {
663 // The two questions a rename separates. `--concurrency` is declared on the old name and
664 // binds the old key, so both sides are talking about `jobs` — but one asked `lookup` and the
665 // other did not, and the disagreement was reported twice: the flag "is not declared" by the
666 // replacement, and the declaration's flag "nothing does". A CLI would have deleted a live
667 // binding to satisfy it.
668 let bound = [("--jobs", "jobs"), ("--concurrency", "concurrency")];
669 assert_eq!(RENAMED.drift(&bound), Vec::<String>::new());
670
671 // And bound through the name that replaced it, which is the same setting and the same flag.
672 let by_new_name = [("--jobs", "jobs"), ("--concurrency", "jobs")];
673 assert_eq!(RENAMED.drift(&by_new_name), Vec::<String>::new());
674 }
675
676 #[test]
677 fn a_flag_on_a_rename_that_leads_nowhere_is_not_satisfied_by_another_broken_one() {
678 // `means` is `None` for a key whose rename names nothing or loops. Compared to each other,
679 // two of those were equal — so a declared flag counted as bound because some *other* dead
680 // key happened to be bound to the same spelling, and the dead declaration went unreported.
681 // The generator refuses a registry like this, but `drift` is also what a hand-written one
682 // is held to.
683 static BROKEN_PROPS: &[PropMeta] = &[
684 PropMeta {
685 cli: &["--gone"],
686 renamed_to: Some("nowhere"),
687 ..PropMeta::new("gone", Ty::Uint)
688 },
689 PropMeta {
690 cli: &["--gone"],
691 renamed_to: Some("also-nowhere"),
692 ..PropMeta::new("other", Ty::Uint)
693 },
694 ];
695 const BROKEN: Registry = Registry::new(BROKEN_PROPS);
696
697 let bound = [("--gone", "other")];
698 let problems = BROKEN.drift(&bound);
699 assert_eq!(
700 problems,
701 vec![
702 "`--gone` is bound to `other`, which is not a setting",
703 "`gone` says `--gone` sets it, and it is not a setting anything can reach: its \
704 `renamed_to` names nothing, or the chain it starts loops",
705 "`other` says `--gone` sets it, and it is not a setting anything can reach: its \
706 `renamed_to` names nothing, or the chain it starts loops",
707 ]
708 );
709 }
710
711 #[test]
712 fn a_key_resolves_to_its_own_index() {
713 let found = REGISTRY.lookup("jobs").expect("declared");
714 assert_eq!(found.id, PropId(0));
715 assert_eq!(found.renamed_from, None);
716 assert_eq!(REGISTRY.get(found.id).key, "jobs");
717 assert_eq!(REGISTRY.lookup("nonesuch"), None);
718 }
719
720 #[test]
721 fn an_old_name_resolves_to_the_setting_that_replaced_it() {
722 // What a config file written a year ago needs, and the reason a rename does not have
723 // to be a breaking change.
724 let found = REGISTRY.lookup("concurrency").expect("declared");
725 assert_eq!(found.id, PropId(0), "should land on jobs");
726 assert_eq!(found.renamed_from, Some("concurrency"));
727
728 // Through two renames, reporting the name the user actually wrote rather than the
729 // intermediate one they have never heard of.
730 let chained = REGISTRY.lookup("threads").expect("declared");
731 assert_eq!(chained.id, PropId(0));
732 assert_eq!(chained.renamed_from, Some("threads"));
733 }
734
735 #[test]
736 fn a_rename_cycle_fails_the_lookup_rather_than_the_process() {
737 // One mistyped field in a registry somebody wrote by hand. Recursing on `renamed_to`
738 // overflowed the stack, which is an abort with no message — a lookup that cannot answer
739 // should return `None` and let the caller warn about an unknown key.
740 static CYCLE: &[PropMeta] = &[
741 PropMeta {
742 renamed_to: Some("b"),
743 ..PropMeta::new("a", Ty::Bool)
744 },
745 PropMeta {
746 renamed_to: Some("a"),
747 ..PropMeta::new("b", Ty::Bool)
748 },
749 // The simplest form: a setting renamed to itself.
750 PropMeta {
751 renamed_to: Some("self"),
752 ..PropMeta::new("self", Ty::Bool)
753 },
754 ];
755 const REGISTRY: Registry = Registry::new(CYCLE);
756 assert_eq!(REGISTRY.lookup("a"), None);
757 assert_eq!(REGISTRY.lookup("b"), None);
758 assert_eq!(REGISTRY.lookup("self"), None);
759 // And a chain that does end still resolves, so the bound is not simply refusing chains.
760 static CHAIN: &[PropMeta] = &[
761 PropMeta::new("new", Ty::Bool),
762 PropMeta {
763 renamed_to: Some("new"),
764 ..PropMeta::new("middle", Ty::Bool)
765 },
766 PropMeta {
767 renamed_to: Some("middle"),
768 ..PropMeta::new("old", Ty::Bool)
769 },
770 ];
771 const CHAINED: Registry = Registry::new(CHAIN);
772 let found = CHAINED.lookup("old").expect("declared");
773 assert_eq!(found.id, PropId(0));
774 assert_eq!(found.renamed_from, Some("old"));
775 }
776
777 #[test]
778 fn a_custom_layer_finds_its_own_keys() {
779 // The whole interface a git or pkl or npmrc layer is written against.
780 let git: Vec<_> = REGISTRY.bindings(SourceKind::new("git")).collect();
781 assert_eq!(git, vec![(PropId(0), "hk.jobs"), (PropId(3), "hk.check")]);
782 let pkl: Vec<_> = REGISTRY.bindings(SourceKind::new("pkl")).collect();
783 assert_eq!(pkl, vec![(PropId(0), "jobs")]);
784 // A kind nothing is bound to yields nothing rather than everything.
785 assert_eq!(REGISTRY.bindings(SourceKind::new("npmrc")).count(), 0);
786 }
787}