Skip to main content

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