figment_directory/
lib.rs

1use std::{
2    fs,
3    marker::PhantomData,
4    path::{Path, PathBuf},
5};
6
7use figment::{
8    providers::Format,
9    value::{Dict, Map, Tag, Value},
10    Error, Figment, Metadata, Profile, Provider, Source,
11};
12
13/// A [`Provider`] that sources values from a (possibly nested) directory of files in a given
14/// [`Format`].
15///
16/// # Constructing
17///
18/// A [`Directory`] provider is typically constructed indirectly via a type that
19/// implements the [`Format`] trait via the [`FormatExt::directory()`] method which in-turn defers
20/// [`Directory::new()`] by default:
21///
22/// ```rust
23/// // The `FormatExt` trait must be in-scope to use its methods.
24/// use figment::providers::{Format, Toml};
25/// use figment_directory::{Directory, FormatExt as _};
26///
27/// // These two are equivalent, except the former requires the explicit type.
28/// let json_directory = Directory::<Toml>::new(figment_directory::RootPath::new("foo"));
29/// let json_directory = Toml::directory("foo");
30/// ```
31///
32/// # Provider Details
33///
34///   * **Profile**
35///
36///     This provider does not set a profile.
37///
38///   * **Metadata**
39///
40///     This provider is named `${NAME} directory`, where `${NAME}` is [`Format::NAME`].
41///     The directories file's paths are specified as file
42///     [`Source`](crate::Source). Path interpolation is unchanged from the
43///     default.
44///
45///   * **Data (Unnested, _default_)**
46///
47///     When nesting is _not_ specified, the source files in the given directory are read and
48///     parsed, and the parsed dictionary is emitted into the profile
49///     configurable via [`Directory::profile()`], which defaults to
50///     [`Profile::Default`]. If the source dictionary is not present
51///     an empty dictionary is emitted.
52///
53///   * **Data (Nested)**
54///
55///     When nesting is specified, the directory is expected to contain files and or subdirectories
56///     named after your profiles. These subdirectories and files are parsed and emitted into the corresponding profiles.
57///
58///     /root
59///     /root/default.toml              |
60///     /root/default/foo.toml          |-- these get put into the "default" profile
61///     /root/default/bar.toml          |
62///  
63///     /root/development.toml          |
64///     /root/development/foo.toml      | -- these get put into the "development" profile
65///
66///   * **Conflict Resolution**
67///     Per default, values in files that are higher up in the directory tree override values in deeply nested files.
68///     As an example, take these two files:
69///
70///     ```toml
71///     # /root/a.toml
72///     [b]
73///     c = 1
74///     ```
75///
76///     ```toml
77///     # /root/a/b.toml
78///     c = 2
79///     ```
80///
81///     The provider will prefer the value in `a.toml`, since it is higher up than `a/b.toml`.
82///     Therefore, `c = 1` will "win".
83///
84///     This strategy corresponds to the "Join" strategy in the [figment docs on conflict resolution](https://docs.rs/figment/0.10.19/figment/struct.Figment.html#conflict-resolution).
85///     The behaviour can be changed by using the methods on [`Directory`] corresponding to the
86///     available strategies: [`Directory::merge`], [`Directory::adjoin`], [`Directory::admerge`] and [`Directory::join`] (if you like to be explicit).
87pub struct Directory<F, FS = RootPath> {
88    file_system: FS,
89    conflict_resolution_strategy: ConflictResolutionStrategy,
90    profile: Option<Profile>,
91    format: PhantomData<F>,
92}
93
94pub trait FormatExt: Format {
95    fn directory<P: Into<PathBuf>>(path: P) -> Directory<Self, RootPath>;
96    #[cfg(feature = "include-dir")]
97    fn included_directory<'a>(
98        dir: &'a include_dir::Dir<'a>,
99    ) -> Directory<Self, &'a include_dir::Dir<'a>>;
100}
101
102impl<F> FormatExt for F
103where
104    F: Format,
105{
106    fn directory<P: Into<PathBuf>>(path: P) -> Directory<Self, RootPath> {
107        Directory::new(RootPath(path.into()))
108    }
109
110    #[cfg(feature = "include-dir")]
111    fn included_directory<'a>(
112        dir: &'a include_dir::Dir<'a>,
113    ) -> Directory<Self, &'a include_dir::Dir<'a>> {
114        Directory::new(dir)
115    }
116}
117
118impl<F> Directory<F, RootPath> {}
119
120impl<F, FS> Directory<F, FS> {
121    pub fn new(file_system: FS) -> Self {
122        Self {
123            file_system,
124            conflict_resolution_strategy: ConflictResolutionStrategy::Join,
125            profile: Some(Profile::Default),
126            format: PhantomData,
127        }
128    }
129    /// Enables nesting on `self`, which results in top-level keys of the
130    /// sourced data being treated as profiles.
131    ///
132    /// ```rust
133    /// use serde::Deserialize;
134    /// use figment::{Figment, Jail, providers::{Format, Toml}, value::Map};
135    /// use figment_directory::FormatExt as _;
136    ///
137    /// #[derive(Debug, PartialEq, Deserialize)]
138    /// struct Config {
139    ///     numbers: Vec<usize>,
140    ///     untyped: Map<String, usize>,
141    /// }
142    ///
143    /// Jail::expect_with(|jail| {
144    ///     jail.create_dir("cfg")?;
145    ///     jail.create_file("cfg/default.toml", r#"
146    ///         [untyped]
147    ///         global = 0
148    ///         hi = 7
149    ///     "#)?;
150    ///     jail.create_file("cfg/staging.toml", r#"
151    ///         numbers = [1, 2, 3]
152    ///     "#)?;
153    ///     jail.create_file("cfg/release.toml", r#"
154    ///         numbers = [6, 7, 8]
155    ///     "#)?;
156    ///
157    ///     // Enable nesting via `nested()`.
158    ///     let figment = Figment::from(Toml::directory("cfg").nested());
159    ///
160    ///     let figment = figment.select("staging");
161    ///     let config: Config = figment.extract()?;
162    ///     assert_eq!(config, Config {
163    ///         numbers: vec![1, 2, 3],
164    ///         untyped: figment::util::map!["global".into() => 0, "hi".into() => 7],
165    ///     });
166    ///
167    ///     let config: Config = figment.select("release").extract()?;
168    ///     assert_eq!(config, Config {
169    ///         numbers: vec![6, 7, 8],
170    ///         untyped: figment::util::map!["global".into() => 0, "hi".into() => 7],
171    ///     });
172    ///
173    ///     Ok(())
174    /// });
175    /// ```
176    pub fn nested(mut self) -> Self {
177        self.profile = None;
178        self
179    }
180
181    /// Set the profile to emit data to when nesting is disabled.
182    ///
183    /// ```rust
184    /// use serde::Deserialize;
185    /// use figment::{Figment, Jail, providers::{Format, Toml}, value::Map, Profile};
186    /// use figment_directory::FormatExt as _;
187    ///
188    /// #[derive(Debug, PartialEq, Deserialize)]
189    /// struct Config { nested: NestedConfig }
190    ///
191    /// #[derive(Debug, PartialEq, Deserialize)]
192    /// struct NestedConfig { value: u8 }
193    ///
194    /// Jail::expect_with(|jail| {
195    ///     jail.create_dir("cfg")?;
196    ///     jail.create_file("cfg/nested.toml", r#"
197    ///         value = 123
198    ///     "#);
199    ///     let provider = Toml::directory("cfg").profile("debug");
200    ///     let figment = Figment::from(provider).select("debug");
201    ///     let config: Config = figment.extract()?;
202    ///     assert_eq!(config.nested, NestedConfig { value: 123 });
203    ///     let result: Result<Config, _> = figment.select(Profile::Default).extract();
204    ///     assert!(result.is_err(), "extract() should have errored but there was a value in the default profile");
205    ///
206    ///     Ok(())
207    /// });
208    /// ```
209    pub fn profile<P: Into<Profile>>(mut self, profile: P) -> Self {
210        self.profile = Some(profile.into());
211        self
212    }
213
214    /// Set the conflict resolution strategy to
215    ///   * prefer values in files that are lower down in the directory tree
216    ///   * override conflicting arrays instead of appending
217    ///
218    /// ```rust
219    /// use serde::Deserialize;
220    /// use figment::{Figment, Jail, providers::{Format, Toml}, value::Map};
221    /// use figment_directory::FormatExt as _;
222    ///
223    /// #[derive(Debug, PartialEq, Deserialize)]
224    /// struct Config {
225    ///     numbers: Vec<usize>,
226    ///     untyped: Map<String, usize>,
227    /// }
228    ///
229    /// #[derive(Debug, PartialEq, Deserialize)]
230    /// struct NestLevelOne {
231    ///     one: Config,
232    /// }
233    ///
234    /// #[derive(Debug, PartialEq, Deserialize)]
235    /// struct NestLevelTwo {
236    ///     two: NestLevelOne,
237    /// }
238    ///
239    /// Jail::expect_with(|jail| {
240    ///     jail.create_dir("cfg")?;
241    ///     jail.create_file("cfg/two.toml", r#"
242    ///         [one.untyped]
243    ///         global = 0
244    ///         hi = 7
245    ///         
246    ///         [one]
247    ///         numbers = [1, 2, 3]
248    ///     "#)?;
249    ///     jail.create_dir("cfg/two")?;
250    ///     jail.create_file("cfg/two/one.toml", r#"
251    ///         numbers = [6, 7, 8]
252    ///
253    ///         [untyped]
254    ///         hi = 8
255    ///         foo = 42
256    ///     "#)?;
257    ///
258    ///     // Set conflict resolution strategy via `merge()`.
259    ///     let figment = Figment::from(Toml::directory("cfg").merge());
260    ///
261    ///     let config: NestLevelTwo = figment.extract()?;
262    ///     assert_eq!(config.two.one, Config {
263    ///         numbers: vec![6, 7, 8],
264    ///         untyped: figment::util::map!["global".into() => 0, "hi".into() => 8, "foo".into() => 42],
265    ///     });
266    ///
267    ///     Ok(())
268    /// });
269    /// ```
270    pub fn merge(mut self) -> Self {
271        self.conflict_resolution_strategy = ConflictResolutionStrategy::Merge;
272        self
273    }
274
275    /// Set the conflict resolution strategy to
276    ///   * prefer values in files that are higher up in the directory tree
277    ///   * override conflicting arrays instead of appending
278    ///
279    /// ```rust
280    /// use serde::Deserialize;
281    /// use figment::{Figment, Jail, providers::{Format, Toml}, value::Map};
282    /// use figment_directory::FormatExt as _;
283    ///
284    /// #[derive(Debug, PartialEq, Deserialize)]
285    /// struct Config {
286    ///     numbers: Vec<usize>,
287    ///     untyped: Map<String, usize>,
288    /// }
289    ///
290    /// #[derive(Debug, PartialEq, Deserialize)]
291    /// struct NestLevelOne {
292    ///     one: Config,
293    /// }
294    ///
295    /// #[derive(Debug, PartialEq, Deserialize)]
296    /// struct NestLevelTwo {
297    ///     two: NestLevelOne,
298    /// }
299    ///
300    /// Jail::expect_with(|jail| {
301    ///     jail.create_dir("cfg")?;
302    ///     jail.create_file("cfg/two.toml", r#"
303    ///         [one.untyped]
304    ///         global = 0
305    ///         hi = 7
306    ///         
307    ///         [one]
308    ///         numbers = [1, 2, 3]
309    ///     "#)?;
310    ///     jail.create_dir("cfg/two")?;
311    ///     jail.create_file("cfg/two/one.toml", r#"
312    ///         numbers = [6, 7, 8]
313    ///
314    ///         [untyped]
315    ///         hi = 8
316    ///         foo = 42
317    ///     "#)?;
318    ///
319    ///     // Set conflict resolution strategy via `join()`.
320    ///     let figment = Figment::from(Toml::directory("cfg").join());
321    ///
322    ///     let config: NestLevelTwo = figment.extract()?;
323    ///     assert_eq!(config.two.one, Config {
324    ///         numbers: vec![1, 2, 3],
325    ///         untyped: figment::util::map!["global".into() => 0, "hi".into() => 7, "foo".into() => 42],
326    ///     });
327    ///
328    ///     Ok(())
329    /// });
330    /// ```
331    pub fn join(mut self) -> Self {
332        self.conflict_resolution_strategy = ConflictResolutionStrategy::Join;
333        self
334    }
335
336    /// Set the conflict resolution strategy to
337    ///   * prefer values in files that are lower down in the directory tree
338    ///   * append conflicting arrays instead of overriding
339    ///
340    /// ```rust
341    /// use serde::Deserialize;
342    /// use figment::{Figment, Jail, providers::{Format, Toml}, value::Map};
343    /// use figment_directory::FormatExt as _;
344    ///
345    /// #[derive(Debug, PartialEq, Deserialize)]
346    /// struct Config {
347    ///     numbers: Vec<usize>,
348    ///     untyped: Map<String, usize>,
349    /// }
350    ///
351    /// #[derive(Debug, PartialEq, Deserialize)]
352    /// struct NestLevelOne {
353    ///     one: Config,
354    /// }
355    ///
356    /// #[derive(Debug, PartialEq, Deserialize)]
357    /// struct NestLevelTwo {
358    ///     two: NestLevelOne,
359    /// }
360    ///
361    /// Jail::expect_with(|jail| {
362    ///     jail.create_dir("cfg")?;
363    ///     jail.create_file("cfg/two.toml", r#"
364    ///         [one.untyped]
365    ///         global = 0
366    ///         hi = 7
367    ///         
368    ///         [one]
369    ///         numbers = [1, 2, 3]
370    ///     "#)?;
371    ///     jail.create_dir("cfg/two")?;
372    ///     jail.create_file("cfg/two/one.toml", r#"
373    ///         numbers = [6, 7, 8]
374    ///
375    ///         [untyped]
376    ///         hi = 8
377    ///         foo = 42
378    ///     "#)?;
379    ///
380    ///     // Set conflict resolution strategy via `admerge()`.
381    ///     let figment = Figment::from(Toml::directory("cfg").admerge());
382    ///
383    ///     let config: NestLevelTwo = figment.extract()?;
384    ///     assert_eq!(config.two.one, Config {
385    ///         numbers: vec![1, 2, 3, 6, 7, 8],
386    ///         untyped: figment::util::map!["global".into() => 0, "hi".into() => 8, "foo".into() => 42],
387    ///     });
388    ///
389    ///     Ok(())
390    /// });
391    /// ```
392    pub fn admerge(mut self) -> Self {
393        self.conflict_resolution_strategy = ConflictResolutionStrategy::Admerge;
394        self
395    }
396
397    /// Set the conflict resolution strategy to
398    ///   * prefer values in files that are higher up in the directory tree
399    ///   * append conflicting arrays instead of overriding
400    ///
401    /// ```rust
402    /// use serde::Deserialize;
403    /// use figment::{Figment, Jail, providers::{Format, Toml}, value::Map};
404    /// use figment_directory::FormatExt as _;
405    ///
406    /// #[derive(Debug, PartialEq, Deserialize)]
407    /// struct Config {
408    ///     numbers: Vec<usize>,
409    ///     untyped: Map<String, usize>,
410    /// }
411    ///
412    /// #[derive(Debug, PartialEq, Deserialize)]
413    /// struct NestLevelOne {
414    ///     one: Config,
415    /// }
416    ///
417    /// #[derive(Debug, PartialEq, Deserialize)]
418    /// struct NestLevelTwo {
419    ///     two: NestLevelOne,
420    /// }
421    ///
422    /// Jail::expect_with(|jail| {
423    ///     jail.create_dir("cfg")?;
424    ///     jail.create_file("cfg/two.toml", r#"
425    ///         [one.untyped]
426    ///         global = 0
427    ///         hi = 7
428    ///         
429    ///         [one]
430    ///         numbers = [1, 2, 3]
431    ///     "#)?;
432    ///     jail.create_dir("cfg/two")?;
433    ///     jail.create_file("cfg/two/one.toml", r#"
434    ///         numbers = [6, 7, 8]
435    ///
436    ///         [untyped]
437    ///         hi = 8
438    ///         foo = 42
439    ///     "#)?;
440    ///
441    ///     // Set conflict resolution strategy via `adjoin()`.
442    ///     let figment = Figment::from(Toml::directory("cfg").adjoin());
443    ///
444    ///     let config: NestLevelTwo = figment.extract()?;
445    ///     assert_eq!(config.two.one, Config {
446    ///         numbers: vec![1, 2, 3, 6, 7, 8],
447    ///         untyped: figment::util::map!["global".into() => 0, "hi".into() => 7, "foo".into() => 42],
448    ///     });
449    ///
450    ///     Ok(())
451    /// });
452    /// ```
453    pub fn adjoin(mut self) -> Self {
454        self.conflict_resolution_strategy = ConflictResolutionStrategy::Adjoin;
455        self
456    }
457}
458
459#[derive(Debug, Clone, Copy, PartialEq)]
460pub enum ConflictResolutionStrategy {
461    Merge,
462    Join,
463    Adjoin,
464    Admerge,
465}
466
467impl ConflictResolutionStrategy {
468    fn resolve<P: Provider>(&self, figment: Figment, provider: P) -> Figment {
469        let strategy = match self {
470            ConflictResolutionStrategy::Merge => Figment::merge,
471            ConflictResolutionStrategy::Join => Figment::join,
472            ConflictResolutionStrategy::Adjoin => Figment::adjoin,
473            ConflictResolutionStrategy::Admerge => Figment::admerge,
474        };
475        strategy(figment, provider)
476    }
477}
478
479impl<F, FS> Provider for Directory<F, FS>
480where
481    F: Format,
482    FS: Filesystem,
483{
484    fn metadata(&self) -> Metadata {
485        Metadata::from(
486            format!("{} Directory", F::NAME),
487            Source::File(self.file_system.path().to_owned()),
488        )
489    }
490
491    fn data(&self) -> Result<Map<Profile, Dict>, Error> {
492        match &self.profile {
493            Some(profile) => collect_dir::<F, FS>(
494                &self.file_system,
495                self.conflict_resolution_strategy,
496                profile.clone(),
497            )
498            .data(),
499            None => {
500                collect_nested_dir::<F, FS>(&self.file_system, self.conflict_resolution_strategy)
501            }
502        }
503    }
504}
505
506fn collect_nested_dir<F, FS>(
507    file_system: &FS,
508    strategy: ConflictResolutionStrategy,
509) -> figment::Result<Map<Profile, Dict>>
510where
511    F: Format,
512    FS: Filesystem,
513{
514    let Ok(dir_entries) = file_system.read_dir() else {
515        return Ok(Map::new());
516    };
517    let mut map = Map::new();
518    for entry in dir_entries {
519        let Ok(entry) = entry else {
520            continue;
521        };
522        let entry_path = entry.path();
523        let Some(provider) = collect::<F, FS>(entry, strategy, Profile::Default) else {
524            continue;
525        };
526        let Some(file_stem) = entry_path.file_stem().and_then(|stem| stem.to_str()) else {
527            continue;
528        };
529        let data = provider.data()?.remove(&Profile::Default);
530        println!("{file_stem}, {data:?}");
531        if let Some(data) = data {
532            for (profile, dict) in data.into_iter().filter_map(|(profile, value)| {
533                let Value::Dict(_, dict) = value else {
534                    return None;
535                };
536                Some((profile, dict))
537            }) {
538                map.insert(Profile::from(profile), dict);
539            }
540        }
541    }
542    Ok(map)
543}
544
545fn collect_dir<F, FS>(
546    file_system: &FS,
547    strategy: ConflictResolutionStrategy,
548    profile: Profile,
549) -> Figment
550where
551    F: Format,
552    FS: Filesystem,
553{
554    let mut figment = Figment::new();
555    let Ok(dir_entries) = file_system.read_dir() else {
556        return figment;
557    };
558    for entry in dir_entries {
559        if let Some(provider) = entry
560            .ok()
561            .and_then(|entry| collect::<F, FS>(entry, strategy, profile.clone()))
562        {
563            figment = strategy.resolve(figment, provider);
564        }
565    }
566    figment
567}
568
569fn collect<F, FS>(
570    entry: FS::DirEntry,
571    strategy: ConflictResolutionStrategy,
572    profile: Profile,
573) -> Option<impl Provider>
574where
575    F: Format,
576    FS: Filesystem,
577{
578    match entry.into_fs_entry() {
579        FilesystemEntry::Invalid => None,
580        FilesystemEntry::File { stem, file } => {
581            let nested_provider = NestedProvider {
582                inner: file.to_figment::<F>(profile),
583                key: stem,
584            };
585            Some(nested_provider)
586        }
587        FilesystemEntry::Dir { dir: fs, name } => {
588            let nested_figment = collect_dir::<F, _>(&fs, strategy, profile);
589            Some(NestedProvider {
590                inner: nested_figment,
591                key: name.to_string(),
592            })
593        }
594    }
595}
596
597struct NestedProvider<P> {
598    inner: P,
599    key: String,
600}
601
602impl<P> Provider for NestedProvider<P>
603where
604    P: Provider,
605{
606    fn metadata(&self) -> Metadata {
607        self.inner.metadata()
608    }
609
610    fn data(&self) -> Result<Map<Profile, Dict>, Error> {
611        let inner_data = self.inner.data()?;
612        let data = inner_data
613            .into_iter()
614            .map(|(profile, inner_dict)| {
615                let mut dict = Dict::new();
616                dict.insert(self.key.clone(), Value::Dict(Tag::default(), inner_dict));
617                (profile, dict)
618            })
619            .collect();
620        Ok(data)
621    }
622}
623
624trait Filesystem {
625    type DirEntry: DirectoryEntry;
626    type ReadDir: Iterator<Item = Result<Self::DirEntry, Self::Error>>;
627    type Error: std::error::Error;
628
629    fn read_dir(&self) -> Result<Self::ReadDir, Self::Error>;
630    fn path(&self) -> &Path;
631}
632
633enum FilesystemEntry<F, D> {
634    File { stem: String, file: F },
635    Dir { name: String, dir: D },
636    Invalid,
637}
638
639trait DirectoryEntry {
640    type File: FilesystemFile;
641    type Dir: Filesystem;
642    fn path(&self) -> PathBuf;
643    fn file_name(&self) -> Option<String>;
644    fn into_fs_entry(self) -> FilesystemEntry<Self::File, Self::Dir>;
645}
646
647trait FilesystemFile {
648    fn to_figment<F: Format>(self, profile: Profile) -> Figment;
649}
650
651#[derive(Debug, Clone)]
652pub struct RootPath(PathBuf);
653
654impl RootPath {
655    pub fn new<P: Into<PathBuf>>(path: P) -> Self {
656        Self(path.into())
657    }
658}
659
660impl Filesystem for RootPath {
661    type DirEntry = fs::DirEntry;
662    type ReadDir = fs::ReadDir;
663    type Error = std::io::Error;
664
665    fn read_dir(&self) -> Result<Self::ReadDir, Self::Error> {
666        fs::read_dir(&self.0)
667    }
668
669    fn path(&self) -> &Path {
670        &self.0
671    }
672}
673
674struct PathFile(PathBuf);
675
676impl FilesystemFile for PathFile {
677    fn to_figment<F: Format>(self, profile: Profile) -> Figment {
678        Figment::from(F::file_exact(&self.0).profile(profile))
679    }
680}
681
682impl DirectoryEntry for fs::DirEntry {
683    type Dir = RootPath;
684    type File = PathFile;
685    fn path(&self) -> PathBuf {
686        fs::DirEntry::path(self)
687    }
688
689    fn file_name(&self) -> Option<String> {
690        fs::DirEntry::file_name(self).into_string().ok()
691    }
692
693    fn into_fs_entry(self) -> FilesystemEntry<Self::File, Self::Dir> {
694        let Some(name) = DirectoryEntry::file_name(&self) else {
695            return FilesystemEntry::Invalid;
696        };
697        let path = self.path();
698        if path.is_dir() {
699            FilesystemEntry::Dir {
700                dir: RootPath(path),
701                name,
702            }
703        } else {
704            let Some((stem, _ext)) = name.rsplit_once('.') else {
705                return FilesystemEntry::Invalid;
706            };
707            FilesystemEntry::File {
708                file: PathFile(path),
709                stem: stem.to_owned(),
710            }
711        }
712    }
713}
714
715#[cfg(feature = "include-dir")]
716impl<'a> Filesystem for &'a include_dir::Dir<'a> {
717    type DirEntry = &'a include_dir::DirEntry<'a>;
718    type ReadDir = InfallibleIter<core::slice::Iter<'a, include_dir::DirEntry<'a>>>;
719    type Error = std::convert::Infallible;
720
721    fn read_dir(&self) -> Result<Self::ReadDir, Self::Error> {
722        Ok(InfallibleIter(self.entries().iter()))
723    }
724
725    fn path(&self) -> &Path {
726        include_dir::Dir::path(self)
727    }
728}
729
730#[cfg(feature = "include-dir")]
731impl<'a> DirectoryEntry for &'a include_dir::DirEntry<'a> {
732    type File = &'a include_dir::File<'a>;
733    type Dir = &'a include_dir::Dir<'a>;
734
735    fn path(&self) -> PathBuf {
736        include_dir::DirEntry::path(self).to_owned()
737    }
738
739    fn file_name(&self) -> Option<String> {
740        let os_str = include_dir::DirEntry::path(self).file_name()?;
741        let str = os_str.to_str()?;
742        Some(str.to_owned())
743    }
744
745    fn into_fs_entry(self) -> FilesystemEntry<Self::File, Self::Dir> {
746        let Some(name) = DirectoryEntry::file_name(&self) else {
747            return FilesystemEntry::Invalid;
748        };
749        match self {
750            include_dir::DirEntry::Dir(fs) => FilesystemEntry::Dir { dir: fs, name },
751            include_dir::DirEntry::File(file) => {
752                let Some((stem, _ext)) = name.rsplit_once('.') else {
753                    return FilesystemEntry::Invalid;
754                };
755                FilesystemEntry::File {
756                    file,
757                    stem: stem.to_owned(),
758                }
759            }
760        }
761    }
762}
763
764#[cfg(feature = "include-dir")]
765impl<'a> FilesystemFile for &'a include_dir::File<'a> {
766    fn to_figment<F: Format>(self, profile: Profile) -> Figment {
767        let Some(contents) = self.contents_utf8() else {
768            return Figment::new();
769        };
770        let data = figment::providers::Data::<F>::string(contents).profile(profile);
771        Figment::from(data)
772    }
773}
774
775#[cfg(feature = "include-dir")]
776struct InfallibleIter<I>(I);
777
778#[cfg(feature = "include-dir")]
779impl<T, I> Iterator for InfallibleIter<I>
780where
781    I: Iterator<Item = T>,
782{
783    type Item = Result<T, std::convert::Infallible>;
784
785    fn next(&mut self) -> Option<Self::Item> {
786        self.0.next().map(Ok)
787    }
788}
789
790#[cfg(test)]
791mod tests {
792    use figment::{providers::Toml, Figment, Jail};
793    use serde::Deserialize;
794
795    use super::*;
796
797    #[test]
798    fn directory_does_not_exist() {
799        Jail::expect_with(|_jail| {
800            let config: Dict = Figment::from(Toml::directory("cfg")).extract()?;
801
802            assert_eq!(config, Dict::new());
803            Ok(())
804        })
805    }
806
807    #[test]
808    fn handles_nested_directory() {
809        Jail::expect_with(|jail| {
810            jail.create_dir("root")?;
811            jail.create_file(
812                "root/basic.toml",
813                r#"
814                    int = 5
815                    str = "string"
816                "#,
817            )?;
818            jail.create_dir("root/basic")?;
819            jail.create_file(
820                "root/basic/nested.toml",
821                r#"
822                    bool = true
823                    array = [1.5]
824                    default = 2
825                "#,
826            )?;
827
828            let config: NestedBasicConfig =
829                Figment::new().merge(Toml::directory("root")).extract()?;
830
831            assert_eq!(config.basic.int, 5);
832            assert_eq!(&config.basic.str, "string");
833            assert!(config.basic.nested.bool);
834            assert_eq!(config.basic.nested.array, vec![1.5]);
835            assert_eq!(config.basic.nested.default, 2);
836            Ok(())
837        })
838    }
839
840    #[test]
841    #[cfg(feature = "include-dir")]
842    fn handles_nested_directory_include_dir() {
843        let basic_entries = [include_dir::DirEntry::File(include_dir::File::new(
844            "nested.toml",
845            r#"
846bool = true
847array = [1.5]
848default = 2
849                "#
850            .as_bytes(),
851        ))];
852        let root_entries = [
853            include_dir::DirEntry::File(include_dir::File::new(
854                "basic.toml",
855                r#"
856int = 5
857str = "string"
858            "#
859                .as_bytes(),
860            )),
861            include_dir::DirEntry::Dir(include_dir::Dir::new("basic", &basic_entries)),
862        ];
863        let dir = include_dir::Dir::new("root", &root_entries);
864
865        let config: NestedBasicConfig = Figment::new()
866            .merge(Toml::included_directory(&dir))
867            .extract()
868            .unwrap();
869
870        assert_eq!(config.basic.int, 5);
871        assert_eq!(&config.basic.str, "string");
872        assert!(config.basic.nested.bool);
873        assert_eq!(config.basic.nested.array, vec![1.5]);
874        assert_eq!(config.basic.nested.default, 2);
875    }
876
877    #[derive(Debug, Deserialize)]
878    struct NestedBasicConfig {
879        basic: BasicConfig,
880    }
881
882    #[derive(Debug, Deserialize)]
883    struct BasicConfig {
884        str: String,
885        int: i64,
886        nested: NestedConfig,
887    }
888
889    #[derive(Debug, Deserialize)]
890    struct NestedConfig {
891        bool: bool,
892        array: Vec<f64>,
893        default: i64,
894    }
895}