hoard/config/builder/
hoard.rs

1//! This module contains definitions useful for working directly with [`Hoard`]s.
2//!
3//! A [`Hoard`] is a collection of at least one [`Pile`], where a [`Pile`] is a single file
4//! or directory that may appear in different locations on a system depending on that system's
5//! configuration. The path used is determined by the most specific match in the *environment
6//! condition*, which is a string like `foo|bar|baz` where `foo`, `bar`, and `baz` are the
7//! names of [`Environment`](super::environment::Environment)s defined in the configuration file.
8//! All environments in the condition must match the current system for its matching path to be
9//! used.
10
11use std::collections::BTreeMap;
12
13use serde::{Deserialize, Serialize};
14use thiserror::Error;
15
16use crate::config::builder::envtrie::{EnvTrie, Error as TrieError};
17use crate::env_vars::{Error as EnvError, PathWithEnv};
18use crate::hoard::PileConfig;
19use crate::newtypes::{EnvironmentName, EnvironmentString, NonEmptyPileName};
20
21type ConfigMultiple = crate::config::hoard::MultipleEntries;
22type ConfigSingle = crate::config::hoard::Pile;
23type ConfigHoard = crate::config::hoard::Hoard;
24
25/// Errors that may occur while processing a [`Builder`](super::Builder) [`Hoard`] into and
26/// [`Config`](crate::config::Config) [`Hoard`](crate::hoard::Hoard).
27#[derive(Debug, Error)]
28pub enum Error {
29    /// Error while evaluating a [`Pile`]'s [`EnvTrie`].
30    #[error("error while processing environment requirements: {0}")]
31    EnvTrie(#[from] TrieError),
32    /// Error while expanding environment variables in a path.
33    #[error("error while expanding environment variables in path: {0}")]
34    ExpandEnv(#[from] EnvError),
35}
36
37/// A single pile in the hoard.
38#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
39pub struct Pile {
40    /// Configuration specific to this pile.
41    ///
42    /// Will be merged with higher-level configuration. If no configuration is specified
43    /// (i.e., merging results in `None`), a default configuration will be used.
44    pub config: Option<PileConfig>,
45    /// Mapping of environment strings to a string path that may contain environment variables.
46    ///
47    /// See [`PathWithEnv`] for more on path format.
48    #[serde(flatten)]
49    pub items: BTreeMap<EnvironmentString, PathWithEnv>,
50}
51
52impl Pile {
53    #[tracing::instrument(level = "debug", name = "process_pile")]
54    fn process_with(
55        self,
56        envs: &BTreeMap<EnvironmentName, bool>,
57        exclusivity: &[Vec<EnvironmentName>],
58    ) -> Result<ConfigSingle, Error> {
59        let Pile { config, items } = self;
60        let trie = EnvTrie::new(&items, exclusivity)?;
61        let path = trie
62            .get_path(envs)?
63            .cloned()
64            .map(PathWithEnv::process)
65            .transpose()?;
66
67        Ok(ConfigSingle {
68            config: config.unwrap_or_default(),
69            path,
70        })
71    }
72
73    pub(crate) fn layer_config(&mut self, config: Option<&PileConfig>) {
74        PileConfig::layer_options(&mut self.config, config);
75    }
76}
77
78/// A set of multiple related piles (i.e. in a single hoard).
79#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
80pub struct MultipleEntries {
81    /// Any custom configuration that applies to all contained files.
82    ///
83    /// If `None`, a default configuration will be used during processing.
84    pub config: Option<PileConfig>,
85    /// A mapping of pile name to not-yet-processed [`Pile`]s.
86    #[serde(flatten)]
87    pub items: BTreeMap<NonEmptyPileName, Pile>,
88}
89
90impl MultipleEntries {
91    #[tracing::instrument(level = "debug", name = "process_multiple_entries")]
92    fn process_with(
93        self,
94        envs: &BTreeMap<EnvironmentName, bool>,
95        exclusivity: &[Vec<EnvironmentName>],
96    ) -> Result<ConfigMultiple, super::Error> {
97        let MultipleEntries { config, items } = self;
98        let items = items
99            .into_iter()
100            .map(|(pile, mut entry)| {
101                tracing::debug!(%pile, "processing pile");
102                entry.layer_config(config.as_ref());
103                let entry = entry.process_with(envs, exclusivity).map_err(Error::from)?;
104                Ok((pile, entry))
105            })
106            .collect::<Result<_, super::Error>>()?;
107
108        Ok(ConfigMultiple { piles: items })
109    }
110
111    pub(crate) fn layer_config(&mut self, config: Option<&PileConfig>) {
112        PileConfig::layer_options(&mut self.config, config);
113    }
114}
115
116/// A definition of a Hoard.
117#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
118#[serde(untagged)]
119pub enum Hoard {
120    /// A single anonymous [`Pile`].
121    Single(Pile),
122    /// Multiple named [`Pile`]s.
123    Multiple(MultipleEntries),
124}
125
126impl Hoard {
127    /// Resolve with path(s) to use for the `Hoard`.
128    ///
129    /// Uses the provided information to determine which environment combination is the best match
130    /// for each [`Pile`] and thus which path to use for each one.
131    ///
132    /// # Errors
133    ///
134    /// Any [`enum@Error`] that occurs while evaluating the `Hoard`.
135    #[tracing::instrument(level = "debug", name = "process_hoard")]
136    pub fn process_with(
137        self,
138        envs: &BTreeMap<EnvironmentName, bool>,
139        exclusivity: &[Vec<EnvironmentName>],
140    ) -> Result<crate::config::hoard::Hoard, super::Error> {
141        match self {
142            Hoard::Single(single) => {
143                tracing::debug!("processing anonymous pile");
144                Ok(ConfigHoard::Anonymous(
145                    single
146                        .process_with(envs, exclusivity)
147                        .map_err(super::Error::from)?,
148                ))
149            }
150            Hoard::Multiple(multiple) => {
151                tracing::debug!("processing named pile(s)");
152                Ok(ConfigHoard::Named(
153                    multiple.process_with(envs, exclusivity)?,
154                ))
155            }
156        }
157    }
158
159    pub(crate) fn layer_config(&mut self, config: Option<&PileConfig>) {
160        match self {
161            Hoard::Single(pile) => pile.layer_config(config),
162            Hoard::Multiple(multi) => multi.layer_config(config),
163        }
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use crate::hoard::pile_config::{
170        AsymmetricEncryption, Config as PileConfig, Encryption, SymmetricEncryption,
171    };
172
173    use super::*;
174
175    mod process {
176        use std::path::PathBuf;
177
178        use maplit::btreemap;
179
180        use crate::hoard::Pile as RealPile;
181        use crate::paths::SystemPath;
182
183        use super::*;
184
185        #[test]
186        fn env_vars_are_expanded() {
187            let pile = Pile {
188                config: None,
189                #[cfg(unix)]
190                items: btreemap! {
191                    "foo".parse().unwrap() => "${HOME}/something".into()
192                },
193                #[cfg(windows)]
194                items: btreemap! {
195                    "foo".parse().unwrap() => "${USERPROFILE}/something".into()
196                },
197            };
198
199            #[cfg(unix)]
200            let home = std::env::var("HOME").expect("failed to read $HOME");
201            #[cfg(windows)]
202            let home = std::env::var("USERPROFILE").expect("failed to read $USERPROFILE");
203            let expected = RealPile {
204                config: PileConfig::default(),
205                path: Some(
206                    SystemPath::try_from(PathBuf::from(format!("{home}/something"))).unwrap(),
207                ),
208            };
209
210            let envs = btreemap! { "foo".parse().unwrap() =>  true };
211            let result = pile
212                .process_with(&envs, &[])
213                .expect("pile should process without issues");
214
215            assert_eq!(result, expected);
216        }
217    }
218
219    mod serde {
220        use maplit::btreemap;
221        use serde_test::{assert_de_tokens_error, assert_tokens, Token};
222
223        use super::*;
224
225        #[test]
226        fn single_entry_no_config() {
227            let hoard = Hoard::Single(Pile {
228                config: None,
229                items: btreemap! {
230                    "bar_env|foo_env".parse().unwrap() => "/some/path".into()
231                },
232            });
233
234            assert_tokens(
235                &hoard,
236                &[
237                    Token::Map { len: None },
238                    Token::Str("config"),
239                    Token::None,
240                    Token::Str("bar_env|foo_env"),
241                    Token::Str("/some/path"),
242                    Token::MapEnd,
243                ],
244            );
245        }
246
247        #[test]
248        fn single_entry_with_config() {
249            let hoard = Hoard::Single(Pile {
250                config: Some(PileConfig {
251                    encryption: Some(Encryption::Asymmetric(AsymmetricEncryption {
252                        public_key: "public key".to_string(),
253                    })),
254                    ..PileConfig::default()
255                }),
256                items: btreemap! {
257                    "bar_env|foo_env".parse().unwrap() => "/some/path".into()
258                },
259            });
260
261            assert_tokens(
262                &hoard,
263                &[
264                    Token::Map { len: None },
265                    Token::Str("config"),
266                    Token::Some,
267                    Token::Struct {
268                        name: "Config",
269                        len: 5,
270                    },
271                    Token::Str("hash_algorithm"),
272                    Token::None,
273                    Token::Str("encrypt"),
274                    Token::Some,
275                    Token::Struct {
276                        name: "AsymmetricEncryption",
277                        len: 2,
278                    },
279                    Token::Str("type"),
280                    Token::Str("asymmetric"),
281                    Token::Str("public_key"),
282                    Token::Str("public key"),
283                    Token::StructEnd,
284                    Token::Str("ignore"),
285                    Token::Seq { len: Some(0) },
286                    Token::SeqEnd,
287                    Token::Str("file_permissions"),
288                    Token::None,
289                    Token::Str("folder_permissions"),
290                    Token::None,
291                    Token::StructEnd,
292                    Token::Str("bar_env|foo_env"),
293                    Token::Str("/some/path"),
294                    Token::MapEnd,
295                ],
296            );
297        }
298
299        #[test]
300        fn multiple_entry_no_config() {
301            let hoard = Hoard::Multiple(MultipleEntries {
302                config: None,
303                items: btreemap! {
304                    "item1".parse().unwrap() => Pile {
305                        config: None,
306                        items: btreemap! {
307                            "bar_env|foo_env".parse().unwrap() => "/some/path".into()
308                        }
309                    },
310                },
311            });
312
313            assert_tokens(
314                &hoard,
315                &[
316                    Token::Map { len: None },
317                    Token::Str("config"),
318                    Token::None,
319                    Token::Str("item1"),
320                    Token::Map { len: None },
321                    Token::Str("config"),
322                    Token::None,
323                    Token::Str("bar_env|foo_env"),
324                    Token::Str("/some/path"),
325                    Token::MapEnd,
326                    Token::MapEnd,
327                ],
328            );
329        }
330
331        #[test]
332        fn multiple_entry_with_config() {
333            let hoard = Hoard::Multiple(MultipleEntries {
334                config: Some(PileConfig {
335                    encryption: Some(Encryption::Symmetric(SymmetricEncryption::Password(
336                        "correcthorsebatterystaple".into(),
337                    ))),
338                    ..PileConfig::default()
339                }),
340                items: btreemap! {
341                    "item1".parse().unwrap() => Pile {
342                        config: None,
343                        items: btreemap! {
344                            "bar_env|foo_env".parse().unwrap() => "/some/path".into()
345                        }
346                    },
347                },
348            });
349
350            assert_tokens(
351                &hoard,
352                &[
353                    Token::Map { len: None },
354                    Token::Str("config"),
355                    Token::Some,
356                    Token::Struct {
357                        name: "Config",
358                        len: 5,
359                    },
360                    Token::Str("hash_algorithm"),
361                    Token::None,
362                    Token::Str("encrypt"),
363                    Token::Some,
364                    Token::Map { len: Some(2) },
365                    Token::Str("type"),
366                    Token::Str("symmetric"),
367                    Token::Str("password"),
368                    Token::Str("correcthorsebatterystaple"),
369                    Token::MapEnd,
370                    Token::Str("ignore"),
371                    Token::Seq { len: Some(0) },
372                    Token::SeqEnd,
373                    Token::Str("file_permissions"),
374                    Token::None,
375                    Token::Str("folder_permissions"),
376                    Token::None,
377                    Token::StructEnd,
378                    Token::Str("item1"),
379                    Token::Map { len: None },
380                    Token::Str("config"),
381                    Token::None,
382                    Token::Str("bar_env|foo_env"),
383                    Token::Str("/some/path"),
384                    Token::MapEnd,
385                    Token::MapEnd,
386                ],
387            );
388        }
389
390        #[test]
391        fn test_invalid_glob() {
392            assert_de_tokens_error::<PileConfig>(
393                &[
394                    Token::Struct {
395                        name: "Config",
396                        len: 5,
397                    },
398                    Token::Str("hash_algorithm"),
399                    Token::None,
400                    Token::Str("encrypt"),
401                    Token::None,
402                    Token::Str("file_permissions"),
403                    Token::None,
404                    Token::Str("folder_permissions"),
405                    Token::None,
406                    Token::Str("ignore"),
407                    Token::Seq { len: Some(2) },
408                    Token::Str("**/valid*"),
409                    Token::Str("invalid**"),
410                    Token::SeqEnd,
411                    Token::StructEnd,
412                ],
413                "Pattern syntax error near position 6: recursive wildcards must form a single path component",
414            );
415        }
416
417        #[test]
418        fn test_valid_globs() {
419            let config = PileConfig {
420                ignore: vec![
421                    glob::Pattern::new("**/valid*").unwrap(),
422                    glob::Pattern::new("*/also_valid/**").unwrap(),
423                ],
424                ..PileConfig::default()
425            };
426
427            assert_tokens::<PileConfig>(
428                &config,
429                &[
430                    Token::Struct {
431                        name: "Config",
432                        len: 5,
433                    },
434                    Token::Str("hash_algorithm"),
435                    Token::None,
436                    Token::Str("encrypt"),
437                    Token::None,
438                    Token::Str("ignore"),
439                    Token::Seq { len: Some(2) },
440                    Token::Str("**/valid*"),
441                    Token::Str("*/also_valid/**"),
442                    Token::SeqEnd,
443                    Token::Str("file_permissions"),
444                    Token::None,
445                    Token::Str("folder_permissions"),
446                    Token::None,
447                    Token::StructEnd,
448                ],
449            );
450        }
451    }
452}