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::{one_line, Value};
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::{resolve, Layers};
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!(f32::from_value(&Value::Float(f64::INFINITY))
415            .expect("an infinity reads as one")
416            .is_infinite());
417    }
418
419    static PROPS: &[PropMeta] = &[
420        PropMeta {
421            default: Some(Const::Int(4)),
422            envs: &["MYCLI_JOBS"],
423            ..PropMeta::new("jobs", Ty::Uint)
424        },
425        PropMeta {
426            default: Some(Const::Bool(false)),
427            envs: &["MYCLI_RAW"],
428            ..PropMeta::new("raw", Ty::Bool)
429        },
430        PropMeta {
431            envs: &["MYCLI_CACHE_DIR"],
432            ..PropMeta::new("cache_dir", Ty::Option(&Ty::Path))
433        },
434        // Text splitting is the named parser's job, not the reader's: these arrive from an
435        // environment variable as one string.
436        PropMeta {
437            envs: &["MYCLI_EXCLUDE"],
438            parse: Some(Parser::ListByComma),
439            ..PropMeta::new("exclude", Ty::List(&Ty::String))
440        },
441        PropMeta {
442            envs: &["MYCLI_PORTS"],
443            parse: Some(Parser::ListByComma),
444            ..PropMeta::new("ports", Ty::List(&Ty::Uint))
445        },
446        PropMeta {
447            envs: &["MYCLI_ALIASES"],
448            ..PropMeta::new("aliases", Ty::Map(&Ty::String))
449        },
450        PropMeta {
451            envs: &["MYCLI_RATIO"],
452            ..PropMeta::new("ratio", Ty::Float)
453        },
454        // No default and not an option: a setting the CLI must have and nobody has supplied.
455        PropMeta::new("profile", Ty::String),
456    ];
457    const REGISTRY: Registry = Registry::new(PROPS);
458
459    fn id(key: &str) -> PropId {
460        REGISTRY.lookup(key).expect("declared").id
461    }
462
463    /// Values as text, the way an environment variable arrives.
464    struct Text(&'static [(&'static str, &'static str)]);
465
466    impl Layer for Text {
467        fn source(&self) -> SourceKind {
468            SourceKind::ENV
469        }
470        fn load(&self, ctx: &LayerCtx) -> Result<LayerOutput, LayerError> {
471            let mut out = LayerOutput::new();
472            for (key, raw) in self.0 {
473                let origin = Origin::new(SourceKind::ENV, format!("MYCLI_{}", key.to_uppercase()));
474                match ctx.entry_for_key(key, raw, origin) {
475                    Ok(entry) => out.push(entry),
476                    Err(warning) => out.warn(warning),
477                }
478            }
479            Ok(out)
480        }
481    }
482
483    #[test]
484    fn a_resolution_reads_as_the_types_a_struct_holds() {
485        let layer = Text(&[
486            ("jobs", "8"),
487            ("raw", "yes"),
488            ("cache_dir", "/tmp/cache"),
489            ("exclude", "target,dist"),
490            ("ports", "80,443"),
491            ("ratio", "0.5"),
492            ("profile", "release"),
493        ]);
494        let resolved = resolve(REGISTRY, Layers::new().then(&layer)).expect("resolves");
495
496        let mut fold = resolved.fold();
497        let jobs: Option<u64> = fold.required(id("jobs"));
498        let raw: Option<bool> = fold.required(id("raw"));
499        let cache_dir: Option<PathBuf> = fold.optional(id("cache_dir"));
500        let exclude: Option<Vec<String>> = fold.required(id("exclude"));
501        let ports: Option<Vec<u64>> = fold.required(id("ports"));
502        let ratio: Option<f64> = fold.required(id("ratio"));
503        let profile: Option<String> = fold.required(id("profile"));
504        fold.finish().expect("every value fits its field");
505
506        assert_eq!(jobs, Some(8));
507        assert_eq!(raw, Some(true));
508        assert_eq!(cache_dir, Some(PathBuf::from("/tmp/cache")));
509        assert_eq!(
510            exclude,
511            Some(vec!["target".to_string(), "dist".to_string()]),
512            "a list-typed setting keeps the order the file gave it"
513        );
514        assert_eq!(
515            ports,
516            Some(vec![80, 443]),
517            "and reads its items as the type"
518        );
519        assert_eq!(ratio, Some(0.5));
520        assert_eq!(profile, Some("release".to_string()));
521    }
522
523    #[test]
524    fn a_declared_default_is_read_like_any_other_value() {
525        // Nothing supplied a thing, so this reads the seeded defaults — the case where a
526        // generated struct is built from the registry alone.
527        let resolved = resolve(REGISTRY, Layers::new()).expect("resolves");
528        let mut fold = resolved.fold();
529        let jobs: Option<u64> = fold.required(id("jobs"));
530        let cache_dir: Option<PathBuf> = fold.optional(id("cache_dir"));
531        let exclude: Option<Vec<String>> = fold.optional(id("exclude"));
532        assert_eq!(jobs, Some(4));
533        assert_eq!(cache_dir, None, "no default, and absence is not an error");
534        assert_eq!(exclude, None);
535    }
536
537    #[test]
538    fn a_setting_with_no_value_and_no_default_says_which_one() {
539        // A field that is not an `Option` and has nothing to hold. Reported rather than
540        // unwrapped, because the CLI's own registry is what is wrong and the author needs the
541        // key to fix it.
542        let resolved = resolve(REGISTRY, Layers::new()).expect("resolves");
543        let mut fold = resolved.fold();
544        let profile: Option<String> = fold.required(id("profile"));
545        assert_eq!(profile, None);
546        let err = fold.finish().expect_err("should not read");
547        assert_eq!(err.to_string(), "profile has no value and no default");
548    }
549
550    #[test]
551    fn a_value_the_field_cannot_hold_names_where_it_came_from() {
552        // The reachable failure: a hook writing past the declared type. `Resolved::coerced` is
553        // unchecked by design — it is where a CLI puts the rules only it knows — so this is
554        // where writing `-1` to a `uint` is caught, and the message has to name the hook rather
555        // than leave the author guessing which of a dozen coercions did it.
556        let mut resolved = resolve(REGISTRY, Layers::new()).expect("resolves");
557        resolved.coerced(id("jobs"), Value::Int(-1), "one job when raw");
558
559        let mut fold = resolved.fold();
560        let jobs: Option<u64> = fold.required(id("jobs"));
561        assert_eq!(jobs, None);
562        let err = fold.finish().expect_err("should not read");
563        assert_eq!(
564            err.to_string(),
565            "jobs expected a non-negative integer but has `-1` (set by one job when raw)"
566        );
567    }
568
569    #[test]
570    fn every_bad_value_is_reported_and_not_only_the_first() {
571        // Three settings the fields cannot hold. The fleet's hand-written folds return the
572        // first, so a config file with three mistakes in it takes three runs to fix.
573        let mut resolved = resolve(REGISTRY, Layers::new()).expect("resolves");
574        resolved.coerced(id("jobs"), Value::Int(-1), "a hook");
575        resolved.coerced(id("raw"), Value::String("sometimes".into()), "a hook");
576        resolved.coerced(
577            id("ports"),
578            Value::List(vec![Value::Int(80), Value::Int(-443)]),
579            "a hook",
580        );
581
582        let mut fold = resolved.fold();
583        let _: Option<u64> = fold.required(id("jobs"));
584        let _: Option<bool> = fold.required(id("raw"));
585        let _: Option<Vec<u64>> = fold.required(id("ports"));
586        let _: Option<String> = fold.required(id("profile"));
587        let err = fold.finish().expect_err("should not read");
588
589        let message = err.to_string();
590        let lines: Vec<&str> = message.lines().collect();
591        assert_eq!(lines.len(), 4, "{err}");
592        assert!(
593            lines[0].starts_with("jobs expected a non-negative integer"),
594            "{err}"
595        );
596        assert!(
597            lines[1].starts_with("raw expected a boolean but has `sometimes`"),
598            "{err}"
599        );
600        // The item is what is wrong, and the item is what the message quotes.
601        assert!(
602            lines[2].starts_with("ports expected a non-negative integer but has `-443`"),
603            "{err}"
604        );
605        assert!(lines[3].starts_with("profile has no value"), "{err}");
606    }
607
608    #[test]
609    fn a_failure_stays_on_its_own_line_whatever_the_value_holds() {
610        // A multi-line string is perfectly ordinary in TOML, and a path may contain a newline.
611        // Interpolated as they are, one failure spilled across three lines and the failures listed
612        // after it read as part of it.
613        let mut resolved = resolve(REGISTRY, Layers::new()).expect("resolves");
614        resolved.coerced(
615            id("jobs"),
616            Value::String("two\nor three".into()),
617            "a hook\nover two lines",
618        );
619        let mut fold = resolved.fold();
620        let _: Option<u64> = fold.required(id("jobs"));
621        let _: Option<String> = fold.required(id("profile"));
622        let err = fold.finish().expect_err("should not read");
623
624        let message = err.to_string();
625        assert_eq!(message.lines().count(), 2, "{message}");
626        assert!(
627            message.starts_with(
628                "jobs expected a non-negative integer but has `two\\nor three` \
629                 (set by a hook\\nover two lines)"
630            ),
631            "{message}"
632        );
633    }
634
635    #[test]
636    fn a_failure_about_an_empty_value_still_names_one() {
637        // An emptied list is a value, and one a user can perfectly well have arrived at:
638        // `MYCLI_EXCLUDE=` clears a declared default. Quoted as its own text it named nothing —
639        // "expected a non-negative integer but has ``" — so it is reported as its shape, the same
640        // way the merge's warnings and `explain` report it.
641        let mut resolved = resolve(REGISTRY, Layers::new()).expect("resolves");
642        resolved.coerced(id("jobs"), Value::List(Vec::new()), "a hook");
643        let mut fold = resolved.fold();
644        let jobs: Option<u64> = fold.required(id("jobs"));
645        assert_eq!(jobs, None);
646        let err = fold.finish().expect_err("a list is not an integer");
647        assert_eq!(
648            err.to_string(),
649            "jobs expected a non-negative integer but has `[]` (set by a hook)"
650        );
651    }
652
653    #[test]
654    fn a_type_only_the_tool_understands_is_read_as_whatever_the_field_says() {
655        // `any` is not coerced by the merge, by declaration — so the field type is the only
656        // thing that says what belongs, and it is also the only place a mismatch can be caught.
657        static ANY: &[PropMeta] = &[PropMeta::new("either", Ty::Any)];
658        const ANY_REGISTRY: Registry = Registry::new(ANY);
659        let layer = Text(&[]);
660        let mut resolved = resolve(ANY_REGISTRY, Layers::new().then(&layer)).expect("resolves");
661        let id = ANY_REGISTRY.lookup("either").expect("declared").id;
662        resolved.coerced(id, Value::Map(BTreeMap::new()), "a hook");
663
664        let mut fold = resolved.fold();
665        let text: Option<String> = fold.optional(id);
666        assert_eq!(text, None);
667        let err = fold.finish().expect_err("a table is not a string");
668        assert!(
669            err.to_string().starts_with("either expected a string"),
670            "{err}"
671        );
672    }
673
674    #[test]
675    fn a_table_setting_reads_as_a_map_of_the_declared_type() {
676        let layer = Text(&[("aliases", "lts")]);
677        let mut resolved = resolve(REGISTRY, Layers::new().then(&layer)).expect("resolves");
678        resolved.coerced(
679            id("aliases"),
680            Value::Map(
681                [("node".to_string(), Value::from("20"))]
682                    .into_iter()
683                    .collect(),
684            ),
685            "a hook",
686        );
687        let mut fold = resolved.fold();
688        let aliases: Option<BTreeMap<String, String>> = fold.optional(id("aliases"));
689        fold.finish().expect("reads");
690        assert_eq!(
691            aliases,
692            Some(
693                [("node".to_string(), "20".to_string())]
694                    .into_iter()
695                    .collect()
696            )
697        );
698    }
699
700    #[test]
701    fn one_setting_can_be_read_without_a_fold() {
702        // For a CLI reading a couple of settings by hand rather than generating a struct.
703        let layer = Text(&[("jobs", "12")]);
704        let resolved = resolve(REGISTRY, Layers::new().then(&layer)).expect("resolves");
705        assert_eq!(resolved.read::<u64>(id("jobs")), Ok(Some(12)));
706        assert_eq!(resolved.read::<PathBuf>(id("cache_dir")), Ok(None));
707        let err = resolved
708            .read::<bool>(id("jobs"))
709            .expect_err("not a boolean");
710        assert_eq!(
711            err.to_string(),
712            "jobs expected a boolean but has `12` (set by MYCLI_JOBS)"
713        );
714    }
715
716    #[test]
717    fn a_lossy_fold_falls_back_to_the_declared_default_and_still_reports() {
718        let resolved = resolve(REGISTRY, Layers::new().then(&Text(&[]))).expect("resolves");
719        let mut resolved = resolved;
720        // The unchecked door into a resolution, and the reason these errors exist at all.
721        resolved.coerced(id("jobs"), Value::Int(-1), "a hook that got it wrong");
722
723        // Strict: nothing comes back, so a caller folding a whole struct loses every other
724        // setting it just resolved.
725        let mut strict = resolved.fold();
726        assert_eq!(strict.required::<u64>(id("jobs")), None);
727        assert_eq!(strict.required::<bool>(id("raw")), Some(false));
728        strict.finish().expect_err("one field did not read");
729
730        // Lossy: the field that failed takes what it declared, and the failure is still on the
731        // list for the CLI to raise, log, or ignore.
732        let mut lossy = resolved.fold_lossy();
733        assert_eq!(
734            lossy.required::<u64>(id("jobs")),
735            Some(4),
736            "the declared default, not the hook's -1"
737        );
738        assert_eq!(lossy.required::<bool>(id("raw")), Some(false));
739        let errors = lossy.into_errors();
740        assert_eq!(errors.0.len(), 1, "{errors}");
741        assert_eq!(errors.0[0].key, "jobs");
742    }
743
744    #[test]
745    fn a_lossy_fold_does_not_invent_a_default_that_was_never_declared() {
746        // The merge seeds every declared default into the values, so nothing at all here means
747        // nothing was declared — a hole in the spec rather than a bad value, and filling it
748        // would hide the one thing the caller needs told.
749        let resolved = resolve(REGISTRY, Layers::new().then(&Text(&[]))).expect("resolves");
750        let mut lossy = resolved.fold_lossy();
751        assert_eq!(lossy.required::<String>(id("profile")), None);
752        let errors = lossy.into_errors();
753        assert_eq!(errors.0.len(), 1, "{errors}");
754        assert!(matches!(errors.0[0].kind, ReadErrorKind::Missing));
755    }
756
757    #[test]
758    fn a_lossy_fold_leaves_an_optional_field_empty_when_its_value_is_bad() {
759        // `Option<T>` already has somewhere to put "no usable value", and the setting declares
760        // no default to reach for. The error is what carries the news.
761        let resolved = resolve(
762            REGISTRY,
763            Layers::new().then(&Text(&[("MYCLI_RATIO", "0.5")])),
764        )
765        .expect("resolves");
766        let mut resolved = resolved;
767        resolved.coerced(id("ratio"), Value::from("not a number"), "a hook, again");
768
769        let mut lossy = resolved.fold_lossy();
770        assert_eq!(lossy.optional::<f64>(id("ratio")), None);
771        assert_eq!(lossy.into_errors().0.len(), 1);
772    }
773}