1#![doc = include_str!("../README.md")]
2#![warn(clippy::nursery, clippy::cargo, clippy::pedantic)]
3#![allow(clippy::module_name_repetitions)]
4
5pub mod error;
6
7#[cfg(any(feature = "json", feature = "xml", feature = "yaml", feature = "ron"))]
10use std::io::BufReader;
11use std::{
12 ffi::OsStr,
13 fmt::{self, Debug},
14 fs::{File, OpenOptions},
15 io::Write,
16 path::{Path, PathBuf},
17};
18
19use error::Error;
20#[cfg(feature = "json5")]
21use error::Json5Error;
22pub use error::Result;
23#[cfg(feature = "xml")]
24use error::XmlError;
25use serde::{Serialize, de::DeserializeOwned};
26#[cfg(feature = "toml")]
27use {error::TomlError, toml_crate as toml};
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
31pub enum ConfigFormat {
32 Json,
33 Json5,
34 Toml,
35 Xml,
36 Yaml,
37 Ron,
38}
39
40impl ConfigFormat {
41 #[must_use]
43 pub fn from_extension(extension: &str) -> Option<Self> {
44 match extension.to_lowercase().as_str() {
45 #[cfg(feature = "json")]
46 "json" => Some(Self::Json),
47 #[cfg(feature = "json5")]
48 "json5" => Some(Self::Json5),
49 #[cfg(feature = "toml")]
50 "toml" => Some(Self::Toml),
51 #[cfg(feature = "xml")]
52 "xml" => Some(Self::Xml),
53 #[cfg(feature = "yaml")]
54 "yaml" | "yml" => Some(Self::Yaml),
55 #[cfg(feature = "ron")]
56 "ron" => Some(Self::Ron),
57 _ => None,
58 }
59 }
60
61 pub fn from_path(path: &Path) -> Option<Self> {
63 Self::from_extension(path.extension().and_then(OsStr::to_str)?)
64 }
65}
66
67pub trait LoadConfigFile {
70 fn load_with_specific_format(
83 path: impl AsRef<Path>,
84 config_type: ConfigFormat,
85 ) -> Result<Option<Self>>
86 where
87 Self: Sized;
88
89 fn load(path: impl AsRef<Path>) -> Result<Option<Self>>
103 where
104 Self: Sized,
105 {
106 let path = path.as_ref();
107 let config_type = ConfigFormat::from_path(path).ok_or(Error::UnsupportedFormat)?;
108 Self::load_with_specific_format(path, config_type)
109 }
110
111 fn load_or_default(path: impl AsRef<Path>) -> Result<Self>
126 where
127 Self: Sized + Default,
128 {
129 Self::load(path).map(std::option::Option::unwrap_or_default)
130 }
131}
132
133#[allow(unused_macros)]
134macro_rules! not_found_to_none {
135 ($input:expr) => {
136 match $input {
137 Ok(config) => Ok(Some(config)),
138 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
139 Err(e) => Err(e),
140 }
141 };
142}
143
144impl<C: DeserializeOwned> LoadConfigFile for C {
145 #[allow(unused_variables)]
146 fn load_with_specific_format(
147 path: impl AsRef<Path>,
148 config_type: ConfigFormat,
149 ) -> Result<Option<Self>>
150 where
151 Self: Sized,
152 {
153 let path = path.as_ref();
154
155 match config_type {
156 #[cfg(feature = "json")]
157 ConfigFormat::Json => Ok(not_found_to_none!(open_file(path))?
158 .map(|x| serde_json::from_reader(BufReader::new(x)))
159 .transpose()?),
160 #[cfg(feature = "json5")]
161 ConfigFormat::Json5 => Ok(not_found_to_none!(std::fs::read_to_string(path))?
162 .map(|x| json_five::from_str(x.as_str()))
163 .transpose()
164 .map_err(Json5Error::DeserializationError)?),
165 #[cfg(feature = "toml")]
166 ConfigFormat::Toml => Ok(not_found_to_none!(std::fs::read_to_string(path))?
167 .map(|x| toml::from_str(x.as_str()))
168 .transpose()
169 .map_err(TomlError::DeserializationError)?),
170 #[cfg(feature = "xml")]
171 ConfigFormat::Xml => Ok(not_found_to_none!(open_file(path))?
172 .map(|x| quick_xml::de::from_reader(BufReader::new(x)))
174 .transpose()
175 .map_err(XmlError::DeserializationError)?),
176 #[cfg(feature = "yaml")]
177 ConfigFormat::Yaml => Ok(not_found_to_none!(open_file(path))?
178 .map(|x| yaml_serde::from_reader(BufReader::new(x)))
179 .transpose()?),
180 #[cfg(feature = "ron")]
181 ConfigFormat::Ron => Ok(not_found_to_none!(open_file(path))?
182 .map(|x| ron_crate::de::from_reader(BufReader::new(x)))
183 .transpose()
184 .map_err(ron_crate::Error::from)?),
185 #[allow(unreachable_patterns)]
186 _ => Err(Error::UnsupportedFormat),
187 }
188 }
189}
190
191pub trait StoreConfigFile: Serialize {
194 fn store_with_specific_format(
207 &self,
208 path: impl AsRef<Path>,
209 config_type: ConfigFormat,
210 ) -> Result<()>;
211
212 fn store(&self, path: impl AsRef<Path>) -> Result<()>
223 where
224 Self: Sized,
225 {
226 let path = path.as_ref();
227 let config_type = ConfigFormat::from_path(path).ok_or(Error::UnsupportedFormat)?;
228 self.store_with_specific_format(path, config_type)
229 }
230
231 fn store_opts(&self, path: impl AsRef<Path>) -> StoreBuilder<'_, Self>
256 where
257 Self: Sized,
258 {
259 StoreBuilder::new(self, path)
260 }
261}
262
263impl<C: Serialize> StoreConfigFile for C {
264 fn store_with_specific_format(
265 &self,
266 path: impl AsRef<Path>,
267 config_type: ConfigFormat,
268 ) -> Result<()> {
269 store_impl(self, path.as_ref(), config_type, true, true)
270 }
271}
272
273pub trait Storable: Serialize + Sized {
283 fn path(&self) -> impl AsRef<Path>;
285
286 fn save(&self) -> Result<()> {
297 StoreConfigFile::store(self, self.path())
298 }
299
300 fn save_opts(&self) -> StoreBuilder<'_, Self> {
325 StoreBuilder::new(self, self.path())
326 }
327}
328
329#[allow(unused)]
331fn open_file(path: &Path) -> std::io::Result<File> {
332 File::open(path)
333}
334
335#[allow(unused)]
338fn open_write_file(path: &Path) -> Result<File> {
339 if let Some(parent) = path.parent() {
340 std::fs::create_dir_all(parent)?;
341 }
342 OpenOptions::new()
343 .write(true)
344 .create(true)
345 .truncate(true)
346 .open(path)
347 .map_err(Error::FileAccess)
348}
349
350#[allow(unused)]
354fn open_create_new_file(path: &Path) -> Result<File> {
355 if let Some(parent) = path.parent() {
356 std::fs::create_dir_all(parent)?;
357 }
358 OpenOptions::new()
359 .write(true)
360 .create_new(true)
361 .open(path)
362 .map_err(|e| {
363 if e.kind() == std::io::ErrorKind::AlreadyExists {
364 Error::FileExists
365 } else {
366 Error::FileAccess(e)
367 }
368 })
369}
370
371#[allow(unused)]
383fn store_impl<S: Serialize>(
384 value: &S,
385 path: &Path,
386 config_type: ConfigFormat,
387 pretty: bool,
388 overwrite: bool,
389) -> Result<()> {
390 match config_type {
391 #[cfg(feature = "json")]
392 ConfigFormat::Json => {
393 let mut sink = open_sink(path, overwrite)?;
394 if pretty {
395 serde_json::to_writer_pretty(&mut sink, value)
396 } else {
397 serde_json::to_writer(&mut sink, value)
398 }
399 .map_err(Error::Json)?;
400 sink.finalize(path, overwrite)
401 }
402 #[cfg(feature = "json5")]
403 ConfigFormat::Json5 => {
404 let mut sink = open_sink(path, overwrite)?;
405 sink.write_all(
406 json_five::to_string(value)
407 .map_err(Json5Error::SerializationError)?
408 .as_bytes(),
409 )?;
410 sink.finalize(path, overwrite)
411 }
412 #[cfg(feature = "toml")]
413 ConfigFormat::Toml => {
414 let mut sink = open_sink(path, overwrite)?;
415 let serialized = if pretty {
416 toml::to_string_pretty(value)
417 } else {
418 toml::to_string(value)
419 };
420 sink.write_all(
421 serialized
422 .map_err(TomlError::SerializationError)?
423 .as_bytes(),
424 )?;
425 sink.finalize(path, overwrite)
426 }
427 #[cfg(feature = "xml")]
428 ConfigFormat::Xml => {
429 let mut sink = open_sink(path, overwrite)?;
430 sink.write_all(
431 quick_xml::se::to_string(value)
432 .map_err(XmlError::SerializationError)?
433 .as_bytes(),
434 )?;
435 sink.finalize(path, overwrite)
436 }
437 #[cfg(feature = "yaml")]
438 ConfigFormat::Yaml => {
439 let mut sink = open_sink(path, overwrite)?;
440 yaml_serde::to_writer(&mut sink, value).map_err(Error::Yaml)?;
441 sink.finalize(path, overwrite)
442 }
443 #[cfg(feature = "ron")]
444 ConfigFormat::Ron => {
445 let mut sink = open_sink(path, overwrite)?;
446 let serialized = if pretty {
447 ron_crate::ser::to_string_pretty(value, ron_crate::ser::PrettyConfig::default())
448 } else {
449 ron_crate::ser::to_string(value)
450 }?;
451 sink.write_all(serialized.as_bytes())?;
452 sink.finalize(path, overwrite)
453 }
454 #[allow(unreachable_patterns)]
455 _ => Err(Error::UnsupportedFormat),
456 }
457}
458
459#[allow(unused)]
467enum Sink {
468 Plain(File),
469 #[cfg(feature = "atomic")]
470 Temp(tempfile::NamedTempFile),
471}
472
473impl Write for Sink {
474 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
475 match self {
476 Self::Plain(file) => file.write(buf),
477 #[cfg(feature = "atomic")]
478 Self::Temp(tmp) => tmp.write(buf),
479 }
480 }
481
482 fn flush(&mut self) -> std::io::Result<()> {
483 match self {
484 Self::Plain(file) => file.flush(),
485 #[cfg(feature = "atomic")]
486 Self::Temp(tmp) => tmp.flush(),
487 }
488 }
489}
490
491impl Sink {
492 #[allow(unused)]
500 fn finalize(self, path: &Path, overwrite: bool) -> Result<()> {
501 match self {
502 Self::Plain(_) => Ok(()),
503 #[cfg(feature = "atomic")]
504 Self::Temp(tmp) => {
505 let result = if overwrite {
506 tmp.persist(path)
507 } else {
508 tmp.persist_noclobber(path)
509 };
510 result
511 .map_err(|tempfile::PersistError { error, .. }| {
512 if !overwrite && error.kind() == std::io::ErrorKind::AlreadyExists {
513 Error::FileExists
514 } else {
515 Error::FileAccess(error)
516 }
517 })
518 .map(|_| ())
519 }
520 }
521 }
522}
523
524#[allow(unused)]
532fn open_sink(path: &Path, overwrite: bool) -> Result<Sink> {
533 #[cfg(feature = "atomic")]
534 {
535 let parent = path.parent().filter(|p| !p.as_os_str().is_empty());
536 if let Some(p) = parent {
537 std::fs::create_dir_all(p)?;
538 }
539 let dir = parent.unwrap_or_else(|| Path::new("."));
540 Ok(Sink::Temp(tempfile::NamedTempFile::new_in(dir)?))
541 }
542 #[cfg(not(feature = "atomic"))]
543 {
544 let file = if overwrite {
545 open_write_file(path)?
546 } else {
547 open_create_new_file(path)?
548 };
549 Ok(Sink::Plain(file))
550 }
551}
552
553#[must_use]
563pub struct StoreBuilder<'a, T: Sized + Serialize> {
564 value: &'a T,
565 path: PathBuf,
566 pretty: bool,
567 overwrite: bool,
568 config_format: Option<ConfigFormat>,
569}
570
571impl<T: Sized + Serialize> fmt::Debug for StoreBuilder<'_, T> {
572 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
573 f.debug_struct("StoreBuilder")
574 .field("path", &self.path)
575 .field("pretty", &self.pretty)
576 .field("overwrite", &self.overwrite)
577 .field("config_format", &self.config_format)
578 .finish()
579 }
580}
581
582impl<'a, T: Sized + Serialize> StoreBuilder<'a, T> {
583 fn new(value: &'a T, path: impl AsRef<Path>) -> Self {
584 Self {
585 value,
586 path: path.as_ref().to_path_buf(),
587 pretty: true,
588 overwrite: true,
589 config_format: None,
590 }
591 }
592
593 pub const fn pretty(mut self, pretty: bool) -> Self {
596 self.pretty = pretty;
597 self
598 }
599
600 pub const fn overwrite(mut self, overwrite: bool) -> Self {
604 self.overwrite = overwrite;
605 self
606 }
607
608 pub const fn format(mut self, config_format: ConfigFormat) -> Self {
610 self.config_format = Some(config_format);
611 self
612 }
613
614 pub fn execute(&self) -> Result<()> {
625 let config_type = self
626 .config_format
627 .or_else(|| ConfigFormat::from_path(&self.path))
628 .ok_or(Error::UnsupportedFormat)?;
629 store_impl(
630 self.value,
631 &self.path,
632 config_type,
633 self.pretty,
634 self.overwrite,
635 )
636 }
637}
638
639#[cfg(test)]
640mod test {
641
642 use serde::Deserialize;
643 use tempfile::TempDir;
644
645 use super::*;
646
647 #[derive(Debug, Serialize, Deserialize, PartialEq, Default, Eq)]
648 struct TestConfig {
649 host: String,
650 port: u64,
651 tags: Vec<String>,
652 inner: TestConfigInner,
653 }
654
655 #[derive(Debug, Serialize, Deserialize, PartialEq, Default, Eq)]
656 struct TestConfigInner {
657 answer: u8,
658 }
659
660 impl TestConfig {
661 #[allow(unused)]
662 fn example() -> Self {
663 Self {
664 host: "example.com".to_string(),
665 port: 443,
666 tags: vec!["example".to_string(), "test".to_string()],
667 inner: TestConfigInner { answer: 42 },
668 }
669 }
670 }
671
672 fn test_read_with_extension(extension: &str) {
673 let config = TestConfig::load(format!("testdata/config.{extension}"));
674 assert_eq!(config.unwrap().unwrap(), TestConfig::example());
675 }
676
677 fn test_write_with_extension(extension: &str) {
678 let tempdir = TempDir::new().unwrap();
679 let mut temp = tempdir.path().join("config");
680 temp.set_extension(extension);
681 TestConfig::example().store(&temp).unwrap();
682 assert!(temp.is_file());
683 assert_eq!(
684 TestConfig::example(),
685 TestConfig::load(&temp).unwrap().unwrap()
686 );
687 }
688
689 #[test]
690 fn test_unknown() {
691 let config = TestConfig::load("/tmp/foobar");
692 assert!(matches!(config, Err(Error::UnsupportedFormat)));
693 }
694
695 #[test]
696 #[cfg(feature = "toml")]
697 fn test_file_not_found() {
698 let config = TestConfig::load("/tmp/foobar.toml");
699 assert!(config.unwrap().is_none());
700 }
701
702 #[test]
703 #[cfg(feature = "json")]
704 fn test_json() {
705 test_read_with_extension("json");
706 test_write_with_extension("json");
707 }
708
709 #[test]
710 #[cfg(feature = "json5")]
711 fn test_json5() {
712 test_read_with_extension("json5");
713 test_write_with_extension("json5");
714 }
715
716 #[test]
717 #[cfg(feature = "toml")]
718 fn test_toml() {
719 test_read_with_extension("toml");
720 test_write_with_extension("toml");
721 }
722
723 #[test]
724 #[cfg(feature = "xml")]
725 fn test_xml() {
726 test_read_with_extension("xml");
727 test_write_with_extension("xml");
728 }
729
730 #[test]
731 #[cfg(feature = "yaml")]
732 fn test_yaml() {
733 test_read_with_extension("yml");
734 test_write_with_extension("yaml");
735 }
736
737 #[test]
738 #[cfg(feature = "ron")]
739 fn test_ron() {
740 test_read_with_extension("ron");
741 test_write_with_extension("ron");
742 }
743
744 #[test]
745 #[cfg(all(feature = "toml", feature = "yaml"))]
746 fn test_store_load_with_specific_format() {
747 let tempdir = TempDir::new().unwrap();
748 let temp = tempdir
749 .path()
750 .join("test_store_load_with_specific_format.toml");
751 std::fs::File::create(&temp).unwrap();
752 TestConfig::example()
753 .store_with_specific_format(&temp, ConfigFormat::Yaml)
754 .unwrap();
755 assert!(TestConfig::load(&temp).is_err());
756 assert!(TestConfig::load_with_specific_format(&temp, ConfigFormat::Yaml).is_ok());
757 }
758
759 #[test]
760 #[cfg(feature = "toml")]
761 fn test_load_or_default() {
762 let tempdir = TempDir::new().unwrap();
763 let temp = tempdir.path().join("test_load_or_default.toml");
764 assert_eq!(
765 TestConfig::load_or_default(&temp).expect("load_or_default failed"),
766 TestConfig::default()
767 );
768 }
769
770 #[test]
771 #[cfg(feature = "toml")]
772 fn test_store_builder_round_trip() {
773 let tempdir = TempDir::new().unwrap();
774 for pretty in [true, false] {
775 let temp = tempdir
776 .path()
777 .join(format!("test_store_builder_{pretty}.toml"));
778 TestConfig::example()
779 .store_opts(&temp)
780 .pretty(pretty)
781 .execute()
782 .unwrap();
783 assert_eq!(
784 TestConfig::example(),
785 TestConfig::load(&temp).unwrap().unwrap()
786 );
787 }
788 }
789
790 #[test]
791 #[cfg(feature = "toml")]
792 fn test_store_builder_pretty_compact_differ() {
793 let tempdir = TempDir::new().unwrap();
794 let pretty = tempdir.path().join("pretty.toml");
795 let compact = tempdir.path().join("compact.toml");
796 TestConfig::example()
797 .store_opts(&pretty)
798 .pretty(true)
799 .execute()
800 .unwrap();
801 TestConfig::example()
802 .store_opts(&compact)
803 .pretty(false)
804 .execute()
805 .unwrap();
806 assert!(
808 std::fs::metadata(&pretty).unwrap().len() > std::fs::metadata(&compact).unwrap().len()
809 );
810 }
811
812 #[test]
813 #[cfg(feature = "toml")]
814 fn test_store_builder_overwrite_false() {
815 let tempdir = TempDir::new().unwrap();
816 let temp = tempdir.path().join("test_store_builder_overwrite.toml");
817 std::fs::File::create(&temp).unwrap();
818 assert!(
819 TestConfig::example()
820 .store_opts(&temp)
821 .overwrite(false)
822 .execute()
823 .is_err()
824 );
825 }
826
827 #[test]
828 #[cfg(feature = "toml")]
829 fn test_store_builder_format_override() {
830 let tempdir = TempDir::new().unwrap();
831 let temp = tempdir.path().join("test_store_builder_override.dat");
833 TestConfig::example()
834 .store_opts(&temp)
835 .format(ConfigFormat::Toml)
836 .execute()
837 .unwrap();
838 assert!(TestConfig::load(&temp).is_err());
839 assert!(
840 TestConfig::load_with_specific_format(&temp, ConfigFormat::Toml)
841 .unwrap()
842 .is_some()
843 );
844 }
845
846 struct AlwaysFail;
849 impl serde::Serialize for AlwaysFail {
850 fn serialize<S: serde::Serializer>(
851 &self,
852 _serializer: S,
853 ) -> std::result::Result<S::Ok, S::Error> {
854 use serde::ser::Error as _;
855 Err(S::Error::custom("intentional serialization failure"))
856 }
857 }
858
859 #[test]
864 #[cfg(all(feature = "atomic", feature = "toml"))]
865 fn test_atomic_preserves_original_on_serialize_failure() {
866 let tempdir = TempDir::new().unwrap();
867 let temp = tempdir.path().join("atomic_preserve_on_fail.toml");
868 TestConfig::example().store(&temp).unwrap();
869 let before = std::fs::read(&temp).unwrap();
870
871 let res = AlwaysFail.store_opts(&temp).overwrite(true).execute();
872 assert!(res.is_err(), "expected serialization to fail");
873
874 assert_eq!(std::fs::read(&temp).unwrap(), before);
875 assert_eq!(
877 std::fs::read_dir(tempdir.path()).unwrap().count(),
878 1,
879 "temp file should be cleaned up"
880 );
881 }
882
883 #[test]
884 #[cfg(all(feature = "atomic", feature = "toml"))]
885 fn test_atomic_overwrite_false_preserves_original() {
886 let tempdir = TempDir::new().unwrap();
887 let temp = tempdir.path().join("atomic_overwrite_false.toml");
888 TestConfig::example().store(&temp).unwrap();
889 let before = std::fs::read(&temp).unwrap();
890
891 let res = TestConfig::default()
892 .store_opts(&temp)
893 .overwrite(false)
894 .execute();
895 assert!(res.is_err(), "expected FileExists error");
896
897 assert_eq!(std::fs::read(&temp).unwrap(), before);
898 }
899}
900
901#[cfg(test)]
902mod storable {
903 use std::path::{Path, PathBuf};
904
905 use serde::Serialize;
906 use tempfile::TempDir;
907
908 use super::Storable;
909
910 #[derive(Serialize)]
911 struct TestStorable {
912 path: PathBuf,
913 }
914
915 impl Storable for TestStorable {
916 fn path(&self) -> impl AsRef<Path> {
917 &self.path
918 }
919 }
920
921 #[test]
922 fn test_save() {
923 let tempdir = TempDir::new().unwrap();
924 let temp = tempdir.path().join("test_save.toml");
925 TestStorable { path: temp.clone() }.save().unwrap();
926 assert!(temp.is_file());
927 }
928
929 #[test]
930 fn test_save_opts() {
931 let tempdir = TempDir::new().unwrap();
932 let temp = tempdir.path().join("test_save_opts.toml");
933 TestStorable { path: temp.clone() }
934 .save_opts()
935 .pretty(false)
936 .execute()
937 .unwrap();
938 assert!(temp.is_file());
939 }
940
941 #[test]
942 fn test_both_traits_no_ambiguity() {
943 use super::StoreConfigFile;
946
947 let tempdir = TempDir::new().unwrap();
948 let temp = tempdir.path().join("test_no_ambiguity.toml");
949 let config = TestStorable { path: temp.clone() };
950 config.store(&temp).unwrap(); config.save().unwrap(); assert!(temp.is_file());
953 }
954}