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