Skip to main content

toolkit/bootstrap/config/
mod.rs

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