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::context::ParsingContext;
8use crate::spec::data_types::SpecDataTypes;
9use crate::spec::helpers::{string_entry, NodeHelper};
10
11#[derive(Debug, Default, Clone, Serialize)]
12#[non_exhaustive]
13pub struct SpecConfig {
14    pub props: BTreeMap<String, SpecConfigProp>,
15}
16
17impl SpecConfig {
18    /// Config properties keyed by their dotted path.
19    pub fn new(props: impl IntoIterator<Item = (String, SpecConfigProp)>) -> Self {
20        Self {
21            props: props.into_iter().collect(),
22        }
23    }
24}
25
26impl SpecConfig {
27    pub(crate) fn parse(ctx: &ParsingContext, node: &NodeHelper) -> Result<Self, UsageErr> {
28        let mut config = Self::default();
29        for node in node.children() {
30            node.ensure_arg_len(1..=1)?;
31            match node.name() {
32                "prop" => {
33                    let key = node.arg(0)?;
34                    let key = key.ensure_string()?.to_string();
35                    let mut prop = SpecConfigProp::default();
36                    for (k, v) in node.props() {
37                        match k {
38                            "default" => prop.default = v.value.to_string().into(),
39                            "default_note" => prop.default_note = Some(v.ensure_string()?),
40                            "data_type" => prop.data_type = v.ensure_string()?.parse()?,
41                            "env" => prop.env = v.ensure_string()?.to_string().into(),
42                            "help" => prop.help = v.ensure_string()?.to_string().into(),
43                            "long_help" => prop.long_help = v.ensure_string()?.to_string().into(),
44                            k => bail_parse!(ctx, node.span(), "unsupported config prop key {k}"),
45                        }
46                    }
47                    config.props.insert(key, prop);
48                }
49                k => bail_parse!(ctx, node.node.name().span(), "unsupported config key {k}"),
50            }
51        }
52        Ok(config)
53    }
54
55    pub(crate) fn merge(&mut self, other: &Self) {
56        for (key, prop) in &other.props {
57            self.props
58                .entry(key.to_string())
59                .or_insert_with(|| prop.clone());
60        }
61    }
62}
63
64impl SpecConfig {
65    pub fn is_empty(&self) -> bool {
66        self.props.is_empty()
67    }
68}
69
70#[derive(Debug, Clone, Serialize)]
71#[non_exhaustive]
72pub struct SpecConfigProp {
73    pub default: Option<String>,
74    pub default_note: Option<String>,
75    pub data_type: SpecDataTypes,
76    pub env: Option<String>,
77    pub help: Option<String>,
78    pub long_help: Option<String>,
79}
80
81impl SpecConfigProp {
82    /// A config property. Every field is optional; set what applies.
83    pub fn new() -> Self {
84        Self::default()
85    }
86
87    /// Environment variable that sets this property.
88    pub fn env(mut self, env: impl Into<String>) -> Self {
89        self.env = Some(env.into());
90        self
91    }
92
93    /// Short help text.
94    pub fn help(mut self, help: impl Into<String>) -> Self {
95        self.help = Some(help.into());
96        self
97    }
98
99    /// Default value, rendered in docs.
100    pub fn default_value(mut self, default: impl Into<String>) -> Self {
101        self.default = Some(default.into());
102        self
103    }
104}
105
106impl SpecConfigProp {
107    fn to_kdl_node(&self, key: String) -> KdlNode {
108        let mut node = KdlNode::new("prop");
109        node.push(KdlEntry::new(key));
110        if let Some(default) = &self.default {
111            node.push(string_entry(Some("default"), default));
112        }
113        if let Some(default_note) = &self.default_note {
114            node.push(string_entry(Some("default_note"), default_note));
115        }
116        if let Some(env) = &self.env {
117            node.push(string_entry(Some("env"), env));
118        }
119        if let Some(help) = &self.help {
120            node.push(string_entry(Some("help"), help));
121        }
122        if let Some(long_help) = &self.long_help {
123            node.push(string_entry(Some("long_help"), long_help));
124        }
125        node
126    }
127}
128
129impl Default for SpecConfigProp {
130    fn default() -> Self {
131        Self {
132            default: None,
133            default_note: None,
134            data_type: SpecDataTypes::Null,
135            env: None,
136            help: None,
137            long_help: None,
138        }
139    }
140}
141
142impl From<&SpecConfig> for KdlNode {
143    fn from(config: &SpecConfig) -> Self {
144        let mut node = KdlNode::new("config");
145        for (key, prop) in &config.props {
146            let doc = node.children_mut().get_or_insert_with(KdlDocument::new);
147            doc.nodes_mut().push(prop.to_kdl_node(key.to_string()));
148        }
149        node
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use crate::Spec;
156    use insta::assert_snapshot;
157
158    #[test]
159    fn test_config_defaults() {
160        let spec = Spec::parse(
161            &Default::default(),
162            r#"
163config {
164    prop "color" default=#true env="COLOR" help="Enable color output"
165    prop "user" default="admin" env="USER" help="User to run as"
166    prop "jobs" default=4 env="JOBS" help="Number of jobs to run"
167    prop "timeout" default=1.5 env="TIMEOUT" help="Timeout in seconds" \
168        long_help="Timeout in seconds, can be fractional"
169}
170        "#,
171        )
172        .unwrap();
173
174        assert_snapshot!(spec, @r##"
175        config {
176            prop color default="#true" env=COLOR help="Enable color output"
177            prop jobs default="4" env=JOBS help="Number of jobs to run"
178            prop timeout default="1.5" env=TIMEOUT help="Timeout in seconds" long_help="Timeout in seconds, can be fractional"
179            prop user default=admin env=USER help="User to run as"
180        }
181        "##);
182    }
183}