Skip to main content

usage/spec/
config.rs

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