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