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