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