Skip to main content

usage_config/
explain.rs

1//! Why a setting has the value it has.
2//!
3//! hk's `config explain` is the best of its kind in the fleet — it names the winning source and
4//! the exact identifier, and it does per-item provenance for lists. It also costs about two
5//! hundred lines, and it is written against a *second* merge function that exists only to
6//! answer this question. Two merges can disagree, and when they do the explanation describes a
7//! resolution that never happened.
8//!
9//! Here there is one merge and its provenance is the answer, so this module is a renderer and
10//! nothing more. Every adopter gets the command; none of them writes it.
11//!
12//! The output is plain text on purpose. A CLI that wants JSON has the same [`Resolved`] this
13//! reads and better taste than a library about what its own JSON should look like.
14
15use std::fmt::Write as _;
16
17use crate::registry::PropId;
18use crate::resolve::Resolved;
19use crate::source::SourceKind;
20use crate::value::{one_line, shown};
21
22/// One setting explained: what it is, where it came from, and what else tried.
23///
24/// ```text
25/// jobs = 4
26///   set by  MISE_JOBS
27///   type    uint
28///
29///   also considered, lowest precedence first:
30///     the default
31///     mise.toml#jobs
32/// ```
33///
34/// Returns `None` for a key the registry does not have, which the caller reports its own way —
35/// a CLI usually wants to suggest a near miss, and this module has no business guessing.
36pub fn explain(resolved: &Resolved, key: &str) -> Option<String> {
37    let registry = resolved.registry();
38    let found = registry.lookup(key)?;
39    let meta = registry.get(found.id);
40    let mut out = String::new();
41
42    // The name the *user* asked about, then the setting it turned out to be. Printing only the
43    // canonical name would answer a question nobody asked.
44    if let Some(old) = found.renamed_from {
45        let _ = writeln!(out, "{old} is now {}", meta.key);
46    }
47
48    match resolved.get(found.id) {
49        Some(value) => {
50            let _ = writeln!(out, "{} = {}", meta.key, shown(value));
51        }
52        None => {
53            let _ = writeln!(out, "{} is unset", meta.key);
54        }
55    }
56
57    if let Some(origin) = resolved.origin(found.id) {
58        // "set by the default" reads oddly, and a rewrite is not something anybody *set*.
59        let verb = match origin.kind {
60            SourceKind::DEFAULTS => "default",
61            SourceKind::COERCED => "derived",
62            _ => "set by",
63        };
64        let _ = writeln!(out, "  {verb:<8}{}", one_line(origin.describe()));
65    }
66    // The spec's own spelling, not the prose an error message uses: a reader searching the docs
67    // for "a non-negative integer" finds nothing, and `uint` is what the author wrote.
68    let _ = writeln!(out, "  {:<8}{}", "type", meta.ty.name());
69    // What it will take, when it says. A user reading an explanation because their value was refused
70    // needs the list here rather than in a warning they have already scrolled past.
71    if !meta.choices.is_empty() {
72        let _ = writeln!(out, "  {:<8}{}", "one of", one_line(&meta.allowed()));
73    }
74    if let Some(help) = meta.help {
75        // Through the same helper as everything else: an adopter's help is a doc comment, and a
76        // doc comment with a second paragraph in it split one fact into several records.
77        let _ = writeln!(out, "  {:<8}{}", "", one_line(help));
78    }
79
80    // Everything that contributed, which for a `union` or `deep` setting is how a list assembled
81    // from four files is accounted for. The winner is the last of them, so printing it again
82    // would be noise — but for anything with more than one contributor, *which* others there
83    // were is the question being asked.
84    let contributors = resolved.contributors(found.id);
85    if contributors.len() > 1 {
86        let _ = writeln!(out, "\n  also considered, lowest precedence first:");
87        for origin in &contributors[..contributors.len() - 1] {
88            // No padding: the column it was aligning for is one this crate cannot fill. A
89            // contributor's *value* is not kept — provenance is origins — so the width only ever
90            // added trailing spaces to the last thing on the line, and a path copied out of an
91            // explanation stopped being the path the merge recorded.
92            let _ = writeln!(out, "    {}", one_line(origin.describe()));
93        }
94    }
95
96    // Where else it *could* come from, which is the other half of the question: a user who does
97    // not like the answer needs to know what to change.
98    // The blank line opens whichever of these comes first, rather than belonging to the
99    // environment: a setting with git or pkl bindings and no environment variables had its
100    // `also` line jammed against the type or the help above it, reading as part of them.
101    if !meta.envs.is_empty() || !meta.cli.is_empty() || !meta.bindings.is_empty() {
102        let _ = writeln!(out);
103    }
104    if !meta.cli.is_empty() {
105        // Before the environment, because it is the way a user is most likely to reach for next: an
106        // explanation that listed the variables and not the flag answered half the question.
107        let _ = writeln!(out, "  command line {}", one_line(&meta.cli.join(", ")));
108    }
109    if !meta.envs.is_empty() {
110        let _ = writeln!(out, "  environment  {}", one_line(&meta.envs.join(", ")));
111    }
112    if !meta.bindings.is_empty() {
113        let bindings: Vec<String> = meta
114            .bindings
115            .iter()
116            .map(|(kind, key)| format!("{kind} {key}"))
117            .collect();
118        let _ = writeln!(out, "  also         {}", one_line(&bindings.join(", ")));
119    }
120    // Starting from the *asked-about* name, because a deprecation notice lives on the old name and
121    // reading it off the setting that replaced it printed nothing at all for the one case where it
122    // matters — and following the renames from there, because a notice can sit anywhere along a
123    // chain: `a` renamed to `b`, and `b` the one carrying the notice that says to use `c`.
124    let deprecated = registry.deprecation(found.written);
125    if let Some(why) = deprecated {
126        let _ = writeln!(out, "\n  deprecated: {}", one_line(why));
127    }
128
129    Some(out)
130}
131
132/// Every warning the resolution produced, as lines.
133///
134/// Separate from [`explain`] because they answer different questions and belong in different
135/// places: a warning is about the *whole* resolution and a CLI prints it when its logging is up,
136/// while an explanation is about one setting somebody asked after.
137pub fn warnings(resolved: &Resolved) -> Vec<String> {
138    resolved
139        .warnings
140        .iter()
141        .map(|warning| {
142            let message = one_line(&warning.message);
143            match &warning.origin {
144                // The message quotes the value it rejected, which is a value out of a file and
145                // can hold anything — including the newline that would hide every warning after
146                // this one.
147                Some(origin) => format!("{message} ({})", one_line(origin.describe())),
148                None => message,
149            }
150        })
151        .collect()
152}
153
154/// Every setting and its value, one per line, for a `config ls`.
155///
156/// Hidden settings are left out — they are settable and documented nowhere, so listing them
157/// would be the one place they surface. Sorted by key, because a registry's order is the order
158/// somebody wrote a TOML file in and a list a human reads should not depend on that.
159pub fn list(resolved: &Resolved) -> Vec<String> {
160    let registry = resolved.registry();
161    let mut ids: Vec<PropId> = registry
162        .ids()
163        .filter(|id| !registry.get(*id).hide && registry.get(*id).renamed_to.is_none())
164        .collect();
165    ids.sort_by_key(|id| registry.get(*id).key);
166    ids.into_iter()
167        .map(|id| {
168            let meta = registry.get(id);
169            match resolved.get(id) {
170                Some(value) => format!("{} = {}", meta.key, shown(value)),
171                None => format!("{} is unset", meta.key),
172            }
173        })
174        .collect()
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180    use crate::layer::{Entry, Layer, LayerCtx, LayerError, LayerOutput};
181    use crate::registry::{Merge, PropMeta, Registry, Scope};
182    use crate::resolve::{resolve, Layers};
183    use crate::source::{FileScope, Origin};
184    use crate::ty::Ty;
185    use crate::value::{Const, Value};
186
187    static PROPS: &[PropMeta] = &[
188        PropMeta {
189            default: Some(Const::Int(4)),
190            envs: &["HK_JOBS", "HK_JOB"],
191            cli: &["--jobs", "-j"],
192            bindings: &[("git", "hk.jobs")],
193            help: Some("How many jobs to run at once"),
194            ..PropMeta::new("jobs", Ty::Uint)
195        },
196        PropMeta {
197            merge: Merge::Union,
198            ..PropMeta::new("exclude", Ty::List(&Ty::String))
199        },
200        PropMeta {
201            deprecated: Some("Use jobs instead."),
202            renamed_to: Some("jobs"),
203            ..PropMeta::new("concurrency", Ty::Uint)
204        },
205        PropMeta {
206            hide: true,
207            ..PropMeta::new("internal", Ty::Bool)
208        },
209        PropMeta {
210            scope: Scope::Global,
211            ..PropMeta::new("trusted", Ty::Bool)
212        },
213        // Bindings and no environment variable, which is where the blank line went missing.
214        PropMeta {
215            bindings: &[("pkl", "hk.stash")],
216            ..PropMeta::new("stash", Ty::String)
217        },
218    ];
219    const REGISTRY: Registry = Registry::new(PROPS);
220
221    struct Fixed(Vec<Entry>);
222
223    impl Layer for Fixed {
224        fn source(&self) -> SourceKind {
225            SourceKind::FILE
226        }
227        fn load(&self, _ctx: &LayerCtx) -> Result<LayerOutput, LayerError> {
228            Ok(LayerOutput {
229                entries: self.0.clone(),
230                warnings: Vec::new(),
231            })
232        }
233    }
234
235    fn id(key: &str) -> PropId {
236        REGISTRY.lookup(key).expect("declared").id
237    }
238
239    #[test]
240    fn an_explanation_names_the_winner_and_what_it_beat() {
241        // The question a user asks: not "what is it" but "why is it that". The answer needs the
242        // exact identifier — `HK_JOBS`, not "the environment" — because that is the thing they
243        // have to go and change.
244        let env = Fixed(vec![Entry::new(
245            id("jobs"),
246            Value::Int(8),
247            Origin::new(SourceKind::ENV, "HK_JOBS"),
248        )]);
249        let file = Fixed(vec![Entry::new(
250            id("jobs"),
251            Value::Int(2),
252            Origin::file("hk.toml#jobs", FileScope::Project),
253        )]);
254        let resolved =
255            resolve(REGISTRY, Layers::new().then(&env).then(&file)).expect("should resolve");
256
257        let text = explain(&resolved, "jobs").expect("declared");
258        assert!(text.starts_with("jobs = 8\n"), "{text}");
259        assert!(text.contains("set by  HK_JOBS"), "{text}");
260        assert!(text.contains("type    uint"), "{text}");
261        assert!(text.contains("How many jobs to run at once"), "{text}");
262        // What else tried, lowest first, and the winner not repeated among them.
263        assert!(text.contains("also considered"), "{text}");
264        assert!(text.contains("the default"), "{text}");
265        assert!(text.contains("hk.toml#jobs"), "{text}");
266        assert_eq!(
267            text.matches("HK_JOBS").count(),
268            2,
269            "once as the winner, once as a place it can be set:\n{text}"
270        );
271        // Nothing is padded to a column, because there is no second column: a path copied out of
272        // an explanation has to be the path the merge recorded, trailing spaces and all.
273        for line in text.lines() {
274            assert_eq!(line, line.trim_end(), "trailing space:\n{text}");
275        }
276        // The flag, before the variables: a user reading an explanation because they do not like the
277        // answer reaches for the command line first, and listing the variables alone answered half
278        // the question they asked.
279        // And where else it could come from, which is the other half of the question. Their
280        // positions rather than their presence: an explanation that lists the same three lines in
281        // the other order is a different answer to "what do I change", and `contains` cannot tell.
282        let cli = text.find("command line --jobs, -j").expect(text.as_str());
283        let env = text
284            .find("environment  HK_JOBS, HK_JOB")
285            .expect(text.as_str());
286        let also = text.find("also         git hk.jobs").expect(text.as_str());
287        assert!(cli < env && env < also, "{text}");
288    }
289
290    #[test]
291    fn a_default_is_not_described_as_something_somebody_set() {
292        let resolved = resolve(REGISTRY, Layers::new()).expect("should resolve");
293        let text = explain(&resolved, "jobs").expect("declared");
294        assert!(text.contains("default the default"), "{text}");
295        assert!(!text.contains("set by"), "nobody set it: {text}");
296        // With one contributor there is nothing it beat, so no list of also-considereds.
297        assert!(!text.contains("also considered"), "{text}");
298    }
299
300    #[test]
301    fn a_rewritten_value_says_it_was_derived() {
302        // mise's `raw` implying `jobs = 1`. Calling this "set by" would send the user looking
303        // for a file that never said it.
304        let mut resolved = resolve(REGISTRY, Layers::new()).expect("should resolve");
305        resolved.coerced(id("jobs"), Value::Int(1), "raw implies one job");
306        let text = explain(&resolved, "jobs").expect("declared");
307        assert!(text.contains("jobs = 1"), "{text}");
308        assert!(text.contains("derived raw implies one job"), "{text}");
309    }
310
311    #[test]
312    fn asking_after_an_old_name_answers_about_both() {
313        // Somebody reading a config file written a year ago. Printing only the canonical name
314        // would answer a question they did not ask.
315        let resolved = resolve(REGISTRY, Layers::new()).expect("should resolve");
316        let text = explain(&resolved, "concurrency").expect("declared");
317        assert!(text.starts_with("concurrency is now jobs\n"), "{text}");
318        assert!(text.contains("jobs = 4"), "{text}");
319        assert!(text.contains("deprecated: Use jobs instead."), "{text}");
320    }
321
322    #[test]
323    fn a_name_renamed_twice_still_finds_the_notice_along_the_way() {
324        // A setting renamed once, then again: `threads` became `concurrency`, which became `jobs`.
325        // The notice worth printing is the one on the step that has it, and reading only the name
326        // the user typed found nothing — a rename is not itself an explanation of what to do
327        // instead.
328        static CHAIN: &[PropMeta] = &[
329            PropMeta {
330                default: Some(Const::Int(4)),
331                ..PropMeta::new("jobs", Ty::Uint)
332            },
333            PropMeta {
334                deprecated: Some("Use jobs instead."),
335                renamed_to: Some("jobs"),
336                ..PropMeta::new("concurrency", Ty::Uint)
337            },
338            PropMeta {
339                renamed_to: Some("concurrency"),
340                ..PropMeta::new("threads", Ty::Uint)
341            },
342        ];
343        const CHAINED: Registry = Registry::new(CHAIN);
344
345        let resolved = resolve(CHAINED, Layers::new()).expect("should resolve");
346        let text = explain(&resolved, "threads").expect("declared");
347        assert!(text.starts_with("threads is now jobs\n"), "{text}");
348        assert!(text.contains("deprecated: Use jobs instead."), "{text}");
349    }
350
351    #[test]
352    fn an_alias_on_a_renamed_setting_keeps_its_deprecation_notice() {
353        static ALIASED: &[PropMeta] = &[
354            PropMeta {
355                default: Some(Const::Int(4)),
356                ..PropMeta::new("jobs", Ty::Uint)
357            },
358            PropMeta {
359                aliases: &["parallelism"],
360                deprecated: Some("Use jobs instead."),
361                renamed_to: Some("jobs"),
362                ..PropMeta::new("concurrency", Ty::Uint)
363            },
364        ];
365        const REGISTRY: Registry = Registry::new(ALIASED);
366
367        let resolved = resolve(REGISTRY, Layers::new()).expect("should resolve");
368        let text = explain(&resolved, "parallelism").expect("declared alias");
369        assert!(text.starts_with("jobs = 4\n"), "{text}");
370        assert!(!text.contains("is now"), "an alias is not a rename: {text}");
371        assert!(text.contains("deprecated: Use jobs instead."), "{text}");
372    }
373
374    #[test]
375    fn a_setting_nothing_supplied_says_so_rather_than_guessing() {
376        let resolved = resolve(REGISTRY, Layers::new()).expect("should resolve");
377        let text = explain(&resolved, "exclude").expect("declared");
378        assert!(text.contains("exclude is unset"), "{text}");
379        // A key the registry does not have is the caller's to report: a CLI usually wants to
380        // suggest a near miss, and this module has no business guessing at one.
381        assert_eq!(explain(&resolved, "nonesuch"), None);
382    }
383
384    #[test]
385    fn a_setting_that_is_empty_says_so_rather_than_trailing_off() {
386        // Emptied on purpose, which is a supported thing to do: `HK_EXCLUDE=` is how a declared
387        // default is turned off. The value's own text is nothing at all, so `key = {}` printed a
388        // line that stopped after the `=` — with a trailing space, and looking truncated rather
389        // than empty. Set is not unset, and both are worth saying.
390        let cleared = Fixed(vec![Entry::new(
391            id("exclude"),
392            Value::List(Vec::new()),
393            Origin::new(SourceKind::ENV, "HK_EXCLUDE"),
394        )]);
395        let empty_text = Fixed(vec![Entry::new(
396            id("stash"),
397            Value::from(""),
398            Origin::new(SourceKind::ENV, "HK_STASH"),
399        )]);
400        let resolved =
401            resolve(REGISTRY, Layers::new().then(&cleared).then(&empty_text)).expect("resolves");
402
403        let text = explain(&resolved, "exclude").expect("declared");
404        assert!(text.starts_with("exclude = []\n"), "{text}");
405        assert!(text.contains("set by  HK_EXCLUDE"), "{text}");
406        let text = explain(&resolved, "stash").expect("declared");
407        assert!(text.starts_with("stash = \"\"\n"), "{text}");
408
409        // And the same in a listing, where every other line has a value on it.
410        let listed = list(&resolved);
411        assert!(listed.contains(&"exclude = []".to_string()), "{listed:?}");
412        assert!(listed.contains(&"stash = \"\"".to_string()), "{listed:?}");
413        for line in &listed {
414            assert_eq!(line, line.trim_end(), "trailing space: {listed:?}");
415        }
416    }
417
418    #[test]
419    fn every_contributor_to_a_union_is_accounted_for() {
420        // What hk's per-item provenance is for: a list assembled from several places, where
421        // "where did this come from" has more than one answer.
422        let env = Fixed(vec![Entry::new(
423            id("exclude"),
424            Value::List(vec![Value::from("target")]),
425            Origin::new(SourceKind::ENV, "HK_EXCLUDE"),
426        )]);
427        let file = Fixed(vec![Entry::new(
428            id("exclude"),
429            Value::List(vec![Value::from("vendor")]),
430            Origin::file("hk.toml#exclude", FileScope::Project),
431        )]);
432        let resolved =
433            resolve(REGISTRY, Layers::new().then(&env).then(&file)).expect("should resolve");
434        let text = explain(&resolved, "exclude").expect("declared");
435        assert!(text.contains("exclude = vendor,target"), "{text}");
436        assert!(text.contains("hk.toml#exclude"), "{text}");
437        assert!(text.contains("HK_EXCLUDE"), "{text}");
438    }
439
440    #[test]
441    fn a_value_with_a_newline_in_it_still_occupies_one_line() {
442        // Both renderers here are line-oriented, and a multi-line string is a perfectly ordinary
443        // thing to put in a TOML file — its continuation looked like another setting in a
444        // listing, and like provenance in an explanation.
445        let file = Fixed(vec![Entry::new(
446            id("stash"),
447            Value::from("first\nsecond"),
448            Origin::file("hk.toml#stash", FileScope::Project),
449        )]);
450        let resolved = resolve(REGISTRY, Layers::new().then(&file)).expect("should resolve");
451
452        let text = explain(&resolved, "stash").expect("declared");
453        assert!(text.contains("stash = first\\nsecond"), "{text}");
454        assert_eq!(
455            text.lines().filter(|l| l.starts_with("stash")).count(),
456            1,
457            "the value should not start a second record:\n{text}"
458        );
459        // And a listing keeps one setting per line, which is the whole shape of its output.
460        let lines = list(&resolved);
461        assert!(
462            lines.iter().any(|l| l == "stash = first\\nsecond"),
463            "{lines:?}"
464        );
465        assert_eq!(lines.len(), 4, "{lines:?}");
466    }
467
468    #[test]
469    fn nothing_interpolated_into_a_line_can_leave_it() {
470        // Three things here can carry a newline, and all three did: the value, the *origin* — a
471        // path may contain one — and a warning message, which quotes the value it rejected. Any
472        // of them splitting its line makes the rest read as another record, and for warnings it
473        // hides every one after it.
474        struct Odd;
475        impl Layer for Odd {
476            fn source(&self) -> SourceKind {
477                SourceKind::FILE
478            }
479            fn load(&self, ctx: &LayerCtx) -> Result<LayerOutput, LayerError> {
480                let mut out = LayerOutput::new();
481                // A path with a newline in it, and a value the declared type will refuse.
482                let odd = Origin::file("hk\n.toml#jobs", FileScope::Project);
483                match ctx.entry_for_key("jobs", "lots\nand lots", odd) {
484                    Ok(entry) => out.push(entry),
485                    Err(warning) => out.warn(warning),
486                }
487                // Two contributors, so the also-considered list is rendered — and the one
488                // that loses is the one whose path carries the newline.
489                out.push(Entry::new(
490                    id("stash"),
491                    Value::from("lower"),
492                    Origin::file("also\nodd#stash", FileScope::System),
493                ));
494                out.push(Entry::new(
495                    id("stash"),
496                    Value::from("first\nsecond"),
497                    // The winner's path carries one too: its line is rendered by a different
498                    // branch from the also-considered list, and only one of the two was covered.
499                    Origin::file("winner\nodd#stash", FileScope::Project),
500                ));
501                Ok(out)
502            }
503        }
504        let odd = Odd;
505        let resolved = resolve(REGISTRY, Layers::new().then(&odd)).expect("should resolve");
506
507        // One warning, one line, whatever the rejected value contained.
508        let lines = warnings(&resolved);
509        assert_eq!(lines.len(), 1, "{lines:?}");
510        assert_eq!(lines[0].lines().count(), 1, "{lines:?}");
511
512        // And an explanation stays one fact per line, with the odd path escaped into its own.
513        let text = explain(&resolved, "stash").expect("declared");
514        for line in text.lines() {
515            assert!(
516                !line.trim_start().starts_with("odd"),
517                "a path's second half became a record of its own:\n{text}"
518            );
519        }
520        assert!(
521            text.contains("also\\nodd#stash"),
522            "the losing contributor's path should be escaped in place:\n{text}"
523        );
524        assert_eq!(
525            text.matches("also\\nodd#stash").count(),
526            1,
527            "once, in the also-considered list:\n{text}"
528        );
529        assert!(
530            text.contains("winner\\nodd#stash"),
531            "the winner's own path should be escaped too:\n{text}"
532        );
533
534        // A path is reported as it is, though: doubling the separators in
535        // `C:\Users\me\hk.toml` gives a reader something to copy that leads nowhere.
536        struct Windows;
537        impl Layer for Windows {
538            fn source(&self) -> SourceKind {
539                SourceKind::FILE
540            }
541            fn load(&self, _ctx: &LayerCtx) -> Result<LayerOutput, LayerError> {
542                Ok(LayerOutput {
543                    entries: vec![Entry::new(
544                        id("stash"),
545                        Value::from(r"C:\Users\me"),
546                        Origin::file(r"C:\Users\me\hk.toml#stash", FileScope::Global),
547                    )],
548                    warnings: Vec::new(),
549                })
550            }
551        }
552        let windows = Windows;
553        let resolved = resolve(REGISTRY, Layers::new().then(&windows)).expect("should resolve");
554        let text = explain(&resolved, "stash").expect("declared");
555        assert!(text.contains(r"C:\Users\me\hk.toml#stash"), "{text}");
556        assert!(!text.contains(r"C:\\Users"), "separators doubled:\n{text}");
557    }
558
559    #[test]
560    fn metadata_stays_on_its_own_line_too() {
561        // An adopter's help is a doc comment, and a doc comment with a second paragraph is the
562        // ordinary case — not an exotic one. Written raw, one fact became several records that
563        // read like provenance.
564        static PROSE: &[PropMeta] = &[PropMeta {
565            help: Some("One line\n\nAnd a second paragraph."),
566            deprecated: Some("Gone soon.\nReally."),
567            ..PropMeta::new("wordy", Ty::Bool)
568        }];
569        const WORDY: Registry = Registry::new(PROSE);
570        let resolved = resolve(WORDY, Layers::new()).expect("should resolve");
571        let text = explain(&resolved, "wordy").expect("declared");
572        assert!(
573            text.contains("One line\\n\\nAnd a second paragraph."),
574            "{text}"
575        );
576        assert!(text.contains("deprecated: Gone soon.\\nReally."), "{text}");
577        // Four lines: the value, the type, the help, and the deprecation — plus the blank one
578        // before it. Any of them splitting would push the count up.
579        assert_eq!(text.lines().count(), 5, "{text:?}");
580    }
581
582    #[test]
583    fn a_setting_with_bindings_and_no_environment_still_reads_as_a_section() {
584        // The blank line used to belong to the environment section, so a setting with git or pkl
585        // bindings and no variables had its `also` line jammed against the type above it.
586        let resolved = resolve(REGISTRY, Layers::new()).expect("should resolve");
587        let text = explain(&resolved, "stash").expect("declared");
588        assert!(text.contains("\n\n  also         pkl hk.stash"), "{text:?}");
589    }
590
591    #[test]
592    fn a_listing_leaves_out_what_is_hidden_and_sorts_what_is_left() {
593        // A registry's order is the order somebody wrote a TOML file in; a list a human reads
594        // should not depend on that. And a hidden setting is documented nowhere, so a listing
595        // would be the one place it surfaced.
596        let resolved = resolve(REGISTRY, Layers::new()).expect("should resolve");
597        let lines = list(&resolved);
598        assert_eq!(
599            lines,
600            [
601                "exclude is unset",
602                "jobs = 4",
603                "stash is unset",
604                "trusted is unset"
605            ],
606            "sorted, without `internal` or the old name for `jobs`"
607        );
608    }
609
610    #[test]
611    fn a_warning_names_its_place_once() {
612        // A type error used to put the origin in its own text as well, so the rendered line said
613        // the same file twice — harder to scan, and no more informative for it. The message says
614        // what is wrong and the renderer says where, which also means every warning is rendered
615        // the same way.
616        struct Bad;
617        impl Layer for Bad {
618            fn source(&self) -> SourceKind {
619                SourceKind::FILE
620            }
621            fn load(&self, ctx: &LayerCtx) -> Result<LayerOutput, LayerError> {
622                let mut out = LayerOutput::new();
623                let origin = Origin::file("hk.toml#jobs", FileScope::Project);
624                match ctx.entry_for_key("jobs", "lots", origin) {
625                    Ok(entry) => out.push(entry),
626                    Err(warning) => out.warn(warning),
627                }
628                Ok(out)
629            }
630        }
631        let bad = Bad;
632        let resolved = resolve(REGISTRY, Layers::new().then(&bad)).expect("should resolve");
633        let lines = warnings(&resolved);
634        assert_eq!(lines.len(), 1, "{lines:?}");
635        assert_eq!(
636            lines[0].matches("hk.toml#jobs").count(),
637            1,
638            "the place should appear once: {lines:?}"
639        );
640        assert!(lines[0].ends_with("(hk.toml#jobs)"), "{lines:?}");
641    }
642
643    #[test]
644    fn warnings_carry_the_place_that_caused_them() {
645        // A message without the file is a message a user cannot act on.
646        let project = Fixed(vec![Entry::new(
647            id("trusted"),
648            Value::Bool(true),
649            Origin::file("hk.toml#trusted", FileScope::Project),
650        )]);
651        let resolved = resolve(REGISTRY, Layers::new().then(&project)).expect("should resolve");
652        let lines = warnings(&resolved);
653        assert_eq!(lines.len(), 1, "{lines:?}");
654        assert!(lines[0].starts_with("trusted cannot be set"), "{lines:?}");
655        assert!(lines[0].ends_with("(hk.toml#trusted)"), "{lines:?}");
656    }
657}