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.
144                "true" | "1" | "yes" | "y" | "on" => Ok(Value::Bool(true)),
145                "false" | "0" | "no" | "n" | "off" | "" => Ok(Value::Bool(false)),
146                _ => Err(TypeError {
147                    expected: "a boolean",
148                    found: text,
149                }),
150            },
151
152            (Self::Int | Self::Uint, Value::Int(i)) if ty != Self::Uint || i >= 0 => {
153                Ok(Value::Int(i))
154            }
155            (Self::Int | Self::Uint, Value::String(text)) => match text.trim().parse::<i64>() {
156                Ok(i) if ty != Self::Uint || i >= 0 => Ok(Value::Int(i)),
157                _ => Err(TypeError {
158                    expected: ty.describe(),
159                    found: text,
160                }),
161            },
162
163            (Self::Float, Value::Float(f)) => Ok(Value::Float(f)),
164            // A whole number is a perfectly good float, and a spec that says `float` should
165            // not reject `1`.
166            (Self::Float, Value::Int(i)) => Ok(Value::Float(i as f64)),
167            (Self::Float, Value::String(text)) => match text.trim().parse::<f64>() {
168                Ok(f) => Ok(Value::Float(f)),
169                Err(_) => Err(TypeError {
170                    expected: "a number",
171                    found: text,
172                }),
173            },
174
175            (Self::String | Self::Path | Self::Url | Self::Duration, Value::String(s)) => {
176                Ok(Value::String(s))
177            }
178            // A number written where text was expected is text that happens to look like a
179            // number — `MISE_PYTHON_VERSION=3` should not fail. A *collection* is not text,
180            // though: rendering one gave `"k=v"` or `"a,b"`, which is a value nobody wrote, and
181            // for a structured source it turned a table the file really did contain into a
182            // string that only looks like one.
183            (
184                Self::String | Self::Path | Self::Url | Self::Duration,
185                found @ (Value::List(_) | Value::Map(_)),
186            ) => Err(TypeError {
187                expected: ty.describe(),
188                found: crate::value::shown(&found),
189            }),
190            (Self::String | Self::Path | Self::Url | Self::Duration, other) => {
191                Ok(Value::String(other.display()))
192            }
193
194            (Self::List(item) | Self::Set(item), Value::List(items)) => Ok(Value::List(
195                items
196                    .into_iter()
197                    .map(|value| item.coerce(value))
198                    .collect::<Result<Vec<_>, _>>()?,
199            )),
200
201            (Self::Object, Value::Map(entries)) => Ok(Value::Map(entries)),
202            (Self::Map(item), Value::Map(entries)) => Ok(Value::Map(
203                entries
204                    .into_iter()
205                    .map(|(key, value)| item.coerce(value).map(|value| (key, value)))
206                    .collect::<Result<_, _>>()?,
207            )),
208
209            (ty, found) => Err(TypeError {
210                expected: ty.describe(),
211                found: crate::value::shown(&found),
212            }),
213        }
214    }
215}
216
217/// A named way of splitting one string into several values.
218///
219/// Spec vocabulary rather than a Rust callback, so a spec that says `parse="list_by_comma"`
220/// means the same thing to a Go or a TypeScript runtime reading the same file. A parser a
221/// tool has written itself rides as an `x` extension and never reaches here.
222#[derive(Debug, Copy, Clone, PartialEq)]
223pub enum Parser {
224    ListByComma,
225    ListByColon,
226    /// `:` or `;`, whichever this platform uses between path entries.
227    ListByOsPathSeparator,
228    /// Splits on commas and drops repeats, keeping the first of each.
229    SetByComma,
230}
231
232impl Parser {
233    /// The name a spec writes.
234    pub fn name(self) -> &'static str {
235        match self {
236            Self::ListByComma => "list_by_comma",
237            Self::ListByColon => "list_by_colon",
238            Self::ListByOsPathSeparator => "list_by_os_path_separator",
239            Self::SetByComma => "set_by_comma",
240        }
241    }
242
243    /// This parser by the name a spec writes.
244    pub fn from_name(name: &str) -> Option<Self> {
245        match name {
246            "list_by_comma" => Some(Self::ListByComma),
247            "list_by_colon" => Some(Self::ListByColon),
248            "list_by_os_path_separator" => Some(Self::ListByOsPathSeparator),
249            "set_by_comma" => Some(Self::SetByComma),
250            _ => None,
251        }
252    }
253
254    /// `raw` split into the values it names.
255    ///
256    /// An empty string is an empty list rather than a list holding nothing — `HK_EXCLUDE=`
257    /// means "exclude nothing", which is a thing a user says to override a default.
258    pub fn split(self, raw: &str) -> Value {
259        let separator = match self {
260            Self::ListByComma | Self::SetByComma => ',',
261            Self::ListByColon => ':',
262            Self::ListByOsPathSeparator => {
263                if cfg!(windows) {
264                    ';'
265                } else {
266                    ':'
267                }
268            }
269        };
270        if raw.is_empty() {
271            return Value::List(Vec::new());
272        }
273        let mut parts: Vec<&str> = raw.split(separator).map(str::trim).collect();
274        if self == Self::SetByComma {
275            let mut seen = Vec::new();
276            parts.retain(|part| {
277                let fresh = !seen.contains(part);
278                if fresh {
279                    seen.push(*part);
280                }
281                fresh
282            });
283        }
284        Value::List(parts.into_iter().map(Value::from).collect())
285    }
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291
292    fn s(text: &str) -> Value {
293        Value::String(text.to_string())
294    }
295
296    #[test]
297    fn text_from_a_layer_with_no_types_is_read_as_declared() {
298        // Every environment variable arrives as a string, so this is the path most values
299        // in a real CLI take.
300        assert_eq!(Ty::Bool.coerce(s("yes")), Ok(Value::Bool(true)));
301        assert_eq!(Ty::Bool.coerce(s("off")), Ok(Value::Bool(false)));
302        assert_eq!(Ty::Int.coerce(s("-3")), Ok(Value::Int(-3)));
303        assert_eq!(Ty::Float.coerce(s(" 1.5 ")), Ok(Value::Float(1.5)));
304        // Whitespace around a number is a typo, not a different number.
305        assert_eq!(Ty::Int.coerce(s(" 4 ")), Ok(Value::Int(4)));
306    }
307
308    #[test]
309    fn a_collection_is_not_text() {
310        // A number written where text was expected is text that happens to look like one. A list
311        // or a table is not: rendering one produced `a,b` or `k=v`, a value nobody wrote — and
312        // for a file, which really can hold a table, it turned that table into a string that only
313        // looks like one.
314        assert!(Ty::String
315            .coerce(Value::List(vec![Value::from("a")]))
316            .is_err());
317        assert!(Ty::Path
318            .coerce(Value::Map(
319                [("k".to_string(), Value::from("v"))].into_iter().collect()
320            ))
321            .is_err());
322        // A scalar still converts, which is the rule this is narrowing rather than replacing.
323        assert_eq!(Ty::String.coerce(Value::Int(3)), Ok(s("3")));
324    }
325
326    #[test]
327    fn a_value_that_cannot_be_the_declared_type_is_an_error() {
328        // The error carries what arrived, because "expected an integer" without the value is
329        // no help when it came from a file three directories up.
330        assert_eq!(
331            Ty::Int.coerce(s("abc")),
332            Err(TypeError {
333                expected: "an integer",
334                found: "abc".to_string()
335            })
336        );
337        // `FOO=maybe` for a boolean is a mistake worth reporting rather than reading as true.
338        assert!(Ty::Bool.coerce(s("maybe")).is_err());
339        // A negative number where only positives belong.
340        assert!(Ty::Uint.coerce(s("-1")).is_err());
341        assert!(Ty::Uint.coerce(Value::Int(-1)).is_err());
342        assert_eq!(Ty::Uint.coerce(Value::Int(0)), Ok(Value::Int(0)));
343    }
344
345    #[test]
346    fn one_value_where_a_list_belongs_is_a_list_of_one() {
347        // `MISE_ENV=production`, which every registry in the fleet accepts and no layer
348        // should have to know about.
349        const ITEM: &Ty = &Ty::String;
350        assert_eq!(
351            Ty::List(ITEM).coerce(s("production")),
352            Ok(Value::List(vec![s("production")]))
353        );
354        // And the items of a real list are coerced too, so a list of ints from a JSON file
355        // full of strings still arrives as ints.
356        const INT: &Ty = &Ty::Int;
357        assert_eq!(
358            Ty::List(INT).coerce(Value::List(vec![s("1"), Value::Int(2)])),
359            Ok(Value::List(vec![Value::Int(1), Value::Int(2)]))
360        );
361        assert!(Ty::List(INT).coerce(Value::List(vec![s("x")])).is_err());
362    }
363
364    #[test]
365    fn an_empty_string_is_no_items_rather_than_one_empty_one() {
366        // What `HK_EXCLUDE=` means, and the rule the named parsers already follow. Wrapping it
367        // as a one-element list holding `""` added an item nobody asked for, and — since an
368        // empty list is how a higher layer clears a declared default — left the default in
369        // place for exactly the setting the user was trying to empty.
370        const ITEM: &Ty = &Ty::String;
371        assert_eq!(Ty::List(ITEM).coerce(s("")), Ok(Value::List(Vec::new())));
372        assert_eq!(Ty::Set(ITEM).coerce(s("")), Ok(Value::List(Vec::new())));
373        // A non-empty bare value is still a list of one.
374        assert_eq!(
375            Ty::List(ITEM).coerce(s("only")),
376            Ok(Value::List(vec![s("only")]))
377        );
378        // And an empty string is still a perfectly good *string*.
379        assert_eq!(Ty::String.coerce(s("")), Ok(s("")));
380    }
381
382    #[test]
383    fn a_type_usage_cannot_know_takes_what_it_is_given() {
384        // The escape hatch: a union or a tool-private type. Refusing here would make the
385        // spec's own escape hatch unusable.
386        assert_eq!(Ty::Any.coerce(s("either")), Ok(s("either")));
387        assert_eq!(Ty::Any.coerce(Value::Bool(true)), Ok(Value::Bool(true)));
388        // And `option<T>` is coerced as its inner type, since absence is about the setting
389        // rather than about the value that did arrive.
390        const INNER: &Ty = &Ty::Int;
391        assert_eq!(Ty::Option(INNER).coerce(s("7")), Ok(Value::Int(7)));
392    }
393
394    #[test]
395    fn a_named_parser_splits_one_string_the_way_the_spec_says() {
396        assert_eq!(
397            Parser::ListByComma.split("a, b,c"),
398            Value::List(vec![s("a"), s("b"), s("c")])
399        );
400        // A set keeps the first of each, so the position of a value is stable.
401        assert_eq!(
402            Parser::SetByComma.split("a,b,a"),
403            Value::List(vec![s("a"), s("b")])
404        );
405        // Emptying a list is a thing a user does to override a default, so it has to be
406        // expressible: `HK_EXCLUDE=` is no items, not one empty one.
407        assert_eq!(Parser::ListByComma.split(""), Value::List(Vec::new()));
408        // Round-tripping the name is what lets a spec and another language's runtime agree.
409        for parser in [
410            Parser::ListByComma,
411            Parser::ListByColon,
412            Parser::ListByOsPathSeparator,
413            Parser::SetByComma,
414        ] {
415            assert_eq!(Parser::from_name(parser.name()), Some(parser));
416        }
417        assert_eq!(Parser::from_name("list_by_semicolon"), None);
418    }
419}