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)]
172 pub internal_auth: Option<InternalAuthConfig>,
173}
174
175fn default_drain_timeout_secs() -> u64 {
176 30
177}
178
179fn default_healthcheck_timeout_ms() -> u64 {
180 500
181}
182
183#[derive(Debug, Clone, Deserialize, Serialize)]
185#[serde(deny_unknown_fields)]
186pub struct InternalAuthConfig {
187 #[serde(default)]
190 pub audiences: Vec<String>,
191}
192
193impl ConfigProvider for AppConfig {
194 fn get_gear_config(&self, gear_name: &str) -> Option<&serde_json::Value> {
195 self.gears.get(gear_name)
196 }
197}
198
199#[derive(Debug, Clone, Deserialize, Serialize)]
200#[serde(deny_unknown_fields)]
201pub struct ServerConfig {
202 #[serde(default = "default_server_name")]
203 pub name: String,
204 #[serde(default = "default_home_dir")]
205 pub home_dir: PathBuf, }
207
208fn default_server_name() -> String {
209 "cf-gears".to_owned()
210}
211
212fn default_home_dir() -> PathBuf {
213 super::host::paths::default_home_dir().join(".cf-gears")
214}
215
216impl Default for ServerConfig {
217 fn default() -> Self {
218 Self {
219 name: default_server_name(),
220 home_dir: default_home_dir(),
221 }
222 }
223}
224
225impl ServerConfig {
226 fn normalize_home_dir_inplace(&mut self) -> Result<()> {
227 self.home_dir = super::host::normalize_path(
228 self.home_dir
229 .to_str()
230 .context("home directory configuration is not a valid path")?,
231 )
232 .context("home_dir normalization failed")?;
233
234 std::fs::create_dir_all(&self.home_dir).context("Failed to create home_dir")?;
235
236 Ok(())
237 }
238}
239
240#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
242#[serde(rename_all = "lowercase")]
243pub enum ConsoleFormat {
244 #[default]
246 Text,
247 Json,
249}
250
251pub type LoggingConfig = HashMap<String, Section>;
254
255pub type VendorConfig = HashMap<String, serde_json::Value>;
259
260mod optional_level_serde {
262 use serde::{Deserialize, Deserializer, Serializer};
263 use tracing::Level;
264
265 #[allow(clippy::ref_option, clippy::trivially_copy_pass_by_ref)]
266 pub fn serialize<S>(level: &Option<Level>, serializer: S) -> Result<S::Ok, S::Error>
267 where
268 S: Serializer,
269 {
270 match level {
271 Some(l) => serializer.serialize_str(l.as_str()),
272 None => serializer.serialize_str("off"),
273 }
274 }
275
276 pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<Level>, D::Error>
277 where
278 D: Deserializer<'de>,
279 {
280 let s = String::deserialize(deserializer)?;
281 match s.to_lowercase().as_str() {
282 "trace" => Ok(Some(Level::TRACE)),
283 "debug" => Ok(Some(Level::DEBUG)),
284 "info" => Ok(Some(Level::INFO)),
285 "warn" => Ok(Some(Level::WARN)),
286 "error" => Ok(Some(Level::ERROR)),
287 "off" | "none" => Ok(None),
288 _ => Err(serde::de::Error::custom(format!("invalid level: {s}"))),
289 }
290 }
291
292 #[allow(clippy::unnecessary_wraps)]
293 pub fn default() -> Option<Level> {
294 Some(Level::INFO)
295 }
296}
297
298#[derive(Debug, Serialize, Deserialize, Clone)]
299pub struct SectionFile {
300 pub file: String,
301 #[serde(
302 default = "optional_level_serde::default",
303 with = "optional_level_serde"
304 )]
305 pub file_level: Option<Level>,
306}
307
308#[derive(Debug, Serialize, Deserialize, Clone)]
309pub struct Section {
310 #[serde(default)]
311 pub console_format: ConsoleFormat,
312 #[serde(
313 default = "optional_level_serde::default",
314 with = "optional_level_serde"
315 )]
316 pub console_level: Option<Level>,
317 #[serde(flatten)]
318 pub section_file: Option<SectionFile>,
319 pub max_age_days: Option<u32>, #[serde(default)]
321 pub max_backups: Option<usize>, #[serde(default)]
323 pub max_size_mb: Option<u64>, }
325
326impl Section {
327 #[must_use]
328 pub fn file(&self) -> Option<&str> {
329 self.section_file
330 .as_ref()
331 .map(|f| f.file.as_str())
332 .filter(|s| !s.is_empty())
333 }
334
335 #[must_use]
336 pub fn file_level(&self) -> Option<Level> {
337 self.section_file.as_ref().and_then(|f| f.file_level)
338 }
339}
340
341#[must_use]
343pub fn default_logging_config() -> LoggingConfig {
344 let mut logging = HashMap::new();
345 logging.insert(
346 "default".to_owned(),
347 Section {
348 console_level: Some(Level::INFO),
349 section_file: Some(SectionFile {
350 file: "logs/cf-gears.log".to_owned(),
351 file_level: Some(Level::DEBUG),
352 }),
353 console_format: ConsoleFormat::default(),
354 max_age_days: Some(7),
355 max_backups: Some(3),
356 max_size_mb: Some(100),
357 },
358 );
359 logging
360}
361
362pub(crate) fn remap_gear_env_key(key: &str) -> String {
373 let lower = key.to_ascii_lowercase();
375 let mut parts: Vec<&str> = lower.split('.').collect();
376 if parts.first() == Some(&"gears") && parts.len() >= 2 {
377 let gear = parts[1].replace('_', "-");
378 parts[1] = gear.as_str();
379 parts.join(".")
380 } else {
381 lower
382 }
383}
384
385impl AppConfig {
386 pub fn load_layered(config_path: &PathBuf) -> Result<Self> {
392 use figment::{
393 Figment,
394 providers::{Env, Format, Serialized},
395 };
396
397 let figment = Figment::new()
401 .merge(Serialized::defaults(AppConfig::default()))
402 .merge(StrictYaml::file(config_path))
403 .merge(
405 Env::prefixed("APP__")
406 .split("__")
407 .map(|key| remap_gear_env_key(key.as_str()).into()),
408 );
409
410 let mut config: AppConfig = figment
411 .extract()
412 .with_context(|| "Failed to extract config from figment".to_owned())?;
413
414 config
416 .server
417 .normalize_home_dir_inplace()
418 .context("Failed to resolve server.home_dir")?;
419
420 if let Some(dir) = config.gears_dir.as_ref() {
422 merge_gear_files(&mut config.gears, dir)?;
423 }
424
425 Ok(config)
426 }
427
428 pub fn load_or_default(config_path: Option<&PathBuf>) -> Result<Self> {
434 if let Some(path) = config_path {
435 ensure!(
436 path.is_file(),
437 "config file does not exist: {}",
438 path.to_string_lossy()
439 );
440 Self::load_layered(path)
441 } else {
442 let mut c = Self::default();
443 c.server
444 .normalize_home_dir_inplace()
445 .context("Failed to resolve server.home_dir (defaults)")?;
446 Ok(c)
447 }
448 }
449
450 pub fn to_yaml(&self) -> Result<String> {
455 serde_saphyr::to_string(self).context("Failed to serialize config to YAML")
456 }
457
458 pub fn vendor_config<T: DeserializeOwned>(
464 &self,
465 vendor_name: &str,
466 ) -> Result<T, VendorConfigError> {
467 let raw = self
468 .vendor
469 .get(vendor_name)
470 .ok_or_else(|| VendorConfigError::NotFound {
471 vendor: vendor_name.to_owned(),
472 })?;
473 T::deserialize(raw).map_err(|e| VendorConfigError::InvalidConfig {
474 vendor: vendor_name.to_owned(),
475 cause: e,
476 })
477 }
478
479 pub fn vendor_config_or_default<T: DeserializeOwned + Default>(
485 &self,
486 vendor_name: &str,
487 ) -> Result<T, VendorConfigError> {
488 let Some(raw) = self.vendor.get(vendor_name) else {
489 return Ok(T::default());
490 };
491 T::deserialize(raw).map_err(|e| VendorConfigError::InvalidConfig {
492 vendor: vendor_name.to_owned(),
493 cause: e,
494 })
495 }
496
497 pub fn apply_cli_overrides(&mut self, verbose: u8) {
499 if let Some(default_section) = self.logging.get_mut("default") {
501 default_section.console_level = match verbose {
502 0 => default_section.console_level, 1 => Some(Level::DEBUG),
504 _ => Some(Level::TRACE),
505 };
506 }
507 }
508}
509
510#[derive(Debug, Clone)]
512pub struct CliArgs {
513 pub config: Option<String>,
514 pub print_config: bool,
515 pub verbose: u8,
516 pub mock: bool,
517}
518
519fn strict_yaml_parse<T: serde::de::DeserializeOwned>(s: &str) -> Result<T, serde_saphyr::Error> {
521 let opts = serde_saphyr::Options {
522 duplicate_keys: serde_saphyr::DuplicateKeyPolicy::Error,
523 ..serde_saphyr::Options::default()
524 };
525 serde_saphyr::from_str_with_options(s, opts)
526}
527
528struct StrictYaml;
534
535impl figment::providers::Format for StrictYaml {
536 type Error = serde_saphyr::Error;
537
538 const NAME: &'static str = "YAML";
539
540 fn from_str<T: serde::de::DeserializeOwned>(s: &str) -> Result<T, Self::Error> {
541 strict_yaml_parse(s)
542 }
543}
544
545fn merge_gear_files(
546 bag: &mut HashMap<String, serde_json::Value>,
547 dir: impl AsRef<Path>,
548) -> Result<()> {
549 use std::fs;
550 let dir = dir.as_ref();
551 if !dir.exists() {
552 return Ok(());
553 }
554 for entry in fs::read_dir(dir)? {
555 let entry = entry?;
556 let path = entry.path();
557 if !path.is_file() {
558 continue;
559 }
560 let ext = path
561 .extension()
562 .and_then(|s| s.to_str())
563 .unwrap_or("")
564 .to_ascii_lowercase();
565 if ext != "yml" && ext != "yaml" {
566 continue;
567 }
568 let name = path
569 .file_stem()
570 .and_then(|s| s.to_str())
571 .unwrap_or("")
572 .to_owned();
573 let raw = fs::read_to_string(&path)?;
574 let json: serde_json::Value = strict_yaml_parse(&raw)
575 .with_context(|| format!("failed to parse gear file: {}", path.display()))?;
576 bag.insert(name, json);
577 }
578 Ok(())
579}
580
581pub fn expand_env_in_dsn(dsn: &str) -> Result<String> {
589 toolkit_utils::var_expand::expand_env_vars(dsn).map_err(|e| anyhow::anyhow!("{e}"))
590}
591
592pub fn resolve_password(password: Option<&str>) -> Result<Option<String>> {
597 if let Some(pwd) = password {
598 if pwd.starts_with("${") && pwd.ends_with('}') {
599 let var_name = &pwd[2..pwd.len() - 1];
601 let resolved = std::env::var(var_name).with_context(|| {
602 format!("Environment variable '{var_name}' not found for password")
603 })?;
604 Ok(Some(resolved))
605 } else {
606 Ok(Some(pwd.to_owned()))
608 }
609 } else {
610 Ok(None)
611 }
612}
613
614pub fn validate_dsn(dsn: &str) -> Result<()> {
620 if dsn.starts_with("sqlite:") {
622 return Ok(());
623 }
624
625 let _parsed = dsn::parse(dsn).map_err(|e| anyhow::anyhow!("Invalid DSN '{dsn}': {e}"))?;
626
627 Ok(())
628}
629
630fn resolve_sqlite_dsn(
635 dsn: &str,
636 home_dir: &Path,
637 gear_name: &str,
638 dry_run: bool,
639) -> Result<String> {
640 if dsn.contains("@file(") {
641 if let Some(start) = dsn.find("@file(")
643 && let Some(end) = dsn[start..].find(')')
644 {
645 let file_path = &dsn[start + 6..start + end]; let resolved_path = if file_path.starts_with('/')
648 || (file_path.len() > 1 && file_path.chars().nth(1) == Some(':'))
649 {
650 PathBuf::from(file_path)
652 } else {
653 let gear_dir = home_dir.join(gear_name);
655 if !dry_run {
656 std::fs::create_dir_all(&gear_dir).with_context(|| {
657 format!("Failed to create gear directory: {}", gear_dir.display())
658 })?;
659 }
660 gear_dir.join(file_path)
661 };
662
663 let normalized_path = normalize_path(&resolved_path);
664 if normalized_path.len() > 1 && normalized_path.chars().nth(1) == Some(':') {
667 return Ok(format!("sqlite:{normalized_path}"));
669 }
670 return Ok(format!("sqlite://{normalized_path}"));
672 }
673 return Err(anyhow::anyhow!(
674 "Invalid @file() syntax in SQLite DSN: {dsn}"
675 ));
676 }
677
678 if dsn == "sqlite://" || dsn == "sqlite:///" || dsn == "sqlite:" {
680 let gear_dir = home_dir.join(gear_name);
681 if !dry_run {
682 std::fs::create_dir_all(&gear_dir).with_context(|| {
683 format!("Failed to create gear directory: {}", gear_dir.display())
684 })?;
685 }
686 let db_path = gear_dir.join(format!("{gear_name}.sqlite"));
687 let normalized_path = normalize_path(&db_path);
688 if normalized_path.len() > 1 && normalized_path.chars().nth(1) == Some(':') {
691 return Ok(format!("sqlite:{normalized_path}"));
693 }
694 return Ok(format!("sqlite://{normalized_path}"));
696 }
697
698 Ok(dsn.to_owned())
700}
701
702fn build_server_dsn(
706 scheme: &str,
707 host: Option<&str>,
708 port: Option<u16>,
709 user: Option<&str>,
710 password: Option<&str>,
711 dbname: Option<&str>,
712 params: &HashMap<String, String>,
713) -> Result<String> {
714 let host = host.unwrap_or("localhost");
715 let user = user.unwrap_or("postgres"); let mut url = Url::parse(&format!("{scheme}://dummy/"))
719 .with_context(|| format!("Invalid scheme: {scheme}"))?;
720
721 url.set_host(Some(host))
723 .with_context(|| format!("Invalid host: {host}"))?;
724
725 if let Some(port) = port {
727 url.set_port(Some(port))
728 .map_err(|()| anyhow::anyhow!("Invalid port: {port}"))?;
729 }
730
731 url.set_username(user)
733 .map_err(|()| anyhow::anyhow!("Failed to set username: {user}"))?;
734
735 if let Some(password) = password {
737 url.set_password(Some(password))
738 .map_err(|()| anyhow::anyhow!("Failed to set password"))?;
739 }
740
741 if let Some(dbname) = dbname {
743 let encoded_dbname = urlencoding::encode(dbname);
745 url.set_path(&format!("/{encoded_dbname}"));
746 } else {
747 url.set_path("/");
748 }
749
750 if !params.is_empty() {
752 let mut query_pairs = url.query_pairs_mut();
754 for (key, value) in params {
755 query_pairs.append_pair(key, value);
756 }
757 }
758
759 Ok(url.to_string())
760}
761
762fn build_sqlite_dsn_with_dbname_override(
764 original_dsn: &str,
765 dbname: &str,
766 gear_name: &str,
767 home_dir: &Path,
768 dry_run: bool,
769) -> Result<String> {
770 let query_params = if let Some(query_start) = original_dsn.find('?') {
772 &original_dsn[query_start..]
773 } else {
774 ""
775 };
776
777 let gear_dir = home_dir.join(gear_name);
779 if !dry_run {
780 std::fs::create_dir_all(&gear_dir)
781 .with_context(|| format!("Failed to create gear directory: {}", gear_dir.display()))?;
782 }
783 let db_path = gear_dir.join(dbname);
784 let normalized_path = normalize_path(&db_path);
785
786 let dsn_base = if normalized_path.len() > 1 && normalized_path.chars().nth(1) == Some(':') {
788 format!("sqlite:{normalized_path}")
790 } else {
791 format!("sqlite://{normalized_path}")
793 };
794
795 Ok(format!("{dsn_base}{query_params}"))
796}
797
798fn build_sqlite_dsn(
804 dsn: Option<&str>,
805 file: Option<&str>,
806 path: Option<&PathBuf>,
807 dbname: Option<&str>,
808 gear_name: &str,
809 home_dir: &Path,
810 dry_run: bool,
811) -> Result<String> {
812 if let Some(dsn) = dsn {
814 let resolved_dsn = resolve_sqlite_dsn(dsn, home_dir, gear_name, dry_run)?;
815
816 if let Some(dbname) = dbname {
818 return build_sqlite_dsn_with_dbname_override(
819 &resolved_dsn,
820 dbname,
821 gear_name,
822 home_dir,
823 dry_run,
824 );
825 }
826
827 validate_dsn(&resolved_dsn)?;
828 return Ok(resolved_dsn);
829 }
830
831 if let Some(path) = path {
833 let absolute_path = if path.is_absolute() {
834 path.clone()
835 } else {
836 home_dir.join(path)
837 };
838 let normalized_path = normalize_path(&absolute_path);
839 if normalized_path.len() > 1 && normalized_path.chars().nth(1) == Some(':') {
842 return Ok(format!("sqlite:{normalized_path}"));
844 }
845 return Ok(format!("sqlite://{normalized_path}"));
847 }
848
849 if let Some(file) = file {
851 let gear_dir = home_dir.join(gear_name);
852 if !dry_run {
853 std::fs::create_dir_all(&gear_dir).with_context(|| {
854 format!("Failed to create gear directory: {}", gear_dir.display())
855 })?;
856 }
857 let db_path = gear_dir.join(file);
858 let normalized_path = normalize_path(&db_path);
859 if normalized_path.len() > 1 && normalized_path.chars().nth(1) == Some(':') {
862 return Ok(format!("sqlite:{normalized_path}"));
864 }
865 return Ok(format!("sqlite://{normalized_path}"));
867 }
868
869 let gear_dir = home_dir.join(gear_name);
871 if !dry_run {
872 std::fs::create_dir_all(&gear_dir)
873 .with_context(|| format!("Failed to create gear directory: {}", gear_dir.display()))?;
874 }
875 let db_path = gear_dir.join(format!("{gear_name}.sqlite"));
876 let normalized_path = normalize_path(&db_path);
877 if normalized_path.len() > 1 && normalized_path.chars().nth(1) == Some(':') {
880 Ok(format!("sqlite:{normalized_path}"))
882 } else {
883 Ok(format!("sqlite://{normalized_path}"))
885 }
886}
887
888type DbConfigResult = Result<Option<(String , PoolCfg)>>;
890
891#[derive(Default)]
893struct DbConfigBuilder {
894 dsn: Option<String>,
895 host: Option<String>,
896 port: Option<u16>,
897 user: Option<String>,
898 password: Option<String>,
899 dbname: Option<String>,
900 params: HashMap<String, String>,
901 pool: PoolCfg,
902}
903
904impl DbConfigBuilder {
905 fn new() -> Self {
906 Self::default()
907 }
908
909 fn apply_global_server(
911 &mut self,
912 global_server: &DbConnConfig,
913 home_dir: &Path,
914 gear_name: &str,
915 dry_run: bool,
916 ) -> Result<()> {
917 if let Some(global_dsn) = &global_server.dsn {
919 let expanded_dsn = expand_env_in_dsn(global_dsn.expose())?;
920 let resolved_dsn = if expanded_dsn.starts_with("sqlite") {
922 resolve_sqlite_dsn(&expanded_dsn, home_dir, gear_name, dry_run)?
923 } else {
924 expanded_dsn
925 };
926 validate_dsn(&resolved_dsn)?;
927 self.dsn = Some(resolved_dsn);
928 }
929
930 if let Some(host) = &global_server.host {
932 self.host = Some(host.clone());
933 }
934 if let Some(port) = global_server.port {
935 self.port = Some(port);
936 }
937 if let Some(user) = &global_server.user {
938 self.user = Some(user.clone());
939 }
940 if let Some(password) = resolve_password(
941 global_server
942 .password
943 .as_ref()
944 .map(toolkit_utils::SecretString::expose),
945 )? {
946 self.password = Some(password);
947 }
948 if let Some(dbname) = &global_server.dbname {
949 self.dbname = Some(dbname.clone());
950 }
951 if let Some(params) = &global_server.params {
952 self.params.extend(params.clone());
953 }
954 if let Some(pool) = &global_server.pool {
955 self.pool = pool.clone();
956 }
957
958 Ok(())
959 }
960
961 fn apply_gear_dsn(
963 &mut self,
964 gear_dsn: &str,
965 home_dir: &Path,
966 gear_name: &str,
967 dry_run: bool,
968 ) -> Result<()> {
969 let resolved_dsn = if gear_dsn.starts_with("sqlite") {
971 resolve_sqlite_dsn(gear_dsn, home_dir, gear_name, dry_run)?
972 } else {
973 gear_dsn.to_owned()
974 };
975 validate_dsn(&resolved_dsn)?;
976 self.dsn = Some(resolved_dsn);
977 Ok(())
978 }
979
980 fn apply_gear_fields(&mut self, gear_db_config: &DbConnConfig) -> Result<()> {
982 if let Some(host) = &gear_db_config.host {
983 self.host = Some(host.clone());
984 }
985 if let Some(port) = gear_db_config.port {
986 self.port = Some(port);
987 }
988 if let Some(user) = &gear_db_config.user {
989 self.user = Some(user.clone());
990 }
991 if let Some(password) = resolve_password(
992 gear_db_config
993 .password
994 .as_ref()
995 .map(toolkit_utils::SecretString::expose),
996 )? {
997 self.password = Some(password);
998 }
999 if let Some(dbname) = &gear_db_config.dbname {
1000 self.dbname = Some(dbname.clone());
1001 }
1002 if let Some(params) = &gear_db_config.params {
1003 self.params.extend(params.clone());
1004 }
1005 if let Some(pool) = &gear_db_config.pool {
1006 if let Some(max_conns) = pool.max_conns {
1008 self.pool.max_conns = Some(max_conns);
1009 }
1010 if let Some(acquire_timeout) = pool.acquire_timeout {
1011 self.pool.acquire_timeout = Some(acquire_timeout);
1012 }
1013 }
1014 Ok(())
1015 }
1016
1017 fn has_field_overrides(&self) -> bool {
1019 self.host.is_some()
1020 || self.port.is_some()
1021 || self.user.is_some()
1022 || self.password.is_some()
1023 || !self.params.is_empty()
1024 }
1025}
1026
1027fn decide_backend(builder: &DbConfigBuilder, gear_db_config: &DbConnConfig) -> bool {
1029 gear_db_config.file.is_some()
1032 || gear_db_config.path.is_some()
1033 || builder
1034 .dsn
1035 .as_ref()
1036 .is_some_and(|dsn| dsn.starts_with("sqlite"))
1037 || (gear_db_config.server.is_none() && builder.dsn.is_none())
1038}
1039
1040fn finalize_sqlite_dsn(
1042 builder: &DbConfigBuilder,
1043 gear_db_config: &DbConnConfig,
1044 gear_name: &str,
1045 home_dir: &Path,
1046 dry_run: bool,
1047) -> Result<String> {
1048 build_sqlite_dsn(
1049 builder.dsn.as_deref(),
1050 gear_db_config.file.as_deref(),
1051 gear_db_config.path.as_ref(),
1052 builder.dbname.as_deref(),
1053 gear_name,
1054 home_dir,
1055 dry_run,
1056 )
1057}
1058
1059fn finalize_server_dsn(builder: &DbConfigBuilder, gear_name: &str) -> Result<String> {
1061 let dbname = if let Some(dbname) = builder.dbname.as_deref() {
1063 dbname.to_owned()
1064 } else if let Some(dsn) = builder.dsn.as_ref() {
1065 if let Ok(parsed) = url::Url::parse(dsn) {
1067 let path = parsed.path();
1068 if path.len() > 1 {
1069 path[1..].to_string()
1071 } else {
1072 return Err(anyhow::anyhow!(
1073 "Server-based database config for gear '{gear_name}' missing required 'dbname'"
1074 ));
1075 }
1076 } else {
1077 return Err(anyhow::anyhow!(
1078 "Server-based database config for gear '{gear_name}' missing required 'dbname'"
1079 ));
1080 }
1081 } else {
1082 return Err(anyhow::anyhow!(
1083 "Server-based database config for gear '{gear_name}' missing required 'dbname'"
1084 ));
1085 };
1086
1087 if builder.has_field_overrides() || builder.dsn.is_none() {
1088 let scheme = if let Some(dsn) = &builder.dsn {
1090 let parsed = Url::parse(dsn)?;
1091 parsed.scheme().to_owned()
1092 } else {
1093 "postgresql".to_owned() };
1095
1096 build_server_dsn(
1097 &scheme,
1098 builder.host.as_deref(),
1099 builder.port,
1100 builder.user.as_deref(),
1101 builder.password.as_deref(),
1102 Some(&dbname),
1103 &builder.params,
1104 )
1105 } else if let Some(original_dsn) = &builder.dsn {
1106 if let Ok(mut parsed) = Url::parse(original_dsn) {
1108 let original_dbname = parsed.path().trim_start_matches('/');
1110 if original_dbname != dbname {
1111 parsed.set_path(&format!("/{dbname}"));
1112 }
1113 Ok(parsed.to_string())
1114 } else {
1115 build_server_dsn(
1117 "postgresql",
1118 builder.host.as_deref(),
1119 builder.port,
1120 builder.user.as_deref(),
1121 builder.password.as_deref(),
1122 Some(&dbname),
1123 &builder.params,
1124 )
1125 }
1126 } else {
1127 unreachable!("final_dsn should not be None when has_field_overrides is false")
1129 }
1130}
1131
1132fn redact_dsn_for_logging(dsn: &str) -> Result<String> {
1134 if dsn.contains('@') {
1135 let parsed = Url::parse(dsn)?;
1136 let mut log_url = parsed;
1137 if log_url.password().is_some() {
1138 log_url.set_password(Some("***")).ok();
1139 }
1140 Ok(log_url.to_string())
1141 } else {
1142 Ok(dsn.to_owned())
1143 }
1144}
1145
1146pub const TOOLKIT_MODULE_CONFIG_ENV: &str = "TOOLKIT_MODULE_CONFIG";
1150
1151#[derive(Debug, Clone, Serialize, Deserialize)]
1154pub struct RenderedDbConfig {
1155 #[serde(skip_serializing_if = "Option::is_none")]
1158 pub global: Option<GlobalDatabaseConfig>,
1159 #[serde(skip_serializing_if = "Option::is_none")]
1162 pub gear: Option<DbConnConfig>,
1163}
1164
1165impl RenderedDbConfig {
1166 #[must_use]
1168 pub fn new(global: Option<GlobalDatabaseConfig>, gear: Option<DbConnConfig>) -> Self {
1169 Self { global, gear }
1170 }
1171}
1172
1173#[derive(Debug, Clone, Serialize, Deserialize)]
1183pub struct RenderedGearConfig {
1184 #[serde(skip_serializing_if = "Option::is_none")]
1187 pub database: Option<RenderedDbConfig>,
1188 #[serde(default)]
1190 pub config: serde_json::Value,
1191 #[serde(skip_serializing_if = "Option::is_none")]
1194 pub logging: Option<LoggingConfig>,
1195 #[serde(skip_serializing_if = "Option::is_none")]
1197 pub opentelemetry: Option<OpenTelemetryConfig>,
1198}
1199
1200impl RenderedGearConfig {
1201 pub fn from_json(json: &str) -> Result<Self> {
1206 serde_json::from_str(json).context("Failed to parse RenderedGearConfig from JSON")
1207 }
1208
1209 pub fn to_json(&self) -> Result<String> {
1214 serde_json::to_string(self).context("Failed to serialize RenderedGearConfig to JSON")
1215 }
1216}
1217
1218pub fn render_gear_config_for_oop(
1235 app: &AppConfig,
1236 gear_name: &str,
1237 _home_dir: &std::path::Path,
1238) -> Result<RenderedGearConfig> {
1239 let gear_db_config = parse_gear_config(app, gear_name)
1242 .ok()
1243 .and_then(|entry| entry.database);
1244
1245 let database = if gear_db_config.is_some() || app.database.is_some() {
1247 Some(RenderedDbConfig::new(app.database.clone(), gear_db_config))
1248 } else {
1249 None
1250 };
1251
1252 let config = parse_gear_config(app, gear_name)
1254 .map(|entry| entry.config)
1255 .unwrap_or_default();
1256
1257 let logging = app.logging.clone();
1259
1260 let opentelemetry = if app.opentelemetry.tracing.enabled || app.opentelemetry.metrics.enabled {
1262 Some(app.opentelemetry.clone())
1263 } else {
1264 None
1265 };
1266
1267 Ok(RenderedGearConfig {
1268 database,
1269 config,
1270 logging: Some(logging),
1271 opentelemetry,
1272 })
1273}
1274
1275pub fn parse_gear_config(app: &AppConfig, gear_name: &str) -> Result<GearConfig> {
1280 let gear_raw = app
1281 .gears
1282 .get(gear_name)
1283 .cloned()
1284 .ok_or_else(|| anyhow::anyhow!("Gear '{gear_name}' not found in config"))?;
1285
1286 let gear_config: GearConfig = serde_json::from_value(gear_raw)?;
1287 Ok(gear_config)
1288}
1289
1290pub fn get_gear_runtime_config(app: &AppConfig, gear_name: &str) -> Result<Option<GearRuntime>> {
1295 let entry = parse_gear_config(app, gear_name)?;
1296 Ok(entry.runtime)
1297}
1298
1299pub fn build_final_db_for_gear(
1310 app: &AppConfig,
1311 gear_name: &str,
1312 home_dir: &Path,
1313 dry_run: bool,
1314) -> DbConfigResult {
1315 let Some(gear_raw) = app.gears.get(gear_name) else {
1317 return Ok(None); };
1319
1320 let gear_entry: GearConfig = serde_json::from_value(gear_raw.clone())
1321 .with_context(|| format!("Invalid gear config structure for '{gear_name}'"))?;
1322
1323 let Some(gear_db_config) = gear_entry.database else {
1324 tracing::warn!(
1325 "Gear '{}' has no database configuration; DB capability disabled",
1326 gear_name
1327 );
1328 return Ok(None);
1329 };
1330
1331 let global_db_config = app.database.as_ref();
1333
1334 let mut builder = DbConfigBuilder::new();
1336
1337 if let Some(server_name) = &gear_db_config.server {
1339 let global_server = global_db_config
1340 .and_then(|gc| gc.servers.get(server_name))
1341 .ok_or_else(|| {
1342 anyhow::anyhow!("Referenced server '{server_name}' not found in global config")
1343 })?;
1344
1345 builder.apply_global_server(global_server, home_dir, gear_name, dry_run)?;
1346 }
1347
1348 if let Some(gear_dsn) = &gear_db_config.dsn {
1350 builder.apply_gear_dsn(gear_dsn.expose(), home_dir, gear_name, dry_run)?;
1351 }
1352
1353 builder.apply_gear_fields(&gear_db_config)?;
1355
1356 let is_sqlite = decide_backend(&builder, &gear_db_config);
1358
1359 let result_dsn = if is_sqlite {
1360 finalize_sqlite_dsn(&builder, &gear_db_config, gear_name, home_dir, dry_run)?
1361 } else {
1362 finalize_server_dsn(&builder, gear_name)?
1363 };
1364
1365 validate_dsn(&result_dsn)?;
1367
1368 let log_dsn = redact_dsn_for_logging(&result_dsn)?;
1370
1371 tracing::info!(
1372 "Built final DB config for gear '{}': {}",
1373 gear_name,
1374 log_dsn
1375 );
1376
1377 Ok(Some((result_dsn, builder.pool)))
1378}
1379
1380#[must_use]
1383pub fn get_gear_db_config(app: &AppConfig, gear_name: &str) -> Option<DbConnConfig> {
1384 let gear_raw = app.gears.get(gear_name)?;
1385 let gear_entry: GearConfig = serde_json::from_value(gear_raw.clone()).ok()?;
1386 gear_entry.database
1387}
1388
1389#[must_use]
1392pub fn gear_home(app: &AppConfig, gear_name: &str) -> PathBuf {
1393 PathBuf::from(&app.server.home_dir).join(gear_name)
1394}
1395
1396#[cfg(test)]
1397#[cfg_attr(coverage_nightly, coverage(off))]
1398mod tests {
1399 use super::*;
1400 use serial_test::serial;
1401 use std::fs;
1402 use temp_env::with_var;
1403 use tempfile::tempdir;
1404
1405 fn is_normalized_path(p: &Path) -> bool {
1407 p.is_absolute() && !p.starts_with("~")
1408 }
1409
1410 fn default_subdir() -> &'static str {
1412 ".cf-gears"
1413 }
1414
1415 #[test]
1416 fn test_remap_gear_env_key() {
1417 let cases = [
1420 ("gears.my_gear.port", "gears.my-gear.port"),
1422 ("gears.my_gear.max_age_days", "gears.my-gear.max_age_days"),
1424 ("gears.a_b_c.field", "gears.a-b-c.field"),
1426 ("gears.my-gear.port", "gears.my-gear.port"),
1428 ("vendor.my_vendor.key", "vendor.my_vendor.key"),
1430 ("server.home_dir", "server.home_dir"),
1431 ("gears", "gears"),
1433 ("GEARS.MY_GEAR.PORT", "gears.my-gear.port"),
1435 ("server", "server"),
1437 ];
1438
1439 for (input, expected) in cases {
1440 assert_eq!(
1441 remap_gear_env_key(input),
1442 expected,
1443 "remap_gear_env_key({input:?})"
1444 );
1445 }
1446 }
1447
1448 #[test]
1449 fn test_default_config_structure() {
1450 let config = AppConfig::default();
1451
1452 assert!(config.database.is_none());
1454
1455 let logging = config.logging;
1457 assert!(logging.contains_key("default"));
1458
1459 let default_section = &logging["default"];
1460 assert_eq!(default_section.console_level, Some(Level::INFO));
1461 assert_eq!(default_section.file().unwrap(), "logs/cf-gears.log");
1462
1463 assert!(config.gears.is_empty());
1465 }
1466
1467 #[test]
1470 #[serial]
1471 fn test_load_layered_normalizes_home_dir() {
1472 let tmp = tempdir().unwrap();
1473 let cfg_path = tmp.path().join("cfg.yaml");
1474
1475 let yaml = r#"
1477server:
1478 home_dir: "~/.test_cfgears"
1479
1480database:
1481 servers:
1482 test_postgres:
1483 dsn: "postgres://user:pass@localhost/db"
1484 pool:
1485 max_conns: 20
1486
1487logging:
1488 default:
1489 console_level: debug
1490 file: "logs/default.log"
1491"#;
1492 fs::write(&cfg_path, yaml).unwrap();
1493
1494 let config = AppConfig::load_layered(&cfg_path).unwrap();
1495
1496 assert!(is_normalized_path(&config.server.home_dir));
1498 assert!(config.server.home_dir.ends_with(".test_cfgears"));
1499
1500 let logging = &config.logging;
1506 let def = &logging["default"];
1507 assert_eq!(def.console_level, Some(Level::DEBUG));
1508 assert_eq!(def.section_file.as_ref().unwrap().file, "logs/default.log");
1509 }
1510
1511 #[test]
1512 fn test_load_or_default_normalizes_home_dir_when_none() {
1513 let tmp = tempdir().unwrap();
1516 let env_var = if cfg!(target_os = "windows") {
1517 "APPDATA"
1518 } else {
1519 "HOME"
1520 };
1521 with_var(env_var, Some(tmp.path().to_str().unwrap()), || {
1522 let config = AppConfig::load_or_default(None).unwrap();
1523 assert!(is_normalized_path(&config.server.home_dir));
1524 assert!(config.server.home_dir.ends_with(default_subdir()));
1525 });
1526 }
1527
1528 #[test]
1531 #[serial]
1532 fn test_minimal_yaml_config() {
1533 let tmp = tempdir().unwrap();
1534 let cfg_path = tmp.path().join("cfg.yaml");
1535
1536 let yaml = r#"
1537server:
1538 home_dir: "~/.minimal"
1539"#;
1540 fs::write(&cfg_path, yaml).unwrap();
1541
1542 let config = AppConfig::load_layered(&cfg_path).unwrap();
1543
1544 assert!(is_normalized_path(&config.server.home_dir));
1546 assert!(config.server.home_dir.ends_with(".minimal"));
1547
1548 assert!(config.database.is_none());
1550 assert!(config.gears.is_empty());
1551 }
1552
1553 #[test]
1554 fn test_cli_overrides() {
1555 let mut config = AppConfig::default();
1556
1557 let args = CliArgs {
1558 config: None,
1559 print_config: false,
1560 verbose: 2, mock: false,
1562 };
1563
1564 config.apply_cli_overrides(args.verbose);
1565
1566 let logging = &config.logging;
1570 let default_section = &logging["default"];
1571 assert_eq!(default_section.console_level, Some(Level::TRACE));
1572 }
1573
1574 #[test]
1575 fn test_cli_verbose_levels_matrix() {
1576 for (verbose_level, expected_log_level) in [
1577 (0, Some(Level::INFO)), (1, Some(Level::DEBUG)),
1579 (2, Some(Level::TRACE)),
1580 (3, Some(Level::TRACE)), ] {
1582 let mut config = AppConfig::default();
1583 let args = CliArgs {
1584 config: None,
1585 print_config: false,
1586 verbose: verbose_level,
1587 mock: false,
1588 };
1589
1590 config.apply_cli_overrides(args.verbose);
1591
1592 let logging = &config.logging;
1593 let default_section = &logging["default"];
1594
1595 if verbose_level == 0 {
1596 assert_eq!(default_section.console_level, Some(Level::INFO));
1597 } else {
1598 assert_eq!(default_section.console_level, expected_log_level);
1599 }
1600 }
1601 }
1602
1603 #[test]
1606 #[serial]
1607 fn test_layered_config_loading_with_gears_dir() {
1608 let tmp = tempdir().unwrap();
1609 let cfg_path = tmp.path().join("gears_dir.yaml");
1610 let gears_dir = tmp.path().join("gears");
1611
1612 fs::create_dir_all(&gears_dir).unwrap();
1613 let gear_cfg = gears_dir.join("test_gear.yaml");
1614 fs::write(
1615 &gear_cfg,
1616 r#"
1617setting1: "value1"
1618setting2: 42
1619"#,
1620 )
1621 .unwrap();
1622
1623 let gears_dir_str = normalize_path(&gears_dir);
1625 let yaml = format!(
1626 r#"
1627server:
1628 home_dir: "~/.gears_test"
1629
1630gears_dir: "{gears_dir_str}"
1631
1632gears:
1633 existing_gear:
1634 key: "value"
1635"#
1636 );
1637
1638 fs::write(&cfg_path, yaml).unwrap();
1639
1640 let config = AppConfig::load_layered(&cfg_path).unwrap();
1641
1642 assert!(config.gears.contains_key("existing_gear"));
1644
1645 assert!(config.gears.contains_key("test_gear"));
1647
1648 let test_gear = &config.gears["test_gear"];
1650 assert_eq!(test_gear["setting1"], "value1");
1651 assert_eq!(test_gear["setting2"], 42);
1652 }
1653
1654 #[test]
1657 #[serial]
1658 fn test_load_and_init_logging_smoke() {
1659 let tmp = tempdir().unwrap();
1661 let cfg_path = tmp.path().join("logging.yaml");
1662 let yaml = r#"
1663server:
1664 home_dir: "~/.logging_test"
1665
1666logging:
1667 default:
1668 console_level: debug
1669 file: ""
1670 file_level: info
1671"#;
1672 fs::write(&cfg_path, yaml).unwrap();
1673
1674 let config = AppConfig::load_layered(&cfg_path).unwrap();
1675 let logging = &config.logging;
1676 assert!(logging.contains_key("default"));
1677
1678 let default_section = &logging["default"];
1679 assert_eq!(default_section.console_level, Some(Level::DEBUG));
1680 assert_eq!(default_section.file_level(), Some(Level::INFO));
1681 }
1683
1684 fn create_app_with_server(server_name: &str, db_config: DbConnConfig) -> AppConfig {
1688 let mut servers = HashMap::new();
1689 servers.insert(server_name.to_owned(), db_config);
1690
1691 AppConfig {
1692 database: Some(GlobalDatabaseConfig {
1693 servers,
1694 auto_provision: None,
1695 }),
1696 ..Default::default()
1697 }
1698 }
1699
1700 fn add_gear_to_app(app: &mut AppConfig, gear_name: &str, database_config: &serde_json::Value) {
1702 app.gears.insert(
1703 gear_name.to_owned(),
1704 serde_json::json!({
1705 "database": database_config,
1706 "config": {}
1707 }),
1708 );
1709 }
1710
1711 fn add_gear_with_config(app: &mut AppConfig, gear_name: &str, config: &serde_json::Value) {
1713 app.gears.insert(
1714 gear_name.to_owned(),
1715 serde_json::json!({
1716 "database": {},
1717 "config": config
1718 }),
1719 );
1720 }
1721
1722 fn create_minimal_app() -> AppConfig {
1724 AppConfig {
1725 database: None,
1726 gears: HashMap::new(),
1727 ..Default::default()
1728 }
1729 }
1730
1731 #[test]
1732 fn test_precedence_global_dsn_only() {
1733 let tmp = tempdir().unwrap();
1734 let home_dir = tmp.path();
1735
1736 let mut app = create_app_with_server(
1737 "test_server",
1738 DbConnConfig {
1739 dsn: Some(toolkit_utils::SecretString::new(
1740 "postgresql://global_user:global_pass@global_host:5432/global_db",
1741 )),
1742 ..Default::default()
1743 },
1744 );
1745
1746 add_gear_to_app(
1748 &mut app,
1749 "test_gear",
1750 &serde_json::json!({
1751 "server": "test_server"
1752 }),
1753 );
1754
1755 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
1756 assert!(result.is_some());
1757
1758 let (dsn, _pool) = result.unwrap();
1759 assert!(dsn.contains("global_user"));
1760 assert!(dsn.contains("global_host"));
1761 assert!(dsn.contains("global_db"));
1762 }
1763
1764 #[test]
1765 fn test_precedence_global_fields_only() {
1766 let tmp = tempdir().unwrap();
1767 let home_dir = tmp.path();
1768
1769 let mut app = create_app_with_server(
1770 "test_server",
1771 DbConnConfig {
1772 host: Some("field_host".to_owned()),
1773 port: Some(5433),
1774 user: Some("field_user".to_owned()),
1775 dbname: Some("field_db".to_owned()),
1776 ..Default::default()
1777 },
1778 );
1779
1780 add_gear_to_app(
1782 &mut app,
1783 "test_gear",
1784 &serde_json::json!({
1785 "server": "test_server"
1786 }),
1787 );
1788
1789 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
1790 assert!(result.is_some());
1791
1792 let (dsn, _pool) = result.unwrap();
1793 assert!(dsn.contains("field_host"));
1794 assert!(dsn.contains("5433"));
1795 assert!(dsn.contains("field_user"));
1796 assert!(dsn.contains("field_db"));
1797 }
1798
1799 #[test]
1800 fn test_precedence_gear_dsn_only() {
1801 let tmp = tempdir().unwrap();
1802 let home_dir = tmp.path();
1803
1804 let app = AppConfig {
1805 gears: {
1806 let mut gears = HashMap::new();
1807 gears.insert(
1808 "test_gear".to_owned(),
1809 serde_json::json!({
1810 "database": {
1811 "dsn": "sqlite://gear_test.db?wal=true&synchronous=NORMAL"
1812 },
1813 "config": {}
1814 }),
1815 );
1816 gears
1817 },
1818 ..Default::default()
1819 };
1820
1821 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
1822 assert!(result.is_some());
1823
1824 let (dsn, _pool) = result.unwrap();
1825 assert!(dsn.contains("gear_test.db"));
1826 assert!(dsn.contains("wal=true"));
1827 }
1828
1829 #[test]
1830 fn test_precedence_gear_fields_only() {
1831 let tmp = tempdir().unwrap();
1832 let home_dir = tmp.path();
1833
1834 let app = AppConfig {
1835 gears: {
1836 let mut gears = HashMap::new();
1837 gears.insert(
1838 "test_gear".to_owned(),
1839 serde_json::json!({
1840 "database": {
1841 "file": "gear_fields.db"
1842 },
1843 "config": {}
1844 }),
1845 );
1846 gears
1847 },
1848 ..Default::default()
1849 };
1850
1851 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
1852 assert!(result.is_some());
1853
1854 let (dsn, _pool) = result.unwrap();
1855 assert!(dsn.contains("gear_fields.db"));
1856 #[cfg(windows)]
1858 assert!(dsn.starts_with("sqlite:") && !dsn.starts_with("sqlite://"));
1859 #[cfg(unix)]
1860 assert!(dsn.starts_with("sqlite://"));
1861 }
1862
1863 #[test]
1864 fn test_precedence_fields_override_dsn() {
1865 let tmp = tempdir().unwrap();
1866 let home_dir = tmp.path();
1867
1868 let mut app = create_app_with_server(
1869 "test_server",
1870 DbConnConfig {
1871 dsn: Some(toolkit_utils::SecretString::new(
1872 "postgresql://old_user:old_pass@old_host:5432/old_db",
1873 )),
1874 host: Some("new_host".to_owned()), port: Some(5433), user: Some("new_user".to_owned()), dbname: Some("new_db".to_owned()), ..Default::default()
1879 },
1880 );
1881
1882 add_gear_to_app(
1884 &mut app,
1885 "test_gear",
1886 &serde_json::json!({
1887 "server": "test_server",
1888 "port": 5434 }),
1890 );
1891
1892 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
1893 assert!(result.is_some());
1894
1895 let (dsn, _pool) = result.unwrap();
1896 assert!(dsn.contains("new_host"));
1898 assert!(dsn.contains("5434")); assert!(dsn.contains("new_user"));
1900 assert!(dsn.contains("new_db"));
1901 assert!(!dsn.contains("old_host"));
1903 assert!(!dsn.contains("5432"));
1904 assert!(!dsn.contains("old_user"));
1905 assert!(!dsn.contains("old_db"));
1906 }
1907
1908 #[test]
1909 fn test_env_expansion_password() {
1910 let tmp = tempdir().unwrap();
1911 let home_dir = tmp.path();
1912
1913 with_var("TEST_DB_PASSWORD", Some("secret123"), || {
1914 let mut app = create_app_with_server(
1915 "test_server",
1916 DbConnConfig {
1917 host: Some("localhost".to_owned()),
1918 port: Some(5432),
1919 user: Some("testuser".to_owned()),
1920 password: Some(toolkit_utils::SecretString::new("${TEST_DB_PASSWORD}")), dbname: Some("testdb".to_owned()),
1922 ..Default::default()
1923 },
1924 );
1925
1926 add_gear_to_app(
1927 &mut app,
1928 "test_gear",
1929 &serde_json::json!({
1930 "server": "test_server"
1931 }),
1932 );
1933
1934 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
1935 assert!(result.is_some());
1936
1937 let (dsn, _pool) = result.unwrap();
1938 assert!(dsn.contains("secret123"));
1939 });
1940 }
1941
1942 #[test]
1943 fn test_env_expansion_in_dsn() {
1944 let tmp = tempdir().unwrap();
1945 let home_dir = tmp.path();
1946
1947 temp_env::with_vars(
1948 [
1949 ("DB_HOST", Some("test-server")),
1950 ("DB_PASSWORD", Some("env_secret")),
1951 ],
1952 || {
1953 let mut app = create_app_with_server(
1954 "test_server",
1955 DbConnConfig {
1956 dsn: Some(toolkit_utils::SecretString::new(
1957 "postgresql://user:${DB_PASSWORD}@${DB_HOST}:5432/mydb",
1958 )),
1959 ..Default::default()
1960 },
1961 );
1962
1963 add_gear_to_app(
1964 &mut app,
1965 "test_gear",
1966 &serde_json::json!({
1967 "server": "test_server"
1968 }),
1969 );
1970
1971 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
1972 assert!(result.is_some());
1973
1974 let (dsn, _pool) = result.unwrap();
1975 assert!(dsn.contains("test-server"));
1976 assert!(dsn.contains("env_secret"));
1977 assert!(!dsn.contains("${DB_HOST}"));
1979 assert!(!dsn.contains("${DB_PASSWORD}"));
1980 },
1981 );
1982 }
1983
1984 #[test]
1985 fn test_sqlite_file_path_resolution() {
1986 let tmp = tempdir().unwrap();
1987 let home_dir = tmp.path();
1988
1989 let app1 = AppConfig {
1991 gears: {
1992 let mut gears = HashMap::new();
1993 gears.insert(
1994 "test_gear".to_owned(),
1995 serde_json::json!({
1996 "database": {
1997 "file": "test.db"
1998 },
1999 "config": {}
2000 }),
2001 );
2002 gears
2003 },
2004 ..Default::default()
2005 };
2006
2007 let result1 = build_final_db_for_gear(&app1, "test_gear", home_dir, false).unwrap();
2008 assert!(result1.is_some());
2009 let (dsn1, _) = result1.unwrap();
2010 assert!(dsn1.contains("test_gear"));
2011 assert!(dsn1.contains("test.db"));
2012
2013 let abs_path = tmp.path().join("absolute.db");
2015 let app2 = AppConfig {
2016 gears: {
2017 let mut gears = HashMap::new();
2018 gears.insert(
2019 "test_gear".to_owned(),
2020 serde_json::json!({
2021 "database": {
2022 "path": abs_path.to_string_lossy()
2023 },
2024 "config": {}
2025 }),
2026 );
2027 gears
2028 },
2029 ..Default::default()
2030 };
2031
2032 let result2 = build_final_db_for_gear(&app2, "test_gear", home_dir, false).unwrap();
2033 assert!(result2.is_some());
2034 let (dsn2, _) = result2.unwrap();
2035 assert!(dsn2.contains("absolute.db"));
2036
2037 let app3 = AppConfig {
2039 gears: {
2040 let mut gears = HashMap::new();
2041 gears.insert(
2042 "test_gear".to_owned(),
2043 serde_json::json!({
2044 "database": {},
2045 "config": {}
2046 }),
2047 );
2048 gears
2049 },
2050 ..Default::default()
2051 };
2052
2053 let result3 = build_final_db_for_gear(&app3, "test_gear", home_dir, false).unwrap();
2054 assert!(result3.is_some());
2055 let (dsn3, _) = result3.unwrap();
2056 assert!(dsn3.contains("test_gear.sqlite"));
2057 }
2058
2059 #[cfg(windows)]
2060 #[test]
2061 fn test_sqlite_path_resolution_windows() {
2062 let tmp = tempdir().unwrap();
2063 let home_dir = tmp.path();
2064
2065 let app = AppConfig {
2066 gears: {
2067 let mut gears = HashMap::new();
2068 gears.insert(
2069 "test_gear".to_owned(),
2070 serde_json::json!({
2071 "database": {
2072 "file": "test.db"
2073 },
2074 "config": {}
2075 }),
2076 );
2077 gears
2078 },
2079 ..Default::default()
2080 };
2081
2082 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2083 assert!(result.is_some());
2084 let (dsn, _) = result.unwrap();
2085
2086 assert!(!dsn.contains('\\'));
2088 assert!(dsn.contains('/'));
2089 }
2090
2091 #[test]
2092 fn test_sqlite_dsn_with_server_reference_and_dbname_override() {
2093 let tmp = tempdir().unwrap();
2094 let home_dir = tmp.path();
2095
2096 let mut app = AppConfig::default();
2097
2098 let mut servers = HashMap::new();
2100 servers.insert(
2101 "sqlite_users".to_owned(),
2102 DbConnConfig {
2103 engine: None,
2104 dsn: Some(toolkit_utils::SecretString::new(
2105 "sqlite://users_info.db?WAL=true&synchronous=NORMAL&busy_timeout=5000",
2106 )),
2107 host: None,
2108 port: None,
2109 user: None,
2110 password: None,
2111 dbname: None,
2112 params: None,
2113 pool: None,
2114 file: None,
2115 path: None,
2116 lock_keepalive: None,
2117 server: None,
2118 },
2119 );
2120
2121 app.database = Some(GlobalDatabaseConfig {
2122 servers,
2123 auto_provision: None,
2124 });
2125
2126 app.gears.insert(
2128 "users_info".to_owned(),
2129 serde_json::json!({
2130 "database": {
2131 "server": "sqlite_users",
2132 "dbname": "users_info.db"
2133 },
2134 "config": {}
2135 }),
2136 );
2137
2138 let result = build_final_db_for_gear(&app, "users_info", home_dir, false).unwrap();
2139 assert!(result.is_some());
2140 let (dsn, _) = result.unwrap();
2141
2142 assert!(dsn.contains("?WAL=true&synchronous=NORMAL&busy_timeout=5000"));
2144 assert!(dsn.contains("users_info/users_info.db"));
2145
2146 #[cfg(windows)]
2148 {
2149 assert!(dsn.starts_with("sqlite:"));
2151 assert!(!dsn.starts_with("sqlite://"));
2152 }
2153
2154 #[cfg(unix)]
2155 {
2156 assert!(dsn.starts_with("sqlite://"));
2158 }
2159 }
2160
2161 #[cfg(unix)]
2162 #[test]
2163 fn test_sqlite_path_resolution_unix() {
2164 let tmp = tempdir().unwrap();
2165 let home_dir = tmp.path();
2166
2167 let app = AppConfig {
2168 gears: {
2169 let mut gears = HashMap::new();
2170 gears.insert(
2171 "test_gear".to_owned(),
2172 serde_json::json!({
2173 "database": {
2174 "file": "test.db"
2175 },
2176 "config": {}
2177 }),
2178 );
2179 gears
2180 },
2181 ..Default::default()
2182 };
2183
2184 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2185 assert!(result.is_some());
2186 let (dsn, _) = result.unwrap();
2187
2188 assert!(dsn.starts_with("sqlite://"));
2190 assert!(dsn.contains("/test_gear/test.db"));
2191 }
2192
2193 #[test]
2194 fn test_server_based_db_missing_dbname_error() {
2195 let tmp = tempdir().unwrap();
2196 let home_dir = tmp.path();
2197
2198 let mut app = create_app_with_server(
2199 "test_server",
2200 DbConnConfig {
2201 host: Some("localhost".to_owned()),
2202 port: Some(5432),
2203 user: Some("testuser".to_owned()),
2204 ..Default::default()
2206 },
2207 );
2208
2209 add_gear_to_app(
2210 &mut app,
2211 "test_gear",
2212 &serde_json::json!({
2213 "server": "test_server"
2214 }),
2215 );
2216
2217 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false);
2218 assert!(result.is_err());
2219 let error_msg = result.unwrap_err().to_string();
2220 assert!(error_msg.contains("missing required 'dbname'"));
2221 }
2222
2223 #[test]
2224 fn test_gear_no_database_config() {
2225 let tmp = tempdir().unwrap();
2226 let home_dir = tmp.path();
2227
2228 let app = AppConfig {
2230 gears: {
2231 let mut gears = HashMap::new();
2232 gears.insert(
2233 "no_db_gear".to_owned(),
2234 serde_json::json!({
2235 "config": {
2236 "some_setting": "value"
2237 }
2238 }),
2239 );
2240 gears
2241 },
2242 ..Default::default()
2243 };
2244
2245 let result = build_final_db_for_gear(&app, "no_db_gear", home_dir, false).unwrap();
2246 assert!(result.is_none());
2247 }
2248
2249 #[test]
2250 fn test_gear_empty_database_config() {
2251 let tmp = tempdir().unwrap();
2252 let home_dir = tmp.path();
2253
2254 let app = AppConfig {
2256 gears: {
2257 let mut gears = HashMap::new();
2258 gears.insert(
2259 "empty_db_gear".to_owned(),
2260 serde_json::json!({
2261 "database": null,
2262 "config": {}
2263 }),
2264 );
2265 gears
2266 },
2267 ..Default::default()
2268 };
2269
2270 let result = build_final_db_for_gear(&app, "empty_db_gear", home_dir, false).unwrap();
2271 assert!(result.is_none());
2272 }
2273
2274 #[test]
2275 fn test_referenced_server_not_found() {
2276 let tmp = tempdir().unwrap();
2277 let home_dir = tmp.path();
2278
2279 let app = AppConfig {
2280 gears: {
2281 let mut gears = HashMap::new();
2282 gears.insert(
2283 "test_gear".to_owned(),
2284 serde_json::json!({
2285 "database": {
2286 "server": "nonexistent_server"
2287 },
2288 "config": {}
2289 }),
2290 );
2291 gears
2292 },
2293 ..Default::default()
2294 };
2295
2296 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false);
2297 assert!(result.is_err());
2298 let error_msg = result.unwrap_err().to_string();
2299 assert!(error_msg.contains("Referenced server 'nonexistent_server' not found"));
2300 }
2301
2302 #[test]
2303 fn test_dsn_validation_invalid_url() {
2304 let tmp = tempdir().unwrap();
2305 let home_dir = tmp.path();
2306
2307 let app = AppConfig {
2308 gears: {
2309 let mut gears = HashMap::new();
2310 gears.insert(
2311 "test_gear".to_owned(),
2312 serde_json::json!({
2313 "database": {
2314 "dsn": "invalid://not-a-valid[url"
2315 },
2316 "config": {}
2317 }),
2318 );
2319 gears
2320 },
2321 ..Default::default()
2322 };
2323
2324 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false);
2325 assert!(result.is_err());
2326 }
2327
2328 #[test]
2329 fn test_env_variable_not_found() {
2330 let tmp = tempdir().unwrap();
2331 let home_dir = tmp.path();
2332
2333 with_var("NONEXISTENT_PASSWORD", None::<&str>, || {
2335 let mut app = create_app_with_server(
2336 "test_server",
2337 DbConnConfig {
2338 host: Some("localhost".to_owned()),
2339 password: Some(toolkit_utils::SecretString::new("${NONEXISTENT_PASSWORD}")),
2340 dbname: Some("testdb".to_owned()),
2341 ..Default::default()
2342 },
2343 );
2344
2345 add_gear_to_app(
2346 &mut app,
2347 "test_gear",
2348 &serde_json::json!({
2349 "server": "test_server"
2350 }),
2351 );
2352
2353 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false);
2354 assert!(result.is_err());
2355 let error_msg = result.unwrap_err().to_string();
2356 assert!(error_msg.contains("NONEXISTENT_PASSWORD"));
2357 });
2358 }
2359
2360 #[test]
2361 fn test_sqlite_at_file_relative_path() {
2362 let tmp = tempdir().unwrap();
2363 let home_dir = tmp.path();
2364
2365 let app = AppConfig {
2366 gears: {
2367 let mut gears = HashMap::new();
2368 gears.insert(
2369 "test_gear".to_owned(),
2370 serde_json::json!({
2371 "database": {
2372 "dsn": "sqlite://@file(users.db)"
2373 },
2374 "config": {}
2375 }),
2376 );
2377 gears
2378 },
2379 ..Default::default()
2380 };
2381
2382 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2383 assert!(result.is_some());
2384
2385 let (dsn, _pool) = result.unwrap();
2386 assert!(dsn.contains("test_gear"));
2387 assert!(dsn.contains("users.db"));
2388 #[cfg(windows)]
2390 assert!(dsn.starts_with("sqlite:") && !dsn.starts_with("sqlite://"));
2391 #[cfg(unix)]
2392 assert!(dsn.starts_with("sqlite:///"));
2393 }
2394
2395 #[test]
2396 fn test_sqlite_at_file_absolute_path() {
2397 let tmp = tempdir().unwrap();
2398 let home_dir = tmp.path();
2399 let abs_path = tmp.path().join("absolute_db.sqlite");
2400
2401 let app = AppConfig {
2402 gears: {
2403 let mut gears = HashMap::new();
2404 gears.insert(
2405 "test_gear".to_owned(),
2406 serde_json::json!({
2407 "database": {
2408 "dsn": format!("sqlite://@file({})", abs_path.to_string_lossy())
2409 },
2410 "config": {}
2411 }),
2412 );
2413 gears
2414 },
2415 ..Default::default()
2416 };
2417
2418 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2419 assert!(result.is_some());
2420
2421 let (dsn, _pool) = result.unwrap();
2422 assert!(dsn.contains("absolute_db.sqlite"));
2423 #[cfg(windows)]
2425 assert!(dsn.starts_with("sqlite:") && !dsn.starts_with("sqlite://"));
2426 #[cfg(unix)]
2427 assert!(dsn.starts_with("sqlite:///"));
2428 }
2429
2430 #[test]
2431 fn test_sqlite_empty_dsn_default() {
2432 let tmp = tempdir().unwrap();
2433 let home_dir = tmp.path();
2434
2435 let app = AppConfig {
2436 gears: {
2437 let mut gears = HashMap::new();
2438 gears.insert(
2439 "test_gear".to_owned(),
2440 serde_json::json!({
2441 "database": {
2442 "dsn": "sqlite://"
2443 },
2444 "config": {}
2445 }),
2446 );
2447 gears
2448 },
2449 ..Default::default()
2450 };
2451
2452 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2453 assert!(result.is_some());
2454
2455 let (dsn, _pool) = result.unwrap();
2456 assert!(dsn.contains("test_gear"));
2457 assert!(dsn.contains("test_gear.sqlite"));
2458 #[cfg(windows)]
2460 assert!(dsn.starts_with("sqlite:") && !dsn.starts_with("sqlite://"));
2461 #[cfg(unix)]
2462 assert!(dsn.starts_with("sqlite:///"));
2463 }
2464
2465 #[test]
2466 fn test_sqlite_at_file_invalid_syntax() {
2467 let tmp = tempdir().unwrap();
2468 let home_dir = tmp.path();
2469
2470 let app = AppConfig {
2471 gears: {
2472 let mut gears = HashMap::new();
2473 gears.insert(
2474 "test_gear".to_owned(),
2475 serde_json::json!({
2476 "database": {
2477 "dsn": "sqlite://@file(missing_closing_paren"
2478 },
2479 "config": {}
2480 }),
2481 );
2482 gears
2483 },
2484 ..Default::default()
2485 };
2486
2487 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false);
2488 assert!(result.is_err());
2489 let error_msg = result.unwrap_err().to_string();
2490 assert!(error_msg.contains("Invalid @file() syntax"));
2491 }
2492
2493 #[test]
2494 fn test_dsn_special_characters_in_credentials() {
2495 let tmp = tempdir().unwrap();
2496 let home_dir = tmp.path();
2497
2498 let mut app = create_app_with_server(
2500 "test_server",
2501 DbConnConfig {
2502 host: Some("localhost".to_owned()),
2503 port: Some(5432),
2504 user: Some("user@domain".to_owned()),
2505 password: Some(toolkit_utils::SecretString::new(
2506 "pa@ss:w0rd/with%special&chars",
2507 )),
2508 dbname: Some("test/db".to_owned()),
2509 ..Default::default()
2510 },
2511 );
2512
2513 add_gear_to_app(
2514 &mut app,
2515 "test_gear",
2516 &serde_json::json!({
2517 "server": "test_server"
2518 }),
2519 );
2520
2521 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2522 assert!(result.is_some());
2523
2524 let (dsn, _pool) = result.unwrap();
2525
2526 assert!(dsn.starts_with("postgresql://"));
2528 assert!(dsn.contains("user%40domain")); assert!(dsn.contains("/test%2Fdb")); validate_dsn(&dsn).expect("DSN with special characters should be valid");
2533
2534 let parsed_dsn = dsn::parse(&dsn).expect("DSN should be parseable");
2536 assert_eq!(parsed_dsn.username.as_deref(), Some("user@domain"));
2537 assert_eq!(
2538 parsed_dsn.password.as_deref(),
2539 Some("pa@ss:w0rd/with%special&chars")
2540 );
2541 }
2544
2545 #[test]
2546 #[allow(clippy::non_ascii_literal)]
2547 fn test_dsn_unicode_characters() {
2548 let tmp = tempdir().unwrap();
2549 let home_dir = tmp.path();
2550
2551 let mut app = create_app_with_server(
2553 "test_server",
2554 DbConnConfig {
2555 host: Some("localhost".to_owned()),
2556 user: Some("ユーザー".to_owned()), dbname: Some("unicode_db".to_owned()),
2558 ..Default::default()
2559 },
2560 );
2561
2562 add_gear_to_app(
2563 &mut app,
2564 "test_gear",
2565 &serde_json::json!({
2566 "server": "test_server"
2567 }),
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
2575 assert!(dsn.starts_with("postgresql://"));
2577 assert!(dsn.contains('%')); validate_dsn(&dsn).expect("DSN with Unicode characters should be valid");
2582 }
2583
2584 #[test]
2585 fn test_dsn_query_parameters_encoding() {
2586 let tmp = tempdir().unwrap();
2587 let home_dir = tmp.path();
2588
2589 let mut params = HashMap::new();
2590 params.insert("ssl mode".to_owned(), "require & verify".to_owned());
2591 params.insert("application_name".to_owned(), "my-app/v1.0".to_owned());
2592
2593 let mut app = create_app_with_server(
2594 "test_server",
2595 DbConnConfig {
2596 host: Some("localhost".to_owned()),
2597 user: Some("testuser".to_owned()),
2598 dbname: Some("testdb".to_owned()),
2599 params: Some(params),
2600 ..Default::default()
2601 },
2602 );
2603
2604 add_gear_to_app(
2605 &mut app,
2606 "test_gear",
2607 &serde_json::json!({
2608 "server": "test_server"
2609 }),
2610 );
2611
2612 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2613 assert!(result.is_some());
2614
2615 let (dsn, _pool) = result.unwrap();
2616
2617 assert!(dsn.contains("ssl+mode=require+%26+verify"));
2619 assert!(dsn.contains("application_name=my-app%2Fv1.0"));
2620
2621 validate_dsn(&dsn).expect("DSN with encoded query parameters should be valid");
2623 }
2624
2625 #[test]
2626 fn test_pool_config_merging() {
2627 use std::time::Duration;
2628
2629 let tmp = tempdir().unwrap();
2630 let home_dir = tmp.path();
2631
2632 let mut app = create_app_with_server(
2634 "test_server",
2635 DbConnConfig {
2636 host: Some("localhost".to_owned()),
2637 dbname: Some("testdb".to_owned()),
2638 pool: Some(PoolCfg {
2639 max_conns: Some(10),
2640 min_conns: None,
2641 acquire_timeout: Some(Duration::from_secs(5)),
2642 idle_timeout: None,
2643 max_lifetime: None,
2644 test_before_acquire: None,
2645 }),
2646 ..Default::default()
2647 },
2648 );
2649
2650 add_gear_to_app(
2652 &mut app,
2653 "test_gear",
2654 &serde_json::json!({
2655 "server": "test_server",
2656 "pool": {
2657 "max_conns": 20
2658 }
2659 }),
2660 );
2661
2662 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2663 assert!(result.is_some());
2664
2665 let (_dsn, pool) = result.unwrap();
2666 assert_eq!(pool.max_conns, Some(20)); assert_eq!(pool.acquire_timeout, Some(Duration::from_secs(5))); }
2669
2670 #[test]
2671 fn test_pool_config_gear_overrides_all() {
2672 use std::time::Duration;
2673
2674 let tmp = tempdir().unwrap();
2675 let home_dir = tmp.path();
2676
2677 let mut app = create_app_with_server(
2679 "test_server",
2680 DbConnConfig {
2681 host: Some("localhost".to_owned()),
2682 dbname: Some("testdb".to_owned()),
2683 pool: Some(PoolCfg {
2684 max_conns: Some(10),
2685 min_conns: None,
2686 acquire_timeout: Some(Duration::from_secs(5)),
2687 idle_timeout: None,
2688 max_lifetime: None,
2689 test_before_acquire: None,
2690 }),
2691 ..Default::default()
2692 },
2693 );
2694
2695 add_gear_to_app(
2697 &mut app,
2698 "test_gear",
2699 &serde_json::json!({
2700 "server": "test_server",
2701 "pool": {
2702 "max_conns": 30,
2703 "acquire_timeout": "10s"
2704 }
2705 }),
2706 );
2707
2708 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2709 assert!(result.is_some());
2710
2711 let (_dsn, pool) = result.unwrap();
2712 assert_eq!(pool.max_conns, Some(30));
2713 assert_eq!(pool.acquire_timeout, Some(Duration::from_secs(10)));
2714 }
2715
2716 #[test]
2717 fn test_list_gear_names() {
2718 let mut app = create_minimal_app();
2719 add_gear_with_config(&mut app, "zebra_gear", &serde_json::json!({}));
2720 add_gear_with_config(&mut app, "alpha_gear", &serde_json::json!({}));
2721 add_gear_with_config(&mut app, "beta_gear", &serde_json::json!({}));
2722
2723 let gear_names = list_gear_names(&app);
2724
2725 assert_eq!(gear_names.len(), 3);
2727 assert_eq!(gear_names[0], "alpha_gear");
2728 assert_eq!(gear_names[1], "beta_gear");
2729 assert_eq!(gear_names[2], "zebra_gear");
2730 }
2731
2732 #[test]
2733 fn test_list_gear_names_empty() {
2734 let app = create_minimal_app();
2735 let gear_names = list_gear_names(&app);
2736 assert_eq!(gear_names.len(), 0);
2737 }
2738
2739 #[test]
2740 fn test_redact_dsn_password_postgres() {
2741 let dsn = "postgres://user:secretpass@localhost:5432/mydb";
2742 let redacted = redact_dsn_password(dsn).unwrap();
2743 assert_eq!(
2744 redacted,
2745 "postgres://user:***REDACTED***@localhost:5432/mydb"
2746 );
2747 }
2748
2749 #[test]
2750 fn test_redact_dsn_password_no_password() {
2751 let dsn = "postgres://user@localhost:5432/mydb";
2752 let redacted = redact_dsn_password(dsn).unwrap();
2753 assert_eq!(redacted, "postgres://user@localhost:5432/mydb");
2755 }
2756
2757 #[test]
2758 fn test_redact_dsn_password_special_chars() {
2759 let dsn = "postgres://user:p@ss%40word@localhost:5432/mydb";
2760 let redacted = redact_dsn_password(dsn).unwrap();
2761 assert_eq!(
2762 redacted,
2763 "postgres://user:***REDACTED***@localhost:5432/mydb"
2764 );
2765 }
2766
2767 #[test]
2768 fn test_render_effective_gears_config() {
2769 let mut app = create_minimal_app();
2770 add_gear_with_config(
2771 &mut app,
2772 "test_gear",
2773 &serde_json::json!({
2774 "my_setting": "my_value",
2775 "enabled": true
2776 }),
2777 );
2778
2779 let result = render_effective_gears_config(&app).unwrap();
2780
2781 assert!(result.is_object());
2783 let gears = result.as_object().unwrap();
2784 assert!(gears.contains_key("test_gear"));
2785
2786 let test_gear = gears.get("test_gear").unwrap();
2787 assert!(test_gear.is_object());
2788 let test_gear_obj = test_gear.as_object().unwrap();
2789
2790 assert!(test_gear_obj.contains_key("config"));
2792
2793 let config = test_gear_obj.get("config").unwrap();
2795 assert_eq!(config.get("my_setting").unwrap(), "my_value");
2796 assert_eq!(config.get("enabled").unwrap(), true);
2797 }
2798
2799 #[test]
2800 fn test_render_effective_gears_config_with_database() {
2801 let mut app = create_app_with_server(
2802 "test_server",
2803 DbConnConfig {
2804 host: Some("localhost".to_owned()),
2805 port: Some(5432),
2806 user: Some("user".to_owned()),
2807 password: Some(toolkit_utils::SecretString::new("pass")),
2808 dbname: Some("db".to_owned()),
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 }),
2820 );
2821
2822 let result = render_effective_gears_config(&app).unwrap();
2823 let gears = result.as_object().unwrap();
2824 let test_gear = gears.get("test_gear").unwrap().as_object().unwrap();
2825
2826 assert!(test_gear.contains_key("database"));
2828 let database = test_gear.get("database").unwrap().as_object().unwrap();
2829 assert!(database.contains_key("dsn"));
2830
2831 let dsn = database.get("dsn").unwrap().as_str().unwrap();
2833 assert!(dsn.contains("***REDACTED***"));
2834 assert!(!dsn.contains("pass"));
2835 }
2836
2837 #[test]
2838 fn test_render_effective_gears_config_minimal() {
2839 let mut app = create_minimal_app();
2841
2842 app.gears
2844 .insert("minimal_gear".to_owned(), serde_json::json!({}));
2845
2846 let result = render_effective_gears_config(&app).unwrap();
2847
2848 assert!(result.is_object());
2851 }
2852
2853 #[test]
2854 fn test_dump_effective_gears_config_yaml() {
2855 let mut app = create_minimal_app();
2856 add_gear_with_config(
2857 &mut app,
2858 "test_gear",
2859 &serde_json::json!({
2860 "setting": "value"
2861 }),
2862 );
2863
2864 let yaml = dump_effective_gears_config_yaml(&app).unwrap();
2865
2866 assert!(yaml.contains("test_gear:"));
2868 assert!(yaml.contains("config:"));
2869 assert!(yaml.contains("setting: value"));
2870 }
2871
2872 #[test]
2873 fn test_dump_effective_gears_config_json() {
2874 let mut app = create_minimal_app();
2875 add_gear_with_config(
2876 &mut app,
2877 "test_gear",
2878 &serde_json::json!({
2879 "setting": "value"
2880 }),
2881 );
2882
2883 let json = dump_effective_gears_config_json(&app).unwrap();
2884
2885 assert!(json.contains("\"test_gear\""));
2887 assert!(json.contains("\"config\""));
2888 assert!(json.contains("\"setting\""));
2889 assert!(json.contains("\"value\""));
2890
2891 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
2893 assert!(parsed.is_object());
2894 }
2895
2896 #[test]
2897 fn test_render_multiple_gears() {
2898 let mut app = create_minimal_app();
2899 add_gear_with_config(&mut app, "gear_a", &serde_json::json!({"a": 1}));
2900 add_gear_with_config(&mut app, "gear_b", &serde_json::json!({"b": 2}));
2901 add_gear_with_config(&mut app, "gear_c", &serde_json::json!({"c": 3}));
2902
2903 let result = render_effective_gears_config(&app).unwrap();
2904 let gears = result.as_object().unwrap();
2905
2906 assert_eq!(gears.len(), 3);
2907 assert!(gears.contains_key("gear_a"));
2908 assert!(gears.contains_key("gear_b"));
2909 assert!(gears.contains_key("gear_c"));
2910 }
2911
2912 #[derive(Debug, Deserialize, Default, PartialEq)]
2915 struct TestVendorConfig {
2916 #[serde(default)]
2917 api_token: String,
2918 #[serde(default)]
2919 api_url: String,
2920 }
2921
2922 #[test]
2923 fn test_vendor_section_parses_from_yaml() {
2924 let yaml = r#"
2925server:
2926 home_dir: "~/.test_vendor"
2927vendor:
2928 acme:
2929 api_token: "acme-token-123"
2930 api_url: "https://acme.example.com"
2931 other_corp:
2932 api_token: "other-token-789"
2933 api_url: "https://other.example.com"
2934"#;
2935 let config: AppConfig = serde_saphyr::from_str(yaml).unwrap();
2936 assert_eq!(config.vendor.len(), 2);
2937 assert!(config.vendor.contains_key("acme"));
2938 assert!(config.vendor.contains_key("other_corp"));
2939
2940 let acme: TestVendorConfig = config.vendor_config("acme").unwrap();
2941 assert_eq!(acme.api_token, "acme-token-123");
2942 assert_eq!(acme.api_url, "https://acme.example.com");
2943
2944 let other: TestVendorConfig = config.vendor_config("other_corp").unwrap();
2945 assert_eq!(other.api_token, "other-token-789");
2946 assert_eq!(other.api_url, "https://other.example.com");
2947 }
2948
2949 #[test]
2950 fn test_vendor_section_defaults_to_empty() {
2951 let config = AppConfig::default();
2952 assert!(config.vendor.is_empty());
2953 }
2954
2955 #[test]
2956 fn test_vendor_config_typed_access() {
2957 let mut config = AppConfig::default();
2958 config.vendor.insert(
2959 "acme".to_owned(),
2960 serde_json::json!({
2961 "api_token": "acme-token-123",
2962 "api_url": "https://acme.example.com"
2963 }),
2964 );
2965
2966 let acme: TestVendorConfig = config.vendor_config("acme").unwrap();
2967 assert_eq!(acme.api_token, "acme-token-123");
2968 assert_eq!(acme.api_url, "https://acme.example.com");
2969 }
2970
2971 #[test]
2972 fn test_vendor_config_not_found() {
2973 let config = AppConfig::default();
2974 let result: Result<TestVendorConfig, _> = config.vendor_config("nonexistent");
2975 assert!(matches!(
2976 result,
2977 Err(VendorConfigError::NotFound { ref vendor }) if vendor == "nonexistent"
2978 ));
2979 }
2980
2981 #[test]
2982 fn test_vendor_config_invalid_structure() {
2983 let mut config = AppConfig::default();
2984 config
2985 .vendor
2986 .insert("bad".to_owned(), serde_json::json!("not an object"));
2987
2988 let result: Result<TestVendorConfig, _> = config.vendor_config("bad");
2989 assert!(matches!(
2990 result,
2991 Err(VendorConfigError::InvalidConfig { ref vendor, .. }) if vendor == "bad"
2992 ));
2993 }
2994
2995 #[test]
2996 fn test_vendor_config_or_default_missing() {
2997 let config = AppConfig::default();
2998 let acme: TestVendorConfig = config.vendor_config_or_default("acme").unwrap();
2999 assert_eq!(acme, TestVendorConfig::default());
3000 }
3001
3002 #[test]
3003 fn test_vendor_config_or_default_present() {
3004 let mut config = AppConfig::default();
3005 config.vendor.insert(
3006 "acme".to_owned(),
3007 serde_json::json!({ "api_token": "acme-token-123" }),
3008 );
3009
3010 let acme: TestVendorConfig = config.vendor_config_or_default("acme").unwrap();
3011 assert_eq!(acme.api_token, "acme-token-123");
3012 }
3013
3014 #[test]
3015 #[serial]
3016 fn test_vendor_config_env_override() {
3017 let tmp = tempdir().unwrap();
3018 let cfg_path = tmp.path().join("cfg.yaml");
3019 let yaml = r#"
3020server:
3021 home_dir: "~/.test_vendor"
3022vendor:
3023 env_test_vendor:
3024 api_token: "from_yaml"
3025"#;
3026 fs::write(&cfg_path, yaml).unwrap();
3027
3028 with_var(
3029 "APP__VENDOR__ENV_TEST_VENDOR__API_TOKEN",
3030 Some("from_env"),
3031 || {
3032 let config = AppConfig::load_layered(&cfg_path).unwrap();
3033 let v: TestVendorConfig = config.vendor_config("env_test_vendor").unwrap();
3034 assert_eq!(v.api_token, "from_env");
3035 },
3036 );
3037 }
3038
3039 #[test]
3040 #[serial]
3041 fn test_gear_config_env_override_underscore_gear_name() {
3042 let tmp = tempdir().unwrap();
3045 let cfg_path = tmp.path().join("cfg.yaml");
3046 let yaml = r#"
3047server:
3048 home_dir: "~/.test_gear_env_underscore"
3049gears:
3050 static-authz-plugin:
3051 config:
3052 vendor: "from_yaml"
3053"#;
3054 fs::write(&cfg_path, yaml).unwrap();
3055
3056 with_var(
3057 "APP__GEARS__STATIC_AUTHZ_PLUGIN__CONFIG__VENDOR",
3058 Some("acme"),
3059 || {
3060 let config = AppConfig::load_layered(&cfg_path).unwrap();
3061 let gear = config.gears.get("static-authz-plugin").unwrap();
3062 assert_eq!(gear["config"]["vendor"], serde_json::json!("acme"));
3063 },
3064 );
3065 }
3066
3067 #[test]
3068 #[serial]
3069 fn test_gear_config_env_override_dash_gear_name_backcompat() {
3070 let tmp = tempdir().unwrap();
3072 let cfg_path = tmp.path().join("cfg.yaml");
3073 let yaml = r#"
3074server:
3075 home_dir: "~/.test_gear_env_dash"
3076gears:
3077 static-authz-plugin:
3078 config:
3079 vendor: "from_yaml"
3080"#;
3081 fs::write(&cfg_path, yaml).unwrap();
3082
3083 with_var(
3084 "APP__GEARS__static-authz-plugin__CONFIG__VENDOR",
3085 Some("acme"),
3086 || {
3087 let config = AppConfig::load_layered(&cfg_path).unwrap();
3088 let gear = config.gears.get("static-authz-plugin").unwrap();
3089 assert_eq!(gear["config"]["vendor"], serde_json::json!("acme"));
3090 },
3091 );
3092 }
3093
3094 #[test]
3095 #[serial]
3096 fn test_gear_config_env_override_preserves_field_underscores() {
3097 let tmp = tempdir().unwrap();
3099 let cfg_path = tmp.path().join("cfg.yaml");
3100 let yaml = r#"
3101server:
3102 home_dir: "~/.test_gear_env_field"
3103gears:
3104 static-authz-plugin:
3105 config:
3106 some_field: "from_yaml"
3107"#;
3108 fs::write(&cfg_path, yaml).unwrap();
3109
3110 with_var(
3111 "APP__GEARS__STATIC_AUTHZ_PLUGIN__CONFIG__SOME_FIELD",
3112 Some("from_env"),
3113 || {
3114 let config = AppConfig::load_layered(&cfg_path).unwrap();
3115 let gear = config.gears.get("static-authz-plugin").unwrap();
3116 assert_eq!(gear["config"]["some_field"], serde_json::json!("from_env"));
3117 },
3118 );
3119 }
3120
3121 #[test]
3122 #[serial]
3123 fn test_vendor_config_env_override_unaffected_by_gear_remap() {
3124 let tmp = tempdir().unwrap();
3129 let cfg_path = tmp.path().join("cfg.yaml");
3130 let yaml = r#"
3131server:
3132 home_dir: "~/.test_vendor_unaffected"
3133vendor:
3134 env_test_vendor:
3135 api_token: "from_yaml"
3136"#;
3137 fs::write(&cfg_path, yaml).unwrap();
3138
3139 with_var(
3140 "APP__VENDOR__ENV_TEST_VENDOR__API_TOKEN",
3141 Some("from_env"),
3142 || {
3143 let config = AppConfig::load_layered(&cfg_path).unwrap();
3144
3145 let v: TestVendorConfig = config.vendor_config("env_test_vendor").unwrap();
3147 assert_eq!(v.api_token, "from_env");
3148
3149 assert!(
3152 config.vendor.contains_key("env_test_vendor"),
3153 "underscore vendor key must be preserved"
3154 );
3155 assert!(
3156 !config.vendor.contains_key("env-test-vendor"),
3157 "gear remap leaked into the vendor branch"
3158 );
3159 },
3160 );
3161 }
3162
3163 #[test]
3164 fn test_vendor_multiple_vendors_typed_access() {
3165 let mut config = AppConfig::default();
3166 config.vendor.insert(
3167 "acme".to_owned(),
3168 serde_json::json!({ "api_token": "acme-token", "api_url": "https://acme.com" }),
3169 );
3170 config.vendor.insert(
3171 "other_corp".to_owned(),
3172 serde_json::json!({ "api_token": "other-token", "api_url": "https://other.com" }),
3173 );
3174
3175 let acme: TestVendorConfig = config.vendor_config("acme").unwrap();
3176 let other: TestVendorConfig = config.vendor_config("other_corp").unwrap();
3177
3178 assert_eq!(acme.api_token, "acme-token");
3179 assert_eq!(other.api_token, "other-token");
3180 assert_eq!(acme.api_url, "https://acme.com");
3181 assert_eq!(other.api_url, "https://other.com");
3182 }
3183
3184 #[test]
3185 fn test_vendor_nested_config() {
3186 #[derive(Debug, Deserialize, PartialEq)]
3187 struct NestedVendorConfig {
3188 api_url: String,
3189 feature_flags: FeatureFlags,
3190 }
3191
3192 #[derive(Debug, Deserialize, PartialEq)]
3193 struct FeatureFlags {
3194 beta_mode: bool,
3195 max_retries: u32,
3196 }
3197
3198 let mut config = AppConfig::default();
3199 config.vendor.insert(
3200 "acme".to_owned(),
3201 serde_json::json!({
3202 "api_url": "https://acme.com",
3203 "feature_flags": {
3204 "beta_mode": true,
3205 "max_retries": 3
3206 }
3207 }),
3208 );
3209
3210 let acme: NestedVendorConfig = config.vendor_config("acme").unwrap();
3211 assert_eq!(acme.api_url, "https://acme.com");
3212 assert!(acme.feature_flags.beta_mode);
3213 assert_eq!(acme.feature_flags.max_retries, 3);
3214 }
3215
3216 #[test]
3217 fn test_vendor_config_or_default_invalid_returns_error() {
3218 let mut config = AppConfig::default();
3219 config
3220 .vendor
3221 .insert("bad".to_owned(), serde_json::json!("not an object"));
3222
3223 let result: Result<TestVendorConfig, _> = config.vendor_config_or_default("bad");
3224 assert!(matches!(
3225 result,
3226 Err(VendorConfigError::InvalidConfig { ref vendor, .. }) if vendor == "bad"
3227 ));
3228 }
3229
3230 #[test]
3231 fn test_vendor_config_yaml_roundtrip() {
3232 let mut config = AppConfig::default();
3233 config.vendor.insert(
3234 "acme".to_owned(),
3235 serde_json::json!({ "api_token": "acme-token-123" }),
3236 );
3237
3238 let yaml = config.to_yaml().unwrap();
3239 assert!(yaml.contains("vendor"));
3240 assert!(yaml.contains("acme"));
3241 assert!(yaml.contains("acme-token-123"));
3242 }
3243
3244 #[test]
3245 fn test_vendor_coexists_with_gears() {
3246 let mut config = AppConfig::default();
3247 config.gears.insert(
3248 "my_gear".to_owned(),
3249 serde_json::json!({ "config": { "some_setting": true } }),
3250 );
3251 config.vendor.insert(
3252 "acme".to_owned(),
3253 serde_json::json!({ "api_token": "acme-token-123" }),
3254 );
3255
3256 assert!(config.gears.contains_key("my_gear"));
3257 assert!(config.vendor.contains_key("acme"));
3258
3259 let acme: TestVendorConfig = config.vendor_config("acme").unwrap();
3260 assert_eq!(acme.api_token, "acme-token-123");
3261 }
3262
3263 #[test]
3264 fn test_vendor_error_display_messages() {
3265 let not_found = VendorConfigError::NotFound {
3266 vendor: "acme".to_owned(),
3267 };
3268 assert_eq!(
3269 not_found.to_string(),
3270 "vendor 'acme' not found in configuration"
3271 );
3272
3273 let invalid = VendorConfigError::InvalidConfig {
3274 vendor: "bad".to_owned(),
3275 cause: serde_json::from_str::<TestVendorConfig>("invalid").unwrap_err(),
3276 };
3277 let msg = invalid.to_string();
3278 assert!(msg.starts_with("invalid config for vendor 'bad':"));
3279 assert!(std::error::Error::source(&invalid).is_none());
3280 }
3281
3282 #[test]
3283 fn test_vendor_empty_object_in_yaml() {
3284 let yaml = r#"
3285server:
3286 home_dir: "~/.test_vendor"
3287vendor: {}
3288"#;
3289 let config: AppConfig = serde_saphyr::from_str(yaml).unwrap();
3290 assert!(config.vendor.is_empty());
3291 }
3292
3293 #[test]
3296 fn test_reject_duplicate_gear_names() {
3297 let tmp = tempdir().unwrap();
3298 let cfg_path = tmp.path().join("cfg.yaml");
3299 let yaml = r#"
3300server:
3301 home_dir: "~/.test_dup"
3302gears:
3303 gear1:
3304 config: {}
3305 gear2:
3306 config: {}
3307 gear1:
3308 config: {}
3309"#;
3310 fs::write(&cfg_path, yaml).unwrap();
3311
3312 let result = AppConfig::load_layered(&cfg_path);
3313 assert!(result.is_err(), "duplicate gear names should be rejected");
3314 let msg = format!("{:?}", result.unwrap_err());
3315 assert!(
3316 msg.contains("duplicate") || msg.contains("Duplicate"),
3317 "error should mention duplicates: {msg}"
3318 );
3319 }
3320
3321 #[test]
3322 fn test_reject_duplicate_keys_in_gear_file() {
3323 let tmp = tempdir().unwrap();
3324 let gears_dir = tmp.path().join("gears.d");
3325 fs::create_dir_all(&gears_dir).unwrap();
3326
3327 let gear_yaml = r#"
3329config:
3330 key1: "value1"
3331config:
3332 key2: "value2"
3333"#;
3334 fs::write(gears_dir.join("bad_gear.yaml"), gear_yaml).unwrap();
3335
3336 let cfg_yaml = format!(
3337 r#"
3338server:
3339 home_dir: "~/.test_dup_modfile"
3340gears_dir: "{}"
3341"#,
3342 normalize_path(&gears_dir)
3343 );
3344 let cfg_path = tmp.path().join("cfg.yaml");
3345 fs::write(&cfg_path, cfg_yaml).unwrap();
3346
3347 let result = AppConfig::load_layered(&cfg_path);
3348 assert!(
3349 result.is_err(),
3350 "duplicate keys in a gear file should be rejected"
3351 );
3352 let msg = format!("{:?}", result.unwrap_err());
3353 assert!(
3354 msg.contains("duplicate") || msg.contains("Duplicate"),
3355 "error should mention duplicates: {msg}"
3356 );
3357 }
3358
3359 #[test]
3360 fn test_no_false_positive_on_unique_gears() {
3361 let tmp = tempdir().unwrap();
3362 let cfg_path = tmp.path().join("cfg.yaml");
3363 let yaml = r#"
3364server:
3365 home_dir: "~/.test_ok"
3366gears:
3367 gear1:
3368 config: {}
3369 gear2:
3370 config: {}
3371 gear3:
3372 config: {}
3373"#;
3374 fs::write(&cfg_path, yaml).unwrap();
3375
3376 let result = AppConfig::load_layered(&cfg_path);
3377 assert!(
3378 result.is_ok(),
3379 "unique gear names should be accepted: {:?}",
3380 result.unwrap_err()
3381 );
3382 }
3383}
3384
3385