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
442fn reject_legacy_tracing_key(figment: &figment::Figment) -> Result<()> {
459 if figment.find_value("tracing").is_err() {
460 return Ok(());
461 }
462
463 anyhow::bail!(
464 "the top-level `tracing:` section was replaced by `opentelemetry:`; \
465 move the settings across:\n\
466 \n\
467 \x20 tracing.enabled -> opentelemetry.tracing.enabled\n\
468 \x20 tracing.service_name -> opentelemetry.resource.service_name\n\
469 \x20 tracing.resource -> opentelemetry.resource.attributes\n\
470 \x20 tracing.metrics -> opentelemetry.metrics\n\
471 \x20 tracing.exporter -> opentelemetry.exporter (shared) or \
472 opentelemetry.tracing.exporter\n\
473 \x20 tracing.sampler, .propagation, .http, .logs_correlation \
474 -> opentelemetry.tracing.*\n\
475 \n\
476 Environment overrides use the same path, so APP__TRACING__* becomes \
477 APP__OPENTELEMETRY__*. See docs/TRACING_SETUP.md."
478 );
479}
480
481impl AppConfig {
482 pub fn load_layered(config_path: &PathBuf) -> Result<Self> {
488 use figment::{
489 Figment,
490 providers::{Env, Format, Serialized},
491 };
492
493 let figment = Figment::new()
497 .merge(Serialized::defaults(AppConfig::default()))
498 .merge(StrictYaml::file(config_path))
499 .merge(
501 Env::prefixed("APP__")
502 .split("__")
503 .map(|key| remap_gear_env_key(key.as_str()).into()),
504 );
505
506 reject_legacy_tracing_key(&figment)?;
507
508 let mut config: AppConfig = figment
509 .extract()
510 .with_context(|| "Failed to extract config from figment".to_owned())?;
511
512 config
514 .server
515 .normalize_home_dir_inplace()
516 .context("Failed to resolve server.home_dir")?;
517
518 if let Some(dir) = config.gears_dir.as_ref() {
520 merge_gear_files(&mut config.gears, dir)?;
521 }
522
523 Ok(config)
524 }
525
526 pub fn load_or_default(config_path: Option<&PathBuf>) -> Result<Self> {
532 if let Some(path) = config_path {
533 ensure!(
534 path.is_file(),
535 "config file does not exist: {}",
536 path.to_string_lossy()
537 );
538 Self::load_layered(path)
539 } else {
540 let mut c = Self::default();
541 c.server
542 .normalize_home_dir_inplace()
543 .context("Failed to resolve server.home_dir (defaults)")?;
544 Ok(c)
545 }
546 }
547
548 pub fn to_yaml(&self) -> Result<String> {
553 serde_saphyr::to_string(self).context("Failed to serialize config to YAML")
554 }
555
556 pub fn vendor_config<T: DeserializeOwned>(
562 &self,
563 vendor_name: &str,
564 ) -> Result<T, VendorConfigError> {
565 let raw = self
566 .vendor
567 .get(vendor_name)
568 .ok_or_else(|| VendorConfigError::NotFound {
569 vendor: vendor_name.to_owned(),
570 })?;
571 T::deserialize(raw).map_err(|e| VendorConfigError::InvalidConfig {
572 vendor: vendor_name.to_owned(),
573 cause: e,
574 })
575 }
576
577 pub fn vendor_config_or_default<T: DeserializeOwned + Default>(
583 &self,
584 vendor_name: &str,
585 ) -> Result<T, VendorConfigError> {
586 let Some(raw) = self.vendor.get(vendor_name) else {
587 return Ok(T::default());
588 };
589 T::deserialize(raw).map_err(|e| VendorConfigError::InvalidConfig {
590 vendor: vendor_name.to_owned(),
591 cause: e,
592 })
593 }
594
595 pub fn apply_cli_overrides(&mut self, verbose: u8) {
597 if let Some(default_section) = self.logging.get_mut("default") {
599 default_section.console_level = match verbose {
600 0 => default_section.console_level, 1 => Some(Level::DEBUG),
602 _ => Some(Level::TRACE),
603 };
604 }
605 }
606}
607
608#[derive(Debug, Clone)]
610pub struct CliArgs {
611 pub config: Option<String>,
612 pub print_config: bool,
613 pub verbose: u8,
614 pub mock: bool,
615}
616
617fn strict_yaml_parse<T: serde::de::DeserializeOwned>(s: &str) -> Result<T, serde_saphyr::Error> {
619 let opts = serde_saphyr::Options {
620 duplicate_keys: serde_saphyr::DuplicateKeyPolicy::Error,
621 ..serde_saphyr::Options::default()
622 };
623 serde_saphyr::from_str_with_options(s, opts)
624}
625
626struct StrictYaml;
632
633impl figment::providers::Format for StrictYaml {
634 type Error = serde_saphyr::Error;
635
636 const NAME: &'static str = "YAML";
637
638 fn from_str<T: serde::de::DeserializeOwned>(s: &str) -> Result<T, Self::Error> {
639 strict_yaml_parse(s)
640 }
641}
642
643fn merge_gear_files(
644 bag: &mut HashMap<String, serde_json::Value>,
645 dir: impl AsRef<Path>,
646) -> Result<()> {
647 use std::fs;
648 let dir = dir.as_ref();
649 if !dir.exists() {
650 return Ok(());
651 }
652 for entry in fs::read_dir(dir)? {
653 let entry = entry?;
654 let path = entry.path();
655 if !path.is_file() {
656 continue;
657 }
658 let ext = path
659 .extension()
660 .and_then(|s| s.to_str())
661 .unwrap_or("")
662 .to_ascii_lowercase();
663 if ext != "yml" && ext != "yaml" {
664 continue;
665 }
666 let name = path
667 .file_stem()
668 .and_then(|s| s.to_str())
669 .unwrap_or("")
670 .to_owned();
671 let raw = fs::read_to_string(&path)?;
672 let json: serde_json::Value = strict_yaml_parse(&raw)
673 .with_context(|| format!("failed to parse gear file: {}", path.display()))?;
674 bag.insert(name, json);
675 }
676 Ok(())
677}
678
679pub fn expand_env_in_dsn(dsn: &str) -> Result<String> {
687 toolkit_utils::var_expand::expand_env_vars(dsn).map_err(|e| anyhow::anyhow!("{e}"))
688}
689
690pub fn resolve_password(password: Option<&str>) -> Result<Option<String>> {
695 if let Some(pwd) = password {
696 if pwd.starts_with("${") && pwd.ends_with('}') {
697 let var_name = &pwd[2..pwd.len() - 1];
699 let resolved = std::env::var(var_name).with_context(|| {
700 format!("Environment variable '{var_name}' not found for password")
701 })?;
702 Ok(Some(resolved))
703 } else {
704 Ok(Some(pwd.to_owned()))
706 }
707 } else {
708 Ok(None)
709 }
710}
711
712pub fn validate_dsn(dsn: &str) -> Result<()> {
718 if dsn.starts_with("sqlite:") {
720 return Ok(());
721 }
722
723 let _parsed = dsn::parse(dsn).map_err(|e| anyhow::anyhow!("Invalid DSN '{dsn}': {e}"))?;
724
725 Ok(())
726}
727
728fn resolve_sqlite_dsn(
733 dsn: &str,
734 home_dir: &Path,
735 gear_name: &str,
736 dry_run: bool,
737) -> Result<String> {
738 if dsn.contains("@file(") {
739 if let Some(start) = dsn.find("@file(")
741 && let Some(end) = dsn[start..].find(')')
742 {
743 let file_path = &dsn[start + 6..start + end]; let resolved_path = if file_path.starts_with('/')
746 || (file_path.len() > 1 && file_path.chars().nth(1) == Some(':'))
747 {
748 PathBuf::from(file_path)
750 } else {
751 let gear_dir = home_dir.join(gear_name);
753 if !dry_run {
754 std::fs::create_dir_all(&gear_dir).with_context(|| {
755 format!("Failed to create gear directory: {}", gear_dir.display())
756 })?;
757 }
758 gear_dir.join(file_path)
759 };
760
761 let normalized_path = normalize_path(&resolved_path);
762 if normalized_path.len() > 1 && normalized_path.chars().nth(1) == Some(':') {
765 return Ok(format!("sqlite:{normalized_path}"));
767 }
768 return Ok(format!("sqlite://{normalized_path}"));
770 }
771 return Err(anyhow::anyhow!(
772 "Invalid @file() syntax in SQLite DSN: {dsn}"
773 ));
774 }
775
776 if dsn == "sqlite://" || dsn == "sqlite:///" || dsn == "sqlite:" {
778 let gear_dir = home_dir.join(gear_name);
779 if !dry_run {
780 std::fs::create_dir_all(&gear_dir).with_context(|| {
781 format!("Failed to create gear directory: {}", gear_dir.display())
782 })?;
783 }
784 let db_path = gear_dir.join(format!("{gear_name}.sqlite"));
785 let normalized_path = normalize_path(&db_path);
786 if normalized_path.len() > 1 && normalized_path.chars().nth(1) == Some(':') {
789 return Ok(format!("sqlite:{normalized_path}"));
791 }
792 return Ok(format!("sqlite://{normalized_path}"));
794 }
795
796 Ok(dsn.to_owned())
798}
799
800fn build_server_dsn(
804 scheme: &str,
805 host: Option<&str>,
806 port: Option<u16>,
807 user: Option<&str>,
808 password: Option<&str>,
809 dbname: Option<&str>,
810 params: &HashMap<String, String>,
811) -> Result<String> {
812 let host = host.unwrap_or("localhost");
813 let user = user.unwrap_or("postgres"); let mut url = Url::parse(&format!("{scheme}://dummy/"))
817 .with_context(|| format!("Invalid scheme: {scheme}"))?;
818
819 url.set_host(Some(host))
821 .with_context(|| format!("Invalid host: {host}"))?;
822
823 if let Some(port) = port {
825 url.set_port(Some(port))
826 .map_err(|()| anyhow::anyhow!("Invalid port: {port}"))?;
827 }
828
829 url.set_username(user)
831 .map_err(|()| anyhow::anyhow!("Failed to set username: {user}"))?;
832
833 if let Some(password) = password {
835 url.set_password(Some(password))
836 .map_err(|()| anyhow::anyhow!("Failed to set password"))?;
837 }
838
839 if let Some(dbname) = dbname {
841 let encoded_dbname = urlencoding::encode(dbname);
843 url.set_path(&format!("/{encoded_dbname}"));
844 } else {
845 url.set_path("/");
846 }
847
848 if !params.is_empty() {
850 let mut query_pairs = url.query_pairs_mut();
852 for (key, value) in params {
853 query_pairs.append_pair(key, value);
854 }
855 }
856
857 Ok(url.to_string())
858}
859
860fn build_sqlite_dsn_with_dbname_override(
862 original_dsn: &str,
863 dbname: &str,
864 gear_name: &str,
865 home_dir: &Path,
866 dry_run: bool,
867) -> Result<String> {
868 let query_params = if let Some(query_start) = original_dsn.find('?') {
870 &original_dsn[query_start..]
871 } else {
872 ""
873 };
874
875 let gear_dir = home_dir.join(gear_name);
877 if !dry_run {
878 std::fs::create_dir_all(&gear_dir)
879 .with_context(|| format!("Failed to create gear directory: {}", gear_dir.display()))?;
880 }
881 let db_path = gear_dir.join(dbname);
882 let normalized_path = normalize_path(&db_path);
883
884 let dsn_base = if normalized_path.len() > 1 && normalized_path.chars().nth(1) == Some(':') {
886 format!("sqlite:{normalized_path}")
888 } else {
889 format!("sqlite://{normalized_path}")
891 };
892
893 Ok(format!("{dsn_base}{query_params}"))
894}
895
896fn build_sqlite_dsn(
902 dsn: Option<&str>,
903 file: Option<&str>,
904 path: Option<&PathBuf>,
905 dbname: Option<&str>,
906 gear_name: &str,
907 home_dir: &Path,
908 dry_run: bool,
909) -> Result<String> {
910 if let Some(dsn) = dsn {
912 let resolved_dsn = resolve_sqlite_dsn(dsn, home_dir, gear_name, dry_run)?;
913
914 if let Some(dbname) = dbname {
916 return build_sqlite_dsn_with_dbname_override(
917 &resolved_dsn,
918 dbname,
919 gear_name,
920 home_dir,
921 dry_run,
922 );
923 }
924
925 validate_dsn(&resolved_dsn)?;
926 return Ok(resolved_dsn);
927 }
928
929 if let Some(path) = path {
931 let absolute_path = if path.is_absolute() {
932 path.clone()
933 } else {
934 home_dir.join(path)
935 };
936 let normalized_path = normalize_path(&absolute_path);
937 if normalized_path.len() > 1 && normalized_path.chars().nth(1) == Some(':') {
940 return Ok(format!("sqlite:{normalized_path}"));
942 }
943 return Ok(format!("sqlite://{normalized_path}"));
945 }
946
947 if let Some(file) = file {
949 let gear_dir = home_dir.join(gear_name);
950 if !dry_run {
951 std::fs::create_dir_all(&gear_dir).with_context(|| {
952 format!("Failed to create gear directory: {}", gear_dir.display())
953 })?;
954 }
955 let db_path = gear_dir.join(file);
956 let normalized_path = normalize_path(&db_path);
957 if normalized_path.len() > 1 && normalized_path.chars().nth(1) == Some(':') {
960 return Ok(format!("sqlite:{normalized_path}"));
962 }
963 return Ok(format!("sqlite://{normalized_path}"));
965 }
966
967 let gear_dir = home_dir.join(gear_name);
969 if !dry_run {
970 std::fs::create_dir_all(&gear_dir)
971 .with_context(|| format!("Failed to create gear directory: {}", gear_dir.display()))?;
972 }
973 let db_path = gear_dir.join(format!("{gear_name}.sqlite"));
974 let normalized_path = normalize_path(&db_path);
975 if normalized_path.len() > 1 && normalized_path.chars().nth(1) == Some(':') {
978 Ok(format!("sqlite:{normalized_path}"))
980 } else {
981 Ok(format!("sqlite://{normalized_path}"))
983 }
984}
985
986type DbConfigResult = Result<Option<(String , PoolCfg)>>;
988
989#[derive(Default)]
991struct DbConfigBuilder {
992 dsn: Option<String>,
993 host: Option<String>,
994 port: Option<u16>,
995 user: Option<String>,
996 password: Option<String>,
997 dbname: Option<String>,
998 params: HashMap<String, String>,
999 pool: PoolCfg,
1000}
1001
1002impl DbConfigBuilder {
1003 fn new() -> Self {
1004 Self::default()
1005 }
1006
1007 fn apply_global_server(
1009 &mut self,
1010 global_server: &DbConnConfig,
1011 home_dir: &Path,
1012 gear_name: &str,
1013 dry_run: bool,
1014 ) -> Result<()> {
1015 if let Some(global_dsn) = &global_server.dsn {
1017 let expanded_dsn = expand_env_in_dsn(global_dsn.expose())?;
1018 let resolved_dsn = if expanded_dsn.starts_with("sqlite") {
1020 resolve_sqlite_dsn(&expanded_dsn, home_dir, gear_name, dry_run)?
1021 } else {
1022 expanded_dsn
1023 };
1024 validate_dsn(&resolved_dsn)?;
1025 self.dsn = Some(resolved_dsn);
1026 }
1027
1028 if let Some(host) = &global_server.host {
1030 self.host = Some(host.clone());
1031 }
1032 if let Some(port) = global_server.port {
1033 self.port = Some(port);
1034 }
1035 if let Some(user) = &global_server.user {
1036 self.user = Some(user.clone());
1037 }
1038 if let Some(password) = resolve_password(
1039 global_server
1040 .password
1041 .as_ref()
1042 .map(toolkit_utils::SecretString::expose),
1043 )? {
1044 self.password = Some(password);
1045 }
1046 if let Some(dbname) = &global_server.dbname {
1047 self.dbname = Some(dbname.clone());
1048 }
1049 if let Some(params) = &global_server.params {
1050 self.params.extend(params.clone());
1051 }
1052 if let Some(pool) = &global_server.pool {
1053 self.pool = pool.clone();
1054 }
1055
1056 Ok(())
1057 }
1058
1059 fn apply_gear_dsn(
1061 &mut self,
1062 gear_dsn: &str,
1063 home_dir: &Path,
1064 gear_name: &str,
1065 dry_run: bool,
1066 ) -> Result<()> {
1067 let resolved_dsn = if gear_dsn.starts_with("sqlite") {
1069 resolve_sqlite_dsn(gear_dsn, home_dir, gear_name, dry_run)?
1070 } else {
1071 gear_dsn.to_owned()
1072 };
1073 validate_dsn(&resolved_dsn)?;
1074 self.dsn = Some(resolved_dsn);
1075 Ok(())
1076 }
1077
1078 fn apply_gear_fields(&mut self, gear_db_config: &DbConnConfig) -> Result<()> {
1080 if let Some(host) = &gear_db_config.host {
1081 self.host = Some(host.clone());
1082 }
1083 if let Some(port) = gear_db_config.port {
1084 self.port = Some(port);
1085 }
1086 if let Some(user) = &gear_db_config.user {
1087 self.user = Some(user.clone());
1088 }
1089 if let Some(password) = resolve_password(
1090 gear_db_config
1091 .password
1092 .as_ref()
1093 .map(toolkit_utils::SecretString::expose),
1094 )? {
1095 self.password = Some(password);
1096 }
1097 if let Some(dbname) = &gear_db_config.dbname {
1098 self.dbname = Some(dbname.clone());
1099 }
1100 if let Some(params) = &gear_db_config.params {
1101 self.params.extend(params.clone());
1102 }
1103 if let Some(pool) = &gear_db_config.pool {
1104 if let Some(max_conns) = pool.max_conns {
1106 self.pool.max_conns = Some(max_conns);
1107 }
1108 if let Some(acquire_timeout) = pool.acquire_timeout {
1109 self.pool.acquire_timeout = Some(acquire_timeout);
1110 }
1111 }
1112 Ok(())
1113 }
1114
1115 fn has_field_overrides(&self) -> bool {
1117 self.host.is_some()
1118 || self.port.is_some()
1119 || self.user.is_some()
1120 || self.password.is_some()
1121 || !self.params.is_empty()
1122 }
1123}
1124
1125fn decide_backend(builder: &DbConfigBuilder, gear_db_config: &DbConnConfig) -> bool {
1127 gear_db_config.file.is_some()
1130 || gear_db_config.path.is_some()
1131 || builder
1132 .dsn
1133 .as_ref()
1134 .is_some_and(|dsn| dsn.starts_with("sqlite"))
1135 || (gear_db_config.server.is_none() && builder.dsn.is_none())
1136}
1137
1138fn finalize_sqlite_dsn(
1140 builder: &DbConfigBuilder,
1141 gear_db_config: &DbConnConfig,
1142 gear_name: &str,
1143 home_dir: &Path,
1144 dry_run: bool,
1145) -> Result<String> {
1146 build_sqlite_dsn(
1147 builder.dsn.as_deref(),
1148 gear_db_config.file.as_deref(),
1149 gear_db_config.path.as_ref(),
1150 builder.dbname.as_deref(),
1151 gear_name,
1152 home_dir,
1153 dry_run,
1154 )
1155}
1156
1157fn finalize_server_dsn(builder: &DbConfigBuilder, gear_name: &str) -> Result<String> {
1159 let dbname = if let Some(dbname) = builder.dbname.as_deref() {
1161 dbname.to_owned()
1162 } else if let Some(dsn) = builder.dsn.as_ref() {
1163 if let Ok(parsed) = url::Url::parse(dsn) {
1165 let path = parsed.path();
1166 if path.len() > 1 {
1167 path[1..].to_string()
1169 } else {
1170 return Err(anyhow::anyhow!(
1171 "Server-based database config for gear '{gear_name}' missing required 'dbname'"
1172 ));
1173 }
1174 } else {
1175 return Err(anyhow::anyhow!(
1176 "Server-based database config for gear '{gear_name}' missing required 'dbname'"
1177 ));
1178 }
1179 } else {
1180 return Err(anyhow::anyhow!(
1181 "Server-based database config for gear '{gear_name}' missing required 'dbname'"
1182 ));
1183 };
1184
1185 if builder.has_field_overrides() || builder.dsn.is_none() {
1186 let scheme = if let Some(dsn) = &builder.dsn {
1188 let parsed = Url::parse(dsn)?;
1189 parsed.scheme().to_owned()
1190 } else {
1191 "postgresql".to_owned() };
1193
1194 build_server_dsn(
1195 &scheme,
1196 builder.host.as_deref(),
1197 builder.port,
1198 builder.user.as_deref(),
1199 builder.password.as_deref(),
1200 Some(&dbname),
1201 &builder.params,
1202 )
1203 } else if let Some(original_dsn) = &builder.dsn {
1204 if let Ok(mut parsed) = Url::parse(original_dsn) {
1206 let original_dbname = parsed.path().trim_start_matches('/');
1208 if original_dbname != dbname {
1209 parsed.set_path(&format!("/{dbname}"));
1210 }
1211 Ok(parsed.to_string())
1212 } else {
1213 build_server_dsn(
1215 "postgresql",
1216 builder.host.as_deref(),
1217 builder.port,
1218 builder.user.as_deref(),
1219 builder.password.as_deref(),
1220 Some(&dbname),
1221 &builder.params,
1222 )
1223 }
1224 } else {
1225 unreachable!("final_dsn should not be None when has_field_overrides is false")
1227 }
1228}
1229
1230fn redact_dsn_for_logging(dsn: &str) -> Result<String> {
1232 if dsn.contains('@') {
1233 let parsed = Url::parse(dsn)?;
1234 let mut log_url = parsed;
1235 if log_url.password().is_some() {
1236 log_url.set_password(Some("***")).ok();
1237 }
1238 Ok(log_url.to_string())
1239 } else {
1240 Ok(dsn.to_owned())
1241 }
1242}
1243
1244pub const TOOLKIT_MODULE_CONFIG_ENV: &str = "TOOLKIT_MODULE_CONFIG";
1248
1249#[derive(Debug, Clone, Serialize, Deserialize)]
1252pub struct RenderedDbConfig {
1253 #[serde(skip_serializing_if = "Option::is_none")]
1256 pub global: Option<GlobalDatabaseConfig>,
1257 #[serde(skip_serializing_if = "Option::is_none")]
1260 pub gear: Option<DbConnConfig>,
1261}
1262
1263impl RenderedDbConfig {
1264 #[must_use]
1266 pub fn new(global: Option<GlobalDatabaseConfig>, gear: Option<DbConnConfig>) -> Self {
1267 Self { global, gear }
1268 }
1269}
1270
1271#[derive(Debug, Clone, Serialize, Deserialize)]
1281pub struct RenderedGearConfig {
1282 #[serde(skip_serializing_if = "Option::is_none")]
1285 pub database: Option<RenderedDbConfig>,
1286 #[serde(default)]
1288 pub config: serde_json::Value,
1289 #[serde(skip_serializing_if = "Option::is_none")]
1292 pub logging: Option<LoggingConfig>,
1293 #[serde(skip_serializing_if = "Option::is_none")]
1295 pub opentelemetry: Option<OpenTelemetryConfig>,
1296}
1297
1298impl RenderedGearConfig {
1299 pub fn from_json(json: &str) -> Result<Self> {
1304 serde_json::from_str(json).context("Failed to parse RenderedGearConfig from JSON")
1305 }
1306
1307 pub fn to_json(&self) -> Result<String> {
1312 serde_json::to_string(self).context("Failed to serialize RenderedGearConfig to JSON")
1313 }
1314}
1315
1316pub fn render_gear_config_for_oop(
1333 app: &AppConfig,
1334 gear_name: &str,
1335 _home_dir: &std::path::Path,
1336) -> Result<RenderedGearConfig> {
1337 let gear_db_config = parse_gear_config(app, gear_name)
1340 .ok()
1341 .and_then(|entry| entry.database);
1342
1343 let database = if gear_db_config.is_some() || app.database.is_some() {
1345 Some(RenderedDbConfig::new(app.database.clone(), gear_db_config))
1346 } else {
1347 None
1348 };
1349
1350 let config = parse_gear_config(app, gear_name)
1352 .map(|entry| entry.config)
1353 .unwrap_or_default();
1354
1355 let logging = app.logging.clone();
1357
1358 let opentelemetry = if app.opentelemetry.tracing.enabled || app.opentelemetry.metrics.enabled {
1360 Some(app.opentelemetry.clone())
1361 } else {
1362 None
1363 };
1364
1365 Ok(RenderedGearConfig {
1366 database,
1367 config,
1368 logging: Some(logging),
1369 opentelemetry,
1370 })
1371}
1372
1373pub fn parse_gear_config(app: &AppConfig, gear_name: &str) -> Result<GearConfig> {
1378 let gear_raw = app
1379 .gears
1380 .get(gear_name)
1381 .cloned()
1382 .ok_or_else(|| anyhow::anyhow!("Gear '{gear_name}' not found in config"))?;
1383
1384 let gear_config: GearConfig = serde_json::from_value(gear_raw)?;
1385 Ok(gear_config)
1386}
1387
1388pub fn get_gear_runtime_config(app: &AppConfig, gear_name: &str) -> Result<Option<GearRuntime>> {
1393 let entry = parse_gear_config(app, gear_name)?;
1394 Ok(entry.runtime)
1395}
1396
1397pub fn build_final_db_for_gear(
1408 app: &AppConfig,
1409 gear_name: &str,
1410 home_dir: &Path,
1411 dry_run: bool,
1412) -> DbConfigResult {
1413 let Some(gear_raw) = app.gears.get(gear_name) else {
1415 return Ok(None); };
1417
1418 let gear_entry: GearConfig = serde_json::from_value(gear_raw.clone())
1419 .with_context(|| format!("Invalid gear config structure for '{gear_name}'"))?;
1420
1421 let Some(gear_db_config) = gear_entry.database else {
1422 tracing::warn!(
1423 "Gear '{}' has no database configuration; DB capability disabled",
1424 gear_name
1425 );
1426 return Ok(None);
1427 };
1428
1429 let global_db_config = app.database.as_ref();
1431
1432 let mut builder = DbConfigBuilder::new();
1434
1435 if let Some(server_name) = &gear_db_config.server {
1437 let global_server = global_db_config
1438 .and_then(|gc| gc.servers.get(server_name))
1439 .ok_or_else(|| {
1440 anyhow::anyhow!("Referenced server '{server_name}' not found in global config")
1441 })?;
1442
1443 builder.apply_global_server(global_server, home_dir, gear_name, dry_run)?;
1444 }
1445
1446 if let Some(gear_dsn) = &gear_db_config.dsn {
1448 builder.apply_gear_dsn(gear_dsn.expose(), home_dir, gear_name, dry_run)?;
1449 }
1450
1451 builder.apply_gear_fields(&gear_db_config)?;
1453
1454 let is_sqlite = decide_backend(&builder, &gear_db_config);
1456
1457 let result_dsn = if is_sqlite {
1458 finalize_sqlite_dsn(&builder, &gear_db_config, gear_name, home_dir, dry_run)?
1459 } else {
1460 finalize_server_dsn(&builder, gear_name)?
1461 };
1462
1463 validate_dsn(&result_dsn)?;
1465
1466 let log_dsn = redact_dsn_for_logging(&result_dsn)?;
1468
1469 tracing::info!(
1470 "Built final DB config for gear '{}': {}",
1471 gear_name,
1472 log_dsn
1473 );
1474
1475 Ok(Some((result_dsn, builder.pool)))
1476}
1477
1478#[must_use]
1481pub fn get_gear_db_config(app: &AppConfig, gear_name: &str) -> Option<DbConnConfig> {
1482 let gear_raw = app.gears.get(gear_name)?;
1483 let gear_entry: GearConfig = serde_json::from_value(gear_raw.clone()).ok()?;
1484 gear_entry.database
1485}
1486
1487#[must_use]
1490pub fn gear_home(app: &AppConfig, gear_name: &str) -> PathBuf {
1491 PathBuf::from(&app.server.home_dir).join(gear_name)
1492}
1493
1494#[cfg(test)]
1495#[cfg_attr(coverage_nightly, coverage(off))]
1496mod tests {
1497 use super::*;
1498 use serial_test::serial;
1499 use std::fs;
1500 use temp_env::with_var;
1501 use tempfile::tempdir;
1502
1503 fn is_normalized_path(p: &Path) -> bool {
1505 p.is_absolute() && !p.starts_with("~")
1506 }
1507
1508 fn default_subdir() -> &'static str {
1510 ".cf-gears"
1511 }
1512
1513 #[test]
1514 fn test_remap_gear_env_key() {
1515 let cases = [
1518 ("gears.my_gear.port", "gears.my-gear.port"),
1520 ("gears.my_gear.max_age_days", "gears.my-gear.max_age_days"),
1522 ("gears.a_b_c.field", "gears.a-b-c.field"),
1524 ("gears.my-gear.port", "gears.my-gear.port"),
1526 ("vendor.my_vendor.key", "vendor.my_vendor.key"),
1528 ("server.home_dir", "server.home_dir"),
1529 ("gears", "gears"),
1531 ("GEARS.MY_GEAR.PORT", "gears.my-gear.port"),
1533 ("server", "server"),
1535 ];
1536
1537 for (input, expected) in cases {
1538 assert_eq!(
1539 remap_gear_env_key(input),
1540 expected,
1541 "remap_gear_env_key({input:?})"
1542 );
1543 }
1544 }
1545
1546 #[test]
1547 fn test_default_config_structure() {
1548 let config = AppConfig::default();
1549
1550 assert!(config.database.is_none());
1552
1553 let logging = config.logging;
1555 assert!(logging.contains_key("default"));
1556
1557 let default_section = &logging["default"];
1558 assert_eq!(default_section.console_level, Some(Level::INFO));
1559 assert_eq!(default_section.file().unwrap(), "logs/cf-gears.log");
1560
1561 assert!(config.gears.is_empty());
1563 }
1564
1565 #[test]
1566 fn oop_http_labels_coerce_numeric_and_bool_values_to_strings() {
1567 let cfg: OopHttpConfig = serde_json::from_value(serde_json::json!({
1571 "listen_addr": "0.0.0.0:8080",
1572 "labels": {
1573 "shard": 7,
1574 "role": "ingest",
1575 "canary": true,
1576 }
1577 }))
1578 .expect("numeric/bool label values must deserialize");
1579
1580 assert_eq!(cfg.labels.get("shard").map(String::as_str), Some("7"));
1581 assert_eq!(cfg.labels.get("role").map(String::as_str), Some("ingest"));
1582 assert_eq!(cfg.labels.get("canary").map(String::as_str), Some("true"));
1583 }
1584
1585 #[test]
1588 #[serial]
1589 fn test_load_layered_normalizes_home_dir() {
1590 let tmp = tempdir().unwrap();
1591 let cfg_path = tmp.path().join("cfg.yaml");
1592
1593 let yaml = r#"
1595server:
1596 home_dir: "~/.test_cfgears"
1597
1598database:
1599 servers:
1600 test_postgres:
1601 dsn: "postgres://user:pass@localhost/db"
1602 pool:
1603 max_conns: 20
1604
1605logging:
1606 default:
1607 console_level: debug
1608 file: "logs/default.log"
1609"#;
1610 fs::write(&cfg_path, yaml).unwrap();
1611
1612 let config = AppConfig::load_layered(&cfg_path).unwrap();
1613
1614 assert!(is_normalized_path(&config.server.home_dir));
1616 assert!(config.server.home_dir.ends_with(".test_cfgears"));
1617
1618 let logging = &config.logging;
1624 let def = &logging["default"];
1625 assert_eq!(def.console_level, Some(Level::DEBUG));
1626 assert_eq!(def.section_file.as_ref().unwrap().file, "logs/default.log");
1627 }
1628
1629 #[test]
1630 fn test_load_or_default_normalizes_home_dir_when_none() {
1631 let tmp = tempdir().unwrap();
1634 let env_var = if cfg!(target_os = "windows") {
1635 "APPDATA"
1636 } else {
1637 "HOME"
1638 };
1639 with_var(env_var, Some(tmp.path().to_str().unwrap()), || {
1640 let config = AppConfig::load_or_default(None).unwrap();
1641 assert!(is_normalized_path(&config.server.home_dir));
1642 assert!(config.server.home_dir.ends_with(default_subdir()));
1643 });
1644 }
1645
1646 #[test]
1649 #[serial]
1650 fn test_minimal_yaml_config() {
1651 let tmp = tempdir().unwrap();
1652 let cfg_path = tmp.path().join("cfg.yaml");
1653
1654 let yaml = r#"
1655server:
1656 home_dir: "~/.minimal"
1657"#;
1658 fs::write(&cfg_path, yaml).unwrap();
1659
1660 let config = AppConfig::load_layered(&cfg_path).unwrap();
1661
1662 assert!(is_normalized_path(&config.server.home_dir));
1664 assert!(config.server.home_dir.ends_with(".minimal"));
1665
1666 assert!(config.database.is_none());
1668 assert!(config.gears.is_empty());
1669 }
1670
1671 #[test]
1672 fn test_cli_overrides() {
1673 let mut config = AppConfig::default();
1674
1675 let args = CliArgs {
1676 config: None,
1677 print_config: false,
1678 verbose: 2, mock: false,
1680 };
1681
1682 config.apply_cli_overrides(args.verbose);
1683
1684 let logging = &config.logging;
1688 let default_section = &logging["default"];
1689 assert_eq!(default_section.console_level, Some(Level::TRACE));
1690 }
1691
1692 #[test]
1693 fn test_cli_verbose_levels_matrix() {
1694 for (verbose_level, expected_log_level) in [
1695 (0, Some(Level::INFO)), (1, Some(Level::DEBUG)),
1697 (2, Some(Level::TRACE)),
1698 (3, Some(Level::TRACE)), ] {
1700 let mut config = AppConfig::default();
1701 let args = CliArgs {
1702 config: None,
1703 print_config: false,
1704 verbose: verbose_level,
1705 mock: false,
1706 };
1707
1708 config.apply_cli_overrides(args.verbose);
1709
1710 let logging = &config.logging;
1711 let default_section = &logging["default"];
1712
1713 if verbose_level == 0 {
1714 assert_eq!(default_section.console_level, Some(Level::INFO));
1715 } else {
1716 assert_eq!(default_section.console_level, expected_log_level);
1717 }
1718 }
1719 }
1720
1721 #[test]
1724 #[serial]
1725 fn test_layered_config_loading_with_gears_dir() {
1726 let tmp = tempdir().unwrap();
1727 let cfg_path = tmp.path().join("gears_dir.yaml");
1728 let gears_dir = tmp.path().join("gears");
1729
1730 fs::create_dir_all(&gears_dir).unwrap();
1731 let gear_cfg = gears_dir.join("test_gear.yaml");
1732 fs::write(
1733 &gear_cfg,
1734 r#"
1735setting1: "value1"
1736setting2: 42
1737"#,
1738 )
1739 .unwrap();
1740
1741 let gears_dir_str = normalize_path(&gears_dir);
1743 let yaml = format!(
1744 r#"
1745server:
1746 home_dir: "~/.gears_test"
1747
1748gears_dir: "{gears_dir_str}"
1749
1750gears:
1751 existing_gear:
1752 key: "value"
1753"#
1754 );
1755
1756 fs::write(&cfg_path, yaml).unwrap();
1757
1758 let config = AppConfig::load_layered(&cfg_path).unwrap();
1759
1760 assert!(config.gears.contains_key("existing_gear"));
1762
1763 assert!(config.gears.contains_key("test_gear"));
1765
1766 let test_gear = &config.gears["test_gear"];
1768 assert_eq!(test_gear["setting1"], "value1");
1769 assert_eq!(test_gear["setting2"], 42);
1770 }
1771
1772 #[test]
1775 #[serial]
1776 fn test_load_and_init_logging_smoke() {
1777 let tmp = tempdir().unwrap();
1779 let cfg_path = tmp.path().join("logging.yaml");
1780 let yaml = r#"
1781server:
1782 home_dir: "~/.logging_test"
1783
1784logging:
1785 default:
1786 console_level: debug
1787 file: ""
1788 file_level: info
1789"#;
1790 fs::write(&cfg_path, yaml).unwrap();
1791
1792 let config = AppConfig::load_layered(&cfg_path).unwrap();
1793 let logging = &config.logging;
1794 assert!(logging.contains_key("default"));
1795
1796 let default_section = &logging["default"];
1797 assert_eq!(default_section.console_level, Some(Level::DEBUG));
1798 assert_eq!(default_section.file_level(), Some(Level::INFO));
1799 }
1801
1802 fn create_app_with_server(server_name: &str, db_config: DbConnConfig) -> AppConfig {
1806 let mut servers = HashMap::new();
1807 servers.insert(server_name.to_owned(), db_config);
1808
1809 AppConfig {
1810 database: Some(GlobalDatabaseConfig {
1811 servers,
1812 auto_provision: None,
1813 }),
1814 ..Default::default()
1815 }
1816 }
1817
1818 fn add_gear_to_app(app: &mut AppConfig, gear_name: &str, database_config: &serde_json::Value) {
1820 app.gears.insert(
1821 gear_name.to_owned(),
1822 serde_json::json!({
1823 "database": database_config,
1824 "config": {}
1825 }),
1826 );
1827 }
1828
1829 fn add_gear_with_config(app: &mut AppConfig, gear_name: &str, config: &serde_json::Value) {
1831 app.gears.insert(
1832 gear_name.to_owned(),
1833 serde_json::json!({
1834 "database": {},
1835 "config": config
1836 }),
1837 );
1838 }
1839
1840 fn create_minimal_app() -> AppConfig {
1842 AppConfig {
1843 database: None,
1844 gears: HashMap::new(),
1845 ..Default::default()
1846 }
1847 }
1848
1849 #[test]
1850 fn test_precedence_global_dsn_only() {
1851 let tmp = tempdir().unwrap();
1852 let home_dir = tmp.path();
1853
1854 let mut app = create_app_with_server(
1855 "test_server",
1856 DbConnConfig {
1857 dsn: Some(toolkit_utils::SecretString::new(
1858 "postgresql://global_user:global_pass@global_host:5432/global_db",
1859 )),
1860 ..Default::default()
1861 },
1862 );
1863
1864 add_gear_to_app(
1866 &mut app,
1867 "test_gear",
1868 &serde_json::json!({
1869 "server": "test_server"
1870 }),
1871 );
1872
1873 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
1874 assert!(result.is_some());
1875
1876 let (dsn, _pool) = result.unwrap();
1877 assert!(dsn.contains("global_user"));
1878 assert!(dsn.contains("global_host"));
1879 assert!(dsn.contains("global_db"));
1880 }
1881
1882 #[test]
1883 fn test_precedence_global_fields_only() {
1884 let tmp = tempdir().unwrap();
1885 let home_dir = tmp.path();
1886
1887 let mut app = create_app_with_server(
1888 "test_server",
1889 DbConnConfig {
1890 host: Some("field_host".to_owned()),
1891 port: Some(5433),
1892 user: Some("field_user".to_owned()),
1893 dbname: Some("field_db".to_owned()),
1894 ..Default::default()
1895 },
1896 );
1897
1898 add_gear_to_app(
1900 &mut app,
1901 "test_gear",
1902 &serde_json::json!({
1903 "server": "test_server"
1904 }),
1905 );
1906
1907 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
1908 assert!(result.is_some());
1909
1910 let (dsn, _pool) = result.unwrap();
1911 assert!(dsn.contains("field_host"));
1912 assert!(dsn.contains("5433"));
1913 assert!(dsn.contains("field_user"));
1914 assert!(dsn.contains("field_db"));
1915 }
1916
1917 #[test]
1918 fn test_precedence_gear_dsn_only() {
1919 let tmp = tempdir().unwrap();
1920 let home_dir = tmp.path();
1921
1922 let app = AppConfig {
1923 gears: {
1924 let mut gears = HashMap::new();
1925 gears.insert(
1926 "test_gear".to_owned(),
1927 serde_json::json!({
1928 "database": {
1929 "dsn": "sqlite://gear_test.db?wal=true&synchronous=NORMAL"
1930 },
1931 "config": {}
1932 }),
1933 );
1934 gears
1935 },
1936 ..Default::default()
1937 };
1938
1939 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
1940 assert!(result.is_some());
1941
1942 let (dsn, _pool) = result.unwrap();
1943 assert!(dsn.contains("gear_test.db"));
1944 assert!(dsn.contains("wal=true"));
1945 }
1946
1947 #[test]
1948 fn test_precedence_gear_fields_only() {
1949 let tmp = tempdir().unwrap();
1950 let home_dir = tmp.path();
1951
1952 let app = AppConfig {
1953 gears: {
1954 let mut gears = HashMap::new();
1955 gears.insert(
1956 "test_gear".to_owned(),
1957 serde_json::json!({
1958 "database": {
1959 "file": "gear_fields.db"
1960 },
1961 "config": {}
1962 }),
1963 );
1964 gears
1965 },
1966 ..Default::default()
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("gear_fields.db"));
1974 #[cfg(windows)]
1976 assert!(dsn.starts_with("sqlite:") && !dsn.starts_with("sqlite://"));
1977 #[cfg(unix)]
1978 assert!(dsn.starts_with("sqlite://"));
1979 }
1980
1981 #[test]
1982 fn test_precedence_fields_override_dsn() {
1983 let tmp = tempdir().unwrap();
1984 let home_dir = tmp.path();
1985
1986 let mut app = create_app_with_server(
1987 "test_server",
1988 DbConnConfig {
1989 dsn: Some(toolkit_utils::SecretString::new(
1990 "postgresql://old_user:old_pass@old_host:5432/old_db",
1991 )),
1992 host: Some("new_host".to_owned()), port: Some(5433), user: Some("new_user".to_owned()), dbname: Some("new_db".to_owned()), ..Default::default()
1997 },
1998 );
1999
2000 add_gear_to_app(
2002 &mut app,
2003 "test_gear",
2004 &serde_json::json!({
2005 "server": "test_server",
2006 "port": 5434 }),
2008 );
2009
2010 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2011 assert!(result.is_some());
2012
2013 let (dsn, _pool) = result.unwrap();
2014 assert!(dsn.contains("new_host"));
2016 assert!(dsn.contains("5434")); assert!(dsn.contains("new_user"));
2018 assert!(dsn.contains("new_db"));
2019 assert!(!dsn.contains("old_host"));
2021 assert!(!dsn.contains("5432"));
2022 assert!(!dsn.contains("old_user"));
2023 assert!(!dsn.contains("old_db"));
2024 }
2025
2026 #[test]
2027 fn test_env_expansion_password() {
2028 let tmp = tempdir().unwrap();
2029 let home_dir = tmp.path();
2030
2031 with_var("TEST_DB_PASSWORD", Some("secret123"), || {
2032 let mut app = create_app_with_server(
2033 "test_server",
2034 DbConnConfig {
2035 host: Some("localhost".to_owned()),
2036 port: Some(5432),
2037 user: Some("testuser".to_owned()),
2038 password: Some(toolkit_utils::SecretString::new("${TEST_DB_PASSWORD}")), dbname: Some("testdb".to_owned()),
2040 ..Default::default()
2041 },
2042 );
2043
2044 add_gear_to_app(
2045 &mut app,
2046 "test_gear",
2047 &serde_json::json!({
2048 "server": "test_server"
2049 }),
2050 );
2051
2052 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2053 assert!(result.is_some());
2054
2055 let (dsn, _pool) = result.unwrap();
2056 assert!(dsn.contains("secret123"));
2057 });
2058 }
2059
2060 #[test]
2061 fn test_env_expansion_in_dsn() {
2062 let tmp = tempdir().unwrap();
2063 let home_dir = tmp.path();
2064
2065 temp_env::with_vars(
2066 [
2067 ("DB_HOST", Some("test-server")),
2068 ("DB_PASSWORD", Some("env_secret")),
2069 ],
2070 || {
2071 let mut app = create_app_with_server(
2072 "test_server",
2073 DbConnConfig {
2074 dsn: Some(toolkit_utils::SecretString::new(
2075 "postgresql://user:${DB_PASSWORD}@${DB_HOST}:5432/mydb",
2076 )),
2077 ..Default::default()
2078 },
2079 );
2080
2081 add_gear_to_app(
2082 &mut app,
2083 "test_gear",
2084 &serde_json::json!({
2085 "server": "test_server"
2086 }),
2087 );
2088
2089 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2090 assert!(result.is_some());
2091
2092 let (dsn, _pool) = result.unwrap();
2093 assert!(dsn.contains("test-server"));
2094 assert!(dsn.contains("env_secret"));
2095 assert!(!dsn.contains("${DB_HOST}"));
2097 assert!(!dsn.contains("${DB_PASSWORD}"));
2098 },
2099 );
2100 }
2101
2102 #[test]
2103 fn test_sqlite_file_path_resolution() {
2104 let tmp = tempdir().unwrap();
2105 let home_dir = tmp.path();
2106
2107 let app1 = AppConfig {
2109 gears: {
2110 let mut gears = HashMap::new();
2111 gears.insert(
2112 "test_gear".to_owned(),
2113 serde_json::json!({
2114 "database": {
2115 "file": "test.db"
2116 },
2117 "config": {}
2118 }),
2119 );
2120 gears
2121 },
2122 ..Default::default()
2123 };
2124
2125 let result1 = build_final_db_for_gear(&app1, "test_gear", home_dir, false).unwrap();
2126 assert!(result1.is_some());
2127 let (dsn1, _) = result1.unwrap();
2128 assert!(dsn1.contains("test_gear"));
2129 assert!(dsn1.contains("test.db"));
2130
2131 let abs_path = tmp.path().join("absolute.db");
2133 let app2 = AppConfig {
2134 gears: {
2135 let mut gears = HashMap::new();
2136 gears.insert(
2137 "test_gear".to_owned(),
2138 serde_json::json!({
2139 "database": {
2140 "path": abs_path.to_string_lossy()
2141 },
2142 "config": {}
2143 }),
2144 );
2145 gears
2146 },
2147 ..Default::default()
2148 };
2149
2150 let result2 = build_final_db_for_gear(&app2, "test_gear", home_dir, false).unwrap();
2151 assert!(result2.is_some());
2152 let (dsn2, _) = result2.unwrap();
2153 assert!(dsn2.contains("absolute.db"));
2154
2155 let app3 = AppConfig {
2157 gears: {
2158 let mut gears = HashMap::new();
2159 gears.insert(
2160 "test_gear".to_owned(),
2161 serde_json::json!({
2162 "database": {},
2163 "config": {}
2164 }),
2165 );
2166 gears
2167 },
2168 ..Default::default()
2169 };
2170
2171 let result3 = build_final_db_for_gear(&app3, "test_gear", home_dir, false).unwrap();
2172 assert!(result3.is_some());
2173 let (dsn3, _) = result3.unwrap();
2174 assert!(dsn3.contains("test_gear.sqlite"));
2175 }
2176
2177 #[cfg(windows)]
2178 #[test]
2179 fn test_sqlite_path_resolution_windows() {
2180 let tmp = tempdir().unwrap();
2181 let home_dir = tmp.path();
2182
2183 let app = AppConfig {
2184 gears: {
2185 let mut gears = HashMap::new();
2186 gears.insert(
2187 "test_gear".to_owned(),
2188 serde_json::json!({
2189 "database": {
2190 "file": "test.db"
2191 },
2192 "config": {}
2193 }),
2194 );
2195 gears
2196 },
2197 ..Default::default()
2198 };
2199
2200 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2201 assert!(result.is_some());
2202 let (dsn, _) = result.unwrap();
2203
2204 assert!(!dsn.contains('\\'));
2206 assert!(dsn.contains('/'));
2207 }
2208
2209 #[test]
2210 fn test_sqlite_dsn_with_server_reference_and_dbname_override() {
2211 let tmp = tempdir().unwrap();
2212 let home_dir = tmp.path();
2213
2214 let mut app = AppConfig::default();
2215
2216 let mut servers = HashMap::new();
2218 servers.insert(
2219 "sqlite_users".to_owned(),
2220 DbConnConfig {
2221 engine: None,
2222 dsn: Some(toolkit_utils::SecretString::new(
2223 "sqlite://users_info.db?WAL=true&synchronous=NORMAL&busy_timeout=5000",
2224 )),
2225 host: None,
2226 port: None,
2227 user: None,
2228 password: None,
2229 dbname: None,
2230 params: None,
2231 pool: None,
2232 file: None,
2233 path: None,
2234 lock_keepalive: None,
2235 server: None,
2236 },
2237 );
2238
2239 app.database = Some(GlobalDatabaseConfig {
2240 servers,
2241 auto_provision: None,
2242 });
2243
2244 app.gears.insert(
2246 "users_info".to_owned(),
2247 serde_json::json!({
2248 "database": {
2249 "server": "sqlite_users",
2250 "dbname": "users_info.db"
2251 },
2252 "config": {}
2253 }),
2254 );
2255
2256 let result = build_final_db_for_gear(&app, "users_info", home_dir, false).unwrap();
2257 assert!(result.is_some());
2258 let (dsn, _) = result.unwrap();
2259
2260 assert!(dsn.contains("?WAL=true&synchronous=NORMAL&busy_timeout=5000"));
2262 assert!(dsn.contains("users_info/users_info.db"));
2263
2264 #[cfg(windows)]
2266 {
2267 assert!(dsn.starts_with("sqlite:"));
2269 assert!(!dsn.starts_with("sqlite://"));
2270 }
2271
2272 #[cfg(unix)]
2273 {
2274 assert!(dsn.starts_with("sqlite://"));
2276 }
2277 }
2278
2279 #[cfg(unix)]
2280 #[test]
2281 fn test_sqlite_path_resolution_unix() {
2282 let tmp = tempdir().unwrap();
2283 let home_dir = tmp.path();
2284
2285 let app = AppConfig {
2286 gears: {
2287 let mut gears = HashMap::new();
2288 gears.insert(
2289 "test_gear".to_owned(),
2290 serde_json::json!({
2291 "database": {
2292 "file": "test.db"
2293 },
2294 "config": {}
2295 }),
2296 );
2297 gears
2298 },
2299 ..Default::default()
2300 };
2301
2302 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2303 assert!(result.is_some());
2304 let (dsn, _) = result.unwrap();
2305
2306 assert!(dsn.starts_with("sqlite://"));
2308 assert!(dsn.contains("/test_gear/test.db"));
2309 }
2310
2311 #[test]
2312 fn test_server_based_db_missing_dbname_error() {
2313 let tmp = tempdir().unwrap();
2314 let home_dir = tmp.path();
2315
2316 let mut app = create_app_with_server(
2317 "test_server",
2318 DbConnConfig {
2319 host: Some("localhost".to_owned()),
2320 port: Some(5432),
2321 user: Some("testuser".to_owned()),
2322 ..Default::default()
2324 },
2325 );
2326
2327 add_gear_to_app(
2328 &mut app,
2329 "test_gear",
2330 &serde_json::json!({
2331 "server": "test_server"
2332 }),
2333 );
2334
2335 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false);
2336 assert!(result.is_err());
2337 let error_msg = result.unwrap_err().to_string();
2338 assert!(error_msg.contains("missing required 'dbname'"));
2339 }
2340
2341 #[test]
2342 fn test_gear_no_database_config() {
2343 let tmp = tempdir().unwrap();
2344 let home_dir = tmp.path();
2345
2346 let app = AppConfig {
2348 gears: {
2349 let mut gears = HashMap::new();
2350 gears.insert(
2351 "no_db_gear".to_owned(),
2352 serde_json::json!({
2353 "config": {
2354 "some_setting": "value"
2355 }
2356 }),
2357 );
2358 gears
2359 },
2360 ..Default::default()
2361 };
2362
2363 let result = build_final_db_for_gear(&app, "no_db_gear", home_dir, false).unwrap();
2364 assert!(result.is_none());
2365 }
2366
2367 #[test]
2368 fn test_gear_empty_database_config() {
2369 let tmp = tempdir().unwrap();
2370 let home_dir = tmp.path();
2371
2372 let app = AppConfig {
2374 gears: {
2375 let mut gears = HashMap::new();
2376 gears.insert(
2377 "empty_db_gear".to_owned(),
2378 serde_json::json!({
2379 "database": null,
2380 "config": {}
2381 }),
2382 );
2383 gears
2384 },
2385 ..Default::default()
2386 };
2387
2388 let result = build_final_db_for_gear(&app, "empty_db_gear", home_dir, false).unwrap();
2389 assert!(result.is_none());
2390 }
2391
2392 #[test]
2393 fn test_referenced_server_not_found() {
2394 let tmp = tempdir().unwrap();
2395 let home_dir = tmp.path();
2396
2397 let app = AppConfig {
2398 gears: {
2399 let mut gears = HashMap::new();
2400 gears.insert(
2401 "test_gear".to_owned(),
2402 serde_json::json!({
2403 "database": {
2404 "server": "nonexistent_server"
2405 },
2406 "config": {}
2407 }),
2408 );
2409 gears
2410 },
2411 ..Default::default()
2412 };
2413
2414 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false);
2415 assert!(result.is_err());
2416 let error_msg = result.unwrap_err().to_string();
2417 assert!(error_msg.contains("Referenced server 'nonexistent_server' not found"));
2418 }
2419
2420 #[test]
2421 fn test_dsn_validation_invalid_url() {
2422 let tmp = tempdir().unwrap();
2423 let home_dir = tmp.path();
2424
2425 let app = AppConfig {
2426 gears: {
2427 let mut gears = HashMap::new();
2428 gears.insert(
2429 "test_gear".to_owned(),
2430 serde_json::json!({
2431 "database": {
2432 "dsn": "invalid://not-a-valid[url"
2433 },
2434 "config": {}
2435 }),
2436 );
2437 gears
2438 },
2439 ..Default::default()
2440 };
2441
2442 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false);
2443 assert!(result.is_err());
2444 }
2445
2446 #[test]
2447 fn test_env_variable_not_found() {
2448 let tmp = tempdir().unwrap();
2449 let home_dir = tmp.path();
2450
2451 with_var("NONEXISTENT_PASSWORD", None::<&str>, || {
2453 let mut app = create_app_with_server(
2454 "test_server",
2455 DbConnConfig {
2456 host: Some("localhost".to_owned()),
2457 password: Some(toolkit_utils::SecretString::new("${NONEXISTENT_PASSWORD}")),
2458 dbname: Some("testdb".to_owned()),
2459 ..Default::default()
2460 },
2461 );
2462
2463 add_gear_to_app(
2464 &mut app,
2465 "test_gear",
2466 &serde_json::json!({
2467 "server": "test_server"
2468 }),
2469 );
2470
2471 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false);
2472 assert!(result.is_err());
2473 let error_msg = result.unwrap_err().to_string();
2474 assert!(error_msg.contains("NONEXISTENT_PASSWORD"));
2475 });
2476 }
2477
2478 #[test]
2479 fn test_sqlite_at_file_relative_path() {
2480 let tmp = tempdir().unwrap();
2481 let home_dir = tmp.path();
2482
2483 let app = AppConfig {
2484 gears: {
2485 let mut gears = HashMap::new();
2486 gears.insert(
2487 "test_gear".to_owned(),
2488 serde_json::json!({
2489 "database": {
2490 "dsn": "sqlite://@file(users.db)"
2491 },
2492 "config": {}
2493 }),
2494 );
2495 gears
2496 },
2497 ..Default::default()
2498 };
2499
2500 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2501 assert!(result.is_some());
2502
2503 let (dsn, _pool) = result.unwrap();
2504 assert!(dsn.contains("test_gear"));
2505 assert!(dsn.contains("users.db"));
2506 #[cfg(windows)]
2508 assert!(dsn.starts_with("sqlite:") && !dsn.starts_with("sqlite://"));
2509 #[cfg(unix)]
2510 assert!(dsn.starts_with("sqlite:///"));
2511 }
2512
2513 #[test]
2514 fn test_sqlite_at_file_absolute_path() {
2515 let tmp = tempdir().unwrap();
2516 let home_dir = tmp.path();
2517 let abs_path = tmp.path().join("absolute_db.sqlite");
2518
2519 let app = AppConfig {
2520 gears: {
2521 let mut gears = HashMap::new();
2522 gears.insert(
2523 "test_gear".to_owned(),
2524 serde_json::json!({
2525 "database": {
2526 "dsn": format!("sqlite://@file({})", abs_path.to_string_lossy())
2527 },
2528 "config": {}
2529 }),
2530 );
2531 gears
2532 },
2533 ..Default::default()
2534 };
2535
2536 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2537 assert!(result.is_some());
2538
2539 let (dsn, _pool) = result.unwrap();
2540 assert!(dsn.contains("absolute_db.sqlite"));
2541 #[cfg(windows)]
2543 assert!(dsn.starts_with("sqlite:") && !dsn.starts_with("sqlite://"));
2544 #[cfg(unix)]
2545 assert!(dsn.starts_with("sqlite:///"));
2546 }
2547
2548 #[test]
2549 fn test_sqlite_empty_dsn_default() {
2550 let tmp = tempdir().unwrap();
2551 let home_dir = tmp.path();
2552
2553 let app = AppConfig {
2554 gears: {
2555 let mut gears = HashMap::new();
2556 gears.insert(
2557 "test_gear".to_owned(),
2558 serde_json::json!({
2559 "database": {
2560 "dsn": "sqlite://"
2561 },
2562 "config": {}
2563 }),
2564 );
2565 gears
2566 },
2567 ..Default::default()
2568 };
2569
2570 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2571 assert!(result.is_some());
2572
2573 let (dsn, _pool) = result.unwrap();
2574 assert!(dsn.contains("test_gear"));
2575 assert!(dsn.contains("test_gear.sqlite"));
2576 #[cfg(windows)]
2578 assert!(dsn.starts_with("sqlite:") && !dsn.starts_with("sqlite://"));
2579 #[cfg(unix)]
2580 assert!(dsn.starts_with("sqlite:///"));
2581 }
2582
2583 #[test]
2584 fn test_sqlite_at_file_invalid_syntax() {
2585 let tmp = tempdir().unwrap();
2586 let home_dir = tmp.path();
2587
2588 let app = AppConfig {
2589 gears: {
2590 let mut gears = HashMap::new();
2591 gears.insert(
2592 "test_gear".to_owned(),
2593 serde_json::json!({
2594 "database": {
2595 "dsn": "sqlite://@file(missing_closing_paren"
2596 },
2597 "config": {}
2598 }),
2599 );
2600 gears
2601 },
2602 ..Default::default()
2603 };
2604
2605 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false);
2606 assert!(result.is_err());
2607 let error_msg = result.unwrap_err().to_string();
2608 assert!(error_msg.contains("Invalid @file() syntax"));
2609 }
2610
2611 #[test]
2612 fn test_dsn_special_characters_in_credentials() {
2613 let tmp = tempdir().unwrap();
2614 let home_dir = tmp.path();
2615
2616 let mut app = create_app_with_server(
2618 "test_server",
2619 DbConnConfig {
2620 host: Some("localhost".to_owned()),
2621 port: Some(5432),
2622 user: Some("user@domain".to_owned()),
2623 password: Some(toolkit_utils::SecretString::new(
2624 "pa@ss:w0rd/with%special&chars",
2625 )),
2626 dbname: Some("test/db".to_owned()),
2627 ..Default::default()
2628 },
2629 );
2630
2631 add_gear_to_app(
2632 &mut app,
2633 "test_gear",
2634 &serde_json::json!({
2635 "server": "test_server"
2636 }),
2637 );
2638
2639 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2640 assert!(result.is_some());
2641
2642 let (dsn, _pool) = result.unwrap();
2643
2644 assert!(dsn.starts_with("postgresql://"));
2646 assert!(dsn.contains("user%40domain")); assert!(dsn.contains("/test%2Fdb")); validate_dsn(&dsn).expect("DSN with special characters should be valid");
2651
2652 let parsed_dsn = dsn::parse(&dsn).expect("DSN should be parseable");
2654 assert_eq!(parsed_dsn.username.as_deref(), Some("user@domain"));
2655 assert_eq!(
2656 parsed_dsn.password.as_deref(),
2657 Some("pa@ss:w0rd/with%special&chars")
2658 );
2659 }
2662
2663 #[test]
2664 #[allow(clippy::non_ascii_literal)]
2665 fn test_dsn_unicode_characters() {
2666 let tmp = tempdir().unwrap();
2667 let home_dir = tmp.path();
2668
2669 let mut app = create_app_with_server(
2671 "test_server",
2672 DbConnConfig {
2673 host: Some("localhost".to_owned()),
2674 user: Some("ユーザー".to_owned()), dbname: Some("unicode_db".to_owned()),
2676 ..Default::default()
2677 },
2678 );
2679
2680 add_gear_to_app(
2681 &mut app,
2682 "test_gear",
2683 &serde_json::json!({
2684 "server": "test_server"
2685 }),
2686 );
2687
2688 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2689 assert!(result.is_some());
2690
2691 let (dsn, _pool) = result.unwrap();
2692
2693 assert!(dsn.starts_with("postgresql://"));
2695 assert!(dsn.contains('%')); validate_dsn(&dsn).expect("DSN with Unicode characters should be valid");
2700 }
2701
2702 #[test]
2703 fn test_dsn_query_parameters_encoding() {
2704 let tmp = tempdir().unwrap();
2705 let home_dir = tmp.path();
2706
2707 let mut params = HashMap::new();
2708 params.insert("ssl mode".to_owned(), "require & verify".to_owned());
2709 params.insert("application_name".to_owned(), "my-app/v1.0".to_owned());
2710
2711 let mut app = create_app_with_server(
2712 "test_server",
2713 DbConnConfig {
2714 host: Some("localhost".to_owned()),
2715 user: Some("testuser".to_owned()),
2716 dbname: Some("testdb".to_owned()),
2717 params: Some(params),
2718 ..Default::default()
2719 },
2720 );
2721
2722 add_gear_to_app(
2723 &mut app,
2724 "test_gear",
2725 &serde_json::json!({
2726 "server": "test_server"
2727 }),
2728 );
2729
2730 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2731 assert!(result.is_some());
2732
2733 let (dsn, _pool) = result.unwrap();
2734
2735 assert!(dsn.contains("ssl+mode=require+%26+verify"));
2737 assert!(dsn.contains("application_name=my-app%2Fv1.0"));
2738
2739 validate_dsn(&dsn).expect("DSN with encoded query parameters should be valid");
2741 }
2742
2743 #[test]
2744 fn test_pool_config_merging() {
2745 use std::time::Duration;
2746
2747 let tmp = tempdir().unwrap();
2748 let home_dir = tmp.path();
2749
2750 let mut app = create_app_with_server(
2752 "test_server",
2753 DbConnConfig {
2754 host: Some("localhost".to_owned()),
2755 dbname: Some("testdb".to_owned()),
2756 pool: Some(PoolCfg {
2757 max_conns: Some(10),
2758 min_conns: None,
2759 acquire_timeout: Some(Duration::from_secs(5)),
2760 idle_timeout: None,
2761 max_lifetime: None,
2762 test_before_acquire: None,
2763 }),
2764 ..Default::default()
2765 },
2766 );
2767
2768 add_gear_to_app(
2770 &mut app,
2771 "test_gear",
2772 &serde_json::json!({
2773 "server": "test_server",
2774 "pool": {
2775 "max_conns": 20
2776 }
2777 }),
2778 );
2779
2780 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2781 assert!(result.is_some());
2782
2783 let (_dsn, pool) = result.unwrap();
2784 assert_eq!(pool.max_conns, Some(20)); assert_eq!(pool.acquire_timeout, Some(Duration::from_secs(5))); }
2787
2788 #[test]
2789 fn test_pool_config_gear_overrides_all() {
2790 use std::time::Duration;
2791
2792 let tmp = tempdir().unwrap();
2793 let home_dir = tmp.path();
2794
2795 let mut app = create_app_with_server(
2797 "test_server",
2798 DbConnConfig {
2799 host: Some("localhost".to_owned()),
2800 dbname: Some("testdb".to_owned()),
2801 pool: Some(PoolCfg {
2802 max_conns: Some(10),
2803 min_conns: None,
2804 acquire_timeout: Some(Duration::from_secs(5)),
2805 idle_timeout: None,
2806 max_lifetime: None,
2807 test_before_acquire: None,
2808 }),
2809 ..Default::default()
2810 },
2811 );
2812
2813 add_gear_to_app(
2815 &mut app,
2816 "test_gear",
2817 &serde_json::json!({
2818 "server": "test_server",
2819 "pool": {
2820 "max_conns": 30,
2821 "acquire_timeout": "10s"
2822 }
2823 }),
2824 );
2825
2826 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2827 assert!(result.is_some());
2828
2829 let (_dsn, pool) = result.unwrap();
2830 assert_eq!(pool.max_conns, Some(30));
2831 assert_eq!(pool.acquire_timeout, Some(Duration::from_secs(10)));
2832 }
2833
2834 #[test]
2835 fn test_list_gear_names() {
2836 let mut app = create_minimal_app();
2837 add_gear_with_config(&mut app, "zebra_gear", &serde_json::json!({}));
2838 add_gear_with_config(&mut app, "alpha_gear", &serde_json::json!({}));
2839 add_gear_with_config(&mut app, "beta_gear", &serde_json::json!({}));
2840
2841 let gear_names = list_gear_names(&app);
2842
2843 assert_eq!(gear_names.len(), 3);
2845 assert_eq!(gear_names[0], "alpha_gear");
2846 assert_eq!(gear_names[1], "beta_gear");
2847 assert_eq!(gear_names[2], "zebra_gear");
2848 }
2849
2850 #[test]
2851 fn test_list_gear_names_empty() {
2852 let app = create_minimal_app();
2853 let gear_names = list_gear_names(&app);
2854 assert_eq!(gear_names.len(), 0);
2855 }
2856
2857 #[test]
2858 fn test_redact_dsn_password_postgres() {
2859 let dsn = "postgres://user:secretpass@localhost:5432/mydb";
2860 let redacted = redact_dsn_password(dsn).unwrap();
2861 assert_eq!(
2862 redacted,
2863 "postgres://user:***REDACTED***@localhost:5432/mydb"
2864 );
2865 }
2866
2867 #[test]
2868 fn test_redact_dsn_password_no_password() {
2869 let dsn = "postgres://user@localhost:5432/mydb";
2870 let redacted = redact_dsn_password(dsn).unwrap();
2871 assert_eq!(redacted, "postgres://user@localhost:5432/mydb");
2873 }
2874
2875 #[test]
2876 fn test_redact_dsn_password_special_chars() {
2877 let dsn = "postgres://user:p@ss%40word@localhost:5432/mydb";
2878 let redacted = redact_dsn_password(dsn).unwrap();
2879 assert_eq!(
2880 redacted,
2881 "postgres://user:***REDACTED***@localhost:5432/mydb"
2882 );
2883 }
2884
2885 #[test]
2886 fn test_render_effective_gears_config() {
2887 let mut app = create_minimal_app();
2888 add_gear_with_config(
2889 &mut app,
2890 "test_gear",
2891 &serde_json::json!({
2892 "my_setting": "my_value",
2893 "enabled": true
2894 }),
2895 );
2896
2897 let result = render_effective_gears_config(&app).unwrap();
2898
2899 assert!(result.is_object());
2901 let gears = result.as_object().unwrap();
2902 assert!(gears.contains_key("test_gear"));
2903
2904 let test_gear = gears.get("test_gear").unwrap();
2905 assert!(test_gear.is_object());
2906 let test_gear_obj = test_gear.as_object().unwrap();
2907
2908 assert!(test_gear_obj.contains_key("config"));
2910
2911 let config = test_gear_obj.get("config").unwrap();
2913 assert_eq!(config.get("my_setting").unwrap(), "my_value");
2914 assert_eq!(config.get("enabled").unwrap(), true);
2915 }
2916
2917 #[test]
2918 fn test_render_effective_gears_config_with_database() {
2919 let mut app = create_app_with_server(
2920 "test_server",
2921 DbConnConfig {
2922 host: Some("localhost".to_owned()),
2923 port: Some(5432),
2924 user: Some("user".to_owned()),
2925 password: Some(toolkit_utils::SecretString::new("pass")),
2926 dbname: Some("db".to_owned()),
2927 ..Default::default()
2928 },
2929 );
2930
2931 add_gear_to_app(
2933 &mut app,
2934 "test_gear",
2935 &serde_json::json!({
2936 "server": "test_server"
2937 }),
2938 );
2939
2940 let result = render_effective_gears_config(&app).unwrap();
2941 let gears = result.as_object().unwrap();
2942 let test_gear = gears.get("test_gear").unwrap().as_object().unwrap();
2943
2944 assert!(test_gear.contains_key("database"));
2946 let database = test_gear.get("database").unwrap().as_object().unwrap();
2947 assert!(database.contains_key("dsn"));
2948
2949 let dsn = database.get("dsn").unwrap().as_str().unwrap();
2951 assert!(dsn.contains("***REDACTED***"));
2952 assert!(!dsn.contains("pass"));
2953 }
2954
2955 #[test]
2956 fn test_render_effective_gears_config_minimal() {
2957 let mut app = create_minimal_app();
2959
2960 app.gears
2962 .insert("minimal_gear".to_owned(), serde_json::json!({}));
2963
2964 let result = render_effective_gears_config(&app).unwrap();
2965
2966 assert!(result.is_object());
2969 }
2970
2971 #[test]
2972 fn test_dump_effective_gears_config_yaml() {
2973 let mut app = create_minimal_app();
2974 add_gear_with_config(
2975 &mut app,
2976 "test_gear",
2977 &serde_json::json!({
2978 "setting": "value"
2979 }),
2980 );
2981
2982 let yaml = dump_effective_gears_config_yaml(&app).unwrap();
2983
2984 assert!(yaml.contains("test_gear:"));
2986 assert!(yaml.contains("config:"));
2987 assert!(yaml.contains("setting: value"));
2988 }
2989
2990 #[test]
2991 fn test_dump_effective_gears_config_json() {
2992 let mut app = create_minimal_app();
2993 add_gear_with_config(
2994 &mut app,
2995 "test_gear",
2996 &serde_json::json!({
2997 "setting": "value"
2998 }),
2999 );
3000
3001 let json = dump_effective_gears_config_json(&app).unwrap();
3002
3003 assert!(json.contains("\"test_gear\""));
3005 assert!(json.contains("\"config\""));
3006 assert!(json.contains("\"setting\""));
3007 assert!(json.contains("\"value\""));
3008
3009 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
3011 assert!(parsed.is_object());
3012 }
3013
3014 #[test]
3015 fn test_render_multiple_gears() {
3016 let mut app = create_minimal_app();
3017 add_gear_with_config(&mut app, "gear_a", &serde_json::json!({"a": 1}));
3018 add_gear_with_config(&mut app, "gear_b", &serde_json::json!({"b": 2}));
3019 add_gear_with_config(&mut app, "gear_c", &serde_json::json!({"c": 3}));
3020
3021 let result = render_effective_gears_config(&app).unwrap();
3022 let gears = result.as_object().unwrap();
3023
3024 assert_eq!(gears.len(), 3);
3025 assert!(gears.contains_key("gear_a"));
3026 assert!(gears.contains_key("gear_b"));
3027 assert!(gears.contains_key("gear_c"));
3028 }
3029
3030 #[derive(Debug, Deserialize, Default, PartialEq)]
3033 struct TestVendorConfig {
3034 #[serde(default)]
3035 api_token: String,
3036 #[serde(default)]
3037 api_url: String,
3038 }
3039
3040 #[test]
3041 fn test_vendor_section_parses_from_yaml() {
3042 let yaml = r#"
3043server:
3044 home_dir: "~/.test_vendor"
3045vendor:
3046 acme:
3047 api_token: "acme-token-123"
3048 api_url: "https://acme.example.com"
3049 other_corp:
3050 api_token: "other-token-789"
3051 api_url: "https://other.example.com"
3052"#;
3053 let config: AppConfig = serde_saphyr::from_str(yaml).unwrap();
3054 assert_eq!(config.vendor.len(), 2);
3055 assert!(config.vendor.contains_key("acme"));
3056 assert!(config.vendor.contains_key("other_corp"));
3057
3058 let acme: TestVendorConfig = config.vendor_config("acme").unwrap();
3059 assert_eq!(acme.api_token, "acme-token-123");
3060 assert_eq!(acme.api_url, "https://acme.example.com");
3061
3062 let other: TestVendorConfig = config.vendor_config("other_corp").unwrap();
3063 assert_eq!(other.api_token, "other-token-789");
3064 assert_eq!(other.api_url, "https://other.example.com");
3065 }
3066
3067 #[test]
3068 fn test_vendor_section_defaults_to_empty() {
3069 let config = AppConfig::default();
3070 assert!(config.vendor.is_empty());
3071 }
3072
3073 #[test]
3074 fn test_vendor_config_typed_access() {
3075 let mut config = AppConfig::default();
3076 config.vendor.insert(
3077 "acme".to_owned(),
3078 serde_json::json!({
3079 "api_token": "acme-token-123",
3080 "api_url": "https://acme.example.com"
3081 }),
3082 );
3083
3084 let acme: TestVendorConfig = config.vendor_config("acme").unwrap();
3085 assert_eq!(acme.api_token, "acme-token-123");
3086 assert_eq!(acme.api_url, "https://acme.example.com");
3087 }
3088
3089 #[test]
3090 fn test_vendor_config_not_found() {
3091 let config = AppConfig::default();
3092 let result: Result<TestVendorConfig, _> = config.vendor_config("nonexistent");
3093 assert!(matches!(
3094 result,
3095 Err(VendorConfigError::NotFound { ref vendor }) if vendor == "nonexistent"
3096 ));
3097 }
3098
3099 #[test]
3100 fn test_vendor_config_invalid_structure() {
3101 let mut config = AppConfig::default();
3102 config
3103 .vendor
3104 .insert("bad".to_owned(), serde_json::json!("not an object"));
3105
3106 let result: Result<TestVendorConfig, _> = config.vendor_config("bad");
3107 assert!(matches!(
3108 result,
3109 Err(VendorConfigError::InvalidConfig { ref vendor, .. }) if vendor == "bad"
3110 ));
3111 }
3112
3113 #[test]
3114 fn test_vendor_config_or_default_missing() {
3115 let config = AppConfig::default();
3116 let acme: TestVendorConfig = config.vendor_config_or_default("acme").unwrap();
3117 assert_eq!(acme, TestVendorConfig::default());
3118 }
3119
3120 #[test]
3121 fn test_vendor_config_or_default_present() {
3122 let mut config = AppConfig::default();
3123 config.vendor.insert(
3124 "acme".to_owned(),
3125 serde_json::json!({ "api_token": "acme-token-123" }),
3126 );
3127
3128 let acme: TestVendorConfig = config.vendor_config_or_default("acme").unwrap();
3129 assert_eq!(acme.api_token, "acme-token-123");
3130 }
3131
3132 #[test]
3133 #[serial]
3134 fn test_vendor_config_env_override() {
3135 let tmp = tempdir().unwrap();
3136 let cfg_path = tmp.path().join("cfg.yaml");
3137 let yaml = r#"
3138server:
3139 home_dir: "~/.test_vendor"
3140vendor:
3141 env_test_vendor:
3142 api_token: "from_yaml"
3143"#;
3144 fs::write(&cfg_path, yaml).unwrap();
3145
3146 with_var(
3147 "APP__VENDOR__ENV_TEST_VENDOR__API_TOKEN",
3148 Some("from_env"),
3149 || {
3150 let config = AppConfig::load_layered(&cfg_path).unwrap();
3151 let v: TestVendorConfig = config.vendor_config("env_test_vendor").unwrap();
3152 assert_eq!(v.api_token, "from_env");
3153 },
3154 );
3155 }
3156
3157 #[test]
3158 #[serial]
3159 fn test_oop_http_labels_from_yaml_and_env() {
3160 let tmp = tempdir().unwrap();
3161 let cfg_path = tmp.path().join("cfg.yaml");
3162 let yaml = r#"
3163server:
3164 home_dir: "~/.test_oop_labels"
3165oop_http:
3166 listen_addr: "0.0.0.0:8080"
3167 labels:
3168 role: "ingest"
3169"#;
3170 fs::write(&cfg_path, yaml).unwrap();
3171
3172 with_var("APP__OOP_HTTP__LABELS__ZONE", Some("us-east-1"), || {
3175 let config = AppConfig::load_layered(&cfg_path).unwrap();
3176 let oop = config.oop_http.expect("oop_http present");
3177 assert_eq!(oop.labels.get("role"), Some(&"ingest".to_owned()));
3178 assert_eq!(
3179 oop.labels.get("zone"),
3180 Some(&"us-east-1".to_owned()),
3181 "APP__OOP_HTTP__LABELS__ZONE should populate labels[zone]"
3182 );
3183 });
3184
3185 with_var("APP__OOP_HTTP__LABELS__SHARD", Some("7"), || {
3190 let config = AppConfig::load_layered(&cfg_path).unwrap();
3191 let oop = config.oop_http.expect("oop_http present");
3192 assert_eq!(
3193 oop.labels.get("shard"),
3194 Some(&"7".to_owned()),
3195 "a bare-numeric env label value should be coerced to the string \"7\""
3196 );
3197 });
3198 }
3199
3200 #[test]
3201 #[serial]
3202 fn test_gear_config_env_override_underscore_gear_name() {
3203 let tmp = tempdir().unwrap();
3206 let cfg_path = tmp.path().join("cfg.yaml");
3207 let yaml = r#"
3208server:
3209 home_dir: "~/.test_gear_env_underscore"
3210gears:
3211 static-authz-plugin:
3212 config:
3213 vendor: "from_yaml"
3214"#;
3215 fs::write(&cfg_path, yaml).unwrap();
3216
3217 with_var(
3218 "APP__GEARS__STATIC_AUTHZ_PLUGIN__CONFIG__VENDOR",
3219 Some("acme"),
3220 || {
3221 let config = AppConfig::load_layered(&cfg_path).unwrap();
3222 let gear = config.gears.get("static-authz-plugin").unwrap();
3223 assert_eq!(gear["config"]["vendor"], serde_json::json!("acme"));
3224 },
3225 );
3226 }
3227
3228 #[test]
3229 #[serial]
3230 fn test_gear_config_env_override_dash_gear_name_backcompat() {
3231 let tmp = tempdir().unwrap();
3233 let cfg_path = tmp.path().join("cfg.yaml");
3234 let yaml = r#"
3235server:
3236 home_dir: "~/.test_gear_env_dash"
3237gears:
3238 static-authz-plugin:
3239 config:
3240 vendor: "from_yaml"
3241"#;
3242 fs::write(&cfg_path, yaml).unwrap();
3243
3244 with_var(
3245 "APP__GEARS__static-authz-plugin__CONFIG__VENDOR",
3246 Some("acme"),
3247 || {
3248 let config = AppConfig::load_layered(&cfg_path).unwrap();
3249 let gear = config.gears.get("static-authz-plugin").unwrap();
3250 assert_eq!(gear["config"]["vendor"], serde_json::json!("acme"));
3251 },
3252 );
3253 }
3254
3255 #[test]
3256 #[serial]
3257 fn test_gear_config_env_override_preserves_field_underscores() {
3258 let tmp = tempdir().unwrap();
3260 let cfg_path = tmp.path().join("cfg.yaml");
3261 let yaml = r#"
3262server:
3263 home_dir: "~/.test_gear_env_field"
3264gears:
3265 static-authz-plugin:
3266 config:
3267 some_field: "from_yaml"
3268"#;
3269 fs::write(&cfg_path, yaml).unwrap();
3270
3271 with_var(
3272 "APP__GEARS__STATIC_AUTHZ_PLUGIN__CONFIG__SOME_FIELD",
3273 Some("from_env"),
3274 || {
3275 let config = AppConfig::load_layered(&cfg_path).unwrap();
3276 let gear = config.gears.get("static-authz-plugin").unwrap();
3277 assert_eq!(gear["config"]["some_field"], serde_json::json!("from_env"));
3278 },
3279 );
3280 }
3281
3282 #[test]
3283 #[serial]
3284 fn test_vendor_config_env_override_unaffected_by_gear_remap() {
3285 let tmp = tempdir().unwrap();
3290 let cfg_path = tmp.path().join("cfg.yaml");
3291 let yaml = r#"
3292server:
3293 home_dir: "~/.test_vendor_unaffected"
3294vendor:
3295 env_test_vendor:
3296 api_token: "from_yaml"
3297"#;
3298 fs::write(&cfg_path, yaml).unwrap();
3299
3300 with_var(
3301 "APP__VENDOR__ENV_TEST_VENDOR__API_TOKEN",
3302 Some("from_env"),
3303 || {
3304 let config = AppConfig::load_layered(&cfg_path).unwrap();
3305
3306 let v: TestVendorConfig = config.vendor_config("env_test_vendor").unwrap();
3308 assert_eq!(v.api_token, "from_env");
3309
3310 assert!(
3313 config.vendor.contains_key("env_test_vendor"),
3314 "underscore vendor key must be preserved"
3315 );
3316 assert!(
3317 !config.vendor.contains_key("env-test-vendor"),
3318 "gear remap leaked into the vendor branch"
3319 );
3320 },
3321 );
3322 }
3323
3324 #[test]
3325 fn test_vendor_multiple_vendors_typed_access() {
3326 let mut config = AppConfig::default();
3327 config.vendor.insert(
3328 "acme".to_owned(),
3329 serde_json::json!({ "api_token": "acme-token", "api_url": "https://acme.com" }),
3330 );
3331 config.vendor.insert(
3332 "other_corp".to_owned(),
3333 serde_json::json!({ "api_token": "other-token", "api_url": "https://other.com" }),
3334 );
3335
3336 let acme: TestVendorConfig = config.vendor_config("acme").unwrap();
3337 let other: TestVendorConfig = config.vendor_config("other_corp").unwrap();
3338
3339 assert_eq!(acme.api_token, "acme-token");
3340 assert_eq!(other.api_token, "other-token");
3341 assert_eq!(acme.api_url, "https://acme.com");
3342 assert_eq!(other.api_url, "https://other.com");
3343 }
3344
3345 #[test]
3346 fn test_vendor_nested_config() {
3347 #[derive(Debug, Deserialize, PartialEq)]
3348 struct NestedVendorConfig {
3349 api_url: String,
3350 feature_flags: FeatureFlags,
3351 }
3352
3353 #[derive(Debug, Deserialize, PartialEq)]
3354 struct FeatureFlags {
3355 beta_mode: bool,
3356 max_retries: u32,
3357 }
3358
3359 let mut config = AppConfig::default();
3360 config.vendor.insert(
3361 "acme".to_owned(),
3362 serde_json::json!({
3363 "api_url": "https://acme.com",
3364 "feature_flags": {
3365 "beta_mode": true,
3366 "max_retries": 3
3367 }
3368 }),
3369 );
3370
3371 let acme: NestedVendorConfig = config.vendor_config("acme").unwrap();
3372 assert_eq!(acme.api_url, "https://acme.com");
3373 assert!(acme.feature_flags.beta_mode);
3374 assert_eq!(acme.feature_flags.max_retries, 3);
3375 }
3376
3377 #[test]
3378 fn test_vendor_config_or_default_invalid_returns_error() {
3379 let mut config = AppConfig::default();
3380 config
3381 .vendor
3382 .insert("bad".to_owned(), serde_json::json!("not an object"));
3383
3384 let result: Result<TestVendorConfig, _> = config.vendor_config_or_default("bad");
3385 assert!(matches!(
3386 result,
3387 Err(VendorConfigError::InvalidConfig { ref vendor, .. }) if vendor == "bad"
3388 ));
3389 }
3390
3391 #[test]
3392 fn test_vendor_config_yaml_roundtrip() {
3393 let mut config = AppConfig::default();
3394 config.vendor.insert(
3395 "acme".to_owned(),
3396 serde_json::json!({ "api_token": "acme-token-123" }),
3397 );
3398
3399 let yaml = config.to_yaml().unwrap();
3400 assert!(yaml.contains("vendor"));
3401 assert!(yaml.contains("acme"));
3402 assert!(yaml.contains("acme-token-123"));
3403 }
3404
3405 #[test]
3406 fn test_vendor_coexists_with_gears() {
3407 let mut config = AppConfig::default();
3408 config.gears.insert(
3409 "my_gear".to_owned(),
3410 serde_json::json!({ "config": { "some_setting": true } }),
3411 );
3412 config.vendor.insert(
3413 "acme".to_owned(),
3414 serde_json::json!({ "api_token": "acme-token-123" }),
3415 );
3416
3417 assert!(config.gears.contains_key("my_gear"));
3418 assert!(config.vendor.contains_key("acme"));
3419
3420 let acme: TestVendorConfig = config.vendor_config("acme").unwrap();
3421 assert_eq!(acme.api_token, "acme-token-123");
3422 }
3423
3424 #[test]
3425 fn test_vendor_error_display_messages() {
3426 let not_found = VendorConfigError::NotFound {
3427 vendor: "acme".to_owned(),
3428 };
3429 assert_eq!(
3430 not_found.to_string(),
3431 "vendor 'acme' not found in configuration"
3432 );
3433
3434 let invalid = VendorConfigError::InvalidConfig {
3435 vendor: "bad".to_owned(),
3436 cause: serde_json::from_str::<TestVendorConfig>("invalid").unwrap_err(),
3437 };
3438 let msg = invalid.to_string();
3439 assert!(msg.starts_with("invalid config for vendor 'bad':"));
3440 assert!(std::error::Error::source(&invalid).is_none());
3441 }
3442
3443 #[test]
3444 fn test_vendor_empty_object_in_yaml() {
3445 let yaml = r#"
3446server:
3447 home_dir: "~/.test_vendor"
3448vendor: {}
3449"#;
3450 let config: AppConfig = serde_saphyr::from_str(yaml).unwrap();
3451 assert!(config.vendor.is_empty());
3452 }
3453
3454 #[test]
3462 #[serial]
3463 fn test_legacy_tracing_section_reports_migration() {
3464 let tmp = tempdir().unwrap();
3465 let cfg_path = tmp.path().join("cfg.yaml");
3466 let yaml = r#"
3467server:
3468 home_dir: "~/.test_legacy_tracing"
3469tracing:
3470 enabled: true
3471 service_name: "cf-gears-api"
3472 exporter:
3473 kind: "otlp_grpc"
3474 endpoint: "http://127.0.0.1:4317"
3475"#;
3476 fs::write(&cfg_path, yaml).unwrap();
3477
3478 let result = AppConfig::load_layered(&cfg_path);
3479 assert!(result.is_err(), "legacy `tracing:` should be rejected");
3480 let msg = format!("{:?}", result.unwrap_err());
3481
3482 for expected in [
3483 "opentelemetry",
3484 "opentelemetry.resource.service_name",
3485 "docs/TRACING_SETUP.md",
3486 ] {
3487 assert!(msg.contains(expected), "missing {expected:?} in: {msg}");
3488 }
3489 }
3490
3491 #[test]
3494 #[serial]
3495 fn test_nested_opentelemetry_tracing_is_accepted() {
3496 let tmp = tempdir().unwrap();
3497 let cfg_path = tmp.path().join("cfg.yaml");
3498 let yaml = r#"
3499server:
3500 home_dir: "~/.test_nested_tracing"
3501opentelemetry:
3502 resource:
3503 service_name: "cf-gears-api"
3504 tracing:
3505 enabled: true
3506 metrics:
3507 enabled: false
3508"#;
3509 fs::write(&cfg_path, yaml).unwrap();
3510
3511 let config = AppConfig::load_layered(&cfg_path).expect("nested form should load");
3512 assert!(config.opentelemetry.tracing.enabled);
3513 assert_eq!(config.opentelemetry.resource.service_name, "cf-gears-api");
3514 }
3515
3516 #[test]
3522 #[serial]
3523 fn test_legacy_tracing_env_override_reports_migration() {
3524 let tmp = tempdir().unwrap();
3525 let cfg_path = tmp.path().join("cfg.yaml");
3526 let yaml = r#"
3527server:
3528 home_dir: "~/.test_legacy_tracing_env"
3529opentelemetry:
3530 resource:
3531 service_name: "cf-gears-api"
3532"#;
3533 fs::write(&cfg_path, yaml).unwrap();
3534
3535 with_var("APP__TRACING__ENABLED", Some("true"), || {
3536 let result = AppConfig::load_layered(&cfg_path);
3537 assert!(
3538 result.is_err(),
3539 "legacy `APP__TRACING__*` override should be rejected"
3540 );
3541 let msg = format!("{:?}", result.unwrap_err());
3542 assert!(
3543 msg.contains("opentelemetry"),
3544 "missing migration hint in: {msg}"
3545 );
3546 });
3547 }
3548
3549 #[test]
3555 #[serial]
3556 fn test_reject_duplicate_gear_names() {
3557 let tmp = tempdir().unwrap();
3558 let cfg_path = tmp.path().join("cfg.yaml");
3559 let yaml = r#"
3560server:
3561 home_dir: "~/.test_dup"
3562gears:
3563 gear1:
3564 config: {}
3565 gear2:
3566 config: {}
3567 gear1:
3568 config: {}
3569"#;
3570 fs::write(&cfg_path, yaml).unwrap();
3571
3572 let result = AppConfig::load_layered(&cfg_path);
3573 assert!(result.is_err(), "duplicate gear names should be rejected");
3574 let msg = format!("{:?}", result.unwrap_err());
3575 assert!(
3576 msg.contains("duplicate") || msg.contains("Duplicate"),
3577 "error should mention duplicates: {msg}"
3578 );
3579 }
3580
3581 #[test]
3582 #[serial]
3583 fn test_reject_duplicate_keys_in_gear_file() {
3584 let tmp = tempdir().unwrap();
3585 let gears_dir = tmp.path().join("gears.d");
3586 fs::create_dir_all(&gears_dir).unwrap();
3587
3588 let gear_yaml = r#"
3590config:
3591 key1: "value1"
3592config:
3593 key2: "value2"
3594"#;
3595 fs::write(gears_dir.join("bad_gear.yaml"), gear_yaml).unwrap();
3596
3597 let cfg_yaml = format!(
3598 r#"
3599server:
3600 home_dir: "~/.test_dup_modfile"
3601gears_dir: "{}"
3602"#,
3603 normalize_path(&gears_dir)
3604 );
3605 let cfg_path = tmp.path().join("cfg.yaml");
3606 fs::write(&cfg_path, cfg_yaml).unwrap();
3607
3608 let result = AppConfig::load_layered(&cfg_path);
3609 assert!(
3610 result.is_err(),
3611 "duplicate keys in a gear file should be rejected"
3612 );
3613 let msg = format!("{:?}", result.unwrap_err());
3614 assert!(
3615 msg.contains("duplicate") || msg.contains("Duplicate"),
3616 "error should mention duplicates: {msg}"
3617 );
3618 }
3619
3620 #[test]
3621 #[serial]
3622 fn test_no_false_positive_on_unique_gears() {
3623 let tmp = tempdir().unwrap();
3624 let cfg_path = tmp.path().join("cfg.yaml");
3625 let yaml = r#"
3626server:
3627 home_dir: "~/.test_ok"
3628gears:
3629 gear1:
3630 config: {}
3631 gear2:
3632 config: {}
3633 gear3:
3634 config: {}
3635"#;
3636 fs::write(&cfg_path, yaml).unwrap();
3637
3638 let result = AppConfig::load_layered(&cfg_path);
3639 assert!(
3640 result.is_ok(),
3641 "unique gear names should be accepted: {:?}",
3642 result.unwrap_err()
3643 );
3644 }
3645}
3646
3647