1
//! Implement a configuration source based on command-line arguments.
2

            
3
use once_cell::sync::Lazy;
4
use regex::Regex;
5

            
6
/// A CmdLine holds a set of command-line arguments that augment a
7
/// configuration.
8
///
9
/// These arguments are formatted in toml, and concatenated into a
10
/// single toml object.  With arguments of the form "key=bareword",
11
/// the bareword is quoted for convenience.
12
#[derive(Debug, Clone)]
13
pub struct CmdLine {
14
    /// String for decorating Values.
15
    //
16
    // TODO(nickm): not yet used.
17
    #[allow(dead_code)]
18
    name: String,
19
    /// List of toml lines as given on the command line.
20
    contents: Vec<String>,
21
}
22

            
23
impl Default for CmdLine {
24
4
    fn default() -> Self {
25
4
        Self::new()
26
4
    }
27
}
28

            
29
impl CmdLine {
30
    /// Make a new empty command-line
31
1514
    pub fn new() -> Self {
32
1514
        CmdLine {
33
1514
            name: "command line".to_string(),
34
1514
            contents: Vec::new(),
35
1514
        }
36
1514
    }
37
    /// Add a single line of toml to the configuration.
38
18
    pub fn push_toml_line(&mut self, line: String) {
39
18
        self.contents.push(line);
40
18
    }
41

            
42
    /// Try to adjust the contents of a toml deserialization error so
43
    /// that instead it refers to a single command-line argument.
44
8
    fn convert_toml_error(
45
8
        &self,
46
8
        toml_str: &str,
47
8
        error_message: &str,
48
8
        span: &Option<std::ops::Range<usize>>,
49
8
    ) -> String {
50
8
        // Function to translate a string index to a 0-offset line number.
51
246
        let linepos = |idx| toml_str.bytes().take(idx).filter(|b| *b == b'\n').count();
52

            
53
        // Find the source position as a line within toml_str, and convert that
54
        // to an index into self.contents.
55
8
        let source_line = span
56
8
            .as_ref()
57
12
            .and_then(|range| {
58
8
                let startline = linepos(range.start);
59
8
                let endline = linepos(range.end);
60
8
                (startline == endline).then_some(startline)
61
12
            })
62
12
            .and_then(|pos| self.contents.get(pos));
63
8

            
64
8
        match (source_line, span.as_ref()) {
65
6
            (Some(source), _) => {
66
6
                format!("Couldn't parse command line: {error_message} in {source:?}")
67
            }
68
2
            (None, Some(range)) if toml_str.get(range.clone()).is_some() => format!(
69
                "Couldn't parse command line: {error_message} within {:?}",
70
                &toml_str[range.clone()]
71
            ),
72
2
            _ => format!("Couldn't parse command line: {error_message}"),
73
        }
74
8
    }
75

            
76
    /// Compose elements of this cmdline into a single toml string.
77
1514
    fn build_toml(&self) -> String {
78
1514
        let mut toml_s = String::new();
79
1532
        for line in &self.contents {
80
18
            toml_s.push_str(tweak_toml_bareword(line).as_ref().unwrap_or(line));
81
18
            toml_s.push('\n');
82
18
        }
83
1514
        toml_s
84
1514
    }
85
}
86

            
87
impl figment::Provider for CmdLine {
88
1508
    fn metadata(&self) -> figment::Metadata {
89
1508
        figment::Metadata::named("command line")
90
1508
    }
91

            
92
1512
    fn data(&self) -> figment::Result<figment::value::Map<figment::Profile, figment::value::Dict>> {
93
1512
        let toml_str = self.build_toml();
94
1513
        let toml: toml::Value = toml::from_str(&toml_str).map_err(|toml_err| {
95
2
            self.convert_toml_error(&toml_str, toml_err.message(), &toml_err.span())
96
1513
        })?;
97

            
98
1510
        figment::providers::Serialized::defaults(toml).data()
99
1512
    }
100
}
101

            
102
/// If `s` is a string of the form "keyword=bareword", return a new string
103
/// where `bareword` is quoted. Otherwise return None.
104
///
105
/// This isn't a smart transformation outside the context of 'config',
106
/// since many serde formats don't do so good a job when they get a
107
/// string when they wanted a number or whatever.  But 'config' is
108
/// pretty happy to convert strings to other stuff.
109
32
fn tweak_toml_bareword(s: &str) -> Option<String> {
110
    /// Regex to match a keyword=bareword item.
111
2
    static RE: Lazy<Regex> = Lazy::new(|| {
112
2
        Regex::new(
113
2
            r#"(?x:
114
2
               ^
115
2
                [ \t]*
116
2
                # first capture group: dotted barewords
117
2
                ((?:[a-zA-Z0-9_\-]+\.)*
118
2
                 [a-zA-Z0-9_\-]+)
119
2
                [ \t]*=[ \t]*
120
2
                # second group: one bareword without hyphens
121
2
                ([a-zA-Z0-9_]+)
122
2
                [ \t]*
123
2
                $)"#,
124
2
        )
125
2
        .expect("Built-in regex compilation failed")
126
2
    });
