Skip to main content

usage_config/
ty.rs

1//! The type a setting was declared with, and reading a raw string as it.
2//!
3//! A trimmed-down runtime form of the spec's type grammar: enough to coerce and validate,
4//! with none of the parsing. The derive turns a `Vec<String>` field into
5//! `Ty::List(&Ty::String)` at compile time, so the shape a value must take costs a match
6//! rather than a parse.
7//!
8//! Every layer that reads text — the environment, an `.npmrc`, a git config — hands over a
9//! string, and the declared type is the only thing that says whether `"1"` is the number
10//! one, the string "1", or a one-element list.
11
12use crate::value::Value;
13
14/// A declared type, as a generated registry holds it.
15///
16/// Containers borrow so the whole thing is `const`-constructible:
17/// `Ty::List(&Ty::String)`.
18#[derive(Debug, Copy, Clone, PartialEq)]
19pub enum Ty {
20    Bool,
21    Int,
22    /// An integer that may not be negative.
23    Uint,
24    Float,
25    String,
26    /// A filesystem path. Read as a string here; what makes it a path is what the CLI does
27    /// with it, and refusing one because it does not exist yet would be wrong.
28    Path,
29    Url,
30    /// A span of time, as text — `"30s"`, `"1h"`. Not parsed here: the crate that owns the
31    /// duration type owns its spelling, and the generated struct is where it is turned into
32    /// one.
33    Duration,
34    /// A table whose keys the spec does not describe.
35    Object,
36    List(&'static Ty),
37    /// Like a list, but duplicates are dropped on merge.
38    Set(&'static Ty),
39    /// A table with values of one type.
40    Map(&'static Ty),
41    /// Absent is a legitimate state. Only meaningful about the setting as a whole, so
42    /// coercion looks straight through it.
43    Option(&'static Ty),
44    /// A union, or a type only the tool understands. Nothing is coerced and nothing is
45    /// refused: the spec said usage cannot know what belongs here, so it takes what it is
46    /// given.
47    Any,
48}
49
50/// Why a value could not be read as the type its setting declares.
51#[derive(Debug, Clone, PartialEq)]
52pub struct TypeError {
53    /// The type as a human reads it: "an integer".
54    pub expected: &'static str,
55    /// What arrived instead, quoted the way it was written.
56    pub found: String,
57}
58
59impl Ty {
60    /// The innermost type, looking through `option`.
61    pub fn inner(self) -> Ty {
62        match self {
63            Self::Option(inner) => inner.inner(),
64            other => other,
65        }
66    }
67
68    /// This type as the spec spells it: `uint`, `list<string>`, `option<path>`.
69    ///
70    /// Distinct from [`Ty::describe`], which is prose for an error message. An explanation shows
71    /// the author's own vocabulary, because that is what a reader will search the docs for —
72    /// "type a non-negative integer" sends them looking for something no spec says.
73    pub fn name(self) -> String {
74        match self {
75            Self::Bool => "bool".into(),
76            Self::Int => "int".into(),
77            Self::Uint => "uint".into(),
78            Self::Float => "float".into(),
79            Self::String => "string".into(),
80            Self::Path => "path".into(),
81            Self::Url => "url".into(),
82            Self::Duration => "duration".into(),
83            Self::Object => "object".into(),
84            Self::List(inner) => format!("list<{}>", inner.name()),
85            Self::Set(inner) => format!("set<{}>", inner.name()),
86            Self::Map(value) => format!("map<string, {}>", value.name()),
87            Self::Option(inner) => format!("option<{}>", inner.name()),
88            // A union or a type only the tool understands: the registry keeps no spelling for
89            // it, and inventing one would be worse than admitting the fact.
90            Self::Any => "any".into(),
91        }
92    }
93
94    /// The name of this type as an error message should say it.
95    pub fn describe(self) -> &'static str {
96        match self.inner() {
97            Self::Bool => "a boolean",
98            Self::Int => "an integer",
99            Self::Uint => "a non-negative integer",
100            Self::Float => "a number",
101            Self::String => "a string",
102            Self::Path => "a path",
103            Self::Url => "a URL",
104            Self::Duration => "a duration",
105            Self::Object | Self::Map(_) => "a table",
106            Self::List(_) | Self::Set(_) => "a list",
107            Self::Option(_) | Self::Any => "a value",
108        }
109    }
110
111    /// `value` read as this type.
112    ///
113    /// Text arriving from a layer that has no types of its own is converted; a value that
114    /// already has the right shape passes through untouched. Anything else is an error
115    /// rather than a silent reinterpretation — the whole point of declaring the type.
116    pub fn coerce(self, value: Value) -> Result<Value, TypeError> {
117        let ty = self.inner();
118        // A list-typed setting given one bare value means a list of one. Every registry in
119        // the fleet relies on this for `MISE_ENV=production`, and doing it here means no
120        // layer has to know.
121        if let (
122            Self::List(item) | Self::Set(item),
123            Value::Bool(_) | Value::Int(_) | Value::Float(_) | Value::String(_),
124        ) = (ty, &value)
125        {
126            // An empty string is no items, not one empty item — the same rule the named
127            // parsers follow, and the one `HK_EXCLUDE=` relies on to turn a declared default
128            // off. Wrapping it produced a list holding `""`, which cleared nothing and added
129            // an item nobody asked for.
130            if matches!(&value, Value::String(text) if text.is_empty()) {
131                return Ok(Value::List(Vec::new()));
132            }
133            return Ok(Value::List(vec![item.coerce(value)?]));
134        }
135        match (ty, value) {
136            // Nothing to say about a type nothing was declared for.
137            (Self::Any, value) => Ok(value),
138
139            (Self::Bool, Value::Bool(b)) => Ok(Value::Bool(b)),
140            (Self::Bool, Value::String(text)) => match text.as_str() {
141                // The spellings every one of these registries accepts. Deliberately not
142                // "anything non-empty is true": `FOO=false` meaning true is the kind of
143                // surprise a config system exists to prevent. Words are ASCII-case-insensitive:
144                // environment variables such as fnox's `FNOX_NO_DEFAULTS=TRUE` accepted that
145                // spelling before moving to this shared resolver.
146                "1" => Ok(Value::Bool(true)),
147                "0" | "" => Ok(Value::Bool(false)),
148                word if ["true", "yes", "y", "on"]
149                    .iter()
150                    .any(|known| word.eq_ignore_ascii_case(known)) =>
151                {
152                    Ok(Value::Bool(true))
153                }
154                word if ["false", "no", "n", "off"]
155                    .iter()
156                    .any(|known| word.eq_ignore_ascii_case(known)) =>
157                {
158                    Ok(Value::Bool(false))
159                }
160                _ => Err(TypeError {
161                    expected: "a boolean",
162                    found: text,
163                }),
164            },
165
166            (Self::Int | Self::Uint, Value::Int(i)) if ty != Self::Uint || i >= 0 => {
167                Ok(Value::Int(i))
168            }
169            (Self::Int | Self::Uint, Value::String(text)) => match text.trim().parse::<i64>() {
170                Ok(i) if ty != Self::Uint || i >= 0 => Ok(Value::Int(i)),
171                _ => Err(TypeError {
172                    expected: ty.describe(),
173                    found: text,
174                }),
175            },
176
177            (Self::Float, Value::Float(f)) => Ok(Value::Float(f)),
178            // A whole number is a perfectly good float, and a spec that says `float` should
179            // not reject `1`.
180            (Self::Float, Value::Int(i)) => Ok(Value::Float(i as f64)),
181            (Self::Float, Value::String(text)) => match text.trim().parse::<f64>() {
182                Ok(f) => Ok(Value::Float(f)),
183                Err(_) => Err(TypeError {
184                    expected: "a number",
185                    found: text,
186                }),
187            },
188
189            (Self::String | Self::Path | Self::Url | Self::Duration, Value::String(s)) => {
190                Ok(Value::String(s))
191            }
192            // A number written where text was expected is text that happens to look like a
193            // number — `MISE_PYTHON_VERSION=3` should not fail. A *collection* is not text,
194            // though: rendering one gave `"k=v"` or `"a,b"`, which is a value nobody wrote, and
195            // for a structured source it turned a table the file really did contain into a
196            // string that only looks like one.
197            (
198                Self::String | Self::Path | Self::Url | Self::Duration,
199                found @ (Value::List(_) | Value::Map(_)),
200            ) => Err(TypeError {
201                expected: ty.describe(),
202                found: crate::value::shown(&found),
203            }),
204            (Self::String | Self::Path | Self::Url | Self::Duration, other) => {
205                Ok(Value::String(other.display()))
206            }
207
208            (Self::List(item) | Self::Set(item), Value::List(items)) => Ok(Value::List(
209                items
210                    .into_iter()
211                    .map(|value| item.coerce(value))
212                    .collect::<Result<Vec<_>, _>>()?,
213            )),
214
215            (Self::Object, Value::Map(entries)) => Ok(Value::Map(entries)),
216            (Self::Map(item), Value::Map(entries)) => Ok(Value::Map(
217                entries
218                    .into_iter()
219                    .map(|(key, value)| item.coerce(value).map(|value| (key, value)))
220                    .collect::<Result<_, _>>()?,
221            )),
222
223            (ty, found) => Err(TypeError {
224                expected: ty.describe(),
225                found: crate::value::shown(&found),
226            }),
227        }
228    }
229}
230
231/// A named way of splitting one string into several values.
232///
233/// Spec vocabulary rather than a Rust callback, so a spec that says `parse="list_by_comma"`
234/// means the same thing to a Go or a TypeScript runtime reading the same file. A parser a
235/// tool has written itself rides as an `x` extension and never reaches here.
236#[derive(Debug, Copy, Clone, PartialEq)]
237pub enum Parser {
238    ListByComma,
239    ListByColon,
240    /// `:` or `;`, whichever this platform uses between path entries.
241    ListByOsPathSeparator,
242    /// Splits on commas and drops repeats, keeping the first of each.
243    SetByComma,
244}
245
246impl Parser {
247    /// The name a spec writes.
248    pub fn name(self) -> &'static str {
249        match self {
250            Self::ListByComma => "list_by_comma",
251            Self::ListByColon => "list_by_colon",
252            Self::ListByOsPathSeparator => "list_by_os_path_separator",
253            Self::SetByComma => "set_by_comma",
254        }
255    }
256
257    /// This parser by the name a spec writes.
258    pub fn from_name(name: &str) -> Option<Self> {
259        match name {
260            "list_by_comma" => Some(Self::ListByComma),
261            "list_by_colon" => Some(Self::ListByColon),
262            "list_by_os_path_separator" => Some(Self::ListByOsPathSeparator),
263            "set_by_comma" => Some(Self::SetByComma),
264            _ => None,
265        }
266    }
267
268    /// `raw` split into the values it names.
269    ///
270    /// An empty string is an empty list rather than a list holding nothing — `HK_EXCLUDE=`
271    /// means "exclude nothing", which is a thing a user says to override a default.
272    pub fn split(self, raw: &str) -> Value {
273        let separator = match self {
274            Self::ListByComma | Self::SetByComma => ',',
275            Self::ListByColon => ':',
276            Self::ListByOsPathSeparator => {
277                if cfg!(windows) {
278                    ';'
279                } else {
280                    ':'
281                }
282            }
283        };
284        if raw.is_empty() {
285            return Value::List(Vec::new());
286        }
287        let mut parts: Vec<&str> = raw.split(separator).map(str::trim).collect();
288        if self == Self::SetByComma {
289            let mut seen = Vec::new();
290            parts.retain(|part| {
291                let fresh = !seen.contains(part);
292                if fresh {
293                    seen.push(*part);
294                }
295                fresh
296            });
297        }
298        Value::List(parts.into_iter().map(Value::from).collect())
299    }
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    fn s(text: &str) -> Value {
307        Value::String(text.to_string())
308    }
309
310    #[test]
311    fn text_from_a_layer_with_no_types_is_read_as_declared() {
312        // Every environment variable arrives as a string, so this is the path most values
313        // in a real CLI take.
314        assert_eq!(Ty::Bool.coerce(s("yes")), Ok(Value::Bool(true)));
315        assert_eq!(Ty::Bool.coerce(s("off")), Ok(Value::Bool(false)));
316        for value in ["TRUE", "True", "YES", "On"] {
317            assert_eq!(Ty::Bool.coerce(s(value)), Ok(Value::Bool(true)), "{value}");
318        }
319        for value in ["FALSE", "False", "NO", "Off"] {
320            assert_eq!(Ty::Bool.coerce(s(value)), Ok(Value::Bool(false)), "{value}");
321        }
322        assert_eq!(Ty::Int.coerce(s("-3")), Ok(Value::Int(-3)));
323        assert_eq!(Ty::Float.coerce(s(" 1.5 ")), Ok(Value::Float(1.5)));
324        // Whitespace around a number is a typo, not a different number.
325        assert_eq!(Ty::Int.coerce(s(" 4 ")), Ok(Value::Int(4)));
326    }
327
328    #[test]
329    fn a_collection_is_not_text() {
330        // A number written where text was expected is text that happens to look like one. A list
331        // or a table is not: rendering one produced `a,b` or `k=v`, a value nobody wrote — and
332        // for a file, which really can hold a table, it turned that table into a string that only
333        // looks like one.
334        assert!(Ty::String
335            .coerce(Value::List(vec![Value::from("a")]))
336            .is_err());
337        assert!(Ty::Path
338            .coerce(Value::Map(
339                [("k".to_string(), Value::from("v"))].into_iter().collect()
340            ))
341            .is_err());
342        // A scalar still converts, which is the rule this is narrowing rather than replacing.
343        assert_eq!(Ty::String.coerce(Value::Int(3)), Ok(s("3")));
344    }
345
346    #[test]
347    fn a_value_that_cannot_be_the_declared_type_is_an_error() {
348        // The error carries what arrived, because "expected an integer" without the value is
349        // no help when it came from a file three directories up.
350        assert_eq!(
351            Ty::Int.coerce(s("abc")),
352            Err(TypeError {
353                expected: "an integer",
354                found: "abc".to_string()
355            })
356        );
357        // `FOO=maybe` for a boolean is a mistake worth reporting rather than reading as true.
358        assert!(Ty::Bool.coerce(s("maybe")).is_err());
359        // A negative number where only positives belong.
360        assert!(Ty::Uint.coerce(s("-1")).is_err());
361        assert!(Ty::Uint.coerce(Value::Int(-1)).is_err());
362        assert_eq!(Ty::Uint.coerce(Value::Int(0)), Ok(Value::Int(0)));
363    }
364
365    #[test]
366    fn one_value_where_a_list_belongs_is_a_list_of_one() {
367        // `MISE_ENV=production`, which every registry in the fleet accepts and no layer
368        // should have to know about.
369        const ITEM: &Ty = &Ty::String;
370        assert_eq!(
371            Ty::List(ITEM).coerce(s("production")),
372            Ok(Value::List(vec![s("production")]))
373        );
374        // And the items of a real list are coerced too, so a list of ints from a JSON file
375        // full of strings still arrives as ints.
376        const INT: &Ty = &Ty::Int;
377        assert_eq!(
378            Ty::List(INT).coerce(Value::List(vec![s("1"), Value::Int(2)])),
379            Ok(Value::List(vec![Value::Int(1), Value::Int(2)]))
380        );
381        assert!(Ty::List(INT).coerce(Value::List(vec![s("x")])).is_err());
382    }
383
384    #[test]
385    fn an_empty_string_is_no_items_rather_than_one_empty_one() {
386        // What `HK_EXCLUDE=` means, and the rule the named parsers already follow. Wrapping it
387        // as a one-element list holding `""` added an item nobody asked for, and — since an
388        // empty list is how a higher layer clears a declared default — left the default in
389        // place for exactly the setting the user was trying to empty.
390        const ITEM: &Ty = &Ty::String;
391        assert_eq!(Ty::List(ITEM).coerce(s("")), Ok(Value::List(Vec::new())));
392        assert_eq!(Ty::Set(ITEM).coerce(s("")), Ok(Value::List(Vec::new())));
393        // A non-empty bare value is still a list of one.
394        assert_eq!(
395            Ty::List(ITEM).coerce(s("only")),
396            Ok(Value::List(vec![s("only")]))
397        );
398        // And an empty string is still a perfectly good *string*.
399        assert_eq!(Ty::String.coerce(s("")), Ok(s("")));
400    }
401
402    #[test]
403    fn a_type_usage_cannot_know_takes_what_it_is_given() {
404        // The escape hatch: a union or a tool-private type. Refusing here would make the
405        // spec's own escape hatch unusable.
406        assert_eq!(Ty::Any.coerce(s("either")), Ok(s("either")));
407        assert_eq!(Ty::Any.coerce(Value::Bool(true)), Ok(Value::Bool(true)));
408        // And `option<T>` is coerced as its inner type, since absence is about the setting
409        // rather than about the value that did arrive.
410        const INNER: &Ty = &Ty::Int;
411        assert_eq!(Ty::Option(INNER).coerce(s("7")), Ok(Value::Int(7)));
412    }
413
414    #[test]
415    fn a_named_parser_splits_one_string_the_way_the_spec_says() {
416        assert_eq!(
417            Parser::ListByComma.split("a, b,c"),
418            Value::List(vec![s("a"), s("b"), s("c")])
419        );
420        // A set keeps the first of each, so the position of a value is stable.
421        assert_eq!(
422            Parser::SetByComma.split("a,b,a"),
423            Value::List(vec![s("a"), s("b")])
424        );
425        // Emptying a list is a thing a user does to override a default, so it has to be
426        // expressible: `HK_EXCLUDE=` is no items, not one empty one.
427        assert_eq!(Parser::ListByComma.split(""), Value::List(Vec::new()));
428        // Round-tripping the name is what lets a spec and another language's runtime agree.
429        for parser in [
430            Parser::ListByComma,
431            Parser::ListByColon,
432            Parser::ListByOsPathSeparator,
433            Parser::SetByComma,
434        ] {
435            assert_eq!(Parser::from_name(parser.name()), Some(parser));
436        }
437        assert_eq!(Parser::from_name("list_by_semicolon"), None);
438    }
439}