spin_runtime_config/
lib.rs

1use std::path::{Path, PathBuf};
2
3use anyhow::Context as _;
4use spin_common::ui::quoted_path;
5use spin_factor_key_value::runtime_config::spin::{self as key_value};
6use spin_factor_key_value::KeyValueFactor;
7use spin_factor_llm::{spin as llm, LlmFactor};
8use spin_factor_outbound_http::OutboundHttpFactor;
9use spin_factor_outbound_mqtt::OutboundMqttFactor;
10use spin_factor_outbound_mysql::OutboundMysqlFactor;
11use spin_factor_outbound_networking::runtime_config::spin::SpinRuntimeConfig as OutboundNetworkingSpinRuntimeConfig;
12use spin_factor_outbound_networking::OutboundNetworkingFactor;
13use spin_factor_outbound_pg::OutboundPgFactor;
14use spin_factor_outbound_redis::OutboundRedisFactor;
15use spin_factor_sqlite::SqliteFactor;
16use spin_factor_variables::VariablesFactor;
17use spin_factor_wasi::WasiFactor;
18use spin_factors::runtime_config::toml::GetTomlValue as _;
19use spin_factors::{
20    runtime_config::toml::TomlKeyTracker, FactorRuntimeConfigSource, RuntimeConfigSourceFinalizer,
21};
22use spin_key_value_spin::{SpinKeyValueRuntimeConfig, SpinKeyValueStore};
23use spin_sqlite as sqlite;
24use spin_trigger::cli::UserProvidedPath;
25use toml::Value;
26
27pub mod variables;
28
29/// The default state directory for the trigger.
30pub const DEFAULT_STATE_DIR: &str = ".spin";
31
32/// A runtime configuration which has been resolved from a runtime config source.
33///
34/// Includes other pieces of configuration that are used to resolve the runtime configuration.
35pub struct ResolvedRuntimeConfig<T> {
36    /// The resolved runtime configuration.
37    pub runtime_config: T,
38    /// The resolver used to resolve key-value stores from runtime configuration.
39    pub key_value_resolver: key_value::RuntimeConfigResolver,
40    /// The resolver used to resolve sqlite databases from runtime configuration.
41    pub sqlite_resolver: sqlite::RuntimeConfigResolver,
42    /// The fully resolved state directory.
43    ///
44    /// `None` is used for an "unset" state directory which each factor will treat differently.
45    pub state_dir: Option<PathBuf>,
46    /// The fully resolved log directory.
47    ///
48    /// `None` is used for an "unset" log directory.
49    pub log_dir: Option<PathBuf>,
50    /// The maximum memory allocation limit.
51    pub max_instance_memory: Option<usize>,
52    /// The input TOML, for informational summaries.
53    pub toml: toml::Table,
54}
55
56impl<T> ResolvedRuntimeConfig<T> {
57    pub fn summarize(&self, runtime_config_path: Option<&Path>) {
58        let summarize_labeled_typed_tables = |key| {
59            let mut summaries = vec![];
60            if let Some(tables) = self.toml.get(key).and_then(Value::as_table) {
61                for (label, config) in tables {
62                    if let Some(ty) = config.get("type").and_then(Value::as_str) {
63                        summaries.push(format!("[{key}.{label}: {ty}]"))
64                    }
65                }
66            }
67            summaries
68        };
69
70        let mut summaries = vec![];
71        // [key_value_store.<label>: <type>]
72        summaries.extend(summarize_labeled_typed_tables("key_value_store"));
73        // [sqlite_database.<label>: <type>]
74        summaries.extend(summarize_labeled_typed_tables("sqlite_database"));
75        // [llm_compute: <type>]
76        if let Some(table) = self.toml.get("llm_compute").and_then(Value::as_table) {
77            if let Some(ty) = table.get("type").and_then(Value::as_str) {
78                summaries.push(format!("[llm_compute: {ty}"));
79            }
80        }
81        if !summaries.is_empty() {
82            let summaries = summaries.join(", ");
83            let from_path = runtime_config_path
84                .map(|path| format!("from {}", quoted_path(path)))
85                .unwrap_or_default();
86            println!("Using runtime config {summaries} {from_path}");
87        }
88    }
89}
90
91impl<T> ResolvedRuntimeConfig<T>
92where
93    T: for<'a, 'b> TryFrom<TomlRuntimeConfigSource<'a, 'b>>,
94    for<'a, 'b> <T as TryFrom<TomlRuntimeConfigSource<'a, 'b>>>::Error: Into<anyhow::Error>,
95{
96    /// Creates a new resolved runtime configuration from a runtime config source TOML file.
97    ///
98    /// `provided_state_dir` is the explicitly provided state directory, if any.
99    pub fn from_file(
100        runtime_config_path: Option<&Path>,
101        local_app_dir: Option<PathBuf>,
102        provided_state_dir: UserProvidedPath,
103        provided_log_dir: UserProvidedPath,
104    ) -> anyhow::Result<Self> {
105        let toml = match runtime_config_path {
106            Some(runtime_config_path) => {
107                let file = std::fs::read_to_string(runtime_config_path).with_context(|| {
108                    format!(
109                        "failed to read runtime config file '{}'",
110                        runtime_config_path.display()
111                    )
112                })?;
113                toml::from_str(&file).with_context(|| {
114                    format!(
115                        "failed to parse runtime config file '{}' as toml",
116                        runtime_config_path.display()
117                    )
118                })?
119            }
120            None => Default::default(),
121        };
122        let toml_resolver =
123            TomlResolver::new(&toml, local_app_dir, provided_state_dir, provided_log_dir);
124
125        Self::new(toml_resolver, runtime_config_path)
126    }
127
128    /// Creates a new resolved runtime configuration from a TOML table.
129    pub fn new(
130        toml_resolver: TomlResolver<'_>,
131        runtime_config_path: Option<&Path>,
132    ) -> anyhow::Result<Self> {
133        let runtime_config_dir = runtime_config_path
134            .and_then(Path::parent)
135            .map(ToOwned::to_owned);
136        let state_dir = toml_resolver.state_dir()?;
137        let outbound_networking = runtime_config_dir
138            .clone()
139            .map(OutboundNetworkingSpinRuntimeConfig::new);
140        let key_value_resolver = key_value_config_resolver(runtime_config_dir, state_dir.clone());
141        let sqlite_resolver = sqlite_config_resolver(state_dir.clone())
142            .context("failed to resolve sqlite runtime config")?;
143
144        let toml = toml_resolver.toml();
145        let log_dir = toml_resolver.log_dir()?;
146        let max_instance_memory = toml_resolver.max_instance_memory()?;
147
148        let source = TomlRuntimeConfigSource::new(
149            toml_resolver,
150            &key_value_resolver,
151            outbound_networking.as_ref(),
152            &sqlite_resolver,
153        );
154
155        // Note: all valid fields in the runtime config must have been referenced at
156        // this point or the finalizer will fail due to `validate_all_keys_used`
157        // not passing.
158        let runtime_config: T = source.try_into().map_err(Into::into)?;
159
160        Ok(Self {
161            runtime_config,
162            key_value_resolver,
163            sqlite_resolver,
164            state_dir,
165            log_dir,
166            max_instance_memory,
167            toml,
168        })
169    }
170
171    /// The fully resolved state directory.
172    pub fn state_dir(&self) -> Option<PathBuf> {
173        self.state_dir.clone()
174    }
175
176    /// The fully resolved state directory.
177    pub fn log_dir(&self) -> Option<PathBuf> {
178        self.log_dir.clone()
179    }
180
181    /// The maximum memory allocation limit.
182    pub fn max_instance_memory(&self) -> Option<usize> {
183        self.max_instance_memory
184    }
185}
186
187#[derive(Clone, Debug)]
188/// Resolves runtime configuration from a TOML file.
189pub struct TomlResolver<'a> {
190    table: TomlKeyTracker<'a>,
191    /// The local app directory.
192    local_app_dir: Option<PathBuf>,
193    /// Explicitly provided state directory.
194    state_dir: UserProvidedPath,
195    /// Explicitly provided log directory.
196    log_dir: UserProvidedPath,
197}
198
199impl<'a> TomlResolver<'a> {
200    /// Create a new TOML resolver.
201    pub fn new(
202        table: &'a toml::Table,
203        local_app_dir: Option<PathBuf>,
204        state_dir: UserProvidedPath,
205        log_dir: UserProvidedPath,
206    ) -> Self {
207        Self {
208            table: TomlKeyTracker::new(table),
209            local_app_dir,
210            state_dir,
211            log_dir,
212        }
213    }
214
215    /// Get the configured state_directory.
216    ///
217    /// Errors if the path cannot be converted to an absolute path.
218    pub fn state_dir(&self) -> std::io::Result<Option<PathBuf>> {
219        let mut state_dir = self.state_dir.clone();
220        // If the state_dir is not explicitly provided, check the toml.
221        if matches!(state_dir, UserProvidedPath::Default) {
222            let from_toml =
223                self.table
224                    .get("state_dir")
225                    .and_then(|v| v.as_str())
226                    .map(|toml_value| {
227                        if toml_value.is_empty() {
228                            // If the toml value is empty, treat it as unset.
229                            UserProvidedPath::Unset
230                        } else {
231                            // Otherwise, treat the toml value as a provided path.
232                            UserProvidedPath::Provided(PathBuf::from(toml_value))
233                        }
234                    });
235            // If toml value is not provided, use the original value after all.
236            state_dir = from_toml.unwrap_or(state_dir);
237        }
238
239        match (state_dir, &self.local_app_dir) {
240            (UserProvidedPath::Provided(p), _) => Ok(Some(std::path::absolute(p)?)),
241            (UserProvidedPath::Default, Some(local_app_dir)) => {
242                Ok(Some(local_app_dir.join(".spin")))
243            }
244            (UserProvidedPath::Default | UserProvidedPath::Unset, _) => Ok(None),
245        }
246    }
247
248    /// Get the configured log directory.
249    ///
250    /// Errors if the path cannot be converted to an absolute path.
251    pub fn log_dir(&self) -> std::io::Result<Option<PathBuf>> {
252        let mut log_dir = self.log_dir.clone();
253        // If the log_dir is not explicitly provided, check the toml.
254        if matches!(log_dir, UserProvidedPath::Default) {
255            let from_toml = self
256                .table
257                .get("log_dir")
258                .and_then(|v| v.as_str())
259                .map(|toml_value| {
260                    if toml_value.is_empty() {
261                        // If the toml value is empty, treat it as unset.
262                        UserProvidedPath::Unset
263                    } else {
264                        // Otherwise, treat the toml value as a provided path.
265                        UserProvidedPath::Provided(PathBuf::from(toml_value))
266                    }
267                });
268            // If toml value is not provided, use the original value after all.
269            log_dir = from_toml.unwrap_or(log_dir);
270        }
271
272        match log_dir {
273            UserProvidedPath::Provided(p) => Ok(Some(std::path::absolute(p)?)),
274            UserProvidedPath::Default => Ok(self.state_dir()?.map(|p| p.join("logs"))),
275            UserProvidedPath::Unset => Ok(None),
276        }
277    }
278
279    /// Get the configured maximum memory allocation limit.
280    pub fn max_instance_memory(&self) -> anyhow::Result<Option<usize>> {
281        self.table
282            .get("max_instance_memory")
283            .and_then(|v| v.as_integer())
284            .map(|toml_value| toml_value.try_into())
285            .transpose()
286            .map_err(Into::into)
287    }
288
289    /// Validate that all keys in the TOML file have been used.
290    pub fn validate_all_keys_used(&self) -> spin_factors::Result<()> {
291        self.table.validate_all_keys_used()
292    }
293
294    fn toml(&self) -> toml::Table {
295        self.table.as_ref().clone()
296    }
297}
298
299/// The TOML based runtime configuration source Spin CLI.
300pub struct TomlRuntimeConfigSource<'a, 'b> {
301    toml: TomlResolver<'b>,
302    key_value: &'a key_value::RuntimeConfigResolver,
303    outbound_networking: Option<&'a OutboundNetworkingSpinRuntimeConfig>,
304    sqlite: &'a sqlite::RuntimeConfigResolver,
305}
306
307impl<'a, 'b> TomlRuntimeConfigSource<'a, 'b> {
308    pub fn new(
309        toml_resolver: TomlResolver<'b>,
310        key_value: &'a key_value::RuntimeConfigResolver,
311        outbound_networking: Option<&'a OutboundNetworkingSpinRuntimeConfig>,
312        sqlite: &'a sqlite::RuntimeConfigResolver,
313    ) -> Self {
314        Self {
315            toml: toml_resolver,
316            key_value,
317            outbound_networking,
318            sqlite,
319        }
320    }
321}
322
323impl FactorRuntimeConfigSource<KeyValueFactor> for TomlRuntimeConfigSource<'_, '_> {
324    fn get_runtime_config(
325        &mut self,
326    ) -> anyhow::Result<Option<spin_factor_key_value::RuntimeConfig>> {
327        Ok(Some(self.key_value.resolve(Some(&self.toml.table))?))
328    }
329}
330
331impl FactorRuntimeConfigSource<OutboundNetworkingFactor> for TomlRuntimeConfigSource<'_, '_> {
332    fn get_runtime_config(
333        &mut self,
334    ) -> anyhow::Result<Option<<OutboundNetworkingFactor as spin_factors::Factor>::RuntimeConfig>>
335    {
336        let Some(tls) = self.outbound_networking else {
337            return Ok(None);
338        };
339        tls.config_from_table(&self.toml.table)
340    }
341}
342
343impl FactorRuntimeConfigSource<VariablesFactor> for TomlRuntimeConfigSource<'_, '_> {
344    fn get_runtime_config(
345        &mut self,
346    ) -> anyhow::Result<Option<<VariablesFactor as spin_factors::Factor>::RuntimeConfig>> {
347        Ok(Some(variables::runtime_config_from_toml(&self.toml.table)?))
348    }
349}
350
351impl FactorRuntimeConfigSource<OutboundPgFactor> for TomlRuntimeConfigSource<'_, '_> {
352    fn get_runtime_config(&mut self) -> anyhow::Result<Option<()>> {
353        Ok(None)
354    }
355}
356
357impl FactorRuntimeConfigSource<OutboundMysqlFactor> for TomlRuntimeConfigSource<'_, '_> {
358    fn get_runtime_config(&mut self) -> anyhow::Result<Option<()>> {
359        Ok(None)
360    }
361}
362
363impl FactorRuntimeConfigSource<LlmFactor> for TomlRuntimeConfigSource<'_, '_> {
364    fn get_runtime_config(&mut self) -> anyhow::Result<Option<spin_factor_llm::RuntimeConfig>> {
365        llm::runtime_config_from_toml(&self.toml.table, self.toml.state_dir()?)
366    }
367}
368
369impl FactorRuntimeConfigSource<OutboundRedisFactor> for TomlRuntimeConfigSource<'_, '_> {
370    fn get_runtime_config(&mut self) -> anyhow::Result<Option<()>> {
371        Ok(None)
372    }
373}
374
375impl FactorRuntimeConfigSource<WasiFactor> for TomlRuntimeConfigSource<'_, '_> {
376    fn get_runtime_config(&mut self) -> anyhow::Result<Option<()>> {
377        Ok(None)
378    }
379}
380
381impl FactorRuntimeConfigSource<OutboundHttpFactor> for TomlRuntimeConfigSource<'_, '_> {
382    fn get_runtime_config(
383        &mut self,
384    ) -> anyhow::Result<Option<<OutboundHttpFactor as spin_factors::Factor>::RuntimeConfig>> {
385        spin_factor_outbound_http::runtime_config::spin::config_from_table(&self.toml.table)
386    }
387}
388
389impl FactorRuntimeConfigSource<OutboundMqttFactor> for TomlRuntimeConfigSource<'_, '_> {
390    fn get_runtime_config(&mut self) -> anyhow::Result<Option<()>> {
391        Ok(None)
392    }
393}
394
395impl FactorRuntimeConfigSource<SqliteFactor> for TomlRuntimeConfigSource<'_, '_> {
396    fn get_runtime_config(&mut self) -> anyhow::Result<Option<spin_factor_sqlite::RuntimeConfig>> {
397        Ok(Some(self.sqlite.resolve(&self.toml.table)?))
398    }
399}
400
401impl RuntimeConfigSourceFinalizer for TomlRuntimeConfigSource<'_, '_> {
402    fn finalize(&mut self) -> anyhow::Result<()> {
403        Ok(self.toml.validate_all_keys_used()?)
404    }
405}
406
407const DEFAULT_KEY_VALUE_STORE_LABEL: &str = "default";
408
409/// The key-value runtime configuration resolver.
410///
411/// Takes a base path that all local key-value stores which are configured with
412/// relative paths will be relative to. It also takes a default store base path
413/// which will be used as the directory for the default store.
414pub fn key_value_config_resolver(
415    local_store_base_path: Option<PathBuf>,
416    default_store_base_path: Option<PathBuf>,
417) -> key_value::RuntimeConfigResolver {
418    let mut key_value = key_value::RuntimeConfigResolver::new();
419
420    // Register the supported store types.
421    // Unwraps are safe because the store types are known to not overlap.
422    key_value
423        .register_store_type(spin_key_value_spin::SpinKeyValueStore::new(
424            local_store_base_path.clone(),
425        ))
426        .unwrap();
427    key_value
428        .register_store_type(spin_key_value_redis::RedisKeyValueStore::new())
429        .unwrap();
430    key_value
431        .register_store_type(spin_key_value_azure::AzureKeyValueStore::new(None))
432        .unwrap();
433    key_value
434        .register_store_type(spin_key_value_aws::AwsDynamoKeyValueStore::new())
435        .unwrap();
436
437    // Add handling of "default" store.
438    let default_store_path = default_store_base_path.map(|p| p.join(DEFAULT_SPIN_STORE_FILENAME));
439    // Unwraps are safe because the store is known to be serializable as toml.
440    key_value
441        .add_default_store::<SpinKeyValueStore>(
442            DEFAULT_KEY_VALUE_STORE_LABEL,
443            SpinKeyValueRuntimeConfig::new(default_store_path),
444        )
445        .unwrap();
446
447    key_value
448}
449
450/// The default filename for the SQLite database.
451const DEFAULT_SPIN_STORE_FILENAME: &str = "sqlite_key_value.db";
452
453/// The sqlite runtime configuration resolver.
454///
455/// Takes a path to the directory where the default database should be stored.
456/// If the path is `None`, the default database will be in-memory.
457fn sqlite_config_resolver(
458    default_database_dir: Option<PathBuf>,
459) -> anyhow::Result<sqlite::RuntimeConfigResolver> {
460    let local_database_dir =
461        std::env::current_dir().context("failed to get current working directory")?;
462    Ok(sqlite::RuntimeConfigResolver::new(
463        default_database_dir,
464        local_database_dir,
465    ))
466}
467
468#[cfg(test)]
469mod tests {
470    use std::{collections::HashMap, sync::Arc};
471
472    use spin_factors::RuntimeFactors;
473    use spin_factors_test::TestEnvironment;
474
475    use super::*;
476
477    /// Define a test factor with the given field and factor type.
478    macro_rules! define_test_factor {
479        ($field:ident : $factor:ty) => {
480            #[derive(RuntimeFactors)]
481            struct TestFactors {
482                $field: $factor,
483            }
484            impl TryFrom<TomlRuntimeConfigSource<'_, '_>> for TestFactorsRuntimeConfig {
485                type Error = anyhow::Error;
486
487                fn try_from(value: TomlRuntimeConfigSource<'_, '_>) -> Result<Self, Self::Error> {
488                    Self::from_source(value)
489                }
490            }
491            fn resolve_toml(
492                toml: toml::Table,
493                path: impl AsRef<std::path::Path>,
494            ) -> anyhow::Result<ResolvedRuntimeConfig<TestFactorsRuntimeConfig>> {
495                ResolvedRuntimeConfig::<TestFactorsRuntimeConfig>::new(
496                    toml_resolver(&toml),
497                    Some(path.as_ref()),
498                )
499            }
500        };
501    }
502
503    #[test]
504    fn sqlite_is_configured_correctly() {
505        define_test_factor!(sqlite: SqliteFactor);
506
507        impl TestFactorsRuntimeConfig {
508            /// Get the connection creators for the configured sqlite databases.
509            fn connection_creators(
510                &self,
511            ) -> &HashMap<String, Arc<dyn spin_factor_sqlite::ConnectionCreator>> {
512                &self.sqlite.as_ref().unwrap().connection_creators
513            }
514
515            /// Get the labels of the configured sqlite databases.
516            fn configured_labels(&self) -> Vec<&str> {
517                let mut configured_labels = self
518                    .connection_creators()
519                    .keys()
520                    .map(|s| s.as_str())
521                    .collect::<Vec<_>>();
522                // Sort the labels to ensure consistent ordering.
523                configured_labels.sort();
524                configured_labels
525            }
526        }
527
528        // Test that the default label is added if not provided.
529        let toml = toml::toml! {
530            [sqlite_database.foo]
531            type = "spin"
532        };
533        assert_eq!(
534            resolve_toml(toml, ".")
535                .unwrap()
536                .runtime_config
537                .configured_labels(),
538            vec!["default", "foo"]
539        );
540
541        // Test that the default label is added with an empty toml config.
542        let toml = toml::Table::new();
543        let runtime_config = resolve_toml(toml, "config.toml").unwrap().runtime_config;
544        assert_eq!(runtime_config.configured_labels(), vec!["default"]);
545    }
546
547    #[test]
548    fn key_value_is_configured_correctly() {
549        define_test_factor!(key_value: KeyValueFactor);
550        impl TestFactorsRuntimeConfig {
551            /// Get whether the store manager exists for the given label.
552            fn has_store_manager(&self, label: &str) -> bool {
553                self.key_value.as_ref().unwrap().has_store_manager(label)
554            }
555        }
556
557        // Test that the default label is added if not provided.
558        let toml = toml::toml! {
559            [key_value_store.foo]
560            type = "spin"
561        };
562        let runtime_config = resolve_toml(toml, "config.toml").unwrap().runtime_config;
563        assert!(["default", "foo"]
564            .iter()
565            .all(|label| runtime_config.has_store_manager(label)));
566    }
567
568    #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
569    async fn custom_spin_key_value_works_with_custom_paths() -> anyhow::Result<()> {
570        use spin_world::v2::key_value::HostStore;
571        define_test_factor!(key_value: KeyValueFactor);
572        let tmp_dir = tempfile::TempDir::with_prefix("example")?;
573        let absolute_path = tmp_dir.path().join("foo/custom.db");
574        let relative_path = tmp_dir.path().join("custom.db");
575        // Check that the dbs do not exist yet - they will exist by the end of the test
576        assert!(!absolute_path.exists());
577        assert!(!relative_path.exists());
578
579        let path_str = absolute_path.to_str().unwrap();
580        let runtime_config = toml::toml! {
581            [key_value_store.absolute]
582            type = "spin"
583            path = path_str
584
585            [key_value_store.relative]
586            type = "spin"
587            path = "custom.db"
588        };
589        let factors = TestFactors {
590            key_value: KeyValueFactor::new(),
591        };
592        let env = TestEnvironment::new(factors)
593            .extend_manifest(toml::toml! {
594                [component.test-component]
595                source = "does-not-exist.wasm"
596                key_value_stores = ["absolute", "relative"]
597            })
598            .runtime_config(
599                resolve_toml(runtime_config, tmp_dir.path().join("runtime-config.toml"))
600                    .unwrap()
601                    .runtime_config,
602            )?;
603        let mut state = env.build_instance_state().await?;
604
605        // Actually get a key since store creation is lazy
606        let store = state.key_value.open("absolute".to_owned()).await??;
607        let _ = state.key_value.get(store, "foo".to_owned()).await??;
608
609        let store = state.key_value.open("relative".to_owned()).await??;
610        let _ = state.key_value.get(store, "foo".to_owned()).await??;
611
612        // Check that the dbs have been created
613        assert!(absolute_path.exists());
614        assert!(relative_path.exists());
615        Ok(())
616    }
617
618    fn toml_resolver(toml: &toml::Table) -> TomlResolver<'_> {
619        TomlResolver::new(
620            toml,
621            None,
622            UserProvidedPath::Default,
623            UserProvidedPath::Default,
624        )
625    }
626
627    #[test]
628    fn dirs_are_resolved() {
629        define_test_factor!(sqlite: SqliteFactor);
630
631        let toml = toml::toml! {
632            state_dir = "/foo"
633            log_dir = "/bar"
634        };
635        resolve_toml(toml, "config.toml").unwrap();
636    }
637
638    #[test]
639    fn fails_to_resolve_with_unused_key() {
640        define_test_factor!(sqlite: SqliteFactor);
641
642        let toml = toml::toml! {
643            baz = "/baz"
644        };
645        // assert returns an error with value "unused runtime config key(s): local_app_dir"
646        let Err(e) = resolve_toml(toml, "config.toml") else {
647            panic!("Should not be able to resolve unknown key");
648        };
649        assert_eq!(e.to_string(), "unused runtime config key(s): baz");
650    }
651}