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