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#[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(
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 #[default]
262 Project,
263 Global,
265 System,
267}
268
269#[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 #[default]
287 Replace,
288 Union,
290 Deep,
292}
293
294#[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 #[default]
312 Any,
313 Global,
318 Env,
320}
321
322#[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 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 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 for (kind, source) in &other.sources {
402 self.sources.insert(kind.to_string(), source.clone());
403 }
404 if !other.files.is_empty() {
409 self.files = other.files.clone();
410 }
411 }
412}
413
414impl SpecConfig {
415 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 #[serde(skip_serializing_if = "Option::is_none")]
433 pub optional: Option<bool>,
434 #[serde(skip_serializing_if = "Vec::is_empty")]
436 pub aliases: Vec<String>,
437 pub default: Option<SpecConfigValue>,
438 pub default_note: Option<String>,
439 pub data_type: SpecDataTypes,
444 pub value_type: Option<SpecConfigType>,
446 pub env: Option<String>,
448 pub envs: Vec<String>,
450 #[serde(skip_serializing_if = "Vec::is_empty")]
452 pub deprecated_envs: Vec<String>,
453 pub cli: Vec<String>,
455 pub bindings: BTreeMap<String, Vec<String>>,
457 pub help: Option<String>,
458 pub long_help: Option<String>,
459 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 pub renamed_to: Option<String>,
470 pub hide: bool,
472 pub since: Option<String>,
474 pub parse: Option<String>,
477 pub writes_to: Option<String>,
479 pub examples: Vec<String>,
480 pub default_list: Vec<SpecConfigValue>,
485 pub extensions: Vec<(String, SpecConfigValue)>,
490}
491
492impl SpecConfigProp {
493 pub fn new() -> Self {
495 Self::default()
496 }
497
498 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 pub fn deprecated_env(mut self, env: impl Into<String>) -> Self {
516 self.deprecated_envs.push(env.into());
517 self
518 }
519
520 pub fn help(mut self, help: impl Into<String>) -> Self {
522 self.help = Some(help.into());
523 self
524 }
525
526 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 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 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 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
670fn data_type_of(ty: &SpecConfigType) -> SpecDataTypes {
675 use crate::spec::config_type::Base;
676 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
693fn 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
715fn 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" | "type" => {
740 let ty: SpecConfigType = v.ensure_string()?.parse()?;
741 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 "prop" => bail_parse!(
769 ctx,
770 child.node.name().span(),
771 "config props cannot nest; write the key as \"a.b\""
772 ),
773 "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 for arg in child.args() {
792 match SpecConfigValue::from_kdl(arg.value) {
793 Ok(Some(value)) => prop.default_list.push(value),
794 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 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 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 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 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 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 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
899fn string_args(node: &NodeHelper) -> Result<Vec<String>, UsageErr> {
907 node.args().map(|arg| arg.ensure_string()).collect()
908}
909
910fn 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 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 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 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 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 for src in [
1089 "prop \"nope\" data_type=\"integer\" default=\"__import__('os')\"",
1090 "prop \"nope\" data_type=\"boolean\" default=\"perhaps\"",
1091 "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 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 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 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 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 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 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 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 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 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 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 #[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 "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 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 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 let round_tripped: Spec = both.to_string().parse().expect("should reparse");
1689 assert_eq!(round_tripped.config.props["a"].envs, ["FIRST", "SECOND"]);
1690
1691 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 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 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 assert!(spec.to_string().contains("default 80 443"), "{spec}");
1727 }
1728}