127

            
128
41
    RE.captures(s).map(|c| format!("{}=\"{}\"", &c[1], &c[2]))
129
32
}
130

            
131
#[cfg(test)]
132
mod test {
133
    // @@ begin test lint list maintained by maint/add_warning @@
134
    #![allow(clippy::bool_assert_comparison)]
135
    #![allow(clippy::clone_on_copy)]
136
    #![allow(clippy::dbg_macro)]
137
    #![allow(clippy::mixed_attributes_style)]
138
    #![allow(clippy::print_stderr)]
139
    #![allow(clippy::print_stdout)]
140
    #![allow(clippy::single_char_pattern)]
141
    #![allow(clippy::unwrap_used)]
142
    #![allow(clippy::unchecked_duration_subtraction)]
143
    #![allow(clippy::useless_vec)]
144
    #![allow(clippy::needless_pass_by_value)]
145
    //! <!-- @@ end test lint list maintained by maint/add_warning @@ -->
146
    use super::*;
147
    use figment::Provider as _;
148

            
149
    #[test]
150
    fn bareword_expansion() {
151
        assert_eq!(tweak_toml_bareword("dsfklj"), None);
152
        assert_eq!(tweak_toml_bareword("=99"), None);
153
        assert_eq!(tweak_toml_bareword("=[1,2,3]"), None);
154
        assert_eq!(tweak_toml_bareword("a=b-c"), None);
155

            
156
        assert_eq!(tweak_toml_bareword("a=bc"), Some("a=\"bc\"".into()));
157
        assert_eq!(tweak_toml_bareword("a=b_c"), Some("a=\"b_c\"".into()));
158
        assert_eq!(
159
            tweak_toml_bareword("hello.there.now=a_greeting"),
160
            Some("hello.there.now=\"a_greeting\"".into())
161
        );
162
    }
163

            
164
    #[test]
165
    fn conv_toml_error() {
166
        let mut cl = CmdLine::new();
167
        cl.push_toml_line("Hello=world".to_string());
168
        cl.push_toml_line("Hola=mundo".to_string());
169
        cl.push_toml_line("Bonjour=monde".to_string());
170
        let toml_s = cl.build_toml();
171

            
172
        assert_eq!(
173
            &cl.convert_toml_error(&toml_s, "Nice greeting", &Some(0..13)),
174
            "Couldn't parse command line: Nice greeting in \"Hello=world\""
175
        );
176

            
177
        assert_eq!(
178
            &cl.convert_toml_error(&toml_s, "Nice greeting", &Some(99..333)),
179
            "Couldn't parse command line: Nice greeting"
180
        );
181

            
182
        assert_eq!(
183
            &cl.convert_toml_error(&toml_s, "Nice greeting with a thing", &Some(0..13)),
184
            "Couldn't parse command line: Nice greeting with a thing in \"Hello=world\""
185
        );
186
    }
187

            
188
    #[test]
189
    fn parse_good() {
190
        let mut cl = CmdLine::default();
191
        cl.push_toml_line("a=3".to_string());
192
        cl.push_toml_line("bcd=hello".to_string());
193
        cl.push_toml_line("ef=\"gh i\"".to_string());
194
        cl.push_toml_line("w=[1,2,3]".to_string());
195

            
196
        let v = cl
197
            .data()
198
            .unwrap()
199
            .remove(&figment::Profile::Default)
200
            .unwrap();
201

            
202
        assert_eq!(v["a"], "3".into());
203
        assert_eq!(v["bcd"], "hello".into());
204
        assert_eq!(v["ef"], "gh i".into());
205
        assert_eq!(v["w"], vec![1, 2, 3].into());
206
    }
207

            
208
    #[test]
209
    fn parse_bad() {
210
        let mut cl = CmdLine::default();
211
        cl.push_toml_line("x=1 1 1 1 1".to_owned());
212
        let v = cl.data();
213
        assert!(v.is_err());
214
    }
215
}