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