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

            
3
use config::{ConfigError, Source, Value};
4
use once_cell::sync::Lazy;
5
use regex::Regex;
6
use std::collections::HashMap;
7

            
8
/// Alias for the Result type from config.
9
type Result<T> = std::result::Result<T, ConfigError>;
10

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

            
28
impl Default for CmdLine {
29
2
    fn default() -> Self {
30
2
        Self::new()
31
2
    }
32
}
33

            
34
impl CmdLine {
35
    /// Make a new empty command-line
36
7
    pub fn new() -> Self {
37
7
        CmdLine {
38
7
            name: "command line".to_string(),
39
7
            contents: Vec::new(),
40
7
        }
41
7
    }
42
    /// Add a single line of toml to the configuration.
43
10
    pub fn push_toml_line(&mut self, line: String) {
44
10
        self.contents.push(line);
45
10
    }
46
    /// Try to adjust the contents of a toml deserialization error so
47
    /// that instead it refers to a single command-line argument.
48
4
    fn convert_toml_error(&self, s: &str, pos: Option<(usize, usize)>) -> String {
49
4
        /// Regex to match an error message from the toml crate.
50
4
        static RE: Lazy<Regex> = Lazy::new(|| {
51
1
            Regex::new(r"^(.*?) at line [0-9]+ column [0-9]+$").expect("Can't compile regex")
52
1
        });
53
4
        let cap = RE.captures(s);
54
4
        let msg = match cap {
55
3
            Some(c) => c.get(1).expect("mismatch regex: no capture group").as_str(),
56
1
            None => s,
57
        };
58

            
59
4
        let location = match pos {
60
4
            Some((line, _col)) if line < self.contents.len() => {
61
3
                format!(" in {:?}", self.contents[line])
62
            }
63
1
            _ => " on command line".to_string(),
64
        };
65

            
66
4
        format!("{}{}", msg, location)
67
4
    }
68

            
69
    /// Compose elements of this cmdline into a single toml string.
70
6
    fn build_toml(&self) -> String {
71
6
        let mut toml_s = String::new();
72
13
        for line in &self.contents {
73
7
            toml_s.push_str(tweak_toml_bareword(line).as_ref().unwrap_or(line));
74
7
            toml_s.push('\n');
75
7
        }
76
6
        toml_s
77
6
    }
78
}
79

            
80
impl Source for CmdLine {
81
1
    fn clone_into_box(&self) -> Box<dyn Source + Send + Sync> {
82
1
        Box::new(self.clone())
83
1
    }
84

            
85
6
    fn collect(&self) -> Result<HashMap<String, Value>> {
86
6
        let toml_s = self.build_toml();
87
6
        let toml_v: toml::Value = match toml::from_str(&toml_s) {
88
1
            Err(e) => {
89
1
                return Err(ConfigError::Message(
90
1
                    self.convert_toml_error(&e.to_string(), e.line_col()),
91
1
                ))
92
            }
93
5
            Ok(v) => v,
94
5
        };
95
5

            
96
5
        toml_v
97
5
            .try_into()
98
5
            .map_err(|e| ConfigError::Foreign(Box::new(e)))
99
6
    }
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
14
fn tweak_toml_bareword(s: &str) -> Option<String> {
110
14
    /// Regex to match a keyword=bareword item.
111
14
    static RE: Lazy<Regex> = Lazy::new(|| {
112
1
        Regex::new(
113
1
            r#"(?x:
114
1
               ^
115
1
                [ \t]*
116
1
                # first capture group: dotted barewords
117
1
                ((?:[a-zA-Z0-9_\-]+\.)*
118
1
                 [a-zA-Z0-9_\-]+)
119
1
                [ \t]*=[ \t]*
120
1
                # second group: one bareword without hyphens
121
1
                ([a-zA-Z0-9_]+)
122
1
                [ \t]*
123
1
                $)"#,
124
1
        )
125
1
        .expect("Built-in regex compilation failed")
126
1
    });
127
14

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

            
131
#[cfg(test)]
132
mod test {
133
    #![allow(clippy::unwrap_used)]
134
    use super::*;
135
    #[test]
136
    fn bareword_expansion() {
137
        assert_eq!(tweak_toml_bareword("dsfklj"), None);
138
        assert_eq!(tweak_toml_bareword("=99"), None);
139
        assert_eq!(tweak_toml_bareword("=[1,2,3]"), None);
140
        assert_eq!(tweak_toml_bareword("a=b-c"), None);
141

            
142
        assert_eq!(tweak_toml_bareword("a=bc"), Some("a=\"bc\"".into()));
143
        assert_eq!(tweak_toml_bareword("a=b_c"), Some("a=\"b_c\"".into()));
144
        assert_eq!(
145
            tweak_toml_bareword("hello.there.now=a_greeting"),
146
            Some("hello.there.now=\"a_greeting\"".into())
147
        );
148
    }
149

            
150
    #[test]
151
    fn conv_toml_error() {
152
        let mut cl = CmdLine::new();
153
        cl.push_toml_line("Hello=world".to_string());
154
        cl.push_toml_line("Hola=mundo".to_string());
155
        cl.push_toml_line("Bonjour=monde".to_string());
156

            
157
        assert_eq!(
158
            &cl.convert_toml_error("Nice greeting at line 1 column 1", Some((0, 1))),
159
            "Nice greeting in \"Hello=world\""
160
        );
161

            
162
        assert_eq!(
163
            &cl.convert_toml_error("Nice greeting at line 1 column 1", Some((7, 1))),
164
            "Nice greeting on command line"
165
        );
166

            
167
        assert_eq!(
168
            &cl.convert_toml_error("Nice greeting with a thing", Some((0, 1))),
169
            "Nice greeting with a thing in \"Hello=world\""
170
        );
171
    }
172

            
173
    #[test]
174
    fn clone_into_box() {
175
        let mut cl = CmdLine::new();
176
        cl.push_toml_line("Molo=Lizwe".to_owned());
177
        let cl2 = cl.clone_into_box();
178

            
179
        let v = cl2.collect().unwrap();
180
        assert_eq!(v["Molo"], "Lizwe".into());
181
    }
182

            
183
    #[test]
184
    fn parse_good() {
185
        let mut cl = CmdLine::default();
186
        cl.push_toml_line("a=3".to_string());
187
        cl.push_toml_line("bcd=hello".to_string());
188
        cl.push_toml_line("ef=\"gh i\"".to_string());
189
        cl.push_toml_line("w=[1,2,3]".to_string());
190

            
191
        let v = cl.collect().unwrap();
192
        assert_eq!(v["a"], "3".into());
193
        assert_eq!(v["bcd"], "hello".into());
194
        assert_eq!(v["ef"], "gh i".into());
195
        assert_eq!(v["w"], vec![1, 2, 3].into());
196
    }
197

            
198
    #[test]
199
    fn parse_bad() {
200
        let mut cl = CmdLine::default();
201
        cl.push_toml_line("x=1 1 1 1 1".to_owned());
202
        let v = cl.collect();
203
        assert!(v.is_err());
204
    }
205
}