hyperi_rustlib/config/mod.rs
1// Project: hyperi-rustlib
2// File: src/config/mod.rs
3// Purpose: 7-layer configuration cascade
4// Language: Rust
5//
6// License: FSL-1.1-ALv2
7// Copyright: (c) 2026 HYPERI PTY LIMITED
8
9//! Configuration management with 8-layer cascade.
10//!
11//! Provides a hierarchical configuration system matching hyperi-pylib (Python)
12//! and hyperi-golib (Go). Configuration is loaded from multiple sources with
13//! clear priority ordering.
14//!
15//! ## Cascade Priority (highest to lowest)
16//!
17//! 1. CLI arguments (via clap integration)
18//! 2. Environment variables (with configurable prefix)
19//! 3. `.env` file (loaded via dotenvy)
20//! 4. PostgreSQL (optional, via `config-postgres` feature)
21//! 5. `settings.{env}.yaml` (environment-specific)
22//! 6. `settings.yaml` (base settings)
23//! 7. `defaults.yaml`
24//! 8. Hard-coded defaults
25//!
26//! ## How .env Files Work in the Cascade
27//!
28//! The `.env` file is loaded early in the cascade using `dotenvy::dotenv()`.
29//! This populates the process environment, so `.env` values become available
30//! via `std::env::var()`. The cascade then reads environment variables at
31//! layer 2, which includes both real environment variables AND `.env` values.
32//!
33//! **Important**: Real environment variables take precedence over `.env` values
34//! because `dotenvy` does NOT overwrite existing environment variables.
35//!
36//! ```text
37//! Priority (highest wins):
38//! ┌─────────────────────────────────────────────────────────────┐
39//! │ 1. CLI arguments (merged via merge_cli()) │
40//! ├─────────────────────────────────────────────────────────────┤
41//! │ 2. Environment variables (PREFIX_KEY) │
42//! │ ↑ Includes .env values (loaded into env by dotenvy) │
43//! │ ↑ Real env vars win over .env (dotenvy doesn't overwrite)│
44//! ├─────────────────────────────────────────────────────────────┤
45//! │ 3. PostgreSQL config (if config-postgres feature enabled) │
46//! ├─────────────────────────────────────────────────────────────┤
47//! │ 4. settings.{env}.yaml (e.g., settings.production.yaml) │
48//! ├─────────────────────────────────────────────────────────────┤
49//! │ 5. settings.yaml │
50//! ├─────────────────────────────────────────────────────────────┤
51//! │ 6. defaults.yaml │
52//! ├─────────────────────────────────────────────────────────────┤
53//! │ 7. Hard-coded defaults (lowest priority) │
54//! └─────────────────────────────────────────────────────────────┘
55//! ```
56//!
57//! ## Environment Variable Naming
58//!
59//! Use the `env_compat` module for standardised environment variable names
60//! with legacy alias support and deprecation warnings.
61//!
62//! ## Example
63//!
64//! ```rust,no_run
65//! use hyperi_rustlib::config::{self, ConfigOptions};
66//!
67//! // Initialise with env prefix
68//! config::setup(ConfigOptions {
69//! env_prefix: "MYAPP".into(),
70//! ..Default::default()
71//! }).unwrap();
72//!
73//! // Access configuration
74//! let cfg = config::get();
75//! let host = cfg.get_string("database.host").unwrap_or_default();
76//! let port = cfg.get_int("database.port").unwrap_or(5432);
77//! ```
78
79pub mod env_compat;
80pub mod flat_env;
81pub mod registry;
82pub mod sensitive;
83
84#[cfg(feature = "config-reload")]
85pub mod reloader;
86
87#[cfg(feature = "config-reload")]
88pub mod shared;
89
90#[cfg(feature = "config-postgres")]
91pub mod postgres;
92
93use std::path::PathBuf;
94use std::sync::OnceLock;
95use std::time::Duration;
96
97use figment::Figment;
98use figment::providers::{Env, Format, Serialized, Yaml};
99use serde::de::DeserializeOwned;
100use thiserror::Error;
101
102use crate::env::get_app_env;
103
104#[cfg(feature = "config-postgres")]
105use self::postgres::{PostgresConfig, PostgresConfigError, PostgresConfigSource};
106
107/// Global configuration singleton.
108static CONFIG: OnceLock<Config> = OnceLock::new();
109
110/// Configuration errors.
111#[derive(Debug, Error)]
112pub enum ConfigError {
113 /// Failed to load configuration file.
114 #[error("failed to load config file '{path}': {message}")]
115 LoadError { path: PathBuf, message: String },
116
117 /// Failed to extract configuration value.
118 #[error("failed to extract config: {0}")]
119 ExtractError(#[from] figment::Error),
120
121 /// Missing required configuration key.
122 #[error("missing required config key: {0}")]
123 MissingKey(String),
124
125 /// Invalid configuration value.
126 #[error("invalid config value for '{key}': {reason}")]
127 InvalidValue { key: String, reason: String },
128
129 /// Configuration already initialised.
130 #[error("configuration already initialised")]
131 AlreadyInitialised,
132
133 /// Configuration not initialised.
134 #[error("configuration not initialised - call config::setup() first")]
135 NotInitialised,
136
137 /// PostgreSQL config error.
138 #[cfg(feature = "config-postgres")]
139 #[error("PostgreSQL config error: {0}")]
140 Postgres(#[from] PostgresConfigError),
141}
142
143/// Configuration options.
144#[derive(Debug, Clone)]
145pub struct ConfigOptions {
146 /// Environment variable prefix (e.g., "MYAPP" for MYAPP_DATABASE_HOST).
147 pub env_prefix: String,
148
149 /// Override the detected app environment (dev, staging, prod).
150 pub app_env: Option<String>,
151
152 /// Application name for user-scoped config discovery.
153 ///
154 /// When set, enables searching `~/.config/{app_name}/` for config files.
155 /// Falls back to `APP_NAME` or `HYPERI_LIB_APP_NAME` environment variables
156 /// if not explicitly provided.
157 ///
158 /// Default: None (user config directory not searched)
159 pub app_name: Option<String>,
160
161 /// Additional paths to search for config files.
162 pub config_paths: Vec<PathBuf>,
163
164 /// Whether to load `.env` files.
165 ///
166 /// When enabled, loads `.env` files in this order (lowest to highest priority):
167 /// 1. `~/.env` (home directory - global defaults)
168 /// 2. Project `.env` (current directory - project overrides)
169 ///
170 /// Later files override earlier ones. Real environment variables always
171 /// take precedence over `.env` values.
172 pub load_dotenv: bool,
173
174 /// Whether to load home directory `.env` file (`~/.env`).
175 ///
176 /// Only applies when `load_dotenv` is true.
177 /// Default: false (opt-in, matching hyperi-pylib)
178 pub load_home_dotenv: bool,
179
180 /// PostgreSQL config source (optional, requires `config-postgres` feature).
181 #[cfg(feature = "config-postgres")]
182 pub postgres: Option<PostgresConfigSource>,
183}
184
185impl Default for ConfigOptions {
186 fn default() -> Self {
187 Self {
188 env_prefix: String::new(),
189 app_env: None,
190 app_name: None,
191 config_paths: Vec::new(),
192 load_dotenv: true,
193 load_home_dotenv: false,
194 #[cfg(feature = "config-postgres")]
195 postgres: None,
196 }
197 }
198}
199
200/// Configuration manager wrapping Figment.
201#[derive(Debug)]
202pub struct Config {
203 figment: Figment,
204 env_prefix: String,
205}
206
207impl Config {
208 /// Resolve the effective app name from explicit value or environment.
209 fn resolve_app_name(explicit: Option<&str>) -> Option<String> {
210 explicit
211 .map(String::from)
212 .or_else(|| std::env::var("APP_NAME").ok())
213 .or_else(|| std::env::var("HYPERI_LIB_APP_NAME").ok())
214 }
215
216 /// Create a new configuration with the given options.
217 ///
218 /// # Errors
219 ///
220 /// Returns an error if configuration loading fails.
221 pub fn new(opts: ConfigOptions) -> Result<Self, ConfigError> {
222 let app_env = opts.app_env.unwrap_or_else(get_app_env);
223 let resolved_app_name = Self::resolve_app_name(opts.app_name.as_deref());
224 let app_name_ref = resolved_app_name.as_deref();
225
226 // Load .env files in cascade order (lowest to highest priority)
227 // Home directory .env provides global defaults
228 // Project .env provides project-specific overrides
229 // Real environment variables always win (dotenvy doesn't overwrite)
230 if opts.load_dotenv {
231 Self::load_dotenv_cascade(opts.load_home_dotenv);
232 }
233
234 // Build the cascade (lowest to highest priority)
235 let mut figment = Figment::new();
236
237 // 7. Hard-coded defaults (lowest priority)
238 figment = figment.merge(Serialized::defaults(HardcodedDefaults::default()));
239
240 // 6. defaults.yaml
241 for path in Self::find_config_files("defaults", &opts.config_paths, app_name_ref) {
242 figment = figment.merge(Yaml::file(&path));
243 }
244
245 // 5. settings.yaml
246 for path in Self::find_config_files("settings", &opts.config_paths, app_name_ref) {
247 figment = figment.merge(Yaml::file(&path));
248 }
249
250 // 4. settings.{env}.yaml
251 let env_settings = format!("settings.{app_env}");
252 for path in Self::find_config_files(&env_settings, &opts.config_paths, app_name_ref) {
253 figment = figment.merge(Yaml::file(&path));
254 }
255
256 // 3. .env file values are already loaded into env vars
257
258 // 2. Environment variables (with prefix)
259 // Keys are lowercased: TEST_DATABASE_HOST -> database_host
260 // Use double underscore for nesting: TEST_DATABASE__HOST -> database.host
261 if !opts.env_prefix.is_empty() {
262 figment = figment.merge(Env::prefixed(&format!("{}_", opts.env_prefix)).split("__"));
263 }
264
265 // 1. CLI args would be merged by the application via merge_cli()
266
267 Ok(Self {
268 figment,
269 env_prefix: opts.env_prefix,
270 })
271 }
272
273 /// Create a new configuration with async loading (for PostgreSQL support).
274 ///
275 /// This method loads configuration asynchronously, allowing PostgreSQL to be
276 /// used as a config source. PostgreSQL sits above file-based config in the
277 /// cascade, so database values override file values.
278 ///
279 /// # Errors
280 ///
281 /// Returns an error if configuration loading fails.
282 #[cfg(feature = "config-postgres")]
283 pub async fn new_async(opts: ConfigOptions) -> Result<Self, ConfigError> {
284 let app_env = opts.app_env.clone().unwrap_or_else(get_app_env);
285 let resolved_app_name = Self::resolve_app_name(opts.app_name.as_deref());
286 let app_name_ref = resolved_app_name.as_deref();
287
288 // Load .env files in cascade order (lowest to highest priority)
289 if opts.load_dotenv {
290 Self::load_dotenv_cascade(opts.load_home_dotenv);
291 }
292
293 // Determine PostgreSQL config source
294 let pg_source = opts
295 .postgres
296 .clone()
297 .unwrap_or_else(|| PostgresConfigSource::from_env(&opts.env_prefix));
298
299 // Load PostgreSQL config (async)
300 let pg_config = PostgresConfig::load(&pg_source).await?;
301
302 // Build the cascade (lowest to highest priority)
303 let mut figment = Figment::new();
304
305 // 8. Hard-coded defaults (lowest priority)
306 figment = figment.merge(Serialized::defaults(HardcodedDefaults::default()));
307
308 // 7. defaults.yaml
309 for path in Self::find_config_files("defaults", &opts.config_paths, app_name_ref) {
310 figment = figment.merge(Yaml::file(&path));
311 }
312
313 // 6. settings.yaml
314 for path in Self::find_config_files("settings", &opts.config_paths, app_name_ref) {
315 figment = figment.merge(Yaml::file(&path));
316 }
317
318 // 5. settings.{env}.yaml
319 let env_settings = format!("settings.{app_env}");
320 for path in Self::find_config_files(&env_settings, &opts.config_paths, app_name_ref) {
321 figment = figment.merge(Yaml::file(&path));
322 }
323
324 // 4. PostgreSQL config (above files, below .env)
325 if let Some(ref pg) = pg_config {
326 let nested = pg.to_nested();
327 // For merge mode, we merge into existing config
328 // For replace mode, PostgreSQL config replaces file-based config
329 // Since figment merges are additive with later values winning,
330 // we just merge here - the position in the cascade determines priority
331 figment = figment.merge(Serialized::defaults(nested));
332 }
333
334 // 3. .env file values are already loaded into env vars
335
336 // 2. Environment variables (with prefix)
337 if !opts.env_prefix.is_empty() {
338 figment = figment.merge(Env::prefixed(&format!("{}_", opts.env_prefix)).split("__"));
339 }
340
341 // 1. CLI args would be merged by the application via merge_cli()
342
343 Ok(Self {
344 figment,
345 env_prefix: opts.env_prefix,
346 })
347 }
348
349 /// Load `.env` files in cascade order.
350 ///
351 /// Order (lowest to highest priority):
352 /// 1. `~/.env` (home directory - global defaults)
353 /// 2. Project `.env` (current directory - project overrides)
354 ///
355 /// Note: `dotenvy` does NOT overwrite existing environment variables,
356 /// so later files in the cascade take precedence. We load in reverse
357 /// order (project first, then home) so that project values are set first
358 /// and home values only fill in missing variables.
359 ///
360 /// Real environment variables always take precedence over all `.env` values.
361 fn load_dotenv_cascade(load_home: bool) {
362 use tracing::debug;
363
364 // Load project .env first (these values take precedence)
365 // dotenvy::dotenv() looks for .env in current directory
366 match dotenvy::dotenv() {
367 Ok(path) => {
368 debug!(path = %path.display(), "Loaded project .env file");
369 }
370 Err(dotenvy::Error::Io(ref e)) if e.kind() == std::io::ErrorKind::NotFound => {
371 // No project .env, that's fine
372 }
373 Err(e) => {
374 debug!(error = %e, "Failed to load project .env file");
375 }
376 }
377
378 // Load home directory .env (only fills in missing values)
379 if load_home && let Some(home) = dirs::home_dir() {
380 let home_env = home.join(".env");
381 if home_env.exists() {
382 match dotenvy::from_path(&home_env) {
383 Ok(()) => {
384 debug!(path = %home_env.display(), "Loaded home .env file");
385 }
386 Err(e) => {
387 debug!(path = %home_env.display(), error = %e, "Failed to load home .env file");
388 }
389 }
390 }
391 }
392 }
393
394 /// Find config files with the given base name.
395 ///
396 /// Search order (all locations merged, later overrides earlier within
397 /// the same cascade layer):
398 /// 1. Current directory: `./{name}.yaml`
399 /// 2. Project config subdir: `./config/{name}.yaml`
400 /// 3. Container mount: `/config/{name}.yaml` (always checked)
401 /// 4. User config: `~/.config/{app_name}/{name}.yaml` (when app_name set)
402 /// 5. Extra paths from `ConfigOptions::config_paths`
403 fn find_config_files(
404 base_name: &str,
405 extra_paths: &[PathBuf],
406 app_name: Option<&str>,
407 ) -> Vec<PathBuf> {
408 let mut files = Vec::new();
409 let extensions = ["yaml", "yml"];
410
411 // 1. Current directory
412 for ext in &extensions {
413 let path = PathBuf::from(format!("{base_name}.{ext}"));
414 if path.exists() {
415 files.push(path);
416 break;
417 }
418 }
419
420 // 2. Project config subdirectory
421 for ext in &extensions {
422 let path = PathBuf::from(format!("config/{base_name}.{ext}"));
423 if path.exists() {
424 files.push(path);
425 break;
426 }
427 }
428
429 // 3. Container config mount (/config/)
430 let container_config = PathBuf::from("/config");
431 if container_config.is_dir() {
432 for ext in &extensions {
433 let path = container_config.join(format!("{base_name}.{ext}"));
434 if path.exists() {
435 files.push(path);
436 break;
437 }
438 }
439 }
440
441 // 4. User config directory (~/.config/{app_name}/)
442 if let Some(name) = app_name
443 && let Some(config_dir) = dirs::config_dir()
444 {
445 let user_config = config_dir.join(name);
446 if user_config.is_dir() {
447 for ext in &extensions {
448 let path = user_config.join(format!("{base_name}.{ext}"));
449 if path.exists() {
450 files.push(path);
451 break;
452 }
453 }
454 }
455 }
456
457 // 5. Extra paths (from ConfigOptions::config_paths)
458 for base in extra_paths {
459 for ext in &extensions {
460 let path = base.join(format!("{base_name}.{ext}"));
461 if path.exists() {
462 files.push(path);
463 break;
464 }
465 }
466 }
467
468 files
469 }
470
471 /// Merge CLI arguments into the configuration.
472 ///
473 /// Call this after parsing CLI args to add them as highest priority.
474 #[must_use]
475 pub fn merge_cli<T: serde::Serialize>(mut self, cli_args: T) -> Self {
476 self.figment = self.figment.merge(Serialized::defaults(cli_args));
477 self
478 }
479
480 /// Get a string value.
481 #[must_use]
482 pub fn get_string(&self, key: &str) -> Option<String> {
483 self.figment.extract_inner::<String>(key).ok()
484 }
485
486 /// Get an integer value.
487 #[must_use]
488 pub fn get_int(&self, key: &str) -> Option<i64> {
489 self.figment.extract_inner::<i64>(key).ok()
490 }
491
492 /// Get a float value.
493 #[must_use]
494 pub fn get_float(&self, key: &str) -> Option<f64> {
495 self.figment.extract_inner::<f64>(key).ok()
496 }
497
498 /// Get a boolean value.
499 #[must_use]
500 pub fn get_bool(&self, key: &str) -> Option<bool> {
501 self.figment.extract_inner::<bool>(key).ok()
502 }
503
504 /// Get a duration value (parses strings like "30s", "5m", "1h").
505 #[must_use]
506 pub fn get_duration(&self, key: &str) -> Option<Duration> {
507 let value = self.get_string(key)?;
508 parse_duration(&value)
509 }
510
511 /// Get a list of strings.
512 #[must_use]
513 pub fn get_string_list(&self, key: &str) -> Option<Vec<String>> {
514 self.figment.extract_inner::<Vec<String>>(key).ok()
515 }
516
517 /// Check if a key exists.
518 #[must_use]
519 pub fn contains(&self, key: &str) -> bool {
520 self.figment.find_value(key).is_ok()
521 }
522
523 /// Deserialise the entire configuration into a typed struct.
524 ///
525 /// # Errors
526 ///
527 /// Returns an error if deserialisation fails.
528 pub fn unmarshal<T: DeserializeOwned>(&self) -> Result<T, ConfigError> {
529 self.figment.extract().map_err(ConfigError::ExtractError)
530 }
531
532 /// Deserialise a specific key into a typed struct.
533 ///
534 /// # Errors
535 ///
536 /// Returns an error if deserialisation fails.
537 pub fn unmarshal_key<T: DeserializeOwned>(&self, key: &str) -> Result<T, ConfigError> {
538 self.figment
539 .extract_inner(key)
540 .map_err(ConfigError::ExtractError)
541 }
542
543 /// Deserialise a specific key and auto-register it in the config registry.
544 ///
545 /// Same as [`unmarshal_key`](Self::unmarshal_key) but also records the
546 /// section in the global [`registry`] for reflection. The type must
547 /// implement `Serialize + Default` so the registry can capture both
548 /// the effective and default values.
549 ///
550 /// # Errors
551 ///
552 /// Returns an error if deserialisation fails. The section is only
553 /// registered on success.
554 pub fn unmarshal_key_registered<T>(&self, key: &str) -> Result<T, ConfigError>
555 where
556 T: DeserializeOwned + serde::Serialize + Default + 'static,
557 {
558 let value: T = self.unmarshal_key(key)?;
559 registry::register::<T>(key, &value);
560 Ok(value)
561 }
562
563 /// Get the environment variable prefix.
564 #[must_use]
565 pub fn env_prefix(&self) -> &str {
566 &self.env_prefix
567 }
568}
569
570/// Hard-coded default values (lowest priority in cascade).
571#[derive(Debug, serde::Serialize)]
572struct HardcodedDefaults {
573 log_level: String,
574 log_format: String,
575}
576
577impl Default for HardcodedDefaults {
578 fn default() -> Self {
579 Self {
580 log_level: "info".to_string(),
581 log_format: "auto".to_string(),
582 }
583 }
584}
585
586/// Parse a duration string like "30s", "5m", "1h", "2h30m".
587fn parse_duration(s: &str) -> Option<Duration> {
588 let s = s.trim().to_lowercase();
589
590 // Try simple formats first
591 if let Some(secs) = s.strip_suffix('s') {
592 return secs.parse::<u64>().ok().map(Duration::from_secs);
593 }
594 if let Some(mins) = s.strip_suffix('m') {
595 return mins
596 .parse::<u64>()
597 .ok()
598 .map(|m| Duration::from_secs(m * 60));
599 }
600 if let Some(hours) = s.strip_suffix('h') {
601 return hours
602 .parse::<u64>()
603 .ok()
604 .map(|h| Duration::from_secs(h * 3600));
605 }
606
607 // Try parsing as seconds
608 s.parse::<u64>().ok().map(Duration::from_secs)
609}
610
611// Global singleton functions
612
613/// Initialise the global configuration.
614///
615/// # Errors
616///
617/// Returns an error if configuration loading fails or if already initialised.
618pub fn setup(opts: ConfigOptions) -> Result<(), ConfigError> {
619 let config = Config::new(opts)?;
620 CONFIG
621 .set(config)
622 .map_err(|_| ConfigError::AlreadyInitialised)
623}
624
625/// Initialise the global configuration with async loading (for PostgreSQL support).
626///
627/// This function loads configuration asynchronously, allowing PostgreSQL to be
628/// used as a config source.
629///
630/// # Errors
631///
632/// Returns an error if configuration loading fails or if already initialised.
633#[cfg(feature = "config-postgres")]
634pub async fn setup_async(opts: ConfigOptions) -> Result<(), ConfigError> {
635 let config = Config::new_async(opts).await?;
636 CONFIG
637 .set(config)
638 .map_err(|_| ConfigError::AlreadyInitialised)
639}
640
641/// Get the global configuration.
642///
643/// # Panics
644///
645/// Panics if configuration has not been initialised.
646#[must_use]
647pub fn get() -> &'static Config {
648 CONFIG
649 .get()
650 .expect("configuration not initialised - call config::setup() first")
651}
652
653/// Try to get the global configuration.
654#[must_use]
655pub fn try_get() -> Option<&'static Config> {
656 CONFIG.get()
657}
658
659#[cfg(test)]
660mod tests {
661 use super::*;
662
663 #[test]
664 fn test_parse_duration_seconds() {
665 assert_eq!(parse_duration("30s"), Some(Duration::from_secs(30)));
666 assert_eq!(parse_duration("1s"), Some(Duration::from_secs(1)));
667 }
668
669 #[test]
670 fn test_parse_duration_minutes() {
671 assert_eq!(parse_duration("5m"), Some(Duration::from_secs(300)));
672 assert_eq!(parse_duration("1m"), Some(Duration::from_secs(60)));
673 }
674
675 #[test]
676 fn test_parse_duration_hours() {
677 assert_eq!(parse_duration("1h"), Some(Duration::from_secs(3600)));
678 assert_eq!(parse_duration("2h"), Some(Duration::from_secs(7200)));
679 }
680
681 #[test]
682 fn test_parse_duration_plain_number() {
683 assert_eq!(parse_duration("60"), Some(Duration::from_secs(60)));
684 }
685
686 #[test]
687 fn test_config_options_default() {
688 let opts = ConfigOptions::default();
689 assert!(opts.env_prefix.is_empty());
690 assert!(opts.app_env.is_none());
691 assert!(opts.app_name.is_none());
692 assert!(opts.config_paths.is_empty());
693 assert!(opts.load_dotenv);
694 assert!(!opts.load_home_dotenv);
695 }
696
697 #[test]
698 fn test_config_new() {
699 let config = Config::new(ConfigOptions::default());
700 assert!(config.is_ok());
701 }
702
703 #[test]
704 fn test_config_hardcoded_defaults() {
705 let config = Config::new(ConfigOptions::default()).unwrap();
706
707 // Should have hardcoded defaults
708 assert_eq!(config.get_string("log_level"), Some("info".to_string()));
709 assert_eq!(config.get_string("log_format"), Some("auto".to_string()));
710 }
711
712 #[test]
713 fn test_config_env_override() {
714 // Env vars use double underscore for nesting: PREFIX_KEY__SUBKEY -> key.subkey
715 // For flat keys, just use PREFIX_KEY -> key
716 temp_env::with_var("TEST_HOST", Some("testhost"), || {
717 let config = Config::new(ConfigOptions {
718 env_prefix: "TEST".into(),
719 ..Default::default()
720 })
721 .unwrap();
722 assert_eq!(config.get_string("host"), Some("testhost".to_string()));
723 });
724 }
725}