config_file2/
lib.rs

1#![doc = include_str!("../README.md")]
2#![warn(clippy::nursery, clippy::cargo, clippy::pedantic)]
3#[allow(clippy::module_name_repetitions)]
4pub mod error;
5use std::{
6    ffi::OsStr,
7    fmt::Debug,
8    fs::{File, OpenOptions},
9    io::Write,
10    path::Path,
11};
12
13use error::Error;
14pub use error::Result;
15use serde::{de::DeserializeOwned, Serialize};
16#[cfg(feature = "toml")]
17use {error::TomlError, toml_crate as toml};
18#[cfg(feature = "xml")]
19use {error::XmlError, std::io::BufReader};
20
21/// Format of configuration file.
22#[derive(Debug, Clone, Copy)]
23pub enum ConfigFormat {
24    Json,
25    Toml,
26    Xml,
27    Yaml,
28    Ron,
29}
30
31impl ConfigFormat {
32    /// Get the [`ConfigType`] from a file extension
33    #[must_use]
34    pub fn from_extension(extension: &str) -> Option<Self> {
35        match extension.to_lowercase().as_str() {
36            #[cfg(feature = "json")]
37            "json" => Some(Self::Json),
38            #[cfg(feature = "toml")]
39            "toml" => Some(Self::Toml),
40            #[cfg(feature = "xml")]
41            "xml" => Some(Self::Xml),
42            #[cfg(feature = "yaml")]
43            "yaml" | "yml" => Some(Self::Yaml),
44            #[cfg(feature = "ron")]
45            "ron" => Some(Self::Ron),
46            _ => None,
47        }
48    }
49
50    /// Get the [`ConfigType`] from a path
51    pub fn from_path(path: &Path) -> Option<Self> {
52        Self::from_extension(path.extension().and_then(OsStr::to_str)?)
53    }
54}
55
56/// Trait for loading a struct from a configuration file.
57/// This trait is automatically implemented when [`serde::Deserialize`] is.
58pub trait LoadConfigFile {
59    /// Load config from path with specific format, *do not use extension to
60    /// determine*.
61    ///
62    /// # Returns
63    ///
64    /// - Returns `Ok(Some(config))` if the file exists.
65    /// - Returns `Ok(None)` if the file does not exist.
66    ///
67    /// # Errors
68    ///
69    /// - Returns [`Error::FileAccess`] if the file cannot be read.
70    /// - Returns `Error::<Format>` if deserialization from file fails.
71    fn load_with_specific_format(
72        path: impl AsRef<Path>,
73        config_type: ConfigFormat,
74    ) -> Result<Option<Self>>
75    where
76        Self: Sized;
77
78    /// Load config from path.
79    ///
80    /// # Returns
81    ///
82    /// - Returns `Ok(Some(config))` if the file exists.
83    /// - Returns `Ok(None)` if the file does not exist.
84    ///
85    /// # Errors
86    ///
87    /// - Returns [`Error::FileAccess`] if the file cannot be read.
88    /// - Returns [`Error::UnsupportedFormat`] if the file extension is not
89    ///   supported.
90    /// - Returns `Error::<Format>` if deserialization from file fails.
91    fn load(path: impl AsRef<Path>) -> Result<Option<Self>>
92    where
93        Self: Sized,
94    {
95        let path = path.as_ref();
96        let config_type = ConfigFormat::from_path(path).ok_or(Error::UnsupportedFormat)?;
97        Self::load_with_specific_format(path, config_type)
98    }
99
100    /// Load config from path, if not found, use default instead
101    ///
102    /// # Returns
103    ///
104    /// - Returns the config loaded from file if the file exists, or default
105    ///   value if the file does not exist.
106    ///
107    /// # Errors
108    ///
109    /// - Returns [`Error::FileAccess`] if the file cannot be read by Permission
110    ///   denied or other failures.
111    /// - Returns [`Error::UnsupportedFormat`] if the file extension is not
112    ///   supported.
113    /// - Returns `Error::<Format>` if deserialization from file fails.
114    fn load_or_default(path: impl AsRef<Path>) -> Result<Self>
115    where
116        Self: Sized + Default,
117    {
118        Self::load(path).map(std::option::Option::unwrap_or_default)
119    }
120}
121
122macro_rules! not_found_to_none {
123    ($input:expr) => {
124        match $input {
125            Ok(config) => Ok(Some(config)),
126            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
127            Err(e) => Err(e),
128        }
129    };
130}
131
132impl<C: DeserializeOwned> LoadConfigFile for C {
133    fn load_with_specific_format(
134        path: impl AsRef<Path>,
135        config_type: ConfigFormat,
136    ) -> Result<Option<Self>>
137    where
138        Self: Sized,
139    {
140        let path = path.as_ref();
141
142        match config_type {
143            #[cfg(feature = "json")]
144            ConfigFormat::Json => Ok(not_found_to_none!(open_file(path))?
145                .map(|x| serde_json::from_reader(x))
146                .transpose()?),
147            #[cfg(feature = "toml")]
148            ConfigFormat::Toml => Ok(not_found_to_none!(std::fs::read_to_string(path))?
149                .map(|x| toml::from_str(x.as_str()))
150                .transpose()
151                .map_err(TomlError::DeserializationError)?),
152            #[cfg(feature = "xml")]
153            ConfigFormat::Xml => Ok(not_found_to_none!(open_file(path))?
154                .map(|x| quick_xml::de::from_reader(BufReader::new(x)))
155                .transpose()
156                .map_err(XmlError::DeserializationError)?),
157            #[cfg(feature = "yaml")]
158            ConfigFormat::Yaml => Ok(not_found_to_none!(open_file(path))?
159                .map(|x| serde_yml::from_reader(x))
160                .transpose()?),
161            #[cfg(feature = "ron")]
162            ConfigFormat::Ron => Ok(not_found_to_none!(open_file(path))?
163                .map(|x| ron_crate::de::from_reader(x))
164                .transpose()
165                .map_err(Into::<ron_crate::Error>::into)?),
166            #[allow(unreachable_patterns)]
167            _ => Err(Error::UnsupportedFormat),
168        }
169    }
170}
171
172/// Trait for storing a struct into a configuration file.
173/// This trait is automatically implemented when [`serde::Serialize`] is.
174pub trait StoreConfigFile {
175    /// Store config file to path with specific format, do not use extension to
176    /// determine. If the file already exists, the config file
177    /// will be overwritten.
178    ///
179    /// # Errors
180    ///
181    /// - Returns [`Error::FileAccess`] if the file cannot be written.
182    /// - Returns [`Error::UnsupportedFormat`] if the file extension is not
183    ///   supported.
184    /// - Returns `Error::<Format>` if serialization to file fails.
185    fn store_with_specific_format(
186        &self,
187        path: impl AsRef<Path>,
188        config_type: ConfigFormat,
189    ) -> Result<()>;
190
191    /// Store config file to path. If the file already exists, the config file
192    /// will be overwritten.
193    ///
194    /// # Errors
195    ///
196    /// - Returns [`Error::UnsupportedFormat`] if the file extension is not
197    ///   supported.
198    /// - Returns `Error::<Format>` if serialization to file fails.
199    fn store(&self, path: impl AsRef<Path>) -> Result<()>
200    where
201        Self: Sized,
202    {
203        let path = path.as_ref();
204        let config_type = ConfigFormat::from_path(path).ok_or(Error::UnsupportedFormat)?;
205        self.store_with_specific_format(path, config_type)
206    }
207    /// Store config file to path, if path exists, return error
208    ///
209    /// # Errors
210    ///
211    /// - Returns [`Error::FileExists`] if the file already exists.
212    /// - Returns [`Error::UnsupportedFormat`] if the file extension is not
213    ///   supported.
214    /// - Returns `Error::<Format>` if serialization to file fails.
215    fn store_without_overwrite(&self, path: impl AsRef<Path>) -> Result<()>
216    where
217        Self: Sized,
218    {
219        if path.as_ref().exists() {
220            return Err(Error::FileExists);
221        }
222        self.store(path)
223    }
224}
225
226impl<C: Serialize> StoreConfigFile for C {
227    fn store_with_specific_format(
228        &self,
229        path: impl AsRef<Path>,
230        config_type: ConfigFormat,
231    ) -> Result<()> {
232        let path = path.as_ref();
233        match config_type {
234            #[cfg(feature = "json")]
235            ConfigFormat::Json => {
236                serde_json::to_writer_pretty(open_write_file(path)?, &self).map_err(Error::Json)
237            }
238            #[cfg(feature = "toml")]
239            ConfigFormat::Toml => {
240                open_write_file(path)?.write_all(
241                    toml::to_string_pretty(&self)
242                        .map_err(TomlError::SerializationError)?
243                        .as_bytes(),
244                )?;
245                Ok(())
246            }
247            #[cfg(feature = "xml")]
248            ConfigFormat::Xml => Ok(std::fs::write(
249                path,
250                quick_xml::se::to_string(&self).map_err(XmlError::SerializationError)?,
251            )?),
252            #[cfg(feature = "yaml")]
253            ConfigFormat::Yaml => {
254                serde_yml::to_writer(open_write_file(path)?, &self).map_err(Error::Yaml)
255            }
256            #[cfg(feature = "ron")]
257            ConfigFormat::Ron => {
258                open_write_file(path)?.write_all(
259                    ron_crate::ser::to_string_pretty(
260                        &self,
261                        ron_crate::ser::PrettyConfig::default(),
262                    )?
263                    .as_bytes(),
264                )?;
265                Ok(())
266            }
267            #[allow(unreachable_patterns)]
268            _ => Err(Error::UnsupportedFormat),
269        }
270    }
271}
272
273/// A more easy way to store a struct into a configuration file.
274///
275/// Just impl `Storable::path(&self) -> &Path;` to your struct, and then you can
276/// use `store_with_specific_format`, `store`, `store_without_overwrite`
277/// directly by calling the method on your struct.
278pub trait Storable: Serialize + Sized {
279    /// impl by struct.
280    fn path(&self) -> impl AsRef<Path>;
281
282    /// Store config file to path with specific format, do not use extension to
283    /// determine. If the file already exists, the config file
284    /// will be overwritten.
285    ///
286    /// # Errors
287    ///
288    /// - Returns [`Error::FileAccess`] if the file cannot be written.
289    /// - Returns [`Error::UnsupportedFormat`] if the file extension is not
290    ///   supported.
291    /// - Returns `Error::<Format>` if serialization to file fails.
292    fn store_with_specific_format(&self, config_type: ConfigFormat) -> Result<()> {
293        StoreConfigFile::store_with_specific_format(self, self.path().as_ref(), config_type)
294    }
295
296    /// Store config file to path. If the file already exists, the config file
297    /// will be overwritten.
298    ///
299    /// # Errors
300    ///
301    /// - Returns [`Error::UnsupportedFormat`] if the file extension is not
302    ///   supported.
303    /// - Returns `Error::<Format>` if serialization to file fails.
304    fn store(&self) -> Result<()> {
305        StoreConfigFile::store(self, self.path())
306    }
307    /// Store config file to path, if path exists, return error
308    ///
309    /// # Errors
310    ///
311    /// - Returns [`Error::FileExists`] if the file already exists.
312    /// - Returns [`Error::UnsupportedFormat`] if the file extension is not
313    ///   supported.
314    /// - Returns `Error::<Format>` if serialization to file fails.
315    fn store_without_overwrite(&self) -> Result<()> {
316        StoreConfigFile::store_without_overwrite(self, self.path())
317    }
318}
319
320/// Open a file in read-only mode
321#[allow(unused)]
322fn open_file(path: &Path) -> std::io::Result<File> {
323    File::open(path)
324}
325
326/// Open a file in write mode
327#[allow(unused)]
328fn open_write_file(path: &Path) -> Result<File> {
329    if let Some(parent) = path.parent() {
330        std::fs::create_dir_all(parent)?;
331    }
332    OpenOptions::new()
333        .write(true)
334        .create(true)
335        .truncate(true)
336        .open(path)
337        .map_err(Error::FileAccess)
338}
339
340#[cfg(test)]
341mod test {
342
343    use serde::Deserialize;
344    use tempfile::TempDir;
345
346    use super::*;
347
348    #[derive(Debug, Serialize, Deserialize, PartialEq, Default, Eq)]
349    struct TestConfig {
350        host: String,
351        port: u64,
352        tags: Vec<String>,
353        inner: TestConfigInner,
354    }
355
356    #[derive(Debug, Serialize, Deserialize, PartialEq, Default, Eq)]
357    struct TestConfigInner {
358        answer: u8,
359    }
360
361    impl TestConfig {
362        #[allow(unused)]
363        fn example() -> Self {
364            Self {
365                host: "example.com".to_string(),
366                port: 443,
367                tags: vec!["example".to_string(), "test".to_string()],
368                inner: TestConfigInner { answer: 42 },
369            }
370        }
371    }
372
373    fn test_read_with_extension(extension: &str) {
374        let config = TestConfig::load(format!("testdata/config.{extension}"));
375        assert_eq!(config.unwrap().unwrap(), TestConfig::example());
376    }
377
378    fn test_write_with_extension(extension: &str) {
379        let tempdir = TempDir::new().unwrap();
380        let mut temp = tempdir.path().join("config");
381        temp.set_extension(extension);
382        TestConfig::example().store(dbg!(&temp)).unwrap();
383        assert!(temp.is_file());
384        dbg!(std::fs::read_to_string(&temp).unwrap());
385        assert_eq!(
386            TestConfig::example(),
387            TestConfig::load(&temp).unwrap().unwrap()
388        );
389    }
390
391    #[test]
392    fn test_unknown() {
393        let config = TestConfig::load("/tmp/foobar");
394        assert!(matches!(config, Err(Error::UnsupportedFormat)));
395    }
396
397    #[test]
398    #[cfg(feature = "toml")]
399    fn test_file_not_found() {
400        let config = TestConfig::load("/tmp/foobar.toml");
401        assert!(config.unwrap().is_none());
402    }
403
404    #[test]
405    #[cfg(feature = "json")]
406    fn test_json() {
407        test_read_with_extension("json");
408        test_write_with_extension("json");
409    }
410
411    #[test]
412    #[cfg(feature = "toml")]
413    fn test_toml() {
414        test_read_with_extension("toml");
415        test_write_with_extension("toml");
416    }
417
418    #[test]
419    #[cfg(feature = "xml")]
420    fn test_xml() {
421        test_read_with_extension("xml");
422        test_write_with_extension("xml");
423    }
424
425    #[test]
426    #[cfg(feature = "yaml")]
427    fn test_yaml() {
428        test_read_with_extension("yml");
429        test_write_with_extension("yaml");
430    }
431
432    #[test]
433    #[cfg(feature = "ron")]
434    fn test_ron() {
435        test_read_with_extension("ron");
436        test_write_with_extension("ron");
437    }
438
439    #[test]
440    #[cfg(feature = "toml")]
441    fn test_store_without_overwrite() {
442        let tempdir = TempDir::new().unwrap();
443        let temp = tempdir.path().join("test_store_without_overwrite.toml");
444        std::fs::File::create(&temp).unwrap();
445        assert!(TestConfig::example()
446            .store_without_overwrite(dbg!(&temp))
447            .is_err());
448    }
449
450    #[test]
451    #[cfg(all(feature = "toml", feature = "yaml"))]
452    fn test_store_load_with_specific_format() {
453        let tempdir = TempDir::new().unwrap();
454        let temp = tempdir
455            .path()
456            .join("test_store_load_with_specific_format.toml");
457        std::fs::File::create(&temp).unwrap();
458        TestConfig::example()
459            .store_with_specific_format(dbg!(&temp), ConfigFormat::Yaml)
460            .unwrap();
461        assert!(TestConfig::load(&temp).is_err());
462        assert!(TestConfig::load_with_specific_format(&temp, ConfigFormat::Yaml).is_ok());
463    }
464
465    #[test]
466    #[cfg(feature = "toml")]
467    fn test_load_or_default() {
468        let tempdir = TempDir::new().unwrap();
469        let temp = tempdir.path().join("test_load_or_default.toml");
470        assert_eq!(
471            TestConfig::load_or_default(&temp).expect("load_or_default failed"),
472            TestConfig::default()
473        );
474    }
475}
476
477#[cfg(test)]
478mod storable {
479    use std::path::{Path, PathBuf};
480
481    use serde::Serialize;
482    use tempfile::TempDir;
483
484    use super::Storable;
485
486    #[derive(Serialize)]
487    struct TestStorable {
488        path: PathBuf,
489    }
490
491    impl Storable for TestStorable {
492        fn path(&self) -> impl AsRef<Path> {
493            &self.path
494        }
495    }
496
497    #[test]
498    fn test_store() {
499        let tempdir = TempDir::new().unwrap();
500        let temp = tempdir.path().join("test_store.toml");
501        TestStorable { path: temp.clone() }.store().unwrap();
502        assert!(temp.is_file());
503    }
504}