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