ini_rs/
lib.rs

1use std::fs::{OpenOptions};
2use std::io::{self, Write};
3use std::collections::BTreeMap;
4use std::env::consts::OS;
5use std::fmt;
6use std::path::Path;
7extern crate read_lines_with_blank;
8use read_lines_with_blank::{read_lines_with_blank, read_lines_with_blank_from_str};
9
10/// Load INI files into a structured BTreeMap, then edit them.
11/// Can also create new INI files.
12/// You can access the data directly via config_map, or use the provided functions.
13/// This only works on Windows and Linux
14pub struct Ini {
15    pub config_map: BTreeMap<String, BTreeMap<String, String>>,
16    pub config_file: String,
17}
18
19const CONFIG_SECTION_START: &str = "[";
20const CONFIG_SECTION_END: &str = "]";
21const CONFIG_KVP_SPLIT: &str = "=";
22const CONFIG_COMMENT_HASH: &str = "#";
23const CONFIG_COMMENT_SEMI: &str = ";";
24
25const NEW_LINE_WINDOWS: &str = "\r\n";
26const NEW_LINE_LINUX: &str = "\n";
27
28impl Ini {
29    /// Load in an INI file and return its structure.
30    /// If the file doesn't exist, then returns empty structure.
31    pub fn new(location: String) -> Result<Ini, io::Error> {
32        let mut ret = Ini{ config_map: BTreeMap::new(), config_file: location.clone() };
33
34        if !Path::new(&location).exists() {
35            return Ok(ret);
36        }
37        
38
39        let lines = match read_lines_with_blank(&location) {
40            Ok(x) => x,
41            Err(_) => return Err(io::Error::new(io::ErrorKind::Other, "Failed to read file"))
42        };
43
44        ret = match Self::build_struct(lines) {
45            Ok(x) => x,
46            Err(e) => return Err(e)
47        };
48
49        ret.config_file = location;
50        Ok(ret)
51    }
52
53    /// Create ini structure from a string. Does not set the config_file so save doesn't work unless set manually.
54    pub fn from_string(str: String) -> Result<Ini, io::Error> {
55        let lines = match read_lines_with_blank_from_str(&str) {
56            Ok(x) => x,
57            Err(_) => return Err(io::Error::new(io::ErrorKind::Other, "Failed to read file"))
58        };
59
60        Self::build_struct(lines)
61    }
62
63    /// Build the struct given a set of lines
64    /// Will need the file location added
65    fn build_struct(lines: Vec<String>) -> Result<Ini, io::Error> {
66        let mut in_section = false;
67        let mut cur_sec: String = String::from("");
68        let mut ret = Ini{ config_map: BTreeMap::new(), config_file: "".to_string() };
69
70        for line in lines {
71            if line.starts_with(CONFIG_COMMENT_HASH) || line.starts_with(CONFIG_COMMENT_SEMI) {
72                continue;
73            }
74            if line.len() == 0 {
75                continue;
76            }
77
78            // Section found
79            if line.starts_with(CONFIG_SECTION_START) && line.contains(CONFIG_SECTION_END) {
80                cur_sec = line.replace(CONFIG_SECTION_START, "").replace(CONFIG_SECTION_END, "").trim().to_string();
81                ret.config_map.insert(cur_sec.clone(), BTreeMap::new());
82                in_section = true;
83                continue;
84            }
85            // KVP found
86            else if line.contains(CONFIG_KVP_SPLIT) {
87                if !in_section {
88                    return Err(io::Error::new(io::ErrorKind::InvalidData, "Config file was invalid, KVP entry found before section."));
89                }
90
91                let kvp = match line.split_once(CONFIG_KVP_SPLIT) {
92                    Some(x) => x,
93                    None => return Err(io::Error::new(io::ErrorKind::InvalidData, "Config file was invalid, KVP entry couldn't be split.")),
94                };
95
96                ret.config_map.get_mut(&cur_sec).unwrap().insert(kvp.0.to_string(), kvp.1.to_string());
97
98                continue;
99            }
100            else {
101                return Err(io::Error::new(io::ErrorKind::InvalidData, "Config file was invalid, line didn't hit any requirement"));
102            }
103        }
104        Ok(ret)
105    }
106
107    /// Dump out the INI file to a string, returns blank string if no data is present
108    pub fn to_string(&self) -> Result<String, io::Error> {
109        let new_line = match OS {
110            "linux" => NEW_LINE_LINUX,
111            "windows" => NEW_LINE_WINDOWS,
112            _ => return Err(io::Error::new(io::ErrorKind::Unsupported, "Unsupported OS"))
113        };
114
115        let mut ret: String = String::new();
116
117        if self.config_map.is_empty() { return Ok(ret) }
118
119        for (section_k, section_v) in &self.config_map {
120            ret.push_str(CONFIG_SECTION_START);
121            ret.push_str(section_k);
122            ret.push_str(CONFIG_SECTION_END);
123            ret.push_str(new_line);
124
125            for (k,v) in section_v {
126                ret.push_str(k);
127                ret.push_str(CONFIG_KVP_SPLIT);
128                ret.push_str(v);
129                ret.push_str(new_line);
130            }
131        }
132
133        Ok(ret)
134    }
135
136    /// Save an INI file after being edited.
137    /// Only functions correctly on Windows and Linux.
138    /// Ok will contain the size in bytes of the file after writing.
139    /// All comments in the INI file will be lost by doing this.
140    pub fn save(&self) -> Result<usize, io::Error> {
141        if self.config_file.is_empty() {
142            return Err(io::Error::new(io::ErrorKind::Other, "config_file is not set. This is likely because this was created using from_string()"))
143        }
144
145        let mut file = OpenOptions::new().create(true).write(true).truncate(true).open(&self.config_file)?;
146        let str = match self.to_string() {
147            Ok(x) => x,
148            Err(e) => return Err(e)
149        };
150
151        file.write_all(str.as_bytes())?;
152        file.flush()?;
153        file.sync_all()?;
154        
155        Ok(file.metadata()?.len() as usize)
156    }
157    
158
159    /// Get a value from the INI file.
160    pub fn get(&self, section: &str, key: &str) -> Option<String> {
161        if let Some(section_map) = self.config_map.get(section) {
162            if let Some(value) = section_map.get(key) {
163                return Some(value.clone().trim_start().to_string());
164            }
165        }
166        None
167    }
168
169    /// Set a value in the INI file.
170    /// If the section doesn't exist, it will be created.
171    /// If the key doesn't exist, it will be created.
172    /// This will not save the file.
173    pub fn set(&mut self, section: &str, key: &str, value: &str) {
174        let section_map = self.config_map.entry(section.to_string()).or_insert(BTreeMap::new());
175        section_map.insert(key.to_string(), value.to_string());
176    }
177
178    /// Remove a key from the INI file.
179    /// If the section doesn't exist, it will be created.
180    /// If the key doesn't exist, it will be created.
181    /// This will not save the file.
182    pub fn remove(&mut self, section: &str, key: &str) {
183        if let Some(section_map) = self.config_map.get_mut(section) {
184            section_map.remove(key);
185        }
186    }
187
188    /// Remove a section from the INI file.
189    /// This will not save the file.
190    pub fn remove_section(&mut self, section: &str) {
191        self.config_map.remove(section);
192    }   
193}
194
195/// Display trait. Returns the string dump of INI data
196impl fmt::Display for Ini {
197    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
198        let ret = self.to_string().unwrap();
199        write!(f, "{}", ret)
200    }
201}
202
203#[cfg(test)]
204mod tests {
205    use std::fs::{self, File};
206    use std::io::Read;
207    use crate::Ini;
208
209    const INI: &str = "test.ini";
210    const NEW_INI: &str = "test1.ini";
211
212    #[test]
213    fn test_create_struct() {
214        let text = get_text();
215        let str = Ini::from_string(text).unwrap();
216        let file = Ini::new(INI.to_string()).unwrap();
217        assert_eq!(file.config_map, str.config_map);
218    }
219
220    fn get_text() -> String {
221        let mut file = File::open(INI.to_string()).unwrap();
222        let mut contents = String::new();
223        file.read_to_string(&mut contents).unwrap();
224        if contents.len() == 0 {
225            panic!("No config file found");
226        }
227        contents
228    }
229
230    /// The text may not be the same order, but we can make sure it produced something and manually compare
231    #[test]
232    fn test_to_string() {
233        let file = Ini::new(INI.to_string()).unwrap();
234        let text = get_text();
235        println!("COMPARE BELOW\n");
236        println!("Raw text\n{}\n", text);
237        println!("To string\n{}", file.to_string().unwrap());
238        assert_ne!(file.to_string().unwrap().len(), 0);
239    }
240
241    #[test]
242    fn test_save() {
243        let mut file = Ini::new(INI.to_string()).unwrap();
244        file.config_file = NEW_INI.to_string();
245        file.save().unwrap();
246        let exists = fs::exists(NEW_INI.to_string()).unwrap();
247        _ = fs::remove_file(NEW_INI.to_string());
248        assert_eq!(exists, true);
249    }
250
251    #[test]
252    fn test_remove_section_get() {
253        let mut ini = Ini::new(INI.to_string()).unwrap();
254        ini.remove_section("General");
255        assert_eq!(ini.get("General", "app_name"), None)
256    }
257
258    #[test]
259    fn test_set_get() {
260        let mut ini = Ini::new(INI.to_string()).unwrap();
261        ini.set("General", "app_name", "app");
262        assert_eq!(ini.get("General", "app_name").unwrap(), "app".to_string());
263    }
264
265    #[test]
266    fn test_remove_get() {
267        let mut ini = Ini::new(INI.to_string()).unwrap();
268        ini.remove("General", "app_name");
269        assert_eq!(ini.get("General", "app_name"), None);
270    }
271}