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}