1mod dump;
6
7use anyhow::{Context, Result, ensure};
8use 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
20fn normalize_path(path: &Path) -> String {
22 path.to_string_lossy().replace('\\', "/")
23}
24
25#[derive(thiserror::Error, Debug)]
27pub enum VendorConfigError {
28 #[error("vendor '{vendor}' not found in configuration")]
29 NotFound { vendor: String },
30 #[error("invalid config for vendor '{vendor}': {cause}")]
32 InvalidConfig {
33 vendor: String,
34 cause: serde_json::Error,
35 },
36}
37
38pub 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#[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)] pub metadata: serde_json::Value,
56}
57
58#[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 #[serde(default)]
66 pub execution: Option<ExecutionConfig>,
67}
68
69#[derive(Debug, Clone, Deserialize, Serialize, Default)]
71#[serde(deny_unknown_fields)]
72pub struct ExecutionConfig {
73 pub executable_path: String,
75 #[serde(default)]
77 pub args: Vec<String>,
78 #[serde(default)]
80 pub working_directory: Option<String>,
81 #[serde(default)]
83 pub environment: HashMap<String, String>,
84}
85
86#[derive(Debug, Clone, Default, Deserialize, Serialize)]
88#[serde(rename_all = "lowercase")]
89pub enum RuntimeKind {
90 #[default]
91 Local,
92 Oop,
93}
94
95#[derive(Debug, Clone, Deserialize, Serialize)]
98#[serde(deny_unknown_fields)]
99pub struct AppConfig {
100 pub server: ServerConfig,
102 pub database: Option<GlobalDatabaseConfig>,
104 #[serde(default = "default_logging_config")]
106 pub logging: LoggingConfig,
107 #[serde(default)]
109 pub opentelemetry: OpenTelemetryConfig,
110 #[serde(default)]
112 pub gears_dir: Option<String>,
113 #[serde(default)]
115 pub gears: HashMap<String, serde_json::Value>,
116 #[serde(default)]
119 pub vendor: VendorConfig,
120 #[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#[derive(Debug, Clone, Deserialize, Serialize)]
147#[serde(deny_unknown_fields)]
148pub struct OopHttpConfig {
149 pub listen_addr: String,
152 #[serde(default)]
155 pub probe_bind_addr: Option<String>,
156 #[serde(default = "default_drain_timeout_secs")]
158 pub drain_timeout_secs: u64,
159 #[serde(default = "default_healthcheck_timeout_ms")]
162 pub healthcheck_timeout_ms: u64,
163 #[serde(default)]
167 pub advertise_uri: Option<String>,
168 #[serde(default)]
174 pub allow_loopback_advertise: bool,
175 #[serde(default)]
182 pub internal_auth: Option<toolkit_security::InternalAuthConfig>,
183 #[serde(default, deserialize_with = "de_labels_scalar_to_string")]
195 pub labels: std::collections::BTreeMap<String, String>,
196}
197
198fn default_drain_timeout_secs() -> u64 {
199 30
200}
201
202fn default_healthcheck_timeout_ms() -> u64 {
203 500
204}
205
206fn de_labels_scalar_to_string<'de, D>(
215 deserializer: D,
216) -> Result<std::collections::BTreeMap<String, String>, D::Error>
217where
218 D: serde::Deserializer<'de>,
219{
220 use serde::Deserialize;
221
222 #[derive(Deserialize)]
225 #[serde(untagged)]
226 enum Scalar {
227 Str(String),
228 Bool(bool),
229 I64(i64),
230 U64(u64),
231 F64(f64),
232 }
233
234 let raw = std::collections::BTreeMap::<String, Scalar>::deserialize(deserializer)?;
235 Ok(raw
236 .into_iter()
237 .map(|(k, v)| {
238 let value = match v {
239 Scalar::Str(s) => s,
240 Scalar::Bool(b) => b.to_string(),
241 Scalar::I64(i) => i.to_string(),
242 Scalar::U64(u) => u.to_string(),
243 Scalar::F64(f) => f.to_string(),
244 };
245 (k, value)
246 })
247 .collect())
248}
249
250impl ConfigProvider for AppConfig {
251 fn get_gear_config(&self, gear_name: &str) -> Option<&serde_json::Value> {
252 self.gears.get(gear_name)
253 }
254}
255
256#[derive(Debug, Clone, Deserialize, Serialize)]
257#[serde(deny_unknown_fields)]
258pub struct ServerConfig {
259 #[serde(default = "default_server_name")]
260 pub name: String,
261 #[serde(default = "default_home_dir")]
262 pub home_dir: PathBuf, }
264
265fn default_server_name() -> String {
266 "cf-gears".to_owned()
267}
268
269fn default_home_dir() -> PathBuf {
270 super::host::paths::default_home_dir().join(".cf-gears")
271}
272
273impl Default for ServerConfig {
274 fn default() -> Self {
275 Self {
276 name: default_server_name(),
277 home_dir: default_home_dir(),
278 }
279 }
280}
281
282impl ServerConfig {
283 fn normalize_home_dir_inplace(&mut self) -> Result<()> {
284 self.home_dir = super::host::normalize_path(
285 self.home_dir
286 .to_str()
287 .context("home directory configuration is not a valid path")?,
288 )
289 .context("home_dir normalization failed")?;
290
291 std::fs::create_dir_all(&self.home_dir).context("Failed to create home_dir")?;
292
293 Ok(())
294 }
295}
296
297#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
299#[serde(rename_all = "lowercase")]
300pub enum ConsoleFormat {
301 #[default]
303 Text,
304 Json,
306}
307
308pub type LoggingConfig = HashMap<String, Section>;
311
312pub type VendorConfig = HashMap<String, serde_json::Value>;
316
317mod optional_level_serde {
319 use serde::{Deserialize, Deserializer, Serializer};
320 use tracing::Level;
321
322 #[allow(clippy::ref_option, clippy::trivially_copy_pass_by_ref)]
323 pub fn serialize<S>(level: &Option<Level>, serializer: S) -> Result<S::Ok, S::Error>
324 where
325 S: Serializer,
326 {
327 match level {
328 Some(l) => serializer.serialize_str(l.as_str()),
329 None => serializer.serialize_str("off"),
330 }
331 }
332
333 pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<Level>, D::Error>
334 where
335 D: Deserializer<'de>,
336 {
337 let s = String::deserialize(deserializer)?;
338 match s.to_lowercase().as_str() {
339 "trace" => Ok(Some(Level::TRACE)),
340 "debug" => Ok(Some(Level::DEBUG)),
341 "info" => Ok(Some(Level::INFO)),
342 "warn" => Ok(Some(Level::WARN)),
343 "error" => Ok(Some(Level::ERROR)),
344 "off" | "none" => Ok(None),
345 _ => Err(serde::de::Error::custom(format!("invalid level: {s}"))),
346 }
347 }
348
349 #[allow(clippy::unnecessary_wraps)]
350 pub fn default() -> Option<Level> {
351 Some(Level::INFO)
352 }
353}
354
355#[derive(Debug, Serialize, Deserialize, Clone)]
356pub struct SectionFile {
357 pub file: String,
358 #[serde(
359 default = "optional_level_serde::default",
360 with = "optional_level_serde"
361 )]
362 pub file_level: Option<Level>,
363}
364
365#[derive(Debug, Serialize, Deserialize, Clone)]
366pub struct Section {
367 #[serde(default)]
368 pub console_format: ConsoleFormat,
369 #[serde(
370 default = "optional_level_serde::default",
371 with = "optional_level_serde"
372 )]
373 pub console_level: Option<Level>,
374 #[serde(flatten)]
375 pub section_file: Option<SectionFile>,
376 pub max_age_days: Option<u32>, #[serde(default)]
378 pub max_backups: Option<usize>, #[serde(default)]
380 pub max_size_mb: Option<u64>, }
382
383impl Section {
384 #[must_use]
385 pub fn file(&self) -> Option<&str> {
386 self.section_file
387 .as_ref()
388 .map(|f| f.file.as_str())
389 .filter(|s| !s.is_empty())
390 }
391
392 #[must_use]
393 pub fn file_level(&self) -> Option<Level> {
394 self.section_file.as_ref().and_then(|f| f.file_level)
395 }
396}
397
398#[must_use]
400pub fn default_logging_config() -> LoggingConfig {
401 let mut logging = HashMap::new();
402 logging.insert(
403 "default".to_owned(),
404 Section {
405 console_level: Some(Level::INFO),
406 section_file: Some(SectionFile {
407 file: "logs/cf-gears.log".to_owned(),
408 file_level: Some(Level::DEBUG),
409 }),
410 console_format: ConsoleFormat::default(),
411 max_age_days: Some(7),
412 max_backups: Some(3),
413 max_size_mb: Some(100),
414 },
415 );
416 logging
417}
418
419pub(crate) fn remap_gear_env_key(key: &str) -> String {
430 let lower = key.to_ascii_lowercase();
432 let mut parts: Vec<&str> = lower.split('.').collect();
433 if parts.first() == Some(&"gears") && parts.len() >= 2 {
434 let gear = parts[1].replace('_', "-");
435 parts[1] = gear.as_str();
436 parts.join(".")
437 } else {
438 lower
439 }
440}
441
442impl AppConfig {
443 pub fn load_layered(config_path: &PathBuf) -> Result<Self> {
449 use figment::{
450 Figment,
451 providers::{Env, Format, Serialized},
452 };
453
454 let figment = Figment::new()
458 .merge(Serialized::defaults(AppConfig::default()))
459 .merge(StrictYaml::file(config_path))
460 .merge(
462 Env::prefixed("APP__")
463 .split("__")
464 .map(|key| remap_gear_env_key(key.as_str()).into()),
465 );
466
467 let mut config: AppConfig = figment
468 .extract()
469 .with_context(|| "Failed to extract config from figment".to_owned())?;
470
471 config
473 .server
474 .normalize_home_dir_inplace()
475 .context("Failed to resolve server.home_dir")?;
476
477 if let Some(dir) = config.gears_dir.as_ref() {
479 merge_gear_files(&mut config.gears, dir)?;
480 }
481
482 Ok(config)
483 }
484
485 pub fn load_or_default(config_path: Option<&PathBuf>) -> Result<Self> {
491 if let Some(path) = config_path {
492 ensure!(
493 path.is_file(),
494 "config file does not exist: {}",
495 path.to_string_lossy()
496 );
497 Self::load_layered(path)
498 } else {
499 let mut c = Self::default();
500 c.server
501 .normalize_home_dir_inplace()
502 .context("Failed to resolve server.home_dir (defaults)")?;
503 Ok(c)
504 }
505 }
506
507 pub fn to_yaml(&self) -> Result<String> {
512 serde_saphyr::to_string(self).context("Failed to serialize config to YAML")
513 }
514
515 pub fn vendor_config<T: DeserializeOwned>(
521 &self,
522 vendor_name: &str,
523 ) -> Result<T, VendorConfigError> {
524 let raw = self
525 .vendor
526 .get(vendor_name)
527 .ok_or_else(|| VendorConfigError::NotFound {
528 vendor: vendor_name.to_owned(),
529 })?;
530 T::deserialize(raw).map_err(|e| VendorConfigError::InvalidConfig {
531 vendor: vendor_name.to_owned(),
532 cause: e,
533 })
534 }
535
536 pub fn vendor_config_or_default<T: DeserializeOwned + Default>(
542 &self,
543 vendor_name: &str,
544 ) -> Result<T, VendorConfigError> {
545 let Some(raw) = self.vendor.get(vendor_name) else {
546 return Ok(T::default());
547 };
548 T::deserialize(raw).map_err(|e| VendorConfigError::InvalidConfig {
549 vendor: vendor_name.to_owned(),
550 cause: e,
551 })
552 }
553
554 pub fn apply_cli_overrides(&mut self, verbose: u8) {
556 if let Some(default_section) = self.logging.get_mut("default") {
558 default_section.console_level = match verbose {
559 0 => default_section.console_level, 1 => Some(Level::DEBUG),
561 _ => Some(Level::TRACE),
562 };
563 }
564 }
565}
566
567#[derive(Debug, Clone)]
569pub struct CliArgs {
570 pub config: Option<String>,
571 pub print_config: bool,
572 pub verbose: u8,
573 pub mock: bool,
574}
575
576fn strict_yaml_parse<T: serde::de::DeserializeOwned>(s: &str) -> Result<T, serde_saphyr::Error> {
578 let opts = serde_saphyr::Options {
579 duplicate_keys: serde_saphyr::DuplicateKeyPolicy::Error,
580 ..serde_saphyr::Options::default()
581 };
582 serde_saphyr::from_str_with_options(s, opts)
583}
584
585struct StrictYaml;
591
592impl figment::providers::Format for StrictYaml {
593 type Error = serde_saphyr::Error;
594
595 const NAME: &'static str = "YAML";
596
597 fn from_str<T: serde::de::DeserializeOwned>(s: &str) -> Result<T, Self::Error> {
598 strict_yaml_parse(s)
599 }
600}
601
602fn merge_gear_files(
603 bag: &mut HashMap<String, serde_json::Value>,
604 dir: impl AsRef<Path>,
605) -> Result<()> {
606 use std::fs;
607 let dir = dir.as_ref();
608 if !dir.exists() {
609 return Ok(());
610 }
611 for entry in fs::read_dir(dir)? {
612 let entry = entry?;
613 let path = entry.path();
614 if !path.is_file() {
615 continue;
616 }
617 let ext = path
618 .extension()
619 .and_then(|s| s.to_str())
620 .unwrap_or("")
621 .to_ascii_lowercase();
622 if ext != "yml" && ext != "yaml" {
623 continue;
624 }
625 let name = path
626 .file_stem()
627 .and_then(|s| s.to_str())
628 .unwrap_or("")
629 .to_owned();
630 let raw = fs::read_to_string(&path)?;
631 let json: serde_json::Value = strict_yaml_parse(&raw)
632 .with_context(|| format!("failed to parse gear file: {}", path.display()))?;
633 bag.insert(name, json);
634 }
635 Ok(())
636}
637
638pub fn expand_env_in_dsn(dsn: &str) -> Result<String> {
646 toolkit_utils::var_expand::expand_env_vars(dsn).map_err(|e| anyhow::anyhow!("{e}"))
647}
648
649pub fn resolve_password(password: Option<&str>) -> Result<Option<String>> {
654 if let Some(pwd) = password {
655 if pwd.starts_with("${") && pwd.ends_with('}') {
656 let var_name = &pwd[2..pwd.len() - 1];
658 let resolved = std::env::var(var_name).with_context(|| {
659 format!("Environment variable '{var_name}' not found for password")
660 })?;
661 Ok(Some(resolved))
662 } else {
663 Ok(Some(pwd.to_owned()))
665 }
666 } else {
667 Ok(None)
668 }
669}
670
671pub fn validate_dsn(dsn: &str) -> Result<()> {
677 if dsn.starts_with("sqlite:") {
679 return Ok(());
680 }
681
682 let _parsed = dsn::parse(dsn).map_err(|e| anyhow::anyhow!("Invalid DSN '{dsn}': {e}"))?;
683
684 Ok(())
685}
686
687fn resolve_sqlite_dsn(
692 dsn: &str,
693 home_dir: &Path,
694 gear_name: &str,
695 dry_run: bool,
696) -> Result<String> {
697 if dsn.contains("@file(") {
698 if let Some(start) = dsn.find("@file(")
700 && let Some(end) = dsn[start..].find(')')
701 {
702 let file_path = &dsn[start + 6..start + end]; let resolved_path = if file_path.starts_with('/')
705 || (file_path.len() > 1 && file_path.chars().nth(1) == Some(':'))
706 {
707 PathBuf::from(file_path)
709 } else {
710 let gear_dir = home_dir.join(gear_name);
712 if !dry_run {
713 std::fs::create_dir_all(&gear_dir).with_context(|| {
714 format!("Failed to create gear directory: {}", gear_dir.display())
715 })?;
716 }
717 gear_dir.join(file_path)
718 };
719
720 let normalized_path = normalize_path(&resolved_path);
721 if normalized_path.len() > 1 && normalized_path.chars().nth(1) == Some(':') {
724 return Ok(format!("sqlite:{normalized_path}"));
726 }
727 return Ok(format!("sqlite://{normalized_path}"));
729 }
730 return Err(anyhow::anyhow!(
731 "Invalid @file() syntax in SQLite DSN: {dsn}"
732 ));
733 }
734
735 if dsn == "sqlite://" || dsn == "sqlite:///" || dsn == "sqlite:" {
737 let gear_dir = home_dir.join(gear_name);
738 if !dry_run {
739 std::fs::create_dir_all(&gear_dir).with_context(|| {
740 format!("Failed to create gear directory: {}", gear_dir.display())
741 })?;
742 }
743 let db_path = gear_dir.join(format!("{gear_name}.sqlite"));
744 let normalized_path = normalize_path(&db_path);
745 if normalized_path.len() > 1 && normalized_path.chars().nth(1) == Some(':') {
748 return Ok(format!("sqlite:{normalized_path}"));
750 }
751 return Ok(format!("sqlite://{normalized_path}"));
753 }
754
755 Ok(dsn.to_owned())
757}
758
759fn build_server_dsn(
763 scheme: &str,
764 host: Option<&str>,
765 port: Option<u16>,
766 user: Option<&str>,
767 password: Option<&str>,
768 dbname: Option<&str>,
769 params: &HashMap<String, String>,
770) -> Result<String> {
771 let host = host.unwrap_or("localhost");
772 let user = user.unwrap_or("postgres"); let mut url = Url::parse(&format!("{scheme}://dummy/"))
776 .with_context(|| format!("Invalid scheme: {scheme}"))?;
777
778 url.set_host(Some(host))
780 .with_context(|| format!("Invalid host: {host}"))?;
781
782 if let Some(port) = port {
784 url.set_port(Some(port))
785 .map_err(|()| anyhow::anyhow!("Invalid port: {port}"))?;
786 }
787
788 url.set_username(user)
790 .map_err(|()| anyhow::anyhow!("Failed to set username: {user}"))?;
791
792 if let Some(password) = password {
794 url.set_password(Some(password))
795 .map_err(|()| anyhow::anyhow!("Failed to set password"))?;
796 }
797
798 if let Some(dbname) = dbname {
800 let encoded_dbname = urlencoding::encode(dbname);
802 url.set_path(&format!("/{encoded_dbname}"));
803 } else {
804 url.set_path("/");
805 }
806
807 if !params.is_empty() {
809 let mut query_pairs = url.query_pairs_mut();
811 for (key, value) in params {
812 query_pairs.append_pair(key, value);
813 }
814 }
815
816 Ok(url.to_string())
817}
818
819fn build_sqlite_dsn_with_dbname_override(
821 original_dsn: &str,
822 dbname: &str,
823 gear_name: &str,
824 home_dir: &Path,
825 dry_run: bool,
826) -> Result<String> {
827 let query_params = if let Some(query_start) = original_dsn.find('?') {
829 &original_dsn[query_start..]
830 } else {
831 ""
832 };
833
834 let gear_dir = home_dir.join(gear_name);
836 if !dry_run {
837 std::fs::create_dir_all(&gear_dir)
838 .with_context(|| format!("Failed to create gear directory: {}", gear_dir.display()))?;
839 }
840 let db_path = gear_dir.join(dbname);
841 let normalized_path = normalize_path(&db_path);
842
843 let dsn_base = if normalized_path.len() > 1 && normalized_path.chars().nth(1) == Some(':') {
845 format!("sqlite:{normalized_path}")
847 } else {
848 format!("sqlite://{normalized_path}")
850 };
851
852 Ok(format!("{dsn_base}{query_params}"))
853}
854
855fn build_sqlite_dsn(
861 dsn: Option<&str>,
862 file: Option<&str>,
863 path: Option<&PathBuf>,
864 dbname: Option<&str>,
865 gear_name: &str,
866 home_dir: &Path,
867 dry_run: bool,
868) -> Result<String> {
869 if let Some(dsn) = dsn {
871 let resolved_dsn = resolve_sqlite_dsn(dsn, home_dir, gear_name, dry_run)?;
872
873 if let Some(dbname) = dbname {
875 return build_sqlite_dsn_with_dbname_override(
876 &resolved_dsn,
877 dbname,
878 gear_name,
879 home_dir,
880 dry_run,
881 );
882 }
883
884 validate_dsn(&resolved_dsn)?;
885 return Ok(resolved_dsn);
886 }
887
888 if let Some(path) = path {
890 let absolute_path = if path.is_absolute() {
891 path.clone()
892 } else {
893 home_dir.join(path)
894 };
895 let normalized_path = normalize_path(&absolute_path);
896 if normalized_path.len() > 1 && normalized_path.chars().nth(1) == Some(':') {
899 return Ok(format!("sqlite:{normalized_path}"));
901 }
902 return Ok(format!("sqlite://{normalized_path}"));
904 }
905
906 if let Some(file) = file {
908 let gear_dir = home_dir.join(gear_name);
909 if !dry_run {
910 std::fs::create_dir_all(&gear_dir).with_context(|| {
911 format!("Failed to create gear directory: {}", gear_dir.display())
912 })?;
913 }
914 let db_path = gear_dir.join(file);
915 let normalized_path = normalize_path(&db_path);
916 if normalized_path.len() > 1 && normalized_path.chars().nth(1) == Some(':') {
919 return Ok(format!("sqlite:{normalized_path}"));
921 }
922 return Ok(format!("sqlite://{normalized_path}"));
924 }
925
926 let gear_dir = home_dir.join(gear_name);
928 if !dry_run {
929 std::fs::create_dir_all(&gear_dir)
930 .with_context(|| format!("Failed to create gear directory: {}", gear_dir.display()))?;
931 }
932 let db_path = gear_dir.join(format!("{gear_name}.sqlite"));
933 let normalized_path = normalize_path(&db_path);
934 if normalized_path.len() > 1 && normalized_path.chars().nth(1) == Some(':') {
937 Ok(format!("sqlite:{normalized_path}"))
939 } else {
940 Ok(format!("sqlite://{normalized_path}"))
942 }
943}
944
945type DbConfigResult = Result<Option<(String , PoolCfg)>>;
947
948#[derive(Default)]
950struct DbConfigBuilder {
951 dsn: Option<String>,
952 host: Option<String>,
953 port: Option<u16>,
954 user: Option<String>,
955 password: Option<String>,
956 dbname: Option<String>,
957 params: HashMap<String, String>,
958 pool: PoolCfg,
959}
960
961impl DbConfigBuilder {
962 fn new() -> Self {
963 Self::default()
964 }
965
966 fn apply_global_server(
968 &mut self,
969 global_server: &DbConnConfig,
970 home_dir: &Path,
971 gear_name: &str,
972 dry_run: bool,
973 ) -> Result<()> {
974 if let Some(global_dsn) = &global_server.dsn {
976 let expanded_dsn = expand_env_in_dsn(global_dsn.expose())?;
977 let resolved_dsn = if expanded_dsn.starts_with("sqlite") {
979 resolve_sqlite_dsn(&expanded_dsn, home_dir, gear_name, dry_run)?
980 } else {
981 expanded_dsn
982 };
983 validate_dsn(&resolved_dsn)?;
984 self.dsn = Some(resolved_dsn);
985 }
986
987 if let Some(host) = &global_server.host {
989 self.host = Some(host.clone());
990 }
991 if let Some(port) = global_server.port {
992 self.port = Some(port);
993 }
994 if let Some(user) = &global_server.user {
995 self.user = Some(user.clone());
996 }
997 if let Some(password) = resolve_password(
998 global_server
999 .password
1000 .as_ref()
1001 .map(toolkit_utils::SecretString::expose),
1002 )? {
1003 self.password = Some(password);
1004 }
1005 if let Some(dbname) = &global_server.dbname {
1006 self.dbname = Some(dbname.clone());
1007 }
1008 if let Some(params) = &global_server.params {
1009 self.params.extend(params.clone());
1010 }
1011 if let Some(pool) = &global_server.pool {
1012 self.pool = pool.clone();
1013 }
1014
1015 Ok(())
1016 }
1017
1018 fn apply_gear_dsn(
1020 &mut self,
1021 gear_dsn: &str,
1022 home_dir: &Path,
1023 gear_name: &str,
1024 dry_run: bool,
1025 ) -> Result<()> {
1026 let resolved_dsn = if gear_dsn.starts_with("sqlite") {
1028 resolve_sqlite_dsn(gear_dsn, home_dir, gear_name, dry_run)?
1029 } else {
1030 gear_dsn.to_owned()
1031 };
1032 validate_dsn(&resolved_dsn)?;
1033 self.dsn = Some(resolved_dsn);
1034 Ok(())
1035 }
1036
1037 fn apply_gear_fields(&mut self, gear_db_config: &DbConnConfig) -> Result<()> {
1039 if let Some(host) = &gear_db_config.host {
1040 self.host = Some(host.clone());
1041 }
1042 if let Some(port) = gear_db_config.port {
1043 self.port = Some(port);
1044 }
1045 if let Some(user) = &gear_db_config.user {
1046 self.user = Some(user.clone());
1047 }
1048 if let Some(password) = resolve_password(
1049 gear_db_config
1050 .password
1051 .as_ref()
1052 .map(toolkit_utils::SecretString::expose),
1053 )? {
1054 self.password = Some(password);
1055 }
1056 if let Some(dbname) = &gear_db_config.dbname {
1057 self.dbname = Some(dbname.clone());
1058 }
1059 if let Some(params) = &gear_db_config.params {
1060 self.params.extend(params.clone());
1061 }
1062 if let Some(pool) = &gear_db_config.pool {
1063 if let Some(max_conns) = pool.max_conns {
1065 self.pool.max_conns = Some(max_conns);
1066 }
1067 if let Some(acquire_timeout) = pool.acquire_timeout {
1068 self.pool.acquire_timeout = Some(acquire_timeout);
1069 }
1070 }
1071 Ok(())
1072 }
1073
1074 fn has_field_overrides(&self) -> bool {
1076 self.host.is_some()
1077 || self.port.is_some()
1078 || self.user.is_some()
1079 || self.password.is_some()
1080 || !self.params.is_empty()
1081 }
1082}
1083
1084fn decide_backend(builder: &DbConfigBuilder, gear_db_config: &DbConnConfig) -> bool {
1086 gear_db_config.file.is_some()
1089 || gear_db_config.path.is_some()
1090 || builder
1091 .dsn
1092 .as_ref()
1093 .is_some_and(|dsn| dsn.starts_with("sqlite"))
1094 || (gear_db_config.server.is_none() && builder.dsn.is_none())
1095}
1096
1097fn finalize_sqlite_dsn(
1099 builder: &DbConfigBuilder,
1100 gear_db_config: &DbConnConfig,
1101 gear_name: &str,
1102 home_dir: &Path,
1103 dry_run: bool,
1104) -> Result<String> {
1105 build_sqlite_dsn(
1106 builder.dsn.as_deref(),
1107 gear_db_config.file.as_deref(),
1108 gear_db_config.path.as_ref(),
1109 builder.dbname.as_deref(),
1110 gear_name,
1111 home_dir,
1112 dry_run,
1113 )
1114}
1115
1116fn finalize_server_dsn(builder: &DbConfigBuilder, gear_name: &str) -> Result<String> {
1118 let dbname = if let Some(dbname) = builder.dbname.as_deref() {
1120 dbname.to_owned()
1121 } else if let Some(dsn) = builder.dsn.as_ref() {
1122 if let Ok(parsed) = url::Url::parse(dsn) {
1124 let path = parsed.path();
1125 if path.len() > 1 {
1126 path[1..].to_string()
1128 } else {
1129 return Err(anyhow::anyhow!(
1130 "Server-based database config for gear '{gear_name}' missing required 'dbname'"
1131 ));
1132 }
1133 } else {
1134 return Err(anyhow::anyhow!(
1135 "Server-based database config for gear '{gear_name}' missing required 'dbname'"
1136 ));
1137 }
1138 } else {
1139 return Err(anyhow::anyhow!(
1140 "Server-based database config for gear '{gear_name}' missing required 'dbname'"
1141 ));
1142 };
1143
1144 if builder.has_field_overrides() || builder.dsn.is_none() {
1145 let scheme = if let Some(dsn) = &builder.dsn {
1147 let parsed = Url::parse(dsn)?;
1148 parsed.scheme().to_owned()
1149 } else {
1150 "postgresql".to_owned() };
1152
1153 build_server_dsn(
1154 &scheme,
1155 builder.host.as_deref(),
1156 builder.port,
1157 builder.user.as_deref(),
1158 builder.password.as_deref(),
1159 Some(&dbname),
1160 &builder.params,
1161 )
1162 } else if let Some(original_dsn) = &builder.dsn {
1163 if let Ok(mut parsed) = Url::parse(original_dsn) {
1165 let original_dbname = parsed.path().trim_start_matches('/');
1167 if original_dbname != dbname {
1168 parsed.set_path(&format!("/{dbname}"));
1169 }
1170 Ok(parsed.to_string())
1171 } else {
1172 build_server_dsn(
1174 "postgresql",
1175 builder.host.as_deref(),
1176 builder.port,
1177 builder.user.as_deref(),
1178 builder.password.as_deref(),
1179 Some(&dbname),
1180 &builder.params,
1181 )
1182 }
1183 } else {
1184 unreachable!("final_dsn should not be None when has_field_overrides is false")
1186 }
1187}
1188
1189fn redact_dsn_for_logging(dsn: &str) -> Result<String> {
1191 if dsn.contains('@') {
1192 let parsed = Url::parse(dsn)?;
1193 let mut log_url = parsed;
1194 if log_url.password().is_some() {
1195 log_url.set_password(Some("***")).ok();
1196 }
1197 Ok(log_url.to_string())
1198 } else {
1199 Ok(dsn.to_owned())
1200 }
1201}
1202
1203pub const TOOLKIT_MODULE_CONFIG_ENV: &str = "TOOLKIT_MODULE_CONFIG";
1207
1208#[derive(Debug, Clone, Serialize, Deserialize)]
1211pub struct RenderedDbConfig {
1212 #[serde(skip_serializing_if = "Option::is_none")]
1215 pub global: Option<GlobalDatabaseConfig>,
1216 #[serde(skip_serializing_if = "Option::is_none")]
1219 pub gear: Option<DbConnConfig>,
1220}
1221
1222impl RenderedDbConfig {
1223 #[must_use]
1225 pub fn new(global: Option<GlobalDatabaseConfig>, gear: Option<DbConnConfig>) -> Self {
1226 Self { global, gear }
1227 }
1228}
1229
1230#[derive(Debug, Clone, Serialize, Deserialize)]
1240pub struct RenderedGearConfig {
1241 #[serde(skip_serializing_if = "Option::is_none")]
1244 pub database: Option<RenderedDbConfig>,
1245 #[serde(default)]
1247 pub config: serde_json::Value,
1248 #[serde(skip_serializing_if = "Option::is_none")]
1251 pub logging: Option<LoggingConfig>,
1252 #[serde(skip_serializing_if = "Option::is_none")]
1254 pub opentelemetry: Option<OpenTelemetryConfig>,
1255}
1256
1257impl RenderedGearConfig {
1258 pub fn from_json(json: &str) -> Result<Self> {
1263 serde_json::from_str(json).context("Failed to parse RenderedGearConfig from JSON")
1264 }
1265
1266 pub fn to_json(&self) -> Result<String> {
1271 serde_json::to_string(self).context("Failed to serialize RenderedGearConfig to JSON")
1272 }
1273}
1274
1275pub fn render_gear_config_for_oop(
1292 app: &AppConfig,
1293 gear_name: &str,
1294 _home_dir: &std::path::Path,
1295) -> Result<RenderedGearConfig> {
1296 let gear_db_config = parse_gear_config(app, gear_name)
1299 .ok()
1300 .and_then(|entry| entry.database);
1301
1302 let database = if gear_db_config.is_some() || app.database.is_some() {
1304 Some(RenderedDbConfig::new(app.database.clone(), gear_db_config))
1305 } else {
1306 None
1307 };
1308
1309 let config = parse_gear_config(app, gear_name)
1311 .map(|entry| entry.config)
1312 .unwrap_or_default();
1313
1314 let logging = app.logging.clone();
1316
1317 let opentelemetry = if app.opentelemetry.tracing.enabled || app.opentelemetry.metrics.enabled {
1319 Some(app.opentelemetry.clone())
1320 } else {
1321 None
1322 };
1323
1324 Ok(RenderedGearConfig {
1325 database,
1326 config,
1327 logging: Some(logging),
1328 opentelemetry,
1329 })
1330}
1331
1332pub fn parse_gear_config(app: &AppConfig, gear_name: &str) -> Result<GearConfig> {
1337 let gear_raw = app
1338 .gears
1339 .get(gear_name)
1340 .cloned()
1341 .ok_or_else(|| anyhow::anyhow!("Gear '{gear_name}' not found in config"))?;
1342
1343 let gear_config: GearConfig = serde_json::from_value(gear_raw)?;
1344 Ok(gear_config)
1345}
1346
1347pub fn get_gear_runtime_config(app: &AppConfig, gear_name: &str) -> Result<Option<GearRuntime>> {
1352 let entry = parse_gear_config(app, gear_name)?;
1353 Ok(entry.runtime)
1354}
1355
1356pub fn build_final_db_for_gear(
1367 app: &AppConfig,
1368 gear_name: &str,
1369 home_dir: &Path,
1370 dry_run: bool,
1371) -> DbConfigResult {
1372 let Some(gear_raw) = app.gears.get(gear_name) else {
1374 return Ok(None); };
1376
1377 let gear_entry: GearConfig = serde_json::from_value(gear_raw.clone())
1378 .with_context(|| format!("Invalid gear config structure for '{gear_name}'"))?;
1379
1380 let Some(gear_db_config) = gear_entry.database else {
1381 tracing::warn!(
1382 "Gear '{}' has no database configuration; DB capability disabled",
1383 gear_name
1384 );
1385 return Ok(None);
1386 };
1387
1388 let global_db_config = app.database.as_ref();
1390
1391 let mut builder = DbConfigBuilder::new();
1393
1394 if let Some(server_name) = &gear_db_config.server {
1396 let global_server = global_db_config
1397 .and_then(|gc| gc.servers.get(server_name))
1398 .ok_or_else(|| {
1399 anyhow::anyhow!("Referenced server '{server_name}' not found in global config")
1400 })?;
1401
1402 builder.apply_global_server(global_server, home_dir, gear_name, dry_run)?;
1403 }
1404
1405 if let Some(gear_dsn) = &gear_db_config.dsn {
1407 builder.apply_gear_dsn(gear_dsn.expose(), home_dir, gear_name, dry_run)?;
1408 }
1409
1410 builder.apply_gear_fields(&gear_db_config)?;
1412
1413 let is_sqlite = decide_backend(&builder, &gear_db_config);
1415
1416 let result_dsn = if is_sqlite {
1417 finalize_sqlite_dsn(&builder, &gear_db_config, gear_name, home_dir, dry_run)?
1418 } else {
1419 finalize_server_dsn(&builder, gear_name)?
1420 };
1421
1422 validate_dsn(&result_dsn)?;
1424
1425 let log_dsn = redact_dsn_for_logging(&result_dsn)?;
1427
1428 tracing::info!(
1429 "Built final DB config for gear '{}': {}",
1430 gear_name,
1431 log_dsn
1432 );
1433
1434 Ok(Some((result_dsn, builder.pool)))
1435}
1436
1437#[must_use]
1440pub fn get_gear_db_config(app: &AppConfig, gear_name: &str) -> Option<DbConnConfig> {
1441 let gear_raw = app.gears.get(gear_name)?;
1442 let gear_entry: GearConfig = serde_json::from_value(gear_raw.clone()).ok()?;
1443 gear_entry.database
1444}
1445
1446#[must_use]
1449pub fn gear_home(app: &AppConfig, gear_name: &str) -> PathBuf {
1450 PathBuf::from(&app.server.home_dir).join(gear_name)
1451}
1452
1453#[cfg(test)]
1454#[cfg_attr(coverage_nightly, coverage(off))]
1455mod tests {
1456 use super::*;
1457 use serial_test::serial;
1458 use std::fs;
1459 use temp_env::with_var;
1460 use tempfile::tempdir;
1461
1462 fn is_normalized_path(p: &Path) -> bool {
1464 p.is_absolute() && !p.starts_with("~")
1465 }
1466
1467 fn default_subdir() -> &'static str {
1469 ".cf-gears"
1470 }
1471
1472 #[test]
1473 fn test_remap_gear_env_key() {
1474 let cases = [
1477 ("gears.my_gear.port", "gears.my-gear.port"),
1479 ("gears.my_gear.max_age_days", "gears.my-gear.max_age_days"),
1481 ("gears.a_b_c.field", "gears.a-b-c.field"),
1483 ("gears.my-gear.port", "gears.my-gear.port"),
1485 ("vendor.my_vendor.key", "vendor.my_vendor.key"),
1487 ("server.home_dir", "server.home_dir"),
1488 ("gears", "gears"),
1490 ("GEARS.MY_GEAR.PORT", "gears.my-gear.port"),
1492 ("server", "server"),
1494 ];
1495
1496 for (input, expected) in cases {
1497 assert_eq!(
1498 remap_gear_env_key(input),
1499 expected,
1500 "remap_gear_env_key({input:?})"
1501 );
1502 }
1503 }
1504
1505 #[test]
1506 fn test_default_config_structure() {
1507 let config = AppConfig::default();
1508
1509 assert!(config.database.is_none());
1511
1512 let logging = config.logging;
1514 assert!(logging.contains_key("default"));
1515
1516 let default_section = &logging["default"];
1517 assert_eq!(default_section.console_level, Some(Level::INFO));
1518 assert_eq!(default_section.file().unwrap(), "logs/cf-gears.log");
1519
1520 assert!(config.gears.is_empty());
1522 }
1523
1524 #[test]
1525 fn oop_http_labels_coerce_numeric_and_bool_values_to_strings() {
1526 let cfg: OopHttpConfig = serde_json::from_value(serde_json::json!({
1530 "listen_addr": "0.0.0.0:8080",
1531 "labels": {
1532 "shard": 7,
1533 "role": "ingest",
1534 "canary": true,
1535 }
1536 }))
1537 .expect("numeric/bool label values must deserialize");
1538
1539 assert_eq!(cfg.labels.get("shard").map(String::as_str), Some("7"));
1540 assert_eq!(cfg.labels.get("role").map(String::as_str), Some("ingest"));
1541 assert_eq!(cfg.labels.get("canary").map(String::as_str), Some("true"));
1542 }
1543
1544 #[test]
1547 #[serial]
1548 fn test_load_layered_normalizes_home_dir() {
1549 let tmp = tempdir().unwrap();
1550 let cfg_path = tmp.path().join("cfg.yaml");
1551
1552 let yaml = r#"
1554server:
1555 home_dir: "~/.test_cfgears"
1556
1557database:
1558 servers:
1559 test_postgres:
1560 dsn: "postgres://user:pass@localhost/db"
1561 pool:
1562 max_conns: 20
1563
1564logging:
1565 default:
1566 console_level: debug
1567 file: "logs/default.log"
1568"#;
1569 fs::write(&cfg_path, yaml).unwrap();
1570
1571 let config = AppConfig::load_layered(&cfg_path).unwrap();
1572
1573 assert!(is_normalized_path(&config.server.home_dir));
1575 assert!(config.server.home_dir.ends_with(".test_cfgears"));
1576
1577 let logging = &config.logging;
1583 let def = &logging["default"];
1584 assert_eq!(def.console_level, Some(Level::DEBUG));
1585 assert_eq!(def.section_file.as_ref().unwrap().file, "logs/default.log");
1586 }
1587
1588 #[test]
1589 fn test_load_or_default_normalizes_home_dir_when_none() {
1590 let tmp = tempdir().unwrap();
1593 let env_var = if cfg!(target_os = "windows") {
1594 "APPDATA"
1595 } else {
1596 "HOME"
1597 };
1598 with_var(env_var, Some(tmp.path().to_str().unwrap()), || {
1599 let config = AppConfig::load_or_default(None).unwrap();
1600 assert!(is_normalized_path(&config.server.home_dir));
1601 assert!(config.server.home_dir.ends_with(default_subdir()));
1602 });
1603 }
1604
1605 #[test]
1608 #[serial]
1609 fn test_minimal_yaml_config() {
1610 let tmp = tempdir().unwrap();
1611 let cfg_path = tmp.path().join("cfg.yaml");
1612
1613 let yaml = r#"
1614server:
1615 home_dir: "~/.minimal"
1616"#;
1617 fs::write(&cfg_path, yaml).unwrap();
1618
1619 let config = AppConfig::load_layered(&cfg_path).unwrap();
1620
1621 assert!(is_normalized_path(&config.server.home_dir));
1623 assert!(config.server.home_dir.ends_with(".minimal"));
1624
1625 assert!(config.database.is_none());
1627 assert!(config.gears.is_empty());
1628 }
1629
1630 #[test]
1631 fn test_cli_overrides() {
1632 let mut config = AppConfig::default();
1633
1634 let args = CliArgs {
1635 config: None,
1636 print_config: false,
1637 verbose: 2, mock: false,
1639 };
1640
1641 config.apply_cli_overrides(args.verbose);
1642
1643 let logging = &config.logging;
1647 let default_section = &logging["default"];
1648 assert_eq!(default_section.console_level, Some(Level::TRACE));
1649 }
1650
1651 #[test]
1652 fn test_cli_verbose_levels_matrix() {
1653 for (verbose_level, expected_log_level) in [
1654 (0, Some(Level::INFO)), (1, Some(Level::DEBUG)),
1656 (2, Some(Level::TRACE)),
1657 (3, Some(Level::TRACE)), ] {
1659 let mut config = AppConfig::default();
1660 let args = CliArgs {
1661 config: None,
1662 print_config: false,
1663 verbose: verbose_level,
1664 mock: false,
1665 };
1666
1667 config.apply_cli_overrides(args.verbose);
1668
1669 let logging = &config.logging;
1670 let default_section = &logging["default"];
1671
1672 if verbose_level == 0 {
1673 assert_eq!(default_section.console_level, Some(Level::INFO));
1674 } else {
1675 assert_eq!(default_section.console_level, expected_log_level);
1676 }
1677 }
1678 }
1679
1680 #[test]
1683 #[serial]
1684 fn test_layered_config_loading_with_gears_dir() {
1685 let tmp = tempdir().unwrap();
1686 let cfg_path = tmp.path().join("gears_dir.yaml");
1687 let gears_dir = tmp.path().join("gears");
1688
1689 fs::create_dir_all(&gears_dir).unwrap();
1690 let gear_cfg = gears_dir.join("test_gear.yaml");
1691 fs::write(
1692 &gear_cfg,
1693 r#"
1694setting1: "value1"
1695setting2: 42
1696"#,
1697 )
1698 .unwrap();
1699
1700 let gears_dir_str = normalize_path(&gears_dir);
1702 let yaml = format!(
1703 r#"
1704server:
1705 home_dir: "~/.gears_test"
1706
1707gears_dir: "{gears_dir_str}"
1708
1709gears:
1710 existing_gear:
1711 key: "value"
1712"#
1713 );
1714
1715 fs::write(&cfg_path, yaml).unwrap();
1716
1717 let config = AppConfig::load_layered(&cfg_path).unwrap();
1718
1719 assert!(config.gears.contains_key("existing_gear"));
1721
1722 assert!(config.gears.contains_key("test_gear"));
1724
1725 let test_gear = &config.gears["test_gear"];
1727 assert_eq!(test_gear["setting1"], "value1");
1728 assert_eq!(test_gear["setting2"], 42);
1729 }
1730
1731 #[test]
1734 #[serial]
1735 fn test_load_and_init_logging_smoke() {
1736 let tmp = tempdir().unwrap();
1738 let cfg_path = tmp.path().join("logging.yaml");
1739 let yaml = r#"
1740server:
1741 home_dir: "~/.logging_test"
1742
1743logging:
1744 default:
1745 console_level: debug
1746 file: ""
1747 file_level: info
1748"#;
1749 fs::write(&cfg_path, yaml).unwrap();
1750
1751 let config = AppConfig::load_layered(&cfg_path).unwrap();
1752 let logging = &config.logging;
1753 assert!(logging.contains_key("default"));
1754
1755 let default_section = &logging["default"];
1756 assert_eq!(default_section.console_level, Some(Level::DEBUG));
1757 assert_eq!(default_section.file_level(), Some(Level::INFO));
1758 }
1760
1761 fn create_app_with_server(server_name: &str, db_config: DbConnConfig) -> AppConfig {
1765 let mut servers = HashMap::new();
1766 servers.insert(server_name.to_owned(), db_config);
1767
1768 AppConfig {
1769 database: Some(GlobalDatabaseConfig {
1770 servers,
1771 auto_provision: None,
1772 }),
1773 ..Default::default()
1774 }
1775 }
1776
1777 fn add_gear_to_app(app: &mut AppConfig, gear_name: &str, database_config: &serde_json::Value) {
1779 app.gears.insert(
1780 gear_name.to_owned(),
1781 serde_json::json!({
1782 "database": database_config,
1783 "config": {}
1784 }),
1785 );
1786 }
1787
1788 fn add_gear_with_config(app: &mut AppConfig, gear_name: &str, config: &serde_json::Value) {
1790 app.gears.insert(
1791 gear_name.to_owned(),
1792 serde_json::json!({
1793 "database": {},
1794 "config": config
1795 }),
1796 );
1797 }
1798
1799 fn create_minimal_app() -> AppConfig {
1801 AppConfig {
1802 database: None,
1803 gears: HashMap::new(),
1804 ..Default::default()
1805 }
1806 }
1807
1808 #[test]
1809 fn test_precedence_global_dsn_only() {
1810 let tmp = tempdir().unwrap();
1811 let home_dir = tmp.path();
1812
1813 let mut app = create_app_with_server(
1814 "test_server",
1815 DbConnConfig {
1816 dsn: Some(toolkit_utils::SecretString::new(
1817 "postgresql://global_user:global_pass@global_host:5432/global_db",
1818 )),
1819 ..Default::default()
1820 },
1821 );
1822
1823 add_gear_to_app(
1825 &mut app,
1826 "test_gear",
1827 &serde_json::json!({
1828 "server": "test_server"
1829 }),
1830 );
1831
1832 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
1833 assert!(result.is_some());
1834
1835 let (dsn, _pool) = result.unwrap();
1836 assert!(dsn.contains("global_user"));
1837 assert!(dsn.contains("global_host"));
1838 assert!(dsn.contains("global_db"));
1839 }
1840
1841 #[test]
1842 fn test_precedence_global_fields_only() {
1843 let tmp = tempdir().unwrap();
1844 let home_dir = tmp.path();
1845
1846 let mut app = create_app_with_server(
1847 "test_server",
1848 DbConnConfig {
1849 host: Some("field_host".to_owned()),
1850 port: Some(5433),
1851 user: Some("field_user".to_owned()),
1852 dbname: Some("field_db".to_owned()),
1853 ..Default::default()
1854 },
1855 );
1856
1857 add_gear_to_app(
1859 &mut app,
1860 "test_gear",
1861 &serde_json::json!({
1862 "server": "test_server"
1863 }),
1864 );
1865
1866 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
1867 assert!(result.is_some());
1868
1869 let (dsn, _pool) = result.unwrap();
1870 assert!(dsn.contains("field_host"));
1871 assert!(dsn.contains("5433"));
1872 assert!(dsn.contains("field_user"));
1873 assert!(dsn.contains("field_db"));
1874 }
1875
1876 #[test]
1877 fn test_precedence_gear_dsn_only() {
1878 let tmp = tempdir().unwrap();
1879 let home_dir = tmp.path();
1880
1881 let app = AppConfig {
1882 gears: {
1883 let mut gears = HashMap::new();
1884 gears.insert(
1885 "test_gear".to_owned(),
1886 serde_json::json!({
1887 "database": {
1888 "dsn": "sqlite://gear_test.db?wal=true&synchronous=NORMAL"
1889 },
1890 "config": {}
1891 }),
1892 );
1893 gears
1894 },
1895 ..Default::default()
1896 };
1897
1898 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
1899 assert!(result.is_some());
1900
1901 let (dsn, _pool) = result.unwrap();
1902 assert!(dsn.contains("gear_test.db"));
1903 assert!(dsn.contains("wal=true"));
1904 }
1905
1906 #[test]
1907 fn test_precedence_gear_fields_only() {
1908 let tmp = tempdir().unwrap();
1909 let home_dir = tmp.path();
1910
1911 let app = AppConfig {
1912 gears: {
1913 let mut gears = HashMap::new();
1914 gears.insert(
1915 "test_gear".to_owned(),
1916 serde_json::json!({
1917 "database": {
1918 "file": "gear_fields.db"
1919 },
1920 "config": {}
1921 }),
1922 );
1923 gears
1924 },
1925 ..Default::default()
1926 };
1927
1928 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
1929 assert!(result.is_some());
1930
1931 let (dsn, _pool) = result.unwrap();
1932 assert!(dsn.contains("gear_fields.db"));
1933 #[cfg(windows)]
1935 assert!(dsn.starts_with("sqlite:") && !dsn.starts_with("sqlite://"));
1936 #[cfg(unix)]
1937 assert!(dsn.starts_with("sqlite://"));
1938 }
1939
1940 #[test]
1941 fn test_precedence_fields_override_dsn() {
1942 let tmp = tempdir().unwrap();
1943 let home_dir = tmp.path();
1944
1945 let mut app = create_app_with_server(
1946 "test_server",
1947 DbConnConfig {
1948 dsn: Some(toolkit_utils::SecretString::new(
1949 "postgresql://old_user:old_pass@old_host:5432/old_db",
1950 )),
1951 host: Some("new_host".to_owned()), port: Some(5433), user: Some("new_user".to_owned()), dbname: Some("new_db".to_owned()), ..Default::default()
1956 },
1957 );
1958
1959 add_gear_to_app(
1961 &mut app,
1962 "test_gear",
1963 &serde_json::json!({
1964 "server": "test_server",
1965 "port": 5434 }),
1967 );
1968
1969 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
1970 assert!(result.is_some());
1971
1972 let (dsn, _pool) = result.unwrap();
1973 assert!(dsn.contains("new_host"));
1975 assert!(dsn.contains("5434")); assert!(dsn.contains("new_user"));
1977 assert!(dsn.contains("new_db"));
1978 assert!(!dsn.contains("old_host"));
1980 assert!(!dsn.contains("5432"));
1981 assert!(!dsn.contains("old_user"));
1982 assert!(!dsn.contains("old_db"));
1983 }
1984
1985 #[test]
1986 fn test_env_expansion_password() {
1987 let tmp = tempdir().unwrap();
1988 let home_dir = tmp.path();
1989
1990 with_var("TEST_DB_PASSWORD", Some("secret123"), || {
1991 let mut app = create_app_with_server(
1992 "test_server",
1993 DbConnConfig {
1994 host: Some("localhost".to_owned()),
1995 port: Some(5432),
1996 user: Some("testuser".to_owned()),
1997 password: Some(toolkit_utils::SecretString::new("${TEST_DB_PASSWORD}")), dbname: Some("testdb".to_owned()),
1999 ..Default::default()
2000 },
2001 );
2002
2003 add_gear_to_app(
2004 &mut app,
2005 "test_gear",
2006 &serde_json::json!({
2007 "server": "test_server"
2008 }),
2009 );
2010
2011 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2012 assert!(result.is_some());
2013
2014 let (dsn, _pool) = result.unwrap();
2015 assert!(dsn.contains("secret123"));
2016 });
2017 }
2018
2019 #[test]
2020 fn test_env_expansion_in_dsn() {
2021 let tmp = tempdir().unwrap();
2022 let home_dir = tmp.path();
2023
2024 temp_env::with_vars(
2025 [
2026 ("DB_HOST", Some("test-server")),
2027 ("DB_PASSWORD", Some("env_secret")),
2028 ],
2029 || {
2030 let mut app = create_app_with_server(
2031 "test_server",
2032 DbConnConfig {
2033 dsn: Some(toolkit_utils::SecretString::new(
2034 "postgresql://user:${DB_PASSWORD}@${DB_HOST}:5432/mydb",
2035 )),
2036 ..Default::default()
2037 },
2038 );
2039
2040 add_gear_to_app(
2041 &mut app,
2042 "test_gear",
2043 &serde_json::json!({
2044 "server": "test_server"
2045 }),
2046 );
2047
2048 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2049 assert!(result.is_some());
2050
2051 let (dsn, _pool) = result.unwrap();
2052 assert!(dsn.contains("test-server"));
2053 assert!(dsn.contains("env_secret"));
2054 assert!(!dsn.contains("${DB_HOST}"));
2056 assert!(!dsn.contains("${DB_PASSWORD}"));
2057 },
2058 );
2059 }
2060
2061 #[test]
2062 fn test_sqlite_file_path_resolution() {
2063 let tmp = tempdir().unwrap();
2064 let home_dir = tmp.path();
2065
2066 let app1 = AppConfig {
2068 gears: {
2069 let mut gears = HashMap::new();
2070 gears.insert(
2071 "test_gear".to_owned(),
2072 serde_json::json!({
2073 "database": {
2074 "file": "test.db"
2075 },
2076 "config": {}
2077 }),
2078 );
2079 gears
2080 },
2081 ..Default::default()
2082 };
2083
2084 let result1 = build_final_db_for_gear(&app1, "test_gear", home_dir, false).unwrap();
2085 assert!(result1.is_some());
2086 let (dsn1, _) = result1.unwrap();
2087 assert!(dsn1.contains("test_gear"));
2088 assert!(dsn1.contains("test.db"));
2089
2090 let abs_path = tmp.path().join("absolute.db");
2092 let app2 = AppConfig {
2093 gears: {
2094 let mut gears = HashMap::new();
2095 gears.insert(
2096 "test_gear".to_owned(),
2097 serde_json::json!({
2098 "database": {
2099 "path": abs_path.to_string_lossy()
2100 },
2101 "config": {}
2102 }),
2103 );
2104 gears
2105 },
2106 ..Default::default()
2107 };
2108
2109 let result2 = build_final_db_for_gear(&app2, "test_gear", home_dir, false).unwrap();
2110 assert!(result2.is_some());
2111 let (dsn2, _) = result2.unwrap();
2112 assert!(dsn2.contains("absolute.db"));
2113
2114 let app3 = AppConfig {
2116 gears: {
2117 let mut gears = HashMap::new();
2118 gears.insert(
2119 "test_gear".to_owned(),
2120 serde_json::json!({
2121 "database": {},
2122 "config": {}
2123 }),
2124 );
2125 gears
2126 },
2127 ..Default::default()
2128 };
2129
2130 let result3 = build_final_db_for_gear(&app3, "test_gear", home_dir, false).unwrap();
2131 assert!(result3.is_some());
2132 let (dsn3, _) = result3.unwrap();
2133 assert!(dsn3.contains("test_gear.sqlite"));
2134 }
2135
2136 #[cfg(windows)]
2137 #[test]
2138 fn test_sqlite_path_resolution_windows() {
2139 let tmp = tempdir().unwrap();
2140 let home_dir = tmp.path();
2141
2142 let app = AppConfig {
2143 gears: {
2144 let mut gears = HashMap::new();
2145 gears.insert(
2146 "test_gear".to_owned(),
2147 serde_json::json!({
2148 "database": {
2149 "file": "test.db"
2150 },
2151 "config": {}
2152 }),
2153 );
2154 gears
2155 },
2156 ..Default::default()
2157 };
2158
2159 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2160 assert!(result.is_some());
2161 let (dsn, _) = result.unwrap();
2162
2163 assert!(!dsn.contains('\\'));
2165 assert!(dsn.contains('/'));
2166 }
2167
2168 #[test]
2169 fn test_sqlite_dsn_with_server_reference_and_dbname_override() {
2170 let tmp = tempdir().unwrap();
2171 let home_dir = tmp.path();
2172
2173 let mut app = AppConfig::default();
2174
2175 let mut servers = HashMap::new();
2177 servers.insert(
2178 "sqlite_users".to_owned(),
2179 DbConnConfig {
2180 engine: None,
2181 dsn: Some(toolkit_utils::SecretString::new(
2182 "sqlite://users_info.db?WAL=true&synchronous=NORMAL&busy_timeout=5000",
2183 )),
2184 host: None,
2185 port: None,
2186 user: None,
2187 password: None,
2188 dbname: None,
2189 params: None,
2190 pool: None,
2191 file: None,
2192 path: None,
2193 lock_keepalive: None,
2194 server: None,
2195 },
2196 );
2197
2198 app.database = Some(GlobalDatabaseConfig {
2199 servers,
2200 auto_provision: None,
2201 });
2202
2203 app.gears.insert(
2205 "users_info".to_owned(),
2206 serde_json::json!({
2207 "database": {
2208 "server": "sqlite_users",
2209 "dbname": "users_info.db"
2210 },
2211 "config": {}
2212 }),
2213 );
2214
2215 let result = build_final_db_for_gear(&app, "users_info", home_dir, false).unwrap();
2216 assert!(result.is_some());
2217 let (dsn, _) = result.unwrap();
2218
2219 assert!(dsn.contains("?WAL=true&synchronous=NORMAL&busy_timeout=5000"));
2221 assert!(dsn.contains("users_info/users_info.db"));
2222
2223 #[cfg(windows)]
2225 {
2226 assert!(dsn.starts_with("sqlite:"));
2228 assert!(!dsn.starts_with("sqlite://"));
2229 }
2230
2231 #[cfg(unix)]
2232 {
2233 assert!(dsn.starts_with("sqlite://"));
2235 }
2236 }
2237
2238 #[cfg(unix)]
2239 #[test]
2240 fn test_sqlite_path_resolution_unix() {
2241 let tmp = tempdir().unwrap();
2242 let home_dir = tmp.path();
2243
2244 let app = AppConfig {
2245 gears: {
2246 let mut gears = HashMap::new();
2247 gears.insert(
2248 "test_gear".to_owned(),
2249 serde_json::json!({
2250 "database": {
2251 "file": "test.db"
2252 },
2253 "config": {}
2254 }),
2255 );
2256 gears
2257 },
2258 ..Default::default()
2259 };
2260
2261 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2262 assert!(result.is_some());
2263 let (dsn, _) = result.unwrap();
2264
2265 assert!(dsn.starts_with("sqlite://"));
2267 assert!(dsn.contains("/test_gear/test.db"));
2268 }
2269
2270 #[test]
2271 fn test_server_based_db_missing_dbname_error() {
2272 let tmp = tempdir().unwrap();
2273 let home_dir = tmp.path();
2274
2275 let mut app = create_app_with_server(
2276 "test_server",
2277 DbConnConfig {
2278 host: Some("localhost".to_owned()),
2279 port: Some(5432),
2280 user: Some("testuser".to_owned()),
2281 ..Default::default()
2283 },
2284 );
2285
2286 add_gear_to_app(
2287 &mut app,
2288 "test_gear",
2289 &serde_json::json!({
2290 "server": "test_server"
2291 }),
2292 );
2293
2294 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false);
2295 assert!(result.is_err());
2296 let error_msg = result.unwrap_err().to_string();
2297 assert!(error_msg.contains("missing required 'dbname'"));
2298 }
2299
2300 #[test]
2301 fn test_gear_no_database_config() {
2302 let tmp = tempdir().unwrap();
2303 let home_dir = tmp.path();
2304
2305 let app = AppConfig {
2307 gears: {
2308 let mut gears = HashMap::new();
2309 gears.insert(
2310 "no_db_gear".to_owned(),
2311 serde_json::json!({
2312 "config": {
2313 "some_setting": "value"
2314 }
2315 }),
2316 );
2317 gears
2318 },
2319 ..Default::default()
2320 };
2321
2322 let result = build_final_db_for_gear(&app, "no_db_gear", home_dir, false).unwrap();
2323 assert!(result.is_none());
2324 }
2325
2326 #[test]
2327 fn test_gear_empty_database_config() {
2328 let tmp = tempdir().unwrap();
2329 let home_dir = tmp.path();
2330
2331 let app = AppConfig {
2333 gears: {
2334 let mut gears = HashMap::new();
2335 gears.insert(
2336 "empty_db_gear".to_owned(),
2337 serde_json::json!({
2338 "database": null,
2339 "config": {}
2340 }),
2341 );
2342 gears
2343 },
2344 ..Default::default()
2345 };
2346
2347 let result = build_final_db_for_gear(&app, "empty_db_gear", home_dir, false).unwrap();
2348 assert!(result.is_none());
2349 }
2350
2351 #[test]
2352 fn test_referenced_server_not_found() {
2353 let tmp = tempdir().unwrap();
2354 let home_dir = tmp.path();
2355
2356 let app = AppConfig {
2357 gears: {
2358 let mut gears = HashMap::new();
2359 gears.insert(
2360 "test_gear".to_owned(),
2361 serde_json::json!({
2362 "database": {
2363 "server": "nonexistent_server"
2364 },
2365 "config": {}
2366 }),
2367 );
2368 gears
2369 },
2370 ..Default::default()
2371 };
2372
2373 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false);
2374 assert!(result.is_err());
2375 let error_msg = result.unwrap_err().to_string();
2376 assert!(error_msg.contains("Referenced server 'nonexistent_server' not found"));
2377 }
2378
2379 #[test]
2380 fn test_dsn_validation_invalid_url() {
2381 let tmp = tempdir().unwrap();
2382 let home_dir = tmp.path();
2383
2384 let app = AppConfig {
2385 gears: {
2386 let mut gears = HashMap::new();
2387 gears.insert(
2388 "test_gear".to_owned(),
2389 serde_json::json!({
2390 "database": {
2391 "dsn": "invalid://not-a-valid[url"
2392 },
2393 "config": {}
2394 }),
2395 );
2396 gears
2397 },
2398 ..Default::default()
2399 };
2400
2401 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false);
2402 assert!(result.is_err());
2403 }
2404
2405 #[test]
2406 fn test_env_variable_not_found() {
2407 let tmp = tempdir().unwrap();
2408 let home_dir = tmp.path();
2409
2410 with_var("NONEXISTENT_PASSWORD", None::<&str>, || {
2412 let mut app = create_app_with_server(
2413 "test_server",
2414 DbConnConfig {
2415 host: Some("localhost".to_owned()),
2416 password: Some(toolkit_utils::SecretString::new("${NONEXISTENT_PASSWORD}")),
2417 dbname: Some("testdb".to_owned()),
2418 ..Default::default()
2419 },
2420 );
2421
2422 add_gear_to_app(
2423 &mut app,
2424 "test_gear",
2425 &serde_json::json!({
2426 "server": "test_server"
2427 }),
2428 );
2429
2430 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false);
2431 assert!(result.is_err());
2432 let error_msg = result.unwrap_err().to_string();
2433 assert!(error_msg.contains("NONEXISTENT_PASSWORD"));
2434 });
2435 }
2436
2437 #[test]
2438 fn test_sqlite_at_file_relative_path() {
2439 let tmp = tempdir().unwrap();
2440 let home_dir = tmp.path();
2441
2442 let app = AppConfig {
2443 gears: {
2444 let mut gears = HashMap::new();
2445 gears.insert(
2446 "test_gear".to_owned(),
2447 serde_json::json!({
2448 "database": {
2449 "dsn": "sqlite://@file(users.db)"
2450 },
2451 "config": {}
2452 }),
2453 );
2454 gears
2455 },
2456 ..Default::default()
2457 };
2458
2459 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2460 assert!(result.is_some());
2461
2462 let (dsn, _pool) = result.unwrap();
2463 assert!(dsn.contains("test_gear"));
2464 assert!(dsn.contains("users.db"));
2465 #[cfg(windows)]
2467 assert!(dsn.starts_with("sqlite:") && !dsn.starts_with("sqlite://"));
2468 #[cfg(unix)]
2469 assert!(dsn.starts_with("sqlite:///"));
2470 }
2471
2472 #[test]
2473 fn test_sqlite_at_file_absolute_path() {
2474 let tmp = tempdir().unwrap();
2475 let home_dir = tmp.path();
2476 let abs_path = tmp.path().join("absolute_db.sqlite");
2477
2478 let app = AppConfig {
2479 gears: {
2480 let mut gears = HashMap::new();
2481 gears.insert(
2482 "test_gear".to_owned(),
2483 serde_json::json!({
2484 "database": {
2485 "dsn": format!("sqlite://@file({})", abs_path.to_string_lossy())
2486 },
2487 "config": {}
2488 }),
2489 );
2490 gears
2491 },
2492 ..Default::default()
2493 };
2494
2495 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2496 assert!(result.is_some());
2497
2498 let (dsn, _pool) = result.unwrap();
2499 assert!(dsn.contains("absolute_db.sqlite"));
2500 #[cfg(windows)]
2502 assert!(dsn.starts_with("sqlite:") && !dsn.starts_with("sqlite://"));
2503 #[cfg(unix)]
2504 assert!(dsn.starts_with("sqlite:///"));
2505 }
2506
2507 #[test]
2508 fn test_sqlite_empty_dsn_default() {
2509 let tmp = tempdir().unwrap();
2510 let home_dir = tmp.path();
2511
2512 let app = AppConfig {
2513 gears: {
2514 let mut gears = HashMap::new();
2515 gears.insert(
2516 "test_gear".to_owned(),
2517 serde_json::json!({
2518 "database": {
2519 "dsn": "sqlite://"
2520 },
2521 "config": {}
2522 }),
2523 );
2524 gears
2525 },
2526 ..Default::default()
2527 };
2528
2529 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2530 assert!(result.is_some());
2531
2532 let (dsn, _pool) = result.unwrap();
2533 assert!(dsn.contains("test_gear"));
2534 assert!(dsn.contains("test_gear.sqlite"));
2535 #[cfg(windows)]
2537 assert!(dsn.starts_with("sqlite:") && !dsn.starts_with("sqlite://"));
2538 #[cfg(unix)]
2539 assert!(dsn.starts_with("sqlite:///"));
2540 }
2541
2542 #[test]
2543 fn test_sqlite_at_file_invalid_syntax() {
2544 let tmp = tempdir().unwrap();
2545 let home_dir = tmp.path();
2546
2547 let app = AppConfig {
2548 gears: {
2549 let mut gears = HashMap::new();
2550 gears.insert(
2551 "test_gear".to_owned(),
2552 serde_json::json!({
2553 "database": {
2554 "dsn": "sqlite://@file(missing_closing_paren"
2555 },
2556 "config": {}
2557 }),
2558 );
2559 gears
2560 },
2561 ..Default::default()
2562 };
2563
2564 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false);
2565 assert!(result.is_err());
2566 let error_msg = result.unwrap_err().to_string();
2567 assert!(error_msg.contains("Invalid @file() syntax"));
2568 }
2569
2570 #[test]
2571 fn test_dsn_special_characters_in_credentials() {
2572 let tmp = tempdir().unwrap();
2573 let home_dir = tmp.path();
2574
2575 let mut app = create_app_with_server(
2577 "test_server",
2578 DbConnConfig {
2579 host: Some("localhost".to_owned()),
2580 port: Some(5432),
2581 user: Some("user@domain".to_owned()),
2582 password: Some(toolkit_utils::SecretString::new(
2583 "pa@ss:w0rd/with%special&chars",
2584 )),
2585 dbname: Some("test/db".to_owned()),
2586 ..Default::default()
2587 },
2588 );
2589
2590 add_gear_to_app(
2591 &mut app,
2592 "test_gear",
2593 &serde_json::json!({
2594 "server": "test_server"
2595 }),
2596 );
2597
2598 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2599 assert!(result.is_some());
2600
2601 let (dsn, _pool) = result.unwrap();
2602
2603 assert!(dsn.starts_with("postgresql://"));
2605 assert!(dsn.contains("user%40domain")); assert!(dsn.contains("/test%2Fdb")); validate_dsn(&dsn).expect("DSN with special characters should be valid");
2610
2611 let parsed_dsn = dsn::parse(&dsn).expect("DSN should be parseable");
2613 assert_eq!(parsed_dsn.username.as_deref(), Some("user@domain"));
2614 assert_eq!(
2615 parsed_dsn.password.as_deref(),
2616 Some("pa@ss:w0rd/with%special&chars")
2617 );
2618 }
2621
2622 #[test]
2623 #[allow(clippy::non_ascii_literal)]
2624 fn test_dsn_unicode_characters() {
2625 let tmp = tempdir().unwrap();
2626 let home_dir = tmp.path();
2627
2628 let mut app = create_app_with_server(
2630 "test_server",
2631 DbConnConfig {
2632 host: Some("localhost".to_owned()),
2633 user: Some("ユーザー".to_owned()), dbname: Some("unicode_db".to_owned()),
2635 ..Default::default()
2636 },
2637 );
2638
2639 add_gear_to_app(
2640 &mut app,
2641 "test_gear",
2642 &serde_json::json!({
2643 "server": "test_server"
2644 }),
2645 );
2646
2647 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2648 assert!(result.is_some());
2649
2650 let (dsn, _pool) = result.unwrap();
2651
2652 assert!(dsn.starts_with("postgresql://"));
2654 assert!(dsn.contains('%')); validate_dsn(&dsn).expect("DSN with Unicode characters should be valid");
2659 }
2660
2661 #[test]
2662 fn test_dsn_query_parameters_encoding() {
2663 let tmp = tempdir().unwrap();
2664 let home_dir = tmp.path();
2665
2666 let mut params = HashMap::new();
2667 params.insert("ssl mode".to_owned(), "require & verify".to_owned());
2668 params.insert("application_name".to_owned(), "my-app/v1.0".to_owned());
2669
2670 let mut app = create_app_with_server(
2671 "test_server",
2672 DbConnConfig {
2673 host: Some("localhost".to_owned()),
2674 user: Some("testuser".to_owned()),
2675 dbname: Some("testdb".to_owned()),
2676 params: Some(params),
2677 ..Default::default()
2678 },
2679 );
2680
2681 add_gear_to_app(
2682 &mut app,
2683 "test_gear",
2684 &serde_json::json!({
2685 "server": "test_server"
2686 }),
2687 );
2688
2689 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2690 assert!(result.is_some());
2691
2692 let (dsn, _pool) = result.unwrap();
2693
2694 assert!(dsn.contains("ssl+mode=require+%26+verify"));
2696 assert!(dsn.contains("application_name=my-app%2Fv1.0"));
2697
2698 validate_dsn(&dsn).expect("DSN with encoded query parameters should be valid");
2700 }
2701
2702 #[test]
2703 fn test_pool_config_merging() {
2704 use std::time::Duration;
2705
2706 let tmp = tempdir().unwrap();
2707 let home_dir = tmp.path();
2708
2709 let mut app = create_app_with_server(
2711 "test_server",
2712 DbConnConfig {
2713 host: Some("localhost".to_owned()),
2714 dbname: Some("testdb".to_owned()),
2715 pool: Some(PoolCfg {
2716 max_conns: Some(10),
2717 min_conns: None,
2718 acquire_timeout: Some(Duration::from_secs(5)),
2719 idle_timeout: None,
2720 max_lifetime: None,
2721 test_before_acquire: None,
2722 }),
2723 ..Default::default()
2724 },
2725 );
2726
2727 add_gear_to_app(
2729 &mut app,
2730 "test_gear",
2731 &serde_json::json!({
2732 "server": "test_server",
2733 "pool": {
2734 "max_conns": 20
2735 }
2736 }),
2737 );
2738
2739 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2740 assert!(result.is_some());
2741
2742 let (_dsn, pool) = result.unwrap();
2743 assert_eq!(pool.max_conns, Some(20)); assert_eq!(pool.acquire_timeout, Some(Duration::from_secs(5))); }
2746
2747 #[test]
2748 fn test_pool_config_gear_overrides_all() {
2749 use std::time::Duration;
2750
2751 let tmp = tempdir().unwrap();
2752 let home_dir = tmp.path();
2753
2754 let mut app = create_app_with_server(
2756 "test_server",
2757 DbConnConfig {
2758 host: Some("localhost".to_owned()),
2759 dbname: Some("testdb".to_owned()),
2760 pool: Some(PoolCfg {
2761 max_conns: Some(10),
2762 min_conns: None,
2763 acquire_timeout: Some(Duration::from_secs(5)),
2764 idle_timeout: None,
2765 max_lifetime: None,
2766 test_before_acquire: None,
2767 }),
2768 ..Default::default()
2769 },
2770 );
2771
2772 add_gear_to_app(
2774 &mut app,
2775 "test_gear",
2776 &serde_json::json!({
2777 "server": "test_server",
2778 "pool": {
2779 "max_conns": 30,
2780 "acquire_timeout": "10s"
2781 }
2782 }),
2783 );
2784
2785 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2786 assert!(result.is_some());
2787
2788 let (_dsn, pool) = result.unwrap();
2789 assert_eq!(pool.max_conns, Some(30));
2790 assert_eq!(pool.acquire_timeout, Some(Duration::from_secs(10)));
2791 }
2792
2793 #[test]
2794 fn test_list_gear_names() {
2795 let mut app = create_minimal_app();
2796 add_gear_with_config(&mut app, "zebra_gear", &serde_json::json!({}));
2797 add_gear_with_config(&mut app, "alpha_gear", &serde_json::json!({}));
2798 add_gear_with_config(&mut app, "beta_gear", &serde_json::json!({}));
2799
2800 let gear_names = list_gear_names(&app);
2801
2802 assert_eq!(gear_names.len(), 3);
2804 assert_eq!(gear_names[0], "alpha_gear");
2805 assert_eq!(gear_names[1], "beta_gear");
2806 assert_eq!(gear_names[2], "zebra_gear");
2807 }
2808
2809 #[test]
2810 fn test_list_gear_names_empty() {
2811 let app = create_minimal_app();
2812 let gear_names = list_gear_names(&app);
2813 assert_eq!(gear_names.len(), 0);
2814 }
2815
2816 #[test]
2817 fn test_redact_dsn_password_postgres() {
2818 let dsn = "postgres://user:secretpass@localhost:5432/mydb";
2819 let redacted = redact_dsn_password(dsn).unwrap();
2820 assert_eq!(
2821 redacted,
2822 "postgres://user:***REDACTED***@localhost:5432/mydb"
2823 );
2824 }
2825
2826 #[test]
2827 fn test_redact_dsn_password_no_password() {
2828 let dsn = "postgres://user@localhost:5432/mydb";
2829 let redacted = redact_dsn_password(dsn).unwrap();
2830 assert_eq!(redacted, "postgres://user@localhost:5432/mydb");
2832 }
2833
2834 #[test]
2835 fn test_redact_dsn_password_special_chars() {
2836 let dsn = "postgres://user:p@ss%40word@localhost:5432/mydb";
2837 let redacted = redact_dsn_password(dsn).unwrap();
2838 assert_eq!(
2839 redacted,
2840 "postgres://user:***REDACTED***@localhost:5432/mydb"
2841 );
2842 }
2843
2844 #[test]
2845 fn test_render_effective_gears_config() {
2846 let mut app = create_minimal_app();
2847 add_gear_with_config(
2848 &mut app,
2849 "test_gear",
2850 &serde_json::json!({
2851 "my_setting": "my_value",
2852 "enabled": true
2853 }),
2854 );
2855
2856 let result = render_effective_gears_config(&app).unwrap();
2857
2858 assert!(result.is_object());
2860 let gears = result.as_object().unwrap();
2861 assert!(gears.contains_key("test_gear"));
2862
2863 let test_gear = gears.get("test_gear").unwrap();
2864 assert!(test_gear.is_object());
2865 let test_gear_obj = test_gear.as_object().unwrap();
2866
2867 assert!(test_gear_obj.contains_key("config"));
2869
2870 let config = test_gear_obj.get("config").unwrap();
2872 assert_eq!(config.get("my_setting").unwrap(), "my_value");
2873 assert_eq!(config.get("enabled").unwrap(), true);
2874 }
2875
2876 #[test]
2877 fn test_render_effective_gears_config_with_database() {
2878 let mut app = create_app_with_server(
2879 "test_server",
2880 DbConnConfig {
2881 host: Some("localhost".to_owned()),
2882 port: Some(5432),
2883 user: Some("user".to_owned()),
2884 password: Some(toolkit_utils::SecretString::new("pass")),
2885 dbname: Some("db".to_owned()),
2886 ..Default::default()
2887 },
2888 );
2889
2890 add_gear_to_app(
2892 &mut app,
2893 "test_gear",
2894 &serde_json::json!({
2895 "server": "test_server"
2896 }),
2897 );
2898
2899 let result = render_effective_gears_config(&app).unwrap();
2900 let gears = result.as_object().unwrap();
2901 let test_gear = gears.get("test_gear").unwrap().as_object().unwrap();
2902
2903 assert!(test_gear.contains_key("database"));
2905 let database = test_gear.get("database").unwrap().as_object().unwrap();
2906 assert!(database.contains_key("dsn"));
2907
2908 let dsn = database.get("dsn").unwrap().as_str().unwrap();
2910 assert!(dsn.contains("***REDACTED***"));
2911 assert!(!dsn.contains("pass"));
2912 }
2913
2914 #[test]
2915 fn test_render_effective_gears_config_minimal() {
2916 let mut app = create_minimal_app();
2918
2919 app.gears
2921 .insert("minimal_gear".to_owned(), serde_json::json!({}));
2922
2923 let result = render_effective_gears_config(&app).unwrap();
2924
2925 assert!(result.is_object());
2928 }
2929
2930 #[test]
2931 fn test_dump_effective_gears_config_yaml() {
2932 let mut app = create_minimal_app();
2933 add_gear_with_config(
2934 &mut app,
2935 "test_gear",
2936 &serde_json::json!({
2937 "setting": "value"
2938 }),
2939 );
2940
2941 let yaml = dump_effective_gears_config_yaml(&app).unwrap();
2942
2943 assert!(yaml.contains("test_gear:"));
2945 assert!(yaml.contains("config:"));
2946 assert!(yaml.contains("setting: value"));
2947 }
2948
2949 #[test]
2950 fn test_dump_effective_gears_config_json() {
2951 let mut app = create_minimal_app();
2952 add_gear_with_config(
2953 &mut app,
2954 "test_gear",
2955 &serde_json::json!({
2956 "setting": "value"
2957 }),
2958 );
2959
2960 let json = dump_effective_gears_config_json(&app).unwrap();
2961
2962 assert!(json.contains("\"test_gear\""));
2964 assert!(json.contains("\"config\""));
2965 assert!(json.contains("\"setting\""));
2966 assert!(json.contains("\"value\""));
2967
2968 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
2970 assert!(parsed.is_object());
2971 }
2972
2973 #[test]
2974 fn test_render_multiple_gears() {
2975 let mut app = create_minimal_app();
2976 add_gear_with_config(&mut app, "gear_a", &serde_json::json!({"a": 1}));
2977 add_gear_with_config(&mut app, "gear_b", &serde_json::json!({"b": 2}));
2978 add_gear_with_config(&mut app, "gear_c", &serde_json::json!({"c": 3}));
2979
2980 let result = render_effective_gears_config(&app).unwrap();
2981 let gears = result.as_object().unwrap();
2982
2983 assert_eq!(gears.len(), 3);
2984 assert!(gears.contains_key("gear_a"));
2985 assert!(gears.contains_key("gear_b"));
2986 assert!(gears.contains_key("gear_c"));
2987 }
2988
2989 #[derive(Debug, Deserialize, Default, PartialEq)]
2992 struct TestVendorConfig {
2993 #[serde(default)]
2994 api_token: String,
2995 #[serde(default)]
2996 api_url: String,
2997 }
2998
2999 #[test]
3000 fn test_vendor_section_parses_from_yaml() {
3001 let yaml = r#"
3002server:
3003 home_dir: "~/.test_vendor"
3004vendor:
3005 acme:
3006 api_token: "acme-token-123"
3007 api_url: "https://acme.example.com"
3008 other_corp:
3009 api_token: "other-token-789"
3010 api_url: "https://other.example.com"
3011"#;
3012 let config: AppConfig = serde_saphyr::from_str(yaml).unwrap();
3013 assert_eq!(config.vendor.len(), 2);
3014 assert!(config.vendor.contains_key("acme"));
3015 assert!(config.vendor.contains_key("other_corp"));
3016
3017 let acme: TestVendorConfig = config.vendor_config("acme").unwrap();
3018 assert_eq!(acme.api_token, "acme-token-123");
3019 assert_eq!(acme.api_url, "https://acme.example.com");
3020
3021 let other: TestVendorConfig = config.vendor_config("other_corp").unwrap();
3022 assert_eq!(other.api_token, "other-token-789");
3023 assert_eq!(other.api_url, "https://other.example.com");
3024 }
3025
3026 #[test]
3027 fn test_vendor_section_defaults_to_empty() {
3028 let config = AppConfig::default();
3029 assert!(config.vendor.is_empty());
3030 }
3031
3032 #[test]
3033 fn test_vendor_config_typed_access() {
3034 let mut config = AppConfig::default();
3035 config.vendor.insert(
3036 "acme".to_owned(),
3037 serde_json::json!({
3038 "api_token": "acme-token-123",
3039 "api_url": "https://acme.example.com"
3040 }),
3041 );
3042
3043 let acme: TestVendorConfig = config.vendor_config("acme").unwrap();
3044 assert_eq!(acme.api_token, "acme-token-123");
3045 assert_eq!(acme.api_url, "https://acme.example.com");
3046 }
3047
3048 #[test]
3049 fn test_vendor_config_not_found() {
3050 let config = AppConfig::default();
3051 let result: Result<TestVendorConfig, _> = config.vendor_config("nonexistent");
3052 assert!(matches!(
3053 result,
3054 Err(VendorConfigError::NotFound { ref vendor }) if vendor == "nonexistent"
3055 ));
3056 }
3057
3058 #[test]
3059 fn test_vendor_config_invalid_structure() {
3060 let mut config = AppConfig::default();
3061 config
3062 .vendor
3063 .insert("bad".to_owned(), serde_json::json!("not an object"));
3064
3065 let result: Result<TestVendorConfig, _> = config.vendor_config("bad");
3066 assert!(matches!(
3067 result,
3068 Err(VendorConfigError::InvalidConfig { ref vendor, .. }) if vendor == "bad"
3069 ));
3070 }
3071
3072 #[test]
3073 fn test_vendor_config_or_default_missing() {
3074 let config = AppConfig::default();
3075 let acme: TestVendorConfig = config.vendor_config_or_default("acme").unwrap();
3076 assert_eq!(acme, TestVendorConfig::default());
3077 }
3078
3079 #[test]
3080 fn test_vendor_config_or_default_present() {
3081 let mut config = AppConfig::default();
3082 config.vendor.insert(
3083 "acme".to_owned(),
3084 serde_json::json!({ "api_token": "acme-token-123" }),
3085 );
3086
3087 let acme: TestVendorConfig = config.vendor_config_or_default("acme").unwrap();
3088 assert_eq!(acme.api_token, "acme-token-123");
3089 }
3090
3091 #[test]
3092 #[serial]
3093 fn test_vendor_config_env_override() {
3094 let tmp = tempdir().unwrap();
3095 let cfg_path = tmp.path().join("cfg.yaml");
3096 let yaml = r#"
3097server:
3098 home_dir: "~/.test_vendor"
3099vendor:
3100 env_test_vendor:
3101 api_token: "from_yaml"
3102"#;
3103 fs::write(&cfg_path, yaml).unwrap();
3104
3105 with_var(
3106 "APP__VENDOR__ENV_TEST_VENDOR__API_TOKEN",
3107 Some("from_env"),
3108 || {
3109 let config = AppConfig::load_layered(&cfg_path).unwrap();
3110 let v: TestVendorConfig = config.vendor_config("env_test_vendor").unwrap();
3111 assert_eq!(v.api_token, "from_env");
3112 },
3113 );
3114 }
3115
3116 #[test]
3117 #[serial]
3118 fn test_oop_http_labels_from_yaml_and_env() {
3119 let tmp = tempdir().unwrap();
3120 let cfg_path = tmp.path().join("cfg.yaml");
3121 let yaml = r#"
3122server:
3123 home_dir: "~/.test_oop_labels"
3124oop_http:
3125 listen_addr: "0.0.0.0:8080"
3126 labels:
3127 role: "ingest"
3128"#;
3129 fs::write(&cfg_path, yaml).unwrap();
3130
3131 with_var("APP__OOP_HTTP__LABELS__ZONE", Some("us-east-1"), || {
3134 let config = AppConfig::load_layered(&cfg_path).unwrap();
3135 let oop = config.oop_http.expect("oop_http present");
3136 assert_eq!(oop.labels.get("role"), Some(&"ingest".to_owned()));
3137 assert_eq!(
3138 oop.labels.get("zone"),
3139 Some(&"us-east-1".to_owned()),
3140 "APP__OOP_HTTP__LABELS__ZONE should populate labels[zone]"
3141 );
3142 });
3143
3144 with_var("APP__OOP_HTTP__LABELS__SHARD", Some("7"), || {
3149 let config = AppConfig::load_layered(&cfg_path).unwrap();
3150 let oop = config.oop_http.expect("oop_http present");
3151 assert_eq!(
3152 oop.labels.get("shard"),
3153 Some(&"7".to_owned()),
3154 "a bare-numeric env label value should be coerced to the string \"7\""
3155 );
3156 });
3157 }
3158
3159 #[test]
3160 #[serial]
3161 fn test_gear_config_env_override_underscore_gear_name() {
3162 let tmp = tempdir().unwrap();
3165 let cfg_path = tmp.path().join("cfg.yaml");
3166 let yaml = r#"
3167server:
3168 home_dir: "~/.test_gear_env_underscore"
3169gears:
3170 static-authz-plugin:
3171 config:
3172 vendor: "from_yaml"
3173"#;
3174 fs::write(&cfg_path, yaml).unwrap();
3175
3176 with_var(
3177 "APP__GEARS__STATIC_AUTHZ_PLUGIN__CONFIG__VENDOR",
3178 Some("acme"),
3179 || {
3180 let config = AppConfig::load_layered(&cfg_path).unwrap();
3181 let gear = config.gears.get("static-authz-plugin").unwrap();
3182 assert_eq!(gear["config"]["vendor"], serde_json::json!("acme"));
3183 },
3184 );
3185 }
3186
3187 #[test]
3188 #[serial]
3189 fn test_gear_config_env_override_dash_gear_name_backcompat() {
3190 let tmp = tempdir().unwrap();
3192 let cfg_path = tmp.path().join("cfg.yaml");
3193 let yaml = r#"
3194server:
3195 home_dir: "~/.test_gear_env_dash"
3196gears:
3197 static-authz-plugin:
3198 config:
3199 vendor: "from_yaml"
3200"#;
3201 fs::write(&cfg_path, yaml).unwrap();
3202
3203 with_var(
3204 "APP__GEARS__static-authz-plugin__CONFIG__VENDOR",
3205 Some("acme"),
3206 || {
3207 let config = AppConfig::load_layered(&cfg_path).unwrap();
3208 let gear = config.gears.get("static-authz-plugin").unwrap();
3209 assert_eq!(gear["config"]["vendor"], serde_json::json!("acme"));
3210 },
3211 );
3212 }
3213
3214 #[test]
3215 #[serial]
3216 fn test_gear_config_env_override_preserves_field_underscores() {
3217 let tmp = tempdir().unwrap();
3219 let cfg_path = tmp.path().join("cfg.yaml");
3220 let yaml = r#"
3221server:
3222 home_dir: "~/.test_gear_env_field"
3223gears:
3224 static-authz-plugin:
3225 config:
3226 some_field: "from_yaml"
3227"#;
3228 fs::write(&cfg_path, yaml).unwrap();
3229
3230 with_var(
3231 "APP__GEARS__STATIC_AUTHZ_PLUGIN__CONFIG__SOME_FIELD",
3232 Some("from_env"),
3233 || {
3234 let config = AppConfig::load_layered(&cfg_path).unwrap();
3235 let gear = config.gears.get("static-authz-plugin").unwrap();
3236 assert_eq!(gear["config"]["some_field"], serde_json::json!("from_env"));
3237 },
3238 );
3239 }
3240
3241 #[test]
3242 #[serial]
3243 fn test_vendor_config_env_override_unaffected_by_gear_remap() {
3244 let tmp = tempdir().unwrap();
3249 let cfg_path = tmp.path().join("cfg.yaml");
3250 let yaml = r#"
3251server:
3252 home_dir: "~/.test_vendor_unaffected"
3253vendor:
3254 env_test_vendor:
3255 api_token: "from_yaml"
3256"#;
3257 fs::write(&cfg_path, yaml).unwrap();
3258
3259 with_var(
3260 "APP__VENDOR__ENV_TEST_VENDOR__API_TOKEN",
3261 Some("from_env"),
3262 || {
3263 let config = AppConfig::load_layered(&cfg_path).unwrap();
3264
3265 let v: TestVendorConfig = config.vendor_config("env_test_vendor").unwrap();
3267 assert_eq!(v.api_token, "from_env");
3268
3269 assert!(
3272 config.vendor.contains_key("env_test_vendor"),
3273 "underscore vendor key must be preserved"
3274 );
3275 assert!(
3276 !config.vendor.contains_key("env-test-vendor"),
3277 "gear remap leaked into the vendor branch"
3278 );
3279 },
3280 );
3281 }
3282
3283 #[test]
3284 fn test_vendor_multiple_vendors_typed_access() {
3285 let mut config = AppConfig::default();
3286 config.vendor.insert(
3287 "acme".to_owned(),
3288 serde_json::json!({ "api_token": "acme-token", "api_url": "https://acme.com" }),
3289 );
3290 config.vendor.insert(
3291 "other_corp".to_owned(),
3292 serde_json::json!({ "api_token": "other-token", "api_url": "https://other.com" }),
3293 );
3294
3295 let acme: TestVendorConfig = config.vendor_config("acme").unwrap();
3296 let other: TestVendorConfig = config.vendor_config("other_corp").unwrap();
3297
3298 assert_eq!(acme.api_token, "acme-token");
3299 assert_eq!(other.api_token, "other-token");
3300 assert_eq!(acme.api_url, "https://acme.com");
3301 assert_eq!(other.api_url, "https://other.com");
3302 }
3303
3304 #[test]
3305 fn test_vendor_nested_config() {
3306 #[derive(Debug, Deserialize, PartialEq)]
3307 struct NestedVendorConfig {
3308 api_url: String,
3309 feature_flags: FeatureFlags,
3310 }
3311
3312 #[derive(Debug, Deserialize, PartialEq)]
3313 struct FeatureFlags {
3314 beta_mode: bool,
3315 max_retries: u32,
3316 }
3317
3318 let mut config = AppConfig::default();
3319 config.vendor.insert(
3320 "acme".to_owned(),
3321 serde_json::json!({
3322 "api_url": "https://acme.com",
3323 "feature_flags": {
3324 "beta_mode": true,
3325 "max_retries": 3
3326 }
3327 }),
3328 );
3329
3330 let acme: NestedVendorConfig = config.vendor_config("acme").unwrap();
3331 assert_eq!(acme.api_url, "https://acme.com");
3332 assert!(acme.feature_flags.beta_mode);
3333 assert_eq!(acme.feature_flags.max_retries, 3);
3334 }
3335
3336 #[test]
3337 fn test_vendor_config_or_default_invalid_returns_error() {
3338 let mut config = AppConfig::default();
3339 config
3340 .vendor
3341 .insert("bad".to_owned(), serde_json::json!("not an object"));
3342
3343 let result: Result<TestVendorConfig, _> = config.vendor_config_or_default("bad");
3344 assert!(matches!(
3345 result,
3346 Err(VendorConfigError::InvalidConfig { ref vendor, .. }) if vendor == "bad"
3347 ));
3348 }
3349
3350 #[test]
3351 fn test_vendor_config_yaml_roundtrip() {
3352 let mut config = AppConfig::default();
3353 config.vendor.insert(
3354 "acme".to_owned(),
3355 serde_json::json!({ "api_token": "acme-token-123" }),
3356 );
3357
3358 let yaml = config.to_yaml().unwrap();
3359 assert!(yaml.contains("vendor"));
3360 assert!(yaml.contains("acme"));
3361 assert!(yaml.contains("acme-token-123"));
3362 }
3363
3364 #[test]
3365 fn test_vendor_coexists_with_gears() {
3366 let mut config = AppConfig::default();
3367 config.gears.insert(
3368 "my_gear".to_owned(),
3369 serde_json::json!({ "config": { "some_setting": true } }),
3370 );
3371 config.vendor.insert(
3372 "acme".to_owned(),
3373 serde_json::json!({ "api_token": "acme-token-123" }),
3374 );
3375
3376 assert!(config.gears.contains_key("my_gear"));
3377 assert!(config.vendor.contains_key("acme"));
3378
3379 let acme: TestVendorConfig = config.vendor_config("acme").unwrap();
3380 assert_eq!(acme.api_token, "acme-token-123");
3381 }
3382
3383 #[test]
3384 fn test_vendor_error_display_messages() {
3385 let not_found = VendorConfigError::NotFound {
3386 vendor: "acme".to_owned(),
3387 };
3388 assert_eq!(
3389 not_found.to_string(),
3390 "vendor 'acme' not found in configuration"
3391 );
3392
3393 let invalid = VendorConfigError::InvalidConfig {
3394 vendor: "bad".to_owned(),
3395 cause: serde_json::from_str::<TestVendorConfig>("invalid").unwrap_err(),
3396 };
3397 let msg = invalid.to_string();
3398 assert!(msg.starts_with("invalid config for vendor 'bad':"));
3399 assert!(std::error::Error::source(&invalid).is_none());
3400 }
3401
3402 #[test]
3403 fn test_vendor_empty_object_in_yaml() {
3404 let yaml = r#"
3405server:
3406 home_dir: "~/.test_vendor"
3407vendor: {}
3408"#;
3409 let config: AppConfig = serde_saphyr::from_str(yaml).unwrap();
3410 assert!(config.vendor.is_empty());
3411 }
3412
3413 #[test]
3416 fn test_reject_duplicate_gear_names() {
3417 let tmp = tempdir().unwrap();
3418 let cfg_path = tmp.path().join("cfg.yaml");
3419 let yaml = r#"
3420server:
3421 home_dir: "~/.test_dup"
3422gears:
3423 gear1:
3424 config: {}
3425 gear2:
3426 config: {}
3427 gear1:
3428 config: {}
3429"#;
3430 fs::write(&cfg_path, yaml).unwrap();
3431
3432 let result = AppConfig::load_layered(&cfg_path);
3433 assert!(result.is_err(), "duplicate gear names should be rejected");
3434 let msg = format!("{:?}", result.unwrap_err());
3435 assert!(
3436 msg.contains("duplicate") || msg.contains("Duplicate"),
3437 "error should mention duplicates: {msg}"
3438 );
3439 }
3440
3441 #[test]
3442 fn test_reject_duplicate_keys_in_gear_file() {
3443 let tmp = tempdir().unwrap();
3444 let gears_dir = tmp.path().join("gears.d");
3445 fs::create_dir_all(&gears_dir).unwrap();
3446
3447 let gear_yaml = r#"
3449config:
3450 key1: "value1"
3451config:
3452 key2: "value2"
3453"#;
3454 fs::write(gears_dir.join("bad_gear.yaml"), gear_yaml).unwrap();
3455
3456 let cfg_yaml = format!(
3457 r#"
3458server:
3459 home_dir: "~/.test_dup_modfile"
3460gears_dir: "{}"
3461"#,
3462 normalize_path(&gears_dir)
3463 );
3464 let cfg_path = tmp.path().join("cfg.yaml");
3465 fs::write(&cfg_path, cfg_yaml).unwrap();
3466
3467 let result = AppConfig::load_layered(&cfg_path);
3468 assert!(
3469 result.is_err(),
3470 "duplicate keys in a gear file should be rejected"
3471 );
3472 let msg = format!("{:?}", result.unwrap_err());
3473 assert!(
3474 msg.contains("duplicate") || msg.contains("Duplicate"),
3475 "error should mention duplicates: {msg}"
3476 );
3477 }
3478
3479 #[test]
3480 fn test_no_false_positive_on_unique_gears() {
3481 let tmp = tempdir().unwrap();
3482 let cfg_path = tmp.path().join("cfg.yaml");
3483 let yaml = r#"
3484server:
3485 home_dir: "~/.test_ok"
3486gears:
3487 gear1:
3488 config: {}
3489 gear2:
3490 config: {}
3491 gear3:
3492 config: {}
3493"#;
3494 fs::write(&cfg_path, yaml).unwrap();
3495
3496 let result = AppConfig::load_layered(&cfg_path);
3497 assert!(
3498 result.is_ok(),
3499 "unique gear names should be accepted: {:?}",
3500 result.unwrap_err()
3501 );
3502 }
3503}
3504
3505