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#[derive(Debug, Clone, Copy)]
23pub enum ConfigFormat {
24 Json,
25 Toml,
26 Xml,
27 Yaml,
28 Ron,
29}
30
31impl ConfigFormat {
32 #[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 pub fn from_path(path: &Path) -> Option<Self> {
52 Self::from_extension(path.extension().and_then(OsStr::to_str)?)
53 }
54}
55
56pub trait LoadConfigFile {
59 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 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 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
172pub trait StoreConfigFile {
175 fn store_with_specific_format(
186 &self,
187 path: impl AsRef<Path>,
188 config_type: ConfigFormat,
189 ) -> Result<()>;
190
191 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 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
269pub trait Storable: Serialize + Sized {
275 fn path(&self) -> &Path;
277
278 fn store_with_specific_format(&self, config_type: ConfigFormat) -> Result<()> {
289 StoreConfigFile::store_with_specific_format(self, self.path(), config_type)
290 }
291
292 fn store(&self) -> Result<()> {
301 StoreConfigFile::store(self, self.path())
302 }
303 fn store_without_overwrite(&self) -> Result<()> {
312 StoreConfigFile::store_without_overwrite(self, self.path())
313 }
314}
315
316#[allow(unused)]
318fn open_file(path: &Path) -> std::io::Result<File> {
319 File::open(path)
320}
321
322#[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}