Skip to main content

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}