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)]
181 pub auth_disabled: bool,
182 #[serde(default)]
189 pub internal_auth: Option<toolkit_security::InternalAuthConfig>,
190 #[serde(default, deserialize_with = "de_labels_scalar_to_string")]
202 pub labels: std::collections::BTreeMap<String, String>,
203}
204
205fn default_drain_timeout_secs() -> u64 {
206 30
207}
208
209fn default_healthcheck_timeout_ms() -> u64 {
210 500
211}
212
213fn de_labels_scalar_to_string<'de, D>(
222 deserializer: D,
223) -> Result<std::collections::BTreeMap<String, String>, D::Error>
224where
225 D: serde::Deserializer<'de>,
226{
227 use serde::Deserialize;
228
229 #[derive(Deserialize)]
232 #[serde(untagged)]
233 enum Scalar {
234 Str(String),
235 Bool(bool),
236 I64(i64),
237 U64(u64),
238 F64(f64),
239 }
240
241 let raw = std::collections::BTreeMap::<String, Scalar>::deserialize(deserializer)?;
242 Ok(raw
243 .into_iter()
244 .map(|(k, v)| {
245 let value = match v {
246 Scalar::Str(s) => s,
247 Scalar::Bool(b) => b.to_string(),
248 Scalar::I64(i) => i.to_string(),
249 Scalar::U64(u) => u.to_string(),
250 Scalar::F64(f) => f.to_string(),
251 };
252 (k, value)
253 })
254 .collect())
255}
256
257impl ConfigProvider for AppConfig {
258 fn get_gear_config(&self, gear_name: &str) -> Option<&serde_json::Value> {
259 self.gears.get(gear_name)
260 }
261}
262
263#[derive(Debug, Clone, Deserialize, Serialize)]
264#[serde(deny_unknown_fields)]
265pub struct ServerConfig {
266 #[serde(default = "default_server_name")]
267 pub name: String,
268 #[serde(default = "default_home_dir")]
269 pub home_dir: PathBuf, }
271
272fn default_server_name() -> String {
273 "cf-gears".to_owned()
274}
275
276fn default_home_dir() -> PathBuf {
277 super::host::paths::default_home_dir().join(".cf-gears")
278}
279
280impl Default for ServerConfig {
281 fn default() -> Self {
282 Self {
283 name: default_server_name(),
284 home_dir: default_home_dir(),
285 }
286 }
287}
288
289impl ServerConfig {
290 fn normalize_home_dir_inplace(&mut self) -> Result<()> {
291 self.home_dir = super::host::normalize_path(
292 self.home_dir
293 .to_str()
294 .context("home directory configuration is not a valid path")?,
295 )
296 .context("home_dir normalization failed")?;
297
298 std::fs::create_dir_all(&self.home_dir).context("Failed to create home_dir")?;
299
300 Ok(())
301 }
302}
303
304#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
306#[serde(rename_all = "lowercase")]
307pub enum ConsoleFormat {
308 #[default]
310 Text,
311 Json,
313}
314
315pub type LoggingConfig = HashMap<String, Section>;
318
319pub type VendorConfig = HashMap<String, serde_json::Value>;
323
324mod optional_level_serde {
326 use serde::{Deserialize, Deserializer, Serializer};
327 use tracing::Level;
328
329 #[allow(clippy::ref_option, clippy::trivially_copy_pass_by_ref)]
330 pub fn serialize<S>(level: &Option<Level>, serializer: S) -> Result<S::Ok, S::Error>
331 where
332 S: Serializer,
333 {
334 match level {
335 Some(l) => serializer.serialize_str(l.as_str()),
336 None => serializer.serialize_str("off"),
337 }
338 }
339
340 pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<Level>, D::Error>
341 where
342 D: Deserializer<'de>,
343 {
344 let s = String::deserialize(deserializer)?;
345 match s.to_lowercase().as_str() {
346 "trace" => Ok(Some(Level::TRACE)),
347 "debug" => Ok(Some(Level::DEBUG)),
348 "info" => Ok(Some(Level::INFO)),
349 "warn" => Ok(Some(Level::WARN)),
350 "error" => Ok(Some(Level::ERROR)),
351 "off" | "none" => Ok(None),
352 _ => Err(serde::de::Error::custom(format!("invalid level: {s}"))),
353 }
354 }
355
356 #[allow(clippy::unnecessary_wraps)]
357 pub fn default() -> Option<Level> {
358 Some(Level::INFO)
359 }
360}
361
362#[derive(Debug, Serialize, Deserialize, Clone)]
363pub struct SectionFile {
364 pub file: String,
365 #[serde(
366 default = "optional_level_serde::default",
367 with = "optional_level_serde"
368 )]
369 pub file_level: Option<Level>,
370}
371
372#[derive(Debug, Serialize, Deserialize, Clone)]
373pub struct Section {
374 #[serde(default)]
375 pub console_format: ConsoleFormat,
376 #[serde(
377 default = "optional_level_serde::default",
378 with = "optional_level_serde"
379 )]
380 pub console_level: Option<Level>,
381 #[serde(flatten)]
382 pub section_file: Option<SectionFile>,
383 pub max_age_days: Option<u32>, #[serde(default)]
385 pub max_backups: Option<usize>, #[serde(default)]
387 pub max_size_mb: Option<u64>, }
389
390impl Section {
391 #[must_use]
392 pub fn file(&self) -> Option<&str> {
393 self.section_file
394 .as_ref()
395 .map(|f| f.file.as_str())
396 .filter(|s| !s.is_empty())
397 }
398
399 #[must_use]
400 pub fn file_level(&self) -> Option<Level> {
401 self.section_file.as_ref().and_then(|f| f.file_level)
402 }
403}
404
405#[must_use]
407pub fn default_logging_config() -> LoggingConfig {
408 let mut logging = HashMap::new();
409 logging.insert(
410 "default".to_owned(),
411 Section {
412 console_level: Some(Level::INFO),
413 section_file: Some(SectionFile {
414 file: "logs/cf-gears.log".to_owned(),
415 file_level: Some(Level::DEBUG),
416 }),
417 console_format: ConsoleFormat::default(),
418 max_age_days: Some(7),
419 max_backups: Some(3),
420 max_size_mb: Some(100),
421 },
422 );
423 logging
424}
425
426pub(crate) fn remap_gear_env_key(key: &str) -> String {
437 let lower = key.to_ascii_lowercase();
439 let mut parts: Vec<&str> = lower.split('.').collect();
440 if parts.first() == Some(&"gears") && parts.len() >= 2 {
441 let gear = parts[1].replace('_', "-");
442 parts[1] = gear.as_str();
443 parts.join(".")
444 } else {
445 lower
446 }
447}
448
449fn reject_legacy_tracing_key(figment: &figment::Figment) -> Result<()> {
466 if figment.find_value("tracing").is_err() {
467 return Ok(());
468 }
469
470 anyhow::bail!(
471 "the top-level `tracing:` section was replaced by `opentelemetry:`; \
472 move the settings across:\n\
473 \n\
474 \x20 tracing.enabled -> opentelemetry.tracing.enabled\n\
475 \x20 tracing.service_name -> opentelemetry.resource.service_name\n\
476 \x20 tracing.resource -> opentelemetry.resource.attributes\n\
477 \x20 tracing.metrics -> opentelemetry.metrics\n\
478 \x20 tracing.exporter -> opentelemetry.exporter (shared) or \
479 opentelemetry.tracing.exporter\n\
480 \x20 tracing.sampler, .logs_correlation -> opentelemetry.tracing.*\n\
481 \x20 tracing.propagation, .http -> (removed; never read by code)\n\
482 \n\
483 Environment overrides use the same path, so APP__TRACING__* becomes \
484 APP__OPENTELEMETRY__*. See docs/TRACING_SETUP.md."
485 );
486}
487
488impl AppConfig {
489 pub fn load_layered(config_path: &PathBuf) -> Result<Self> {
495 use figment::{
496 Figment,
497 providers::{Env, Format, Serialized},
498 };
499
500 let figment = Figment::new()
504 .merge(Serialized::defaults(AppConfig::default()))
505 .merge(StrictYaml::file(config_path))
506 .merge(
508 Env::prefixed("APP__")
509 .split("__")
510 .map(|key| remap_gear_env_key(key.as_str()).into()),
511 );
512
513 reject_legacy_tracing_key(&figment)?;
514
515 let mut config: AppConfig = figment
516 .extract()
517 .with_context(|| "Failed to extract config from figment".to_owned())?;
518
519 config
521 .server
522 .normalize_home_dir_inplace()
523 .context("Failed to resolve server.home_dir")?;
524
525 if let Some(dir) = config.gears_dir.as_ref() {
527 merge_gear_files(&mut config.gears, dir)?;
528 }
529
530 Ok(config)
531 }
532
533 pub fn load_or_default(config_path: Option<&PathBuf>) -> Result<Self> {
539 if let Some(path) = config_path {
540 ensure!(
541 path.is_file(),
542 "config file does not exist: {}",
543 path.to_string_lossy()
544 );
545 Self::load_layered(path)
546 } else {
547 let mut c = Self::default();
548 c.server
549 .normalize_home_dir_inplace()
550 .context("Failed to resolve server.home_dir (defaults)")?;
551 Ok(c)
552 }
553 }
554
555 pub fn to_yaml(&self) -> Result<String> {
560 serde_saphyr::to_string(self).context("Failed to serialize config to YAML")
561 }
562
563 pub fn vendor_config<T: DeserializeOwned>(
569 &self,
570 vendor_name: &str,
571 ) -> Result<T, VendorConfigError> {
572 let raw = self
573 .vendor
574 .get(vendor_name)
575 .ok_or_else(|| VendorConfigError::NotFound {
576 vendor: vendor_name.to_owned(),
577 })?;
578 T::deserialize(raw).map_err(|e| VendorConfigError::InvalidConfig {
579 vendor: vendor_name.to_owned(),
580 cause: e,
581 })
582 }
583
584 pub fn vendor_config_or_default<T: DeserializeOwned + Default>(
590 &self,
591 vendor_name: &str,
592 ) -> Result<T, VendorConfigError> {
593 let Some(raw) = self.vendor.get(vendor_name) else {
594 return Ok(T::default());
595 };
596 T::deserialize(raw).map_err(|e| VendorConfigError::InvalidConfig {
597 vendor: vendor_name.to_owned(),
598 cause: e,
599 })
600 }
601
602 pub fn apply_cli_overrides(&mut self, verbose: u8) {
604 if let Some(default_section) = self.logging.get_mut("default") {
606 default_section.console_level = match verbose {
607 0 => default_section.console_level, 1 => Some(Level::DEBUG),
609 _ => Some(Level::TRACE),
610 };
611 }
612 }
613}
614
615#[derive(Debug, Clone)]
617pub struct CliArgs {
618 pub config: Option<String>,
619 pub print_config: bool,
620 pub verbose: u8,
621 pub mock: bool,
622}
623
624fn strict_yaml_parse<T: serde::de::DeserializeOwned>(s: &str) -> Result<T, serde_saphyr::Error> {
626 let opts = serde_saphyr::options! {
629 duplicate_keys: serde_saphyr::DuplicateKeyPolicy::Error,
630 };
631 serde_saphyr::from_str_with_options(s, opts)
632}
633
634struct StrictYaml;
640
641impl figment::providers::Format for StrictYaml {
642 type Error = serde_saphyr::Error;
643
644 const NAME: &'static str = "YAML";
645
646 fn from_str<T: serde::de::DeserializeOwned>(s: &str) -> Result<T, Self::Error> {
647 strict_yaml_parse(s)
648 }
649}
650
651fn merge_gear_files(
652 bag: &mut HashMap<String, serde_json::Value>,
653 dir: impl AsRef<Path>,
654) -> Result<()> {
655 use std::fs;
656 let dir = dir.as_ref();
657 if !dir.exists() {
658 return Ok(());
659 }
660 for entry in fs::read_dir(dir)? {
661 let entry = entry?;
662 let path = entry.path();
663 if !path.is_file() {
664 continue;
665 }
666 let ext = path
667 .extension()
668 .and_then(|s| s.to_str())
669 .unwrap_or("")
670 .to_ascii_lowercase();
671 if ext != "yml" && ext != "yaml" {
672 continue;
673 }
674 let name = path
675 .file_stem()
676 .and_then(|s| s.to_str())
677 .unwrap_or("")
678 .to_owned();
679 let raw = fs::read_to_string(&path)?;
680 let json: serde_json::Value = strict_yaml_parse(&raw)
681 .with_context(|| format!("failed to parse gear file: {}", path.display()))?;
682 bag.insert(name, json);
683 }
684 Ok(())
685}
686
687pub fn expand_env_in_dsn(dsn: &str) -> Result<String> {
695 toolkit_utils::var_expand::expand_env_vars(dsn).map_err(|e| anyhow::anyhow!("{e}"))
696}
697
698pub fn resolve_password(password: Option<&str>) -> Result<Option<String>> {
703 if let Some(pwd) = password {
704 if pwd.starts_with("${") && pwd.ends_with('}') {
705 let var_name = &pwd[2..pwd.len() - 1];
707 let resolved = std::env::var(var_name).with_context(|| {
708 format!("Environment variable '{var_name}' not found for password")
709 })?;
710 Ok(Some(resolved))
711 } else {
712 Ok(Some(pwd.to_owned()))
714 }
715 } else {
716 Ok(None)
717 }
718}
719
720pub fn validate_dsn(dsn: &str) -> Result<()> {
726 if dsn.starts_with("sqlite:") {
728 return Ok(());
729 }
730
731 let _parsed = dsn::parse(dsn).map_err(|e| anyhow::anyhow!("Invalid DSN '{dsn}': {e}"))?;
732
733 Ok(())
734}
735
736fn resolve_sqlite_dsn(
741 dsn: &str,
742 home_dir: &Path,
743 gear_name: &str,
744 dry_run: bool,
745) -> Result<String> {
746 if dsn.contains("@file(") {
747 if let Some(start) = dsn.find("@file(")
749 && let Some(end) = dsn[start..].find(')')
750 {
751 let file_path = &dsn[start + 6..start + end]; let resolved_path = if file_path.starts_with('/')
754 || (file_path.len() > 1 && file_path.chars().nth(1) == Some(':'))
755 {
756 PathBuf::from(file_path)
758 } else {
759 let gear_dir = home_dir.join(gear_name);
761 if !dry_run {
762 std::fs::create_dir_all(&gear_dir).with_context(|| {
763 format!("Failed to create gear directory: {}", gear_dir.display())
764 })?;
765 }
766 gear_dir.join(file_path)
767 };
768
769 let normalized_path = normalize_path(&resolved_path);
770 if normalized_path.len() > 1 && normalized_path.chars().nth(1) == Some(':') {
773 return Ok(format!("sqlite:{normalized_path}"));
775 }
776 return Ok(format!("sqlite://{normalized_path}"));
778 }
779 return Err(anyhow::anyhow!(
780 "Invalid @file() syntax in SQLite DSN: {dsn}"
781 ));
782 }
783
784 if dsn == "sqlite://" || dsn == "sqlite:///" || dsn == "sqlite:" {
786 let gear_dir = home_dir.join(gear_name);
787 if !dry_run {
788 std::fs::create_dir_all(&gear_dir).with_context(|| {
789 format!("Failed to create gear directory: {}", gear_dir.display())
790 })?;
791 }
792 let db_path = gear_dir.join(format!("{gear_name}.sqlite"));
793 let normalized_path = normalize_path(&db_path);
794 if normalized_path.len() > 1 && normalized_path.chars().nth(1) == Some(':') {
797 return Ok(format!("sqlite:{normalized_path}"));
799 }
800 return Ok(format!("sqlite://{normalized_path}"));
802 }
803
804 Ok(dsn.to_owned())
806}
807
808fn build_server_dsn(
812 scheme: &str,
813 host: Option<&str>,
814 port: Option<u16>,
815 user: Option<&str>,
816 password: Option<&str>,
817 dbname: Option<&str>,
818 params: &HashMap<String, String>,
819) -> Result<String> {
820 let host = host.unwrap_or("localhost");
821 let user = user.unwrap_or("postgres"); let mut url = Url::parse(&format!("{scheme}://dummy/"))
825 .with_context(|| format!("Invalid scheme: {scheme}"))?;
826
827 url.set_host(Some(host))
829 .with_context(|| format!("Invalid host: {host}"))?;
830
831 if let Some(port) = port {
833 url.set_port(Some(port))
834 .map_err(|()| anyhow::anyhow!("Invalid port: {port}"))?;
835 }
836
837 url.set_username(user)
839 .map_err(|()| anyhow::anyhow!("Failed to set username: {user}"))?;
840
841 if let Some(password) = password {
843 url.set_password(Some(password))
844 .map_err(|()| anyhow::anyhow!("Failed to set password"))?;
845 }
846
847 if let Some(dbname) = dbname {
849 let encoded_dbname = urlencoding::encode(dbname);
851 url.set_path(&format!("/{encoded_dbname}"));
852 } else {
853 url.set_path("/");
854 }
855
856 if !params.is_empty() {
858 let mut query_pairs = url.query_pairs_mut();
860 for (key, value) in params {
861 query_pairs.append_pair(key, value);
862 }
863 }
864
865 Ok(url.to_string())
866}
867
868fn build_sqlite_dsn_with_dbname_override(
870 original_dsn: &str,
871 dbname: &str,
872 gear_name: &str,
873 home_dir: &Path,
874 dry_run: bool,
875) -> Result<String> {
876 let query_params = if let Some(query_start) = original_dsn.find('?') {
878 &original_dsn[query_start..]
879 } else {
880 ""
881 };
882
883 let gear_dir = home_dir.join(gear_name);
885 if !dry_run {
886 std::fs::create_dir_all(&gear_dir)
887 .with_context(|| format!("Failed to create gear directory: {}", gear_dir.display()))?;
888 }
889 let db_path = gear_dir.join(dbname);
890 let normalized_path = normalize_path(&db_path);
891
892 let dsn_base = if normalized_path.len() > 1 && normalized_path.chars().nth(1) == Some(':') {
894 format!("sqlite:{normalized_path}")
896 } else {
897 format!("sqlite://{normalized_path}")
899 };
900
901 Ok(format!("{dsn_base}{query_params}"))
902}
903
904fn build_sqlite_dsn(
910 dsn: Option<&str>,
911 file: Option<&str>,
912 path: Option<&PathBuf>,
913 dbname: Option<&str>,
914 gear_name: &str,
915 home_dir: &Path,
916 dry_run: bool,
917) -> Result<String> {
918 if let Some(dsn) = dsn {
920 let resolved_dsn = resolve_sqlite_dsn(dsn, home_dir, gear_name, dry_run)?;
921
922 if let Some(dbname) = dbname {
924 return build_sqlite_dsn_with_dbname_override(
925 &resolved_dsn,
926 dbname,
927 gear_name,
928 home_dir,
929 dry_run,
930 );
931 }
932
933 validate_dsn(&resolved_dsn)?;
934 return Ok(resolved_dsn);
935 }
936
937 if let Some(path) = path {
939 let absolute_path = if path.is_absolute() {
940 path.clone()
941 } else {
942 home_dir.join(path)
943 };
944 let normalized_path = normalize_path(&absolute_path);
945 if normalized_path.len() > 1 && normalized_path.chars().nth(1) == Some(':') {
948 return Ok(format!("sqlite:{normalized_path}"));
950 }
951 return Ok(format!("sqlite://{normalized_path}"));
953 }
954
955 if let Some(file) = file {
957 let gear_dir = home_dir.join(gear_name);
958 if !dry_run {
959 std::fs::create_dir_all(&gear_dir).with_context(|| {
960 format!("Failed to create gear directory: {}", gear_dir.display())
961 })?;
962 }
963 let db_path = gear_dir.join(file);
964 let normalized_path = normalize_path(&db_path);
965 if normalized_path.len() > 1 && normalized_path.chars().nth(1) == Some(':') {
968 return Ok(format!("sqlite:{normalized_path}"));
970 }
971 return Ok(format!("sqlite://{normalized_path}"));
973 }
974
975 let gear_dir = home_dir.join(gear_name);
977 if !dry_run {
978 std::fs::create_dir_all(&gear_dir)
979 .with_context(|| format!("Failed to create gear directory: {}", gear_dir.display()))?;
980 }
981 let db_path = gear_dir.join(format!("{gear_name}.sqlite"));
982 let normalized_path = normalize_path(&db_path);
983 if normalized_path.len() > 1 && normalized_path.chars().nth(1) == Some(':') {
986 Ok(format!("sqlite:{normalized_path}"))
988 } else {
989 Ok(format!("sqlite://{normalized_path}"))
991 }
992}
993
994type DbConfigResult = Result<Option<(String , PoolCfg)>>;
996
997#[derive(Default)]
999struct DbConfigBuilder {
1000 dsn: Option<String>,
1001 host: Option<String>,
1002 port: Option<u16>,
1003 user: Option<String>,
1004 password: Option<String>,
1005 dbname: Option<String>,
1006 params: HashMap<String, String>,
1007 pool: PoolCfg,
1008}
1009
1010impl DbConfigBuilder {
1011 fn new() -> Self {
1012 Self::default()
1013 }
1014
1015 fn apply_global_server(
1017 &mut self,
1018 global_server: &DbConnConfig,
1019 home_dir: &Path,
1020 gear_name: &str,
1021 dry_run: bool,
1022 ) -> Result<()> {
1023 if let Some(global_dsn) = &global_server.dsn {
1025 let expanded_dsn = expand_env_in_dsn(global_dsn.expose())?;
1026 let resolved_dsn = if expanded_dsn.starts_with("sqlite") {
1028 resolve_sqlite_dsn(&expanded_dsn, home_dir, gear_name, dry_run)?
1029 } else {
1030 expanded_dsn
1031 };
1032 validate_dsn(&resolved_dsn)?;
1033 self.dsn = Some(resolved_dsn);
1034 }
1035
1036 if let Some(host) = &global_server.host {
1038 self.host = Some(host.clone());
1039 }
1040 if let Some(port) = global_server.port {
1041 self.port = Some(port);
1042 }
1043 if let Some(user) = &global_server.user {
1044 self.user = Some(user.clone());
1045 }
1046 if let Some(password) = resolve_password(
1047 global_server
1048 .password
1049 .as_ref()
1050 .map(toolkit_utils::SecretString::expose),
1051 )? {
1052 self.password = Some(password);
1053 }
1054 if let Some(dbname) = &global_server.dbname {
1055 self.dbname = Some(dbname.clone());
1056 }
1057 if let Some(params) = &global_server.params {
1058 self.params.extend(params.clone());
1059 }
1060 if let Some(pool) = &global_server.pool {
1061 self.pool = pool.clone();
1062 }
1063
1064 Ok(())
1065 }
1066
1067 fn apply_gear_dsn(
1069 &mut self,
1070 gear_dsn: &str,
1071 home_dir: &Path,
1072 gear_name: &str,
1073 dry_run: bool,
1074 ) -> Result<()> {
1075 let resolved_dsn = if gear_dsn.starts_with("sqlite") {
1077 resolve_sqlite_dsn(gear_dsn, home_dir, gear_name, dry_run)?
1078 } else {
1079 gear_dsn.to_owned()
1080 };
1081 validate_dsn(&resolved_dsn)?;
1082 self.dsn = Some(resolved_dsn);
1083 Ok(())
1084 }
1085
1086 fn apply_gear_fields(&mut self, gear_db_config: &DbConnConfig) -> Result<()> {
1088 if let Some(host) = &gear_db_config.host {
1089 self.host = Some(host.clone());
1090 }
1091 if let Some(port) = gear_db_config.port {
1092 self.port = Some(port);
1093 }
1094 if let Some(user) = &gear_db_config.user {
1095 self.user = Some(user.clone());
1096 }
1097 if let Some(password) = resolve_password(
1098 gear_db_config
1099 .password
1100 .as_ref()
1101 .map(toolkit_utils::SecretString::expose),
1102 )? {
1103 self.password = Some(password);
1104 }
1105 if let Some(dbname) = &gear_db_config.dbname {
1106 self.dbname = Some(dbname.clone());
1107 }
1108 if let Some(params) = &gear_db_config.params {
1109 self.params.extend(params.clone());
1110 }
1111 if let Some(pool) = &gear_db_config.pool {
1112 if let Some(max_conns) = pool.max_conns {
1114 self.pool.max_conns = Some(max_conns);
1115 }
1116 if let Some(acquire_timeout) = pool.acquire_timeout {
1117 self.pool.acquire_timeout = Some(acquire_timeout);
1118 }
1119 }
1120 Ok(())
1121 }
1122
1123 fn has_field_overrides(&self) -> bool {
1125 self.host.is_some()
1126 || self.port.is_some()
1127 || self.user.is_some()
1128 || self.password.is_some()
1129 || !self.params.is_empty()
1130 }
1131}
1132
1133fn decide_backend(builder: &DbConfigBuilder, gear_db_config: &DbConnConfig) -> bool {
1135 gear_db_config.file.is_some()
1138 || gear_db_config.path.is_some()
1139 || builder
1140 .dsn
1141 .as_ref()
1142 .is_some_and(|dsn| dsn.starts_with("sqlite"))
1143 || (gear_db_config.server.is_none() && builder.dsn.is_none())
1144}
1145
1146fn finalize_sqlite_dsn(
1148 builder: &DbConfigBuilder,
1149 gear_db_config: &DbConnConfig,
1150 gear_name: &str,
1151 home_dir: &Path,
1152 dry_run: bool,
1153) -> Result<String> {
1154 build_sqlite_dsn(
1155 builder.dsn.as_deref(),
1156 gear_db_config.file.as_deref(),
1157 gear_db_config.path.as_ref(),
1158 builder.dbname.as_deref(),
1159 gear_name,
1160 home_dir,
1161 dry_run,
1162 )
1163}
1164
1165fn finalize_server_dsn(builder: &DbConfigBuilder, gear_name: &str) -> Result<String> {
1167 let dbname = if let Some(dbname) = builder.dbname.as_deref() {
1169 dbname.to_owned()
1170 } else if let Some(dsn) = builder.dsn.as_ref() {
1171 if let Ok(parsed) = url::Url::parse(dsn) {
1173 let path = parsed.path();
1174 if path.len() > 1 {
1175 path[1..].to_string()
1177 } else {
1178 return Err(anyhow::anyhow!(
1179 "Server-based database config for gear '{gear_name}' missing required 'dbname'"
1180 ));
1181 }
1182 } else {
1183 return Err(anyhow::anyhow!(
1184 "Server-based database config for gear '{gear_name}' missing required 'dbname'"
1185 ));
1186 }
1187 } else {
1188 return Err(anyhow::anyhow!(
1189 "Server-based database config for gear '{gear_name}' missing required 'dbname'"
1190 ));
1191 };
1192
1193 if builder.has_field_overrides() || builder.dsn.is_none() {
1194 let scheme = if let Some(dsn) = &builder.dsn {
1196 let parsed = Url::parse(dsn)?;
1197 parsed.scheme().to_owned()
1198 } else {
1199 "postgresql".to_owned() };
1201
1202 build_server_dsn(
1203 &scheme,
1204 builder.host.as_deref(),
1205 builder.port,
1206 builder.user.as_deref(),
1207 builder.password.as_deref(),
1208 Some(&dbname),
1209 &builder.params,
1210 )
1211 } else if let Some(original_dsn) = &builder.dsn {
1212 if let Ok(mut parsed) = Url::parse(original_dsn) {
1214 let original_dbname = parsed.path().trim_start_matches('/');
1216 if original_dbname != dbname {
1217 parsed.set_path(&format!("/{dbname}"));
1218 }
1219 Ok(parsed.to_string())
1220 } else {
1221 build_server_dsn(
1223 "postgresql",
1224 builder.host.as_deref(),
1225 builder.port,
1226 builder.user.as_deref(),
1227 builder.password.as_deref(),
1228 Some(&dbname),
1229 &builder.params,
1230 )
1231 }
1232 } else {
1233 unreachable!("final_dsn should not be None when has_field_overrides is false")
1235 }
1236}
1237
1238fn redact_dsn_for_logging(dsn: &str) -> Result<String> {
1240 if dsn.contains('@') {
1241 let parsed = Url::parse(dsn)?;
1242 let mut log_url = parsed;
1243 if log_url.password().is_some() {
1244 log_url.set_password(Some("***")).ok();
1245 }
1246 Ok(log_url.to_string())
1247 } else {
1248 Ok(dsn.to_owned())
1249 }
1250}
1251
1252pub const TOOLKIT_MODULE_CONFIG_ENV: &str = "TOOLKIT_MODULE_CONFIG";
1256
1257#[derive(Debug, Clone, Serialize, Deserialize)]
1260pub struct RenderedDbConfig {
1261 #[serde(skip_serializing_if = "Option::is_none")]
1264 pub global: Option<GlobalDatabaseConfig>,
1265 #[serde(skip_serializing_if = "Option::is_none")]
1268 pub gear: Option<DbConnConfig>,
1269}
1270
1271impl RenderedDbConfig {
1272 #[must_use]
1274 pub fn new(global: Option<GlobalDatabaseConfig>, gear: Option<DbConnConfig>) -> Self {
1275 Self { global, gear }
1276 }
1277}
1278
1279#[derive(Debug, Clone, Serialize, Deserialize)]
1289pub struct RenderedGearConfig {
1290 #[serde(skip_serializing_if = "Option::is_none")]
1293 pub database: Option<RenderedDbConfig>,
1294 #[serde(default)]
1296 pub config: serde_json::Value,
1297 #[serde(skip_serializing_if = "Option::is_none")]
1300 pub logging: Option<LoggingConfig>,
1301 #[serde(skip_serializing_if = "Option::is_none")]
1303 pub opentelemetry: Option<OpenTelemetryConfig>,
1304}
1305
1306impl RenderedGearConfig {
1307 pub fn from_json(json: &str) -> Result<Self> {
1312 serde_json::from_str(json).context("Failed to parse RenderedGearConfig from JSON")
1313 }
1314
1315 pub fn to_json(&self) -> Result<String> {
1320 serde_json::to_string(self).context("Failed to serialize RenderedGearConfig to JSON")
1321 }
1322}
1323
1324pub fn render_gear_config_for_oop(
1341 app: &AppConfig,
1342 gear_name: &str,
1343 _home_dir: &std::path::Path,
1344) -> Result<RenderedGearConfig> {
1345 let gear_db_config = parse_gear_config(app, gear_name)
1348 .ok()
1349 .and_then(|entry| entry.database);
1350
1351 let database = if gear_db_config.is_some() || app.database.is_some() {
1353 Some(RenderedDbConfig::new(app.database.clone(), gear_db_config))
1354 } else {
1355 None
1356 };
1357
1358 let config = parse_gear_config(app, gear_name)
1360 .map(|entry| entry.config)
1361 .unwrap_or_default();
1362
1363 let logging = app.logging.clone();
1365
1366 let opentelemetry = if app.opentelemetry.tracing.enabled || app.opentelemetry.metrics.enabled {
1368 Some(app.opentelemetry.clone())
1369 } else {
1370 None
1371 };
1372
1373 Ok(RenderedGearConfig {
1374 database,
1375 config,
1376 logging: Some(logging),
1377 opentelemetry,
1378 })
1379}
1380
1381pub fn parse_gear_config(app: &AppConfig, gear_name: &str) -> Result<GearConfig> {
1386 let gear_raw = app
1387 .gears
1388 .get(gear_name)
1389 .cloned()
1390 .ok_or_else(|| anyhow::anyhow!("Gear '{gear_name}' not found in config"))?;
1391
1392 let gear_config: GearConfig = serde_json::from_value(gear_raw)?;
1393 Ok(gear_config)
1394}
1395
1396pub fn get_gear_runtime_config(app: &AppConfig, gear_name: &str) -> Result<Option<GearRuntime>> {
1401 let entry = parse_gear_config(app, gear_name)?;
1402 Ok(entry.runtime)
1403}
1404
1405pub fn build_final_db_for_gear(
1416 app: &AppConfig,
1417 gear_name: &str,
1418 home_dir: &Path,
1419 dry_run: bool,
1420) -> DbConfigResult {
1421 let Some(gear_raw) = app.gears.get(gear_name) else {
1423 return Ok(None); };
1425
1426 let gear_entry: GearConfig = serde_json::from_value(gear_raw.clone())
1427 .with_context(|| format!("Invalid gear config structure for '{gear_name}'"))?;
1428
1429 let Some(gear_db_config) = gear_entry.database else {
1430 tracing::warn!(
1431 "Gear '{}' has no database configuration; DB capability disabled",
1432 gear_name
1433 );
1434 return Ok(None);
1435 };
1436
1437 let global_db_config = app.database.as_ref();
1439
1440 let mut builder = DbConfigBuilder::new();
1442
1443 if let Some(server_name) = &gear_db_config.server {
1445 let global_server = global_db_config
1446 .and_then(|gc| gc.servers.get(server_name))
1447 .ok_or_else(|| {
1448 anyhow::anyhow!("Referenced server '{server_name}' not found in global config")
1449 })?;
1450
1451 builder.apply_global_server(global_server, home_dir, gear_name, dry_run)?;
1452 }
1453
1454 if let Some(gear_dsn) = &gear_db_config.dsn {
1456 builder.apply_gear_dsn(gear_dsn.expose(), home_dir, gear_name, dry_run)?;
1457 }
1458
1459 builder.apply_gear_fields(&gear_db_config)?;
1461
1462 let is_sqlite = decide_backend(&builder, &gear_db_config);
1464
1465 let result_dsn = if is_sqlite {
1466 finalize_sqlite_dsn(&builder, &gear_db_config, gear_name, home_dir, dry_run)?
1467 } else {
1468 finalize_server_dsn(&builder, gear_name)?
1469 };
1470
1471 validate_dsn(&result_dsn)?;
1473
1474 let log_dsn = redact_dsn_for_logging(&result_dsn)?;
1476
1477 tracing::info!(
1478 "Built final DB config for gear '{}': {}",
1479 gear_name,
1480 log_dsn
1481 );
1482
1483 Ok(Some((result_dsn, builder.pool)))
1484}
1485
1486#[must_use]
1489pub fn get_gear_db_config(app: &AppConfig, gear_name: &str) -> Option<DbConnConfig> {
1490 let gear_raw = app.gears.get(gear_name)?;
1491 let gear_entry: GearConfig = serde_json::from_value(gear_raw.clone()).ok()?;
1492 gear_entry.database
1493}
1494
1495#[must_use]
1498pub fn gear_home(app: &AppConfig, gear_name: &str) -> PathBuf {
1499 PathBuf::from(&app.server.home_dir).join(gear_name)
1500}
1501
1502#[cfg(test)]
1503#[cfg_attr(coverage_nightly, coverage(off))]
1504mod tests {
1505 use super::*;
1506 use serial_test::serial;
1507 use std::fs;
1508 use temp_env::with_var;
1509 use tempfile::tempdir;
1510
1511 fn is_normalized_path(p: &Path) -> bool {
1513 p.is_absolute() && !p.starts_with("~")
1514 }
1515
1516 fn default_subdir() -> &'static str {
1518 ".cf-gears"
1519 }
1520
1521 #[test]
1522 fn test_remap_gear_env_key() {
1523 let cases = [
1526 ("gears.my_gear.port", "gears.my-gear.port"),
1528 ("gears.my_gear.max_age_days", "gears.my-gear.max_age_days"),
1530 ("gears.a_b_c.field", "gears.a-b-c.field"),
1532 ("gears.my-gear.port", "gears.my-gear.port"),
1534 ("vendor.my_vendor.key", "vendor.my_vendor.key"),
1536 ("server.home_dir", "server.home_dir"),
1537 ("gears", "gears"),
1539 ("GEARS.MY_GEAR.PORT", "gears.my-gear.port"),
1541 ("server", "server"),
1543 ];
1544
1545 for (input, expected) in cases {
1546 assert_eq!(
1547 remap_gear_env_key(input),
1548 expected,
1549 "remap_gear_env_key({input:?})"
1550 );
1551 }
1552 }
1553
1554 #[test]
1555 fn test_default_config_structure() {
1556 let config = AppConfig::default();
1557
1558 assert!(config.database.is_none());
1560
1561 let logging = config.logging;
1563 assert!(logging.contains_key("default"));
1564
1565 let default_section = &logging["default"];
1566 assert_eq!(default_section.console_level, Some(Level::INFO));
1567 assert_eq!(default_section.file().unwrap(), "logs/cf-gears.log");
1568
1569 assert!(config.gears.is_empty());
1571 }
1572
1573 #[test]
1574 fn oop_http_labels_coerce_numeric_and_bool_values_to_strings() {
1575 let cfg: OopHttpConfig = serde_json::from_value(serde_json::json!({
1579 "listen_addr": "0.0.0.0:8080",
1580 "labels": {
1581 "shard": 7,
1582 "role": "ingest",
1583 "canary": true,
1584 }
1585 }))
1586 .expect("numeric/bool label values must deserialize");
1587
1588 assert_eq!(cfg.labels.get("shard").map(String::as_str), Some("7"));
1589 assert_eq!(cfg.labels.get("role").map(String::as_str), Some("ingest"));
1590 assert_eq!(cfg.labels.get("canary").map(String::as_str), Some("true"));
1591 }
1592
1593 #[test]
1596 #[serial]
1597 fn test_load_layered_normalizes_home_dir() {
1598 let tmp = tempdir().unwrap();
1599 let cfg_path = tmp.path().join("cfg.yaml");
1600
1601 let yaml = r#"
1603server:
1604 home_dir: "~/.test_cfgears"
1605
1606database:
1607 servers:
1608 test_postgres:
1609 dsn: "postgres://user:pass@localhost/db"
1610 pool:
1611 max_conns: 20
1612
1613logging:
1614 default:
1615 console_level: debug
1616 file: "logs/default.log"
1617"#;
1618 fs::write(&cfg_path, yaml).unwrap();
1619
1620 let config = AppConfig::load_layered(&cfg_path).unwrap();
1621
1622 assert!(is_normalized_path(&config.server.home_dir));
1624 assert!(config.server.home_dir.ends_with(".test_cfgears"));
1625
1626 let logging = &config.logging;
1632 let def = &logging["default"];
1633 assert_eq!(def.console_level, Some(Level::DEBUG));
1634 assert_eq!(def.section_file.as_ref().unwrap().file, "logs/default.log");
1635 }
1636
1637 #[test]
1638 fn test_load_or_default_normalizes_home_dir_when_none() {
1639 let tmp = tempdir().unwrap();
1642 let env_var = if cfg!(target_os = "windows") {
1643 "APPDATA"
1644 } else {
1645 "HOME"
1646 };
1647 with_var(env_var, Some(tmp.path().to_str().unwrap()), || {
1648 let config = AppConfig::load_or_default(None).unwrap();
1649 assert!(is_normalized_path(&config.server.home_dir));
1650 assert!(config.server.home_dir.ends_with(default_subdir()));
1651 });
1652 }
1653
1654 #[test]
1657 #[serial]
1658 fn test_minimal_yaml_config() {
1659 let tmp = tempdir().unwrap();
1660 let cfg_path = tmp.path().join("cfg.yaml");
1661
1662 let yaml = r#"
1663server:
1664 home_dir: "~/.minimal"
1665"#;
1666 fs::write(&cfg_path, yaml).unwrap();
1667
1668 let config = AppConfig::load_layered(&cfg_path).unwrap();
1669
1670 assert!(is_normalized_path(&config.server.home_dir));
1672 assert!(config.server.home_dir.ends_with(".minimal"));
1673
1674 assert!(config.database.is_none());
1676 assert!(config.gears.is_empty());
1677 }
1678
1679 #[test]
1680 fn test_cli_overrides() {
1681 let mut config = AppConfig::default();
1682
1683 let args = CliArgs {
1684 config: None,
1685 print_config: false,
1686 verbose: 2, mock: false,
1688 };
1689
1690 config.apply_cli_overrides(args.verbose);
1691
1692 let logging = &config.logging;
1696 let default_section = &logging["default"];
1697 assert_eq!(default_section.console_level, Some(Level::TRACE));
1698 }
1699
1700 #[test]
1701 fn test_cli_verbose_levels_matrix() {
1702 for (verbose_level, expected_log_level) in [
1703 (0, Some(Level::INFO)), (1, Some(Level::DEBUG)),
1705 (2, Some(Level::TRACE)),
1706 (3, Some(Level::TRACE)), ] {
1708 let mut config = AppConfig::default();
1709 let args = CliArgs {
1710 config: None,
1711 print_config: false,
1712 verbose: verbose_level,
1713 mock: false,
1714 };
1715
1716 config.apply_cli_overrides(args.verbose);
1717
1718 let logging = &config.logging;
1719 let default_section = &logging["default"];
1720
1721 if verbose_level == 0 {
1722 assert_eq!(default_section.console_level, Some(Level::INFO));
1723 } else {
1724 assert_eq!(default_section.console_level, expected_log_level);
1725 }
1726 }
1727 }
1728
1729 #[test]
1732 #[serial]
1733 fn test_layered_config_loading_with_gears_dir() {
1734 let tmp = tempdir().unwrap();
1735 let cfg_path = tmp.path().join("gears_dir.yaml");
1736 let gears_dir = tmp.path().join("gears");
1737
1738 fs::create_dir_all(&gears_dir).unwrap();
1739 let gear_cfg = gears_dir.join("test_gear.yaml");
1740 fs::write(
1741 &gear_cfg,
1742 r#"
1743setting1: "value1"
1744setting2: 42
1745"#,
1746 )
1747 .unwrap();
1748
1749 let gears_dir_str = normalize_path(&gears_dir);
1751 let yaml = format!(
1752 r#"
1753server:
1754 home_dir: "~/.gears_test"
1755
1756gears_dir: "{gears_dir_str}"
1757
1758gears:
1759 existing_gear:
1760 key: "value"
1761"#
1762 );
1763
1764 fs::write(&cfg_path, yaml).unwrap();
1765
1766 let config = AppConfig::load_layered(&cfg_path).unwrap();
1767
1768 assert!(config.gears.contains_key("existing_gear"));
1770
1771 assert!(config.gears.contains_key("test_gear"));
1773
1774 let test_gear = &config.gears["test_gear"];
1776 assert_eq!(test_gear["setting1"], "value1");
1777 assert_eq!(test_gear["setting2"], 42);
1778 }
1779
1780 #[test]
1783 #[serial]
1784 fn test_load_and_init_logging_smoke() {
1785 let tmp = tempdir().unwrap();
1787 let cfg_path = tmp.path().join("logging.yaml");
1788 let yaml = r#"
1789server:
1790 home_dir: "~/.logging_test"
1791
1792logging:
1793 default:
1794 console_level: debug
1795 file: ""
1796 file_level: info
1797"#;
1798 fs::write(&cfg_path, yaml).unwrap();
1799
1800 let config = AppConfig::load_layered(&cfg_path).unwrap();
1801 let logging = &config.logging;
1802 assert!(logging.contains_key("default"));
1803
1804 let default_section = &logging["default"];
1805 assert_eq!(default_section.console_level, Some(Level::DEBUG));
1806 assert_eq!(default_section.file_level(), Some(Level::INFO));
1807 }
1809
1810 fn create_app_with_server(server_name: &str, db_config: DbConnConfig) -> AppConfig {
1814 let mut servers = HashMap::new();
1815 servers.insert(server_name.to_owned(), db_config);
1816
1817 AppConfig {
1818 database: Some(GlobalDatabaseConfig {
1819 servers,
1820 auto_provision: None,
1821 }),
1822 ..Default::default()
1823 }
1824 }
1825
1826 fn add_gear_to_app(app: &mut AppConfig, gear_name: &str, database_config: &serde_json::Value) {
1828 app.gears.insert(
1829 gear_name.to_owned(),
1830 serde_json::json!({
1831 "database": database_config,
1832 "config": {}
1833 }),
1834 );
1835 }
1836
1837 fn add_gear_with_config(app: &mut AppConfig, gear_name: &str, config: &serde_json::Value) {
1839 app.gears.insert(
1840 gear_name.to_owned(),
1841 serde_json::json!({
1842 "database": {},
1843 "config": config
1844 }),
1845 );
1846 }
1847
1848 fn create_minimal_app() -> AppConfig {
1850 AppConfig {
1851 database: None,
1852 gears: HashMap::new(),
1853 ..Default::default()
1854 }
1855 }
1856
1857 #[test]
1858 fn test_precedence_global_dsn_only() {
1859 let tmp = tempdir().unwrap();
1860 let home_dir = tmp.path();
1861
1862 let mut app = create_app_with_server(
1863 "test_server",
1864 DbConnConfig {
1865 dsn: Some(toolkit_utils::SecretString::new(
1866 "postgresql://global_user:global_pass@global_host:5432/global_db",
1867 )),
1868 ..Default::default()
1869 },
1870 );
1871
1872 add_gear_to_app(
1874 &mut app,
1875 "test_gear",
1876 &serde_json::json!({
1877 "server": "test_server"
1878 }),
1879 );
1880
1881 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
1882 assert!(result.is_some());
1883
1884 let (dsn, _pool) = result.unwrap();
1885 assert!(dsn.contains("global_user"));
1886 assert!(dsn.contains("global_host"));
1887 assert!(dsn.contains("global_db"));
1888 }
1889
1890 #[test]
1891 fn test_precedence_global_fields_only() {
1892 let tmp = tempdir().unwrap();
1893 let home_dir = tmp.path();
1894
1895 let mut app = create_app_with_server(
1896 "test_server",
1897 DbConnConfig {
1898 host: Some("field_host".to_owned()),
1899 port: Some(5433),
1900 user: Some("field_user".to_owned()),
1901 dbname: Some("field_db".to_owned()),
1902 ..Default::default()
1903 },
1904 );
1905
1906 add_gear_to_app(
1908 &mut app,
1909 "test_gear",
1910 &serde_json::json!({
1911 "server": "test_server"
1912 }),
1913 );
1914
1915 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
1916 assert!(result.is_some());
1917
1918 let (dsn, _pool) = result.unwrap();
1919 assert!(dsn.contains("field_host"));
1920 assert!(dsn.contains("5433"));
1921 assert!(dsn.contains("field_user"));
1922 assert!(dsn.contains("field_db"));
1923 }
1924
1925 #[test]
1926 fn test_precedence_gear_dsn_only() {
1927 let tmp = tempdir().unwrap();
1928 let home_dir = tmp.path();
1929
1930 let app = AppConfig {
1931 gears: {
1932 let mut gears = HashMap::new();
1933 gears.insert(
1934 "test_gear".to_owned(),
1935 serde_json::json!({
1936 "database": {
1937 "dsn": "sqlite://gear_test.db?wal=true&synchronous=NORMAL"
1938 },
1939 "config": {}
1940 }),
1941 );
1942 gears
1943 },
1944 ..Default::default()
1945 };
1946
1947 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
1948 assert!(result.is_some());
1949
1950 let (dsn, _pool) = result.unwrap();
1951 assert!(dsn.contains("gear_test.db"));
1952 assert!(dsn.contains("wal=true"));
1953 }
1954
1955 #[test]
1956 fn test_precedence_gear_fields_only() {
1957 let tmp = tempdir().unwrap();
1958 let home_dir = tmp.path();
1959
1960 let app = AppConfig {
1961 gears: {
1962 let mut gears = HashMap::new();
1963 gears.insert(
1964 "test_gear".to_owned(),
1965 serde_json::json!({
1966 "database": {
1967 "file": "gear_fields.db"
1968 },
1969 "config": {}
1970 }),
1971 );
1972 gears
1973 },
1974 ..Default::default()
1975 };
1976
1977 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
1978 assert!(result.is_some());
1979
1980 let (dsn, _pool) = result.unwrap();
1981 assert!(dsn.contains("gear_fields.db"));
1982 #[cfg(windows)]
1984 assert!(dsn.starts_with("sqlite:") && !dsn.starts_with("sqlite://"));
1985 #[cfg(unix)]
1986 assert!(dsn.starts_with("sqlite://"));
1987 }
1988
1989 #[test]
1990 fn test_precedence_fields_override_dsn() {
1991 let tmp = tempdir().unwrap();
1992 let home_dir = tmp.path();
1993
1994 let mut app = create_app_with_server(
1995 "test_server",
1996 DbConnConfig {
1997 dsn: Some(toolkit_utils::SecretString::new(
1998 "postgresql://old_user:old_pass@old_host:5432/old_db",
1999 )),
2000 host: Some("new_host".to_owned()), port: Some(5433), user: Some("new_user".to_owned()), dbname: Some("new_db".to_owned()), ..Default::default()
2005 },
2006 );
2007
2008 add_gear_to_app(
2010 &mut app,
2011 "test_gear",
2012 &serde_json::json!({
2013 "server": "test_server",
2014 "port": 5434 }),
2016 );
2017
2018 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2019 assert!(result.is_some());
2020
2021 let (dsn, _pool) = result.unwrap();
2022 assert!(dsn.contains("new_host"));
2024 assert!(dsn.contains("5434")); assert!(dsn.contains("new_user"));
2026 assert!(dsn.contains("new_db"));
2027 assert!(!dsn.contains("old_host"));
2029 assert!(!dsn.contains("5432"));
2030 assert!(!dsn.contains("old_user"));
2031 assert!(!dsn.contains("old_db"));
2032 }
2033
2034 #[test]
2035 fn test_env_expansion_password() {
2036 let tmp = tempdir().unwrap();
2037 let home_dir = tmp.path();
2038
2039 with_var("TEST_DB_PASSWORD", Some("secret123"), || {
2040 let mut app = create_app_with_server(
2041 "test_server",
2042 DbConnConfig {
2043 host: Some("localhost".to_owned()),
2044 port: Some(5432),
2045 user: Some("testuser".to_owned()),
2046 password: Some(toolkit_utils::SecretString::new("${TEST_DB_PASSWORD}")), dbname: Some("testdb".to_owned()),
2048 ..Default::default()
2049 },
2050 );
2051
2052 add_gear_to_app(
2053 &mut app,
2054 "test_gear",
2055 &serde_json::json!({
2056 "server": "test_server"
2057 }),
2058 );
2059
2060 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2061 assert!(result.is_some());
2062
2063 let (dsn, _pool) = result.unwrap();
2064 assert!(dsn.contains("secret123"));
2065 });
2066 }
2067
2068 #[test]
2069 fn test_env_expansion_in_dsn() {
2070 let tmp = tempdir().unwrap();
2071 let home_dir = tmp.path();
2072
2073 temp_env::with_vars(
2074 [
2075 ("DB_HOST", Some("test-server")),
2076 ("DB_PASSWORD", Some("env_secret")),
2077 ],
2078 || {
2079 let mut app = create_app_with_server(
2080 "test_server",
2081 DbConnConfig {
2082 dsn: Some(toolkit_utils::SecretString::new(
2083 "postgresql://user:${DB_PASSWORD}@${DB_HOST}:5432/mydb",
2084 )),
2085 ..Default::default()
2086 },
2087 );
2088
2089 add_gear_to_app(
2090 &mut app,
2091 "test_gear",
2092 &serde_json::json!({
2093 "server": "test_server"
2094 }),
2095 );
2096
2097 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2098 assert!(result.is_some());
2099
2100 let (dsn, _pool) = result.unwrap();
2101 assert!(dsn.contains("test-server"));
2102 assert!(dsn.contains("env_secret"));
2103 assert!(!dsn.contains("${DB_HOST}"));
2105 assert!(!dsn.contains("${DB_PASSWORD}"));
2106 },
2107 );
2108 }
2109
2110 #[test]
2111 fn test_sqlite_file_path_resolution() {
2112 let tmp = tempdir().unwrap();
2113 let home_dir = tmp.path();
2114
2115 let app1 = AppConfig {
2117 gears: {
2118 let mut gears = HashMap::new();
2119 gears.insert(
2120 "test_gear".to_owned(),
2121 serde_json::json!({
2122 "database": {
2123 "file": "test.db"
2124 },
2125 "config": {}
2126 }),
2127 );
2128 gears
2129 },
2130 ..Default::default()
2131 };
2132
2133 let result1 = build_final_db_for_gear(&app1, "test_gear", home_dir, false).unwrap();
2134 assert!(result1.is_some());
2135 let (dsn1, _) = result1.unwrap();
2136 assert!(dsn1.contains("test_gear"));
2137 assert!(dsn1.contains("test.db"));
2138
2139 let abs_path = tmp.path().join("absolute.db");
2141 let app2 = AppConfig {
2142 gears: {
2143 let mut gears = HashMap::new();
2144 gears.insert(
2145 "test_gear".to_owned(),
2146 serde_json::json!({
2147 "database": {
2148 "path": abs_path.to_string_lossy()
2149 },
2150 "config": {}
2151 }),
2152 );
2153 gears
2154 },
2155 ..Default::default()
2156 };
2157
2158 let result2 = build_final_db_for_gear(&app2, "test_gear", home_dir, false).unwrap();
2159 assert!(result2.is_some());
2160 let (dsn2, _) = result2.unwrap();
2161 assert!(dsn2.contains("absolute.db"));
2162
2163 let app3 = AppConfig {
2165 gears: {
2166 let mut gears = HashMap::new();
2167 gears.insert(
2168 "test_gear".to_owned(),
2169 serde_json::json!({
2170 "database": {},
2171 "config": {}
2172 }),
2173 );
2174 gears
2175 },
2176 ..Default::default()
2177 };
2178
2179 let result3 = build_final_db_for_gear(&app3, "test_gear", home_dir, false).unwrap();
2180 assert!(result3.is_some());
2181 let (dsn3, _) = result3.unwrap();
2182 assert!(dsn3.contains("test_gear.sqlite"));
2183 }
2184
2185 #[cfg(windows)]
2186 #[test]
2187 fn test_sqlite_path_resolution_windows() {
2188 let tmp = tempdir().unwrap();
2189 let home_dir = tmp.path();
2190
2191 let app = AppConfig {
2192 gears: {
2193 let mut gears = HashMap::new();
2194 gears.insert(
2195 "test_gear".to_owned(),
2196 serde_json::json!({
2197 "database": {
2198 "file": "test.db"
2199 },
2200 "config": {}
2201 }),
2202 );
2203 gears
2204 },
2205 ..Default::default()
2206 };
2207
2208 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2209 assert!(result.is_some());
2210 let (dsn, _) = result.unwrap();
2211
2212 assert!(!dsn.contains('\\'));
2214 assert!(dsn.contains('/'));
2215 }
2216
2217 #[test]
2218 fn test_sqlite_dsn_with_server_reference_and_dbname_override() {
2219 let tmp = tempdir().unwrap();
2220 let home_dir = tmp.path();
2221
2222 let mut app = AppConfig::default();
2223
2224 let mut servers = HashMap::new();
2226 servers.insert(
2227 "sqlite_users".to_owned(),
2228 DbConnConfig {
2229 engine: None,
2230 dsn: Some(toolkit_utils::SecretString::new(
2231 "sqlite://users_info.db?WAL=true&synchronous=NORMAL&busy_timeout=5000",
2232 )),
2233 host: None,
2234 port: None,
2235 user: None,
2236 password: None,
2237 dbname: None,
2238 params: None,
2239 pool: None,
2240 file: None,
2241 path: None,
2242 lock_keepalive: None,
2243 server: None,
2244 },
2245 );
2246
2247 app.database = Some(GlobalDatabaseConfig {
2248 servers,
2249 auto_provision: None,
2250 });
2251
2252 app.gears.insert(
2254 "users_info".to_owned(),
2255 serde_json::json!({
2256 "database": {
2257 "server": "sqlite_users",
2258 "dbname": "users_info.db"
2259 },
2260 "config": {}
2261 }),
2262 );
2263
2264 let result = build_final_db_for_gear(&app, "users_info", home_dir, false).unwrap();
2265 assert!(result.is_some());
2266 let (dsn, _) = result.unwrap();
2267
2268 assert!(dsn.contains("?WAL=true&synchronous=NORMAL&busy_timeout=5000"));
2270 assert!(dsn.contains("users_info/users_info.db"));
2271
2272 #[cfg(windows)]
2274 {
2275 assert!(dsn.starts_with("sqlite:"));
2277 assert!(!dsn.starts_with("sqlite://"));
2278 }
2279
2280 #[cfg(unix)]
2281 {
2282 assert!(dsn.starts_with("sqlite://"));
2284 }
2285 }
2286
2287 #[cfg(unix)]
2288 #[test]
2289 fn test_sqlite_path_resolution_unix() {
2290 let tmp = tempdir().unwrap();
2291 let home_dir = tmp.path();
2292
2293 let app = AppConfig {
2294 gears: {
2295 let mut gears = HashMap::new();
2296 gears.insert(
2297 "test_gear".to_owned(),
2298 serde_json::json!({
2299 "database": {
2300 "file": "test.db"
2301 },
2302 "config": {}
2303 }),
2304 );
2305 gears
2306 },
2307 ..Default::default()
2308 };
2309
2310 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2311 assert!(result.is_some());
2312 let (dsn, _) = result.unwrap();
2313
2314 assert!(dsn.starts_with("sqlite://"));
2316 assert!(dsn.contains("/test_gear/test.db"));
2317 }
2318
2319 #[test]
2320 fn test_server_based_db_missing_dbname_error() {
2321 let tmp = tempdir().unwrap();
2322 let home_dir = tmp.path();
2323
2324 let mut app = create_app_with_server(
2325 "test_server",
2326 DbConnConfig {
2327 host: Some("localhost".to_owned()),
2328 port: Some(5432),
2329 user: Some("testuser".to_owned()),
2330 ..Default::default()
2332 },
2333 );
2334
2335 add_gear_to_app(
2336 &mut app,
2337 "test_gear",
2338 &serde_json::json!({
2339 "server": "test_server"
2340 }),
2341 );
2342
2343 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false);
2344 assert!(result.is_err());
2345 let error_msg = result.unwrap_err().to_string();
2346 assert!(error_msg.contains("missing required 'dbname'"));
2347 }
2348
2349 #[test]
2350 fn test_gear_no_database_config() {
2351 let tmp = tempdir().unwrap();
2352 let home_dir = tmp.path();
2353
2354 let app = AppConfig {
2356 gears: {
2357 let mut gears = HashMap::new();
2358 gears.insert(
2359 "no_db_gear".to_owned(),
2360 serde_json::json!({
2361 "config": {
2362 "some_setting": "value"
2363 }
2364 }),
2365 );
2366 gears
2367 },
2368 ..Default::default()
2369 };
2370
2371 let result = build_final_db_for_gear(&app, "no_db_gear", home_dir, false).unwrap();
2372 assert!(result.is_none());
2373 }
2374
2375 #[test]
2376 fn test_gear_empty_database_config() {
2377 let tmp = tempdir().unwrap();
2378 let home_dir = tmp.path();
2379
2380 let app = AppConfig {
2382 gears: {
2383 let mut gears = HashMap::new();
2384 gears.insert(
2385 "empty_db_gear".to_owned(),
2386 serde_json::json!({
2387 "database": null,
2388 "config": {}
2389 }),
2390 );
2391 gears
2392 },
2393 ..Default::default()
2394 };
2395
2396 let result = build_final_db_for_gear(&app, "empty_db_gear", home_dir, false).unwrap();
2397 assert!(result.is_none());
2398 }
2399
2400 #[test]
2401 fn test_referenced_server_not_found() {
2402 let tmp = tempdir().unwrap();
2403 let home_dir = tmp.path();
2404
2405 let app = AppConfig {
2406 gears: {
2407 let mut gears = HashMap::new();
2408 gears.insert(
2409 "test_gear".to_owned(),
2410 serde_json::json!({
2411 "database": {
2412 "server": "nonexistent_server"
2413 },
2414 "config": {}
2415 }),
2416 );
2417 gears
2418 },
2419 ..Default::default()
2420 };
2421
2422 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false);
2423 assert!(result.is_err());
2424 let error_msg = result.unwrap_err().to_string();
2425 assert!(error_msg.contains("Referenced server 'nonexistent_server' not found"));
2426 }
2427
2428 #[test]
2429 fn test_dsn_validation_invalid_url() {
2430 let tmp = tempdir().unwrap();
2431 let home_dir = tmp.path();
2432
2433 let app = AppConfig {
2434 gears: {
2435 let mut gears = HashMap::new();
2436 gears.insert(
2437 "test_gear".to_owned(),
2438 serde_json::json!({
2439 "database": {
2440 "dsn": "invalid://not-a-valid[url"
2441 },
2442 "config": {}
2443 }),
2444 );
2445 gears
2446 },
2447 ..Default::default()
2448 };
2449
2450 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false);
2451 assert!(result.is_err());
2452 }
2453
2454 #[test]
2455 fn test_env_variable_not_found() {
2456 let tmp = tempdir().unwrap();
2457 let home_dir = tmp.path();
2458
2459 with_var("NONEXISTENT_PASSWORD", None::<&str>, || {
2461 let mut app = create_app_with_server(
2462 "test_server",
2463 DbConnConfig {
2464 host: Some("localhost".to_owned()),
2465 password: Some(toolkit_utils::SecretString::new("${NONEXISTENT_PASSWORD}")),
2466 dbname: Some("testdb".to_owned()),
2467 ..Default::default()
2468 },
2469 );
2470
2471 add_gear_to_app(
2472 &mut app,
2473 "test_gear",
2474 &serde_json::json!({
2475 "server": "test_server"
2476 }),
2477 );
2478
2479 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false);
2480 assert!(result.is_err());
2481 let error_msg = result.unwrap_err().to_string();
2482 assert!(error_msg.contains("NONEXISTENT_PASSWORD"));
2483 });
2484 }
2485
2486 #[test]
2487 fn test_sqlite_at_file_relative_path() {
2488 let tmp = tempdir().unwrap();
2489 let home_dir = tmp.path();
2490
2491 let app = AppConfig {
2492 gears: {
2493 let mut gears = HashMap::new();
2494 gears.insert(
2495 "test_gear".to_owned(),
2496 serde_json::json!({
2497 "database": {
2498 "dsn": "sqlite://@file(users.db)"
2499 },
2500 "config": {}
2501 }),
2502 );
2503 gears
2504 },
2505 ..Default::default()
2506 };
2507
2508 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2509 assert!(result.is_some());
2510
2511 let (dsn, _pool) = result.unwrap();
2512 assert!(dsn.contains("test_gear"));
2513 assert!(dsn.contains("users.db"));
2514 #[cfg(windows)]
2516 assert!(dsn.starts_with("sqlite:") && !dsn.starts_with("sqlite://"));
2517 #[cfg(unix)]
2518 assert!(dsn.starts_with("sqlite:///"));
2519 }
2520
2521 #[test]
2522 fn test_sqlite_at_file_absolute_path() {
2523 let tmp = tempdir().unwrap();
2524 let home_dir = tmp.path();
2525 let abs_path = tmp.path().join("absolute_db.sqlite");
2526
2527 let app = AppConfig {
2528 gears: {
2529 let mut gears = HashMap::new();
2530 gears.insert(
2531 "test_gear".to_owned(),
2532 serde_json::json!({
2533 "database": {
2534 "dsn": format!("sqlite://@file({})", abs_path.to_string_lossy())
2535 },
2536 "config": {}
2537 }),
2538 );
2539 gears
2540 },
2541 ..Default::default()
2542 };
2543
2544 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2545 assert!(result.is_some());
2546
2547 let (dsn, _pool) = result.unwrap();
2548 assert!(dsn.contains("absolute_db.sqlite"));
2549 #[cfg(windows)]
2551 assert!(dsn.starts_with("sqlite:") && !dsn.starts_with("sqlite://"));
2552 #[cfg(unix)]
2553 assert!(dsn.starts_with("sqlite:///"));
2554 }
2555
2556 #[test]
2557 fn test_sqlite_empty_dsn_default() {
2558 let tmp = tempdir().unwrap();
2559 let home_dir = tmp.path();
2560
2561 let app = AppConfig {
2562 gears: {
2563 let mut gears = HashMap::new();
2564 gears.insert(
2565 "test_gear".to_owned(),
2566 serde_json::json!({
2567 "database": {
2568 "dsn": "sqlite://"
2569 },
2570 "config": {}
2571 }),
2572 );
2573 gears
2574 },
2575 ..Default::default()
2576 };
2577
2578 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2579 assert!(result.is_some());
2580
2581 let (dsn, _pool) = result.unwrap();
2582 assert!(dsn.contains("test_gear"));
2583 assert!(dsn.contains("test_gear.sqlite"));
2584 #[cfg(windows)]
2586 assert!(dsn.starts_with("sqlite:") && !dsn.starts_with("sqlite://"));
2587 #[cfg(unix)]
2588 assert!(dsn.starts_with("sqlite:///"));
2589 }
2590
2591 #[test]
2592 fn test_sqlite_at_file_invalid_syntax() {
2593 let tmp = tempdir().unwrap();
2594 let home_dir = tmp.path();
2595
2596 let app = AppConfig {
2597 gears: {
2598 let mut gears = HashMap::new();
2599 gears.insert(
2600 "test_gear".to_owned(),
2601 serde_json::json!({
2602 "database": {
2603 "dsn": "sqlite://@file(missing_closing_paren"
2604 },
2605 "config": {}
2606 }),
2607 );
2608 gears
2609 },
2610 ..Default::default()
2611 };
2612
2613 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false);
2614 assert!(result.is_err());
2615 let error_msg = result.unwrap_err().to_string();
2616 assert!(error_msg.contains("Invalid @file() syntax"));
2617 }
2618
2619 #[test]
2620 fn test_dsn_special_characters_in_credentials() {
2621 let tmp = tempdir().unwrap();
2622 let home_dir = tmp.path();
2623
2624 let mut app = create_app_with_server(
2626 "test_server",
2627 DbConnConfig {
2628 host: Some("localhost".to_owned()),
2629 port: Some(5432),
2630 user: Some("user@domain".to_owned()),
2631 password: Some(toolkit_utils::SecretString::new(
2632 "pa@ss:w0rd/with%special&chars",
2633 )),
2634 dbname: Some("test/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("user%40domain")); assert!(dsn.contains("/test%2Fdb")); validate_dsn(&dsn).expect("DSN with special characters should be valid");
2659
2660 let parsed_dsn = dsn::parse(&dsn).expect("DSN should be parseable");
2662 assert_eq!(parsed_dsn.username.as_deref(), Some("user@domain"));
2663 assert_eq!(
2664 parsed_dsn.password.as_deref(),
2665 Some("pa@ss:w0rd/with%special&chars")
2666 );
2667 }
2670
2671 #[test]
2672 #[allow(clippy::non_ascii_literal)]
2673 fn test_dsn_unicode_characters() {
2674 let tmp = tempdir().unwrap();
2675 let home_dir = tmp.path();
2676
2677 let mut app = create_app_with_server(
2679 "test_server",
2680 DbConnConfig {
2681 host: Some("localhost".to_owned()),
2682 user: Some("ユーザー".to_owned()), dbname: Some("unicode_db".to_owned()),
2684 ..Default::default()
2685 },
2686 );
2687
2688 add_gear_to_app(
2689 &mut app,
2690 "test_gear",
2691 &serde_json::json!({
2692 "server": "test_server"
2693 }),
2694 );
2695
2696 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2697 assert!(result.is_some());
2698
2699 let (dsn, _pool) = result.unwrap();
2700
2701 assert!(dsn.starts_with("postgresql://"));
2703 assert!(dsn.contains('%')); validate_dsn(&dsn).expect("DSN with Unicode characters should be valid");
2708 }
2709
2710 #[test]
2711 fn test_dsn_query_parameters_encoding() {
2712 let tmp = tempdir().unwrap();
2713 let home_dir = tmp.path();
2714
2715 let mut params = HashMap::new();
2716 params.insert("ssl mode".to_owned(), "require & verify".to_owned());
2717 params.insert("application_name".to_owned(), "my-app/v1.0".to_owned());
2718
2719 let mut app = create_app_with_server(
2720 "test_server",
2721 DbConnConfig {
2722 host: Some("localhost".to_owned()),
2723 user: Some("testuser".to_owned()),
2724 dbname: Some("testdb".to_owned()),
2725 params: Some(params),
2726 ..Default::default()
2727 },
2728 );
2729
2730 add_gear_to_app(
2731 &mut app,
2732 "test_gear",
2733 &serde_json::json!({
2734 "server": "test_server"
2735 }),
2736 );
2737
2738 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2739 assert!(result.is_some());
2740
2741 let (dsn, _pool) = result.unwrap();
2742
2743 assert!(dsn.contains("ssl+mode=require+%26+verify"));
2745 assert!(dsn.contains("application_name=my-app%2Fv1.0"));
2746
2747 validate_dsn(&dsn).expect("DSN with encoded query parameters should be valid");
2749 }
2750
2751 #[test]
2752 fn test_pool_config_merging() {
2753 use std::time::Duration;
2754
2755 let tmp = tempdir().unwrap();
2756 let home_dir = tmp.path();
2757
2758 let mut app = create_app_with_server(
2760 "test_server",
2761 DbConnConfig {
2762 host: Some("localhost".to_owned()),
2763 dbname: Some("testdb".to_owned()),
2764 pool: Some(PoolCfg {
2765 max_conns: Some(10),
2766 min_conns: None,
2767 acquire_timeout: Some(Duration::from_secs(5)),
2768 idle_timeout: None,
2769 max_lifetime: None,
2770 test_before_acquire: None,
2771 }),
2772 ..Default::default()
2773 },
2774 );
2775
2776 add_gear_to_app(
2778 &mut app,
2779 "test_gear",
2780 &serde_json::json!({
2781 "server": "test_server",
2782 "pool": {
2783 "max_conns": 20
2784 }
2785 }),
2786 );
2787
2788 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2789 assert!(result.is_some());
2790
2791 let (_dsn, pool) = result.unwrap();
2792 assert_eq!(pool.max_conns, Some(20)); assert_eq!(pool.acquire_timeout, Some(Duration::from_secs(5))); }
2795
2796 #[test]
2797 fn test_pool_config_gear_overrides_all() {
2798 use std::time::Duration;
2799
2800 let tmp = tempdir().unwrap();
2801 let home_dir = tmp.path();
2802
2803 let mut app = create_app_with_server(
2805 "test_server",
2806 DbConnConfig {
2807 host: Some("localhost".to_owned()),
2808 dbname: Some("testdb".to_owned()),
2809 pool: Some(PoolCfg {
2810 max_conns: Some(10),
2811 min_conns: None,
2812 acquire_timeout: Some(Duration::from_secs(5)),
2813 idle_timeout: None,
2814 max_lifetime: None,
2815 test_before_acquire: None,
2816 }),
2817 ..Default::default()
2818 },
2819 );
2820
2821 add_gear_to_app(
2823 &mut app,
2824 "test_gear",
2825 &serde_json::json!({
2826 "server": "test_server",
2827 "pool": {
2828 "max_conns": 30,
2829 "acquire_timeout": "10s"
2830 }
2831 }),
2832 );
2833
2834 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2835 assert!(result.is_some());
2836
2837 let (_dsn, pool) = result.unwrap();
2838 assert_eq!(pool.max_conns, Some(30));
2839 assert_eq!(pool.acquire_timeout, Some(Duration::from_secs(10)));
2840 }
2841
2842 #[test]
2843 fn test_list_gear_names() {
2844 let mut app = create_minimal_app();
2845 add_gear_with_config(&mut app, "zebra_gear", &serde_json::json!({}));
2846 add_gear_with_config(&mut app, "alpha_gear", &serde_json::json!({}));
2847 add_gear_with_config(&mut app, "beta_gear", &serde_json::json!({}));
2848
2849 let gear_names = list_gear_names(&app);
2850
2851 assert_eq!(gear_names.len(), 3);
2853 assert_eq!(gear_names[0], "alpha_gear");
2854 assert_eq!(gear_names[1], "beta_gear");
2855 assert_eq!(gear_names[2], "zebra_gear");
2856 }
2857
2858 #[test]
2859 fn test_list_gear_names_empty() {
2860 let app = create_minimal_app();
2861 let gear_names = list_gear_names(&app);
2862 assert_eq!(gear_names.len(), 0);
2863 }
2864
2865 #[test]
2866 fn test_redact_dsn_password_postgres() {
2867 let dsn = "postgres://user:secretpass@localhost:5432/mydb";
2868 let redacted = redact_dsn_password(dsn).unwrap();
2869 assert_eq!(
2870 redacted,
2871 "postgres://user:***REDACTED***@localhost:5432/mydb"
2872 );
2873 }
2874
2875 #[test]
2876 fn test_redact_dsn_password_no_password() {
2877 let dsn = "postgres://user@localhost:5432/mydb";
2878 let redacted = redact_dsn_password(dsn).unwrap();
2879 assert_eq!(redacted, "postgres://user@localhost:5432/mydb");
2881 }
2882
2883 #[test]
2884 fn test_redact_dsn_password_special_chars() {
2885 let dsn = "postgres://user:p@ss%40word@localhost:5432/mydb";
2886 let redacted = redact_dsn_password(dsn).unwrap();
2887 assert_eq!(
2888 redacted,
2889 "postgres://user:***REDACTED***@localhost:5432/mydb"
2890 );
2891 }
2892
2893 #[test]
2894 fn test_render_effective_gears_config() {
2895 let mut app = create_minimal_app();
2896 add_gear_with_config(
2897 &mut app,
2898 "test_gear",
2899 &serde_json::json!({
2900 "my_setting": "my_value",
2901 "enabled": true
2902 }),
2903 );
2904
2905 let result = render_effective_gears_config(&app).unwrap();
2906
2907 assert!(result.is_object());
2909 let gears = result.as_object().unwrap();
2910 assert!(gears.contains_key("test_gear"));
2911
2912 let test_gear = gears.get("test_gear").unwrap();
2913 assert!(test_gear.is_object());
2914 let test_gear_obj = test_gear.as_object().unwrap();
2915
2916 assert!(test_gear_obj.contains_key("config"));
2918
2919 let config = test_gear_obj.get("config").unwrap();
2921 assert_eq!(config.get("my_setting").unwrap(), "my_value");
2922 assert_eq!(config.get("enabled").unwrap(), true);
2923 }
2924
2925 #[test]
2926 fn test_render_effective_gears_config_with_database() {
2927 let mut app = create_app_with_server(
2928 "test_server",
2929 DbConnConfig {
2930 host: Some("localhost".to_owned()),
2931 port: Some(5432),
2932 user: Some("user".to_owned()),
2933 password: Some(toolkit_utils::SecretString::new("pass")),
2934 dbname: Some("db".to_owned()),
2935 ..Default::default()
2936 },
2937 );
2938
2939 add_gear_to_app(
2941 &mut app,
2942 "test_gear",
2943 &serde_json::json!({
2944 "server": "test_server"
2945 }),
2946 );
2947
2948 let result = render_effective_gears_config(&app).unwrap();
2949 let gears = result.as_object().unwrap();
2950 let test_gear = gears.get("test_gear").unwrap().as_object().unwrap();
2951
2952 assert!(test_gear.contains_key("database"));
2954 let database = test_gear.get("database").unwrap().as_object().unwrap();
2955 assert!(database.contains_key("dsn"));
2956
2957 let dsn = database.get("dsn").unwrap().as_str().unwrap();
2959 assert!(dsn.contains("***REDACTED***"));
2960 assert!(!dsn.contains("pass"));
2961 }
2962
2963 #[test]
2964 fn test_render_effective_gears_config_minimal() {
2965 let mut app = create_minimal_app();
2967
2968 app.gears
2970 .insert("minimal_gear".to_owned(), serde_json::json!({}));
2971
2972 let result = render_effective_gears_config(&app).unwrap();
2973
2974 assert!(result.is_object());
2977 }
2978
2979 #[test]
2980 fn test_dump_effective_gears_config_yaml() {
2981 let mut app = create_minimal_app();
2982 add_gear_with_config(
2983 &mut app,
2984 "test_gear",
2985 &serde_json::json!({
2986 "setting": "value"
2987 }),
2988 );
2989
2990 let yaml = dump_effective_gears_config_yaml(&app).unwrap();
2991
2992 assert!(yaml.contains("test_gear:"));
2994 assert!(yaml.contains("config:"));
2995 assert!(yaml.contains("setting: value"));
2996 }
2997
2998 #[test]
2999 fn test_dump_effective_gears_config_json() {
3000 let mut app = create_minimal_app();
3001 add_gear_with_config(
3002 &mut app,
3003 "test_gear",
3004 &serde_json::json!({
3005 "setting": "value"
3006 }),
3007 );
3008
3009 let json = dump_effective_gears_config_json(&app).unwrap();
3010
3011 assert!(json.contains("\"test_gear\""));
3013 assert!(json.contains("\"config\""));
3014 assert!(json.contains("\"setting\""));
3015 assert!(json.contains("\"value\""));
3016
3017 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
3019 assert!(parsed.is_object());
3020 }
3021
3022 #[test]
3023 fn test_render_multiple_gears() {
3024 let mut app = create_minimal_app();
3025 add_gear_with_config(&mut app, "gear_a", &serde_json::json!({"a": 1}));
3026 add_gear_with_config(&mut app, "gear_b", &serde_json::json!({"b": 2}));
3027 add_gear_with_config(&mut app, "gear_c", &serde_json::json!({"c": 3}));
3028
3029 let result = render_effective_gears_config(&app).unwrap();
3030 let gears = result.as_object().unwrap();
3031
3032 assert_eq!(gears.len(), 3);
3033 assert!(gears.contains_key("gear_a"));
3034 assert!(gears.contains_key("gear_b"));
3035 assert!(gears.contains_key("gear_c"));
3036 }
3037
3038 #[derive(Debug, Deserialize, Default, PartialEq)]
3041 struct TestVendorConfig {
3042 #[serde(default)]
3043 api_token: String,
3044 #[serde(default)]
3045 api_url: String,
3046 }
3047
3048 #[test]
3049 fn test_vendor_section_parses_from_yaml() {
3050 let yaml = r#"
3051server:
3052 home_dir: "~/.test_vendor"
3053vendor:
3054 acme:
3055 api_token: "acme-token-123"
3056 api_url: "https://acme.example.com"
3057 other_corp:
3058 api_token: "other-token-789"
3059 api_url: "https://other.example.com"
3060"#;
3061 let config: AppConfig = serde_saphyr::from_str(yaml).unwrap();
3062 assert_eq!(config.vendor.len(), 2);
3063 assert!(config.vendor.contains_key("acme"));
3064 assert!(config.vendor.contains_key("other_corp"));
3065
3066 let acme: TestVendorConfig = config.vendor_config("acme").unwrap();
3067 assert_eq!(acme.api_token, "acme-token-123");
3068 assert_eq!(acme.api_url, "https://acme.example.com");
3069
3070 let other: TestVendorConfig = config.vendor_config("other_corp").unwrap();
3071 assert_eq!(other.api_token, "other-token-789");
3072 assert_eq!(other.api_url, "https://other.example.com");
3073 }
3074
3075 #[test]
3076 fn test_vendor_section_defaults_to_empty() {
3077 let config = AppConfig::default();
3078 assert!(config.vendor.is_empty());
3079 }
3080
3081 #[test]
3082 fn test_vendor_config_typed_access() {
3083 let mut config = AppConfig::default();
3084 config.vendor.insert(
3085 "acme".to_owned(),
3086 serde_json::json!({
3087 "api_token": "acme-token-123",
3088 "api_url": "https://acme.example.com"
3089 }),
3090 );
3091
3092 let acme: TestVendorConfig = config.vendor_config("acme").unwrap();
3093 assert_eq!(acme.api_token, "acme-token-123");
3094 assert_eq!(acme.api_url, "https://acme.example.com");
3095 }
3096
3097 #[test]
3098 fn test_vendor_config_not_found() {
3099 let config = AppConfig::default();
3100 let result: Result<TestVendorConfig, _> = config.vendor_config("nonexistent");
3101 assert!(matches!(
3102 result,
3103 Err(VendorConfigError::NotFound { ref vendor }) if vendor == "nonexistent"
3104 ));
3105 }
3106
3107 #[test]
3108 fn test_vendor_config_invalid_structure() {
3109 let mut config = AppConfig::default();
3110 config
3111 .vendor
3112 .insert("bad".to_owned(), serde_json::json!("not an object"));
3113
3114 let result: Result<TestVendorConfig, _> = config.vendor_config("bad");
3115 assert!(matches!(
3116 result,
3117 Err(VendorConfigError::InvalidConfig { ref vendor, .. }) if vendor == "bad"
3118 ));
3119 }
3120
3121 #[test]
3122 fn test_vendor_config_or_default_missing() {
3123 let config = AppConfig::default();
3124 let acme: TestVendorConfig = config.vendor_config_or_default("acme").unwrap();
3125 assert_eq!(acme, TestVendorConfig::default());
3126 }
3127
3128 #[test]
3129 fn test_vendor_config_or_default_present() {
3130 let mut config = AppConfig::default();
3131 config.vendor.insert(
3132 "acme".to_owned(),
3133 serde_json::json!({ "api_token": "acme-token-123" }),
3134 );
3135
3136 let acme: TestVendorConfig = config.vendor_config_or_default("acme").unwrap();
3137 assert_eq!(acme.api_token, "acme-token-123");
3138 }
3139
3140 #[test]
3141 #[serial]
3142 fn test_vendor_config_env_override() {
3143 let tmp = tempdir().unwrap();
3144 let cfg_path = tmp.path().join("cfg.yaml");
3145 let yaml = r#"
3146server:
3147 home_dir: "~/.test_vendor"
3148vendor:
3149 env_test_vendor:
3150 api_token: "from_yaml"
3151"#;
3152 fs::write(&cfg_path, yaml).unwrap();
3153
3154 with_var(
3155 "APP__VENDOR__ENV_TEST_VENDOR__API_TOKEN",
3156 Some("from_env"),
3157 || {
3158 let config = AppConfig::load_layered(&cfg_path).unwrap();
3159 let v: TestVendorConfig = config.vendor_config("env_test_vendor").unwrap();
3160 assert_eq!(v.api_token, "from_env");
3161 },
3162 );
3163 }
3164
3165 #[test]
3166 #[serial]
3167 fn test_oop_http_labels_from_yaml_and_env() {
3168 let tmp = tempdir().unwrap();
3169 let cfg_path = tmp.path().join("cfg.yaml");
3170 let yaml = r#"
3171server:
3172 home_dir: "~/.test_oop_labels"
3173oop_http:
3174 listen_addr: "0.0.0.0:8080"
3175 labels:
3176 role: "ingest"
3177"#;
3178 fs::write(&cfg_path, yaml).unwrap();
3179
3180 with_var("APP__OOP_HTTP__LABELS__ZONE", Some("us-east-1"), || {
3183 let config = AppConfig::load_layered(&cfg_path).unwrap();
3184 let oop = config.oop_http.expect("oop_http present");
3185 assert_eq!(oop.labels.get("role"), Some(&"ingest".to_owned()));
3186 assert_eq!(
3187 oop.labels.get("zone"),
3188 Some(&"us-east-1".to_owned()),
3189 "APP__OOP_HTTP__LABELS__ZONE should populate labels[zone]"
3190 );
3191 });
3192
3193 with_var("APP__OOP_HTTP__LABELS__SHARD", Some("7"), || {
3198 let config = AppConfig::load_layered(&cfg_path).unwrap();
3199 let oop = config.oop_http.expect("oop_http present");
3200 assert_eq!(
3201 oop.labels.get("shard"),
3202 Some(&"7".to_owned()),
3203 "a bare-numeric env label value should be coerced to the string \"7\""
3204 );
3205 });
3206 }
3207
3208 #[test]
3209 #[serial]
3210 fn test_gear_config_env_override_underscore_gear_name() {
3211 let tmp = tempdir().unwrap();
3214 let cfg_path = tmp.path().join("cfg.yaml");
3215 let yaml = r#"
3216server:
3217 home_dir: "~/.test_gear_env_underscore"
3218gears:
3219 static-authz-plugin:
3220 config:
3221 vendor: "from_yaml"
3222"#;
3223 fs::write(&cfg_path, yaml).unwrap();
3224
3225 with_var(
3226 "APP__GEARS__STATIC_AUTHZ_PLUGIN__CONFIG__VENDOR",
3227 Some("acme"),
3228 || {
3229 let config = AppConfig::load_layered(&cfg_path).unwrap();
3230 let gear = config.gears.get("static-authz-plugin").unwrap();
3231 assert_eq!(gear["config"]["vendor"], serde_json::json!("acme"));
3232 },
3233 );
3234 }
3235
3236 #[test]
3237 #[serial]
3238 fn test_gear_config_env_override_dash_gear_name_backcompat() {
3239 let tmp = tempdir().unwrap();
3241 let cfg_path = tmp.path().join("cfg.yaml");
3242 let yaml = r#"
3243server:
3244 home_dir: "~/.test_gear_env_dash"
3245gears:
3246 static-authz-plugin:
3247 config:
3248 vendor: "from_yaml"
3249"#;
3250 fs::write(&cfg_path, yaml).unwrap();
3251
3252 with_var(
3253 "APP__GEARS__static-authz-plugin__CONFIG__VENDOR",
3254 Some("acme"),
3255 || {
3256 let config = AppConfig::load_layered(&cfg_path).unwrap();
3257 let gear = config.gears.get("static-authz-plugin").unwrap();
3258 assert_eq!(gear["config"]["vendor"], serde_json::json!("acme"));
3259 },
3260 );
3261 }
3262
3263 #[test]
3264 #[serial]
3265 fn test_gear_config_env_override_preserves_field_underscores() {
3266 let tmp = tempdir().unwrap();
3268 let cfg_path = tmp.path().join("cfg.yaml");
3269 let yaml = r#"
3270server:
3271 home_dir: "~/.test_gear_env_field"
3272gears:
3273 static-authz-plugin:
3274 config:
3275 some_field: "from_yaml"
3276"#;
3277 fs::write(&cfg_path, yaml).unwrap();
3278
3279 with_var(
3280 "APP__GEARS__STATIC_AUTHZ_PLUGIN__CONFIG__SOME_FIELD",
3281 Some("from_env"),
3282 || {
3283 let config = AppConfig::load_layered(&cfg_path).unwrap();
3284 let gear = config.gears.get("static-authz-plugin").unwrap();
3285 assert_eq!(gear["config"]["some_field"], serde_json::json!("from_env"));
3286 },
3287 );
3288 }
3289
3290 #[test]
3291 #[serial]
3292 fn test_vendor_config_env_override_unaffected_by_gear_remap() {
3293 let tmp = tempdir().unwrap();
3298 let cfg_path = tmp.path().join("cfg.yaml");
3299 let yaml = r#"
3300server:
3301 home_dir: "~/.test_vendor_unaffected"
3302vendor:
3303 env_test_vendor:
3304 api_token: "from_yaml"
3305"#;
3306 fs::write(&cfg_path, yaml).unwrap();
3307
3308 with_var(
3309 "APP__VENDOR__ENV_TEST_VENDOR__API_TOKEN",
3310 Some("from_env"),
3311 || {
3312 let config = AppConfig::load_layered(&cfg_path).unwrap();
3313
3314 let v: TestVendorConfig = config.vendor_config("env_test_vendor").unwrap();
3316 assert_eq!(v.api_token, "from_env");
3317
3318 assert!(
3321 config.vendor.contains_key("env_test_vendor"),
3322 "underscore vendor key must be preserved"
3323 );
3324 assert!(
3325 !config.vendor.contains_key("env-test-vendor"),
3326 "gear remap leaked into the vendor branch"
3327 );
3328 },
3329 );
3330 }
3331
3332 #[test]
3333 fn test_vendor_multiple_vendors_typed_access() {
3334 let mut config = AppConfig::default();
3335 config.vendor.insert(
3336 "acme".to_owned(),
3337 serde_json::json!({ "api_token": "acme-token", "api_url": "https://acme.com" }),
3338 );
3339 config.vendor.insert(
3340 "other_corp".to_owned(),
3341 serde_json::json!({ "api_token": "other-token", "api_url": "https://other.com" }),
3342 );
3343
3344 let acme: TestVendorConfig = config.vendor_config("acme").unwrap();
3345 let other: TestVendorConfig = config.vendor_config("other_corp").unwrap();
3346
3347 assert_eq!(acme.api_token, "acme-token");
3348 assert_eq!(other.api_token, "other-token");
3349 assert_eq!(acme.api_url, "https://acme.com");
3350 assert_eq!(other.api_url, "https://other.com");
3351 }
3352
3353 #[test]
3354 fn test_vendor_nested_config() {
3355 #[derive(Debug, Deserialize, PartialEq)]
3356 struct NestedVendorConfig {
3357 api_url: String,
3358 feature_flags: FeatureFlags,
3359 }
3360
3361 #[derive(Debug, Deserialize, PartialEq)]
3362 struct FeatureFlags {
3363 beta_mode: bool,
3364 max_retries: u32,
3365 }
3366
3367 let mut config = AppConfig::default();
3368 config.vendor.insert(
3369 "acme".to_owned(),
3370 serde_json::json!({
3371 "api_url": "https://acme.com",
3372 "feature_flags": {
3373 "beta_mode": true,
3374 "max_retries": 3
3375 }
3376 }),
3377 );
3378
3379 let acme: NestedVendorConfig = config.vendor_config("acme").unwrap();
3380 assert_eq!(acme.api_url, "https://acme.com");
3381 assert!(acme.feature_flags.beta_mode);
3382 assert_eq!(acme.feature_flags.max_retries, 3);
3383 }
3384
3385 #[test]
3386 fn test_vendor_config_or_default_invalid_returns_error() {
3387 let mut config = AppConfig::default();
3388 config
3389 .vendor
3390 .insert("bad".to_owned(), serde_json::json!("not an object"));
3391
3392 let result: Result<TestVendorConfig, _> = config.vendor_config_or_default("bad");
3393 assert!(matches!(
3394 result,
3395 Err(VendorConfigError::InvalidConfig { ref vendor, .. }) if vendor == "bad"
3396 ));
3397 }
3398
3399 #[test]
3400 fn test_vendor_config_yaml_roundtrip() {
3401 let mut config = AppConfig::default();
3402 config.vendor.insert(
3403 "acme".to_owned(),
3404 serde_json::json!({ "api_token": "acme-token-123" }),
3405 );
3406
3407 let yaml = config.to_yaml().unwrap();
3408 assert!(yaml.contains("vendor"));
3409 assert!(yaml.contains("acme"));
3410 assert!(yaml.contains("acme-token-123"));
3411 }
3412
3413 #[test]
3414 fn test_vendor_coexists_with_gears() {
3415 let mut config = AppConfig::default();
3416 config.gears.insert(
3417 "my_gear".to_owned(),
3418 serde_json::json!({ "config": { "some_setting": true } }),
3419 );
3420 config.vendor.insert(
3421 "acme".to_owned(),
3422 serde_json::json!({ "api_token": "acme-token-123" }),
3423 );
3424
3425 assert!(config.gears.contains_key("my_gear"));
3426 assert!(config.vendor.contains_key("acme"));
3427
3428 let acme: TestVendorConfig = config.vendor_config("acme").unwrap();
3429 assert_eq!(acme.api_token, "acme-token-123");
3430 }
3431
3432 #[test]
3433 fn test_vendor_error_display_messages() {
3434 let not_found = VendorConfigError::NotFound {
3435 vendor: "acme".to_owned(),
3436 };
3437 assert_eq!(
3438 not_found.to_string(),
3439 "vendor 'acme' not found in configuration"
3440 );
3441
3442 let invalid = VendorConfigError::InvalidConfig {
3443 vendor: "bad".to_owned(),
3444 cause: serde_json::from_str::<TestVendorConfig>("invalid").unwrap_err(),
3445 };
3446 let msg = invalid.to_string();
3447 assert!(msg.starts_with("invalid config for vendor 'bad':"));
3448 assert!(std::error::Error::source(&invalid).is_none());
3449 }
3450
3451 #[test]
3452 fn test_vendor_empty_object_in_yaml() {
3453 let yaml = r#"
3454server:
3455 home_dir: "~/.test_vendor"
3456vendor: {}
3457"#;
3458 let config: AppConfig = serde_saphyr::from_str(yaml).unwrap();
3459 assert!(config.vendor.is_empty());
3460 }
3461
3462 #[test]
3470 #[serial]
3471 fn test_legacy_tracing_section_reports_migration() {
3472 let tmp = tempdir().unwrap();
3473 let cfg_path = tmp.path().join("cfg.yaml");
3474 let yaml = r#"
3475server:
3476 home_dir: "~/.test_legacy_tracing"
3477tracing:
3478 enabled: true
3479 service_name: "cf-gears-api"
3480 exporter:
3481 kind: "otlp_grpc"
3482 endpoint: "http://127.0.0.1:4317"
3483"#;
3484 fs::write(&cfg_path, yaml).unwrap();
3485
3486 let result = AppConfig::load_layered(&cfg_path);
3487 assert!(result.is_err(), "legacy `tracing:` should be rejected");
3488 let msg = format!("{:?}", result.unwrap_err());
3489
3490 for expected in [
3491 "opentelemetry",
3492 "opentelemetry.resource.service_name",
3493 "docs/TRACING_SETUP.md",
3494 ] {
3495 assert!(msg.contains(expected), "missing {expected:?} in: {msg}");
3496 }
3497 }
3498
3499 #[test]
3502 #[serial]
3503 fn test_nested_opentelemetry_tracing_is_accepted() {
3504 let tmp = tempdir().unwrap();
3505 let cfg_path = tmp.path().join("cfg.yaml");
3506 let yaml = r#"
3507server:
3508 home_dir: "~/.test_nested_tracing"
3509opentelemetry:
3510 resource:
3511 service_name: "cf-gears-api"
3512 tracing:
3513 enabled: true
3514 metrics:
3515 enabled: false
3516"#;
3517 fs::write(&cfg_path, yaml).unwrap();
3518
3519 let config = AppConfig::load_layered(&cfg_path).expect("nested form should load");
3520 assert!(config.opentelemetry.tracing.enabled);
3521 assert_eq!(config.opentelemetry.resource.service_name, "cf-gears-api");
3522 }
3523
3524 #[test]
3530 #[serial]
3531 fn test_legacy_tracing_env_override_reports_migration() {
3532 let tmp = tempdir().unwrap();
3533 let cfg_path = tmp.path().join("cfg.yaml");
3534 let yaml = r#"
3535server:
3536 home_dir: "~/.test_legacy_tracing_env"
3537opentelemetry:
3538 resource:
3539 service_name: "cf-gears-api"
3540"#;
3541 fs::write(&cfg_path, yaml).unwrap();
3542
3543 with_var("APP__TRACING__ENABLED", Some("true"), || {
3544 let result = AppConfig::load_layered(&cfg_path);
3545 assert!(
3546 result.is_err(),
3547 "legacy `APP__TRACING__*` override should be rejected"
3548 );
3549 let msg = format!("{:?}", result.unwrap_err());
3550 assert!(
3551 msg.contains("opentelemetry"),
3552 "missing migration hint in: {msg}"
3553 );
3554 });
3555 }
3556
3557 #[test]
3563 #[serial]
3564 fn test_reject_duplicate_gear_names() {
3565 let tmp = tempdir().unwrap();
3566 let cfg_path = tmp.path().join("cfg.yaml");
3567 let yaml = r#"
3568server:
3569 home_dir: "~/.test_dup"
3570gears:
3571 gear1:
3572 config: {}
3573 gear2:
3574 config: {}
3575 gear1:
3576 config: {}
3577"#;
3578 fs::write(&cfg_path, yaml).unwrap();
3579
3580 let result = AppConfig::load_layered(&cfg_path);
3581 assert!(result.is_err(), "duplicate gear names should be rejected");
3582 let msg = format!("{:?}", result.unwrap_err());
3583 assert!(
3584 msg.contains("duplicate") || msg.contains("Duplicate"),
3585 "error should mention duplicates: {msg}"
3586 );
3587 }
3588
3589 #[test]
3590 #[serial]
3591 fn test_reject_duplicate_keys_in_gear_file() {
3592 let tmp = tempdir().unwrap();
3593 let gears_dir = tmp.path().join("gears.d");
3594 fs::create_dir_all(&gears_dir).unwrap();
3595
3596 let gear_yaml = r#"
3598config:
3599 key1: "value1"
3600config:
3601 key2: "value2"
3602"#;
3603 fs::write(gears_dir.join("bad_gear.yaml"), gear_yaml).unwrap();
3604
3605 let cfg_yaml = format!(
3606 r#"
3607server:
3608 home_dir: "~/.test_dup_modfile"
3609gears_dir: "{}"
3610"#,
3611 normalize_path(&gears_dir)
3612 );
3613 let cfg_path = tmp.path().join("cfg.yaml");
3614 fs::write(&cfg_path, cfg_yaml).unwrap();
3615
3616 let result = AppConfig::load_layered(&cfg_path);
3617 assert!(
3618 result.is_err(),
3619 "duplicate keys in a gear file should be rejected"
3620 );
3621 let msg = format!("{:?}", result.unwrap_err());
3622 assert!(
3623 msg.contains("duplicate") || msg.contains("Duplicate"),
3624 "error should mention duplicates: {msg}"
3625 );
3626 }
3627
3628 #[test]
3629 #[serial]
3630 fn test_no_false_positive_on_unique_gears() {
3631 let tmp = tempdir().unwrap();
3632 let cfg_path = tmp.path().join("cfg.yaml");
3633 let yaml = r#"
3634server:
3635 home_dir: "~/.test_ok"
3636gears:
3637 gear1:
3638 config: {}
3639 gear2:
3640 config: {}
3641 gear3:
3642 config: {}
3643"#;
3644 fs::write(&cfg_path, yaml).unwrap();
3645
3646 let result = AppConfig::load_layered(&cfg_path);
3647 assert!(
3648 result.is_ok(),
3649 "unique gear names should be accepted: {:?}",
3650 result.unwrap_err()
3651 );
3652 }
3653}
3654
3655