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}