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#[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
27pub(crate) enum ValueError {
29 IntegerOutOfRange,
31 NotFinite,
33 DoesNotFitType(SpecDataTypes),
36}
37
38impl ValueError {
39 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 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 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 fn to_kdl_entry(&self, key: &str) -> KdlEntry {
86 match self {
87 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 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 fn coerced_to(self, data_type: SpecDataTypes) -> Result<Self, ValueError> {
123 let Self::String(text) = &self else {
124 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 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 pub fn display(&self) -> String {
150 match self {
151 Self::Bool(b) => b.to_string(),
152 Self::Int(i) => i.to_string(),
153 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 pub sources: BTreeMap<String, SpecConfigSource>,
209 pub files: Vec<SpecConfigFile>,
212}
213
214#[derive(Debug, Default, Clone, PartialEq, Serialize)]
220#[non_exhaustive]
221pub struct SpecConfigSource {
222 pub name: Option<String>,
224 pub doc_hint: Option<String>,
226 pub set_hint: Option<String>,
228}
229
230#[derive(Debug, Default, Clone, PartialEq, Serialize)]
232#[non_exhaustive]
233pub struct SpecConfigFile {
234 pub path: String,
235 pub findup: bool,
237 pub scope: SpecConfigFileScope,
240 pub format: Option<String>,
242}
243
244#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Serialize)]
246#[serde(rename_all = "snake_case")]
247pub enum SpecConfigFileScope {
248 #[default]
250 Project,
251 Global,
253 System,
255}
256
257impl_string_enum!(SpecConfigFileScope {
258 SpecConfigFileScope::Project => "project",
259 SpecConfigFileScope::Global => "global",
260 SpecConfigFileScope::System => "system",
261});
262
263#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Serialize)]
265#[serde(rename_all = "snake_case")]
266pub enum SpecConfigMerge {
267 #[default]
269 Replace,
270 Union,
272 Deep,
274}
275
276impl_string_enum!(SpecConfigMerge {
277 SpecConfigMerge::Replace => "replace",
278 SpecConfigMerge::Union => "union",
279 SpecConfigMerge::Deep => "deep",
280});
281
282#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Serialize)]
284#[serde(rename_all = "snake_case")]
285pub enum SpecConfigScope {
286 #[default]
288 Any,
289 Global,
294 Env,
296}
297
298impl_string_enum!(SpecConfigScope {
299 SpecConfigScope::Any => "any",
300 SpecConfigScope::Global => "global",
301 SpecConfigScope::Env => "env",
302});
303
304#[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 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 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 for (kind, source) in &other.sources {
384 self.sources.insert(kind.to_string(), source.clone());
385 }
386 if !other.files.is_empty() {
391 self.files = other.files.clone();
392 }
393 }
394}
395
396impl SpecConfig {
397 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 #[serde(skip_serializing_if = "Option::is_none")]
415 pub optional: Option<bool>,
416 #[serde(skip_serializing_if = "Vec::is_empty")]
418 pub aliases: Vec<String>,
419 pub default: Option<SpecConfigValue>,
420 pub default_note: Option<String>,
421 pub data_type: SpecDataTypes,
426 pub value_type: Option<SpecConfigType>,
428 pub env: Option<String>,
430 pub envs: Vec<String>,
432 #[serde(skip_serializing_if = "Vec::is_empty")]
434 pub deprecated_envs: Vec<String>,
435 pub cli: Vec<String>,
437 pub bindings: BTreeMap<String, Vec<String>>,
439 pub help: Option<String>,
440 pub long_help: Option<String>,
441 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 pub renamed_to: Option<String>,
452 pub hide: bool,
454 pub since: Option<String>,
456 pub parse: Option<String>,
459 pub writes_to: Option<String>,
461 pub examples: Vec<String>,
462 pub default_list: Vec<SpecConfigValue>,
467 pub extensions: Vec<(String, SpecConfigValue)>,
472}
473
474impl SpecConfigProp {
475 pub fn new() -> Self {
477 Self::default()
478 }
479
480 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 pub fn deprecated_env(mut self, env: impl Into<String>) -> Self {
498 self.deprecated_envs.push(env.into());
499 self
500 }
501
502 pub fn help(mut self, help: impl Into<String>) -> Self {
504 self.help = Some(help.into());
505 self
506 }
507
508 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 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 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 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
652fn data_type_of(ty: &SpecConfigType) -> SpecDataTypes {
657 use crate::spec::config_type::Base;
658 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
675fn 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
697fn 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" | "type" => {
722 let ty: SpecConfigType = v.ensure_string()?.parse()?;
723 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 "prop" => bail_parse!(
751 ctx,
752 child.node.name().span(),
753 "config props cannot nest; write the key as \"a.b\""
754 ),
755 "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 for arg in child.args() {
774 match SpecConfigValue::from_kdl(arg.value) {
775 Ok(Some(value)) => prop.default_list.push(value),
776 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 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 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 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 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 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 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
881fn string_args(node: &NodeHelper) -> Result<Vec<String>, UsageErr> {
889 node.args().map(|arg| arg.ensure_string()).collect()
890}
891
892fn 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 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 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 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 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 for src in [
1071 "prop \"nope\" data_type=\"integer\" default=\"__import__('os')\"",
1072 "prop \"nope\" data_type=\"boolean\" default=\"perhaps\"",
1073 "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 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 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 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 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 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 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 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 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 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 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 #[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 "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 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 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 let round_tripped: Spec = both.to_string().parse().expect("should reparse");
1671 assert_eq!(round_tripped.config.props["a"].envs, ["FIRST", "SECOND"]);
1672
1673 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 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 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 assert!(spec.to_string().contains("default 80 443"), "{spec}");
1709 }
1710}