Skip to main content

config_file2/
lib.rs

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// `BufReader` speeds up `from_reader` based deserializers (esp. JSON, ~100x).
8// `Write` is only needed by the `write_all` based serializers.
9#[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/// Format of configuration file.
30#[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    /// Get the [`ConfigFormat`] from a file extension
42    #[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    /// Get the [`ConfigFormat`] from a path
62    pub fn from_path(path: &Path) -> Option<Self> {
63        Self::from_extension(path.extension().and_then(OsStr::to_str)?)
64    }
65}
66
67/// Trait for loading a struct from a configuration file.
68/// This trait is automatically implemented when [`serde::Deserialize`] is.
69pub trait LoadConfigFile {
70    /// Load config from path with specific format, *do not use extension to
71    /// determine*.
72    ///
73    /// # Returns
74    ///
75    /// - Returns `Ok(Some(config))` if the file exists.
76    /// - Returns `Ok(None)` if the file does not exist.
77    ///
78    /// # Errors
79    ///
80    /// - Returns [`Error::FileAccess`] if the file cannot be read.
81    /// - Returns `Error::<Format>` if deserialization from file fails.
82    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    /// Load config from path.
90    ///
91    /// # Returns
92    ///
93    /// - Returns `Ok(Some(config))` if the file exists.
94    /// - Returns `Ok(None)` if the file does not exist.
95    ///
96    /// # Errors
97    ///
98    /// - Returns [`Error::FileAccess`] if the file cannot be read.
99    /// - Returns [`Error::UnsupportedFormat`] if the file extension is not
100    ///   supported.
101    /// - Returns `Error::<Format>` if deserialization from file fails.
102    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    /// Load config from path, if not found, use default instead
112    ///
113    /// # Returns
114    ///
115    /// - Returns the config loaded from file if the file exists, or default
116    ///   value if the file does not exist.
117    ///
118    /// # Errors
119    ///
120    /// - Returns [`Error::FileAccess`] if the file cannot be read by Permission
121    ///   denied or other failures.
122    /// - Returns [`Error::UnsupportedFormat`] if the file extension is not
123    ///   supported.
124    /// - Returns `Error::<Format>` if deserialization from file fails.
125    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                // `quick_xml::de::from_reader` requires `R: BufRead`.
173                .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
191/// Trait for storing a struct into a configuration file.
192/// This trait is automatically implemented when [`serde::Serialize`] is.
193pub trait StoreConfigFile: Serialize {
194    /// Store config file to path with specific format, do not use extension to
195    /// determine. If the file already exists, the config file
196    /// will be overwritten.
197    ///
198    /// Parent directories are created automatically if they don't exist.
199    ///
200    /// # Errors
201    ///
202    /// - Returns [`Error::FileAccess`] if the file cannot be written.
203    /// - Returns [`Error::UnsupportedFormat`] if the file extension is not
204    ///   supported.
205    /// - Returns `Error::<Format>` if serialization to file fails.
206    fn store_with_specific_format(
207        &self,
208        path: impl AsRef<Path>,
209        config_type: ConfigFormat,
210    ) -> Result<()>;
211
212    /// Store config file to path. If the file already exists, the config file
213    /// will be overwritten.
214    ///
215    /// Parent directories are created automatically if they don't exist.
216    ///
217    /// # Errors
218    ///
219    /// - Returns [`Error::UnsupportedFormat`] if the file extension is not
220    ///   supported.
221    /// - Returns `Error::<Format>` if serialization to file fails.
222    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    /// Returns a [`StoreBuilder`] for storing `self` with full control over
232    /// [`pretty`][StoreBuilder::pretty], [`overwrite`][StoreBuilder::overwrite]
233    /// and [`format`][StoreBuilder::format]. Finish the chain with
234    /// [`StoreBuilder::execute`].
235    ///
236    /// Defaults match [`StoreConfigFile::store`]: pretty on, overwrite on,
237    /// format auto-detected from the path extension.
238    ///
239    /// # Examples
240    ///
241    /// ```no_run
242    /// use config_file2::StoreConfigFile;
243    /// use serde::Serialize;
244    ///
245    /// #[derive(Serialize)]
246    /// struct Config;
247    ///
248    /// Config
249    ///     .store_opts("/tmp/config.json")
250    ///     .pretty(false)
251    ///     .overwrite(false)
252    ///     .execute()
253    ///     .unwrap();
254    /// ```
255    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
273/// A struct that knows the path it stores itself at.
274///
275/// Implement [`Storable::path`] to give your type an intrinsic config path,
276/// then use [`Storable::save`] / [`Storable::save_opts`] to write it without
277/// passing a path every call.
278///
279/// `Storable` intentionally uses method names (`save`, `save_opts`) that differ
280/// from [`StoreConfigFile`] (`store`, `store_opts`), so importing both traits
281/// never causes method-name ambiguity on types that implement both.
282pub trait Storable: Serialize + Sized {
283    /// The path this struct stores itself at.
284    fn path(&self) -> impl AsRef<Path>;
285
286    /// Save this struct to its [`path`][Storable::path] with default options
287    /// (pretty on, overwrite on, format auto-detected from the extension).
288    ///
289    /// Parent directories are created automatically if they don't exist.
290    ///
291    /// # Errors
292    ///
293    /// - Returns [`Error::UnsupportedFormat`] if the file extension is not
294    ///   supported.
295    /// - Returns `Error::<Format>` if serialization to file fails.
296    fn save(&self) -> Result<()> {
297        StoreConfigFile::store(self, self.path())
298    }
299
300    /// Returns a [`StoreBuilder`] bound to this struct's
301    /// [`path`][Storable::path], for full control over
302    /// [`pretty`][StoreBuilder::pretty], [`overwrite`][StoreBuilder::overwrite]
303    /// and [`format`][StoreBuilder::format]. Finish with
304    /// [`StoreBuilder::execute`].
305    ///
306    /// # Examples
307    ///
308    /// ```no_run
309    /// use config_file2::Storable;
310    /// use serde::Serialize;
311    /// use std::path::{Path, PathBuf};
312    ///
313    /// #[derive(Serialize)]
314    /// struct Config;
315    ///
316    /// impl Storable for Config {
317    ///     fn path(&self) -> impl AsRef<Path> {
318    ///         PathBuf::from("/tmp/config.json")
319    ///     }
320    /// }
321    ///
322    /// Config.save_opts().pretty(false).execute().unwrap();
323    /// ```
324    fn save_opts(&self) -> StoreBuilder<'_, Self> {
325        StoreBuilder::new(self, self.path())
326    }
327}
328
329/// Open a file in read-only mode
330#[allow(unused)]
331fn open_file(path: &Path) -> std::io::Result<File> {
332    File::open(path)
333}
334
335/// Open a file for writing, truncating it if it already exists. Parent
336/// directories are created automatically.
337#[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/// Open a file for writing, atomically failing with [`Error::FileExists`] if it
351/// already exists (no TOCTOU window). Parent directories are created
352/// automatically.
353#[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/// Serialize `value` to `path` in the given `config_type`.
372///
373/// `pretty` selects pretty-printing where the format supports it
374/// (json/toml/ron); other formats ignore it. When `overwrite` is `true`, an
375/// existing file is replaced; when `false`, [`Error::FileExists`] is returned
376/// if it already exists.
377///
378/// With the `atomic` feature enabled the payload is written to a temporary file
379/// in the target's directory and renamed into place only once serialization
380/// succeeds, so a crash or serialization error never truncates or corrupts an
381/// existing config file. Without it the target is written in place.
382#[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/// Write target that transparently supports optional atomic writes.
460///
461/// Without the `atomic` feature a [`Sink`] is always [`Sink::Plain`]: a normal
462/// [`File`] opened with truncate (overwrite) or create-new semantics. With the
463/// feature enabled it is instead [`Sink::Temp`], a [`tempfile::NamedTempFile`]
464/// created in the target file's directory and renamed into place by
465/// [`Sink::finalize`], so readers never observe a partially-written file.
466#[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    /// Commit a finished write.
493    ///
494    /// For a plain file this is a no-op (the file is closed on drop). For an
495    /// atomic sink it renames the temporary file into `path`: atomically
496    /// replacing any existing file when `overwrite` is `true`, or failing with
497    /// [`Error::FileExists`] when `false`. If this returns an error the
498    /// temporary file is removed, leaving the original target untouched.
499    #[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/// Open a [`Sink`] for writing `path`.
525///
526/// When the `atomic` feature is enabled the sink is a temporary file created in
527/// the same directory as `path` (so the final rename stays on one filesystem),
528/// and parent directories are created up front. Otherwise a plain file is
529/// opened directly with truncate or create-new semantics depending on
530/// `overwrite`.
531#[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/// Builder for storing a value with fine-grained options.
554///
555/// Created via [`StoreConfigFile::store_opts`]. Configure with
556/// [`pretty`][StoreBuilder::pretty], [`overwrite`][StoreBuilder::overwrite] and
557/// [`format`][StoreBuilder::format], then call
558/// [`execute`][StoreBuilder::execute] to write the file.
559///
560/// Defaults: pretty on, overwrite on, format auto-detected from the path
561/// extension (i.e. the same behavior as [`StoreConfigFile::store`]).
562#[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    /// Toggle pretty-printing. Default: `true`. Formats without a pretty
594    /// variant (xml/yaml/json5) ignore this.
595    pub const fn pretty(mut self, pretty: bool) -> Self {
596        self.pretty = pretty;
597        self
598    }
599
600    /// Toggle overwriting an existing file. Default: `true`. When `false`,
601    /// [`execute`][StoreBuilder::execute] returns [`Error::FileExists`] if the
602    /// file already exists.
603    pub const fn overwrite(mut self, overwrite: bool) -> Self {
604        self.overwrite = overwrite;
605        self
606    }
607
608    /// Use a specific [`ConfigFormat`], overriding extension-based detection.
609    pub const fn format(mut self, config_format: ConfigFormat) -> Self {
610        self.config_format = Some(config_format);
611        self
612    }
613
614    /// Store the value with the configured options.
615    ///
616    /// # Errors
617    ///
618    /// - Returns [`Error::UnsupportedFormat`] if the format can't be
619    ///   determined.
620    /// - Returns [`Error::FileAccess`] if the file can't be written.
621    /// - Returns [`Error::FileExists`] if `overwrite` is `false` and the file
622    ///   exists.
623    /// - Returns `Error::<Format>` on serialization failure.
624    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        // pretty TOML is multi-line and therefore larger than compact TOML.
807        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        // an extension we don't recognize, but force toml via the builder.
832        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    /// A type that always fails to serialize, used to verify that a failed
847    /// atomic write never touches the existing target file.
848    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    // Without the `atomic` feature, a serialization failure mid-write leaves the
860    // target file truncated/corrupt because it is opened (and truncated) before
861    // serialization. With `atomic`, the write goes to a temp file that is only
862    // renamed into place on success, so the original is guaranteed intact.
863    #[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        // no leftover temp files in the directory
876        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        // Regression: importing both StoreConfigFile and Storable must not make
944        // `.store(path)` / `.save()` ambiguous.
945        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(); // StoreConfigFile
951        config.save().unwrap(); // Storable
952        assert!(temp.is_file());
953    }
954}