usage-config 6.1.0

Layered configuration resolution for usage specs, with provenance
Documentation
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
//! Why a setting has the value it has.
//!
//! hk's `config explain` is the best of its kind in the fleet — it names the winning source and
//! the exact identifier, and it does per-item provenance for lists. It also costs about two
//! hundred lines, and it is written against a *second* merge function that exists only to
//! answer this question. Two merges can disagree, and when they do the explanation describes a
//! resolution that never happened.
//!
//! Here there is one merge and its provenance is the answer, so this module is a renderer and
//! nothing more. Every adopter gets the command; none of them writes it.
//!
//! The output is plain text on purpose. A CLI that wants JSON has the same [`Resolved`] this
//! reads and better taste than a library about what its own JSON should look like.

use std::fmt::Write as _;

use crate::registry::PropId;
use crate::resolve::Resolved;
use crate::source::SourceKind;
use crate::value::{one_line, shown};

/// One setting explained: what it is, where it came from, and what else tried.
///
/// ```text
/// jobs = 4
///   set by  MISE_JOBS
///   type    uint
///
///   also considered, lowest precedence first:
///     the default
///     mise.toml#jobs
/// ```
///
/// Returns `None` for a key the registry does not have, which the caller reports its own way —
/// a CLI usually wants to suggest a near miss, and this module has no business guessing.
pub fn explain(resolved: &Resolved, key: &str) -> Option<String> {
    let registry = resolved.registry();
    let found = registry.lookup(key)?;
    let meta = registry.get(found.id);
    let mut out = String::new();

    // The name the *user* asked about, then the setting it turned out to be. Printing only the
    // canonical name would answer a question nobody asked.
    if let Some(old) = found.renamed_from {
        let _ = writeln!(out, "{old} is now {}", meta.key);
    }

    match resolved.get(found.id) {
        Some(value) => {
            let _ = writeln!(out, "{} = {}", meta.key, shown(value));
        }
        None => {
            let _ = writeln!(out, "{} is unset", meta.key);
        }
    }

    if let Some(origin) = resolved.origin(found.id) {
        // "set by the default" reads oddly, and a rewrite is not something anybody *set*.
        let verb = match origin.kind {
            SourceKind::DEFAULTS => "default",
            SourceKind::COERCED => "derived",
            _ => "set by",
        };
        let _ = writeln!(out, "  {verb:<8}{}", one_line(origin.describe()));
    }
    // The spec's own spelling, not the prose an error message uses: a reader searching the docs
    // for "a non-negative integer" finds nothing, and `uint` is what the author wrote.
    let _ = writeln!(out, "  {:<8}{}", "type", meta.ty.name());
    // What it will take, when it says. A user reading an explanation because their value was refused
    // needs the list here rather than in a warning they have already scrolled past.
    if !meta.choices.is_empty() {
        let _ = writeln!(out, "  {:<8}{}", "one of", one_line(&meta.allowed()));
    }
    if let Some(help) = meta.help {
        // Through the same helper as everything else: an adopter's help is a doc comment, and a
        // doc comment with a second paragraph in it split one fact into several records.
        let _ = writeln!(out, "  {:<8}{}", "", one_line(help));
    }

    // Everything that contributed, which for a `union` or `deep` setting is how a list assembled
    // from four files is accounted for. The winner is the last of them, so printing it again
    // would be noise — but for anything with more than one contributor, *which* others there
    // were is the question being asked.
    let contributors = resolved.contributors(found.id);
    if contributors.len() > 1 {
        let _ = writeln!(out, "\n  also considered, lowest precedence first:");
        for origin in &contributors[..contributors.len() - 1] {
            // No padding: the column it was aligning for is one this crate cannot fill. A
            // contributor's *value* is not kept — provenance is origins — so the width only ever
            // added trailing spaces to the last thing on the line, and a path copied out of an
            // explanation stopped being the path the merge recorded.
            let _ = writeln!(out, "    {}", one_line(origin.describe()));
        }
    }

    // Where else it *could* come from, which is the other half of the question: a user who does
    // not like the answer needs to know what to change.
    // The blank line opens whichever of these comes first, rather than belonging to the
    // environment: a setting with git or pkl bindings and no environment variables had its
    // `also` line jammed against the type or the help above it, reading as part of them.
    if !meta.envs.is_empty() || !meta.cli.is_empty() || !meta.bindings.is_empty() {
        let _ = writeln!(out);
    }
    if !meta.cli.is_empty() {
        // Before the environment, because it is the way a user is most likely to reach for next: an
        // explanation that listed the variables and not the flag answered half the question.
        let _ = writeln!(out, "  command line {}", one_line(&meta.cli.join(", ")));
    }
    if !meta.envs.is_empty() {
        let _ = writeln!(out, "  environment  {}", one_line(&meta.envs.join(", ")));
    }
    if !meta.bindings.is_empty() {
        let bindings: Vec<String> = meta
            .bindings
            .iter()
            .map(|(kind, key)| format!("{kind} {key}"))
            .collect();
        let _ = writeln!(out, "  also         {}", one_line(&bindings.join(", ")));
    }
    // Starting from the *asked-about* name, because a deprecation notice lives on the old name and
    // reading it off the setting that replaced it printed nothing at all for the one case where it
    // matters — and following the renames from there, because a notice can sit anywhere along a
    // chain: `a` renamed to `b`, and `b` the one carrying the notice that says to use `c`.
    let deprecated = registry.deprecation(found.written);
    if let Some(why) = deprecated {
        let _ = writeln!(out, "\n  deprecated: {}", one_line(why));
    }

    Some(out)
}

