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 => {
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
273pub trait Storable: Serialize + Sized {
279 fn path(&self) -> impl AsRef<Path>;
281
282 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 fn store(&self) -> Result<()> {
305 StoreConfigFile::store(self, self.path())
306 }
307 fn store_without_overwrite(&self) -> Result<()> {
316 StoreConfigFile::store_without_overwrite(self, self.path())
317 }
318}
319
320#[allow(unused)]
322fn open_file(path: &Path) -> std::io::Result<File> {
323 File::open(path)
324}
325
326#[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}