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}