Skip to main content

toolkit/bootstrap/config/
mod.rs

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