Skip to main content

usage_config/
read.rs

1//! A resolution read as the types a settings struct holds.
2//!
3//! The merge already validates every value against the type its setting declares, so most of
4//! this cannot fail. Three things keep it from being a formality, and they are the reason the
5//! errors here carry provenance rather than panicking:
6//!
7//! - A **post-merge hook** writes with [`Resolved::coerced`], which is deliberately unchecked:
8//!   the hook is where a CLI puts the rules only it knows, and making it validate would make it
9//!   a second merge. A hook that writes `-1` to a `uint` is a bug in the CLI, and the error has
10//!   to say which setting and which hook — `mise`'s post-merge coercions touch a dozen settings.
11//! - A type **only the tool understands** (`any`, a union) is not coerced by the merge at all,
12//!   by declaration. The field that holds it is still concrete.
13//! - The field type **narrows further** than the declared one: `uint` is an `i64` in the merge
14//!   and a `u64` in the struct, and `int` is an `i64` that a field may hold as something
15//!   smaller.
16//!
17//! Every failure is collected rather than returned at the first one. A user fixing a config file
18//! wants the whole list — the fleet's hand-written folds return the first problem, so a file with
19//! three bad values takes three runs to fix.
20
21use std::collections::BTreeMap;
22use std::fmt;
23use std::path::PathBuf;
24
25use crate::registry::PropId;
26use crate::resolve::Resolved;
27use crate::source::Origin;
28use crate::ty::TypeError;
29use crate::value::{Value, one_line};
30
31/// A [`Value`] read as the type a field holds.
32///
33/// Strict: nothing here converts. Coercion happens once, in the merge, against the type the
34/// spec declared — a reader that converted as well would be a second set of rules for what a
35/// value means, which is the drift this crate exists to remove.
36pub trait FromValue: Sized {
37    /// Read `value`, or say what was expected and what arrived.
38    fn from_value(value: &Value) -> Result<Self, TypeError>;
39}
40
41/// Why a setting could not be read as the type its field holds.
42#[derive(Debug, Clone, PartialEq)]
43pub struct ReadError {
44    /// The setting's key, as the spec declares it.
45    pub key: &'static str,
46    /// Where the offending value came from. Absent when there is no value at all — nothing to
47    /// have an origin.
48    pub origin: Option<Origin>,
49    pub kind: ReadErrorKind,
50}
51
52/// The two ways reading a setting fails.
53#[derive(Debug, Clone, PartialEq)]
54pub enum ReadErrorKind {
55    /// A value of the wrong shape for the field.
56    Type(TypeError),
57    /// Nothing supplied a value, and the spec declares no default.
58    Missing,
59}
60
61impl fmt::Display for ReadError {
62    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63        match &self.kind {
64            // The same shape a layer's warning uses, so the two read alike when a CLI prints
65            // both, plus the place to go and edit: "expected a non-negative integer" with no source
66            // sends a user looking through every file in the chain.
67            //
68            // Through `one_line` for the same reason the explanation is: these are joined with
69            // newlines below, and the two things interpolated here are the ones that can contain
70            // one — a value out of a file, where a multi-line string is perfectly ordinary, and a
71            // path. One failure spilling onto three lines hides the failures after it.
72            ReadErrorKind::Type(err) => write!(
73                f,
74                "{} expected {} but has `{}`",
75                self.key,
76                err.expected,
77                one_line(&err.found)
78            )?,
79            ReadErrorKind::Missing => write!(f, "{} has no value and no default", self.key)?,
80        }
81        if let Some(origin) = &self.origin {
82            write!(f, " (set by {})", one_line(origin.describe()))?;
83        }
84        Ok(())
85    }
86}
87
88/// Every setting that could not be read, in the order the registry declares them.
89#[derive(Debug, Clone, PartialEq)]
90pub struct ReadErrors(pub Vec<ReadError>);
91
92impl fmt::Display for ReadErrors {
93    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
94        for (i, error) in self.0.iter().enumerate() {
95            if i > 0 {
96                f.write_str("\n")?;
97            }
98            write!(f, "{error}")?;
99        }
100        Ok(())
101    }
102}
103
104impl std::error::Error for ReadErrors {}
105
106/// Reading a whole settings struct out of one resolution.
107///
108/// Generated code reads each field in turn and calls [`Fold::finish`] once, which is what makes
109/// the errors a list rather than the first thing that went wrong.
110pub struct Fold<'a> {
111    resolved: &'a Resolved,
112    errors: Vec<ReadError>,
113    /// Whether a value that will not read falls back to the setting's declared default.
114    lossy: bool,
115}
116
117impl Resolved {
118    /// Start reading this resolution as typed values.
119    pub fn fold(&self) -> Fold<'_> {
120        Fold {
121            resolved: self,
122            errors: Vec::new(),
123            lossy: false,
124        }
125    }
126
127    /// Start reading, keeping every setting that does read.
128    ///
129    /// A strict fold reports and yields nothing, so one bad value costs the caller the whole
130    /// resolution — and a CLI whose only remaining move is `Settings::default()` has thrown away
131    /// the environment and every file over a single field. That is not a policy a library gets to
132    /// choose: erroring, warning and carrying on, or ignoring it outright are all reasonable, and
133    /// which one is right depends on the CLI. So this hands back both halves and lets it decide.
134    ///
135    /// A setting that will not read falls back to its declared default, which is the value the
136    /// CLI would have had if the offending layer had said nothing. The error is still recorded —
137    /// the fallback is not a repair, and a caller that wants to treat it as fatal still can.
138    pub fn fold_lossy(&self) -> Fold<'_> {
139        Fold {
140            resolved: self,
141            errors: Vec::new(),
142            lossy: true,
143        }
144    }
145
146    /// One setting, as the type `T` holds, with the error naming where the value came from.
147    ///
148    /// For a CLI reading a handful of settings by hand. Generated code uses [`Resolved::fold`],
149    /// which reports every bad value instead of this one.
150    pub fn read<T: FromValue>(&self, id: PropId) -> Result<Option<T>, ReadError> {
151        let mut fold = self.fold();
152        let value = fold.optional(id);
153        match fold.errors.pop() {
154            Some(error) => Err(error),
155            None => Ok(value),
156        }
157    }
158}
159
160impl Fold<'_> {
161    /// A setting that may have no value, as `Option<T>`.
162    ///
163    /// Absence is not an error here: a setting with no default and nothing set is a field that
164    /// holds `None`, which is what `option<T>` in the spec means.
165    pub fn optional<T: FromValue>(&mut self, id: PropId) -> Option<T> {
166        let value = self.resolved.get(id)?;
167        match T::from_value(value) {
168            Ok(value) => Some(value),
169            Err(err) => {
170                self.errors.push(ReadError {
171                    key: self.resolved.registry().get(id).key,
172                    origin: self.resolved.origin(id).cloned(),
173                    kind: ReadErrorKind::Type(err),
174                });
175                self.fallback(id)
176            }
177        }
178    }
179
180    /// A setting that must have a value, for a field that is not an `Option`.
181    ///
182    /// Returns `None` only when it has recorded an error, so code that has already called
183    /// [`Fold::finish`] and found it `Ok` may unwrap what this returned. That is the contract
184    /// generated code is written against: the fold reports, and the struct is built afterwards.
185    pub fn required<T: FromValue>(&mut self, id: PropId) -> Option<T> {
186        if self.resolved.get(id).is_none() {
187            self.errors.push(ReadError {
188                key: self.resolved.registry().get(id).key,
189                origin: None,
190                kind: ReadErrorKind::Missing,
191            });
192            // No fallback, in a lossy fold either: the merge seeds every declared default into
193            // the values, so nothing at all here *means* nothing was declared. A setting with no
194            // value and no default is a hole in the spec, and inventing one would hide it.
195            return None;
196        }
197        self.optional(id)
198    }
199
200    /// The declared default, for a value that would not read — in a lossy fold only.
201    ///
202    /// Nothing here re-validates: the default is a `Const` the spec declared, so if it does not
203    /// read as the field's type either, the declaration and the field disagree and there is
204    /// nothing to fall back *to*. The error is already recorded; the field goes without.
205    fn fallback<T: FromValue>(&self, id: PropId) -> Option<T> {
206        if !self.lossy {
207            return None;
208        }
209        let default = self.resolved.registry().get(id).default?;
210        T::from_value(&default.to_value()).ok()
211    }
212
213    /// What has gone wrong so far, for a caller that wants to add to the list.
214    pub fn errors(&self) -> &[ReadError] {
215        &self.errors
216    }
217
218    /// Every setting read, or every reason one could not be.
219    pub fn finish(self) -> Result<(), ReadErrors> {
220        if self.errors.is_empty() {
221            Ok(())
222        } else {
223            Err(ReadErrors(self.errors))
224        }
225    }
226
227    /// Everything that went wrong, whether or not anything did.
228    ///
229    /// What a lossy fold ends with: [`Fold::finish`] answers "did this work", and the answer
230    /// there is always "not entirely" or the caller would not be folding lossily.
231    pub fn into_errors(self) -> ReadErrors {
232        ReadErrors(self.errors)
233    }
234}
235
236/// The error for a value of the wrong shape, quoted the way it was written.
237fn mismatch(expected: &'static str, value: &Value) -> TypeError {
238    TypeError {
239        expected,
240        // Through the same renderer the merge's own warnings use, so an empty value is reported as
241        // `[]` or `""` rather than as nothing at all: "expected a list but has ``" names no value.
242        found: crate::value::shown(value),
243    }
244}
245
246impl FromValue for Value {
247    // A value read as itself, for a field whose type the spec left open: `object` says the keys are
248    // not described, and a union says usage cannot decide what belongs. Neither is a shape a
249    // narrower Rust type could hold without the generator inventing one.
250    fn from_value(value: &Value) -> Result<Self, TypeError> {
251        Ok(value.clone())
252    }
253}
254
255impl FromValue for bool {
256    fn from_value(value: &Value) -> Result<Self, TypeError> {
257        match value {
258            Value::Bool(b) => Ok(*b),
259            other => Err(mismatch("a boolean", other)),
260        }
261    }
262}
263
264impl FromValue for i64 {
265    fn from_value(value: &Value) -> Result<Self, TypeError> {
266        match value {
267            Value::Int(i) => Ok(*i),
268            other => Err(mismatch("an integer", other)),
269        }
270    }
271}
272
273impl FromValue for u64 {
274    fn from_value(value: &Value) -> Result<Self, TypeError> {
275        match value {
276            // Not `as`: a negative one becomes an enormous positive one, which is the shape of
277            // this bug in every codebase that has it. The merge refuses a negative `uint`, so
278            // getting here means a post-merge hook wrote one.
279            Value::Int(i) => {
280                Self::try_from(*i).map_err(|_| mismatch("a non-negative integer", value))
281            }
282            other => Err(mismatch("a non-negative integer", other)),
283        }
284    }
285}
286
287impl FromValue for f64 {
288    fn from_value(value: &Value) -> Result<Self, TypeError> {
289        match value {
290            Value::Float(f) => Ok(*f),
291            // A whole number is a perfectly good float, which is the rule the merge follows
292            // too: a spec that says `float` should not refuse `1`.
293            Value::Int(i) => Ok(*i as Self),
294            other => Err(mismatch("a number", other)),
295        }
296    }
297}
298
299impl FromValue for f32 {
300    fn from_value(value: &Value) -> Result<Self, TypeError> {
301        let wide = f64::from_value(value)?;
302        let narrow = wide as Self;
303        // The rule the narrower integers below follow, held here too: a value that does not
304        // fit is reported rather than wrapped. Rounding a finite value is ordinary precision
305        // loss; turning `1e300` into `inf` is a value the declaration never named.
306        if narrow.is_infinite() && wide.is_finite() {
307            return Err(mismatch("a number that fits 32 bits", value));
308        }
309        Ok(narrow)
310    }
311}
312
313/// The narrower integers a struct actually holds — a fleet CLI keeps `jobs` in a `usize` and a
314/// verbosity in a `u8` — read through the same rule as [`u64`]: a value that does not fit is
315/// reported rather than wrapped, because wrapping is the shape of this bug everywhere it exists.
316macro_rules! narrower_int {
317    ($($ty:ty => $expected:literal,)*) => {$(
318        impl FromValue for $ty {
319            fn from_value(value: &Value) -> Result<Self, TypeError> {
320                match value {
321                    Value::Int(i) => {
322                        Self::try_from(*i).map_err(|_| mismatch($expected, value))
323                    }
324                    other => Err(mismatch($expected, other)),
325                }
326            }
327        }
328    )*};
329}
330
331narrower_int! {
332    u8 => "a non-negative integer that fits 8 bits",
333    u16 => "a non-negative integer that fits 16 bits",
334    u32 => "a non-negative integer that fits 32 bits",
335    usize => "a non-negative integer",
336    i8 => "an integer that fits 8 bits",
337    i16 => "an integer that fits 16 bits",
338    i32 => "an integer that fits 32 bits",
339    isize => "an integer",
340}
341
342impl FromValue for String {
343    fn from_value(value: &Value) -> Result<Self, TypeError> {
344        match value {
345            Value::String(s) => Ok(s.clone()),
346            other => Err(mismatch("a string", other)),
347        }
348    }
349}
350
351impl FromValue for PathBuf {
352    fn from_value(value: &Value) -> Result<Self, TypeError> {
353        match value {
354            Value::String(s) => Ok(Self::from(s)),
355            other => Err(mismatch("a path", other)),
356        }
357    }
358}
359
360impl<T: FromValue> FromValue for Vec<T> {
361    fn from_value(value: &Value) -> Result<Self, TypeError> {
362        match value {
363            // A `set` is a list here: the merge has already dropped the duplicates, and a list
364            // keeps the order the files were read in — which a sorted set would throw away for
365            // settings like a `PATH` where the order is the meaning.
366            Value::List(items) => items.iter().map(T::from_value).collect(),
367            other => Err(mismatch("a list", other)),
368        }
369    }
370}
371
372impl<T: FromValue> FromValue for BTreeMap<String, T> {
373    fn from_value(value: &Value) -> Result<Self, TypeError> {
374        match value {
375            Value::Map(entries) => entries
376                .iter()
377                .map(|(key, value)| T::from_value(value).map(|value| (key.clone(), value)))
378                .collect(),
379            other => Err(mismatch("a table", other)),
380        }
381    }
382}
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387    use crate::layer::{Layer, LayerCtx, LayerError, LayerOutput};
388    use crate::registry::{PropMeta, Registry};
389    use crate::resolve::{Layers, resolve};
390    use crate::source::SourceKind;
391    use crate::ty::{Parser, Ty};
392    use crate::value::Const;
393
394    /// A value that does not fit the field it reads into is reported rather than wrapped.
395    ///
396    /// The narrower integers say so; `f32` reached `f64` and then cast with `as`, which turns
397    /// `1e300` into `inf` and calls it a successful read — a setting whose effective value
398    /// nothing declared.
399    #[test]
400    fn a_value_too_wide_for_the_field_is_reported_rather_than_wrapped() {
401        u8::from_value(&Value::Int(256)).expect_err("256 does not fit 8 bits");
402        i8::from_value(&Value::Int(-129)).expect_err("-129 does not fit 8 bits");
403        usize::from_value(&Value::Int(-1)).expect_err("-1 is not non-negative");
404        assert_eq!(u8::from_value(&Value::Int(255)).expect("255 fits"), 255);
405
406        f32::from_value(&Value::Float(1e300)).expect_err("1e300 is not an f32");
407        f32::from_value(&Value::Float(-1e300)).expect_err("-1e300 is not an f32");
408        // Rounding a value that does fit is ordinary precision loss, which is allowed.
409        assert_eq!(
410            f32::from_value(&Value::Float(0.1)).expect("0.1 fits"),
411            0.1_f32
412        );
413        // An infinity a layer actually supplied is the value it supplied, not an overflow.
414        assert!(
415            f32::from_value(&Value::Float(f64::INFINITY))
416                .expect("an infinity reads as one")
417                .is_infinite()
418        );
419    }
420
421    static PROPS: &[PropMeta] = &[
422        PropMeta {
423            default: Some(Const::Int(4)),
424            envs: &["MYCLI_JOBS"],
425            ..PropMeta::new("jobs", Ty::Uint)
426        },
427        PropMeta {
428            default: Some(Const::Bool(false)),
429            envs: &["MYCLI_RAW"],
430            ..PropMeta::new("raw", Ty::Bool)
431        },
432        PropMeta {
433            envs: &["MYCLI_CACHE_DIR"],
434            ..PropMeta::new("cache_dir", Ty::Option(&Ty::Path))
435        },
436        // Text splitting is the named parser's job, not the reader's: these arrive from an
437        // environment variable as one string.
438        PropMeta {
439            envs: &["MYCLI_EXCLUDE"],
440            parse: Some(Parser::ListByComma),
441            ..PropMeta::new("exclude", Ty::List(&Ty::String))
442        },
443        PropMeta {
444            envs: &["MYCLI_PORTS"],
445            parse: Some(Parser::ListByComma),
446            ..PropMeta::new("ports", Ty::List(&Ty::Uint))
447        },
448        PropMeta {
449            envs: &["MYCLI_ALIASES"],
450            ..PropMeta::new("aliases", Ty::Map(&Ty::String))
451        },
452        PropMeta {
453            envs: &["MYCLI_RATIO"],
454            ..PropMeta::new("ratio", Ty::Float)
455        },
456        // No default and not an option: a setting the CLI must have and nobody has supplied.
457        PropMeta::new("profile", Ty::String),
458    ];
459    const REGISTRY: Registry = Registry::new(PROPS);
460
461    fn id(key: &str) -> PropId {
462        REGISTRY.lookup(key).expect("declared").id
463    }
464
465    /// Values as text, the way an environment variable arrives.
466    struct Text(&'static [(&'static str, &'static str)]);
467
468    impl Layer for Text {
469        fn source(&self) -> SourceKind {
470            SourceKind::ENV
471        }
472        fn load(&self, ctx: &LayerCtx) -> Result<LayerOutput, LayerError> {
473            let mut out = LayerOutput::new();
474            for (key, raw) in self.0 {
475                let origin = Origin::new(SourceKind::ENV, format!("MYCLI_{}", key.to_uppercase()));
476                match ctx.entry_for_key(key, raw, origin) {
477                    Ok(entry) => out.push(entry),
478                    Err(warning) => out.warn(warning),
479                }
480            }
481            Ok(out)
482        }
483    }
484
485    #[test]
486    fn a_resolution_reads_as_the_types_a_struct_holds() {
487        let layer = Text(&[
488            ("jobs", "8"),
489            ("raw", "yes"),
490            ("cache_dir", "/tmp/cache"),
491            ("exclude", "target,dist"),
492            ("ports", "80,443"),
493            ("ratio", "0.5"),
494            ("profile", "release"),
495        ]);
496        let resolved = resolve(REGISTRY, Layers::new().then(&layer)).expect("resolves");
497
498        let mut fold = resolved.fold();
499        let jobs: Option<u64> = fold.required(id("jobs"));
500        let raw: Option<bool> = fold.required(id("raw"));
501        let cache_dir: Option<PathBuf> = fold.optional(id("cache_dir"));
502        let exclude: Option<Vec<String>> = fold.required(id("exclude"));
503        let ports: Option<Vec<u64>> = fold.required(id("ports"));
504        let ratio: Option<f64> = fold.required(id("ratio"));
505        let profile: Option<String> = fold.required(id("profile"));
506        fold.finish().expect("every value fits its field");
507
508        assert_eq!(jobs, Some(8));
509        assert_eq!(raw, Some(true));
510        assert_eq!(cache_dir, Some(PathBuf::from("/tmp/cache")));
511        assert_eq!(
512            exclude,
513            Some(vec!["target".to_string(), "dist".to_string()]),
514            "a list-typed setting keeps the order the file gave it"
515        );
516        assert_eq!(
517            ports,
518            Some(vec![80, 443]),
519            "and reads its items as the type"
520        );
521        assert_eq!(ratio, Some(0.5));
522        assert_eq!(profile, Some("release".to_string()));
523    }
524
525    #[test]
526    fn a_declared_default_is_read_like_any_other_value() {
527        // Nothing supplied a thing, so this reads the seeded defaults — the case where a
528        // generated struct is built from the registry alone.
529        let resolved = resolve(REGISTRY, Layers::new()).expect("resolves");
530        let mut fold = resolved.fold();
531        let jobs: Option<u64> = fold.required(id("jobs"));
532        let cache_dir: Option<PathBuf> = fold.optional(id("cache_dir"));
533        let exclude: Option<Vec<String>> = fold.optional(id("exclude"));
534        assert_eq!(jobs, Some(4));
535        assert_eq!(cache_dir, None, "no default, and absence is not an error");
536        assert_eq!(exclude, None);
537    }
538
539    #[test]
540    fn a_setting_with_no_value_and_no_default_says_which_one() {
541        // A field that is not an `Option` and has nothing to hold. Reported rather than
542        // unwrapped, because the CLI's own registry is what is wrong and the author needs the
543        // key to fix it.
544        let resolved = resolve(REGISTRY, Layers::new()).expect("resolves");
545        let mut fold = resolved.fold();
546        let profile: Option<String> = fold.required(id("profile"));
547        assert_eq!(profile, None);
548        let err = fold.finish().expect_err("should not read");
549        assert_eq!(err.to_string(), "profile has no value and no default");
550    }
551
552    #[test]
553    fn a_value_the_field_cannot_hold_names_where_it_came_from() {
554        // The reachable failure: a hook writing past the declared type. `Resolved::coerced` is
555        // unchecked by design — it is where a CLI puts the rules only it knows — so this is
556        // where writing `-1` to a `uint` is caught, and the message has to name the hook rather
557        // than leave the author guessing which of a dozen coercions did it.
558        let mut resolved = resolve(REGISTRY, Layers::new()).expect("resolves");
559        resolved.coerced(id("jobs"), Value::Int(-1), "one job when raw");
560
561        let mut fold = resolved.fold();
562        let jobs: Option<u64> = fold.required(id("jobs"));
563        assert_eq!(jobs, None);
564        let err = fold.finish().expect_err("should not read");
565        assert_eq!(
566            err.to_string(),
567            "jobs expected a non-negative integer but has `-1` (set by one job when raw)"
568        );
569    }
570
571    #[test]
572    fn every_bad_value_is_reported_and_not_only_the_first() {
573        // Three settings the fields cannot hold. The fleet's hand-written folds return the
574        // first, so a config file with three mistakes in it takes three runs to fix.
575        let mut resolved = resolve(REGISTRY, Layers::new()).expect("resolves");
576        resolved.coerced(id("jobs"), Value::Int(-1), "a hook");
577        resolved.coerced(id("raw"), Value::String("sometimes".into()), "a hook");
578        resolved.coerced(
579            id("ports"),
580            Value::List(vec![Value::Int(80), Value::Int(-443)]),
581            "a hook",
582        );
583
584        let mut fold = resolved.fold();
585        let _: Option<u64> = fold.required(id("jobs"));
586        let _: Option<bool> = fold.required(id("raw"));
587        let _: Option<Vec<u64>> = fold.required(id("ports"));
588        let _: Option<String> = fold.required(id("profile"));
589        let err = fold.finish().expect_err("should not read");
590
591        let message = err.to_string();
592        let lines: Vec<&str> = message.lines().collect();
593        assert_eq!(lines.len(), 4, "{err}");
594        assert!(
595            lines[0].starts_with("jobs expected a non-negative integer"),
596            "{err}"
597        );
598        assert!(
599            lines[1].starts_with("raw expected a boolean but has `sometimes`"),
600            "{err}"
601        );
602        // The item is what is wrong, and the item is what the message quotes.
603        assert!(
604            lines[2].starts_with("ports expected a non-negative integer but has `-443`"),
605            "{err}"
606        );
607        assert!(lines[3].starts_with("profile has no value"), "{err}");
608    }
609
610    #[test]
611    fn a_failure_stays_on_its_own_line_whatever_the_value_holds() {
612        // A multi-line string is perfectly ordinary in TOML, and a path may contain a newline.
613        // Interpolated as they are, one failure spilled across three lines and the failures listed
614        // after it read as part of it.
615        let mut resolved = resolve(REGISTRY, Layers::new()).expect("resolves");
616        resolved.coerced(
617            id("jobs"),
618            Value::String("two\nor three".into()),
619            "a hook\nover two lines",
620        );
621        let mut fold = resolved.fold();
622        let _: Option<u64> = fold.required(id("jobs"));
623        let _: Option<String> = fold.required(id("profile"));
624        let err = fold.finish().expect_err("should not read");
625
626        let message = err.to_string();
627        assert_eq!(message.lines().count(), 2, "{message}");
628        assert!(
629            message.starts_with(
630                "jobs expected a non-negative integer but has `two\\nor three` \
631                 (set by a hook\\nover two lines)"
632            ),
633            "{message}"
634        );
635    }
636
637    #[test]
638    fn a_failure_about_an_empty_value_still_names_one() {
639        // An emptied list is a value, and one a user can perfectly well have arrived at:
640        // `MYCLI_EXCLUDE=` clears a declared default. Quoted as its own text it named nothing —
641        // "expected a non-negative integer but has ``" — so it is reported as its shape, the same
642        // way the merge's warnings and `explain` report it.
643        let mut resolved = resolve(REGISTRY, Layers::new()).expect("resolves");
644        resolved.coerced(id("jobs"), Value::List(Vec::new()), "a hook");
645        let mut fold = resolved.fold();
646        let jobs: Option<u64> = fold.required(id("jobs"));
647        assert_eq!(jobs, None);
648        let err = fold.finish().expect_err("a list is not an integer");
649        assert_eq!(
650            err.to_string(),
651            "jobs expected a non-negative integer but has `[]` (set by a hook)"
652        );
653    }
654
655    #[test]
656    fn a_type_only_the_tool_understands_is_read_as_whatever_the_field_says() {
657        // `any` is not coerced by the merge, by declaration — so the field type is the only
658        // thing that says what belongs, and it is also the only place a mismatch can be caught.
659        static ANY: &[PropMeta] = &[PropMeta::new("either", Ty::Any)];
660        const ANY_REGISTRY: Registry = Registry::new(ANY);
661        let layer = Text(&[]);
662        let mut resolved = resolve(ANY_REGISTRY, Layers::new().then(&layer)).expect("resolves");
663        let id = ANY_REGISTRY.lookup("either").expect("declared").id;
664        resolved.coerced(id, Value::Map(BTreeMap::new()), "a hook");
665
666        let mut fold = resolved.fold();
667        let text: Option<String> = fold.optional(id);
668        assert_eq!(text, None);
669        let err = fold.finish().expect_err("a table is not a string");
670        assert!(
671            err.to_string().starts_with("either expected a string"),
672            "{err}"
673        );
674    }
675
676    #[test]
677    fn a_table_setting_reads_as_a_map_of_the_declared_type() {
678        let layer = Text(&[("aliases", "lts")]);
679        let mut resolved = resolve(REGISTRY, Layers::new().then(&layer)).expect("resolves");
680        resolved.coerced(
681            id("aliases"),
682            Value::Map(
683                [("node".to_string(), Value::from("20"))]
684                    .into_iter()
685                    .collect(),
686            ),
687            "a hook",
688        );
689        let mut fold = resolved.fold();
690        let aliases: Option<BTreeMap<String, String>> = fold.optional(id("aliases"));
691        fold.finish().expect("reads");
692        assert_eq!(
693            aliases,
694            Some(
695                [("node".to_string(), "20".to_string())]
696                    .into_iter()
697                    .collect()
698            )
699        );
700    }
701
702    #[test]
703    fn one_setting_can_be_read_without_a_fold() {
704        // For a CLI reading a couple of settings by hand rather than generating a struct.
705        let layer = Text(&[("jobs", "12")]);
706        let resolved = resolve(REGISTRY, Layers::new().then(&layer)).expect("resolves");
707        assert_eq!(resolved.read::<u64>(id("jobs")), Ok(Some(12)));
708        assert_eq!(resolved.read::<PathBuf>(id("cache_dir")), Ok(None));
709        let err = resolved
710            .read::<bool>(id("jobs"))
711            .expect_err("not a boolean");
712        assert_eq!(
713            err.to_string(),
714            "jobs expected a boolean but has `12` (set by MYCLI_JOBS)"
715        );
716    }
717
718    #[test]
719    fn a_lossy_fold_falls_back_to_the_declared_default_and_still_reports() {
720        let resolved = resolve(REGISTRY, Layers::new().then(&Text(&[]))).expect("resolves");
721        let mut resolved = resolved;
722        // The unchecked door into a resolution, and the reason these errors exist at all.
723        resolved.coerced(id("jobs"), Value::Int(-1), "a hook that got it wrong");
724
725        // Strict: nothing comes back, so a caller folding a whole struct loses every other
726        // setting it just resolved.
727        let mut strict = resolved.fold();
728        assert_eq!(strict.required::<u64>(id("jobs")), None);
729        assert_eq!(strict.required::<bool>(id("raw")), Some(false));
730        strict.finish().expect_err("one field did not read");
731
732        // Lossy: the field that failed takes what it declared, and the failure is still on the
733        // list for the CLI to raise, log, or ignore.
734        let mut lossy = resolved.fold_lossy();
735        assert_eq!(
736            lossy.required::<u64>(id("jobs")),
737            Some(4),
738            "the declared default, not the hook's -1"
739        );
740        assert_eq!(lossy.required::<bool>(id("raw")), Some(false));
741        let errors = lossy.into_errors();
742        assert_eq!(errors.0.len(), 1, "{errors}");
743        assert_eq!(errors.0[0].key, "jobs");
744    }
745
746    #[test]
747    fn a_lossy_fold_does_not_invent_a_default_that_was_never_declared() {
748        // The merge seeds every declared default into the values, so nothing at all here means
749        // nothing was declared — a hole in the spec rather than a bad value, and filling it
750        // would hide the one thing the caller needs told.
751        let resolved = resolve(REGISTRY, Layers::new().then(&Text(&[]))).expect("resolves");
752        let mut lossy = resolved.fold_lossy();
753        assert_eq!(lossy.required::<String>(id("profile")), None);
754        let errors = lossy.into_errors();
755        assert_eq!(errors.0.len(), 1, "{errors}");
756        assert!(matches!(errors.0[0].kind, ReadErrorKind::Missing));
757    }
758
759    #[test]
760    fn a_lossy_fold_leaves_an_optional_field_empty_when_its_value_is_bad() {
761        // `Option<T>` already has somewhere to put "no usable value", and the setting declares
762        // no default to reach for. The error is what carries the news.
763        let resolved = resolve(
764            REGISTRY,
765            Layers::new().then(&Text(&[("MYCLI_RATIO", "0.5")])),
766        )
767        .expect("resolves");
768        let mut resolved = resolved;
769        resolved.coerced(id("ratio"), Value::from("not a number"), "a hook, again");
770
771        let mut lossy = resolved.fold_lossy();
772        assert_eq!(lossy.optional::<f64>(id("ratio")), None);
773        assert_eq!(lossy.into_errors().0.len(), 1);
774    }
775}