Skip to main content

config_shellexpand/
lib.rs

1#![doc=include_str!("../README.md")]
2
3use config::{FileFormat, FileSource, FileSourceFile, FileSourceString, Map, Source, Value};
4use std::convert::Infallible;
5use std::error::Error;
6use std::fmt::Debug;
7use std::path::PathBuf;
8
9/// Context for variable substitution
10/// Used by shellexpand to resolve variable values
11pub trait Context: Debug + Clone + Send + Sync {
12    type Output: AsRef<str>;
13    type Error: Error + Send + Sync + 'static;
14
15    /// Looks up a value in the context
16    fn lookup(&self, key: &str) -> Result<Option<Self::Output>, Self::Error>;
17}
18
19impl Context for () {
20    type Output = String;
21    type Error = Infallible;
22
23    fn lookup(&self, _key: &str) -> Result<Option<Self::Output>, Self::Error> {
24        Ok(None)
25    }
26}
27
28/// Configuration source that expands shell variables in files
29#[derive(Debug, Clone)]
30pub struct TemplatedFile<C, F>
31where
32    C: Context,
33    F: FileSource<FileFormat>,
34{
35    source: F,
36    format: Option<FileFormat>,
37    required: bool,
38    context: Option<C>,
39}
40
41impl<C, F> TemplatedFile<C, F>
42where
43    C: Context,
44    F: FileSource<FileFormat>,
45{
46    /// Mark this templated file as required or not
47    /// If the file is not required, files that doesn't exist will be ignored
48    pub fn required(self, required: bool) -> Self {
49        Self { required, ..self }
50    }
51}
52
53impl TemplatedFile<(), FileSourceFile> {
54    /// Create a new TemplatedFile from a path.
55    ///
56    /// Environment variables in the file content are expanded using the system environment.
57    ///
58    /// # Examples
59    ///
60    /// Load a TOML configuration file, expanding `${CARGO_PKG_NAME}` from the environment:
61    ///
62    /// ```
63    /// use config_shellexpand::TemplatedFile;
64    /// use rxpect::expect;
65    /// use rxpect::expectations::EqualityExpectations;
66    /// use serde::Deserialize;
67    ///
68    /// #[derive(Deserialize)]
69    /// struct Config {
70    ///     package_name: String,
71    /// }
72    ///
73    /// # let path = concat!(env!("CARGO_MANIFEST_DIR"), "/example.toml");
74    /// let config: Config = config::Config::builder()
75    ///     /*
76    ///      * Add a config file with contents like:
77    ///      * package_name = "${CARGO_PKG_NAME}"
78    ///     */
79    ///     .add_source(TemplatedFile::with_name(path))
80    ///     .build()
81    ///     .unwrap()
82    ///     .try_deserialize()
83    ///     .unwrap();
84    ///
85    /// expect(config.package_name).to_equal("config-shellexpand".to_string());
86    /// ```
87    pub fn with_name(name: impl Into<PathBuf>) -> TemplatedFile<(), FileSourceFile> {
88        Self::with_name_and_context(name, None)
89    }
90}
91
92impl<C> TemplatedFile<C, FileSourceFile>
93where
94    C: Context,
95{
96    /// Create a new TemplatedFile from a path with a custom substitution context.
97    ///
98    /// The [`Context`] controls how variable names are resolved. This is useful when you want
99    /// to supply values from a source other than (or in addition to) the system environment.
100    ///
101    /// See [`shellexpand::env_with_context`](https://docs.rs/shellexpand/latest/shellexpand/fn.env_with_context.html)
102    /// for details on how context-based variable resolution works.
103    ///
104    /// # Examples
105    ///
106    /// Parse a configuration string using a custom [`Context`] that provides the value for
107    /// `CARGO_PKG_NAME` independently of the environment:
108    ///
109    /// ```
110    /// use config::FileFormat;
111    /// use config_shellexpand::{Context, TemplatedFile};
112    /// use rxpect::expect;
113    /// use rxpect::expectations::EqualityExpectations;
114    /// use serde::Deserialize;
115    /// use std::convert::Infallible;
116    ///
117    /// #[derive(Clone, Debug)]
118    /// struct MyContext;
119    ///
120    /// impl Context for MyContext {
121    ///     type Output = &'static str;
122    ///     type Error = Infallible;
123    ///
124    ///     fn lookup(&self, key: &str) -> Result<Option<&'static str>, Infallible> {
125    ///         Ok(match key {
126    ///             "CARGO_PKG_NAME" => Some("my-package"),
127    ///             _ => None,
128    ///         })
129    ///     }
130    /// }
131    ///
132    /// #[derive(Deserialize)]
133    /// struct Config {
134    ///     package_name: String,
135    /// }
136    ///
137    /// # let path = concat!(env!("CARGO_MANIFEST_DIR"), "/example.toml");
138    /// let config: Config = config::Config::builder()
139    ///     /*
140    ///      * Add a config file with contents like:
141    ///      * package_name = "${CARGO_PKG_NAME}"
142    ///     */
143    ///     .add_source(TemplatedFile::with_name_and_context(path, Some(MyContext)))
144    ///     .build()
145    ///     .unwrap()
146    ///     .try_deserialize()
147    ///     .unwrap();
148    ///
149    /// expect(config.package_name).to_equal("my-package".to_string());
150    /// ```
151    pub fn with_name_and_context(
152        name: impl Into<PathBuf>,
153        context: Option<C>,
154    ) -> TemplatedFile<C, FileSourceFile> {
155        Self {
156            source: FileSourceFile::new(name.into()),
157            format: None,
158            required: true,
159            context,
160        }
161    }
162}
163
164impl TemplatedFile<(), FileSourceString> {
165    /// Create a new TemplatedFile from an inline string.
166    ///
167    /// Environment variables in the content are expanded using the system environment.
168    ///
169    /// # Examples
170    ///
171    /// Parse an inline configuration string, expanding `${CARGO_PKG_NAME}` from the environment:
172    ///
173    /// ```
174    /// use config::FileFormat;
175    /// use config_shellexpand::TemplatedFile;
176    /// use rxpect::expect;
177    /// use rxpect::expectations::EqualityExpectations;
178    /// use serde::Deserialize;
179    ///
180    /// #[derive(Deserialize)]
181    /// struct Config {
182    ///     package_name: String,
183    /// }
184    ///
185    /// let config: Config = config::Config::builder()
186    ///     .add_source(TemplatedFile::from_str(
187    ///         "package_name = \"${CARGO_PKG_NAME}\"",
188    ///         FileFormat::Toml,
189    ///     ))
190    ///     .build()
191    ///     .unwrap()
192    ///     .try_deserialize()
193    ///     .unwrap();
194    ///
195    /// expect(config.package_name).to_equal("config-shellexpand".to_string());
196    /// ```
197    pub fn from_str(
198        contents: impl AsRef<str>,
199        format: FileFormat,
200    ) -> TemplatedFile<(), FileSourceString> {
201        Self::from_str_and_context(contents, format, None)
202    }
203}
204
205impl<C> TemplatedFile<C, FileSourceString>
206where
207    C: Context,
208{
209    /// Create a new TemplatedFile from an inline string with a custom substitution context.
210    ///
211    /// The [`Context`] controls how variable names are resolved. This is useful when you want
212    /// to supply values from a source other than (or in addition to) the system environment.
213    ///
214    /// See [`shellexpand::env_with_context`](https://docs.rs/shellexpand/latest/shellexpand/fn.env_with_context.html)
215    /// for details on how context-based variable resolution works.
216    ///
217    /// # Examples
218    ///
219    /// Parse a configuration string using a custom [`Context`] that provides the value for
220    /// `CARGO_PKG_NAME` independently of the environment:
221    ///
222    /// ```
223    /// use config::FileFormat;
224    /// use config_shellexpand::{Context, TemplatedFile};
225    /// use rxpect::expect;
226    /// use rxpect::expectations::EqualityExpectations;
227    /// use serde::Deserialize;
228    /// use std::convert::Infallible;
229    ///
230    /// #[derive(Clone, Debug)]
231    /// struct MyContext;
232    ///
233    /// impl Context for MyContext {
234    ///     type Output = &'static str;
235    ///     type Error = Infallible;
236    ///
237    ///     fn lookup(&self, key: &str) -> Result<Option<&'static str>, Infallible> {
238    ///         Ok(match key {
239    ///             "CARGO_PKG_NAME" => Some("my-package"),
240    ///             _ => None,
241    ///         })
242    ///     }
243    /// }
244    ///
245    /// #[derive(Deserialize)]
246    /// struct Config {
247    ///     package_name: String,
248    /// }
249    ///
250    /// let config: Config = config::Config::builder()
251    ///     .add_source(TemplatedFile::from_str_and_context(
252    ///         "package_name = \"${CARGO_PKG_NAME}\"",
253    ///         FileFormat::Toml,
254    ///         Some(MyContext),
255    ///     ))
256    ///     .build()
257    ///     .unwrap()
258    ///     .try_deserialize()
259    ///     .unwrap();
260    ///
261    /// expect(config.package_name).to_equal("my-package".to_string());
262    /// ```
263    pub fn from_str_and_context(
264        contents: impl AsRef<str>,
265        format: FileFormat,
266        context: Option<C>,
267    ) -> TemplatedFile<C, FileSourceString> {
268        Self {
269            source: contents.as_ref().into(),
270            format: Some(format),
271            required: true,
272            context,
273        }
274    }
275}
276
277fn is_file_not_found(error: &(dyn Error + Send + Sync + 'static)) -> bool {
278    // This is a bit brittle, if config changes what kind of error it returns when a file can't be found this might break
279    // We're relying on the test to catch this
280    error
281        .downcast_ref::<std::io::Error>()
282        .is_some_and(|e| e.kind() == std::io::ErrorKind::NotFound)
283}
284
285impl<C, F> Source for TemplatedFile<C, F>
286where
287    C: Context + 'static,
288    F: FileSource<FileFormat> + Send + Sync + 'static,
289{
290    fn clone_into_box(&self) -> Box<dyn Source + Send + Sync> {
291        Box::new(self.clone())
292    }
293
294    fn collect(&self) -> Result<Map<String, Value>, config::ConfigError> {
295        let result = match self.source.resolve(self.format) {
296            Ok(result) => result,
297            // The file is not found, but is also not required
298            Err(error) if !self.required && is_file_not_found(error.as_ref()) => {
299                return Ok(Map::new());
300            }
301            Err(error) => return Err(config::ConfigError::Foreign(error)),
302        };
303        let uri = result.uri().clone();
304        let content = result.content();
305        let format = result.format();
306
307        let content = if let Some(context) = &self.context {
308            shellexpand::env_with_context(&content, |key| context.lookup(key))
309                .map_err(|error| config::ConfigError::Foreign(Box::new(error)))?
310        } else {
311            shellexpand::env(content)
312                .map_err(|error| config::ConfigError::Foreign(Box::new(error)))?
313        };
314        format
315            .parse(uri.as_ref(), content.as_ref())
316            .map_err(|cause| config::ConfigError::FileParse {
317                uri: uri.clone(),
318                cause,
319            })
320    }
321}
322
323#[cfg(test)]
324mod tests {
325    use crate::{Context, TemplatedFile};
326    use config::{ConfigError, FileFormat};
327    use rstest::rstest;
328    use rxpect::expect;
329    use rxpect::expectations::{EqualityExpectations, ResultExpectations};
330    use serde::Deserialize;
331    use std::convert::Infallible;
332    use std::fs::File;
333    use std::io::Read;
334    use std::path::PathBuf;
335
336    #[derive(Debug, Clone, Eq, PartialEq, Deserialize)]
337    struct TestConfig {
338        root_value: i32,
339        section: TestConfigSection,
340    }
341
342    #[derive(Debug, Clone, Eq, PartialEq, Deserialize)]
343    struct TestConfigSection {
344        value: i32,
345        string: String,
346        boolean: bool,
347    }
348
349    fn non_templated_config() -> TestConfig {
350        TestConfig {
351            root_value: 1,
352            section: TestConfigSection {
353                value: 2,
354                string: "foo".to_string(),
355                boolean: true,
356            },
357        }
358    }
359
360    fn templated_config() -> TestConfig {
361        TestConfig {
362            root_value: 3,
363            section: TestConfigSection {
364                value: 2,
365                string: "bar".to_string(),
366                boolean: false,
367            },
368        }
369    }
370
371    #[derive(Clone, Debug)]
372    struct TestContext;
373
374    impl Context for TestContext {
375        type Output = &'static str;
376        type Error = Infallible;
377
378        fn lookup(&self, key: &str) -> Result<Option<Self::Output>, Self::Error> {
379            Ok(match key {
380                "ROOT_VALUE" => Some("3"),
381                "STRING_VALUE" => Some("bar"),
382                "BOOLEAN_VALUE" => Some("false"),
383                _ => None,
384            })
385        }
386    }
387
388    #[derive(Clone, Debug)]
389    struct FailingTestContext;
390
391    impl Context for FailingTestContext {
392        type Output = &'static str;
393        type Error = std::io::Error;
394
395        fn lookup(&self, _key: &str) -> Result<Option<Self::Output>, Self::Error> {
396            Err(std::io::Error::other("Failed to lookup value"))
397        }
398    }
399
400    fn fixture(name: impl AsRef<str>) -> PathBuf {
401        let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
402        path.push("src/test_fixtures");
403        path.push(name.as_ref());
404        path
405    }
406
407    #[rstest]
408    #[case("no_templates.toml")]
409    #[case("no_templates.json")]
410    #[case("no_templates.yaml")]
411    pub fn that_file_with_no_template_expansions_is_passed_through_untouched(
412        #[case] filename: &str,
413    ) {
414        // Given a file with no template expansions
415        let file = TemplatedFile::with_name(fixture(filename));
416
417        // When the file is sourced
418        let config: TestConfig = config::Config::builder()
419            .add_source(file)
420            .build()
421            .expect("Failed to build config")
422            .try_deserialize()
423            .expect("Failed to deserialize config");
424
425        // Then the configuration contains all the expected values
426        expect(config).to_equal(non_templated_config());
427    }
428
429    #[test]
430    pub fn that_file_with_template_expansions_is_expanded() {
431        // Given a file with template expansions
432        let file =
433            TemplatedFile::with_name_and_context(fixture("templated.toml"), Some(TestContext));
434
435        // When the file is sourced
436        let config: TestConfig = config::Config::builder()
437            .add_source(file)
438            .build()
439            .expect("Failed to build config")
440            .try_deserialize()
441            .expect("Failed to deserialize config");
442
443        // Then the configuration contains all the expected values
444        expect(config).to_equal(templated_config());
445    }
446
447    #[test]
448    pub fn that_file_can_be_read_from_string() {
449        // Given a file with template expansions
450        let mut contents = String::new();
451        File::open(fixture("templated.toml"))
452            .expect("Failed to open test fixture")
453            .read_to_string(&mut contents)
454            .expect("Failed to read test fixture");
455        let file =
456            TemplatedFile::from_str_and_context(&contents, FileFormat::Toml, Some(TestContext));
457
458        // When the file is sourced
459        let config: TestConfig = config::Config::builder()
460            .add_source(file)
461            .build()
462            .expect("Failed to build config")
463            .try_deserialize()
464            .expect("Failed to deserialize config");
465
466        // Then the configuration contains all the expected values
467        expect(config).to_equal(templated_config());
468    }
469
470    #[test]
471    pub fn that_missing_file_produces_an_error() {
472        // Given a path to a file that does not exist
473        let file = TemplatedFile::with_name(fixture("does_not_exist.toml"));
474
475        // When the file is sourced
476        let result = config::Config::builder().add_source(file).build();
477
478        // Then the result is a Foreign error (the IO error from config is forwarded unchanged)
479        expect(result).to_be_err_matching(|error| matches!(error, ConfigError::Foreign(_)));
480    }
481
482    #[test]
483    pub fn that_file_format_errors_are_propagated() {
484        // Given a file whose content is invalid after shellexpand passes it through unchanged
485        // (shellexpand does not fail on malformed syntax — it leaves it as-is, and the
486        // downstream format parser then fails)
487        let file = TemplatedFile::with_name(fixture("toml_syntax_error.toml"));
488
489        // When the file is sourced
490        let result = config::Config::builder().add_source(file).build();
491
492        // Then the result is a FileParse error (the parse error from config is forwarded unchanged)
493        expect(result).to_be_err_matching(|error| matches!(error, ConfigError::FileParse { .. }));
494    }
495
496    #[test]
497    pub fn that_context_errors_are_propagated() {
498        // Given a file with template expansions
499        let file = TemplatedFile::with_name_and_context(
500            fixture("templated.toml"),
501            Some(FailingTestContext),
502        );
503
504        // When the file is sourced
505        let result = config::Config::builder().add_source(file).build();
506
507        // Then the result is an error
508        expect(result).to_be_err_matching(|error| match error {
509            ConfigError::Foreign(error) => error
510                .downcast_ref::<shellexpand::LookupError<std::io::Error>>()
511                .is_some_and(|e| e.cause.kind() == std::io::ErrorKind::Other),
512            _ => false,
513        });
514    }
515
516    #[test]
517    pub fn that_lookup_errors_are_propagated() {
518        // Given a file with template expansions
519        let file = TemplatedFile::from_str(
520            r#"value = ${THIS_VARIABLE_BETTER_NOT_BE_SET_IN_YOUR_ENVIRONMENT_OR_THIS_TEST_WILL_FAIL}"#,
521            FileFormat::Toml,
522        );
523
524        // When the file is sourced
525        let result = config::Config::builder().add_source(file).build();
526
527        // Then the result is an error
528        expect(result).to_be_err_matching(|error| match error {
529            ConfigError::Foreign(error) => error
530                .downcast_ref::<shellexpand::LookupError<std::env::VarError>>()
531                .is_some_and(|e| e.cause == std::env::VarError::NotPresent),
532            _ => false,
533        });
534    }
535
536    #[test]
537    pub fn that_optional_file_is_ignored_when_not_found() {
538        // Given a file that doesn't exist
539        let file = TemplatedFile::with_name("does_not_exist.toml").required(false);
540
541        // When the file is sourced
542        let result = config::Config::builder().add_source(file).build();
543
544        // Then the result is not an error
545        expect(result).to_be_ok();
546    }
547}