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 => ron_crate::ser::to_writer_pretty(
258                open_write_file(path)?,
259                &self,
260                ron_crate::ser::PrettyConfig::default(),
261            )
262            .map_err(Error::Ron),
263            #[allow(unreachable_patterns)]
264            _ => Err(Error::UnsupportedFormat),
265        }
266    }
267}
268
269/// A more easy way to store a struct into a configuration file.
270///
271/// Just impl `Storable::path(&self) -> &Path;` to your struct, and then you can
272/// use `store_with_specific_format`, `store`, `store_without_overwrite`
273/// directly by calling the method on your struct.
274pub trait Storable: Serialize + Sized {
275    /// impl by struct.
276    fn path(&self) -> &Path;
277
278    /// Store config file to path with specific format, do not use extension to
279    /// determine. If the file already exists, the config file
280    /// will be overwritten.
281    ///
282    /// # Errors
283    ///
284    /// - Returns [`Error::FileAccess`] if the file cannot be written.
285    /// - Returns [`Error::UnsupportedFormat`] if the file extension is not
286    ///   supported.
287    /// - Returns `Error::<Format>` if serialization to file fails.
288    fn store_with_specific_format(&self, config_type: ConfigFormat) -> Result<()> {
289        StoreConfigFile::store_with_specific_format(self, self.path(), config_type)
290    }
291
292    /// Store config file to path. If the file already exists, the config file
293    /// will be overwritten.
294    ///
295    /// # Errors
296    ///
297    /// - Returns [`Error::UnsupportedFormat`] if the file extension is not
298    ///   supported.
299    /// - Returns `Error::<Format>` if serialization to file fails.
300    fn store(&self) -> Result<()> {
301        StoreConfigFile::store(self, self.path())
302    }
303    /// Store config file to path, if path exists, return error
304    ///
305    /// # Errors
306    ///
307    /// - Returns [`Error::FileExists`] if the file already exists.
308    /// - Returns [`Error::UnsupportedFormat`] if the file extension is not
309    ///   supported.
310    /// - Returns `Error::<Format>` if serialization to file fails.
311    fn store_without_overwrite(&self) -> Result<()> {
312        StoreConfigFile::store_without_overwrite(self, self.path())
313    }
314}
315
316/// Open a file in read-only mode
317#[allow(unused)]
318fn open_file(path: &Path) -> std::io::Result<File> {
319    File::open(path)
320}
321
322/// Open a file in write mode
323#[allow(unused)]
324fn open_write_file(path: &Path) -> Result<File> {
325    if let Some(parent) = path.parent() {
326        std::fs::create_dir_all(parent)?;
327    }
328    OpenOptions::new()
329        .write(true)
330        .create(true)
331        .truncate(true)
332        .open(path)
333        .map_err(Error::FileAccess)
334}
335
336#[cfg(test)]
337mod test {
338
339    use serde::Deserialize;
340    use tempfile::TempDir;
341
342    use super::*;
343
344    #[derive(Debug, Serialize, Deserialize, PartialEq, Default, Eq)]
345    struct TestConfig {
346        host: String,
347        port: u64,
348        tags: Vec<String>,
349        inner: TestConfigInner,
350    }
351
352    #[derive(Debug, Serialize, Deserialize, PartialEq, Default, Eq)]
353    struct TestConfigInner {
354        answer: u8,
355    }
356
357    impl TestConfig {
358        #[allow(unused)]
359        fn example() -> Self {
360            Self {
361                host: "example.com".to_string(),
362                port: 443,
363                tags: vec!["example".to_string(), "test".to_string()],
364                inner: TestConfigInner { answer: 42 },
365            }
366        }
367    }
368
369    fn test_read_with_extension(extension: &str) {
370        let config = TestConfig::load(format!("testdata/config.{extension}"));
371        assert_eq!(config.unwrap().unwrap(), TestConfig::example());
372    }
373
374    fn test_write_with_extension(extension: &str) {
375        let tempdir = TempDir::new().unwrap();
376        let mut temp = tempdir.path().join("config");
377        temp.set_extension(extension);
378        TestConfig::example().store(dbg!(&temp)).unwrap();
379        assert!(temp.is_file());
380        dbg!(std::fs::read_to_string(&temp).unwrap());
381        assert_eq!(
382            TestConfig::example(),
383            TestConfig::load(&temp).unwrap().unwrap()
384        );
385    }
386
387    #[test]
388    fn test_unknown() {
389        let config = TestConfig::load("/tmp/foobar");
390        assert!(matches!(config, Err(Error::UnsupportedFormat)));
391    }
392
393    #[test]
394    #[cfg(feature = "toml")]
395    fn test_file_not_found() {
396        let config = TestConfig::load("/tmp/foobar.toml");
397        assert!(config.unwrap().is_none());
398    }
399
400    #[test]
401    #[cfg(feature = "json")]
402    fn test_json() {
403        test_read_with_extension("json");
404        test_write_with_extension("json");
405    }
406
407    #[test]
408    #[cfg(feature = "toml")]
409    fn test_toml() {
410        test_read_with_extension("toml");
411        test_write_with_extension("toml");
412    }
413
414    #[test]
415    #[cfg(feature = "xml")]
416    fn test_xml() {
417        test_read_with_extension("xml");
418        test_write_with_extension("xml");
419    }
420
421    #[test]
422    #[cfg(feature = "yaml")]
423    fn test_yaml() {
424        test_read_with_extension("yml");
425        test_write_with_extension("yaml");
426    }
427
428    #[test]
429    #[cfg(feature = "ron")]
430    fn test_ron() {
431        test_read_with_extension("ron");
432        test_write_with_extension("ron");
433    }
434
435    #[test]
436    #[cfg(feature = "toml")]
437    fn test_store_without_overwrite() {
438        let tempdir = TempDir::new().unwrap();
439        let temp = tempdir.path().join("test_store_without_overwrite.toml");
440        std::fs::File::create(&temp).unwrap();
441        assert!(TestConfig::example()
442            .store_without_overwrite(dbg!(&temp))
443            .is_err());
444    }
445
446    #[test]
447    #[cfg(all(feature = "toml", feature = "yaml"))]
448    fn test_store_load_with_specific_format() {
449        let tempdir = TempDir::new().unwrap();
450        let temp = tempdir
451            .path()
452            .join("test_store_load_with_specific_format.toml");
453        std::fs::File::create(&temp).unwrap();
454        TestConfig::example()
455            .store_with_specific_format(dbg!(&temp), ConfigFormat::Yaml)
456            .unwrap();
457        assert!(TestConfig::load(&temp).is_err());
458        assert!(TestConfig::load_with_specific_format(&temp, ConfigFormat::Yaml).is_ok());
459    }
460
461    #[test]
462    #[cfg(feature = "toml")]
463    fn test_load_or_default() {
464        let tempdir = TempDir::new().unwrap();
465        let temp = tempdir.path().join("test_load_or_default.toml");
466        assert_eq!(
467            TestConfig::load_or_default(&temp).expect("load_or_default failed"),
468            TestConfig::default()
469        );
470    }
471}
472
473#[cfg(test)]
474mod storable {
475    use std::path::{Path, PathBuf};
476
477    use serde::Serialize;
478    use tempfile::TempDir;
479
480    use super::Storable;
481
482    #[derive(Serialize)]
483    struct TestStorable {
484        path: PathBuf,
485    }
486
487    impl Storable for TestStorable {
488        fn path(&self) -> &Path {
489            &self.path
490        }
491    }
492
493    #[test]
494    fn test_store() {
495        let tempdir = TempDir::new().unwrap();
496        let temp = tempdir.path().join("test_store.toml");
497        TestStorable { path: temp.clone() }.store().unwrap();
498        assert!(temp.is_file());
499    }
500}