Skip to main content

toolkit/bootstrap/config/
mod.rs

1//! Configuration gear for toolkit-bootstrap
2//!
3//! This gear provides configuration types and utilities for both host and `OoP` gears.
4
5mod dump;
6
7use anyhow::{Context, Result, ensure};
8// Use DB config types from toolkit-db
9use serde::de::DeserializeOwned;
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12use std::path::{Path, PathBuf};
13pub use toolkit_db::{DbConnConfig, GlobalDatabaseConfig, PoolCfg};
14use tracing::Level;
15
16use crate::ConfigProvider;
17use crate::telemetry::OpenTelemetryConfig;
18use url::Url;
19
20/// Normalize a path to use forward slashes (for cross-platform YAML/DSN compatibility).
21fn normalize_path(path: &Path) -> String {
22    path.to_string_lossy().replace('\\', "/")
23}
24
25/// Error type for vendor configuration access.
26#[derive(thiserror::Error, Debug)]
27pub enum VendorConfigError {
28    #[error("vendor '{vendor}' not found in configuration")]
29    NotFound { vendor: String },
30    // Intentionally not named `source`; doing so would duplicate chained error output.
31    #[error("invalid config for vendor '{vendor}': {cause}")]
32    InvalidConfig {
33        vendor: String,
34        cause: serde_json::Error,
35    },
36}
37
38// Re-export dump functions
39pub use dump::{
40    dump_effective_gears_config_json, dump_effective_gears_config_yaml, list_gear_names,
41    redact_dsn_password, render_effective_gears_config,
42};
43
44/// Small typed view to parse each gear entry.
45#[derive(Debug, Clone, Deserialize)]
46#[serde(deny_unknown_fields)]
47pub struct GearConfig {
48    #[serde(default)]
49    pub database: Option<DbConnConfig>,
50    #[serde(default)]
51    pub config: serde_json::Value,
52    #[serde(default)]
53    pub runtime: Option<GearRuntime>,
54    #[serde(default)] // Used by the CLI
55    pub metadata: serde_json::Value,
56}
57
58/// Runtime configuration for a gear (local vs out-of-process).
59#[derive(Debug, Clone, Deserialize, Serialize, Default)]
60#[serde(deny_unknown_fields)]
61pub struct GearRuntime {
62    #[serde(default, rename = "type")]
63    pub mod_type: RuntimeKind,
64    /// Execution configuration for `OoP` gears.
65    #[serde(default)]
66    pub execution: Option<ExecutionConfig>,
67}
68
69/// Execution configuration for out-of-process gears.
70#[derive(Debug, Clone, Deserialize, Serialize, Default)]
71#[serde(deny_unknown_fields)]
72pub struct ExecutionConfig {
73    /// Path to the executable. Supports absolute paths or `~` expansion.
74    pub executable_path: String,
75    /// Command-line arguments to pass to the executable.
76    #[serde(default)]
77    pub args: Vec<String>,
78    /// Working directory for the process (optional, defaults to current dir).
79    #[serde(default)]
80    pub working_directory: Option<String>,
81    /// Environment variables to set for the process.
82    #[serde(default)]
83    pub environment: HashMap<String, String>,
84}
85
86/// Gear runtime kind.
87#[derive(Debug, Clone, Default, Deserialize, Serialize)]
88#[serde(rename_all = "lowercase")]
89pub enum RuntimeKind {
90    #[default]
91    Local,
92    Oop,
93}
94
95/// Main application configuration with strongly-typed global sections
96/// and a flexible per-gear configuration bag.
97#[derive(Debug, Clone, Deserialize, Serialize)]
98#[serde(deny_unknown_fields)]
99pub struct AppConfig {
100    /// Core server configuration.
101    pub server: ServerConfig,
102    /// New typed database configuration (optional).
103    pub database: Option<GlobalDatabaseConfig>,
104    /// Logging configuration
105    #[serde(default = "default_logging_config")]
106    pub logging: LoggingConfig,
107    /// OpenTelemetry configuration (resource, tracing, metrics).
108    #[serde(default)]
109    pub opentelemetry: OpenTelemetryConfig,
110    /// Directory containing per-gear YAML files (optional).
111    #[serde(default)]
112    pub gears_dir: Option<String>,
113    /// Per-gear configuration bag: `gear_name` → arbitrary JSON/YAML value.
114    #[serde(default)]
115    pub gears: HashMap<String, serde_json::Value>,
116    /// Per-vendor configuration bag: `vendor_name` → arbitrary JSON/YAML value.
117    /// Allows vendors to add their own typed configuration sections.
118    #[serde(default)]
119    pub vendor: VendorConfig,
120    /// Out-of-process HTTP server configuration.
121    ///
122    /// When present, an `OoP` gear starts an Axum HTTP server (probes,
123    /// gear routes, self-registration, dependency resolution, graceful drain)
124    /// instead of the legacy gRPC-only lifecycle (`cpt-cf-component-oop-bootstrap`).
125    #[serde(default)]
126    pub oop_http: Option<OopHttpConfig>,
127}
128
129impl Default for AppConfig {
130    fn default() -> Self {
131        let server = ServerConfig::default();
132        Self {
133            server,
134            database: None,
135            logging: default_logging_config(),
136            opentelemetry: OpenTelemetryConfig::default(),
137            gears_dir: None,
138            gears: HashMap::new(),
139            vendor: VendorConfig::new(),
140            oop_http: None,
141        }
142    }
143}
144
145/// Out-of-process HTTP server configuration (`cpt-cf-component-oop-bootstrap`).
146#[derive(Debug, Clone, Deserialize, Serialize)]
147#[serde(deny_unknown_fields)]
148pub struct OopHttpConfig {
149    /// Address the main HTTP server binds to (gear routes + probes),
150    /// e.g. `"0.0.0.0:8080"`.
151    pub listen_addr: String,
152    /// Optional separate bind address for probe endpoints (sidecar port).
153    /// When set, `/healthz` and `/readyz` are also served here.
154    #[serde(default)]
155    pub probe_bind_addr: Option<String>,
156    /// Maximum seconds to wait for in-flight requests to drain on shutdown.
157    #[serde(default = "default_drain_timeout_secs")]
158    pub drain_timeout_secs: u64,
159    /// Per-check timeout (ms) for readiness healthchecks on `/readyz`; raise for
160    /// slow dependencies. Mirrors the `api-gateway` `healthcheck_timeout_ms`.
161    #[serde(default = "default_healthcheck_timeout_ms")]
162    pub healthcheck_timeout_ms: u64,
163    /// Base URL other services use to reach this instance (registered as the
164    /// instance's REST endpoint). Defaults to `http://<listen_addr>` with an
165    /// unspecified host (`0.0.0.0`) rewritten to `127.0.0.1`.
166    #[serde(default)]
167    pub advertise_uri: Option<String>,
168    /// Allow a loopback / unspecified `advertise_uri` (`127.0.0.1`, `::1`,
169    /// `localhost`, `0.0.0.0`, `[::]`). Off by default: such an endpoint is
170    /// registered-but-unreachable in multi-host Profile 2 / Profile 3, so
171    /// bootstrap fails fast (`cpt-cf-adr-instance-addressable-discovery`).
172    /// Set `true` only for single-host / local-dev.
173    #[serde(default)]
174    pub allow_loopback_advertise: bool,
175    /// Platform-plane (`InternalAuthenticator`) configuration. When present it
176    /// drives both the *inbound* HTTP validator on the gear's own routes and
177    /// the *outbound* credential attached to the gear's `DirectoryService`
178    /// calls. The `shared_secret` provider works out of the box; the `kube`
179    /// provider's inbound `TokenReview` validator requires the `k8s-auth`
180    /// feature.
181    #[serde(default)]
182    pub internal_auth: Option<toolkit_security::InternalAuthConfig>,
183    /// Stable addressing labels (k8s `matchLabels` style) advertised with this
184    /// instance's directory registration, for label-based instance selection
185    /// (`DirectoryClient::resolve_by_labels`).
186    ///
187    /// Sourced from config (`oop_http.labels.<key>`) or the environment
188    /// (`APP__OOP_HTTP__LABELS__<KEY>`). A bare-numeric env *value* (e.g. a
189    /// `StatefulSet` ordinal injected as `APP__OOP_HTTP__LABELS__SHARD=7`) is
190    /// coerced to a string here rather than aborting the config load. Note:
191    /// environment-sourced keys are still lower-cased by the config loader, so
192    /// keys that must preserve case or contain `.`/`-` should be set in the
193    /// config file rather than via env.
194    #[serde(default, deserialize_with = "de_labels_scalar_to_string")]
195    pub labels: std::collections::BTreeMap<String, String>,
196}
197
198fn default_drain_timeout_secs() -> u64 {
199    30
200}
201
202fn default_healthcheck_timeout_ms() -> u64 {
203    500
204}
205
206/// Deserialize a label map, coercing scalar values (numbers, booleans) to
207/// strings.
208///
209/// The environment layer parses a bare-numeric value like
210/// `APP__OOP_HTTP__LABELS__SHARD=7` into an integer, which would otherwise fail
211/// to deserialize into a `String` and abort the entire `AppConfig::load_layered`
212/// — precisely on the path a k8s `StatefulSet` ordinal is injected. Accepting the
213/// scalar and rendering it as a string keeps that value load-able as a label.
214fn de_labels_scalar_to_string<'de, D>(
215    deserializer: D,
216) -> Result<std::collections::BTreeMap<String, String>, D::Error>
217where
218    D: serde::Deserializer<'de>,
219{
220    use serde::Deserialize;
221
222    // Ordering matters for `untagged`: a string input matches `Str`; an integer
223    // matches `I64`/`U64` before `F64`, so `7` renders as `"7"` not `"7.0"`.
224    #[derive(Deserialize)]
225    #[serde(untagged)]
226    enum Scalar {
227        Str(String),
228        Bool(bool),
229        I64(i64),
230        U64(u64),
231        F64(f64),
232    }
233
234    let raw = std::collections::BTreeMap::<String, Scalar>::deserialize(deserializer)?;
235    Ok(raw
236        .into_iter()
237        .map(|(k, v)| {
238            let value = match v {
239                Scalar::Str(s) => s,
240                Scalar::Bool(b) => b.to_string(),
241                Scalar::I64(i) => i.to_string(),
242                Scalar::U64(u) => u.to_string(),
243                Scalar::F64(f) => f.to_string(),
244            };
245            (k, value)
246        })
247        .collect())
248}
249
250impl ConfigProvider for AppConfig {
251    fn get_gear_config(&self, gear_name: &str) -> Option<&serde_json::Value> {
252        self.gears.get(gear_name)
253    }
254}
255
256#[derive(Debug, Clone, Deserialize, Serialize)]
257#[serde(deny_unknown_fields)]
258pub struct ServerConfig {
259    #[serde(default = "default_server_name")]
260    pub name: String,
261    #[serde(default = "default_home_dir")]
262    pub home_dir: PathBuf, // will be normalized to absolute path
263}
264
265fn default_server_name() -> String {
266    "cf-gears".to_owned()
267}
268
269fn default_home_dir() -> PathBuf {
270    super::host::paths::default_home_dir().join(".cf-gears")
271}
272
273impl Default for ServerConfig {
274    fn default() -> Self {
275        Self {
276            name: default_server_name(),
277            home_dir: default_home_dir(),
278        }
279    }
280}
281
282impl ServerConfig {
283    fn normalize_home_dir_inplace(&mut self) -> Result<()> {
284        self.home_dir = super::host::normalize_path(
285            self.home_dir
286                .to_str()
287                .context("home directory configuration is not a valid path")?,
288        )
289        .context("home_dir normalization failed")?;
290
291        std::fs::create_dir_all(&self.home_dir).context("Failed to create home_dir")?;
292
293        Ok(())
294    }
295}
296
297/// Console output format for the logging layer.
298#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
299#[serde(rename_all = "lowercase")]
300pub enum ConsoleFormat {
301    /// Human-readable text output (default).
302    #[default]
303    Text,
304    /// Structured JSON output (useful for container log collectors).
305    Json,
306}
307
308/// Logging configuration - maps subsystem names to their logging settings.
309/// Key "default" is the catch-all for logs that don't match explicit subsystems.
310pub type LoggingConfig = HashMap<String, Section>;
311
312/// Per-vendor configuration bag: vendor name → arbitrary JSON/YAML value.
313/// Each vendor's section can be deserialized into a typed struct via
314/// [`AppConfig::vendor_config`] or [`AppConfig::vendor_config_or_default`].
315pub type VendorConfig = HashMap<String, serde_json::Value>;
316
317// ================= Custom serde gear for optional Level (supports "off") =================
318mod optional_level_serde {
319    use serde::{Deserialize, Deserializer, Serializer};
320    use tracing::Level;
321
322    #[allow(clippy::ref_option, clippy::trivially_copy_pass_by_ref)]
323    pub fn serialize<S>(level: &Option<Level>, serializer: S) -> Result<S::Ok, S::Error>
324    where
325        S: Serializer,
326    {
327        match level {
328            Some(l) => serializer.serialize_str(l.as_str()),
329            None => serializer.serialize_str("off"),
330        }
331    }
332
333    pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<Level>, D::Error>
334    where
335        D: Deserializer<'de>,
336    {
337        let s = String::deserialize(deserializer)?;
338        match s.to_lowercase().as_str() {
339            "trace" => Ok(Some(Level::TRACE)),
340            "debug" => Ok(Some(Level::DEBUG)),
341            "info" => Ok(Some(Level::INFO)),
342            "warn" => Ok(Some(Level::WARN)),
343            "error" => Ok(Some(Level::ERROR)),
344            "off" | "none" => Ok(None),
345            _ => Err(serde::de::Error::custom(format!("invalid level: {s}"))),
346        }
347    }
348
349    #[allow(clippy::unnecessary_wraps)]
350    pub fn default() -> Option<Level> {
351        Some(Level::INFO)
352    }
353}
354
355#[derive(Debug, Serialize, Deserialize, Clone)]
356pub struct SectionFile {
357    pub file: String,
358    #[serde(
359        default = "optional_level_serde::default",
360        with = "optional_level_serde"
361    )]
362    pub file_level: Option<Level>,
363}
364
365#[derive(Debug, Serialize, Deserialize, Clone)]
366pub struct Section {
367    #[serde(default)]
368    pub console_format: ConsoleFormat,
369    #[serde(
370        default = "optional_level_serde::default",
371        with = "optional_level_serde"
372    )]
373    pub console_level: Option<Level>,
374    #[serde(flatten)]
375    pub section_file: Option<SectionFile>,
376    pub max_age_days: Option<u32>, // Not implemented yet
377    #[serde(default)]
378    pub max_backups: Option<usize>, // How many files to keep
379    #[serde(default)]
380    pub max_size_mb: Option<u64>, // Max size of the file in MB
381}
382
383impl Section {
384    #[must_use]
385    pub fn file(&self) -> Option<&str> {
386        self.section_file
387            .as_ref()
388            .map(|f| f.file.as_str())
389            .filter(|s| !s.is_empty())
390    }
391
392    #[must_use]
393    pub fn file_level(&self) -> Option<Level> {
394        self.section_file.as_ref().and_then(|f| f.file_level)
395    }
396}
397
398/// Create a default logging configuration.
399#[must_use]
400pub fn default_logging_config() -> LoggingConfig {
401    let mut logging = HashMap::new();
402    logging.insert(
403        "default".to_owned(),
404        Section {
405            console_level: Some(Level::INFO),
406            section_file: Some(SectionFile {
407                file: "logs/cf-gears.log".to_owned(),
408                file_level: Some(Level::DEBUG),
409            }),
410            console_format: ConsoleFormat::default(),
411            max_age_days: Some(7),
412            max_backups: Some(3),
413            max_size_mb: Some(100),
414        },
415    );
416    logging
417}
418
419/// Remap a split, `APP__`-prefixed environment key into a figment config path.
420///
421/// k8s env var names must be `C_IDENTIFIERs` (no dashes), but gear names are
422/// kebab-case. Under the `gears` branch we therefore remap `_` -> `-` in the
423/// gear-name segment only (the one right after `gears`). Nested field names
424/// (e.g. `max_age_days`) and other branches (e.g. `vendor`) are left alone; the
425/// dash form is unaffected since remapping `-` is a no-op.
426///
427/// Safe because gear names are guaranteed kebab-case by `validate_kebab_case`
428/// (`#[toolkit::gear]` macro + `make validate-gear-names`).
429pub(crate) fn remap_gear_env_key(key: &str) -> String {
430    // Lowercase so the match is independent of figment's own (later) lowercasing.
431    let lower = key.to_ascii_lowercase();
432    let mut parts: Vec<&str> = lower.split('.').collect();
433    if parts.first() == Some(&"gears") && parts.len() >= 2 {
434        let gear = parts[1].replace('_', "-");
435        parts[1] = gear.as_str();
436        parts.join(".")
437    } else {
438        lower
439    }
440}
441
442impl AppConfig {
443    /// Load configuration with layered loading: defaults → YAML file → environment variables.
444    /// Also normalizes `server.home_dir` into an absolute path and creates the directory.
445    ///
446    /// # Errors
447    /// Returns an error if configuration loading or `home_dir` resolution fails.
448    pub fn load_layered(config_path: &PathBuf) -> Result<Self> {
449        use figment::{
450            Figment,
451            providers::{Env, Format, Serialized},
452        };
453
454        // For layered loading, start from AppConfig::default() which provides logging
455        // defaults (via default_logging_config()); other optional sections (database,
456        // tracing, gears_dir) remain None unless overridden by YAML/ENV.
457        let figment = Figment::new()
458            .merge(Serialized::defaults(AppConfig::default()))
459            .merge(StrictYaml::file(config_path))
460            // Example: APP__SERVER__PORT=8087 maps to server.port.
461            .merge(
462                Env::prefixed("APP__")
463                    .split("__")
464                    .map(|key| remap_gear_env_key(key.as_str()).into()),
465            );
466
467        let mut config: AppConfig = figment
468            .extract()
469            .with_context(|| "Failed to extract config from figment".to_owned())?;
470
471        // Normalize + create home_dir immediately.
472        config
473            .server
474            .normalize_home_dir_inplace()
475            .context("Failed to resolve server.home_dir")?;
476
477        // Merge gear files if gears_dir is specified.
478        if let Some(dir) = config.gears_dir.as_ref() {
479            merge_gear_files(&mut config.gears, dir)?;
480        }
481
482        Ok(config)
483    }
484
485    /// Load configuration from file or create with default values.
486    /// Also normalizes `server.home_dir` into an absolute path and creates the directory.
487    ///
488    /// # Errors
489    /// Returns an error if configuration loading or `home_dir` resolution fails.
490    pub fn load_or_default(config_path: Option<&PathBuf>) -> Result<Self> {
491        if let Some(path) = config_path {
492            ensure!(
493                path.is_file(),
494                "config file does not exist: {}",
495                path.to_string_lossy()
496            );
497            Self::load_layered(path)
498        } else {
499            let mut c = Self::default();
500            c.server
501                .normalize_home_dir_inplace()
502                .context("Failed to resolve server.home_dir (defaults)")?;
503            Ok(c)
504        }
505    }
506
507    /// Serialize configuration to YAML.
508    ///
509    /// # Errors
510    /// Returns an error if serialization fails.
511    pub fn to_yaml(&self) -> Result<String> {
512        serde_saphyr::to_string(self).context("Failed to serialize config to YAML")
513    }
514
515    /// Deserialize a vendor configuration section into a typed struct.
516    ///
517    /// # Errors
518    /// Returns `VendorConfigError::NotFound` if the vendor is not present,
519    /// or `VendorConfigError::InvalidConfig` if deserialization fails.
520    pub fn vendor_config<T: DeserializeOwned>(
521        &self,
522        vendor_name: &str,
523    ) -> Result<T, VendorConfigError> {
524        let raw = self
525            .vendor
526            .get(vendor_name)
527            .ok_or_else(|| VendorConfigError::NotFound {
528                vendor: vendor_name.to_owned(),
529            })?;
530        T::deserialize(raw).map_err(|e| VendorConfigError::InvalidConfig {
531            vendor: vendor_name.to_owned(),
532            cause: e,
533        })
534    }
535
536    /// Deserialize a vendor configuration section, returning `T::default()` if absent.
537    ///
538    /// # Errors
539    /// Returns `VendorConfigError::InvalidConfig` if the section exists but cannot be
540    /// deserialized into `T`.
541    pub fn vendor_config_or_default<T: DeserializeOwned + Default>(
542        &self,
543        vendor_name: &str,
544    ) -> Result<T, VendorConfigError> {
545        let Some(raw) = self.vendor.get(vendor_name) else {
546            return Ok(T::default());
547        };
548        T::deserialize(raw).map_err(|e| VendorConfigError::InvalidConfig {
549            vendor: vendor_name.to_owned(),
550            cause: e,
551        })
552    }
553
554    /// Apply overrides from command line arguments.
555    pub fn apply_cli_overrides(&mut self, verbose: u8) {
556        // Set logging level based on verbose flags for "default" section.
557        if let Some(default_section) = self.logging.get_mut("default") {
558            default_section.console_level = match verbose {
559                0 => default_section.console_level, // keep
560                1 => Some(Level::DEBUG),
561                _ => Some(Level::TRACE),
562            };
563        }
564    }
565}
566
567/// Command line arguments structure.
568#[derive(Debug, Clone)]
569pub struct CliArgs {
570    pub config: Option<String>,
571    pub print_config: bool,
572    pub verbose: u8,
573    pub mock: bool,
574}
575
576/// Parse YAML with duplicate-key rejection.
577fn strict_yaml_parse<T: serde::de::DeserializeOwned>(s: &str) -> Result<T, serde_saphyr::Error> {
578    let opts = serde_saphyr::Options {
579        duplicate_keys: serde_saphyr::DuplicateKeyPolicy::Error,
580        ..serde_saphyr::Options::default()
581    };
582    serde_saphyr::from_str_with_options(s, opts)
583}
584
585/// YAML [`Format`](figment::providers::Format) provider that rejects duplicate
586/// mapping keys instead of silently keeping the last value.
587///
588/// Drop-in replacement for figment's built-in `Yaml` — use
589/// `StrictYaml::file(path)` wherever you would use `Yaml::file(path)`.
590struct StrictYaml;
591
592impl figment::providers::Format for StrictYaml {
593    type Error = serde_saphyr::Error;
594
595    const NAME: &'static str = "YAML";
596
597    fn from_str<T: serde::de::DeserializeOwned>(s: &str) -> Result<T, Self::Error> {
598        strict_yaml_parse(s)
599    }
600}
601
602fn merge_gear_files(
603    bag: &mut HashMap<String, serde_json::Value>,
604    dir: impl AsRef<Path>,
605) -> Result<()> {
606    use std::fs;
607    let dir = dir.as_ref();
608    if !dir.exists() {
609        return Ok(());
610    }
611    for entry in fs::read_dir(dir)? {
612        let entry = entry?;
613        let path = entry.path();
614        if !path.is_file() {
615            continue;
616        }
617        let ext = path
618            .extension()
619            .and_then(|s| s.to_str())
620            .unwrap_or("")
621            .to_ascii_lowercase();
622        if ext != "yml" && ext != "yaml" {
623            continue;
624        }
625        let name = path
626            .file_stem()
627            .and_then(|s| s.to_str())
628            .unwrap_or("")
629            .to_owned();
630        let raw = fs::read_to_string(&path)?;
631        let json: serde_json::Value = strict_yaml_parse(&raw)
632            .with_context(|| format!("failed to parse gear file: {}", path.display()))?;
633        bag.insert(name, json);
634    }
635    Ok(())
636}
637
638// ---- New ToolKit DB Handling Functions ----
639
640/// Expands environment variables in a DSN string.
641/// Replaces `${VARNAME}` with the actual environment variable value.
642///
643/// # Errors
644/// Returns an error if any referenced env var is missing.
645pub fn expand_env_in_dsn(dsn: &str) -> Result<String> {
646    toolkit_utils::var_expand::expand_env_vars(dsn).map_err(|e| anyhow::anyhow!("{e}"))
647}
648
649/// Resolves password: if it contains ${VAR}, expands from environment variable; otherwise returns as-is.
650///
651/// # Errors
652/// Returns an error if the referenced environment variable is not found.
653pub fn resolve_password(password: Option<&str>) -> Result<Option<String>> {
654    if let Some(pwd) = password {
655        if pwd.starts_with("${") && pwd.ends_with('}') {
656            // Extract variable name from ${VAR_NAME}
657            let var_name = &pwd[2..pwd.len() - 1];
658            let resolved = std::env::var(var_name).with_context(|| {
659                format!("Environment variable '{var_name}' not found for password")
660            })?;
661            Ok(Some(resolved))
662        } else {
663            // Return literal password as-is
664            Ok(Some(pwd.to_owned()))
665        }
666    } else {
667        Ok(None)
668    }
669}
670
671/// Validates that a DSN string is parseable by the dsn crate.
672/// Note: `SQLite` DSNs have special formats that dsn crate doesn't recognize, so we skip validation for them.
673///
674/// # Errors
675/// Returns an error if the DSN is invalid.
676pub fn validate_dsn(dsn: &str) -> Result<()> {
677    // Skip validation for SQLite DSNs as they use special syntax not recognized by dsn crate
678    if dsn.starts_with("sqlite:") {
679        return Ok(());
680    }
681
682    let _parsed = dsn::parse(dsn).map_err(|e| anyhow::anyhow!("Invalid DSN '{dsn}': {e}"))?;
683
684    Ok(())
685}
686
687/// Resolves `SQLite` @`file()` syntax in DSN to actual file paths.
688/// - `sqlite://@file(users.sqlite)` → `$HOME/.cf-gears/<gear>/users.sqlite`
689/// - `sqlite://@file(/abs/path/file.db)` → use absolute path
690/// - `sqlite://` or `sqlite:///` → `$HOME/.cf-gears/<gear>/<gear>.sqlite`
691fn resolve_sqlite_dsn(
692    dsn: &str,
693    home_dir: &Path,
694    gear_name: &str,
695    dry_run: bool,
696) -> Result<String> {
697    if dsn.contains("@file(") {
698        // Extract the file path from @file(...)
699        if let Some(start) = dsn.find("@file(")
700            && let Some(end) = dsn[start..].find(')')
701        {
702            let file_path = &dsn[start + 6..start + end]; // +6 for "@file("
703
704            let resolved_path = if file_path.starts_with('/')
705                || (file_path.len() > 1 && file_path.chars().nth(1) == Some(':'))
706            {
707                // Absolute path (Unix or Windows)
708                PathBuf::from(file_path)
709            } else {
710                // Relative path - resolve under gear directory
711                let gear_dir = home_dir.join(gear_name);
712                if !dry_run {
713                    std::fs::create_dir_all(&gear_dir).with_context(|| {
714                        format!("Failed to create gear directory: {}", gear_dir.display())
715                    })?;
716                }
717                gear_dir.join(file_path)
718            };
719
720            let normalized_path = normalize_path(&resolved_path);
721            // For Windows absolute paths (C:/...), use sqlite:path format
722            // For Unix absolute paths (/...), use sqlite://path format
723            if normalized_path.len() > 1 && normalized_path.chars().nth(1) == Some(':') {
724                // Windows absolute path like C:/...
725                return Ok(format!("sqlite:{normalized_path}"));
726            }
727            // Unix absolute path or relative path
728            return Ok(format!("sqlite://{normalized_path}"));
729        }
730        return Err(anyhow::anyhow!(
731            "Invalid @file() syntax in SQLite DSN: {dsn}"
732        ));
733    }
734
735    // Handle empty DSN or just sqlite:// - default to gear.sqlite
736    if dsn == "sqlite://" || dsn == "sqlite:///" || dsn == "sqlite:" {
737        let gear_dir = home_dir.join(gear_name);
738        if !dry_run {
739            std::fs::create_dir_all(&gear_dir).with_context(|| {
740                format!("Failed to create gear directory: {}", gear_dir.display())
741            })?;
742        }
743        let db_path = gear_dir.join(format!("{gear_name}.sqlite"));
744        let normalized_path = normalize_path(&db_path);
745        // For Windows absolute paths (C:/...), use sqlite:path format
746        // For Unix absolute paths (/...), use sqlite://path format
747        if normalized_path.len() > 1 && normalized_path.chars().nth(1) == Some(':') {
748            // Windows absolute path like C:/...
749            return Ok(format!("sqlite:{normalized_path}"));
750        }
751        // Unix absolute path or relative path
752        return Ok(format!("sqlite://{normalized_path}"));
753    }
754
755    // Return DSN as-is for normal cases
756    Ok(dsn.to_owned())
757}
758
759/// Builds a server-based DSN from individual fields.
760/// Used when no base DSN is provided or when overriding DSN components.
761/// Uses `url::Url` to properly handle percent-encoding of special characters.
762fn build_server_dsn(
763    scheme: &str,
764    host: Option<&str>,
765    port: Option<u16>,
766    user: Option<&str>,
767    password: Option<&str>,
768    dbname: Option<&str>,
769    params: &HashMap<String, String>,
770) -> Result<String> {
771    let host = host.unwrap_or("localhost");
772    let user = user.unwrap_or("postgres"); // reasonable default for server-based DBs
773
774    // Start with base URL
775    let mut url = Url::parse(&format!("{scheme}://dummy/"))
776        .with_context(|| format!("Invalid scheme: {scheme}"))?;
777
778    // Set host (required)
779    url.set_host(Some(host))
780        .with_context(|| format!("Invalid host: {host}"))?;
781
782    // Set port if provided
783    if let Some(port) = port {
784        url.set_port(Some(port))
785            .map_err(|()| anyhow::anyhow!("Invalid port: {port}"))?;
786    }
787
788    // Set username
789    url.set_username(user)
790        .map_err(|()| anyhow::anyhow!("Failed to set username: {user}"))?;
791
792    // Set password if provided
793    if let Some(password) = password {
794        url.set_password(Some(password))
795            .map_err(|()| anyhow::anyhow!("Failed to set password"))?;
796    }
797
798    // Set database name as path (with leading slash)
799    if let Some(dbname) = dbname {
800        // Manually encode the dbname to handle special characters
801        let encoded_dbname = urlencoding::encode(dbname);
802        url.set_path(&format!("/{encoded_dbname}"));
803    } else {
804        url.set_path("/");
805    }
806
807    // Set query parameters
808    if !params.is_empty() {
809        // Use url::Url::query_pairs_mut() to properly handle encoding
810        let mut query_pairs = url.query_pairs_mut();
811        for (key, value) in params {
812            query_pairs.append_pair(key, value);
813        }
814    }
815
816    Ok(url.to_string())
817}
818
819/// Builds a `SQLite` DSN by replacing the database file path while preserving query parameters.
820fn build_sqlite_dsn_with_dbname_override(
821    original_dsn: &str,
822    dbname: &str,
823    gear_name: &str,
824    home_dir: &Path,
825    dry_run: bool,
826) -> Result<String> {
827    // Parse the original DSN to extract query parameters
828    let query_params = if let Some(query_start) = original_dsn.find('?') {
829        &original_dsn[query_start..]
830    } else {
831        ""
832    };
833
834    // Build the correct path for the database file
835    let gear_dir = home_dir.join(gear_name);
836    if !dry_run {
837        std::fs::create_dir_all(&gear_dir)
838            .with_context(|| format!("Failed to create gear directory: {}", gear_dir.display()))?;
839    }
840    let db_path = gear_dir.join(dbname);
841    let normalized_path = normalize_path(&db_path);
842
843    // Build the new DSN with correct format for the platform
844    let dsn_base = if normalized_path.len() > 1 && normalized_path.chars().nth(1) == Some(':') {
845        // Windows absolute path like C:/...
846        format!("sqlite:{normalized_path}")
847    } else {
848        // Unix absolute path or relative path
849        format!("sqlite://{normalized_path}")
850    };
851
852    Ok(format!("{dsn_base}{query_params}"))
853}
854
855/// Builds a `SQLite` DSN from file/path or validates existing DSN.
856/// If dbname is provided, it overrides the database file in the DSN.
857///
858/// # Arguments
859/// * `dry_run` - If true, skip directory creation (for read-only inspection)
860fn build_sqlite_dsn(
861    dsn: Option<&str>,
862    file: Option<&str>,
863    path: Option<&PathBuf>,
864    dbname: Option<&str>,
865    gear_name: &str,
866    home_dir: &Path,
867    dry_run: bool,
868) -> Result<String> {
869    // If full DSN provided, resolve @file() syntax and validate
870    if let Some(dsn) = dsn {
871        let resolved_dsn = resolve_sqlite_dsn(dsn, home_dir, gear_name, dry_run)?;
872
873        // If dbname is provided, we need to replace the database file path while preserving query params
874        if let Some(dbname) = dbname {
875            return build_sqlite_dsn_with_dbname_override(
876                &resolved_dsn,
877                dbname,
878                gear_name,
879                home_dir,
880                dry_run,
881            );
882        }
883
884        validate_dsn(&resolved_dsn)?;
885        return Ok(resolved_dsn);
886    }
887
888    // Build from path (absolute)
889    if let Some(path) = path {
890        let absolute_path = if path.is_absolute() {
891            path.clone()
892        } else {
893            home_dir.join(path)
894        };
895        let normalized_path = normalize_path(&absolute_path);
896        // For Windows absolute paths (C:/...), use sqlite:path format
897        // For Unix absolute paths (/...), use sqlite://path format
898        if normalized_path.len() > 1 && normalized_path.chars().nth(1) == Some(':') {
899            // Windows absolute path like C:/...
900            return Ok(format!("sqlite:{normalized_path}"));
901        }
902        // Unix absolute path or relative path
903        return Ok(format!("sqlite://{normalized_path}"));
904    }
905
906    // Build from file (relative under gear dir)
907    if let Some(file) = file {
908        let gear_dir = home_dir.join(gear_name);
909        if !dry_run {
910            std::fs::create_dir_all(&gear_dir).with_context(|| {
911                format!("Failed to create gear directory: {}", gear_dir.display())
912            })?;
913        }
914        let db_path = gear_dir.join(file);
915        let normalized_path = normalize_path(&db_path);
916        // For Windows absolute paths (C:/...), use sqlite:path format
917        // For Unix absolute paths (/...), use sqlite://path format
918        if normalized_path.len() > 1 && normalized_path.chars().nth(1) == Some(':') {
919            // Windows absolute path like C:/...
920            return Ok(format!("sqlite:{normalized_path}"));
921        }
922        // Unix absolute path or relative path
923        return Ok(format!("sqlite://{normalized_path}"));
924    }
925
926    // Default to gear.sqlite
927    let gear_dir = home_dir.join(gear_name);
928    if !dry_run {
929        std::fs::create_dir_all(&gear_dir)
930            .with_context(|| format!("Failed to create gear directory: {}", gear_dir.display()))?;
931    }
932    let db_path = gear_dir.join(format!("{gear_name}.sqlite"));
933    let normalized_path = normalize_path(&db_path);
934    // For Windows absolute paths (C:/...), use sqlite:path format
935    // For Unix absolute paths (/...), use sqlite://path format
936    if normalized_path.len() > 1 && normalized_path.chars().nth(1) == Some(':') {
937        // Windows absolute path like C:/...
938        Ok(format!("sqlite:{normalized_path}"))
939    } else {
940        // Unix absolute path or relative path
941        Ok(format!("sqlite://{normalized_path}"))
942    }
943}
944
945/// Type alias for the complex return type of `build_final_db_for_gear`
946type DbConfigResult = Result<Option<(String /* final_dsn */, PoolCfg)>>;
947
948/// Builder for accumulating database configuration from multiple sources
949#[derive(Default)]
950struct DbConfigBuilder {
951    dsn: Option<String>,
952    host: Option<String>,
953    port: Option<u16>,
954    user: Option<String>,
955    password: Option<String>,
956    dbname: Option<String>,
957    params: HashMap<String, String>,
958    pool: PoolCfg,
959}
960
961impl DbConfigBuilder {
962    fn new() -> Self {
963        Self::default()
964    }
965
966    /// Apply global server configuration
967    fn apply_global_server(
968        &mut self,
969        global_server: &DbConnConfig,
970        home_dir: &Path,
971        gear_name: &str,
972        dry_run: bool,
973    ) -> Result<()> {
974        // Apply global server DSN
975        if let Some(global_dsn) = &global_server.dsn {
976            let expanded_dsn = expand_env_in_dsn(global_dsn.expose())?;
977            // For SQLite, resolve @file() syntax before validation
978            let resolved_dsn = if expanded_dsn.starts_with("sqlite") {
979                resolve_sqlite_dsn(&expanded_dsn, home_dir, gear_name, dry_run)?
980            } else {
981                expanded_dsn
982            };
983            validate_dsn(&resolved_dsn)?;
984            self.dsn = Some(resolved_dsn);
985        }
986
987        // Apply global server fields (override DSN parts)
988        if let Some(host) = &global_server.host {
989            self.host = Some(host.clone());
990        }
991        if let Some(port) = global_server.port {
992            self.port = Some(port);
993        }
994        if let Some(user) = &global_server.user {
995            self.user = Some(user.clone());
996        }
997        if let Some(password) = resolve_password(
998            global_server
999                .password
1000                .as_ref()
1001                .map(toolkit_utils::SecretString::expose),
1002        )? {
1003            self.password = Some(password);
1004        }
1005        if let Some(dbname) = &global_server.dbname {
1006            self.dbname = Some(dbname.clone());
1007        }
1008        if let Some(params) = &global_server.params {
1009            self.params.extend(params.clone());
1010        }
1011        if let Some(pool) = &global_server.pool {
1012            self.pool = pool.clone();
1013        }
1014
1015        Ok(())
1016    }
1017
1018    /// Apply gear DSN (overrides global DSN)
1019    fn apply_gear_dsn(
1020        &mut self,
1021        gear_dsn: &str,
1022        home_dir: &Path,
1023        gear_name: &str,
1024        dry_run: bool,
1025    ) -> Result<()> {
1026        // For SQLite, resolve @file() syntax before validation
1027        let resolved_dsn = if gear_dsn.starts_with("sqlite") {
1028            resolve_sqlite_dsn(gear_dsn, home_dir, gear_name, dry_run)?
1029        } else {
1030            gear_dsn.to_owned()
1031        };
1032        validate_dsn(&resolved_dsn)?;
1033        self.dsn = Some(resolved_dsn);
1034        Ok(())
1035    }
1036
1037    /// Apply gear fields (override everything)
1038    fn apply_gear_fields(&mut self, gear_db_config: &DbConnConfig) -> Result<()> {
1039        if let Some(host) = &gear_db_config.host {
1040            self.host = Some(host.clone());
1041        }
1042        if let Some(port) = gear_db_config.port {
1043            self.port = Some(port);
1044        }
1045        if let Some(user) = &gear_db_config.user {
1046            self.user = Some(user.clone());
1047        }
1048        if let Some(password) = resolve_password(
1049            gear_db_config
1050                .password
1051                .as_ref()
1052                .map(toolkit_utils::SecretString::expose),
1053        )? {
1054            self.password = Some(password);
1055        }
1056        if let Some(dbname) = &gear_db_config.dbname {
1057            self.dbname = Some(dbname.clone());
1058        }
1059        if let Some(params) = &gear_db_config.params {
1060            self.params.extend(params.clone());
1061        }
1062        if let Some(pool) = &gear_db_config.pool {
1063            // Gear pool settings override global ones
1064            if let Some(max_conns) = pool.max_conns {
1065                self.pool.max_conns = Some(max_conns);
1066            }
1067            if let Some(acquire_timeout) = pool.acquire_timeout {
1068                self.pool.acquire_timeout = Some(acquire_timeout);
1069            }
1070        }
1071        Ok(())
1072    }
1073
1074    /// Check if we have any field overrides that require rebuilding the DSN
1075    fn has_field_overrides(&self) -> bool {
1076        self.host.is_some()
1077            || self.port.is_some()
1078            || self.user.is_some()
1079            || self.password.is_some()
1080            || !self.params.is_empty()
1081    }
1082}
1083
1084/// Determines the database backend type (`SQLite` or server-based)
1085fn decide_backend(builder: &DbConfigBuilder, gear_db_config: &DbConnConfig) -> bool {
1086    // Always treat as SQLite if DSN starts with "sqlite", regardless of server reference
1087    // Also treat as SQLite if no server reference and no explicit DSN (default case)
1088    gear_db_config.file.is_some()
1089        || gear_db_config.path.is_some()
1090        || builder
1091            .dsn
1092            .as_ref()
1093            .is_some_and(|dsn| dsn.starts_with("sqlite"))
1094        || (gear_db_config.server.is_none() && builder.dsn.is_none())
1095}
1096
1097/// Finalize `SQLite` DSN from builder state
1098fn finalize_sqlite_dsn(
1099    builder: &DbConfigBuilder,
1100    gear_db_config: &DbConnConfig,
1101    gear_name: &str,
1102    home_dir: &Path,
1103    dry_run: bool,
1104) -> Result<String> {
1105    build_sqlite_dsn(
1106        builder.dsn.as_deref(),
1107        gear_db_config.file.as_deref(),
1108        gear_db_config.path.as_ref(),
1109        builder.dbname.as_deref(),
1110        gear_name,
1111        home_dir,
1112        dry_run,
1113    )
1114}
1115
1116/// Finalize server-based DSN from builder state
1117fn finalize_server_dsn(builder: &DbConfigBuilder, gear_name: &str) -> Result<String> {
1118    // Extract dbname from DSN if not provided separately
1119    let dbname = if let Some(dbname) = builder.dbname.as_deref() {
1120        dbname.to_owned()
1121    } else if let Some(dsn) = builder.dsn.as_ref() {
1122        // Try to extract dbname from DSN path
1123        if let Ok(parsed) = url::Url::parse(dsn) {
1124            let path = parsed.path();
1125            if path.len() > 1 {
1126                // Remove leading slash and return the path as dbname
1127                path[1..].to_string()
1128            } else {
1129                return Err(anyhow::anyhow!(
1130                    "Server-based database config for gear '{gear_name}' missing required 'dbname'"
1131                ));
1132            }
1133        } else {
1134            return Err(anyhow::anyhow!(
1135                "Server-based database config for gear '{gear_name}' missing required 'dbname'"
1136            ));
1137        }
1138    } else {
1139        return Err(anyhow::anyhow!(
1140            "Server-based database config for gear '{gear_name}' missing required 'dbname'"
1141        ));
1142    };
1143
1144    if builder.has_field_overrides() || builder.dsn.is_none() {
1145        // Build DSN from fields when we have overrides or no original DSN
1146        let scheme = if let Some(dsn) = &builder.dsn {
1147            let parsed = Url::parse(dsn)?;
1148            parsed.scheme().to_owned()
1149        } else {
1150            "postgresql".to_owned() // default
1151        };
1152
1153        build_server_dsn(
1154            &scheme,
1155            builder.host.as_deref(),
1156            builder.port,
1157            builder.user.as_deref(),
1158            builder.password.as_deref(),
1159            Some(&dbname),
1160            &builder.params,
1161        )
1162    } else if let Some(original_dsn) = &builder.dsn {
1163        // Use original DSN when no field overrides (but update dbname if needed)
1164        if let Ok(mut parsed) = Url::parse(original_dsn) {
1165            // Update the path with the final dbname if it's different
1166            let original_dbname = parsed.path().trim_start_matches('/');
1167            if original_dbname != dbname {
1168                parsed.set_path(&format!("/{dbname}"));
1169            }
1170            Ok(parsed.to_string())
1171        } else {
1172            // Fallback to building from fields if URL parsing fails
1173            build_server_dsn(
1174                "postgresql",
1175                builder.host.as_deref(),
1176                builder.port,
1177                builder.user.as_deref(),
1178                builder.password.as_deref(),
1179                Some(&dbname),
1180                &builder.params,
1181            )
1182        }
1183    } else {
1184        // This branch should not be reachable due to the condition above
1185        unreachable!("final_dsn should not be None when has_field_overrides is false")
1186    }
1187}
1188
1189/// Redacts password from DSN for logging
1190fn redact_dsn_for_logging(dsn: &str) -> Result<String> {
1191    if dsn.contains('@') {
1192        let parsed = Url::parse(dsn)?;
1193        let mut log_url = parsed;
1194        if log_url.password().is_some() {
1195            log_url.set_password(Some("***")).ok();
1196        }
1197        Ok(log_url.to_string())
1198    } else {
1199        Ok(dsn.to_owned())
1200    }
1201}
1202
1203// ---- OoP Gear Configuration Support ----
1204
1205/// Environment variable name for passing rendered gear config to `OoP` gears.
1206pub const TOOLKIT_MODULE_CONFIG_ENV: &str = "TOOLKIT_MODULE_CONFIG";
1207
1208/// Rendered database configuration for `OoP` gears.
1209/// Contains both global server templates and gear-specific config.
1210#[derive(Debug, Clone, Serialize, Deserialize)]
1211pub struct RenderedDbConfig {
1212    /// Global database configuration with server templates.
1213    /// `OoP` gear can use these servers for reference.
1214    #[serde(skip_serializing_if = "Option::is_none")]
1215    pub global: Option<GlobalDatabaseConfig>,
1216    /// Gear-specific database configuration (already merged with server reference in master).
1217    /// This is the `gears.<name>.database` section after server merge.
1218    #[serde(skip_serializing_if = "Option::is_none")]
1219    pub gear: Option<DbConnConfig>,
1220}
1221
1222impl RenderedDbConfig {
1223    /// Create a new `RenderedDbConfig` from global and gear database configurations.
1224    #[must_use]
1225    pub fn new(global: Option<GlobalDatabaseConfig>, gear: Option<DbConnConfig>) -> Self {
1226        Self { global, gear }
1227    }
1228}
1229
1230/// Rendered gear configuration passed to `OoP` gears via environment variable.
1231///
1232/// This struct contains everything an `OoP` gear needs to initialize:
1233/// - Database configuration (structured, for field-by-field merge in `OoP`)
1234/// - Gear config section
1235/// - Logging configuration (for key-by-key merge in `OoP`)
1236/// - OpenTelemetry configuration (resource, tracing, metrics)
1237///
1238/// The runtime section is excluded as it's only relevant for the master host.
1239#[derive(Debug, Clone, Serialize, Deserialize)]
1240pub struct RenderedGearConfig {
1241    /// Rendered database configuration (structured, not resolved DSN).
1242    /// `OoP` gear will merge this with local --config using field-by-field merge.
1243    #[serde(skip_serializing_if = "Option::is_none")]
1244    pub database: Option<RenderedDbConfig>,
1245    /// Gear-specific config section (passed as-is)
1246    #[serde(default)]
1247    pub config: serde_json::Value,
1248    /// Logging configuration from master host.
1249    /// `OoP` gear will merge this with local --config (local keys override master keys).
1250    #[serde(skip_serializing_if = "Option::is_none")]
1251    pub logging: Option<LoggingConfig>,
1252    /// OpenTelemetry configuration from master host (resource, tracing, metrics).
1253    #[serde(skip_serializing_if = "Option::is_none")]
1254    pub opentelemetry: Option<OpenTelemetryConfig>,
1255}
1256
1257impl RenderedGearConfig {
1258    /// Deserialize from JSON string (used when reading from env var).
1259    ///
1260    /// # Errors
1261    /// Returns an error if JSON parsing fails.
1262    pub fn from_json(json: &str) -> Result<Self> {
1263        serde_json::from_str(json).context("Failed to parse RenderedGearConfig from JSON")
1264    }
1265
1266    /// Serialize to JSON string (used when passing to `OoP` gears via env var).
1267    ///
1268    /// # Errors
1269    /// Returns an error if serialization fails.
1270    pub fn to_json(&self) -> Result<String> {
1271        serde_json::to_string(self).context("Failed to serialize RenderedGearConfig to JSON")
1272    }
1273}
1274
1275/// Render gear configuration for passing to `OoP` gear via environment variable.
1276///
1277/// This function prepares a structured configuration that an `OoP` gear can use
1278/// to initialize itself. The configuration includes:
1279/// - Database configuration (structured, for field-by-field merge in `OoP`)
1280/// - Gear config section
1281/// - Logging configuration (for key-by-key merge in `OoP`)
1282/// - Tracing configuration for OTEL
1283///
1284/// The runtime section is excluded as it's only relevant for the master host.
1285///
1286/// `OoP` gears receive this via `TOOLKIT_MODULE_CONFIG` env var and can override
1287/// any section with their local --config file.
1288///
1289/// # Errors
1290/// Returns an error if gear configuration parsing fails.
1291pub fn render_gear_config_for_oop(
1292    app: &AppConfig,
1293    gear_name: &str,
1294    _home_dir: &std::path::Path,
1295) -> Result<RenderedGearConfig> {
1296    // Get gear's database config (with server reference, but NOT resolved to DSN).
1297    // OoP gear will use DbManager to resolve this with its local overrides.
1298    let gear_db_config = parse_gear_config(app, gear_name)
1299        .ok()
1300        .and_then(|entry| entry.database);
1301
1302    // Build database config with global servers and gear config (structured, not resolved)
1303    let database = if gear_db_config.is_some() || app.database.is_some() {
1304        Some(RenderedDbConfig::new(app.database.clone(), gear_db_config))
1305    } else {
1306        None
1307    };
1308
1309    // Get the gear's config section (excluding database and runtime)
1310    let config = parse_gear_config(app, gear_name)
1311        .map(|entry| entry.config)
1312        .unwrap_or_default();
1313
1314    // Pass logging config from master host so OoP gears can merge with their local config
1315    let logging = app.logging.clone();
1316
1317    // Pass OpenTelemetry config from master host so OoP gears use the same settings
1318    let opentelemetry = if app.opentelemetry.tracing.enabled || app.opentelemetry.metrics.enabled {
1319        Some(app.opentelemetry.clone())
1320    } else {
1321        None
1322    };
1323
1324    Ok(RenderedGearConfig {
1325        database,
1326        config,
1327        logging: Some(logging),
1328        opentelemetry,
1329    })
1330}
1331
1332/// Parse a gear config from the config bag.
1333///
1334/// # Errors
1335/// Returns an error if the gear is not found or config parsing fails.
1336pub fn parse_gear_config(app: &AppConfig, gear_name: &str) -> Result<GearConfig> {
1337    let gear_raw = app
1338        .gears
1339        .get(gear_name)
1340        .cloned()
1341        .ok_or_else(|| anyhow::anyhow!("Gear '{gear_name}' not found in config"))?;
1342
1343    let gear_config: GearConfig = serde_json::from_value(gear_raw)?;
1344    Ok(gear_config)
1345}
1346
1347/// Helper to get runtime config for a gear (if present).
1348///
1349/// # Errors
1350/// Returns an error if gear config parsing fails.
1351pub fn get_gear_runtime_config(app: &AppConfig, gear_name: &str) -> Result<Option<GearRuntime>> {
1352    let entry = parse_gear_config(app, gear_name)?;
1353    Ok(entry.runtime)
1354}
1355
1356/// Merges global + gear DB configs into a final, validated DSN and pool config.
1357/// Precedence: Global DSN -> Global fields -> Gear DSN -> Gear fields (fields always win).
1358/// For server-based, returns error if final dbname is missing.
1359/// For `SQLite`, builds/normalizes sqlite DSN from file/path or uses a full DSN as-is.
1360///
1361/// # Arguments
1362/// * `dry_run` - If true, skip directory creation (for read-only inspection)
1363///
1364/// # Errors
1365/// Returns an error if database configuration is invalid or resolution fails.
1366pub fn build_final_db_for_gear(
1367    app: &AppConfig,
1368    gear_name: &str,
1369    home_dir: &Path,
1370    dry_run: bool,
1371) -> DbConfigResult {
1372    // Parse gear entry from raw JSON
1373    let Some(gear_raw) = app.gears.get(gear_name) else {
1374        return Ok(None); // No gear config
1375    };
1376
1377    let gear_entry: GearConfig = serde_json::from_value(gear_raw.clone())
1378        .with_context(|| format!("Invalid gear config structure for '{gear_name}'"))?;
1379
1380    let Some(gear_db_config) = gear_entry.database else {
1381        tracing::warn!(
1382            "Gear '{}' has no database configuration; DB capability disabled",
1383            gear_name
1384        );
1385        return Ok(None);
1386    };
1387
1388    // Global database config
1389    let global_db_config = app.database.as_ref();
1390
1391    // Build configuration using the builder pattern
1392    let mut builder = DbConfigBuilder::new();
1393
1394    // Step 1: Apply global server config if referenced
1395    if let Some(server_name) = &gear_db_config.server {
1396        let global_server = global_db_config
1397            .and_then(|gc| gc.servers.get(server_name))
1398            .ok_or_else(|| {
1399                anyhow::anyhow!("Referenced server '{server_name}' not found in global config")
1400            })?;
1401
1402        builder.apply_global_server(global_server, home_dir, gear_name, dry_run)?;
1403    }
1404
1405    // Step 2: Apply gear DSN (override global)
1406    if let Some(gear_dsn) = &gear_db_config.dsn {
1407        builder.apply_gear_dsn(gear_dsn.expose(), home_dir, gear_name, dry_run)?;
1408    }
1409
1410    // Step 3: Apply gear fields (override everything)
1411    builder.apply_gear_fields(&gear_db_config)?;
1412
1413    // Determine backend type and finalize DSN
1414    let is_sqlite = decide_backend(&builder, &gear_db_config);
1415
1416    let result_dsn = if is_sqlite {
1417        finalize_sqlite_dsn(&builder, &gear_db_config, gear_name, home_dir, dry_run)?
1418    } else {
1419        finalize_server_dsn(&builder, gear_name)?
1420    };
1421
1422    // Validate final DSN
1423    validate_dsn(&result_dsn)?;
1424
1425    // Redact password for logging
1426    let log_dsn = redact_dsn_for_logging(&result_dsn)?;
1427
1428    tracing::info!(
1429        "Built final DB config for gear '{}': {}",
1430        gear_name,
1431        log_dsn
1432    );
1433
1434    Ok(Some((result_dsn, builder.pool)))
1435}
1436
1437/// Helper function to get gear database configuration from `AppConfig`.
1438/// Returns the `DbConnConfig` for a gear, or None if the gear has no database config.
1439#[must_use]
1440pub fn get_gear_db_config(app: &AppConfig, gear_name: &str) -> Option<DbConnConfig> {
1441    let gear_raw = app.gears.get(gear_name)?;
1442    let gear_entry: GearConfig = serde_json::from_value(gear_raw.clone()).ok()?;
1443    gear_entry.database
1444}
1445
1446/// Helper function to resolve gear home directory.
1447/// Returns the path where gear-specific files (like `SQLite` databases) should be stored.
1448#[must_use]
1449pub fn gear_home(app: &AppConfig, gear_name: &str) -> PathBuf {
1450    PathBuf::from(&app.server.home_dir).join(gear_name)
1451}
1452
1453#[cfg(test)]
1454#[cfg_attr(coverage_nightly, coverage(off))]
1455mod tests {
1456    use super::*;
1457    use serial_test::serial;
1458    use std::fs;
1459    use temp_env::with_var;
1460    use tempfile::tempdir;
1461
1462    /// Helper: a normalized `home_dir` should be absolute and not start with '~'.
1463    fn is_normalized_path(p: &Path) -> bool {
1464        p.is_absolute() && !p.starts_with("~")
1465    }
1466
1467    /// Helper: platform default subdirectory name.
1468    fn default_subdir() -> &'static str {
1469        ".cf-gears"
1470    }
1471
1472    #[test]
1473    fn test_remap_gear_env_key() {
1474        // (input, expected) — input keys are the dot-joined segments figment
1475        // produces after `split("__")`, before its own lowercasing.
1476        let cases = [
1477            // Gear-name segment: underscores -> dashes.
1478            ("gears.my_gear.port", "gears.my-gear.port"),
1479            // Only the gear-name segment is remapped; nested fields keep '_'.
1480            ("gears.my_gear.max_age_days", "gears.my-gear.max_age_days"),
1481            // Multiple underscores in the gear name are all remapped.
1482            ("gears.a_b_c.field", "gears.a-b-c.field"),
1483            // Already-kebab gear name is unaffected (remapping '-' is a no-op).
1484            ("gears.my-gear.port", "gears.my-gear.port"),
1485            // Non-gears branches are left alone.
1486            ("vendor.my_vendor.key", "vendor.my_vendor.key"),
1487            ("server.home_dir", "server.home_dir"),
1488            // `gears` with no gear-name segment is left as-is.
1489            ("gears", "gears"),
1490            // Uppercase input is lowercased.
1491            ("GEARS.MY_GEAR.PORT", "gears.my-gear.port"),
1492            // Bare key without dots.
1493            ("server", "server"),
1494        ];
1495
1496        for (input, expected) in cases {
1497            assert_eq!(
1498                remap_gear_env_key(input),
1499                expected,
1500                "remap_gear_env_key({input:?})"
1501            );
1502        }
1503    }
1504
1505    #[test]
1506    fn test_default_config_structure() {
1507        let config = AppConfig::default();
1508
1509        // Database defaults (simplified structure)
1510        assert!(config.database.is_none());
1511
1512        // Logging defaults
1513        let logging = config.logging;
1514        assert!(logging.contains_key("default"));
1515
1516        let default_section = &logging["default"];
1517        assert_eq!(default_section.console_level, Some(Level::INFO));
1518        assert_eq!(default_section.file().unwrap(), "logs/cf-gears.log");
1519
1520        // Gears bag is empty by default
1521        assert!(config.gears.is_empty());
1522    }
1523
1524    #[test]
1525    fn oop_http_labels_coerce_numeric_and_bool_values_to_strings() {
1526        // A bare-numeric env value (e.g. `APP__OOP_HTTP__LABELS__SHARD=7`) is
1527        // parsed as an integer by the env layer; it must load as a string
1528        // label rather than aborting the config parse.
1529        let cfg: OopHttpConfig = serde_json::from_value(serde_json::json!({
1530            "listen_addr": "0.0.0.0:8080",
1531            "labels": {
1532                "shard": 7,
1533                "role": "ingest",
1534                "canary": true,
1535            }
1536        }))
1537        .expect("numeric/bool label values must deserialize");
1538
1539        assert_eq!(cfg.labels.get("shard").map(String::as_str), Some("7"));
1540        assert_eq!(cfg.labels.get("role").map(String::as_str), Some("ingest"));
1541        assert_eq!(cfg.labels.get("canary").map(String::as_str), Some("true"));
1542    }
1543
1544    // `#[serial]`: calls load_layered, which reads the APP__ env layer; serialize
1545    // against the env-override tests that mutate process-global APP__* vars.
1546    #[test]
1547    #[serial]
1548    fn test_load_layered_normalizes_home_dir() {
1549        let tmp = tempdir().unwrap();
1550        let cfg_path = tmp.path().join("cfg.yaml");
1551
1552        // Provide a user path with "~" to ensure expansion and normalization.
1553        let yaml = r#"
1554server:
1555  home_dir: "~/.test_cfgears"
1556
1557database:
1558  servers:
1559    test_postgres:
1560      dsn: "postgres://user:pass@localhost/db"
1561      pool:
1562        max_conns: 20
1563
1564logging:
1565  default:
1566    console_level: debug
1567    file: "logs/default.log"
1568"#;
1569        fs::write(&cfg_path, yaml).unwrap();
1570
1571        let config = AppConfig::load_layered(&cfg_path).unwrap();
1572
1573        // home_dir should be normalized immediately
1574        assert!(is_normalized_path(&config.server.home_dir));
1575        assert!(config.server.home_dir.ends_with(".test_cfgears"));
1576
1577        // database parsed (TODO: update test to use new config format)
1578        // For now, since this test uses old format YAML, we skip DB assertions
1579        // let db = config.database.as_ref().unwrap();
1580
1581        // logging parsed
1582        let logging = &config.logging;
1583        let def = &logging["default"];
1584        assert_eq!(def.console_level, Some(Level::DEBUG));
1585        assert_eq!(def.section_file.as_ref().unwrap().file, "logs/default.log");
1586    }
1587
1588    #[test]
1589    fn test_load_or_default_normalizes_home_dir_when_none() {
1590        // No external file => defaults, but home_dir must be normalized.
1591        // Ensure platform env is present for home resolution in CI.
1592        let tmp = tempdir().unwrap();
1593        let env_var = if cfg!(target_os = "windows") {
1594            "APPDATA"
1595        } else {
1596            "HOME"
1597        };
1598        with_var(env_var, Some(tmp.path().to_str().unwrap()), || {
1599            let config = AppConfig::load_or_default(None).unwrap();
1600            assert!(is_normalized_path(&config.server.home_dir));
1601            assert!(config.server.home_dir.ends_with(default_subdir()));
1602        });
1603    }
1604
1605    // `#[serial]`: load_layered reads the APP__ env layer, so `gears.is_empty()`
1606    // would race with the (also `#[serial]`) env-override tests that set APP__GEARS__*.
1607    #[test]
1608    #[serial]
1609    fn test_minimal_yaml_config() {
1610        let tmp = tempdir().unwrap();
1611        let cfg_path = tmp.path().join("cfg.yaml");
1612
1613        let yaml = r#"
1614server:
1615  home_dir: "~/.minimal"
1616"#;
1617        fs::write(&cfg_path, yaml).unwrap();
1618
1619        let config = AppConfig::load_layered(&cfg_path).unwrap();
1620
1621        // Required fields are parsed; home_dir normalized
1622        assert!(is_normalized_path(&config.server.home_dir));
1623        assert!(config.server.home_dir.ends_with(".minimal"));
1624
1625        // Optional sections default to None
1626        assert!(config.database.is_none());
1627        assert!(config.gears.is_empty());
1628    }
1629
1630    #[test]
1631    fn test_cli_overrides() {
1632        let mut config = AppConfig::default();
1633
1634        let args = CliArgs {
1635            config: None,
1636            print_config: false,
1637            verbose: 2, // trace
1638            mock: false,
1639        };
1640
1641        config.apply_cli_overrides(args.verbose);
1642
1643        // Port override
1644
1645        // Verbose override affects logging
1646        let logging = &config.logging;
1647        let default_section = &logging["default"];
1648        assert_eq!(default_section.console_level, Some(Level::TRACE));
1649    }
1650
1651    #[test]
1652    fn test_cli_verbose_levels_matrix() {
1653        for (verbose_level, expected_log_level) in [
1654            (0, Some(Level::INFO)), // unchanged from default
1655            (1, Some(Level::DEBUG)),
1656            (2, Some(Level::TRACE)),
1657            (3, Some(Level::TRACE)), // cap at trace
1658        ] {
1659            let mut config = AppConfig::default();
1660            let args = CliArgs {
1661                config: None,
1662                print_config: false,
1663                verbose: verbose_level,
1664                mock: false,
1665            };
1666
1667            config.apply_cli_overrides(args.verbose);
1668
1669            let logging = &config.logging;
1670            let default_section = &logging["default"];
1671
1672            if verbose_level == 0 {
1673                assert_eq!(default_section.console_level, Some(Level::INFO));
1674            } else {
1675                assert_eq!(default_section.console_level, expected_log_level);
1676            }
1677        }
1678    }
1679
1680    // `#[serial]`: calls load_layered (reads APP__ env layer) and asserts on the
1681    // gears map, which the env-override tests mutate via APP__GEARS__*.
1682    #[test]
1683    #[serial]
1684    fn test_layered_config_loading_with_gears_dir() {
1685        let tmp = tempdir().unwrap();
1686        let cfg_path = tmp.path().join("gears_dir.yaml");
1687        let gears_dir = tmp.path().join("gears");
1688
1689        fs::create_dir_all(&gears_dir).unwrap();
1690        let gear_cfg = gears_dir.join("test_gear.yaml");
1691        fs::write(
1692            &gear_cfg,
1693            r#"
1694setting1: "value1"
1695setting2: 42
1696"#,
1697        )
1698        .unwrap();
1699
1700        // Convert Windows paths to forward slashes for YAML compatibility
1701        let gears_dir_str = normalize_path(&gears_dir);
1702        let yaml = format!(
1703            r#"
1704server:
1705  home_dir: "~/.gears_test"
1706
1707gears_dir: "{gears_dir_str}"
1708
1709gears:
1710  existing_gear:
1711    key: "value"
1712"#
1713        );
1714
1715        fs::write(&cfg_path, yaml).unwrap();
1716
1717        let config = AppConfig::load_layered(&cfg_path).unwrap();
1718
1719        // Should have loaded the existing gear from gears section
1720        assert!(config.gears.contains_key("existing_gear"));
1721
1722        // Should have also loaded the gear from gears_dir
1723        assert!(config.gears.contains_key("test_gear"));
1724
1725        // Check the loaded gear config
1726        let test_gear = &config.gears["test_gear"];
1727        assert_eq!(test_gear["setting1"], "value1");
1728        assert_eq!(test_gear["setting2"], 42);
1729    }
1730
1731    // `#[serial]`: calls load_layered, which reads the APP__ env layer; serialize
1732    // against the env-override tests that mutate process-global APP__* vars.
1733    #[test]
1734    #[serial]
1735    fn test_load_and_init_logging_smoke() {
1736        // Just verifies structure is acceptable for logging init path.
1737        let tmp = tempdir().unwrap();
1738        let cfg_path = tmp.path().join("logging.yaml");
1739        let yaml = r#"
1740server:
1741  home_dir: "~/.logging_test"
1742
1743logging:
1744  default:
1745    console_level: debug
1746    file: ""
1747    file_level: info
1748"#;
1749        fs::write(&cfg_path, yaml).unwrap();
1750
1751        let config = AppConfig::load_layered(&cfg_path).unwrap();
1752        let logging = &config.logging;
1753        assert!(logging.contains_key("default"));
1754
1755        let default_section = &logging["default"];
1756        assert_eq!(default_section.console_level, Some(Level::DEBUG));
1757        assert_eq!(default_section.file_level(), Some(Level::INFO));
1758        // not calling init to avoid side effects in tests
1759    }
1760
1761    // ===================== DB Configuration Precedence Tests =====================
1762
1763    /// Helper function to create `AppConfig` with database server configuration
1764    fn create_app_with_server(server_name: &str, db_config: DbConnConfig) -> AppConfig {
1765        let mut servers = HashMap::new();
1766        servers.insert(server_name.to_owned(), db_config);
1767
1768        AppConfig {
1769            database: Some(GlobalDatabaseConfig {
1770                servers,
1771                auto_provision: None,
1772            }),
1773            ..Default::default()
1774        }
1775    }
1776
1777    /// Helper function to add a gear to `AppConfig`
1778    fn add_gear_to_app(app: &mut AppConfig, gear_name: &str, database_config: &serde_json::Value) {
1779        app.gears.insert(
1780            gear_name.to_owned(),
1781            serde_json::json!({
1782                "database": database_config,
1783                "config": {}
1784            }),
1785        );
1786    }
1787
1788    /// Helper function to add a gear with custom config to `AppConfig`
1789    fn add_gear_with_config(app: &mut AppConfig, gear_name: &str, config: &serde_json::Value) {
1790        app.gears.insert(
1791            gear_name.to_owned(),
1792            serde_json::json!({
1793                "database": {},
1794                "config": config
1795            }),
1796        );
1797    }
1798
1799    /// Helper function to create a minimal `AppConfig` for testing
1800    fn create_minimal_app() -> AppConfig {
1801        AppConfig {
1802            database: None,
1803            gears: HashMap::new(),
1804            ..Default::default()
1805        }
1806    }
1807
1808    #[test]
1809    fn test_precedence_global_dsn_only() {
1810        let tmp = tempdir().unwrap();
1811        let home_dir = tmp.path();
1812
1813        let mut app = create_app_with_server(
1814            "test_server",
1815            DbConnConfig {
1816                dsn: Some(toolkit_utils::SecretString::new(
1817                    "postgresql://global_user:global_pass@global_host:5432/global_db",
1818                )),
1819                ..Default::default()
1820            },
1821        );
1822
1823        // Gear references global server
1824        add_gear_to_app(
1825            &mut app,
1826            "test_gear",
1827            &serde_json::json!({
1828                "server": "test_server"
1829            }),
1830        );
1831
1832        let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
1833        assert!(result.is_some());
1834
1835        let (dsn, _pool) = result.unwrap();
1836        assert!(dsn.contains("global_user"));
1837        assert!(dsn.contains("global_host"));
1838        assert!(dsn.contains("global_db"));
1839    }
1840
1841    #[test]
1842    fn test_precedence_global_fields_only() {
1843        let tmp = tempdir().unwrap();
1844        let home_dir = tmp.path();
1845
1846        let mut app = create_app_with_server(
1847            "test_server",
1848            DbConnConfig {
1849                host: Some("field_host".to_owned()),
1850                port: Some(5433),
1851                user: Some("field_user".to_owned()),
1852                dbname: Some("field_db".to_owned()),
1853                ..Default::default()
1854            },
1855        );
1856
1857        // Gear references global server
1858        add_gear_to_app(
1859            &mut app,
1860            "test_gear",
1861            &serde_json::json!({
1862                "server": "test_server"
1863            }),
1864        );
1865
1866        let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
1867        assert!(result.is_some());
1868
1869        let (dsn, _pool) = result.unwrap();
1870        assert!(dsn.contains("field_host"));
1871        assert!(dsn.contains("5433"));
1872        assert!(dsn.contains("field_user"));
1873        assert!(dsn.contains("field_db"));
1874    }
1875
1876    #[test]
1877    fn test_precedence_gear_dsn_only() {
1878        let tmp = tempdir().unwrap();
1879        let home_dir = tmp.path();
1880
1881        let app = AppConfig {
1882            gears: {
1883                let mut gears = HashMap::new();
1884                gears.insert(
1885                    "test_gear".to_owned(),
1886                    serde_json::json!({
1887                        "database": {
1888                            "dsn": "sqlite://gear_test.db?wal=true&synchronous=NORMAL"
1889                        },
1890                        "config": {}
1891                    }),
1892                );
1893                gears
1894            },
1895            ..Default::default()
1896        };
1897
1898        let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
1899        assert!(result.is_some());
1900
1901        let (dsn, _pool) = result.unwrap();
1902        assert!(dsn.contains("gear_test.db"));
1903        assert!(dsn.contains("wal=true"));
1904    }
1905
1906    #[test]
1907    fn test_precedence_gear_fields_only() {
1908        let tmp = tempdir().unwrap();
1909        let home_dir = tmp.path();
1910
1911        let app = AppConfig {
1912            gears: {
1913                let mut gears = HashMap::new();
1914                gears.insert(
1915                    "test_gear".to_owned(),
1916                    serde_json::json!({
1917                        "database": {
1918                            "file": "gear_fields.db"
1919                        },
1920                        "config": {}
1921                    }),
1922                );
1923                gears
1924            },
1925            ..Default::default()
1926        };
1927
1928        let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
1929        assert!(result.is_some());
1930
1931        let (dsn, _pool) = result.unwrap();
1932        assert!(dsn.contains("gear_fields.db"));
1933        // Platform-specific DSN format check
1934        #[cfg(windows)]
1935        assert!(dsn.starts_with("sqlite:") && !dsn.starts_with("sqlite://"));
1936        #[cfg(unix)]
1937        assert!(dsn.starts_with("sqlite://"));
1938    }
1939
1940    #[test]
1941    fn test_precedence_fields_override_dsn() {
1942        let tmp = tempdir().unwrap();
1943        let home_dir = tmp.path();
1944
1945        let mut app = create_app_with_server(
1946            "test_server",
1947            DbConnConfig {
1948                dsn: Some(toolkit_utils::SecretString::new(
1949                    "postgresql://old_user:old_pass@old_host:5432/old_db",
1950                )),
1951                host: Some("new_host".to_owned()), // This should override DSN host
1952                port: Some(5433),                  // This should override DSN port
1953                user: Some("new_user".to_owned()), // This should override DSN user
1954                dbname: Some("new_db".to_owned()), // This should override DSN dbname
1955                ..Default::default()
1956            },
1957        );
1958
1959        // Gear also overrides some fields
1960        add_gear_to_app(
1961            &mut app,
1962            "test_gear",
1963            &serde_json::json!({
1964                "server": "test_server",
1965                "port": 5434  // Gear field should override global field
1966            }),
1967        );
1968
1969        let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
1970        assert!(result.is_some());
1971
1972        let (dsn, _pool) = result.unwrap();
1973        // Fields should override DSN parts
1974        assert!(dsn.contains("new_host"));
1975        assert!(dsn.contains("5434")); // Gear override should win
1976        assert!(dsn.contains("new_user"));
1977        assert!(dsn.contains("new_db"));
1978        // Old DSN values should not appear
1979        assert!(!dsn.contains("old_host"));
1980        assert!(!dsn.contains("5432"));
1981        assert!(!dsn.contains("old_user"));
1982        assert!(!dsn.contains("old_db"));
1983    }
1984
1985    #[test]
1986    fn test_env_expansion_password() {
1987        let tmp = tempdir().unwrap();
1988        let home_dir = tmp.path();
1989
1990        with_var("TEST_DB_PASSWORD", Some("secret123"), || {
1991            let mut app = create_app_with_server(
1992                "test_server",
1993                DbConnConfig {
1994                    host: Some("localhost".to_owned()),
1995                    port: Some(5432),
1996                    user: Some("testuser".to_owned()),
1997                    password: Some(toolkit_utils::SecretString::new("${TEST_DB_PASSWORD}")), // Should expand to "secret123"
1998                    dbname: Some("testdb".to_owned()),
1999                    ..Default::default()
2000                },
2001            );
2002
2003            add_gear_to_app(
2004                &mut app,
2005                "test_gear",
2006                &serde_json::json!({
2007                    "server": "test_server"
2008                }),
2009            );
2010
2011            let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2012            assert!(result.is_some());
2013
2014            let (dsn, _pool) = result.unwrap();
2015            assert!(dsn.contains("secret123"));
2016        });
2017    }
2018
2019    #[test]
2020    fn test_env_expansion_in_dsn() {
2021        let tmp = tempdir().unwrap();
2022        let home_dir = tmp.path();
2023
2024        temp_env::with_vars(
2025            [
2026                ("DB_HOST", Some("test-server")),
2027                ("DB_PASSWORD", Some("env_secret")),
2028            ],
2029            || {
2030                let mut app = create_app_with_server(
2031                    "test_server",
2032                    DbConnConfig {
2033                        dsn: Some(toolkit_utils::SecretString::new(
2034                            "postgresql://user:${DB_PASSWORD}@${DB_HOST}:5432/mydb",
2035                        )),
2036                        ..Default::default()
2037                    },
2038                );
2039
2040                add_gear_to_app(
2041                    &mut app,
2042                    "test_gear",
2043                    &serde_json::json!({
2044                        "server": "test_server"
2045                    }),
2046                );
2047
2048                let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2049                assert!(result.is_some());
2050
2051                let (dsn, _pool) = result.unwrap();
2052                assert!(dsn.contains("test-server"));
2053                assert!(dsn.contains("env_secret"));
2054                // ${} placeholders should be replaced
2055                assert!(!dsn.contains("${DB_HOST}"));
2056                assert!(!dsn.contains("${DB_PASSWORD}"));
2057            },
2058        );
2059    }
2060
2061    #[test]
2062    fn test_sqlite_file_path_resolution() {
2063        let tmp = tempdir().unwrap();
2064        let home_dir = tmp.path();
2065
2066        // Test 1: file (relative to home_dir/gear_name/)
2067        let app1 = AppConfig {
2068            gears: {
2069                let mut gears = HashMap::new();
2070                gears.insert(
2071                    "test_gear".to_owned(),
2072                    serde_json::json!({
2073                        "database": {
2074                            "file": "test.db"
2075                        },
2076                        "config": {}
2077                    }),
2078                );
2079                gears
2080            },
2081            ..Default::default()
2082        };
2083
2084        let result1 = build_final_db_for_gear(&app1, "test_gear", home_dir, false).unwrap();
2085        assert!(result1.is_some());
2086        let (dsn1, _) = result1.unwrap();
2087        assert!(dsn1.contains("test_gear"));
2088        assert!(dsn1.contains("test.db"));
2089
2090        // Test 2: path (absolute path)
2091        let abs_path = tmp.path().join("absolute.db");
2092        let app2 = AppConfig {
2093            gears: {
2094                let mut gears = HashMap::new();
2095                gears.insert(
2096                    "test_gear".to_owned(),
2097                    serde_json::json!({
2098                        "database": {
2099                            "path": abs_path.to_string_lossy()
2100                        },
2101                        "config": {}
2102                    }),
2103                );
2104                gears
2105            },
2106            ..Default::default()
2107        };
2108
2109        let result2 = build_final_db_for_gear(&app2, "test_gear", home_dir, false).unwrap();
2110        assert!(result2.is_some());
2111        let (dsn2, _) = result2.unwrap();
2112        assert!(dsn2.contains("absolute.db"));
2113
2114        // Test 3: no file or path (should default to gear_name.sqlite)
2115        let app3 = AppConfig {
2116            gears: {
2117                let mut gears = HashMap::new();
2118                gears.insert(
2119                    "test_gear".to_owned(),
2120                    serde_json::json!({
2121                        "database": {},
2122                        "config": {}
2123                    }),
2124                );
2125                gears
2126            },
2127            ..Default::default()
2128        };
2129
2130        let result3 = build_final_db_for_gear(&app3, "test_gear", home_dir, false).unwrap();
2131        assert!(result3.is_some());
2132        let (dsn3, _) = result3.unwrap();
2133        assert!(dsn3.contains("test_gear.sqlite"));
2134    }
2135
2136    #[cfg(windows)]
2137    #[test]
2138    fn test_sqlite_path_resolution_windows() {
2139        let tmp = tempdir().unwrap();
2140        let home_dir = tmp.path();
2141
2142        let app = AppConfig {
2143            gears: {
2144                let mut gears = HashMap::new();
2145                gears.insert(
2146                    "test_gear".to_owned(),
2147                    serde_json::json!({
2148                        "database": {
2149                            "file": "test.db"
2150                        },
2151                        "config": {}
2152                    }),
2153                );
2154                gears
2155            },
2156            ..Default::default()
2157        };
2158
2159        let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2160        assert!(result.is_some());
2161        let (dsn, _) = result.unwrap();
2162
2163        // On Windows, paths should be normalized to forward slashes in DSN
2164        assert!(!dsn.contains('\\'));
2165        assert!(dsn.contains('/'));
2166    }
2167
2168    #[test]
2169    fn test_sqlite_dsn_with_server_reference_and_dbname_override() {
2170        let tmp = tempdir().unwrap();
2171        let home_dir = tmp.path();
2172
2173        let mut app = AppConfig::default();
2174
2175        // Global server with SQLite DSN and query params
2176        let mut servers = HashMap::new();
2177        servers.insert(
2178            "sqlite_users".to_owned(),
2179            DbConnConfig {
2180                engine: None,
2181                dsn: Some(toolkit_utils::SecretString::new(
2182                    "sqlite://users_info.db?WAL=true&synchronous=NORMAL&busy_timeout=5000",
2183                )),
2184                host: None,
2185                port: None,
2186                user: None,
2187                password: None,
2188                dbname: None,
2189                params: None,
2190                pool: None,
2191                file: None,
2192                path: None,
2193                lock_keepalive: None,
2194                server: None,
2195            },
2196        );
2197
2198        app.database = Some(GlobalDatabaseConfig {
2199            servers,
2200            auto_provision: None,
2201        });
2202
2203        // Gear that references the server but overrides the dbname
2204        app.gears.insert(
2205            "users_info".to_owned(),
2206            serde_json::json!({
2207                "database": {
2208                    "server": "sqlite_users",
2209                    "dbname": "users_info.db"
2210                },
2211                "config": {}
2212            }),
2213        );
2214
2215        let result = build_final_db_for_gear(&app, "users_info", home_dir, false).unwrap();
2216        assert!(result.is_some());
2217        let (dsn, _) = result.unwrap();
2218
2219        // Should be an absolute path with preserved query parameters
2220        assert!(dsn.contains("?WAL=true&synchronous=NORMAL&busy_timeout=5000"));
2221        assert!(dsn.contains("users_info/users_info.db"));
2222
2223        // Platform-specific path format
2224        #[cfg(windows)]
2225        {
2226            // Windows should use sqlite:C:/path format
2227            assert!(dsn.starts_with("sqlite:"));
2228            assert!(!dsn.starts_with("sqlite://"));
2229        }
2230
2231        #[cfg(unix)]
2232        {
2233            // Unix should use sqlite://path format
2234            assert!(dsn.starts_with("sqlite://"));
2235        }
2236    }
2237
2238    #[cfg(unix)]
2239    #[test]
2240    fn test_sqlite_path_resolution_unix() {
2241        let tmp = tempdir().unwrap();
2242        let home_dir = tmp.path();
2243
2244        let app = AppConfig {
2245            gears: {
2246                let mut gears = HashMap::new();
2247                gears.insert(
2248                    "test_gear".to_owned(),
2249                    serde_json::json!({
2250                        "database": {
2251                            "file": "test.db"
2252                        },
2253                        "config": {}
2254                    }),
2255                );
2256                gears
2257            },
2258            ..Default::default()
2259        };
2260
2261        let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2262        assert!(result.is_some());
2263        let (dsn, _) = result.unwrap();
2264
2265        // On Unix, paths should be absolute
2266        assert!(dsn.starts_with("sqlite://"));
2267        assert!(dsn.contains("/test_gear/test.db"));
2268    }
2269
2270    #[test]
2271    fn test_server_based_db_missing_dbname_error() {
2272        let tmp = tempdir().unwrap();
2273        let home_dir = tmp.path();
2274
2275        let mut app = create_app_with_server(
2276            "test_server",
2277            DbConnConfig {
2278                host: Some("localhost".to_owned()),
2279                port: Some(5432),
2280                user: Some("testuser".to_owned()),
2281                // Missing dbname for server-based DB
2282                ..Default::default()
2283            },
2284        );
2285
2286        add_gear_to_app(
2287            &mut app,
2288            "test_gear",
2289            &serde_json::json!({
2290                "server": "test_server"
2291            }),
2292        );
2293
2294        let result = build_final_db_for_gear(&app, "test_gear", home_dir, false);
2295        assert!(result.is_err());
2296        let error_msg = result.unwrap_err().to_string();
2297        assert!(error_msg.contains("missing required 'dbname'"));
2298    }
2299
2300    #[test]
2301    fn test_gear_no_database_config() {
2302        let tmp = tempdir().unwrap();
2303        let home_dir = tmp.path();
2304
2305        // Gear with no database section
2306        let app = AppConfig {
2307            gears: {
2308                let mut gears = HashMap::new();
2309                gears.insert(
2310                    "no_db_gear".to_owned(),
2311                    serde_json::json!({
2312                        "config": {
2313                            "some_setting": "value"
2314                        }
2315                    }),
2316                );
2317                gears
2318            },
2319            ..Default::default()
2320        };
2321
2322        let result = build_final_db_for_gear(&app, "no_db_gear", home_dir, false).unwrap();
2323        assert!(result.is_none());
2324    }
2325
2326    #[test]
2327    fn test_gear_empty_database_config() {
2328        let tmp = tempdir().unwrap();
2329        let home_dir = tmp.path();
2330
2331        // Gear with empty database section
2332        let app = AppConfig {
2333            gears: {
2334                let mut gears = HashMap::new();
2335                gears.insert(
2336                    "empty_db_gear".to_owned(),
2337                    serde_json::json!({
2338                        "database": null,
2339                        "config": {}
2340                    }),
2341                );
2342                gears
2343            },
2344            ..Default::default()
2345        };
2346
2347        let result = build_final_db_for_gear(&app, "empty_db_gear", home_dir, false).unwrap();
2348        assert!(result.is_none());
2349    }
2350
2351    #[test]
2352    fn test_referenced_server_not_found() {
2353        let tmp = tempdir().unwrap();
2354        let home_dir = tmp.path();
2355
2356        let app = AppConfig {
2357            gears: {
2358                let mut gears = HashMap::new();
2359                gears.insert(
2360                    "test_gear".to_owned(),
2361                    serde_json::json!({
2362                        "database": {
2363                            "server": "nonexistent_server"
2364                        },
2365                        "config": {}
2366                    }),
2367                );
2368                gears
2369            },
2370            ..Default::default()
2371        };
2372
2373        let result = build_final_db_for_gear(&app, "test_gear", home_dir, false);
2374        assert!(result.is_err());
2375        let error_msg = result.unwrap_err().to_string();
2376        assert!(error_msg.contains("Referenced server 'nonexistent_server' not found"));
2377    }
2378
2379    #[test]
2380    fn test_dsn_validation_invalid_url() {
2381        let tmp = tempdir().unwrap();
2382        let home_dir = tmp.path();
2383
2384        let app = AppConfig {
2385            gears: {
2386                let mut gears = HashMap::new();
2387                gears.insert(
2388                    "test_gear".to_owned(),
2389                    serde_json::json!({
2390                        "database": {
2391                            "dsn": "invalid://not-a-valid[url"
2392                        },
2393                        "config": {}
2394                    }),
2395                );
2396                gears
2397            },
2398            ..Default::default()
2399        };
2400
2401        let result = build_final_db_for_gear(&app, "test_gear", home_dir, false);
2402        assert!(result.is_err());
2403    }
2404
2405    #[test]
2406    fn test_env_variable_not_found() {
2407        let tmp = tempdir().unwrap();
2408        let home_dir = tmp.path();
2409
2410        // Use with_var with None to ensure the env var doesn't exist
2411        with_var("NONEXISTENT_PASSWORD", None::<&str>, || {
2412            let mut app = create_app_with_server(
2413                "test_server",
2414                DbConnConfig {
2415                    host: Some("localhost".to_owned()),
2416                    password: Some(toolkit_utils::SecretString::new("${NONEXISTENT_PASSWORD}")),
2417                    dbname: Some("testdb".to_owned()),
2418                    ..Default::default()
2419                },
2420            );
2421
2422            add_gear_to_app(
2423                &mut app,
2424                "test_gear",
2425                &serde_json::json!({
2426                    "server": "test_server"
2427                }),
2428            );
2429
2430            let result = build_final_db_for_gear(&app, "test_gear", home_dir, false);
2431            assert!(result.is_err());
2432            let error_msg = result.unwrap_err().to_string();
2433            assert!(error_msg.contains("NONEXISTENT_PASSWORD"));
2434        });
2435    }
2436
2437    #[test]
2438    fn test_sqlite_at_file_relative_path() {
2439        let tmp = tempdir().unwrap();
2440        let home_dir = tmp.path();
2441
2442        let app = AppConfig {
2443            gears: {
2444                let mut gears = HashMap::new();
2445                gears.insert(
2446                    "test_gear".to_owned(),
2447                    serde_json::json!({
2448                        "database": {
2449                            "dsn": "sqlite://@file(users.db)"
2450                        },
2451                        "config": {}
2452                    }),
2453                );
2454                gears
2455            },
2456            ..Default::default()
2457        };
2458
2459        let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2460        assert!(result.is_some());
2461
2462        let (dsn, _pool) = result.unwrap();
2463        assert!(dsn.contains("test_gear"));
2464        assert!(dsn.contains("users.db"));
2465        // Platform-specific DSN format check
2466        #[cfg(windows)]
2467        assert!(dsn.starts_with("sqlite:") && !dsn.starts_with("sqlite://"));
2468        #[cfg(unix)]
2469        assert!(dsn.starts_with("sqlite:///"));
2470    }
2471
2472    #[test]
2473    fn test_sqlite_at_file_absolute_path() {
2474        let tmp = tempdir().unwrap();
2475        let home_dir = tmp.path();
2476        let abs_path = tmp.path().join("absolute_db.sqlite");
2477
2478        let app = AppConfig {
2479            gears: {
2480                let mut gears = HashMap::new();
2481                gears.insert(
2482                    "test_gear".to_owned(),
2483                    serde_json::json!({
2484                        "database": {
2485                            "dsn": format!("sqlite://@file({})", abs_path.to_string_lossy())
2486                        },
2487                        "config": {}
2488                    }),
2489                );
2490                gears
2491            },
2492            ..Default::default()
2493        };
2494
2495        let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2496        assert!(result.is_some());
2497
2498        let (dsn, _pool) = result.unwrap();
2499        assert!(dsn.contains("absolute_db.sqlite"));
2500        // Platform-specific DSN format check
2501        #[cfg(windows)]
2502        assert!(dsn.starts_with("sqlite:") && !dsn.starts_with("sqlite://"));
2503        #[cfg(unix)]
2504        assert!(dsn.starts_with("sqlite:///"));
2505    }
2506
2507    #[test]
2508    fn test_sqlite_empty_dsn_default() {
2509        let tmp = tempdir().unwrap();
2510        let home_dir = tmp.path();
2511
2512        let app = AppConfig {
2513            gears: {
2514                let mut gears = HashMap::new();
2515                gears.insert(
2516                    "test_gear".to_owned(),
2517                    serde_json::json!({
2518                        "database": {
2519                            "dsn": "sqlite://"
2520                        },
2521                        "config": {}
2522                    }),
2523                );
2524                gears
2525            },
2526            ..Default::default()
2527        };
2528
2529        let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2530        assert!(result.is_some());
2531
2532        let (dsn, _pool) = result.unwrap();
2533        assert!(dsn.contains("test_gear"));
2534        assert!(dsn.contains("test_gear.sqlite"));
2535        // Platform-specific DSN format check
2536        #[cfg(windows)]
2537        assert!(dsn.starts_with("sqlite:") && !dsn.starts_with("sqlite://"));
2538        #[cfg(unix)]
2539        assert!(dsn.starts_with("sqlite:///"));
2540    }
2541
2542    #[test]
2543    fn test_sqlite_at_file_invalid_syntax() {
2544        let tmp = tempdir().unwrap();
2545        let home_dir = tmp.path();
2546
2547        let app = AppConfig {
2548            gears: {
2549                let mut gears = HashMap::new();
2550                gears.insert(
2551                    "test_gear".to_owned(),
2552                    serde_json::json!({
2553                        "database": {
2554                            "dsn": "sqlite://@file(missing_closing_paren"
2555                        },
2556                        "config": {}
2557                    }),
2558                );
2559                gears
2560            },
2561            ..Default::default()
2562        };
2563
2564        let result = build_final_db_for_gear(&app, "test_gear", home_dir, false);
2565        assert!(result.is_err());
2566        let error_msg = result.unwrap_err().to_string();
2567        assert!(error_msg.contains("Invalid @file() syntax"));
2568    }
2569
2570    #[test]
2571    fn test_dsn_special_characters_in_credentials() {
2572        let tmp = tempdir().unwrap();
2573        let home_dir = tmp.path();
2574
2575        // Test with special characters in username and password
2576        let mut app = create_app_with_server(
2577            "test_server",
2578            DbConnConfig {
2579                host: Some("localhost".to_owned()),
2580                port: Some(5432),
2581                user: Some("user@domain".to_owned()),
2582                password: Some(toolkit_utils::SecretString::new(
2583                    "pa@ss:w0rd/with%special&chars",
2584                )),
2585                dbname: Some("test/db".to_owned()),
2586                ..Default::default()
2587            },
2588        );
2589
2590        add_gear_to_app(
2591            &mut app,
2592            "test_gear",
2593            &serde_json::json!({
2594                "server": "test_server"
2595            }),
2596        );
2597
2598        let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2599        assert!(result.is_some());
2600
2601        let (dsn, _pool) = result.unwrap();
2602
2603        // Verify DSN is properly encoded
2604        assert!(dsn.starts_with("postgresql://"));
2605        assert!(dsn.contains("user%40domain")); // @ encoded as %40
2606        assert!(dsn.contains("/test%2Fdb")); // / in dbname encoded as %2F
2607
2608        // Verify DSN is parseable and contains expected user
2609        validate_dsn(&dsn).expect("DSN with special characters should be valid");
2610
2611        // Parse the DSN to verify it contains the correct components
2612        let parsed_dsn = dsn::parse(&dsn).expect("DSN should be parseable");
2613        assert_eq!(parsed_dsn.username.as_deref(), Some("user@domain"));
2614        assert_eq!(
2615            parsed_dsn.password.as_deref(),
2616            Some("pa@ss:w0rd/with%special&chars")
2617        );
2618        // Note: dsn crate may have limitations with path parsing - just verify the main DSN works
2619        // The important thing is that the DSN is valid and contains the right components
2620    }
2621
2622    #[test]
2623    #[allow(clippy::non_ascii_literal)]
2624    fn test_dsn_unicode_characters() {
2625        let tmp = tempdir().unwrap();
2626        let home_dir = tmp.path();
2627
2628        // Test with Unicode characters
2629        let mut app = create_app_with_server(
2630            "test_server",
2631            DbConnConfig {
2632                host: Some("localhost".to_owned()),
2633                user: Some("ユーザー".to_owned()), // Japanese characters
2634                dbname: Some("unicode_db".to_owned()),
2635                ..Default::default()
2636            },
2637        );
2638
2639        add_gear_to_app(
2640            &mut app,
2641            "test_gear",
2642            &serde_json::json!({
2643                "server": "test_server"
2644            }),
2645        );
2646
2647        let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2648        assert!(result.is_some());
2649
2650        let (dsn, _pool) = result.unwrap();
2651
2652        // Verify DSN is properly encoded with Unicode
2653        assert!(dsn.starts_with("postgresql://"));
2654        // Unicode characters should be percent-encoded
2655        assert!(dsn.contains('%')); // Should contain encoded characters
2656
2657        // Verify DSN is parseable
2658        validate_dsn(&dsn).expect("DSN with Unicode characters should be valid");
2659    }
2660
2661    #[test]
2662    fn test_dsn_query_parameters_encoding() {
2663        let tmp = tempdir().unwrap();
2664        let home_dir = tmp.path();
2665
2666        let mut params = HashMap::new();
2667        params.insert("ssl mode".to_owned(), "require & verify".to_owned());
2668        params.insert("application_name".to_owned(), "my-app/v1.0".to_owned());
2669
2670        let mut app = create_app_with_server(
2671            "test_server",
2672            DbConnConfig {
2673                host: Some("localhost".to_owned()),
2674                user: Some("testuser".to_owned()),
2675                dbname: Some("testdb".to_owned()),
2676                params: Some(params),
2677                ..Default::default()
2678            },
2679        );
2680
2681        add_gear_to_app(
2682            &mut app,
2683            "test_gear",
2684            &serde_json::json!({
2685                "server": "test_server"
2686            }),
2687        );
2688
2689        let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2690        assert!(result.is_some());
2691
2692        let (dsn, _pool) = result.unwrap();
2693
2694        // Verify query parameters are properly encoded (spaces become +, & becomes %26)
2695        assert!(dsn.contains("ssl+mode=require+%26+verify"));
2696        assert!(dsn.contains("application_name=my-app%2Fv1.0"));
2697
2698        // Verify DSN is parseable
2699        validate_dsn(&dsn).expect("DSN with encoded query parameters should be valid");
2700    }
2701
2702    #[test]
2703    fn test_pool_config_merging() {
2704        use std::time::Duration;
2705
2706        let tmp = tempdir().unwrap();
2707        let home_dir = tmp.path();
2708
2709        // Global server with pool config
2710        let mut app = create_app_with_server(
2711            "test_server",
2712            DbConnConfig {
2713                host: Some("localhost".to_owned()),
2714                dbname: Some("testdb".to_owned()),
2715                pool: Some(PoolCfg {
2716                    max_conns: Some(10),
2717                    min_conns: None,
2718                    acquire_timeout: Some(Duration::from_secs(5)),
2719                    idle_timeout: None,
2720                    max_lifetime: None,
2721                    test_before_acquire: None,
2722                }),
2723                ..Default::default()
2724            },
2725        );
2726
2727        // Gear overrides only max_conns
2728        add_gear_to_app(
2729            &mut app,
2730            "test_gear",
2731            &serde_json::json!({
2732                "server": "test_server",
2733                "pool": {
2734                    "max_conns": 20
2735                }
2736            }),
2737        );
2738
2739        let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2740        assert!(result.is_some());
2741
2742        let (_dsn, pool) = result.unwrap();
2743        assert_eq!(pool.max_conns, Some(20)); // Gear override wins
2744        assert_eq!(pool.acquire_timeout, Some(Duration::from_secs(5))); // Global value preserved
2745    }
2746
2747    #[test]
2748    fn test_pool_config_gear_overrides_all() {
2749        use std::time::Duration;
2750
2751        let tmp = tempdir().unwrap();
2752        let home_dir = tmp.path();
2753
2754        // Global server with pool config
2755        let mut app = create_app_with_server(
2756            "test_server",
2757            DbConnConfig {
2758                host: Some("localhost".to_owned()),
2759                dbname: Some("testdb".to_owned()),
2760                pool: Some(PoolCfg {
2761                    max_conns: Some(10),
2762                    min_conns: None,
2763                    acquire_timeout: Some(Duration::from_secs(5)),
2764                    idle_timeout: None,
2765                    max_lifetime: None,
2766                    test_before_acquire: None,
2767                }),
2768                ..Default::default()
2769            },
2770        );
2771
2772        // Gear overrides both pool settings
2773        add_gear_to_app(
2774            &mut app,
2775            "test_gear",
2776            &serde_json::json!({
2777                "server": "test_server",
2778                "pool": {
2779                    "max_conns": 30,
2780                    "acquire_timeout": "10s"
2781                }
2782            }),
2783        );
2784
2785        let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2786        assert!(result.is_some());
2787
2788        let (_dsn, pool) = result.unwrap();
2789        assert_eq!(pool.max_conns, Some(30));
2790        assert_eq!(pool.acquire_timeout, Some(Duration::from_secs(10)));
2791    }
2792
2793    #[test]
2794    fn test_list_gear_names() {
2795        let mut app = create_minimal_app();
2796        add_gear_with_config(&mut app, "zebra_gear", &serde_json::json!({}));
2797        add_gear_with_config(&mut app, "alpha_gear", &serde_json::json!({}));
2798        add_gear_with_config(&mut app, "beta_gear", &serde_json::json!({}));
2799
2800        let gear_names = list_gear_names(&app);
2801
2802        // Should be sorted alphabetically
2803        assert_eq!(gear_names.len(), 3);
2804        assert_eq!(gear_names[0], "alpha_gear");
2805        assert_eq!(gear_names[1], "beta_gear");
2806        assert_eq!(gear_names[2], "zebra_gear");
2807    }
2808
2809    #[test]
2810    fn test_list_gear_names_empty() {
2811        let app = create_minimal_app();
2812        let gear_names = list_gear_names(&app);
2813        assert_eq!(gear_names.len(), 0);
2814    }
2815
2816    #[test]
2817    fn test_redact_dsn_password_postgres() {
2818        let dsn = "postgres://user:secretpass@localhost:5432/mydb";
2819        let redacted = redact_dsn_password(dsn).unwrap();
2820        assert_eq!(
2821            redacted,
2822            "postgres://user:***REDACTED***@localhost:5432/mydb"
2823        );
2824    }
2825
2826    #[test]
2827    fn test_redact_dsn_password_no_password() {
2828        let dsn = "postgres://user@localhost:5432/mydb";
2829        let redacted = redact_dsn_password(dsn).unwrap();
2830        // No password means no redaction needed
2831        assert_eq!(redacted, "postgres://user@localhost:5432/mydb");
2832    }
2833
2834    #[test]
2835    fn test_redact_dsn_password_special_chars() {
2836        let dsn = "postgres://user:p@ss%40word@localhost:5432/mydb";
2837        let redacted = redact_dsn_password(dsn).unwrap();
2838        assert_eq!(
2839            redacted,
2840            "postgres://user:***REDACTED***@localhost:5432/mydb"
2841        );
2842    }
2843
2844    #[test]
2845    fn test_render_effective_gears_config() {
2846        let mut app = create_minimal_app();
2847        add_gear_with_config(
2848            &mut app,
2849            "test_gear",
2850            &serde_json::json!({
2851                "my_setting": "my_value",
2852                "enabled": true
2853            }),
2854        );
2855
2856        let result = render_effective_gears_config(&app).unwrap();
2857
2858        // Check structure
2859        assert!(result.is_object());
2860        let gears = result.as_object().unwrap();
2861        assert!(gears.contains_key("test_gear"));
2862
2863        let test_gear = gears.get("test_gear").unwrap();
2864        assert!(test_gear.is_object());
2865        let test_gear_obj = test_gear.as_object().unwrap();
2866
2867        // Should have config section
2868        assert!(test_gear_obj.contains_key("config"));
2869
2870        // Check config section
2871        let config = test_gear_obj.get("config").unwrap();
2872        assert_eq!(config.get("my_setting").unwrap(), "my_value");
2873        assert_eq!(config.get("enabled").unwrap(), true);
2874    }
2875
2876    #[test]
2877    fn test_render_effective_gears_config_with_database() {
2878        let mut app = create_app_with_server(
2879            "test_server",
2880            DbConnConfig {
2881                host: Some("localhost".to_owned()),
2882                port: Some(5432),
2883                user: Some("user".to_owned()),
2884                password: Some(toolkit_utils::SecretString::new("pass")),
2885                dbname: Some("db".to_owned()),
2886                ..Default::default()
2887            },
2888        );
2889
2890        // Gear with database config
2891        add_gear_to_app(
2892            &mut app,
2893            "test_gear",
2894            &serde_json::json!({
2895                "server": "test_server"
2896            }),
2897        );
2898
2899        let result = render_effective_gears_config(&app).unwrap();
2900        let gears = result.as_object().unwrap();
2901        let test_gear = gears.get("test_gear").unwrap().as_object().unwrap();
2902
2903        // Should have database section
2904        assert!(test_gear.contains_key("database"));
2905        let database = test_gear.get("database").unwrap().as_object().unwrap();
2906        assert!(database.contains_key("dsn"));
2907
2908        // DSN should be redacted
2909        let dsn = database.get("dsn").unwrap().as_str().unwrap();
2910        assert!(dsn.contains("***REDACTED***"));
2911        assert!(!dsn.contains("pass"));
2912    }
2913
2914    #[test]
2915    fn test_render_effective_gears_config_minimal() {
2916        // Test that gears with minimal/no config can be rendered
2917        let mut app = create_minimal_app();
2918
2919        // Manually add a gear with no database or config sections
2920        app.gears
2921            .insert("minimal_gear".to_owned(), serde_json::json!({}));
2922
2923        let result = render_effective_gears_config(&app).unwrap();
2924
2925        // Gear should be present in output (or excluded if truly empty)
2926        // Either way, rendering should succeed
2927        assert!(result.is_object());
2928    }
2929
2930    #[test]
2931    fn test_dump_effective_gears_config_yaml() {
2932        let mut app = create_minimal_app();
2933        add_gear_with_config(
2934            &mut app,
2935            "test_gear",
2936            &serde_json::json!({
2937                "setting": "value"
2938            }),
2939        );
2940
2941        let yaml = dump_effective_gears_config_yaml(&app).unwrap();
2942
2943        // Should be valid YAML
2944        assert!(yaml.contains("test_gear:"));
2945        assert!(yaml.contains("config:"));
2946        assert!(yaml.contains("setting: value"));
2947    }
2948
2949    #[test]
2950    fn test_dump_effective_gears_config_json() {
2951        let mut app = create_minimal_app();
2952        add_gear_with_config(
2953            &mut app,
2954            "test_gear",
2955            &serde_json::json!({
2956                "setting": "value"
2957            }),
2958        );
2959
2960        let json = dump_effective_gears_config_json(&app).unwrap();
2961
2962        // Should be valid JSON
2963        assert!(json.contains("\"test_gear\""));
2964        assert!(json.contains("\"config\""));
2965        assert!(json.contains("\"setting\""));
2966        assert!(json.contains("\"value\""));
2967
2968        // Verify it's parseable
2969        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
2970        assert!(parsed.is_object());
2971    }
2972
2973    #[test]
2974    fn test_render_multiple_gears() {
2975        let mut app = create_minimal_app();
2976        add_gear_with_config(&mut app, "gear_a", &serde_json::json!({"a": 1}));
2977        add_gear_with_config(&mut app, "gear_b", &serde_json::json!({"b": 2}));
2978        add_gear_with_config(&mut app, "gear_c", &serde_json::json!({"c": 3}));
2979
2980        let result = render_effective_gears_config(&app).unwrap();
2981        let gears = result.as_object().unwrap();
2982
2983        assert_eq!(gears.len(), 3);
2984        assert!(gears.contains_key("gear_a"));
2985        assert!(gears.contains_key("gear_b"));
2986        assert!(gears.contains_key("gear_c"));
2987    }
2988
2989    // ========== Vendor configuration tests ==========
2990
2991    #[derive(Debug, Deserialize, Default, PartialEq)]
2992    struct TestVendorConfig {
2993        #[serde(default)]
2994        api_token: String,
2995        #[serde(default)]
2996        api_url: String,
2997    }
2998
2999    #[test]
3000    fn test_vendor_section_parses_from_yaml() {
3001        let yaml = r#"
3002server:
3003  home_dir: "~/.test_vendor"
3004vendor:
3005  acme:
3006    api_token: "acme-token-123"
3007    api_url: "https://acme.example.com"
3008  other_corp:
3009    api_token: "other-token-789"
3010    api_url: "https://other.example.com"
3011"#;
3012        let config: AppConfig = serde_saphyr::from_str(yaml).unwrap();
3013        assert_eq!(config.vendor.len(), 2);
3014        assert!(config.vendor.contains_key("acme"));
3015        assert!(config.vendor.contains_key("other_corp"));
3016
3017        let acme: TestVendorConfig = config.vendor_config("acme").unwrap();
3018        assert_eq!(acme.api_token, "acme-token-123");
3019        assert_eq!(acme.api_url, "https://acme.example.com");
3020
3021        let other: TestVendorConfig = config.vendor_config("other_corp").unwrap();
3022        assert_eq!(other.api_token, "other-token-789");
3023        assert_eq!(other.api_url, "https://other.example.com");
3024    }
3025
3026    #[test]
3027    fn test_vendor_section_defaults_to_empty() {
3028        let config = AppConfig::default();
3029        assert!(config.vendor.is_empty());
3030    }
3031
3032    #[test]
3033    fn test_vendor_config_typed_access() {
3034        let mut config = AppConfig::default();
3035        config.vendor.insert(
3036            "acme".to_owned(),
3037            serde_json::json!({
3038                "api_token": "acme-token-123",
3039                "api_url": "https://acme.example.com"
3040            }),
3041        );
3042
3043        let acme: TestVendorConfig = config.vendor_config("acme").unwrap();
3044        assert_eq!(acme.api_token, "acme-token-123");
3045        assert_eq!(acme.api_url, "https://acme.example.com");
3046    }
3047
3048    #[test]
3049    fn test_vendor_config_not_found() {
3050        let config = AppConfig::default();
3051        let result: Result<TestVendorConfig, _> = config.vendor_config("nonexistent");
3052        assert!(matches!(
3053            result,
3054            Err(VendorConfigError::NotFound { ref vendor }) if vendor == "nonexistent"
3055        ));
3056    }
3057
3058    #[test]
3059    fn test_vendor_config_invalid_structure() {
3060        let mut config = AppConfig::default();
3061        config
3062            .vendor
3063            .insert("bad".to_owned(), serde_json::json!("not an object"));
3064
3065        let result: Result<TestVendorConfig, _> = config.vendor_config("bad");
3066        assert!(matches!(
3067            result,
3068            Err(VendorConfigError::InvalidConfig { ref vendor, .. }) if vendor == "bad"
3069        ));
3070    }
3071
3072    #[test]
3073    fn test_vendor_config_or_default_missing() {
3074        let config = AppConfig::default();
3075        let acme: TestVendorConfig = config.vendor_config_or_default("acme").unwrap();
3076        assert_eq!(acme, TestVendorConfig::default());
3077    }
3078
3079    #[test]
3080    fn test_vendor_config_or_default_present() {
3081        let mut config = AppConfig::default();
3082        config.vendor.insert(
3083            "acme".to_owned(),
3084            serde_json::json!({ "api_token": "acme-token-123" }),
3085        );
3086
3087        let acme: TestVendorConfig = config.vendor_config_or_default("acme").unwrap();
3088        assert_eq!(acme.api_token, "acme-token-123");
3089    }
3090
3091    #[test]
3092    #[serial]
3093    fn test_vendor_config_env_override() {
3094        let tmp = tempdir().unwrap();
3095        let cfg_path = tmp.path().join("cfg.yaml");
3096        let yaml = r#"
3097server:
3098  home_dir: "~/.test_vendor"
3099vendor:
3100  env_test_vendor:
3101    api_token: "from_yaml"
3102"#;
3103        fs::write(&cfg_path, yaml).unwrap();
3104
3105        with_var(
3106            "APP__VENDOR__ENV_TEST_VENDOR__API_TOKEN",
3107            Some("from_env"),
3108            || {
3109                let config = AppConfig::load_layered(&cfg_path).unwrap();
3110                let v: TestVendorConfig = config.vendor_config("env_test_vendor").unwrap();
3111                assert_eq!(v.api_token, "from_env");
3112            },
3113        );
3114    }
3115
3116    #[test]
3117    #[serial]
3118    fn test_oop_http_labels_from_yaml_and_env() {
3119        let tmp = tempdir().unwrap();
3120        let cfg_path = tmp.path().join("cfg.yaml");
3121        let yaml = r#"
3122server:
3123  home_dir: "~/.test_oop_labels"
3124oop_http:
3125  listen_addr: "0.0.0.0:8080"
3126  labels:
3127    role: "ingest"
3128"#;
3129        fs::write(&cfg_path, yaml).unwrap();
3130
3131        // Env layer adds a second label. Env keys are lower-cased by the loader,
3132        // so `ZONE` lands as `zone`.
3133        with_var("APP__OOP_HTTP__LABELS__ZONE", Some("us-east-1"), || {
3134            let config = AppConfig::load_layered(&cfg_path).unwrap();
3135            let oop = config.oop_http.expect("oop_http present");
3136            assert_eq!(oop.labels.get("role"), Some(&"ingest".to_owned()));
3137            assert_eq!(
3138                oop.labels.get("zone"),
3139                Some(&"us-east-1".to_owned()),
3140                "APP__OOP_HTTP__LABELS__ZONE should populate labels[zone]"
3141            );
3142        });
3143
3144        // A bare-numeric env value (e.g. a StatefulSet ordinal injected as
3145        // `APP__OOP_HTTP__LABELS__SHARD=7`) is coerced to its string
3146        // representation by `de_labels_scalar_to_string` rather than failing the
3147        // config load, so numeric-looking labels can be set via the environment.
3148        with_var("APP__OOP_HTTP__LABELS__SHARD", Some("7"), || {
3149            let config = AppConfig::load_layered(&cfg_path).unwrap();
3150            let oop = config.oop_http.expect("oop_http present");
3151            assert_eq!(
3152                oop.labels.get("shard"),
3153                Some(&"7".to_owned()),
3154                "a bare-numeric env label value should be coerced to the string \"7\""
3155            );
3156        });
3157    }
3158
3159    #[test]
3160    #[serial]
3161    fn test_gear_config_env_override_underscore_gear_name() {
3162        // k8s-friendly form: gear name uses underscores in the env var name,
3163        // which should be remapped to the kebab-case gear key.
3164        let tmp = tempdir().unwrap();
3165        let cfg_path = tmp.path().join("cfg.yaml");
3166        let yaml = r#"
3167server:
3168  home_dir: "~/.test_gear_env_underscore"
3169gears:
3170  static-authz-plugin:
3171    config:
3172      vendor: "from_yaml"
3173"#;
3174        fs::write(&cfg_path, yaml).unwrap();
3175
3176        with_var(
3177            "APP__GEARS__STATIC_AUTHZ_PLUGIN__CONFIG__VENDOR",
3178            Some("acme"),
3179            || {
3180                let config = AppConfig::load_layered(&cfg_path).unwrap();
3181                let gear = config.gears.get("static-authz-plugin").unwrap();
3182                assert_eq!(gear["config"]["vendor"], serde_json::json!("acme"));
3183            },
3184        );
3185    }
3186
3187    #[test]
3188    #[serial]
3189    fn test_gear_config_env_override_dash_gear_name_backcompat() {
3190        // Back-compat: the existing dash form must still work.
3191        let tmp = tempdir().unwrap();
3192        let cfg_path = tmp.path().join("cfg.yaml");
3193        let yaml = r#"
3194server:
3195  home_dir: "~/.test_gear_env_dash"
3196gears:
3197  static-authz-plugin:
3198    config:
3199      vendor: "from_yaml"
3200"#;
3201        fs::write(&cfg_path, yaml).unwrap();
3202
3203        with_var(
3204            "APP__GEARS__static-authz-plugin__CONFIG__VENDOR",
3205            Some("acme"),
3206            || {
3207                let config = AppConfig::load_layered(&cfg_path).unwrap();
3208                let gear = config.gears.get("static-authz-plugin").unwrap();
3209                assert_eq!(gear["config"]["vendor"], serde_json::json!("acme"));
3210            },
3211        );
3212    }
3213
3214    #[test]
3215    #[serial]
3216    fn test_gear_config_env_override_preserves_field_underscores() {
3217        // Underscores in nested config field names must NOT be remapped.
3218        let tmp = tempdir().unwrap();
3219        let cfg_path = tmp.path().join("cfg.yaml");
3220        let yaml = r#"
3221server:
3222  home_dir: "~/.test_gear_env_field"
3223gears:
3224  static-authz-plugin:
3225    config:
3226      some_field: "from_yaml"
3227"#;
3228        fs::write(&cfg_path, yaml).unwrap();
3229
3230        with_var(
3231            "APP__GEARS__STATIC_AUTHZ_PLUGIN__CONFIG__SOME_FIELD",
3232            Some("from_env"),
3233            || {
3234                let config = AppConfig::load_layered(&cfg_path).unwrap();
3235                let gear = config.gears.get("static-authz-plugin").unwrap();
3236                assert_eq!(gear["config"]["some_field"], serde_json::json!("from_env"));
3237            },
3238        );
3239    }
3240
3241    #[test]
3242    #[serial]
3243    fn test_vendor_config_env_override_unaffected_by_gear_remap() {
3244        // Isolation invariant: the gear-name `_`->`-` remap must NOT touch the
3245        // `vendor` branch. The underscore vendor name is the canary — if the
3246        // remap leaked, the env override would land under a dashed
3247        // `env-test-vendor` key and the underscore lookup would miss it.
3248        let tmp = tempdir().unwrap();
3249        let cfg_path = tmp.path().join("cfg.yaml");
3250        let yaml = r#"
3251server:
3252  home_dir: "~/.test_vendor_unaffected"
3253vendor:
3254  env_test_vendor:
3255    api_token: "from_yaml"
3256"#;
3257        fs::write(&cfg_path, yaml).unwrap();
3258
3259        with_var(
3260            "APP__VENDOR__ENV_TEST_VENDOR__API_TOKEN",
3261            Some("from_env"),
3262            || {
3263                let config = AppConfig::load_layered(&cfg_path).unwrap();
3264
3265                // The override applied to the underscore key, not a remapped one.
3266                let v: TestVendorConfig = config.vendor_config("env_test_vendor").unwrap();
3267                assert_eq!(v.api_token, "from_env");
3268
3269                // Distinguishing assertion vs. test_vendor_config_env_override:
3270                // no dashed key was synthesized in the vendor branch.
3271                assert!(
3272                    config.vendor.contains_key("env_test_vendor"),
3273                    "underscore vendor key must be preserved"
3274                );
3275                assert!(
3276                    !config.vendor.contains_key("env-test-vendor"),
3277                    "gear remap leaked into the vendor branch"
3278                );
3279            },
3280        );
3281    }
3282
3283    #[test]
3284    fn test_vendor_multiple_vendors_typed_access() {
3285        let mut config = AppConfig::default();
3286        config.vendor.insert(
3287            "acme".to_owned(),
3288            serde_json::json!({ "api_token": "acme-token", "api_url": "https://acme.com" }),
3289        );
3290        config.vendor.insert(
3291            "other_corp".to_owned(),
3292            serde_json::json!({ "api_token": "other-token", "api_url": "https://other.com" }),
3293        );
3294
3295        let acme: TestVendorConfig = config.vendor_config("acme").unwrap();
3296        let other: TestVendorConfig = config.vendor_config("other_corp").unwrap();
3297
3298        assert_eq!(acme.api_token, "acme-token");
3299        assert_eq!(other.api_token, "other-token");
3300        assert_eq!(acme.api_url, "https://acme.com");
3301        assert_eq!(other.api_url, "https://other.com");
3302    }
3303
3304    #[test]
3305    fn test_vendor_nested_config() {
3306        #[derive(Debug, Deserialize, PartialEq)]
3307        struct NestedVendorConfig {
3308            api_url: String,
3309            feature_flags: FeatureFlags,
3310        }
3311
3312        #[derive(Debug, Deserialize, PartialEq)]
3313        struct FeatureFlags {
3314            beta_mode: bool,
3315            max_retries: u32,
3316        }
3317
3318        let mut config = AppConfig::default();
3319        config.vendor.insert(
3320            "acme".to_owned(),
3321            serde_json::json!({
3322                "api_url": "https://acme.com",
3323                "feature_flags": {
3324                    "beta_mode": true,
3325                    "max_retries": 3
3326                }
3327            }),
3328        );
3329
3330        let acme: NestedVendorConfig = config.vendor_config("acme").unwrap();
3331        assert_eq!(acme.api_url, "https://acme.com");
3332        assert!(acme.feature_flags.beta_mode);
3333        assert_eq!(acme.feature_flags.max_retries, 3);
3334    }
3335
3336    #[test]
3337    fn test_vendor_config_or_default_invalid_returns_error() {
3338        let mut config = AppConfig::default();
3339        config
3340            .vendor
3341            .insert("bad".to_owned(), serde_json::json!("not an object"));
3342
3343        let result: Result<TestVendorConfig, _> = config.vendor_config_or_default("bad");
3344        assert!(matches!(
3345            result,
3346            Err(VendorConfigError::InvalidConfig { ref vendor, .. }) if vendor == "bad"
3347        ));
3348    }
3349
3350    #[test]
3351    fn test_vendor_config_yaml_roundtrip() {
3352        let mut config = AppConfig::default();
3353        config.vendor.insert(
3354            "acme".to_owned(),
3355            serde_json::json!({ "api_token": "acme-token-123" }),
3356        );
3357
3358        let yaml = config.to_yaml().unwrap();
3359        assert!(yaml.contains("vendor"));
3360        assert!(yaml.contains("acme"));
3361        assert!(yaml.contains("acme-token-123"));
3362    }
3363
3364    #[test]
3365    fn test_vendor_coexists_with_gears() {
3366        let mut config = AppConfig::default();
3367        config.gears.insert(
3368            "my_gear".to_owned(),
3369            serde_json::json!({ "config": { "some_setting": true } }),
3370        );
3371        config.vendor.insert(
3372            "acme".to_owned(),
3373            serde_json::json!({ "api_token": "acme-token-123" }),
3374        );
3375
3376        assert!(config.gears.contains_key("my_gear"));
3377        assert!(config.vendor.contains_key("acme"));
3378
3379        let acme: TestVendorConfig = config.vendor_config("acme").unwrap();
3380        assert_eq!(acme.api_token, "acme-token-123");
3381    }
3382
3383    #[test]
3384    fn test_vendor_error_display_messages() {
3385        let not_found = VendorConfigError::NotFound {
3386            vendor: "acme".to_owned(),
3387        };
3388        assert_eq!(
3389            not_found.to_string(),
3390            "vendor 'acme' not found in configuration"
3391        );
3392
3393        let invalid = VendorConfigError::InvalidConfig {
3394            vendor: "bad".to_owned(),
3395            cause: serde_json::from_str::<TestVendorConfig>("invalid").unwrap_err(),
3396        };
3397        let msg = invalid.to_string();
3398        assert!(msg.starts_with("invalid config for vendor 'bad':"));
3399        assert!(std::error::Error::source(&invalid).is_none());
3400    }
3401
3402    #[test]
3403    fn test_vendor_empty_object_in_yaml() {
3404        let yaml = r#"
3405server:
3406  home_dir: "~/.test_vendor"
3407vendor: {}
3408"#;
3409        let config: AppConfig = serde_saphyr::from_str(yaml).unwrap();
3410        assert!(config.vendor.is_empty());
3411    }
3412
3413    // ========== Duplicate YAML key rejection tests ==========
3414
3415    #[test]
3416    fn test_reject_duplicate_gear_names() {
3417        let tmp = tempdir().unwrap();
3418        let cfg_path = tmp.path().join("cfg.yaml");
3419        let yaml = r#"
3420server:
3421  home_dir: "~/.test_dup"
3422gears:
3423  gear1:
3424    config: {}
3425  gear2:
3426    config: {}
3427  gear1:
3428    config: {}
3429"#;
3430        fs::write(&cfg_path, yaml).unwrap();
3431
3432        let result = AppConfig::load_layered(&cfg_path);
3433        assert!(result.is_err(), "duplicate gear names should be rejected");
3434        let msg = format!("{:?}", result.unwrap_err());
3435        assert!(
3436            msg.contains("duplicate") || msg.contains("Duplicate"),
3437            "error should mention duplicates: {msg}"
3438        );
3439    }
3440
3441    #[test]
3442    fn test_reject_duplicate_keys_in_gear_file() {
3443        let tmp = tempdir().unwrap();
3444        let gears_dir = tmp.path().join("gears.d");
3445        fs::create_dir_all(&gears_dir).unwrap();
3446
3447        // Gear file with duplicate "config:" key
3448        let gear_yaml = r#"
3449config:
3450  key1: "value1"
3451config:
3452  key2: "value2"
3453"#;
3454        fs::write(gears_dir.join("bad_gear.yaml"), gear_yaml).unwrap();
3455
3456        let cfg_yaml = format!(
3457            r#"
3458server:
3459  home_dir: "~/.test_dup_modfile"
3460gears_dir: "{}"
3461"#,
3462            normalize_path(&gears_dir)
3463        );
3464        let cfg_path = tmp.path().join("cfg.yaml");
3465        fs::write(&cfg_path, cfg_yaml).unwrap();
3466
3467        let result = AppConfig::load_layered(&cfg_path);
3468        assert!(
3469            result.is_err(),
3470            "duplicate keys in a gear file should be rejected"
3471        );
3472        let msg = format!("{:?}", result.unwrap_err());
3473        assert!(
3474            msg.contains("duplicate") || msg.contains("Duplicate"),
3475            "error should mention duplicates: {msg}"
3476        );
3477    }
3478
3479    #[test]
3480    fn test_no_false_positive_on_unique_gears() {
3481        let tmp = tempdir().unwrap();
3482        let cfg_path = tmp.path().join("cfg.yaml");
3483        let yaml = r#"
3484server:
3485  home_dir: "~/.test_ok"
3486gears:
3487  gear1:
3488    config: {}
3489  gear2:
3490    config: {}
3491  gear3:
3492    config: {}
3493"#;
3494        fs::write(&cfg_path, yaml).unwrap();
3495
3496        let result = AppConfig::load_layered(&cfg_path);
3497        assert!(
3498            result.is_ok(),
3499            "unique gear names should be accepted: {:?}",
3500            result.unwrap_err()
3501        );
3502    }
3503}
3504
3505// Note: DB trait implementations and helper functions removed since we now use DbManager