clightningrpc_conf/
lib.rs

1//! Core lightning configuration manager written in rust.
2use indexmap::IndexMap;
3use std::sync::Arc;
4use std::{fmt, io};
5
6mod file;
7mod parser;
8
9use file::{File, SyncFile};
10
11#[derive(Debug)]
12pub struct ParsingError {
13    pub core: u64,
14    pub cause: String,
15}
16
17impl From<io::Error> for ParsingError {
18    fn from(value: io::Error) -> Self {
19        ParsingError {
20            core: 1,
21            cause: format!("{value}"),
22        }
23    }
24}
25
26impl std::fmt::Display for ParsingError {
27    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
28        write!(f, "{}", self.cause)
29    }
30}
31
32pub trait SyncCLNConf {
33    fn parse(&mut self) -> Result<(), ParsingError>;
34}
35
36/// core lightning configuration manager
37/// that help to parser and create a core
38/// lightning configuration with rust.
39#[derive(Debug, Clone)]
40pub struct CLNConf {
41    /// collection of field included
42    /// inside the conf file.
43    ///
44    /// `plugin=path/to/bin` is parser as
45    /// `key=value`.
46    pub fields: IndexMap<String, Vec<String>>,
47    /// other conf file included.
48    pub includes: Vec<Arc<CLNConf>>,
49    pub path: String,
50    create_if_missing: bool,
51}
52
53impl CLNConf {
54    /// create a new instance of the configuration
55    /// file manager.
56    pub fn new(path: String, create_if_missing: bool) -> Self {
57        CLNConf {
58            fields: IndexMap::new(),
59            includes: Vec::new(),
60            path,
61            create_if_missing,
62        }
63    }
64
65    /// build a new instance of the parser.
66    pub fn parser(&self) -> parser::Parser {
67        parser::Parser::new(&self.path, self.create_if_missing)
68    }
69
70    pub fn add_conf(&mut self, key: &str, val: &str) -> Result<(), ParsingError> {
71        if self.fields.contains_key(key) {
72            let values = self.fields.get_mut(key).unwrap();
73            for value in values.iter() {
74                if val == value {
75                    return Err(ParsingError {
76                        core: 2,
77                        cause: format!("field {key} with value {val} already present"),
78                    });
79                }
80            }
81            values.push(val.to_owned());
82        } else {
83            self.fields.insert(key.to_owned(), vec![val.to_owned()]);
84        }
85        Ok(())
86    }
87
88    /// Get a unique field with the specified key, if there are multiple definition
89    /// the function return an error.
90    ///
91    /// In the case of multiple definition of the same key you would like to use `get_confs`.
92    pub fn get_conf(&self, key: &str) -> Result<Option<String>, ParsingError> {
93        let mut results = vec![];
94        if let Some(fields) = self.fields.get(key) {
95            results.append(&mut fields.clone());
96        }
97        for include in &self.includes {
98            let fields = include.get_confs(key);
99            if !fields.is_empty() {
100                results.append(&mut fields.clone());
101            }
102        }
103        if results.is_empty() {
104            return Ok(None);
105        }
106
107        if results.len() > 1 {
108            return Err(ParsingError {
109                core: 1,
110                cause: "mutiple field with the `{key}`".to_owned(),
111            });
112        }
113        Ok(Some(results.first().unwrap().clone()))
114    }
115
116    /// Return a list of values with the specified key, if no
117    /// item is found, return an empity vector.
118    pub fn get_confs(&self, key: &str) -> Vec<String> {
119        let mut results = vec![];
120        if let Some(fields) = self.fields.get(key) {
121            results.append(&mut fields.clone());
122        }
123        for include in &self.includes {
124            let fields = include.get_confs(key);
125            if !fields.is_empty() {
126                results.append(&mut fields.clone());
127            }
128        }
129        results
130    }
131
132    pub fn add_subconf(&mut self, conf: CLNConf) -> Result<(), ParsingError> {
133        for subconf in &self.includes {
134            if conf.path == subconf.path {
135                return Err(ParsingError {
136                    core: 2,
137                    cause: format!("duplicate include {}", conf.path),
138                });
139            }
140        }
141        self.includes.push(conf.into());
142        Ok(())
143    }
144
145    pub fn rm_conf(&mut self, key: &str, val: Option<&str>) -> Result<(), ParsingError> {
146        if self.fields.contains_key(key) {
147            match val {
148                Some(val) => {
149                    let values = self.fields.get_mut(key).unwrap();
150                    if let Some(index) = values.iter().position(|x| x == val) {
151                        values.remove(index);
152                    } else {
153                        return Err(ParsingError {
154                            core: 2,
155                            cause: format!("field {key} with value {val} not found"),
156                        });
157                    }
158                }
159                None => {
160                    self.fields.remove_entry(key);
161                }
162            }
163        } else {
164            return Err(ParsingError {
165                core: 2,
166                cause: format!("field with `{key}` not present"),
167            });
168        }
169        Ok(())
170    }
171
172    pub fn flush(&self) -> Result<(), std::io::Error> {
173        let content = format!("{self}");
174        let file = File::new(&self.path);
175        file.write(&content)?;
176        Ok(())
177    }
178}
179
180impl SyncCLNConf for CLNConf {
181    fn parse(&mut self) -> Result<(), ParsingError> {
182        let parser = self.parser();
183        parser.parse(self)?;
184        Ok(())
185    }
186}
187
188impl fmt::Display for CLNConf {
189    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
190        let mut content = String::new();
191        for field in self.fields.keys() {
192            let values = self.fields.get(field).unwrap();
193            if field.starts_with("comment") {
194                let value = values.first().unwrap().as_str();
195                content += &format!("{value}\n");
196                continue;
197            }
198            for value in values {
199                if value.is_empty() {
200                    content += format!("{field}\n").as_str();
201                    continue;
202                }
203                content += format!("{field}={value}\n").as_str();
204            }
205        }
206
207        for include in &self.includes {
208            content += format!("include {}\n", include.path).as_str();
209        }
210
211        writeln!(f, "{content}")
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use std::env;
218    use std::fs::{remove_file, File};
219    use std::io::Write;
220    use std::time::{SystemTime, UNIX_EPOCH};
221
222    use crate::{CLNConf, SyncCLNConf};
223
224    fn get_conf_path() -> String {
225        let binding = env::temp_dir();
226        let dir = binding.as_os_str().to_str().unwrap();
227        let nanos = SystemTime::now()
228            .duration_since(UNIX_EPOCH)
229            .unwrap()
230            .subsec_nanos();
231        format!("{dir}/conf-{nanos}")
232    }
233
234    fn build_file(content: &str) -> Result<String, std::io::Error> {
235        let conf = get_conf_path();
236        let mut file = File::create(conf.clone())?;
237        write!(file, "{content}")?;
238        Ok(conf)
239    }
240
241    fn cleanup_file(path: &str) {
242        remove_file(path).unwrap();
243    }
244
245    #[test]
246    fn parsing_key_value_one() {
247        let path = build_file("plugin=foo\nnetwork=bitcoin");
248        assert!(path.is_ok());
249        let path = path.unwrap();
250        let mut conf = CLNConf::new(path.to_string(), false);
251        let result = conf.parse();
252        assert!(result.is_ok());
253        assert_eq!(conf.fields.keys().len(), 2);
254
255        assert!(conf.fields.contains_key("plugin"));
256        assert!(conf.fields.contains_key("network"));
257
258        cleanup_file(path.as_str());
259    }
260
261    #[test]
262    fn flush_conf_one() {
263        let path = get_conf_path();
264        let mut conf = CLNConf::new(path.to_string(), false);
265        conf.add_conf("plugin", "/some/path");
266        conf.add_conf("network", "bitcoin");
267        let result = conf.flush();
268        assert!(result.is_ok());
269
270        let mut conf = CLNConf::new(path.to_string(), false);
271        let result = conf.parse();
272        assert!(result.is_ok());
273        assert_eq!(conf.fields.keys().len(), 2);
274        println!("{conf:?}");
275        assert!(conf.fields.contains_key("plugin"));
276        assert!(conf.fields.contains_key("network"));
277
278        cleanup_file(path.as_str());
279    }
280
281    #[test]
282    fn flush_conf_two() {
283        let path = get_conf_path();
284        let mut conf = CLNConf::new(path.to_string(), false);
285        conf.add_conf("plugin", "/some/path");
286        conf.add_conf("plugin", "foo");
287        conf.add_conf("network", "bitcoin");
288        let result = conf.flush();
289        assert!(result.is_ok());
290
291        let mut conf = CLNConf::new(path.to_string(), false);
292        let result = conf.parse();
293        assert!(result.is_ok());
294        assert_eq!(conf.fields.get("plugin").unwrap().len(), 2);
295        println!("{conf:?}");
296        assert!(conf.fields.contains_key("plugin"));
297        assert!(conf.fields.contains_key("network"));
298
299        cleanup_file(path.as_str());
300    }
301
302    #[test]
303    fn flush_conf_three() {
304        let path = get_conf_path();
305        let mut conf = CLNConf::new(path.to_string(), false);
306        conf.add_conf("network", "bitcoin");
307        conf.add_conf("plugin", "/some/path");
308        conf.add_conf("plugin", "/some/other/path");
309        conf.rm_conf("plugin", None);
310        let result = conf.flush();
311        assert!(result.is_ok());
312
313        let mut conf = CLNConf::new(path.to_string(), false);
314        let result = conf.parse();
315        assert!(result.is_ok());
316        assert_eq!(conf.fields.keys().len(), 1);
317        println!("{conf:?}");
318        assert!(!conf.fields.contains_key("plugin"));
319        assert!(conf.fields.contains_key("network"));
320
321        cleanup_file(path.as_str());
322    }
323
324    #[test]
325    fn flush_conf_four() {
326        let path = get_conf_path();
327        let mut conf = CLNConf::new(path.to_string(), false);
328        conf.add_conf("network", "bitcoin");
329        conf.add_conf("plugin", "/some/path");
330        conf.add_conf("plugin", "/some/other/path");
331        conf.rm_conf("plugin", Some("/some/other/path"));
332        let result = conf.flush();
333        assert!(result.is_ok());
334
335        let mut conf = CLNConf::new(path.to_string(), false);
336        let result = conf.parse();
337        assert!(result.is_ok());
338        assert_eq!(conf.fields.keys().len(), 2);
339        println!("{conf:?}");
340        assert!(conf
341            .fields
342            .get("plugin")
343            .as_ref()
344            .map(|&s| s.contains(&"/some/path".to_string()))
345            .unwrap_or(false));
346        assert!(!conf
347            .fields
348            .get("plugin")
349            .as_ref()
350            .map(|&s| s.contains(&"/some/other/path".to_string()))
351            .unwrap_or(false));
352        assert!(conf.fields.contains_key("network"));
353
354        cleanup_file(path.as_str());
355    }
356
357    #[test]
358    fn flush_conf_with_comments() {
359        let path = build_file("# this is just a commit\nplugin=foo\nnetwork=bitcoin");
360        assert!(path.is_ok());
361        let path = path.unwrap();
362        let mut conf = CLNConf::new(path.to_string(), false);
363        let result = conf.parse();
364        assert!(result.is_ok());
365        // subtract the comment item
366        assert_eq!(conf.fields.keys().len() - 1, 2);
367
368        assert!(conf.fields.contains_key("plugin"));
369        assert!(conf.fields.contains_key("network"));
370
371        cleanup_file(path.as_str());
372    }
373
374    #[test]
375    fn flush_conf_with_includes() {
376        let subpath = get_conf_path();
377        let conf = CLNConf::new(subpath.clone(), false);
378        assert!(conf.flush().is_ok());
379
380        let path = build_file(
381            format!("# this is just a commit\nplugin=foo\nnetwork=bitcoin\ninclude {subpath}")
382                .as_str(),
383        );
384        assert!(path.is_ok(), "{}", format!("{path:?}"));
385        let path = path.unwrap();
386        let mut conf = CLNConf::new(path.to_string(), false);
387        let result = conf.parse();
388        assert!(result.is_ok(), "{}", result.unwrap_err().cause);
389        // subtract the comment item
390        assert_eq!(conf.fields.keys().len() - 1, 2);
391
392        assert!(conf.fields.contains_key("plugin"));
393        assert!(conf.fields.contains_key("network"));
394
395        cleanup_file(path.as_str());
396    }
397}