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#[derive(Debug, Clone, Copy)]
25pub enum ConfigFormat {
26 Json,
27 Json5,
28 Toml,
29 Xml,
30 Yaml,
31 Ron,
32}
33
34impl ConfigFormat {
35 #[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 pub fn from_path(path: &Path) -> Option<Self> {
57 Self::from_extension(path.extension().and_then(OsStr::to_str)?)
58 }
59}
60
61pub trait LoadConfigFile {
64 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 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 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
182pub trait StoreConfigFile {
185 fn store_with_specific_format(
196 &self,
197 path: impl AsRef<Path>,
198 config_type: ConfigFormat,
199 ) -> Result<()>;
200
201 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 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
292pub trait Storable: Serialize + Sized {
298 fn path(&self) -> impl AsRef<Path>;
300
301 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 fn store(&self) -> Result<()> {
324 StoreConfigFile::store(self, self.path())
325 }
326 fn store_without_overwrite(&self) -> Result<()> {
335 StoreConfigFile::store_without_overwrite(self, self.path())
336 }
337}
338
339#[allow(unused)]
341fn open_file(path: &Path) -> std::io::Result<File> {
342 File::open(path)
343}
344
345#[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}