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}