figment_directory/
lib.rs

1use std::{
2    fs::{self, DirEntry},
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("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> {
88    path: PathBuf,
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>;
96}
97
98impl<F> FormatExt for F
99where
100    F: Format,
101{
102    fn directory<P: Into<PathBuf>>(path: P) -> Directory<Self> {
103        Directory::new(path)
104    }
105}
106
107impl<F> Directory<F> {
108    pub fn new<P: Into<PathBuf>>(path: P) -> Self {
109        Self {
110            path: path.into(),
111            conflict_resolution_strategy: ConflictResolutionStrategy::Join,
112            profile: Some(Profile::Default),
113            format: PhantomData::default(),
114        }
115    }
116
117    /// Enables nesting on `self`, which results in top-level keys of the
118    /// sourced data being treated as profiles.
119    ///
120    /// ```rust
121    /// use serde::Deserialize;
122    /// use figment::{Figment, Jail, providers::{Format, Toml}, value::Map};
123    /// use figment_directory::FormatExt as _;
124    ///
125    /// #[derive(Debug, PartialEq, Deserialize)]
126    /// struct Config {
127    ///     numbers: Vec<usize>,
128    ///     untyped: Map<String, usize>,
129    /// }
130    ///
131    /// Jail::expect_with(|jail| {
132    ///     jail.create_dir("cfg")?;
133    ///     jail.create_file("cfg/default.toml", r#"
134    ///         [untyped]
135    ///         global = 0
136    ///         hi = 7
137    ///     "#)?;
138    ///     jail.create_file("cfg/staging.toml", r#"
139    ///         numbers = [1, 2, 3]
140    ///     "#)?;
141    ///     jail.create_file("cfg/release.toml", r#"
142    ///         numbers = [6, 7, 8]
143    ///     "#)?;
144    ///
145    ///     // Enable nesting via `nested()`.
146    ///     let figment = Figment::from(Toml::directory("cfg").nested());
147    ///
148    ///     let figment = figment.select("staging");
149    ///     let config: Config = figment.extract()?;
150    ///     assert_eq!(config, Config {
151    ///         numbers: vec![1, 2, 3],
152    ///         untyped: figment::util::map!["global".into() => 0, "hi".into() => 7],
153    ///     });
154    ///
155    ///     let config: Config = figment.select("release").extract()?;
156    ///     assert_eq!(config, Config {
157    ///         numbers: vec![6, 7, 8],
158    ///         untyped: figment::util::map!["global".into() => 0, "hi".into() => 7],
159    ///     });
160    ///
161    ///     Ok(())
162    /// });
163    /// ```
164    pub fn nested(mut self) -> Self {
165        self.profile = None;
166        self
167    }
168
169    /// Set the profile to emit data to when nesting is disabled.
170    ///
171    /// ```rust
172    /// use serde::Deserialize;
173    /// use figment::{Figment, Jail, providers::{Format, Toml}, value::Map, Profile};
174    /// use figment_directory::FormatExt as _;
175    ///
176    /// #[derive(Debug, PartialEq, Deserialize)]
177    /// struct Config { nested: NestedConfig }
178    ///
179    /// #[derive(Debug, PartialEq, Deserialize)]
180    /// struct NestedConfig { value: u8 }
181    ///
182    /// Jail::expect_with(|jail| {
183    ///     jail.create_dir("cfg")?;
184    ///     jail.create_file("cfg/nested.toml", r#"
185    ///         value = 123
186    ///     "#);
187    ///     let provider = Toml::directory("cfg").profile("debug");
188    ///     let figment = Figment::from(provider).select("debug");
189    ///     let config: Config = figment.extract()?;
190    ///     assert_eq!(config.nested, NestedConfig { value: 123 });
191    ///     let result: Result<Config, _> = figment.select(Profile::Default).extract();
192    ///     assert!(result.is_err(), "extract() should have errored but there was a value in the default profile");
193    ///
194    ///     Ok(())
195    /// });
196    /// ```
197    pub fn profile<P: Into<Profile>>(mut self, profile: P) -> Self {
198        self.profile = Some(profile.into());
199        self
200    }
201
202    /// Set the conflict resolution strategy to
203    ///   * prefer values in files that are lower down in the directory tree
204    ///   * override conflicting arrays instead of appending
205    ///
206    /// ```rust
207    /// use serde::Deserialize;
208    /// use figment::{Figment, Jail, providers::{Format, Toml}, value::Map};
209    /// use figment_directory::FormatExt as _;
210    ///
211    /// #[derive(Debug, PartialEq, Deserialize)]
212    /// struct Config {
213    ///     numbers: Vec<usize>,
214    ///     untyped: Map<String, usize>,
215    /// }
216    ///
217    /// #[derive(Debug, PartialEq, Deserialize)]
218    /// struct NestLevelOne {
219    ///     one: Config,
220    /// }
221    ///
222    /// #[derive(Debug, PartialEq, Deserialize)]
223    /// struct NestLevelTwo {
224    ///     two: NestLevelOne,
225    /// }
226    ///
227    /// Jail::expect_with(|jail| {
228    ///     jail.create_dir("cfg")?;
229    ///     jail.create_file("cfg/two.toml", r#"
230    ///         [one.untyped]
231    ///         global = 0
232    ///         hi = 7
233    ///         
234    ///         [one]
235    ///         numbers = [1, 2, 3]
236    ///     "#)?;
237    ///     jail.create_dir("cfg/two")?;
238    ///     jail.create_file("cfg/two/one.toml", r#"
239    ///         numbers = [6, 7, 8]
240    ///
241    ///         [untyped]
242    ///         hi = 8
243    ///         foo = 42
244    ///     "#)?;
245    ///
246    ///     // Set conflict resolution strategy via `merge()`.
247    ///     let figment = Figment::from(Toml::directory("cfg").merge());
248    ///
249    ///     let config: NestLevelTwo = figment.extract()?;
250    ///     assert_eq!(config.two.one, Config {
251    ///         numbers: vec![6, 7, 8],
252    ///         untyped: figment::util::map!["global".into() => 0, "hi".into() => 8, "foo".into() => 42],
253    ///     });
254    ///
255    ///     Ok(())
256    /// });
257    /// ```
258    pub fn merge(mut self) -> Self {
259        self.conflict_resolution_strategy = ConflictResolutionStrategy::Merge;
260        self
261    }
262
263    /// Set the conflict resolution strategy to
264    ///   * prefer values in files that are higher up in the directory tree
265    ///   * override conflicting arrays instead of appending
266    ///
267    /// ```rust
268    /// use serde::Deserialize;
269    /// use figment::{Figment, Jail, providers::{Format, Toml}, value::Map};
270    /// use figment_directory::FormatExt as _;
271    ///
272    /// #[derive(Debug, PartialEq, Deserialize)]
273    /// struct Config {
274    ///     numbers: Vec<usize>,
275    ///     untyped: Map<String, usize>,
276    /// }
277    ///
278    /// #[derive(Debug, PartialEq, Deserialize)]
279    /// struct NestLevelOne {
280    ///     one: Config,
281    /// }
282    ///
283    /// #[derive(Debug, PartialEq, Deserialize)]
284    /// struct NestLevelTwo {
285    ///     two: NestLevelOne,
286    /// }
287    ///
288    /// Jail::expect_with(|jail| {
289    ///     jail.create_dir("cfg")?;
290    ///     jail.create_file("cfg/two.toml", r#"
291    ///         [one.untyped]
292    ///         global = 0
293    ///         hi = 7
294    ///         
295    ///         [one]
296    ///         numbers = [1, 2, 3]
297    ///     "#)?;
298    ///     jail.create_dir("cfg/two")?;
299    ///     jail.create_file("cfg/two/one.toml", r#"
300    ///         numbers = [6, 7, 8]
301    ///
302    ///         [untyped]
303    ///         hi = 8
304    ///         foo = 42
305    ///     "#)?;
306    ///
307    ///     // Set conflict resolution strategy via `join()`.
308    ///     let figment = Figment::from(Toml::directory("cfg").join());
309    ///
310    ///     let config: NestLevelTwo = figment.extract()?;
311    ///     assert_eq!(config.two.one, Config {
312    ///         numbers: vec![1, 2, 3],
313    ///         untyped: figment::util::map!["global".into() => 0, "hi".into() => 7, "foo".into() => 42],
314    ///     });
315    ///
316    ///     Ok(())
317    /// });
318    /// ```
319    pub fn join(mut self) -> Self {
320        self.conflict_resolution_strategy = ConflictResolutionStrategy::Join;
321        self
322    }
323
324    /// Set the conflict resolution strategy to
325    ///   * prefer values in files that are lower down in the directory tree
326    ///   * append conflicting arrays instead of overriding
327    ///
328    /// ```rust
329    /// use serde::Deserialize;
330    /// use figment::{Figment, Jail, providers::{Format, Toml}, value::Map};
331    /// use figment_directory::FormatExt as _;
332    ///
333    /// #[derive(Debug, PartialEq, Deserialize)]
334    /// struct Config {
335    ///     numbers: Vec<usize>,
336    ///     untyped: Map<String, usize>,
337    /// }
338    ///
339    /// #[derive(Debug, PartialEq, Deserialize)]
340    /// struct NestLevelOne {
341    ///     one: Config,
342    /// }
343    ///
344    /// #[derive(Debug, PartialEq, Deserialize)]
345    /// struct NestLevelTwo {
346    ///     two: NestLevelOne,
347    /// }
348    ///
349    /// Jail::expect_with(|jail| {
350    ///     jail.create_dir("cfg")?;
351    ///     jail.create_file("cfg/two.toml", r#"
352    ///         [one.untyped]
353    ///         global = 0
354    ///         hi = 7
355    ///         
356    ///         [one]
357    ///         numbers = [1, 2, 3]
358    ///     "#)?;
359    ///     jail.create_dir("cfg/two")?;
360    ///     jail.create_file("cfg/two/one.toml", r#"
361    ///         numbers = [6, 7, 8]
362    ///
363    ///         [untyped]
364    ///         hi = 8
365    ///         foo = 42
366    ///     "#)?;
367    ///
368    ///     // Set conflict resolution strategy via `admerge()`.
369    ///     let figment = Figment::from(Toml::directory("cfg").admerge());
370    ///
371    ///     let config: NestLevelTwo = figment.extract()?;
372    ///     assert_eq!(config.two.one, Config {
373    ///         numbers: vec![1, 2, 3, 6, 7, 8],
374    ///         untyped: figment::util::map!["global".into() => 0, "hi".into() => 8, "foo".into() => 42],
375    ///     });
376    ///
377    ///     Ok(())
378    /// });
379    /// ```
380    pub fn admerge(mut self) -> Self {
381        self.conflict_resolution_strategy = ConflictResolutionStrategy::Admerge;
382        self
383    }
384
385    /// Set the conflict resolution strategy to
386    ///   * prefer values in files that are higher up in the directory tree
387    ///   * append conflicting arrays instead of overriding
388    ///
389    /// ```rust
390    /// use serde::Deserialize;
391    /// use figment::{Figment, Jail, providers::{Format, Toml}, value::Map};
392    /// use figment_directory::FormatExt as _;
393    ///
394    /// #[derive(Debug, PartialEq, Deserialize)]
395    /// struct Config {
396    ///     numbers: Vec<usize>,
397    ///     untyped: Map<String, usize>,
398    /// }
399    ///
400    /// #[derive(Debug, PartialEq, Deserialize)]
401    /// struct NestLevelOne {
402    ///     one: Config,
403    /// }
404    ///
405    /// #[derive(Debug, PartialEq, Deserialize)]
406    /// struct NestLevelTwo {
407    ///     two: NestLevelOne,
408    /// }
409    ///
410    /// Jail::expect_with(|jail| {
411    ///     jail.create_dir("cfg")?;
412    ///     jail.create_file("cfg/two.toml", r#"
413    ///         [one.untyped]
414    ///         global = 0
415    ///         hi = 7
416    ///         
417    ///         [one]
418    ///         numbers = [1, 2, 3]
419    ///     "#)?;
420    ///     jail.create_dir("cfg/two")?;
421    ///     jail.create_file("cfg/two/one.toml", r#"
422    ///         numbers = [6, 7, 8]
423    ///
424    ///         [untyped]
425    ///         hi = 8
426    ///         foo = 42
427    ///     "#)?;
428    ///
429    ///     // Set conflict resolution strategy via `adjoin()`.
430    ///     let figment = Figment::from(Toml::directory("cfg").adjoin());
431    ///
432    ///     let config: NestLevelTwo = figment.extract()?;
433    ///     assert_eq!(config.two.one, Config {
434    ///         numbers: vec![1, 2, 3, 6, 7, 8],
435    ///         untyped: figment::util::map!["global".into() => 0, "hi".into() => 7, "foo".into() => 42],
436    ///     });
437    ///
438    ///     Ok(())
439    /// });
440    /// ```
441    pub fn adjoin(mut self) -> Self {
442        self.conflict_resolution_strategy = ConflictResolutionStrategy::Adjoin;
443        self
444    }
445}
446
447#[derive(Debug, Clone, Copy, PartialEq)]
448pub enum ConflictResolutionStrategy {
449    Merge,
450    Join,
451    Adjoin,
452    Admerge,
453}
454
455impl ConflictResolutionStrategy {
456    fn resolve<P: Provider>(&self, figment: Figment, provider: P) -> Figment {
457        let strategy = match self {
458            ConflictResolutionStrategy::Merge => Figment::merge,
459            ConflictResolutionStrategy::Join => Figment::join,
460            ConflictResolutionStrategy::Adjoin => Figment::adjoin,
461            ConflictResolutionStrategy::Admerge => Figment::admerge,
462        };
463        strategy(figment, provider)
464    }
465}
466
467impl<F> Provider for Directory<F>
468where
469    F: Format,
470{
471    fn metadata(&self) -> Metadata {
472        Metadata::from(
473            format!("{} Directory", F::NAME),
474            Source::File(self.path.clone()),
475        )
476    }
477
478    fn data(&self) -> Result<Map<Profile, Dict>, Error> {
479        match &self.profile {
480            Some(profile) => collect_dir::<F>(
481                &self.path,
482                self.conflict_resolution_strategy,
483                profile.clone(),
484            )
485            .data(),
486            None => collect_nested_dir::<F>(&self.path, self.conflict_resolution_strategy),
487        }
488    }
489}
490
491fn collect_nested_dir<F>(
492    path: &Path,
493    strategy: ConflictResolutionStrategy,
494) -> figment::Result<Map<Profile, Dict>>
495where
496    F: Format,
497{
498    let Ok(dir_entries) = fs::read_dir(&path) else {
499        return Ok(Map::new());
500    };
501    let mut map = Map::new();
502    for entry in dir_entries {
503        let Ok(entry) = entry else {
504            continue;
505        };
506        let entry_path = entry.path();
507        let Some(provider) = collect::<F>(entry, strategy, Profile::Default) else {
508            continue;
509        };
510        let Some(file_stem) = entry_path.file_stem().and_then(|stem| stem.to_str()) else {
511            continue;
512        };
513        let data = provider.data()?.remove(&Profile::Default);
514        println!("{file_stem}, {data:?}");
515        if let Some(data) = data {
516            for (profile, dict) in data.into_iter().filter_map(|(profile, value)| {
517                let Value::Dict(_, dict) = value else {
518                    return None;
519                };
520                Some((profile, dict))
521            }) {
522                map.insert(Profile::from(profile), dict);
523            }
524        }
525    }
526    Ok(map)
527}
528
529fn collect_dir<F>(path: &Path, strategy: ConflictResolutionStrategy, profile: Profile) -> Figment
530where
531    F: Format,
532{
533    let mut figment = Figment::new();
534    let Ok(dir_entries) = fs::read_dir(&path) else {
535        return figment;
536    };
537    for entry in dir_entries {
538        if let Some(provider) = entry
539            .ok()
540            .and_then(|entry| collect::<F>(entry, strategy, profile.clone()))
541        {
542            figment = strategy.resolve(figment, provider);
543        }
544    }
545    figment
546}
547
548fn collect<F>(
549    entry: DirEntry,
550    strategy: ConflictResolutionStrategy,
551    profile: Profile,
552) -> Option<impl Provider>
553where
554    F: Format,
555{
556    let file_name = entry.file_name();
557    let entry_path = entry.path();
558    if entry_path.is_dir() {
559        let Some(dirname) = file_name.to_str() else {
560            // Ignore files and directories that are not valid UTF-8
561            return None;
562        };
563        let nested_figment = collect_dir::<F>(&entry_path, strategy, profile);
564        return Some(NestedProvider {
565            inner: nested_figment,
566            key: dirname.to_string(),
567        });
568    }
569
570    let Some(file_stem) = entry_path.file_stem().and_then(|stem| stem.to_str()) else {
571        // Ignore files that are not valid UTF-8
572        return None;
573    };
574    let file = F::file_exact(&entry_path).profile(profile);
575
576    let nested_provider = NestedProvider {
577        inner: Figment::from(file),
578        key: file_stem.to_string(),
579    };
580    Some(nested_provider)
581}
582
583struct NestedProvider<P> {
584    inner: P,
585    key: String,
586}
587
588impl<P> Provider for NestedProvider<P>
589where
590    P: Provider,
591{
592    fn metadata(&self) -> Metadata {
593        self.inner.metadata()
594    }
595
596    fn data(&self) -> Result<Map<Profile, Dict>, Error> {
597        let inner_data = self.inner.data()?;
598        let data = inner_data
599            .into_iter()
600            .map(|(profile, inner_dict)| {
601                let mut dict = Dict::new();
602                dict.insert(self.key.clone(), Value::Dict(Tag::default(), inner_dict));
603                (profile, dict)
604            })
605            .collect();
606        Ok(data)
607    }
608}
609
610#[cfg(test)]
611mod tests {
612    use figment::{providers::Toml, Figment, Jail};
613    use serde::Deserialize;
614
615    use super::*;
616
617    #[test]
618    fn directory_does_not_exist() {
619        Jail::expect_with(|_jail| {
620            let config: Dict = Figment::from(Toml::directory("cfg")).extract()?;
621
622            assert_eq!(config, Dict::new());
623            Ok(())
624        })
625    }
626
627    #[test]
628    fn handles_nested_directory() {
629        Jail::expect_with(|jail| {
630            jail.create_dir("root")?;
631            jail.create_file(
632                "root/basic.toml",
633                r#"
634                    int = 5
635                    str = "string"
636                "#,
637            )?;
638            jail.create_dir("root/basic")?;
639            jail.create_file(
640                "root/basic/nested.toml",
641                r#"
642                    bool = true
643                    array = [1.5]
644                    default = 2
645                "#,
646            )?;
647
648            let config: NestedBasicConfig = Figment::new()
649                .merge(Toml::directory("root"))
650                .extract()?;
651
652            assert_eq!(config.basic.int, 5);
653            assert_eq!(&config.basic.str, "string");
654            assert_eq!(config.basic.nested.bool, true);
655            assert_eq!(config.basic.nested.array, vec![1.5]);
656            assert_eq!(config.basic.nested.default, 2);
657            Ok(())
658        })
659    }
660
661    #[derive(Debug, Deserialize)]
662    struct NestedBasicConfig {
663        basic: BasicConfig,
664    }
665
666    #[derive(Debug, Deserialize)]
667    struct BasicConfig {
668        str: String,
669        int: i64,
670        nested: NestedConfig,
671    }
672
673    #[derive(Debug, Deserialize)]
674    struct NestedConfig {
675        bool: bool,
676        array: Vec<f64>,
677        default: i64,
678    }
679}