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}