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