/// Every warning the resolution produced, as lines.
///
/// Separate from [`explain`] because they answer different questions and belong in different
/// places: a warning is about the *whole* resolution and a CLI prints it when its logging is up,
/// while an explanation is about one setting somebody asked after.
pub fn warnings(resolved: &Resolved) -> Vec<String> {
    resolved
        .warnings
        .iter()
        .map(|warning| {
            let message = one_line(&warning.message);
            match &warning.origin {
                // The message quotes the value it rejected, which is a value out of a file and
                // can hold anything — including the newline that would hide every warning after
                // this one.
                Some(origin) => format!("{message} ({})", one_line(origin.describe())),
                None => message,
            }
        })
        .collect()
}

/// Every setting and its value, one per line, for a `config ls`.
///
/// Hidden settings are left out — they are settable and documented nowhere, so listing them
/// would be the one place they surface. Sorted by key, because a registry's order is the order
/// somebody wrote a TOML file in and a list a human reads should not depend on that.
pub fn list(resolved: &Resolved) -> Vec<String> {
    let registry = resolved.registry();
    let mut ids: Vec<PropId> = registry
        .ids()
        .filter(|id| !registry.get(*id).hide && registry.get(*id).renamed_to.is_none())
        .collect();
    ids.sort_by_key(|id| registry.get(*id).key);
    ids.into_iter()
        .map(|id| {
            let meta = registry.get(id);
            match resolved.get(id) {
                Some(value) => format!("{} = {}", meta.key, shown(value)),
                None => format!("{} is unset", meta.key),
            }
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::layer::{Entry, Layer, LayerCtx, LayerError, LayerOutput};
    use crate::registry::{Merge, PropMeta, Registry, Scope};
    use crate::resolve::{resolve, Layers};
    use crate::source::{FileScope, Origin};
    use crate::ty::Ty;
    use crate::value::{Const, Value};

    static PROPS: &[PropMeta] = &[
        PropMeta {
            default: Some(Const::Int(4)),
            envs: &["HK_JOBS", "HK_JOB"],
            cli: &["--jobs", "-j"],
            bindings: &[("git", "hk.jobs")],
            help: Some("How many jobs to run at once"),
            ..PropMeta::new("jobs", Ty::Uint)
        },
        PropMeta {
            merge: Merge::Union,
            ..PropMeta::new("exclude", Ty::List(&Ty::String))
        },
        PropMeta {
            deprecated: Some("Use jobs instead."),
            renamed_to: Some("jobs"),
            ..PropMeta::new("concurrency", Ty::Uint)
        },
        PropMeta {
            hide: true,
            ..PropMeta::new("internal", Ty::Bool)
        },
        PropMeta {
            scope: Scope::Global,
            ..PropMeta::new("trusted", Ty::Bool)
        },
        // Bindings and no environment variable, which is where the blank line went missing.
        PropMeta {
            bindings: &[("pkl", "hk.stash")],
            ..PropMeta::new("stash", Ty::String)
        },
    ];
    const REGISTRY: Registry = Registry::new(PROPS);

    struct Fixed(Vec<Entry>);

    impl Layer for Fixed {
        fn source(&self) -> SourceKind {
            SourceKind::FILE
        }
        fn load(&self, _ctx: &LayerCtx) -> Result<LayerOutput, LayerError> {
            Ok(LayerOutput {
                entries: self.0.clone(),
                warnings: Vec::new(),
            })
        }
    }

    fn id(key: &str) -> PropId {
        REGISTRY.lookup(key).expect("declared").id
    }

    #[test]
    fn an_explanation_names_the_winner_and_what_it_beat() {
        // The question a user asks: not "what is it" but "why is it that". The answer needs the
        // exact identifier — `HK_JOBS`, not "the environment" — because that is the thing they
        // have to go and change.
        let env = Fixed(vec![Entry::new(
            id("jobs"),
            Value::Int(8),
            Origin::new(SourceKind::ENV, "HK_JOBS"),
        )]);
        let file = Fixed(vec![Entry::new(
            id("jobs"),
            Value::Int(2),
            Origin::file("hk.toml#jobs", FileScope::Project),
        )]);
        let resolved =
            resolve(REGISTRY, Layers::new().then(&env).then(&file)).expect("should resolve");

        let text = explain(&resolved, "jobs").expect("declared");
        assert!(text.starts_with("jobs = 8\n"), "{text}");
        assert!(text.contains("set by  HK_JOBS"), "{text}");
        assert!(text.contains("type    uint"), "{text}");
        assert!(text.contains("How many jobs to run at once"), "{text}");
        // What else tried, lowest first, and the winner not repeated among them.
        assert!(text.contains("also considered"), "{text}");
        assert!(text.contains("the default"), "{text}");
        assert!(text.contains("hk.toml#jobs"), "{text}");
        assert_eq!(
            text.matches("HK_JOBS").count(),
            2,
            "once as the winner, once as a place it can be set:\n{text}"
        );
        // Nothing is padded to a column, because there is no second column: a path copied out of
        // an explanation has to be the path the merge recorded, trailing spaces and all.
        for line in text.lines() {
            assert_eq!(line, line.trim_end(), "trailing space:\n{text}");
        }
        // The flag, before the variables: a user reading an explanation because they do not like the
        // answer reaches for the command line first, and listing the variables alone answered half
        // the question they asked.
        // And where else it could come from, which is the other half of the question. Their
        // positions rather than their presence: an explanation that lists the same three lines in
        // the other order is a different answer to "what do I change", and `contains` cannot tell.
        let cli = text.find("command line --jobs, -j").expect(text.as_str());
        let env = text
            .find("environment  HK_JOBS, HK_JOB")
            .expect(text.as_str());
        let also = text.find("also         git hk.jobs").expect(text.as_str());
        assert!(cli < env && env < also, "{text}");
    }

    #[test]
    fn a_default_is_not_described_as_something_somebody_set() {
        let resolved = resolve(REGISTRY, Layers::new()).expect("should resolve");
        let text = explain(&resolved, "jobs").expect("declared");
        assert!(text.contains("default the default"), "{text}");
        assert!(!text.contains("set by"), "nobody set it: {text}");
        // With one contributor there is nothing it beat, so no list of also-considereds.
        assert!(!text.contains("also considered"), "{text}");
    }

    #[test]
    fn a_rewritten_value_says_it_was_derived() {
        // mise's `raw` implying `jobs = 1`. Calling this "set by" would send the user looking
        // for a file that never said it.
        let mut resolved = resolve(REGISTRY, Layers::new()).expect("should resolve");
        resolved.coerced(id("jobs"), Value::Int(1), "raw implies one job");
        let text = explain(&resolved, "jobs").expect("declared");
        assert!(text.contains("jobs = 1"), "{text}");
        assert!(text.contains("derived raw implies one job"), "{text}");
    }

    #[test]
    fn asking_after_an_old_name_answers_about_both() {
        // Somebody reading a config file written a year ago. Printing only the canonical name
        // would answer a question they did not ask.
        let resolved = resolve(REGISTRY, Layers::new()).expect("should resolve");
        let text = explain(&resolved, "concurrency").expect("declared");
        assert!(text.starts_with("concurrency is now jobs\n"), "{text}");
        assert!(text.contains("jobs = 4"), "{text}");
        assert!(text.contains("deprecated: Use jobs instead."), "{text}");
    }

    #[test]
    fn a_name_renamed_twice_still_finds_the_notice_along_the_way() {
        // A setting renamed once, then again: `threads` became `concurrency`, which became `jobs`.
        // The notice worth printing is the one on the step that has it, and reading only the name
        // the user typed found nothing — a rename is not itself an explanation of what to do
        // instead.
        static CHAIN: &[PropMeta] = &[
            PropMeta {
                default: Some(Const::Int(4)),
                ..PropMeta::new("jobs", Ty::Uint)
            },
            PropMeta {
                deprecated: Some("Use jobs instead."),
                renamed_to: Some("jobs"),
                ..PropMeta::new("concurrency", Ty::Uint)
            },
            PropMeta {
                renamed_to: Some("concurrency"),
                ..PropMeta::new("threads", Ty::Uint)
            },
        ];
        const CHAINED: Registry = Registry::new(CHAIN);

        let resolved = resolve(CHAINED, Layers::new()).expect("should resolve");
        let text = explain(&resolved, "threads").expect("declared");
        assert!(text.starts_with("threads is now jobs\n"), "{text}");
        assert!(text.contains("deprecated: Use jobs instead."), "{text}");
    }

    #[test]
    fn an_alias_on_a_renamed_setting_keeps_its_deprecation_notice() {
        static ALIASED: &[PropMeta] = &[
            PropMeta {
                default: Some(Const::Int(4)),
                ..PropMeta::new("jobs", Ty::Uint)
            },
            PropMeta {
                aliases: &["parallelism"],
                deprecated: Some("Use jobs instead."),
                renamed_to: Some("jobs"),
                ..PropMeta::new("concurrency", Ty::Uint)
            },
        ];
        const REGISTRY: Registry = Registry::new(ALIASED);

        let resolved = resolve(REGISTRY, Layers::new()).expect("should resolve");
        let text = explain(&resolved, "parallelism").expect("declared alias");
        assert!(text.starts_with("jobs = 4\n"), "{text}");
        assert!(!text.contains("is now"), "an alias is not a rename: {text}");
        assert!(text.contains("deprecated: Use jobs instead."), "{text}");
    }

    #[test]
    fn a_setting_nothing_supplied_says_so_rather_than_guessing() {
        let resolved = resolve(REGISTRY, Layers::new()).expect("should resolve");
        let text = explain(&resolved, "exclude").expect("declared");
        assert!(text.contains("exclude is unset"), "{text}");
        // A key the registry does not have is the caller's to report: a CLI usually wants to
        // suggest a near miss, and this module has no business guessing at one.
        assert_eq!(explain(&resolved, "nonesuch"), None);
    }

    #[test]
    fn a_setting_that_is_empty_says_so_rather_than_trailing_off() {
        // Emptied on purpose, which is a supported thing to do: `HK_EXCLUDE=` is how a declared
        // default is turned off. The value's own text is nothing at all, so `key = {}` printed a
        // line that stopped after the `=` — with a trailing space, and looking truncated rather
        // than empty. Set is not unset, and both are worth saying.
        let cleared = Fixed(vec![Entry::new(
            id("exclude"),
            Value::List(Vec::new()),
            Origin::new(SourceKind::ENV, "HK_EXCLUDE"),
        )]);
        let empty_text = Fixed(vec![Entry::new(
            id("stash"),
            Value::from(""),
            Origin::new(SourceKind::ENV, "HK_STASH"),
        )]);
        let resolved =
            resolve(REGISTRY, Layers::new().then(&cleared).then(&empty_text)).expect("resolves");

        let text = explain(&resolved, "exclude").expect("declared");
        assert!(text.starts_with("exclude = []\n"), "{text}");
        assert!(text.contains("set by  HK_EXCLUDE"), "{text}");
        let text = explain(&resolved, "stash").expect("declared");
        assert!(text.starts_with("stash = \"\"\n"), "{text}");

        // And the same in a listing, where every other line has a value on it.
        let listed = list(&resolved);
        assert!(listed.contains(&"exclude = []".to_string()), "{listed:?}");
        assert!(listed.contains(&"stash = \"\"".to_string()), "{listed:?}");
        for line in &listed {
            assert_eq!(line, line.trim_end(), "trailing space: {listed:?}");
        }
    }

    #[test]
    fn every_contributor_to_a_union_is_accounted_for() {
        // What hk's per-item provenance is for: a list assembled from several places, where
        // "where did this come from" has more than one answer.
        let env = Fixed(vec![Entry::new(
            id("exclude"),
            Value::List(vec![Value::from("target")]),
            Origin::new(SourceKind::ENV, "HK_EXCLUDE"),
        )]);
        let file = Fixed(vec![Entry::new(
            id("exclude"),
            Value::List(vec![Value::from("vendor")]),
            Origin::file("hk.toml#exclude", FileScope::Project),
        )]);
        let resolved =
            resolve(REGISTRY, Layers::new().then(&env).then(&file)).expect("should resolve");
        let text = explain(&resolved, "exclude").expect("declared");
        assert!(text.contains("exclude = vendor,target"), "{text}");
        assert!(text.contains("hk.toml#exclude"), "{text}");
        assert!(text.contains("HK_EXCLUDE"), "{text}");
    }

    #[test]
    fn a_value_with_a_newline_in_it_still_occupies_one_line() {
        // Both renderers here are line-oriented, and a multi-line string is a perfectly ordinary
        // thing to put in a TOML file — its continuation looked like another setting in a
        // listing, and like provenance in an explanation.
        let file = Fixed(vec![Entry::new(
            id("stash"),
            Value::from("first\nsecond"),
            Origin::file("hk.toml#stash", FileScope::Project),
        )]);
        let resolved = resolve(REGISTRY, Layers::new().then(&file)).expect("should resolve");

        let text = explain(&resolved, "stash").expect("declared");
        assert!(text.contains("stash = first\\nsecond"), "{text}");
        assert_eq!(
            text.lines().filter(|l| l.starts_with("stash")).count(),
            1,
            "the value should not start a second record:\n{text}"
        );
        // And a listing keeps one setting per line, which is the whole shape of its output.
        let lines = list(&resolved);
        assert!(
            lines.iter().any(|l| l == "stash = first\\nsecond"),
            "{lines:?}"
        );
        assert_eq!(lines.len(), 4, "{lines:?}");
    }

    #[test]
    fn nothing_interpolated_into_a_line_can_leave_it() {
        // Three things here can carry a newline, and all three did: the value, the *origin* — a
        // path may contain one — and a warning message, which quotes the value it rejected. Any
        // of them splitting its line makes the rest read as another record, and for warnings it
        // hides every one after it.
        struct Odd;
        impl Layer for Odd {
            fn source(&self) -> SourceKind {
                SourceKind::FILE
            }
            fn load(&self, ctx: &LayerCtx) -> Result<LayerOutput, LayerError> {
                let mut out = LayerOutput::new();
                // A path with a newline in it, and a value the declared type will refuse.
                let odd = Origin::file("hk\n.toml#jobs", FileScope::Project);
                match ctx.entry_for_key("jobs", "lots\nand lots", odd) {
                    Ok(entry) => out.push(entry),
                    Err(warning) => out.warn(warning),
                }
                // Two contributors, so the also-considered list is rendered — and the one
                // that loses is the one whose path carries the newline.
                out.push(Entry::new(
                    id("stash"),
                    Value::from("lower"),
                    Origin::file("also\nodd#stash", FileScope::System),
                ));
                out.push(Entry::new(
                    id("stash"),
                    Value::from("first\nsecond"),
                    // The winner's path carries one too: its line is rendered by a different
                    // branch from the also-considered list, and only one of the two was covered.
                    Origin::file("winner\nodd#stash", FileScope::Project),
                ));
                Ok(out)
            }
        }
        let odd = Odd;
        let resolved = resolve(REGISTRY, Layers::new().then(&odd)).expect("should resolve");

        // One warning, one line, whatever the rejected value contained.
        let lines = warnings(&resolved);
        assert_eq!(lines.len(), 1, "{lines:?}");
        assert_eq!(lines[0].lines().count(), 1, "{lines:?}");

        // And an explanation stays one fact per line, with the odd path escaped into its own.
        let text = explain(&resolved, "stash").expect("declared");
        for line in text.lines() {
            assert!(
                !line.trim_start().starts_with("odd"),
                "a path's second half became a record of its own:\n{text}"
            );
        }
        assert!(
            text.contains("also\\nodd#stash"),
            "the losing contributor's path should be escaped in place:\n{text}"
        );
        assert_eq!(
            text.matches("also\\nodd#stash").count(),
            1,
            "once, in the also-considered list:\n{text}"
        );
        assert!(
            text.contains("winner\\nodd#stash"),
            "the winner's own path should be escaped too:\n{text}"
        );

        // A path is reported as it is, though: doubling the separators in
        // `C:\Users\me\hk.toml` gives a reader something to copy that leads nowhere.
        struct Windows;
        impl Layer for Windows {
            fn source(&self) -> SourceKind {
                SourceKind::FILE
            }
            fn load(&self, _ctx: &LayerCtx) -> Result<LayerOutput, LayerError> {
                Ok(LayerOutput {
                    entries: vec![Entry::new(
                        id("stash"),
                        Value::from(r"C:\Users\me"),
                        Origin::file(r"C:\Users\me\hk.toml#stash", FileScope::Global),
                    )],
                    warnings: Vec::new(),
                })
            }
        }
        let windows = Windows;
        let resolved = resolve(REGISTRY, Layers::new().then(&windows)).expect("should resolve");
        let text = explain(&resolved, "stash").expect("declared");
        assert!(text.contains(r"C:\Users\me\hk.toml#stash"), "{text}");
        assert!(!text.contains(r"C:\\Users"), "separators doubled:\n{text}");
    }

    #[test]
    fn metadata_stays_on_its_own_line_too() {
        // An adopter's help is a doc comment, and a doc comment with a second paragraph is the
        // ordinary case — not an exotic one. Written raw, one fact became several records that
        // read like provenance.
        static PROSE: &[PropMeta] = &[PropMeta {
            help: Some("One line\n\nAnd a second paragraph."),
            deprecated: Some("Gone soon.\nReally."),
            ..PropMeta::new("wordy", Ty::Bool)
        }];
        const WORDY: Registry = Registry::new(PROSE);
        let resolved = resolve(WORDY, Layers::new()).expect("should resolve");
        let text = explain(&resolved, "wordy").expect("declared");
        assert!(
            text.contains("One line\\n\\nAnd a second paragraph."),
            "{text}"
        );
        assert!(text.contains("deprecated: Gone soon.\\nReally."), "{text}");
        // Four lines: the value, the type, the help, and the deprecation — plus the blank one
        // before it. Any of them splitting would push the count up.
        assert_eq!(text.lines().count(), 5, "{text:?}");
    }

    #[test]
    fn a_setting_with_bindings_and_no_environment_still_reads_as_a_section() {
        // The blank line used to belong to the environment section, so a setting with git or pkl
        // bindings and no variables had its `also` line jammed against the type above it.
        let resolved = resolve(REGISTRY, Layers::new()).expect("should resolve");
        let text = explain(&resolved, "stash").expect("declared");
        assert!(text.contains("\n\n  also         pkl hk.stash"), "{text:?}");
    }

    #[test]
    fn a_listing_leaves_out_what_is_hidden_and_sorts_what_is_left() {
        // A registry's order is the order somebody wrote a TOML file in; a list a human reads
        // should not depend on that. And a hidden setting is documented nowhere, so a listing
        // would be the one place it surfaced.
        let resolved = resolve(REGISTRY, Layers::new()).expect("should resolve");
        let lines = list(&resolved);
        assert_eq!(
            lines,
            [
                "exclude is unset",
                "jobs = 4",
                "stash is unset",
                "trusted is unset"
            ],
            "sorted, without `internal` or the old name for `jobs`"
        );
    }

    #[test]
    fn a_warning_names_its_place_once() {
        // A type error used to put the origin in its own text as well, so the rendered line said
        // the same file twice — harder to scan, and no more informative for it. The message says
        // what is wrong and the renderer says where, which also means every warning is rendered
        // the same way.
        struct Bad;
        impl Layer for Bad {
            fn source(&self) -> SourceKind {
                SourceKind::FILE
            }
            fn load(&self, ctx: &LayerCtx) -> Result<LayerOutput, LayerError> {
                let mut out = LayerOutput::new();
                let origin = Origin::file("hk.toml#jobs", FileScope::Project);
                match ctx.entry_for_key("jobs", "lots", origin) {
                    Ok(entry) => out.push(entry),
                    Err(warning) => out.warn(warning),
                }
                Ok(out)
            }
        }
        let bad = Bad;
        let resolved = resolve(REGISTRY, Layers::new().then(&bad)).expect("should resolve");
        let lines = warnings(&resolved);
        assert_eq!(lines.len(), 1, "{lines:?}");
        assert_eq!(
            lines[0].matches("hk.toml#jobs").count(),
            1,
            "the place should appear once: {lines:?}"
        );
        assert!(lines[0].ends_with("(hk.toml#jobs)"), "{lines:?}");
    }

    #[test]
    fn warnings_carry_the_place_that_caused_them() {
        // A message without the file is a message a user cannot act on.
        let project = Fixed(vec![Entry::new(
            id("trusted"),
            Value::Bool(true),
            Origin::file("hk.toml#trusted", FileScope::Project),
        )]);
        let resolved = resolve(REGISTRY, Layers::new().then(&project)).expect("should resolve");
        let lines = warnings(&resolved);
        assert_eq!(lines.len(), 1, "{lines:?}");
        assert!(lines[0].starts_with("trusted cannot be set"), "{lines:?}");
        assert!(lines[0].ends_with("(hk.toml#trusted)"), "{lines:?}");
    }
}