Skip to main content

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