Skip to main content

usage/spec/
config.rs

1use std::collections::BTreeMap;
2
3use crate::kdl::{self, KdlDocument, KdlEntry, KdlNode};
4use serde::Serialize;
5
6use crate::error::UsageErr;
7use crate::spec::config_type::SpecConfigType;
8use crate::spec::context::ParsingContext;
9use crate::spec::data_types::SpecDataTypes;
10use crate::spec::helpers::{string_entry, NodeHelper, ParseEntry};
11
12/// A config property's value, as declared.
13///
14/// Typed rather than a `String` because the previous version stored the KDL *source* form
15/// — `KdlValue::to_string()` — so a `default="4"` was read back as the four characters
16/// `"4"` with its quotes, and writing it out again added another pair. Each round trip
17/// added a layer. Keeping the value means the writer can render it once, correctly.
18#[derive(Debug, Clone, PartialEq, Serialize)]
19#[serde(untagged)]
20pub enum SpecConfigValue {
21    Bool(bool),
22    Int(i64),
23    Float(f64),
24    String(String),
25}
26
27/// What a KDL value could not be read as.
28pub(crate) enum ValueError {
29    /// An integer KDL accepts (it parses `i128`) that does not fit the spec's `i64`.
30    IntegerOutOfRange,
31    /// `#inf`, `#-inf` or `#nan`, which KDL accepts and nothing downstream can carry.
32    NotFinite,
33    /// A string default that the declared type cannot read — `data_type="integer"` beside
34    /// `default="lots"`. Carries the type it should have been.
35    DoesNotFitType(SpecDataTypes),
36}
37
38impl ValueError {
39    /// What to tell whoever wrote the spec.
40    pub(crate) fn describe(&self) -> String {
41        match self {
42            Self::IntegerOutOfRange => "config default does not fit in a 64-bit integer".into(),
43            Self::NotFinite => {
44                "config default must be a finite number: `#inf` and `#nan` cannot be written \
45                 back out, rendered, or carried in JSON"
46                    .into()
47            }
48            Self::DoesNotFitType(ty) => {
49                format!("config default cannot be read as the declared type `{ty}`")
50            }
51        }
52    }
53}
54
55impl SpecConfigValue {
56    /// `None` only for an explicit `#null`.
57    ///
58    /// An out-of-range integer is an error rather than a `None`: returning "absent" for a
59    /// number somebody wrote loses their default silently, and every consumer downstream —
60    /// the writer, the SDKs — then reports the property as having none.
61    pub(crate) fn from_kdl(value: &kdl::KdlValue) -> Result<Option<Self>, ValueError> {
62        Ok(match value {
63            kdl::KdlValue::Bool(b) => Some(Self::Bool(*b)),
64            kdl::KdlValue::Integer(i) => Some(Self::Int(
65                i64::try_from(*i).map_err(|_| ValueError::IntegerOutOfRange)?,
66            )),
67            // Not merely unusual: `serde_json` writes a non-finite float as `null`, so
68            // `usage g json` silently reported the property as having no default at all,
69            // and the Python generator emitted a bare `inf` — which is a `NameError`, not a
70            // number. There is nowhere for this value to go, so it is refused where it is
71            // written rather than lost three consumers later.
72            kdl::KdlValue::Float(f) if !f.is_finite() => return Err(ValueError::NotFinite),
73            kdl::KdlValue::Float(f) => Some(Self::Float(*f)),
74            kdl::KdlValue::String(s) => Some(Self::String(s.clone())),
75            kdl::KdlValue::Null => None,
76        })
77    }
78
79    /// A KDL entry for this value.
80    ///
81    /// No special handling for a whole float: `KdlValue::Float(1.0)` is written `1.0` and
82    /// read back as a float. (Reviewed as a risk — that a `1.0` would render as `1` and
83    /// reparse as an integer — and measured not to happen, including `1e3` normalizing to
84    /// `1000.0`. `a_whole_float_stays_a_float` pins it.)
85    fn to_kdl_entry(&self, key: &str) -> KdlEntry {
86        match self {
87            // Through `string_entry`, like every other string this crate writes: the kdl
88            // crate renders a control character literally and the result cannot be read
89            // back. Building the entry by hand here meant a default containing one — help
90            // text with a colour escape in it, say — wrote a spec that failed to reparse,
91            // which is the exact failure this change exists to fix.
92            Self::String(s) => string_entry(Some(key), s),
93            Self::Bool(b) => KdlEntry::new_prop(key, kdl::KdlValue::Bool(*b)),
94            Self::Int(i) => KdlEntry::new_prop(key, kdl::KdlValue::Integer(*i as i128)),
95            Self::Float(f) => KdlEntry::new_prop(key, kdl::KdlValue::Float(*f)),
96        }
97    }
98
99    /// This value as a bare node argument.
100    fn to_kdl_arg(&self) -> KdlEntry {
101        match self {
102            Self::Bool(b) => KdlEntry::new(*b),
103            Self::Int(i) => KdlEntry::new(kdl::KdlValue::Integer(*i as i128)),
104            Self::Float(f) => KdlEntry::new(*f),
105            Self::String(s) => string_entry(None, s),
106        }
107    }
108
109    /// The same value read as the type the prop declares.
110    ///
111    /// A spec may write the value as a string and the type as a number —
112    /// `data_type="float" default="1.5"` — and reading it as declared means every consumer
113    /// downstream sees a number.
114    ///
115    /// A string the declared type *cannot* read is an error. It used to stay a string, which
116    /// kept it away from anything that would treat its text as a number but left the spec
117    /// saying two contradictory things: the Python generator then emitted an `int` field
118    /// whose default is `"lots"`, and a 20-digit number written in quotes bypassed the
119    /// range check that the same number unquoted would have hit. Refusing it is both safe
120    /// and honest, and across mise's 280 settings — the largest registry in the fleet —
121    /// there is not one string default that fails to read as its declared type.
122    fn coerced_to(self, data_type: SpecDataTypes) -> Result<Self, ValueError> {
123        let Self::String(text) = &self else {
124            // The other direction, which only matters for a declared `string`: an unquoted
125            // `default=4` on a string-typed prop left the value a number, so the generated
126            // Python field was typed `str` and defaulted to `4`. Every other declared type is
127            // a number or a boolean, and one of *those* written as a bare value is already the
128            // right shape.
129            return Ok(match data_type {
130                SpecDataTypes::String => Self::String(self.display()),
131                _ => self,
132            });
133        };
134        let mismatch = || ValueError::DoesNotFitType(data_type);
135        match data_type {
136            SpecDataTypes::Integer => text.parse().map(Self::Int).map_err(|_| mismatch()),
137            SpecDataTypes::Float => match text.parse::<f64>() {
138                // The same reason as above, by the other road: `default="inf"` for a float.
139                Ok(f) if !f.is_finite() => Err(ValueError::NotFinite),
140                Ok(f) => Ok(Self::Float(f)),
141                Err(_) => Err(mismatch()),
142            },
143            SpecDataTypes::Boolean => text.parse().map(Self::Bool).map_err(|_| mismatch()),
144            _ => Ok(self),
145        }
146    }
147
148    /// The value as a human would read it, for docs and help output.
149    pub fn display(&self) -> String {
150        match self {
151            Self::Bool(b) => b.to_string(),
152            Self::Int(i) => i.to_string(),
153            // With its point, because `1` is how an *integer* is written and this is not one. This
154            // is also what `usage-config` writes a float as, and the two have to agree: a spec's
155            // `default=1.0` on a string-typed prop is coerced *here* and its `choice 1.0` is read
156            // *there*, so a difference of one character refused a default and a choice that were
157            // written identically. `usage-conformance` has a test that holds the two together,
158            // because it is the one crate that can see both.
159            Self::Float(f) => {
160                let text = f.to_string();
161                match f.is_finite() && !text.contains(['.', 'e', 'E']) {
162                    true => format!("{text}.0"),
163                    false => text,
164                }
165            }
166            Self::String(s) => s.clone(),
167        }
168    }
169}
170
171impl From<bool> for SpecConfigValue {
172    fn from(value: bool) -> Self {
173        Self::Bool(value)
174    }
175}
176
177impl From<i64> for SpecConfigValue {
178    fn from(value: i64) -> Self {
179        Self::Int(value)
180    }
181}
182
183impl From<f64> for SpecConfigValue {
184    fn from(value: f64) -> Self {
185        Self::Float(value)
186    }
187}
188
189impl From<&str> for SpecConfigValue {
190    fn from(value: &str) -> Self {
191        Self::String(value.to_string())
192    }
193}
194
195impl From<String> for SpecConfigValue {
196    fn from(value: String) -> Self {
197        Self::String(value)
198    }
199}
200
201#[derive(Debug, Default, Clone, PartialEq, Serialize)]
202#[non_exhaustive]
203pub struct SpecConfig {
204    pub props: BTreeMap<String, SpecConfigProp>,
205    /// Source kinds this CLI reads that usage knows nothing about — a git config, a pkl
206    /// file, an `.npmrc`. Declared so docs can say where a setting comes from without
207    /// usage having to understand the source itself.
208    pub sources: BTreeMap<String, SpecConfigSource>,
209    /// Config file locations, in ascending precedence: the last one named wins. This is
210    /// the rc-style chain that docs have to describe and a resolver has to walk.
211    pub files: Vec<SpecConfigFile>,
212}
213
214/// A source kind's display metadata.
215///
216/// usage never reads a git config or an `.npmrc`; it renders what the spec says about
217/// them. `{key}` and `{value}` in a hint are substituted with the setting's key in that
218/// source and the value being set.
219#[derive(Debug, Default, Clone, PartialEq, Serialize)]
220#[non_exhaustive]
221pub struct SpecConfigSource {
222    /// What to call it in prose: "git config", "hk.pkl".
223    pub name: Option<String>,
224    /// How to describe reading a setting from it: "git config `{key}`".
225    pub doc_hint: Option<String>,
226    /// How to describe writing one: "git config {key} {value}".
227    pub set_hint: Option<String>,
228}
229
230/// Where a config file lives, and how it is found.
231#[derive(Debug, Default, Clone, PartialEq, Serialize)]
232#[non_exhaustive]
233pub struct SpecConfigFile {
234    pub path: String,
235    /// Whether to look for this name in the current directory and every parent.
236    pub findup: bool,
237    /// Which class of file this is. `Project` files are the ones a repository can carry,
238    /// and so the ones a `scope="global"` setting refuses to be read from.
239    pub scope: SpecConfigFileScope,
240    /// The format, when the extension does not say: "toml", "json", "yaml".
241    pub format: Option<String>,
242}
243
244/// Which class of file a location belongs to.
245#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Serialize)]
246#[serde(rename_all = "snake_case")]
247pub enum SpecConfigFileScope {
248    /// Somewhere a repository can carry — the least trusted.
249    #[default]
250    Project,
251    /// The user's own configuration.
252    Global,
253    /// Installed by whoever administers the machine.
254    System,
255}
256
257impl_string_enum!(SpecConfigFileScope {
258    SpecConfigFileScope::Project => "project",
259    SpecConfigFileScope::Global => "global",
260    SpecConfigFileScope::System => "system",
261});
262
263/// How values for one property combine across sources.
264#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Serialize)]
265#[serde(rename_all = "snake_case")]
266pub enum SpecConfigMerge {
267    /// The highest-precedence source wins outright.
268    #[default]
269    Replace,
270    /// Collections from every source are concatenated, keeping first position.
271    Union,
272    /// Maps are merged key by key.
273    Deep,
274}
275
276impl_string_enum!(SpecConfigMerge {
277    SpecConfigMerge::Replace => "replace",
278    SpecConfigMerge::Union => "union",
279    SpecConfigMerge::Deep => "deep",
280});
281
282/// Which sources a property may be read from.
283#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Serialize)]
284#[serde(rename_all = "snake_case")]
285pub enum SpecConfigScope {
286    /// Any declared source.
287    #[default]
288    Any,
289    /// Only files a repository cannot supply, and the environment or command line.
290    ///
291    /// mise treats this as a security property — a checked-in file must not be able to
292    /// change it — which is why it is declared here rather than left to each tool.
293    Global,
294    /// Never from a file: the environment or the command line only.
295    Env,
296}
297
298impl_string_enum!(SpecConfigScope {
299    SpecConfigScope::Any => "any",
300    SpecConfigScope::Global => "global",
301    SpecConfigScope::Env => "env",
302});
303
304/// One of a property's allowed values.
305#[derive(Debug, Clone, PartialEq, Serialize)]
306#[non_exhaustive]
307pub struct SpecConfigChoice {
308    pub value: SpecConfigValue,
309    pub help: Option<String>,
310}
311
312impl SpecConfig {
313    /// Config properties keyed by their dotted path.
314    pub fn new(props: impl IntoIterator<Item = (String, SpecConfigProp)>) -> Self {
315        Self {
316            props: props.into_iter().collect(),
317            ..Default::default()
318        }
319    }
320}
321
322impl SpecConfig {
323    pub(crate) fn parse(ctx: &ParsingContext, node: &NodeHelper) -> Result<Self, UsageErr> {
324        let mut config = Self::default();
325        for node in node.children() {
326            match node.name() {
327                "prop" => {
328                    node.ensure_arg_len(1..=1)?;
329                    let key = node.arg(0)?.ensure_string()?.to_string();
330                    let prop = SpecConfigProp::parse(ctx, &node)?;
331                    config.props.insert(key, prop);
332                }
333                "source" => {
334                    node.ensure_arg_len(1..=1)?;
335                    let kind = node.arg(0)?.ensure_string()?.to_string();
336                    let mut source = SpecConfigSource::default();
337                    for (k, v) in node.props() {
338                        match k {
339                            "name" => source.name = Some(v.ensure_string()?),
340                            "doc_hint" => source.doc_hint = Some(v.ensure_string()?),
341                            "set_hint" => source.set_hint = Some(v.ensure_string()?),
342                            k => {
343                                bail_parse!(ctx, node.span(), "unsupported config source key {k}")
344                            }
345                        }
346                    }
347                    refuse_children(ctx, &node, "source")?;
348                    config.sources.insert(kind, source);
349                }
350                "file" => {
351                    node.ensure_arg_len(1..=1)?;
352                    let mut file = SpecConfigFile {
353                        path: node.arg(0)?.ensure_string()?.to_string(),
354                        ..Default::default()
355                    };
356                    for (k, v) in node.props() {
357                        match k {
358                            "findup" => file.findup = v.ensure_bool()?,
359                            "scope" => file.scope = parse_enum(ctx, &node, "scope", &v)?,
360                            "format" => file.format = Some(v.ensure_string()?),
361                            k => bail_parse!(ctx, node.span(), "unsupported config file key {k}"),
362                        }
363                    }
364                    refuse_children(ctx, &node, "file")?;
365                    config.files.push(file);
366                }
367                k => bail_parse!(ctx, node.node.name().span(), "unsupported config key {k}"),
368            }
369        }
370        Ok(config)
371    }
372
373    /// Later declarations win, whole prop at a time.
374    ///
375    /// `Spec::merge` is other-wins for everything else (`merge_opt!`), and config was the
376    /// one place an included file could add a prop but never correct one. Field-wise
377    /// refinement is deliberately not offered until something needs it.
378    pub(crate) fn merge(&mut self, other: &Self) {
379        for (key, prop) in &other.props {
380            self.props.insert(key.to_string(), prop.clone());
381        }
382        // Source kinds are a set, so they merge per kind like props do.
383        for (kind, source) in &other.sources {
384            self.sources.insert(kind.to_string(), source.clone());
385        }
386        // Files are not a set but an ordered precedence chain, and there is no meaningful
387        // way to interleave two of them — so a spec that declares any replaces the chain
388        // whole rather than appending to one it never saw. In the case `include` exists for,
389        // only one of the two declares files at all.
390        if !other.files.is_empty() {
391            self.files = other.files.clone();
392        }
393    }
394}
395
396impl SpecConfig {
397    /// Whether there is nothing to write out.
398    ///
399    /// All three, not just props: a `config` block that declares only where files live is a
400    /// perfectly good one, and reporting it empty made the writer drop it.
401    pub fn is_empty(&self) -> bool {
402        self.props.is_empty() && self.sources.is_empty() && self.files.is_empty()
403    }
404}
405
406#[derive(Debug, Clone, PartialEq, Serialize)]
407#[non_exhaustive]
408pub struct SpecConfigProp {
409    /// Whether absence is a legitimate resolved value.
410    ///
411    /// `None` keeps the inferred rule: an `option<T>` or a property without a default is
412    /// optional. An explicit value lets a registry state the contract instead of relying on
413    /// that inference.
414    #[serde(skip_serializing_if = "Option::is_none")]
415    pub optional: Option<bool>,
416    /// Equivalent config keys accepted without a deprecation warning.
417    #[serde(skip_serializing_if = "Vec::is_empty")]
418    pub aliases: Vec<String>,
419    pub default: Option<SpecConfigValue>,
420    pub default_note: Option<String>,
421    /// The old five-value type, kept so a spec written against it still means what it said.
422    ///
423    /// `type` is the one to write now; this is set from it where the two overlap, so a
424    /// consumer reading either sees the same thing.
425    pub data_type: SpecDataTypes,
426    /// The type, in the expression grammar: `list<string>`, `option<path>`, `bool|string`.
427    pub value_type: Option<SpecConfigType>,
428    /// The first environment variable, kept for the specs that wrote `env=`.
429    pub env: Option<String>,
430    /// Every environment variable that sets this, highest precedence first.
431    pub envs: Vec<String>,
432    /// Environment aliases still read after current names, with a deprecation warning.
433    #[serde(skip_serializing_if = "Vec::is_empty")]
434    pub deprecated_envs: Vec<String>,
435    /// Flags that set this, as declared elsewhere in the spec.
436    pub cli: Vec<String>,
437    /// Keys this setting has in a custom source kind, by kind name.
438    pub bindings: BTreeMap<String, Vec<String>>,
439    pub help: Option<String>,
440    pub long_help: Option<String>,
441    /// The section to list this under in generated docs.
442    pub help_heading: Option<String>,
443    pub choices: Vec<SpecConfigChoice>,
444    pub merge: SpecConfigMerge,
445    pub scope: SpecConfigScope,
446    pub deprecated: Option<String>,
447    pub deprecated_warn_at: Option<String>,
448    pub deprecated_remove_at: Option<String>,
449    /// The property that replaces this one, so a value arriving under the old name can be
450    /// folded into the new one rather than ignored.
451    pub renamed_to: Option<String>,
452    /// Keep it out of docs and completions.
453    pub hide: bool,
454    /// The version that introduced it.
455    pub since: Option<String>,
456    /// A named parser for turning one string into this type — `list_by_comma` and friends.
457    /// Vocabulary rather than code, so any implementation can honor it.
458    pub parse: Option<String>,
459    /// Where `config set` should write this, when it is not the usual file.
460    pub writes_to: Option<String>,
461    pub examples: Vec<String>,
462    /// A list-valued default, which cannot be written as a single property.
463    ///
464    /// Typed like the scalar `default`, so `default 1 2 3` for a `list<int>` stays three
465    /// numbers all the way to the JSON schema instead of becoming three strings.
466    pub default_list: Vec<SpecConfigValue>,
467    /// Anything a tool needs to carry that usage does not interpret.
468    ///
469    /// Preserved in order and written back out, so a registry with tool-private metadata
470    /// (`mise.rust_type`, `aube.npm_shared`) round-trips through the spec untouched.
471    pub extensions: Vec<(String, SpecConfigValue)>,
472}
473
474impl SpecConfigProp {
475    /// A config property. Every field is optional; set what applies.
476    pub fn new() -> Self {
477        Self::default()
478    }
479
480    /// An environment variable that sets this property.
481    ///
482    /// Call it more than once for aliases, highest precedence first, the way
483    /// `env "HK_JOBS" "HK_JOB"` reads in a spec. Both `env` and `envs` are maintained, so a
484    /// consumer can read either — the parser holds the same invariant, and a builder that
485    /// left `envs` empty meant a programmatically built spec serialized without the variable
486    /// every reader of `envs` looks for.
487    pub fn env(mut self, env: impl Into<String>) -> Self {
488        let env = env.into();
489        if self.env.is_none() {
490            self.env = Some(env.clone());
491        }
492        self.envs.push(env);
493        self
494    }
495
496    /// A deprecated environment alias, read after every current name.
497    pub fn deprecated_env(mut self, env: impl Into<String>) -> Self {
498        self.deprecated_envs.push(env.into());
499        self
500    }
501
502    /// Short help text.
503    pub fn help(mut self, help: impl Into<String>) -> Self {
504        self.help = Some(help.into());
505        self
506    }
507
508    /// Default value, rendered in docs.
509    pub fn default_value(mut self, default: impl Into<SpecConfigValue>) -> Self {
510        self.default = Some(default.into());
511        self
512    }
513}
514
515impl SpecConfigProp {
516    fn to_kdl_node(&self, key: String) -> KdlNode {
517        let mut node = KdlNode::new("prop");
518        // The key too: a dotted path is unlikely to hold anything exotic, but "unlikely"
519        // is not the standard the rest of the writer holds itself to.
520        node.push(string_entry(None, &key));
521        if let Some(default) = &self.default {
522            node.push(default.to_kdl_entry("default"));
523        }
524        if let Some(optional) = self.optional {
525            node.push(KdlEntry::new_prop("optional", optional));
526        }
527        // The grammar spelling when there is one, the old five-value name otherwise: a
528        // spec that said `data_type` keeps saying it, and one that says `type` keeps the
529        // richer type it declared. Either way it is written, unlike before — a type parsed
530        // and not serialized survives exactly one hop.
531        match &self.value_type {
532            Some(ty) => node.push(string_entry(Some("type"), &ty.to_string())),
533            None if self.data_type != SpecDataTypes::Null => {
534                node.push(string_entry(Some("data_type"), &self.data_type.to_string()));
535            }
536            None => {}
537        }
538        if let Some(default_note) = &self.default_note {
539            node.push(string_entry(Some("default_note"), default_note));
540        }
541        // Only when it is the whole story: several go in a child node, and writing both
542        // would say it twice.
543        if self.envs.len() <= 1 {
544            if let Some(env) = &self.env {
545                node.push(string_entry(Some("env"), env));
546            }
547        }
548        if let Some(help) = &self.help {
549            node.push(string_entry(Some("help"), help));
550        }
551        if let Some(long_help) = &self.long_help {
552            node.push(string_entry(Some("long_help"), long_help));
553        }
554        if let Some(heading) = &self.help_heading {
555            node.push(string_entry(Some("help_heading"), heading));
556        }
557        if self.merge != SpecConfigMerge::default() {
558            node.push(string_entry(Some("merge"), &self.merge.to_string()));
559        }
560        if self.scope != SpecConfigScope::default() {
561            node.push(string_entry(Some("scope"), &self.scope.to_string()));
562        }
563        if let Some(deprecated) = &self.deprecated {
564            node.push(string_entry(Some("deprecated"), deprecated));
565        }
566        if let Some(at) = &self.deprecated_warn_at {
567            node.push(string_entry(Some("deprecated_warn_at"), at));
568        }
569        if let Some(at) = &self.deprecated_remove_at {
570            node.push(string_entry(Some("deprecated_remove_at"), at));
571        }
572        if let Some(renamed) = &self.renamed_to {
573            node.push(string_entry(Some("renamed_to"), renamed));
574        }
575        if self.hide {
576            node.push(KdlEntry::new_prop("hide", true));
577        }
578        if let Some(since) = &self.since {
579            node.push(string_entry(Some("since"), since));
580        }
581        if let Some(parse) = &self.parse {
582            node.push(string_entry(Some("parse"), parse));
583        }
584        if let Some(writes_to) = &self.writes_to {
585            node.push(string_entry(Some("writes_to"), writes_to));
586        }
587
588        let mut children = KdlDocument::new();
589        if self.envs.len() > 1 {
590            children.nodes_mut().push(string_list("env", &self.envs));
591        }
592        if !self.deprecated_envs.is_empty() {
593            children
594                .nodes_mut()
595                .push(string_list("deprecated_env", &self.deprecated_envs));
596        }
597        if !self.aliases.is_empty() {
598            children
599                .nodes_mut()
600                .push(string_list("alias", &self.aliases));
601        }
602        if !self.cli.is_empty() {
603            children.nodes_mut().push(string_list("cli", &self.cli));
604        }
605        if !self.default_list.is_empty() {
606            let mut node = KdlNode::new("default");
607            for value in &self.default_list {
608                node.push(value.to_kdl_arg());
609            }
610            children.nodes_mut().push(node);
611        }
612        for (kind, keys) in &self.bindings {
613            let mut node = KdlNode::new("source");
614            node.push(string_entry(None, kind));
615            for key in keys {
616                node.push(string_entry(None, key));
617            }
618            children.nodes_mut().push(node);
619        }
620        if !self.choices.is_empty() {
621            let mut block = KdlNode::new("choices");
622            let mut inner = KdlDocument::new();
623            for choice in &self.choices {
624                let mut node = KdlNode::new("choice");
625                node.push(choice.value.to_kdl_arg());
626                if let Some(help) = &choice.help {
627                    node.push(string_entry(Some("help"), help));
628                }
629                inner.nodes_mut().push(node);
630            }
631            block.set_children(inner);
632            children.nodes_mut().push(block);
633        }
634        for example in &self.examples {
635            children
636                .nodes_mut()
637                .push(string_list("example", std::slice::from_ref(example)));
638        }
639        for (key, value) in &self.extensions {
640            let mut node = KdlNode::new("x");
641            node.push(string_entry(None, key));
642            node.push(value.to_kdl_arg());
643            children.nodes_mut().push(node);
644        }
645        if !children.nodes().is_empty() {
646            node.set_children(children);
647        }
648        node
649    }
650}
651
652/// The old five-value type a grammar type corresponds to, where one does.
653///
654/// A composite — `list<string>`, `map<…>` — has no counterpart, so it reads as `Null`:
655/// truthful about what the old vocabulary could say.
656fn data_type_of(ty: &SpecConfigType) -> SpecDataTypes {
657    use crate::spec::config_type::Base;
658    // A union has no counterpart among the old five values, so it gets `Null` like every
659    // other composite. Running it through `simplified()` made `bool|string` claim to be
660    // `Boolean`, and that legacy field drives how a default is read — so a `bool|string`
661    // whose default was the string `"true"` came back as a boolean, contradicting the very
662    // `value_type` that produced it.
663    if matches!(ty, SpecConfigType::Union(_)) {
664        return SpecDataTypes::Null;
665    }
666    match ty.simplified() {
667        SpecConfigType::Base(Base::Bool) => SpecDataTypes::Boolean,
668        SpecConfigType::Base(Base::String) => SpecDataTypes::String,
669        SpecConfigType::Base(Base::Int | Base::Uint) => SpecDataTypes::Integer,
670        SpecConfigType::Base(Base::Float) => SpecDataTypes::Float,
671        _ => SpecDataTypes::Null,
672    }
673}
674
675/// Refuse a child block on a node that has no children in its vocabulary.
676///
677/// `source` and `file` are properties only. They checked their properties and never looked at
678/// their children, so a nested block was dropped in silence — which contradicts the rule the
679/// rest of this block follows, and `prop` already enforces: vocabulary this version does not
680/// know is refused rather than half-read, because half-read is how a spec means one thing here
681/// and another somewhere else.
682fn refuse_children(
683    ctx: &ParsingContext,
684    node: &NodeHelper,
685    name: &'static str,
686) -> Result<(), UsageErr> {
687    if let Some(child) = node.children().into_iter().next() {
688        bail_parse!(
689            ctx,
690            child.node.name().span(),
691            "a config {name} takes properties, not a block"
692        );
693    }
694    Ok(())
695}
696
697/// A node whose arguments are strings: `cli "--jobs" "-j"`.
698fn string_list(name: &str, values: &[String]) -> KdlNode {
699    let mut node = KdlNode::new(name);
700    for value in values {
701        node.push(string_entry(None, value));
702    }
703    node
704}
705
706impl SpecConfigProp {
707    fn parse(ctx: &ParsingContext, node: &NodeHelper) -> Result<Self, UsageErr> {
708        let mut prop = Self::default();
709        for (k, v) in node.props() {
710            match k {
711                "default" => {
712                    prop.default = match SpecConfigValue::from_kdl(v.value) {
713                        Ok(value) => value,
714                        Err(err) => bail_parse!(ctx, v.entry.span(), "{}", err.describe()),
715                    }
716                }
717                "default_note" => prop.default_note = Some(v.ensure_string()?),
718                "optional" => prop.optional = Some(v.ensure_bool()?),
719                // `data_type` was the old spelling and stays readable; `type` is the
720                // grammar, and setting either fills the other in where they overlap.
721                "data_type" | "type" => {
722                    let ty: SpecConfigType = v.ensure_string()?.parse()?;
723                    // From the parsed type rather than its text: the grammar spells a
724                    // boolean `bool` and the old enum spells it `boolean`, so reading the
725                    // text twice loses it on the way back out.
726                    prop.data_type = data_type_of(&ty);
727                    prop.value_type = Some(ty);
728                }
729                "env" => prop.env = Some(v.ensure_string()?),
730                "help" => prop.help = Some(v.ensure_string()?),
731                "long_help" => prop.long_help = Some(v.ensure_string()?),
732                "help_heading" => prop.help_heading = Some(v.ensure_string()?),
733                "merge" => prop.merge = parse_enum(ctx, node, "merge", &v)?,
734                "scope" => prop.scope = parse_enum(ctx, node, "scope", &v)?,
735                "deprecated" => prop.deprecated = Some(v.ensure_string()?),
736                "deprecated_warn_at" => prop.deprecated_warn_at = Some(v.ensure_string()?),
737                "deprecated_remove_at" => prop.deprecated_remove_at = Some(v.ensure_string()?),
738                "renamed_to" => prop.renamed_to = Some(v.ensure_string()?),
739                "hide" => prop.hide = v.ensure_bool()?,
740                "since" => prop.since = Some(v.ensure_string()?),
741                "parse" => prop.parse = Some(v.ensure_string()?),
742                "writes_to" => prop.writes_to = Some(v.ensure_string()?),
743                k => bail_parse!(ctx, node.span(), "unsupported config prop key {k}"),
744            }
745        }
746
747        for child in node.children() {
748            match child.name() {
749                // The mistake worth naming: it used to parse and lose the inner prop.
750                "prop" => bail_parse!(
751                    ctx,
752                    child.node.name().span(),
753                    "config props cannot nest; write the key as \"a.b\""
754                ),
755                // Where several values, or prose, would not fit on the node.
756                // Extended, not assigned: `example` and `source` already accumulate, and a
757                // second `env` line is the natural parallel — assigning silently dropped the
758                // aliases on the first one.
759                "env" => prop.envs.extend(string_args(&child)?),
760                "deprecated_env" => prop.deprecated_envs.extend(string_args(&child)?),
761                "alias" => prop.aliases.extend(string_args(&child)?),
762                "cli" => prop.cli.extend(string_args(&child)?),
763                "example" => prop.examples.extend(string_args(&child)?),
764                "long_help" => {
765                    child.ensure_arg_len(1..=1)?;
766                    prop.long_help = Some(child.arg(0)?.ensure_string()?.to_string());
767                }
768                "default" => {
769                    // A list default, which cannot be written as one property. Accumulated
770                    // across nodes like `env`, `cli` and `example`, rather than assigned —
771                    // clearing the list first meant a second `default` line silently dropped
772                    // everything the first one declared.
773                    for arg in child.args() {
774                        match SpecConfigValue::from_kdl(arg.value) {
775                            Ok(Some(value)) => prop.default_list.push(value),
776                            // `#null` in a list of defaults says nothing at all; a list
777                            // whose default is "one of these is absent" has no meaning.
778                            Ok(None) => bail_parse!(
779                                ctx,
780                                arg.entry.span(),
781                                "a default list holds values, not #null"
782                            ),
783                            Err(err) => {
784                                bail_parse!(ctx, arg.entry.span(), "{}", err.describe())
785                            }
786                        }
787                    }
788                }
789                "source" => {
790                    // `source "git" "hk.jobs" "hk.check"` — this setting's keys in a kind
791                    // declared at the top of the block.
792                    child.ensure_arg_len(1..)?;
793                    let mut args = string_args(&child)?;
794                    let kind = args.remove(0);
795                    prop.bindings.entry(kind).or_default().extend(args);
796                }
797                "choices" => {
798                    for choice in child.children() {
799                        if choice.name() != "choice" {
800                            bail_parse!(
801                                ctx,
802                                choice.node.name().span(),
803                                "a choices block holds `choice` nodes"
804                            );
805                        }
806                        choice.ensure_arg_len(1..=1)?;
807                        let value = match SpecConfigValue::from_kdl(choice.arg(0)?.value) {
808                            Ok(Some(value)) => value,
809                            Ok(None) => bail_parse!(ctx, choice.span(), "a choice needs a value"),
810                            // Same reasons, said about a choice rather than a default: a
811                            // number too large to carry, or one nothing can render.
812                            Err(err) => {
813                                bail_parse!(ctx, choice.span(), "choice: {}", err.describe())
814                            }
815                        };
816                        let mut help = None;
817                        for (k, v) in choice.props() {
818                            match k {
819                                "help" => help = Some(v.ensure_string()?),
820                                k => bail_parse!(ctx, choice.span(), "unsupported choice key {k}"),
821                            }
822                        }
823                        refuse_children(ctx, &choice, "choice")?;
824                        prop.choices.push(SpecConfigChoice { value, help });
825                    }
826                }
827                "x" => {
828                    // The escape hatch: kept in order, written back out, interpreted by
829                    // nobody here.
830                    child.ensure_arg_len(2..=2)?;
831                    let key = child.arg(0)?.ensure_string()?.to_string();
832                    let value = match SpecConfigValue::from_kdl(child.arg(1)?.value) {
833                        Ok(Some(value)) => value,
834                        // An extension promises to come back out exactly as it went in, and
835                        // there is no `#null` to come back to — it used to be stored as `""`
836                        // and written as `""`, which is tool-private metadata altered on save.
837                        Ok(None) => bail_parse!(
838                            ctx,
839                            child.span(),
840                            "an extension value cannot be #null; it would not round-trip"
841                        ),
842                        Err(err) => {
843                            bail_parse!(ctx, child.span(), "extension value: {}", err.describe())
844                        }
845                    };
846                    prop.extensions.push((key, value));
847                }
848                k => bail_parse!(
849                    ctx,
850                    child.node.name().span(),
851                    "unsupported config prop node {k}"
852                ),
853            }
854        }
855
856        // After both loops: `type` may be written after `default`, and the declared type is
857        // what decides how the value is read.
858        let declared = prop.data_type;
859        prop.default = match prop.default.map(|v| v.coerced_to(declared)) {
860            None => None,
861            Some(Ok(value)) => Some(value),
862            Some(Err(err)) => bail_parse!(ctx, node.span(), "{}", err.describe()),
863        };
864        // One env spelling, two ways to write it, and they must never disagree. `env=` is
865        // shorthand for a one-element list, so both forms feed `envs` in the order they were
866        // written and `env` is always its first entry.
867        //
868        // Syncing only when one side was empty left a prop that wrote *both* with two fields
869        // saying different things — `usage g json` exposing the pair, and a writer that picks
870        // between them by list length, so one of the values disappeared on a round trip.
871        if let Some(env) = prop.env.take() {
872            if !prop.envs.contains(&env) {
873                prop.envs.insert(0, env);
874            }
875        }
876        prop.env = prop.envs.first().cloned();
877        Ok(prop)
878    }
879}
880
881/// Every argument of a node, as strings.
882/// The string arguments of a node, refusing anything that is not one.
883///
884/// An environment variable, a flag and a source key are names, so a non-string is a mistake
885/// rather than something to render. Converting instead — which this used to do — turned
886/// `env #true` into a variable named `#true` and wrote it back out quoted, looking for all
887/// the world like somebody had meant it.
888fn string_args(node: &NodeHelper) -> Result<Vec<String>, UsageErr> {
889    node.args().map(|arg| arg.ensure_string()).collect()
890}
891
892/// An enum-valued property, with the accepted spellings in the error.
893fn parse_enum<T>(
894    ctx: &ParsingContext,
895    node: &NodeHelper,
896    key: &str,
897    value: &ParseEntry<'_>,
898) -> Result<T, UsageErr>
899where
900    T: std::str::FromStr + crate::enum_value::StringEnum,
901{
902    let text = value.ensure_string()?;
903    text.parse().map_err(|_| {
904        ctx.build_err(
905            format!(
906                "`{text}` is not a {key}; the choices are {}",
907                T::VARIANTS.join(", ")
908            ),
909            (node.span().offset(), node.span().len()).into(),
910        )
911    })
912}
913
914impl Default for SpecConfigProp {
915    fn default() -> Self {
916        Self {
917            optional: None,
918            aliases: Vec::new(),
919            default: None,
920            default_note: None,
921            data_type: SpecDataTypes::Null,
922            value_type: None,
923            env: None,
924            envs: Vec::new(),
925            deprecated_envs: Vec::new(),
926            cli: Vec::new(),
927            bindings: BTreeMap::new(),
928            help: None,
929            long_help: None,
930            help_heading: None,
931            choices: Vec::new(),
932            merge: SpecConfigMerge::default(),
933            scope: SpecConfigScope::default(),
934            deprecated: None,
935            deprecated_warn_at: None,
936            deprecated_remove_at: None,
937            renamed_to: None,
938            hide: false,
939            since: None,
940            parse: None,
941            writes_to: None,
942            examples: Vec::new(),
943            default_list: Vec::new(),
944            extensions: Vec::new(),
945        }
946    }
947}
948
949impl From<&SpecConfig> for KdlNode {
950    fn from(config: &SpecConfig) -> Self {
951        let mut node = KdlNode::new("config");
952        let doc = node.children_mut().get_or_insert_with(KdlDocument::new);
953        // Declarations first, then locations, then the settings — the order the reference
954        // page describes them in, so a written file reads like the documentation.
955        for (kind, source) in &config.sources {
956            let mut node = KdlNode::new("source");
957            node.push(string_entry(None, kind));
958            if let Some(name) = &source.name {
959                node.push(string_entry(Some("name"), name));
960            }
961            if let Some(hint) = &source.doc_hint {
962                node.push(string_entry(Some("doc_hint"), hint));
963            }
964            if let Some(hint) = &source.set_hint {
965                node.push(string_entry(Some("set_hint"), hint));
966            }
967            doc.nodes_mut().push(node);
968        }
969        // In order: a file list is a precedence chain, so the order is the meaning.
970        for file in &config.files {
971            let mut node = KdlNode::new("file");
972            node.push(string_entry(None, &file.path));
973            if file.findup {
974                node.push(KdlEntry::new_prop("findup", true));
975            }
976            if file.scope != SpecConfigFileScope::default() {
977                node.push(string_entry(Some("scope"), &file.scope.to_string()));
978            }
979            if let Some(format) = &file.format {
980                node.push(string_entry(Some("format"), format));
981            }
982            doc.nodes_mut().push(node);
983        }
984        for (key, prop) in &config.props {
985            doc.nodes_mut().push(prop.to_kdl_node(key.to_string()));
986        }
987        node
988    }
989}
990
991#[cfg(test)]
992mod tests {
993    /// The reason inside a parse error.
994    ///
995    /// `UsageErr::InvalidInput` renders as "Invalid usage config" whatever went wrong — the
996    /// specifics are the diagnostic's label. Asserting on that matters here: a test that only
997    /// checks `is_err()` passes just as happily when the spec was refused for some unrelated
998    /// reason, which is how a check gets credit for work it is not doing.
999    fn detail_of(err: &crate::error::UsageErr) -> String {
1000        match err {
1001            crate::error::UsageErr::InvalidInput(detail, _, _) => detail.clone(),
1002            other => other.to_string(),
1003        }
1004    }
1005
1006    use super::{SpecConfigMerge, SpecConfigScope, SpecConfigValue};
1007    use crate::Spec;
1008    use insta::assert_snapshot;
1009
1010    #[test]
1011    fn optionality_and_key_aliases_round_trip() {
1012        let spec: Spec = r#"
1013name "ex"
1014bin "ex"
1015config {
1016    prop "jobs" type="uint" optional=#false {
1017        alias "parallelism" "threads"
1018    }
1019}
1020"#
1021        .parse()
1022        .unwrap();
1023        let jobs = &spec.config.props["jobs"];
1024        assert_eq!(jobs.optional, Some(false));
1025        assert_eq!(jobs.aliases, ["parallelism", "threads"]);
1026
1027        let written = spec.to_string();
1028        let reparsed: Spec = written.parse().unwrap();
1029        assert_eq!(reparsed.config.props["jobs"], *jobs, "{written}");
1030    }
1031
1032    #[test]
1033    fn test_config_defaults() {
1034        let spec = Spec::parse(
1035            &Default::default(),
1036            r#"
1037config {
1038    prop "color" default=#true env="COLOR" help="Enable color output"
1039    prop "user" default="admin" env="USER" help="User to run as"
1040    prop "jobs" default=4 env="JOBS" help="Number of jobs to run"
1041    prop "timeout" default=1.5 env="TIMEOUT" help="Timeout in seconds" \
1042        long_help="Timeout in seconds, can be fractional"
1043}
1044        "#,
1045        )
1046        .unwrap();
1047
1048        // The values, not their KDL source text: the old snapshot recorded
1049        // `default="#true"` and `default="4"`, which is what a stringly default looks
1050        // like once it has been through the writer.
1051        assert_snapshot!(spec, @r##"
1052        config {
1053            prop color default=#true env=COLOR help="Enable color output"
1054            prop jobs default=4 env=JOBS help="Number of jobs to run"
1055            prop timeout default=1.5 env=TIMEOUT help="Timeout in seconds" long_help="Timeout in seconds, can be fractional"
1056            prop user default=admin env=USER help="User to run as"
1057        }
1058        "##);
1059    }
1060
1061    #[test]
1062    fn a_default_the_declared_type_cannot_read_is_refused() {
1063        // It used to stay a string. That kept it away from anything treating its text as a
1064        // number — the point of the original fix — but left the spec asserting two
1065        // contradictory things, and the Python generator then wrote an `int` field defaulting
1066        // to `"__import__('os')"` in quotes. Refusing it is safe *and* honest.
1067        //
1068        // Not a theoretical strictness: across mise's 280 settings, the largest registry in
1069        // the fleet, every string default reads as its declared type.
1070        for src in [
1071            "prop \"nope\" data_type=\"integer\" default=\"__import__('os')\"",
1072            "prop \"nope\" data_type=\"boolean\" default=\"perhaps\"",
1073            // The quoted road to the range error the unquoted number already hit.
1074            "prop \"nope\" data_type=\"integer\" default=\"99999999999999999999\"",
1075        ] {
1076            let spec = format!("name \"ex\"\nbin \"ex\"\nconfig {{\n  {src}\n}}\n");
1077            let err = Spec::parse(&Default::default(), &spec)
1078                .expect_err(&format!("should not parse: {src}"));
1079            let detail = detail_of(&err);
1080            assert!(
1081                detail.contains("declared type") || detail.contains("64-bit integer"),
1082                "refused for the wrong reason: {detail}"
1083            );
1084        }
1085    }
1086
1087    #[test]
1088    fn a_declared_string_holds_a_string_however_it_was_written() {
1089        // `type="string" default=4` left the value a number, so the generated Python field was
1090        // typed `str` and defaulted to `4` — the declared type and the emitted literal
1091        // disagreeing, which is the whole thing this pass is about.
1092        let spec = Spec::parse(
1093            &Default::default(),
1094            "name \"x\"\nbin \"x\"\nconfig {\n  prop \"a\" data_type=\"string\" default=4\n  prop \"b\" data_type=\"string\" default=#true\n}\n",
1095        )
1096        .expect("should parse");
1097        assert_eq!(
1098            spec.config.props["a"].default,
1099            Some(SpecConfigValue::String("4".into()))
1100        );
1101        assert_eq!(
1102            spec.config.props["b"].default,
1103            Some(SpecConfigValue::String("true".into()))
1104        );
1105    }
1106
1107    #[test]
1108    fn a_default_that_is_not_a_finite_number_is_refused() {
1109        // KDL accepts `#inf` and `#nan`; nothing downstream can carry one. `serde_json`
1110        // writes a non-finite float as `null`, so `usage g json` reported the property as
1111        // having no default at all, and the Python generator emitted a bare `inf`, which is a
1112        // `NameError` rather than a number. Measured, not assumed: both were the behaviour
1113        // before this check.
1114        for value in ["#inf", "#-inf", "#nan", "\"inf\" data_type=\"float\""] {
1115            let spec =
1116                format!("name \"ex\"\nbin \"ex\"\nconfig {{\n  prop \"a\" default={value}\n}}\n");
1117            let err = Spec::parse(&Default::default(), &spec)
1118                .expect_err(&format!("should not parse: default={value}"));
1119            let detail = detail_of(&err);
1120            assert!(
1121                detail.contains("finite"),
1122                "refused for the wrong reason: {detail}"
1123            );
1124        }
1125        // A finite float is untouched.
1126        let spec = Spec::parse(
1127            &Default::default(),
1128            "name \"ex\"\nbin \"ex\"\nconfig {\n  prop \"a\" default=1.5\n}\n",
1129        )
1130        .expect("should parse");
1131        assert_eq!(
1132            spec.config.props["a"].default,
1133            Some(SpecConfigValue::Float(1.5))
1134        );
1135    }
1136
1137    #[test]
1138    fn a_default_a_reader_cannot_render_is_still_written_readably() {
1139        // `string_entry` exists because the kdl crate writes some values in a form this
1140        // crate cannot read back: a control character goes out literally, and the result
1141        // fails to reparse. Help text really does contain them — a CLI that colours its
1142        // help has an escape character in the middle of it — and so, therefore, does a
1143        // default. The typed-default writer built its entry by hand and skipped that
1144        // protection, so the one hop this whole change is about broke again for exactly
1145        // one shape of value.
1146        let spec: Spec =
1147            "name \"ex\"\nbin \"ex\"\nconfig {\n  prop \"prompt\" default=\"a\\u{1b}[0mb\"\n}\n"
1148                .parse()
1149                .expect("should parse");
1150        assert_eq!(
1151            spec.config.props["prompt"].default,
1152            Some(SpecConfigValue::String("a\u{1b}[0mb".to_string()))
1153        );
1154
1155        let written = spec.to_string();
1156        let reparsed: Spec = written
1157            .parse()
1158            .unwrap_or_else(|e| panic!("written spec does not parse: {e}\n{written}"));
1159        assert_eq!(
1160            reparsed.config.props["prompt"].default,
1161            spec.config.props["prompt"].default,
1162        );
1163
1164        // The key travels the same road. Nothing sensible puts a control character in a
1165        // dotted path, but the writer's job is to write back what it was given whatever that
1166        // was, and "nothing sensible would" is not a guarantee about what a spec holds.
1167        let spec: Spec = "name \"ex\"\nbin \"ex\"\nconfig {\n  prop \"a\\u{1b}b\" default=1\n}\n"
1168            .parse()
1169            .expect("should parse");
1170        let written = spec.to_string();
1171        let reparsed: Spec = written
1172            .parse()
1173            .unwrap_or_else(|e| panic!("written spec does not parse: {e}\n{written}"));
1174        assert_eq!(
1175            reparsed.config.props.keys().collect::<Vec<_>>(),
1176            spec.config.props.keys().collect::<Vec<_>>(),
1177        );
1178    }
1179
1180    #[test]
1181    fn a_config_block_survives_being_written_out() {
1182        // Each of these was lost or corrupted by one hop through the writer.
1183        let spec: Spec = r#"
1184name "ex"
1185bin "ex"
1186config {
1187    prop "jobs" data_type="integer" default=4 env="EX_JOBS" help="How many"
1188    prop "color" data_type="boolean" default=#true
1189    prop "shell" data_type="string" default="true"
1190}
1191"#
1192        .parse()
1193        .unwrap();
1194
1195        let written = spec.to_string();
1196        let round_tripped: Spec = written.parse().unwrap();
1197        for (key, before) in &spec.config.props {
1198            let after = round_tripped
1199                .config
1200                .props
1201                .get(key)
1202                .unwrap_or_else(|| panic!("{key} should survive"));
1203            assert_eq!(
1204                after.data_type, before.data_type,
1205                "{key}'s type should survive: {written}"
1206            );
1207            assert_eq!(
1208                after.default, before.default,
1209                "{key}'s default should survive unchanged: {written}"
1210            );
1211        }
1212        // `"true"` is a string whose text looks like a boolean — the case that shows the
1213        // difference between keeping a value and keeping its spelling.
1214        assert_eq!(
1215            round_tripped.config.props["shell"].default,
1216            Some(SpecConfigValue::String("true".into()))
1217        );
1218    }
1219
1220    #[test]
1221    fn a_whole_float_stays_a_float() {
1222        // A pin rather than a fix: review raised that `Float(1.0)` might render as `1` and
1223        // come back an integer. It does not — kdl writes `1.0` — and this keeps it that way.
1224        let spec: Spec = "name \"ex\"\nbin \"ex\"\nconfig {\n  prop \"rate\" default=1.0\n}\n"
1225            .parse()
1226            .unwrap();
1227        assert_eq!(
1228            spec.config.props["rate"].default,
1229            Some(SpecConfigValue::Float(1.0))
1230        );
1231
1232        let written = spec.to_string();
1233        let round_tripped: Spec = written.parse().unwrap();
1234        assert_eq!(
1235            round_tripped.config.props["rate"].default,
1236            Some(SpecConfigValue::Float(1.0)),
1237            "a whole float should not come back an integer: {written}"
1238        );
1239    }
1240
1241    #[test]
1242    fn a_default_too_large_for_an_i64_is_an_error() {
1243        // KDL parses integers as `i128`. Reading one that does not fit as "no default" loses
1244        // a number somebody wrote, and every consumer downstream then reports the property
1245        // as having none.
1246        let err = Spec::parse(
1247            &Default::default(),
1248            "config {\n  prop \"big\" default=99999999999999999999\n}\n",
1249        )
1250        .expect_err("an out-of-range default should not be silently dropped");
1251        match err {
1252            crate::error::UsageErr::InvalidInput(msg, _, _) => {
1253                assert!(msg.contains("64-bit integer"), "unhelpful message: {msg}");
1254            }
1255            err => panic!("unexpected error: {err:?}"),
1256        }
1257    }
1258
1259    #[test]
1260    fn a_declared_type_decides_how_a_default_is_read() {
1261        // A spec may write the value as a string and the type as a number. Reading it as
1262        // declared means consumers see a number — and, just as important, that a string
1263        // which is *not* a number stays a string rather than being handed to something that
1264        // will treat its text as one.
1265        let spec: Spec = r#"
1266name "ex"
1267bin "ex"
1268config {
1269    prop "rate" data_type="float" default="1.5"
1270    prop "jobs" data_type="integer" default="4"
1271    prop "shell" data_type="string" default="true"
1272}
1273"#
1274        .parse()
1275        .unwrap();
1276        assert_eq!(
1277            spec.config.props["rate"].default,
1278            Some(SpecConfigValue::Float(1.5))
1279        );
1280        assert_eq!(
1281            spec.config.props["jobs"].default,
1282            Some(SpecConfigValue::Int(4))
1283        );
1284        assert_eq!(
1285            spec.config.props["shell"].default,
1286            Some(SpecConfigValue::String("true".into()))
1287        );
1288    }
1289
1290    /// Every node kind the vocabulary has, written and read back.
1291    ///
1292    /// The spec is the interchange between authoring and everything that consumes it, so
1293    /// anything it cannot write out is something a tool would lose by saving its own file.
1294    #[test]
1295    fn the_whole_vocabulary_survives_a_round_trip() {
1296        let spec: Spec = r##"
1297name "hk"
1298bin "hk"
1299config {
1300    source "git" name="git config" doc_hint="git config `{key}`" set_hint="git config {key} {value}"
1301    source "pkl" name="hk.pkl"
1302    file "/etc/hk/config.pkl" scope="system"
1303    file "~/.config/hk/config.pkl" scope="global"
1304    file "hk.pkl" findup=#true
1305    file ".hkrc" format="ini"
1306    prop "jobs" type="uint" default=0 default_note="0 = auto-detect" \
1307        help="Number of parallel jobs" since="1.0.0" help_heading="Performance" {
1308        cli "--jobs" "-j"
1309        env "HK_JOBS" "HK_JOB"
1310        deprecated_env "HK_JOBS_OLD"
1311        source "git" "hk.jobs"
1312        source "pkl" "jobs" "defaults.jobs"
1313        example "hk check --jobs 4"
1314    }
1315    prop "exclude" type="list<string>" merge="union" {
1316        default "target" "node_modules"
1317        env "HK_EXCLUDE"
1318    }
1319    prop "stash" type="string" {
1320        choices {
1321            choice "git" help="Use `git stash`"
1322            choice "none" help="No stashing"
1323        }
1324    }
1325    prop "trusted" type="bool" scope="global"
1326    prop "ci" type="bool" hide=#true scope="env" {
1327        env "CI"
1328        x "mise.rust_type" "BoolOrString"
1329        x "mise.rc" #true
1330    }
1331    prop "old.key" deprecated="Use new.key" renamed_to="new.key" \
1332        deprecated_warn_at="2026.12.0" deprecated_remove_at="2027.12.0"
1333    prop "urls" type="map<string, url>" parse="list_by_comma" writes_to="npmrc"
1334}
1335"##
1336        .parse()
1337        .unwrap();
1338
1339        let written = spec.to_string();
1340        let back: Spec = written
1341            .parse()
1342            .unwrap_or_else(|e| panic!("re-reading what we wrote: {e}\n{written}"));
1343
1344        assert_eq!(back.config.sources, spec.config.sources, "{written}");
1345        assert_eq!(back.config.files, spec.config.files, "{written}");
1346        assert_eq!(
1347            back.config.props.keys().collect::<Vec<_>>(),
1348            spec.config.props.keys().collect::<Vec<_>>(),
1349            "{written}"
1350        );
1351        for (key, before) in &spec.config.props {
1352            let after = &back.config.props[key];
1353            assert_eq!(after, before, "{key} changed on the way out:\n{written}");
1354        }
1355
1356        // And the pieces that are easy to write and forget to read.
1357        let jobs = &spec.config.props["jobs"];
1358        assert_eq!(jobs.cli, ["--jobs", "-j"]);
1359        assert_eq!(jobs.envs, ["HK_JOBS", "HK_JOB"]);
1360        assert_eq!(jobs.deprecated_envs, ["HK_JOBS_OLD"]);
1361        assert_eq!(
1362            jobs.env.as_deref(),
1363            Some("HK_JOBS"),
1364            "the first of the list"
1365        );
1366        assert_eq!(jobs.bindings["pkl"], ["jobs", "defaults.jobs"]);
1367        assert_eq!(jobs.examples, ["hk check --jobs 4"]);
1368        assert_eq!(jobs.help_heading.as_deref(), Some("Performance"));
1369        assert_eq!(spec.config.props["exclude"].merge, SpecConfigMerge::Union);
1370        assert_eq!(
1371            spec.config.props["exclude"].default_list,
1372            [
1373                SpecConfigValue::String("target".into()),
1374                SpecConfigValue::String("node_modules".into()),
1375            ]
1376        );
1377        assert_eq!(spec.config.props["stash"].choices.len(), 2);
1378        assert_eq!(
1379            spec.config.props["stash"].choices[0].help.as_deref(),
1380            Some("Use `git stash`")
1381        );
1382        assert_eq!(spec.config.props["trusted"].scope, SpecConfigScope::Global);
1383        assert_eq!(spec.config.props["ci"].scope, SpecConfigScope::Env);
1384        assert!(spec.config.props["ci"].hide);
1385        assert_eq!(
1386            spec.config.props["ci"].extensions,
1387            [
1388                (
1389                    "mise.rust_type".to_string(),
1390                    SpecConfigValue::String("BoolOrString".into())
1391                ),
1392                ("mise.rc".to_string(), SpecConfigValue::Bool(true)),
1393            ]
1394        );
1395        assert_eq!(
1396            spec.config.props["old.key"].renamed_to.as_deref(),
1397            Some("new.key")
1398        );
1399        assert_eq!(
1400            spec.config.props["urls"]
1401                .value_type
1402                .as_ref()
1403                .map(|t| t.to_string()),
1404            Some("map<string, url>".to_string())
1405        );
1406        assert_eq!(
1407            spec.config.props["urls"].parse.as_deref(),
1408            Some("list_by_comma")
1409        );
1410        assert_eq!(
1411            spec.config.props["urls"].writes_to.as_deref(),
1412            Some("npmrc")
1413        );
1414
1415        // The serialized model, committed: this is what `usage g json` hands to a docs
1416        // pipeline, a schema generator, or an implementation in another language, and it is
1417        // the artifact a port can diff against rather than reading this file.
1418        assert_snapshot!(serde_json::to_string_pretty(&spec.config).unwrap());
1419    }
1420
1421    #[test]
1422    fn an_unknown_word_in_the_config_block_is_refused() {
1423        // Strict, deliberately: a spec using vocabulary this version does not have should
1424        // say so rather than half-load. That is what `min_usage_version` is for.
1425        for src in [
1426            "config {\n  prop \"a\" nonsense=1\n}\n",
1427            "config {\n  nonsense \"a\"\n}\n",
1428            "config {\n  prop \"a\" {\n    nonsense \"b\"\n  }\n}\n",
1429            "config {\n  prop \"a\" merge=\"sideways\"\n}\n",
1430            "config {\n  file \"x\" scope=\"elsewhere\"\n}\n",
1431        ] {
1432            assert!(
1433                Spec::parse(&Default::default(), src).is_err(),
1434                "should be refused: {src}"
1435            );
1436        }
1437    }
1438
1439    #[test]
1440    fn a_nested_prop_is_refused_rather_than_dropped() {
1441        let err = Spec::parse(
1442            &Default::default(),
1443            r#"
1444config {
1445    prop "status" {
1446        prop "missing_tools"
1447    }
1448}
1449"#,
1450        )
1451        .expect_err("nesting should not be silently accepted");
1452        // The message is in the diagnostic rather than the summary line, which is where
1453        // this crate keeps parse detail.
1454        match err {
1455            crate::error::UsageErr::InvalidInput(msg, _, _) => {
1456                assert!(msg.contains("cannot nest"), "unhelpful message: {msg}");
1457            }
1458            err => panic!("unexpected error: {err:?}"),
1459        }
1460    }
1461
1462    #[test]
1463    fn a_later_declaration_of_a_prop_wins() {
1464        // What `include` needs: everything else in a spec is other-wins, and config was
1465        // the one place a later file could add a prop but never correct one.
1466        let mut spec = Spec::parse(
1467            &Default::default(),
1468            "config {\n  prop \"jobs\" default=1 help=\"first\"\n}\n",
1469        )
1470        .unwrap();
1471        let other = Spec::parse(
1472            &Default::default(),
1473            "config {\n  prop \"jobs\" default=8 help=\"second\"\n  prop \"color\"\n}\n",
1474        )
1475        .unwrap();
1476
1477        spec.merge(other);
1478        assert_eq!(
1479            spec.config.props["jobs"].default,
1480            Some(SpecConfigValue::Int(8))
1481        );
1482        assert_eq!(spec.config.props["jobs"].help.as_deref(), Some("second"));
1483        assert!(spec.config.props.contains_key("color"));
1484    }
1485
1486    #[test]
1487    fn an_included_file_can_declare_sources_and_files() {
1488        // The case `include` exists for: a spec with many settings keeps them in their own
1489        // file, and that file is where the whole block lives — the source kinds and the file
1490        // chain included. Merging only props dropped both, so a settings file could describe
1491        // where values come from and have it silently discarded.
1492        let mut spec = Spec::parse(&Default::default(), "name \"hk\"\nbin \"hk\"\n").unwrap();
1493        let included = Spec::parse(
1494            &Default::default(),
1495            r#"
1496config {
1497    source "git" name="git config"
1498    file "/etc/hk/config.pkl" scope="system"
1499    file "hk.pkl" findup=#true
1500    prop "jobs" type="uint"
1501}
1502"#,
1503        )
1504        .unwrap();
1505
1506        spec.merge(included);
1507        assert_eq!(
1508            spec.config.sources["git"].name.as_deref(),
1509            Some("git config")
1510        );
1511        assert_eq!(spec.config.files.len(), 2);
1512        assert_eq!(spec.config.files[1].path, "hk.pkl");
1513        assert!(spec.config.files[1].findup);
1514    }
1515
1516    #[test]
1517    fn a_block_of_only_files_is_not_empty() {
1518        // `is_empty` decides whether the writer emits the block at all, so counting only
1519        // props meant a config block that declared just where files live vanished on a round
1520        // trip — the same class of loss as the defaults this stack started with.
1521        let spec = Spec::parse(
1522            &Default::default(),
1523            "name \"x\"\nbin \"x\"\nconfig {\n  file \"x.toml\" findup=#true\n}\n",
1524        )
1525        .unwrap();
1526        assert!(!spec.config.is_empty());
1527        // Unquoted: the writer only quotes what KDL requires it to.
1528        assert!(spec.to_string().contains("file x.toml"), "{spec}");
1529    }
1530
1531    #[test]
1532    fn a_name_that_is_not_a_string_is_refused() {
1533        // These are all names — an environment variable, a flag, a key in another source.
1534        // Rendering a non-string instead of refusing it produced a variable called `#true`
1535        // and wrote it back out quoted, as though somebody had meant it.
1536        for body in [
1537            "prop \"a\" {\n  env #true\n}",
1538            "prop \"a\" {\n  cli 42\n}",
1539            "prop \"a\" {\n  source \"git\" 1\n}",
1540            "prop \"a\" {\n  example #false\n}",
1541        ] {
1542            let src = format!("name \"x\"\nbin \"x\"\nconfig {{\n{body}\n}}\n");
1543            assert!(
1544                Spec::parse(&Default::default(), &src).is_err(),
1545                "should not parse:\n{src}"
1546            );
1547        }
1548    }
1549
1550    #[test]
1551    fn a_union_has_no_legacy_type_and_does_not_claim_one() {
1552        // `data_type` is the old five-value field and a union is not one of the five. Mapping
1553        // it through `simplified()` made `bool|string` say `Boolean` — and that field decides
1554        // how a default is read, so the string default came back as a boolean, contradicting
1555        // the `value_type` it was derived from.
1556        let spec = Spec::parse(
1557            &Default::default(),
1558            "name \"x\"\nbin \"x\"\nconfig {\n  prop \"a\" type=\"bool|string\" default=\"true\"\n}\n",
1559        )
1560        .expect("should parse");
1561        let prop = &spec.config.props["a"];
1562        assert_eq!(prop.data_type, crate::spec::data_types::SpecDataTypes::Null);
1563        assert_eq!(
1564            prop.default,
1565            Some(SpecConfigValue::String("true".into())),
1566            "a union's default is left as written"
1567        );
1568        // The plain boolean it is not the same as still reads as one.
1569        let spec = Spec::parse(
1570            &Default::default(),
1571            "name \"x\"\nbin \"x\"\nconfig {\n  prop \"a\" type=\"bool\" default=\"true\"\n}\n",
1572        )
1573        .expect("should parse");
1574        assert_eq!(
1575            spec.config.props["a"].default,
1576            Some(SpecConfigValue::Bool(true))
1577        );
1578    }
1579
1580    #[test]
1581    fn an_extension_that_could_not_round_trip_is_refused() {
1582        // `x` nodes promise to come back out exactly as they went in. `#null` had nowhere to
1583        // come back to: it was stored as an empty string and written as `""`, which is
1584        // tool-private metadata quietly altered by saving the file.
1585        let err = Spec::parse(
1586            &Default::default(),
1587            "config {\n  prop \"a\" {\n    x \"mise.thing\" #null\n  }\n}\n",
1588        )
1589        .expect_err("should not parse");
1590        assert!(
1591            detail_of(&err).contains("round-trip"),
1592            "refused for the wrong reason: {}",
1593            detail_of(&err)
1594        );
1595    }
1596
1597    #[test]
1598    fn a_second_default_node_adds_to_the_first() {
1599        // The same reason `env` and `cli` accumulate: clearing the list first meant a prop
1600        // that wrote its default over two lines kept only the second.
1601        let spec = Spec::parse(
1602            &Default::default(),
1603            "name \"x\"\nbin \"x\"\nconfig {\n  prop \"a\" type=\"list<string>\" {\n    default \"one\"\n    default \"two\"\n  }\n}\n",
1604        )
1605        .expect("should parse");
1606        assert_eq!(
1607            spec.config.props["a"].default_list,
1608            [
1609                SpecConfigValue::String("one".into()),
1610                SpecConfigValue::String("two".into()),
1611            ]
1612        );
1613    }
1614
1615    #[test]
1616    fn a_second_env_or_cli_node_adds_to_the_first() {
1617        // `example` and `source` already accumulate across nodes; `env` and `cli` assigned, so
1618        // a spec that wrote them on two lines lost the first line's values without a word.
1619        let spec = Spec::parse(
1620            &Default::default(),
1621            "name \"x\"\nbin \"x\"\nconfig {\n  prop \"a\" {\n    env \"FIRST\"\n    env \"SECOND\"\n    cli \"--one\"\n    cli \"--two\"\n  }\n}\n",
1622        )
1623        .expect("should parse");
1624        let prop = &spec.config.props["a"];
1625        assert_eq!(prop.envs, ["FIRST", "SECOND"]);
1626        assert_eq!(prop.cli, ["--one", "--two"]);
1627    }
1628
1629    #[test]
1630    fn a_block_on_a_node_that_takes_none_is_refused() {
1631        // `source` and `file` are properties only, and checked only their properties — so a
1632        // nested block was dropped in silence, which is the one thing this vocabulary is
1633        // strict about not doing.
1634        for src in [
1635            "config {\n  source \"git\" {\n    name \"git config\"\n  }\n}\n",
1636            "config {\n  file \"x.toml\" {\n    scope \"global\"\n  }\n}\n",
1637            // A `choice` is properties-only too, and read its own the same way.
1638            "config {\n  prop \"a\" {\n    choices {\n      choice \"x\" {\n        help \"why\"\n      }\n    }\n  }\n}\n",
1639        ] {
1640            let err = Spec::parse(&Default::default(), src).expect_err(src);
1641            assert!(
1642                detail_of(&err).contains("not a block"),
1643                "refused for the wrong reason: {}",
1644                detail_of(&err)
1645            );
1646        }
1647    }
1648
1649    #[test]
1650    fn both_env_spellings_leave_the_same_prop_however_it_was_built() {
1651        // Two ways to write one thing — `env="X"` and `env "A" "B"` — so `env` and `envs`
1652        // have to agree whichever way a prop arrived. They did after parsing and did not
1653        // after building, so a spec assembled in Rust serialized with an empty `envs` and
1654        // every consumer that reads that field saw a setting no variable could set.
1655        let built = super::SpecConfigProp::new().env("HK_JOBS").env("HK_JOB");
1656        assert_eq!(built.env.as_deref(), Some("HK_JOBS"));
1657        assert_eq!(built.envs, ["HK_JOBS", "HK_JOB"]);
1658
1659        // Both spellings at once. The sync only ran when one side was empty, so this left
1660        // `env` and `envs` asserting different things — `usage g json` exposing the pair, and
1661        // a writer that chooses between them by list length, so a round trip dropped a value.
1662        let both = Spec::parse(
1663            &Default::default(),
1664            "name \"x\"\nbin \"x\"\nconfig {\n  prop \"a\" env=\"FIRST\" {\n    env \"SECOND\"\n  }\n}\n",
1665        )
1666        .unwrap();
1667        assert_eq!(both.config.props["a"].envs, ["FIRST", "SECOND"]);
1668        assert_eq!(both.config.props["a"].env.as_deref(), Some("FIRST"));
1669        // And both values survive being written out and read back.
1670        let round_tripped: Spec = both.to_string().parse().expect("should reparse");
1671        assert_eq!(round_tripped.config.props["a"].envs, ["FIRST", "SECOND"]);
1672
1673        // The property spelling, parsed.
1674        let one = Spec::parse(
1675            &Default::default(),
1676            "name \"x\"\nbin \"x\"\nconfig {\n  prop \"a\" env=\"A\"\n}\n",
1677        )
1678        .unwrap();
1679        let one = &one.config.props["a"];
1680        assert_eq!(one.env.as_deref(), Some("A"));
1681        assert_eq!(one.envs, ["A"]);
1682
1683        // The list spelling, parsed.
1684        let many = Spec::parse(
1685            &Default::default(),
1686            "name \"x\"\nbin \"x\"\nconfig {\n  prop \"a\" {\n    env \"A\" \"B\"\n  }\n}\n",
1687        )
1688        .unwrap();
1689        let many = &many.config.props["a"];
1690        assert_eq!(many.env.as_deref(), Some("A"));
1691        assert_eq!(many.envs, ["A", "B"]);
1692    }
1693
1694    #[test]
1695    fn a_list_default_keeps_the_type_it_was_written_as() {
1696        // `default 1 2 3` for a `list<int>` is three numbers, and a schema or an SDK that
1697        // received three strings would describe the setting wrongly.
1698        let spec = Spec::parse(
1699            &Default::default(),
1700            "name \"x\"\nbin \"x\"\nconfig {\n  prop \"ports\" type=\"list<int>\" {\n    default 80 443\n  }\n}\n",
1701        )
1702        .unwrap();
1703        assert_eq!(
1704            spec.config.props["ports"].default_list,
1705            [SpecConfigValue::Int(80), SpecConfigValue::Int(443)]
1706        );
1707        // And it says so again when written back out, rather than gaining quotes.
1708        assert!(spec.to_string().contains("default 80 443"), "{spec}");
1709    }
1710}