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}': {source}")]
31 InvalidConfig {
32 vendor: String,
33 #[source]
34 source: 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}
121
122impl Default for AppConfig {
123 fn default() -> Self {
124 let server = ServerConfig::default();
125 Self {
126 server,
127 database: None,
128 logging: default_logging_config(),
129 opentelemetry: OpenTelemetryConfig::default(),
130 gears_dir: None,
131 gears: HashMap::new(),
132 vendor: VendorConfig::new(),
133 }
134 }
135}
136
137impl ConfigProvider for AppConfig {
138 fn get_gear_config(&self, gear_name: &str) -> Option<&serde_json::Value> {
139 self.gears.get(gear_name)
140 }
141}
142
143#[derive(Debug, Clone, Deserialize, Serialize)]
144#[serde(deny_unknown_fields)]
145pub struct ServerConfig {
146 #[serde(default = "default_server_name")]
147 pub name: String,
148 #[serde(default = "default_home_dir")]
149 pub home_dir: PathBuf, }
151
152fn default_server_name() -> String {
153 "cf-gears".to_owned()
154}
155
156fn default_home_dir() -> PathBuf {
157 super::host::paths::default_home_dir().join(".cf-gears")
158}
159
160impl Default for ServerConfig {
161 fn default() -> Self {
162 Self {
163 name: default_server_name(),
164 home_dir: default_home_dir(),
165 }
166 }
167}
168
169impl ServerConfig {
170 fn normalize_home_dir_inplace(&mut self) -> Result<()> {
171 self.home_dir = super::host::normalize_path(
172 self.home_dir
173 .to_str()
174 .context("home directory configuration is not a valid path")?,
175 )
176 .context("home_dir normalization failed")?;
177
178 std::fs::create_dir_all(&self.home_dir).context("Failed to create home_dir")?;
179
180 Ok(())
181 }
182}
183
184#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
186#[serde(rename_all = "lowercase")]
187pub enum ConsoleFormat {
188 #[default]
190 Text,
191 Json,
193}
194
195pub type LoggingConfig = HashMap<String, Section>;
198
199pub type VendorConfig = HashMap<String, serde_json::Value>;
203
204mod optional_level_serde {
206 use serde::{Deserialize, Deserializer, Serializer};
207 use tracing::Level;
208
209 #[allow(clippy::ref_option, clippy::trivially_copy_pass_by_ref)]
210 pub fn serialize<S>(level: &Option<Level>, serializer: S) -> Result<S::Ok, S::Error>
211 where
212 S: Serializer,
213 {
214 match level {
215 Some(l) => serializer.serialize_str(l.as_str()),
216 None => serializer.serialize_str("off"),
217 }
218 }
219
220 pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<Level>, D::Error>
221 where
222 D: Deserializer<'de>,
223 {
224 let s = String::deserialize(deserializer)?;
225 match s.to_lowercase().as_str() {
226 "trace" => Ok(Some(Level::TRACE)),
227 "debug" => Ok(Some(Level::DEBUG)),
228 "info" => Ok(Some(Level::INFO)),
229 "warn" => Ok(Some(Level::WARN)),
230 "error" => Ok(Some(Level::ERROR)),
231 "off" | "none" => Ok(None),
232 _ => Err(serde::de::Error::custom(format!("invalid level: {s}"))),
233 }
234 }
235
236 #[allow(clippy::unnecessary_wraps)]
237 pub fn default() -> Option<Level> {
238 Some(Level::INFO)
239 }
240}
241
242#[derive(Debug, Serialize, Deserialize, Clone)]
243pub struct SectionFile {
244 pub file: String,
245 #[serde(
246 default = "optional_level_serde::default",
247 with = "optional_level_serde"
248 )]
249 pub file_level: Option<Level>,
250}
251
252#[derive(Debug, Serialize, Deserialize, Clone)]
253pub struct Section {
254 #[serde(default)]
255 pub console_format: ConsoleFormat,
256 #[serde(
257 default = "optional_level_serde::default",
258 with = "optional_level_serde"
259 )]
260 pub console_level: Option<Level>,
261 #[serde(flatten)]
262 pub section_file: Option<SectionFile>,
263 pub max_age_days: Option<u32>, #[serde(default)]
265 pub max_backups: Option<usize>, #[serde(default)]
267 pub max_size_mb: Option<u64>, }
269
270impl Section {
271 #[must_use]
272 pub fn file(&self) -> Option<&str> {
273 self.section_file
274 .as_ref()
275 .map(|f| f.file.as_str())
276 .filter(|s| !s.is_empty())
277 }
278
279 #[must_use]
280 pub fn file_level(&self) -> Option<Level> {
281 self.section_file.as_ref().and_then(|f| f.file_level)
282 }
283}
284
285#[must_use]
287pub fn default_logging_config() -> LoggingConfig {
288 let mut logging = HashMap::new();
289 logging.insert(
290 "default".to_owned(),
291 Section {
292 console_level: Some(Level::INFO),
293 section_file: Some(SectionFile {
294 file: "logs/cf-gears.log".to_owned(),
295 file_level: Some(Level::DEBUG),
296 }),
297 console_format: ConsoleFormat::default(),
298 max_age_days: Some(7),
299 max_backups: Some(3),
300 max_size_mb: Some(100),
301 },
302 );
303 logging
304}
305
306pub(crate) fn remap_gear_env_key(key: &str) -> String {
317 let lower = key.to_ascii_lowercase();
319 let mut parts: Vec<&str> = lower.split('.').collect();
320 if parts.first() == Some(&"gears") && parts.len() >= 2 {
321 let gear = parts[1].replace('_', "-");
322 parts[1] = gear.as_str();
323 parts.join(".")
324 } else {
325 lower
326 }
327}
328
329impl AppConfig {
330 pub fn load_layered(config_path: &PathBuf) -> Result<Self> {
336 use figment::{
337 Figment,
338 providers::{Env, Format, Serialized},
339 };
340
341 let figment = Figment::new()
345 .merge(Serialized::defaults(AppConfig::default()))
346 .merge(StrictYaml::file(config_path))
347 .merge(
349 Env::prefixed("APP__")
350 .split("__")
351 .map(|key| remap_gear_env_key(key.as_str()).into()),
352 );
353
354 let mut config: AppConfig = figment
355 .extract()
356 .with_context(|| "Failed to extract config from figment".to_owned())?;
357
358 config
360 .server
361 .normalize_home_dir_inplace()
362 .context("Failed to resolve server.home_dir")?;
363
364 if let Some(dir) = config.gears_dir.as_ref() {
366 merge_gear_files(&mut config.gears, dir)?;
367 }
368
369 Ok(config)
370 }
371
372 pub fn load_or_default(config_path: Option<&PathBuf>) -> Result<Self> {
378 if let Some(path) = config_path {
379 ensure!(
380 path.is_file(),
381 "config file does not exist: {}",
382 path.to_string_lossy()
383 );
384 Self::load_layered(path)
385 } else {
386 let mut c = Self::default();
387 c.server
388 .normalize_home_dir_inplace()
389 .context("Failed to resolve server.home_dir (defaults)")?;
390 Ok(c)
391 }
392 }
393
394 pub fn to_yaml(&self) -> Result<String> {
399 serde_saphyr::to_string(self).context("Failed to serialize config to YAML")
400 }
401
402 pub fn vendor_config<T: DeserializeOwned>(
408 &self,
409 vendor_name: &str,
410 ) -> Result<T, VendorConfigError> {
411 let raw = self
412 .vendor
413 .get(vendor_name)
414 .ok_or_else(|| VendorConfigError::NotFound {
415 vendor: vendor_name.to_owned(),
416 })?;
417 T::deserialize(raw).map_err(|e| VendorConfigError::InvalidConfig {
418 vendor: vendor_name.to_owned(),
419 source: e,
420 })
421 }
422
423 pub fn vendor_config_or_default<T: DeserializeOwned + Default>(
429 &self,
430 vendor_name: &str,
431 ) -> Result<T, VendorConfigError> {
432 let Some(raw) = self.vendor.get(vendor_name) else {
433 return Ok(T::default());
434 };
435 T::deserialize(raw).map_err(|e| VendorConfigError::InvalidConfig {
436 vendor: vendor_name.to_owned(),
437 source: e,
438 })
439 }
440
441 pub fn apply_cli_overrides(&mut self, verbose: u8) {
443 if let Some(default_section) = self.logging.get_mut("default") {
445 default_section.console_level = match verbose {
446 0 => default_section.console_level, 1 => Some(Level::DEBUG),
448 _ => Some(Level::TRACE),
449 };
450 }
451 }
452}
453
454#[derive(Debug, Clone)]
456pub struct CliArgs {
457 pub config: Option<String>,
458 pub print_config: bool,
459 pub verbose: u8,
460 pub mock: bool,
461}
462
463fn strict_yaml_parse<T: serde::de::DeserializeOwned>(s: &str) -> Result<T, serde_saphyr::Error> {
465 let opts = serde_saphyr::Options {
466 duplicate_keys: serde_saphyr::DuplicateKeyPolicy::Error,
467 ..serde_saphyr::Options::default()
468 };
469 serde_saphyr::from_str_with_options(s, opts)
470}
471
472struct StrictYaml;
478
479impl figment::providers::Format for StrictYaml {
480 type Error = serde_saphyr::Error;
481
482 const NAME: &'static str = "YAML";
483
484 fn from_str<T: serde::de::DeserializeOwned>(s: &str) -> Result<T, Self::Error> {
485 strict_yaml_parse(s)
486 }
487}
488
489fn merge_gear_files(
490 bag: &mut HashMap<String, serde_json::Value>,
491 dir: impl AsRef<Path>,
492) -> Result<()> {
493 use std::fs;
494 let dir = dir.as_ref();
495 if !dir.exists() {
496 return Ok(());
497 }
498 for entry in fs::read_dir(dir)? {
499 let entry = entry?;
500 let path = entry.path();
501 if !path.is_file() {
502 continue;
503 }
504 let ext = path
505 .extension()
506 .and_then(|s| s.to_str())
507 .unwrap_or("")
508 .to_ascii_lowercase();
509 if ext != "yml" && ext != "yaml" {
510 continue;
511 }
512 let name = path
513 .file_stem()
514 .and_then(|s| s.to_str())
515 .unwrap_or("")
516 .to_owned();
517 let raw = fs::read_to_string(&path)?;
518 let json: serde_json::Value = strict_yaml_parse(&raw)
519 .with_context(|| format!("failed to parse gear file: {}", path.display()))?;
520 bag.insert(name, json);
521 }
522 Ok(())
523}
524
525pub fn expand_env_in_dsn(dsn: &str) -> Result<String> {
533 toolkit_utils::var_expand::expand_env_vars(dsn).map_err(|e| anyhow::anyhow!("{e}"))
534}
535
536pub fn resolve_password(password: Option<&str>) -> Result<Option<String>> {
541 if let Some(pwd) = password {
542 if pwd.starts_with("${") && pwd.ends_with('}') {
543 let var_name = &pwd[2..pwd.len() - 1];
545 let resolved = std::env::var(var_name).with_context(|| {
546 format!("Environment variable '{var_name}' not found for password")
547 })?;
548 Ok(Some(resolved))
549 } else {
550 Ok(Some(pwd.to_owned()))
552 }
553 } else {
554 Ok(None)
555 }
556}
557
558pub fn validate_dsn(dsn: &str) -> Result<()> {
564 if dsn.starts_with("sqlite:") {
566 return Ok(());
567 }
568
569 let _parsed = dsn::parse(dsn).map_err(|e| anyhow::anyhow!("Invalid DSN '{dsn}': {e}"))?;
570
571 Ok(())
572}
573
574fn resolve_sqlite_dsn(
579 dsn: &str,
580 home_dir: &Path,
581 gear_name: &str,
582 dry_run: bool,
583) -> Result<String> {
584 if dsn.contains("@file(") {
585 if let Some(start) = dsn.find("@file(")
587 && let Some(end) = dsn[start..].find(')')
588 {
589 let file_path = &dsn[start + 6..start + end]; let resolved_path = if file_path.starts_with('/')
592 || (file_path.len() > 1 && file_path.chars().nth(1) == Some(':'))
593 {
594 PathBuf::from(file_path)
596 } else {
597 let gear_dir = home_dir.join(gear_name);
599 if !dry_run {
600 std::fs::create_dir_all(&gear_dir).with_context(|| {
601 format!("Failed to create gear directory: {}", gear_dir.display())
602 })?;
603 }
604 gear_dir.join(file_path)
605 };
606
607 let normalized_path = normalize_path(&resolved_path);
608 if normalized_path.len() > 1 && normalized_path.chars().nth(1) == Some(':') {
611 return Ok(format!("sqlite:{normalized_path}"));
613 }
614 return Ok(format!("sqlite://{normalized_path}"));
616 }
617 return Err(anyhow::anyhow!(
618 "Invalid @file() syntax in SQLite DSN: {dsn}"
619 ));
620 }
621
622 if dsn == "sqlite://" || dsn == "sqlite:///" || dsn == "sqlite:" {
624 let gear_dir = home_dir.join(gear_name);
625 if !dry_run {
626 std::fs::create_dir_all(&gear_dir).with_context(|| {
627 format!("Failed to create gear directory: {}", gear_dir.display())
628 })?;
629 }
630 let db_path = gear_dir.join(format!("{gear_name}.sqlite"));
631 let normalized_path = normalize_path(&db_path);
632 if normalized_path.len() > 1 && normalized_path.chars().nth(1) == Some(':') {
635 return Ok(format!("sqlite:{normalized_path}"));
637 }
638 return Ok(format!("sqlite://{normalized_path}"));
640 }
641
642 Ok(dsn.to_owned())
644}
645
646fn build_server_dsn(
650 scheme: &str,
651 host: Option<&str>,
652 port: Option<u16>,
653 user: Option<&str>,
654 password: Option<&str>,
655 dbname: Option<&str>,
656 params: &HashMap<String, String>,
657) -> Result<String> {
658 let host = host.unwrap_or("localhost");
659 let user = user.unwrap_or("postgres"); let mut url = Url::parse(&format!("{scheme}://dummy/"))
663 .with_context(|| format!("Invalid scheme: {scheme}"))?;
664
665 url.set_host(Some(host))
667 .with_context(|| format!("Invalid host: {host}"))?;
668
669 if let Some(port) = port {
671 url.set_port(Some(port))
672 .map_err(|()| anyhow::anyhow!("Invalid port: {port}"))?;
673 }
674
675 url.set_username(user)
677 .map_err(|()| anyhow::anyhow!("Failed to set username: {user}"))?;
678
679 if let Some(password) = password {
681 url.set_password(Some(password))
682 .map_err(|()| anyhow::anyhow!("Failed to set password"))?;
683 }
684
685 if let Some(dbname) = dbname {
687 let encoded_dbname = urlencoding::encode(dbname);
689 url.set_path(&format!("/{encoded_dbname}"));
690 } else {
691 url.set_path("/");
692 }
693
694 if !params.is_empty() {
696 let mut query_pairs = url.query_pairs_mut();
698 for (key, value) in params {
699 query_pairs.append_pair(key, value);
700 }
701 }
702
703 Ok(url.to_string())
704}
705
706fn build_sqlite_dsn_with_dbname_override(
708 original_dsn: &str,
709 dbname: &str,
710 gear_name: &str,
711 home_dir: &Path,
712 dry_run: bool,
713) -> Result<String> {
714 let query_params = if let Some(query_start) = original_dsn.find('?') {
716 &original_dsn[query_start..]
717 } else {
718 ""
719 };
720
721 let gear_dir = home_dir.join(gear_name);
723 if !dry_run {
724 std::fs::create_dir_all(&gear_dir)
725 .with_context(|| format!("Failed to create gear directory: {}", gear_dir.display()))?;
726 }
727 let db_path = gear_dir.join(dbname);
728 let normalized_path = normalize_path(&db_path);
729
730 let dsn_base = if normalized_path.len() > 1 && normalized_path.chars().nth(1) == Some(':') {
732 format!("sqlite:{normalized_path}")
734 } else {
735 format!("sqlite://{normalized_path}")
737 };
738
739 Ok(format!("{dsn_base}{query_params}"))
740}
741
742fn build_sqlite_dsn(
748 dsn: Option<&str>,
749 file: Option<&str>,
750 path: Option<&PathBuf>,
751 dbname: Option<&str>,
752 gear_name: &str,
753 home_dir: &Path,
754 dry_run: bool,
755) -> Result<String> {
756 if let Some(dsn) = dsn {
758 let resolved_dsn = resolve_sqlite_dsn(dsn, home_dir, gear_name, dry_run)?;
759
760 if let Some(dbname) = dbname {
762 return build_sqlite_dsn_with_dbname_override(
763 &resolved_dsn,
764 dbname,
765 gear_name,
766 home_dir,
767 dry_run,
768 );
769 }
770
771 validate_dsn(&resolved_dsn)?;
772 return Ok(resolved_dsn);
773 }
774
775 if let Some(path) = path {
777 let absolute_path = if path.is_absolute() {
778 path.clone()
779 } else {
780 home_dir.join(path)
781 };
782 let normalized_path = normalize_path(&absolute_path);
783 if normalized_path.len() > 1 && normalized_path.chars().nth(1) == Some(':') {
786 return Ok(format!("sqlite:{normalized_path}"));
788 }
789 return Ok(format!("sqlite://{normalized_path}"));
791 }
792
793 if let Some(file) = file {
795 let gear_dir = home_dir.join(gear_name);
796 if !dry_run {
797 std::fs::create_dir_all(&gear_dir).with_context(|| {
798 format!("Failed to create gear directory: {}", gear_dir.display())
799 })?;
800 }
801 let db_path = gear_dir.join(file);
802 let normalized_path = normalize_path(&db_path);
803 if normalized_path.len() > 1 && normalized_path.chars().nth(1) == Some(':') {
806 return Ok(format!("sqlite:{normalized_path}"));
808 }
809 return Ok(format!("sqlite://{normalized_path}"));
811 }
812
813 let gear_dir = home_dir.join(gear_name);
815 if !dry_run {
816 std::fs::create_dir_all(&gear_dir)
817 .with_context(|| format!("Failed to create gear directory: {}", gear_dir.display()))?;
818 }
819 let db_path = gear_dir.join(format!("{gear_name}.sqlite"));
820 let normalized_path = normalize_path(&db_path);
821 if normalized_path.len() > 1 && normalized_path.chars().nth(1) == Some(':') {
824 Ok(format!("sqlite:{normalized_path}"))
826 } else {
827 Ok(format!("sqlite://{normalized_path}"))
829 }
830}
831
832type DbConfigResult = Result<Option<(String , PoolCfg)>>;
834
835#[derive(Default)]
837struct DbConfigBuilder {
838 dsn: Option<String>,
839 host: Option<String>,
840 port: Option<u16>,
841 user: Option<String>,
842 password: Option<String>,
843 dbname: Option<String>,
844 params: HashMap<String, String>,
845 pool: PoolCfg,
846}
847
848impl DbConfigBuilder {
849 fn new() -> Self {
850 Self::default()
851 }
852
853 fn apply_global_server(
855 &mut self,
856 global_server: &DbConnConfig,
857 home_dir: &Path,
858 gear_name: &str,
859 dry_run: bool,
860 ) -> Result<()> {
861 if let Some(global_dsn) = &global_server.dsn {
863 let expanded_dsn = expand_env_in_dsn(global_dsn)?;
864 let resolved_dsn = if expanded_dsn.starts_with("sqlite") {
866 resolve_sqlite_dsn(&expanded_dsn, home_dir, gear_name, dry_run)?
867 } else {
868 expanded_dsn
869 };
870 validate_dsn(&resolved_dsn)?;
871 self.dsn = Some(resolved_dsn);
872 }
873
874 if let Some(host) = &global_server.host {
876 self.host = Some(host.clone());
877 }
878 if let Some(port) = global_server.port {
879 self.port = Some(port);
880 }
881 if let Some(user) = &global_server.user {
882 self.user = Some(user.clone());
883 }
884 if let Some(password) = resolve_password(global_server.password.as_deref())? {
885 self.password = Some(password);
886 }
887 if let Some(dbname) = &global_server.dbname {
888 self.dbname = Some(dbname.clone());
889 }
890 if let Some(params) = &global_server.params {
891 self.params.extend(params.clone());
892 }
893 if let Some(pool) = &global_server.pool {
894 self.pool = pool.clone();
895 }
896
897 Ok(())
898 }
899
900 fn apply_gear_dsn(
902 &mut self,
903 gear_dsn: &str,
904 home_dir: &Path,
905 gear_name: &str,
906 dry_run: bool,
907 ) -> Result<()> {
908 let resolved_dsn = if gear_dsn.starts_with("sqlite") {
910 resolve_sqlite_dsn(gear_dsn, home_dir, gear_name, dry_run)?
911 } else {
912 gear_dsn.to_owned()
913 };
914 validate_dsn(&resolved_dsn)?;
915 self.dsn = Some(resolved_dsn);
916 Ok(())
917 }
918
919 fn apply_gear_fields(&mut self, gear_db_config: &DbConnConfig) -> Result<()> {
921 if let Some(host) = &gear_db_config.host {
922 self.host = Some(host.clone());
923 }
924 if let Some(port) = gear_db_config.port {
925 self.port = Some(port);
926 }
927 if let Some(user) = &gear_db_config.user {
928 self.user = Some(user.clone());
929 }
930 if let Some(password) = resolve_password(gear_db_config.password.as_deref())? {
931 self.password = Some(password);
932 }
933 if let Some(dbname) = &gear_db_config.dbname {
934 self.dbname = Some(dbname.clone());
935 }
936 if let Some(params) = &gear_db_config.params {
937 self.params.extend(params.clone());
938 }
939 if let Some(pool) = &gear_db_config.pool {
940 if let Some(max_conns) = pool.max_conns {
942 self.pool.max_conns = Some(max_conns);
943 }
944 if let Some(acquire_timeout) = pool.acquire_timeout {
945 self.pool.acquire_timeout = Some(acquire_timeout);
946 }
947 }
948 Ok(())
949 }
950
951 fn has_field_overrides(&self) -> bool {
953 self.host.is_some()
954 || self.port.is_some()
955 || self.user.is_some()
956 || self.password.is_some()
957 || !self.params.is_empty()
958 }
959}
960
961fn decide_backend(builder: &DbConfigBuilder, gear_db_config: &DbConnConfig) -> bool {
963 gear_db_config.file.is_some()
966 || gear_db_config.path.is_some()
967 || builder
968 .dsn
969 .as_ref()
970 .is_some_and(|dsn| dsn.starts_with("sqlite"))
971 || (gear_db_config.server.is_none() && builder.dsn.is_none())
972}
973
974fn finalize_sqlite_dsn(
976 builder: &DbConfigBuilder,
977 gear_db_config: &DbConnConfig,
978 gear_name: &str,
979 home_dir: &Path,
980 dry_run: bool,
981) -> Result<String> {
982 build_sqlite_dsn(
983 builder.dsn.as_deref(),
984 gear_db_config.file.as_deref(),
985 gear_db_config.path.as_ref(),
986 builder.dbname.as_deref(),
987 gear_name,
988 home_dir,
989 dry_run,
990 )
991}
992
993fn finalize_server_dsn(builder: &DbConfigBuilder, gear_name: &str) -> Result<String> {
995 let dbname = if let Some(dbname) = builder.dbname.as_deref() {
997 dbname.to_owned()
998 } else if let Some(dsn) = builder.dsn.as_ref() {
999 if let Ok(parsed) = url::Url::parse(dsn) {
1001 let path = parsed.path();
1002 if path.len() > 1 {
1003 path[1..].to_string()
1005 } else {
1006 return Err(anyhow::anyhow!(
1007 "Server-based database config for gear '{gear_name}' missing required 'dbname'"
1008 ));
1009 }
1010 } else {
1011 return Err(anyhow::anyhow!(
1012 "Server-based database config for gear '{gear_name}' missing required 'dbname'"
1013 ));
1014 }
1015 } else {
1016 return Err(anyhow::anyhow!(
1017 "Server-based database config for gear '{gear_name}' missing required 'dbname'"
1018 ));
1019 };
1020
1021 if builder.has_field_overrides() || builder.dsn.is_none() {
1022 let scheme = if let Some(dsn) = &builder.dsn {
1024 let parsed = Url::parse(dsn)?;
1025 parsed.scheme().to_owned()
1026 } else {
1027 "postgresql".to_owned() };
1029
1030 build_server_dsn(
1031 &scheme,
1032 builder.host.as_deref(),
1033 builder.port,
1034 builder.user.as_deref(),
1035 builder.password.as_deref(),
1036 Some(&dbname),
1037 &builder.params,
1038 )
1039 } else if let Some(original_dsn) = &builder.dsn {
1040 if let Ok(mut parsed) = Url::parse(original_dsn) {
1042 let original_dbname = parsed.path().trim_start_matches('/');
1044 if original_dbname != dbname {
1045 parsed.set_path(&format!("/{dbname}"));
1046 }
1047 Ok(parsed.to_string())
1048 } else {
1049 build_server_dsn(
1051 "postgresql",
1052 builder.host.as_deref(),
1053 builder.port,
1054 builder.user.as_deref(),
1055 builder.password.as_deref(),
1056 Some(&dbname),
1057 &builder.params,
1058 )
1059 }
1060 } else {
1061 unreachable!("final_dsn should not be None when has_field_overrides is false")
1063 }
1064}
1065
1066fn redact_dsn_for_logging(dsn: &str) -> Result<String> {
1068 if dsn.contains('@') {
1069 let parsed = Url::parse(dsn)?;
1070 let mut log_url = parsed;
1071 if log_url.password().is_some() {
1072 log_url.set_password(Some("***")).ok();
1073 }
1074 Ok(log_url.to_string())
1075 } else {
1076 Ok(dsn.to_owned())
1077 }
1078}
1079
1080pub const TOOLKIT_MODULE_CONFIG_ENV: &str = "TOOLKIT_MODULE_CONFIG";
1084
1085#[derive(Debug, Clone, Serialize, Deserialize)]
1088pub struct RenderedDbConfig {
1089 #[serde(skip_serializing_if = "Option::is_none")]
1092 pub global: Option<GlobalDatabaseConfig>,
1093 #[serde(skip_serializing_if = "Option::is_none")]
1096 pub gear: Option<DbConnConfig>,
1097}
1098
1099impl RenderedDbConfig {
1100 #[must_use]
1102 pub fn new(global: Option<GlobalDatabaseConfig>, gear: Option<DbConnConfig>) -> Self {
1103 Self { global, gear }
1104 }
1105}
1106
1107#[derive(Debug, Clone, Serialize, Deserialize)]
1117pub struct RenderedGearConfig {
1118 #[serde(skip_serializing_if = "Option::is_none")]
1121 pub database: Option<RenderedDbConfig>,
1122 #[serde(default)]
1124 pub config: serde_json::Value,
1125 #[serde(skip_serializing_if = "Option::is_none")]
1128 pub logging: Option<LoggingConfig>,
1129 #[serde(skip_serializing_if = "Option::is_none")]
1131 pub opentelemetry: Option<OpenTelemetryConfig>,
1132}
1133
1134impl RenderedGearConfig {
1135 pub fn from_json(json: &str) -> Result<Self> {
1140 serde_json::from_str(json).context("Failed to parse RenderedGearConfig from JSON")
1141 }
1142
1143 pub fn to_json(&self) -> Result<String> {
1148 serde_json::to_string(self).context("Failed to serialize RenderedGearConfig to JSON")
1149 }
1150}
1151
1152pub fn render_gear_config_for_oop(
1169 app: &AppConfig,
1170 gear_name: &str,
1171 _home_dir: &std::path::Path,
1172) -> Result<RenderedGearConfig> {
1173 let gear_db_config = parse_gear_config(app, gear_name)
1176 .ok()
1177 .and_then(|entry| entry.database);
1178
1179 let database = if gear_db_config.is_some() || app.database.is_some() {
1181 Some(RenderedDbConfig::new(app.database.clone(), gear_db_config))
1182 } else {
1183 None
1184 };
1185
1186 let config = parse_gear_config(app, gear_name)
1188 .map(|entry| entry.config)
1189 .unwrap_or_default();
1190
1191 let logging = app.logging.clone();
1193
1194 let opentelemetry = if app.opentelemetry.tracing.enabled || app.opentelemetry.metrics.enabled {
1196 Some(app.opentelemetry.clone())
1197 } else {
1198 None
1199 };
1200
1201 Ok(RenderedGearConfig {
1202 database,
1203 config,
1204 logging: Some(logging),
1205 opentelemetry,
1206 })
1207}
1208
1209pub fn parse_gear_config(app: &AppConfig, gear_name: &str) -> Result<GearConfig> {
1214 let gear_raw = app
1215 .gears
1216 .get(gear_name)
1217 .cloned()
1218 .ok_or_else(|| anyhow::anyhow!("Gear '{gear_name}' not found in config"))?;
1219
1220 let gear_config: GearConfig = serde_json::from_value(gear_raw)?;
1221 Ok(gear_config)
1222}
1223
1224pub fn get_gear_runtime_config(app: &AppConfig, gear_name: &str) -> Result<Option<GearRuntime>> {
1229 let entry = parse_gear_config(app, gear_name)?;
1230 Ok(entry.runtime)
1231}
1232
1233pub fn build_final_db_for_gear(
1244 app: &AppConfig,
1245 gear_name: &str,
1246 home_dir: &Path,
1247 dry_run: bool,
1248) -> DbConfigResult {
1249 let Some(gear_raw) = app.gears.get(gear_name) else {
1251 return Ok(None); };
1253
1254 let gear_entry: GearConfig = serde_json::from_value(gear_raw.clone())
1255 .with_context(|| format!("Invalid gear config structure for '{gear_name}'"))?;
1256
1257 let Some(gear_db_config) = gear_entry.database else {
1258 tracing::warn!(
1259 "Gear '{}' has no database configuration; DB capability disabled",
1260 gear_name
1261 );
1262 return Ok(None);
1263 };
1264
1265 let global_db_config = app.database.as_ref();
1267
1268 let mut builder = DbConfigBuilder::new();
1270
1271 if let Some(server_name) = &gear_db_config.server {
1273 let global_server = global_db_config
1274 .and_then(|gc| gc.servers.get(server_name))
1275 .ok_or_else(|| {
1276 anyhow::anyhow!("Referenced server '{server_name}' not found in global config")
1277 })?;
1278
1279 builder.apply_global_server(global_server, home_dir, gear_name, dry_run)?;
1280 }
1281
1282 if let Some(gear_dsn) = &gear_db_config.dsn {
1284 builder.apply_gear_dsn(gear_dsn, home_dir, gear_name, dry_run)?;
1285 }
1286
1287 builder.apply_gear_fields(&gear_db_config)?;
1289
1290 let is_sqlite = decide_backend(&builder, &gear_db_config);
1292
1293 let result_dsn = if is_sqlite {
1294 finalize_sqlite_dsn(&builder, &gear_db_config, gear_name, home_dir, dry_run)?
1295 } else {
1296 finalize_server_dsn(&builder, gear_name)?
1297 };
1298
1299 validate_dsn(&result_dsn)?;
1301
1302 let log_dsn = redact_dsn_for_logging(&result_dsn)?;
1304
1305 tracing::info!(
1306 "Built final DB config for gear '{}': {}",
1307 gear_name,
1308 log_dsn
1309 );
1310
1311 Ok(Some((result_dsn, builder.pool)))
1312}
1313
1314#[must_use]
1317pub fn get_gear_db_config(app: &AppConfig, gear_name: &str) -> Option<DbConnConfig> {
1318 let gear_raw = app.gears.get(gear_name)?;
1319 let gear_entry: GearConfig = serde_json::from_value(gear_raw.clone()).ok()?;
1320 gear_entry.database
1321}
1322
1323#[must_use]
1326pub fn gear_home(app: &AppConfig, gear_name: &str) -> PathBuf {
1327 PathBuf::from(&app.server.home_dir).join(gear_name)
1328}
1329
1330#[cfg(test)]
1331#[cfg_attr(coverage_nightly, coverage(off))]
1332mod tests {
1333 use super::*;
1334 use serial_test::serial;
1335 use std::fs;
1336 use temp_env::with_var;
1337 use tempfile::tempdir;
1338
1339 fn is_normalized_path(p: &Path) -> bool {
1341 p.is_absolute() && !p.starts_with("~")
1342 }
1343
1344 fn default_subdir() -> &'static str {
1346 ".cf-gears"
1347 }
1348
1349 #[test]
1350 fn test_remap_gear_env_key() {
1351 let cases = [
1354 ("gears.my_gear.port", "gears.my-gear.port"),
1356 ("gears.my_gear.max_age_days", "gears.my-gear.max_age_days"),
1358 ("gears.a_b_c.field", "gears.a-b-c.field"),
1360 ("gears.my-gear.port", "gears.my-gear.port"),
1362 ("vendor.my_vendor.key", "vendor.my_vendor.key"),
1364 ("server.home_dir", "server.home_dir"),
1365 ("gears", "gears"),
1367 ("GEARS.MY_GEAR.PORT", "gears.my-gear.port"),
1369 ("server", "server"),
1371 ];
1372
1373 for (input, expected) in cases {
1374 assert_eq!(
1375 remap_gear_env_key(input),
1376 expected,
1377 "remap_gear_env_key({input:?})"
1378 );
1379 }
1380 }
1381
1382 #[test]
1383 fn test_default_config_structure() {
1384 let config = AppConfig::default();
1385
1386 assert!(config.database.is_none());
1388
1389 let logging = config.logging;
1391 assert!(logging.contains_key("default"));
1392
1393 let default_section = &logging["default"];
1394 assert_eq!(default_section.console_level, Some(Level::INFO));
1395 assert_eq!(default_section.file().unwrap(), "logs/cf-gears.log");
1396
1397 assert!(config.gears.is_empty());
1399 }
1400
1401 #[test]
1404 #[serial]
1405 fn test_load_layered_normalizes_home_dir() {
1406 let tmp = tempdir().unwrap();
1407 let cfg_path = tmp.path().join("cfg.yaml");
1408
1409 let yaml = r#"
1411server:
1412 home_dir: "~/.test_cfgears"
1413
1414database:
1415 servers:
1416 test_postgres:
1417 dsn: "postgres://user:pass@localhost/db"
1418 pool:
1419 max_conns: 20
1420
1421logging:
1422 default:
1423 console_level: debug
1424 file: "logs/default.log"
1425"#;
1426 fs::write(&cfg_path, yaml).unwrap();
1427
1428 let config = AppConfig::load_layered(&cfg_path).unwrap();
1429
1430 assert!(is_normalized_path(&config.server.home_dir));
1432 assert!(config.server.home_dir.ends_with(".test_cfgears"));
1433
1434 let logging = &config.logging;
1440 let def = &logging["default"];
1441 assert_eq!(def.console_level, Some(Level::DEBUG));
1442 assert_eq!(def.section_file.as_ref().unwrap().file, "logs/default.log");
1443 }
1444
1445 #[test]
1446 fn test_load_or_default_normalizes_home_dir_when_none() {
1447 let tmp = tempdir().unwrap();
1450 let env_var = if cfg!(target_os = "windows") {
1451 "APPDATA"
1452 } else {
1453 "HOME"
1454 };
1455 with_var(env_var, Some(tmp.path().to_str().unwrap()), || {
1456 let config = AppConfig::load_or_default(None).unwrap();
1457 assert!(is_normalized_path(&config.server.home_dir));
1458 assert!(config.server.home_dir.ends_with(default_subdir()));
1459 });
1460 }
1461
1462 #[test]
1465 #[serial]
1466 fn test_minimal_yaml_config() {
1467 let tmp = tempdir().unwrap();
1468 let cfg_path = tmp.path().join("cfg.yaml");
1469
1470 let yaml = r#"
1471server:
1472 home_dir: "~/.minimal"
1473"#;
1474 fs::write(&cfg_path, yaml).unwrap();
1475
1476 let config = AppConfig::load_layered(&cfg_path).unwrap();
1477
1478 assert!(is_normalized_path(&config.server.home_dir));
1480 assert!(config.server.home_dir.ends_with(".minimal"));
1481
1482 assert!(config.database.is_none());
1484 assert!(config.gears.is_empty());
1485 }
1486
1487 #[test]
1488 fn test_cli_overrides() {
1489 let mut config = AppConfig::default();
1490
1491 let args = CliArgs {
1492 config: None,
1493 print_config: false,
1494 verbose: 2, mock: false,
1496 };
1497
1498 config.apply_cli_overrides(args.verbose);
1499
1500 let logging = &config.logging;
1504 let default_section = &logging["default"];
1505 assert_eq!(default_section.console_level, Some(Level::TRACE));
1506 }
1507
1508 #[test]
1509 fn test_cli_verbose_levels_matrix() {
1510 for (verbose_level, expected_log_level) in [
1511 (0, Some(Level::INFO)), (1, Some(Level::DEBUG)),
1513 (2, Some(Level::TRACE)),
1514 (3, Some(Level::TRACE)), ] {
1516 let mut config = AppConfig::default();
1517 let args = CliArgs {
1518 config: None,
1519 print_config: false,
1520 verbose: verbose_level,
1521 mock: false,
1522 };
1523
1524 config.apply_cli_overrides(args.verbose);
1525
1526 let logging = &config.logging;
1527 let default_section = &logging["default"];
1528
1529 if verbose_level == 0 {
1530 assert_eq!(default_section.console_level, Some(Level::INFO));
1531 } else {
1532 assert_eq!(default_section.console_level, expected_log_level);
1533 }
1534 }
1535 }
1536
1537 #[test]
1540 #[serial]
1541 fn test_layered_config_loading_with_gears_dir() {
1542 let tmp = tempdir().unwrap();
1543 let cfg_path = tmp.path().join("gears_dir.yaml");
1544 let gears_dir = tmp.path().join("gears");
1545
1546 fs::create_dir_all(&gears_dir).unwrap();
1547 let gear_cfg = gears_dir.join("test_gear.yaml");
1548 fs::write(
1549 &gear_cfg,
1550 r#"
1551setting1: "value1"
1552setting2: 42
1553"#,
1554 )
1555 .unwrap();
1556
1557 let gears_dir_str = normalize_path(&gears_dir);
1559 let yaml = format!(
1560 r#"
1561server:
1562 home_dir: "~/.gears_test"
1563
1564gears_dir: "{gears_dir_str}"
1565
1566gears:
1567 existing_gear:
1568 key: "value"
1569"#
1570 );
1571
1572 fs::write(&cfg_path, yaml).unwrap();
1573
1574 let config = AppConfig::load_layered(&cfg_path).unwrap();
1575
1576 assert!(config.gears.contains_key("existing_gear"));
1578
1579 assert!(config.gears.contains_key("test_gear"));
1581
1582 let test_gear = &config.gears["test_gear"];
1584 assert_eq!(test_gear["setting1"], "value1");
1585 assert_eq!(test_gear["setting2"], 42);
1586 }
1587
1588 #[test]
1591 #[serial]
1592 fn test_load_and_init_logging_smoke() {
1593 let tmp = tempdir().unwrap();
1595 let cfg_path = tmp.path().join("logging.yaml");
1596 let yaml = r#"
1597server:
1598 home_dir: "~/.logging_test"
1599
1600logging:
1601 default:
1602 console_level: debug
1603 file: ""
1604 file_level: info
1605"#;
1606 fs::write(&cfg_path, yaml).unwrap();
1607
1608 let config = AppConfig::load_layered(&cfg_path).unwrap();
1609 let logging = &config.logging;
1610 assert!(logging.contains_key("default"));
1611
1612 let default_section = &logging["default"];
1613 assert_eq!(default_section.console_level, Some(Level::DEBUG));
1614 assert_eq!(default_section.file_level(), Some(Level::INFO));
1615 }
1617
1618 fn create_app_with_server(server_name: &str, db_config: DbConnConfig) -> AppConfig {
1622 let mut servers = HashMap::new();
1623 servers.insert(server_name.to_owned(), db_config);
1624
1625 AppConfig {
1626 database: Some(GlobalDatabaseConfig {
1627 servers,
1628 auto_provision: None,
1629 }),
1630 ..Default::default()
1631 }
1632 }
1633
1634 fn add_gear_to_app(app: &mut AppConfig, gear_name: &str, database_config: &serde_json::Value) {
1636 app.gears.insert(
1637 gear_name.to_owned(),
1638 serde_json::json!({
1639 "database": database_config,
1640 "config": {}
1641 }),
1642 );
1643 }
1644
1645 fn add_gear_with_config(app: &mut AppConfig, gear_name: &str, config: &serde_json::Value) {
1647 app.gears.insert(
1648 gear_name.to_owned(),
1649 serde_json::json!({
1650 "database": {},
1651 "config": config
1652 }),
1653 );
1654 }
1655
1656 fn create_minimal_app() -> AppConfig {
1658 AppConfig {
1659 database: None,
1660 gears: HashMap::new(),
1661 ..Default::default()
1662 }
1663 }
1664
1665 #[test]
1666 fn test_precedence_global_dsn_only() {
1667 let tmp = tempdir().unwrap();
1668 let home_dir = tmp.path();
1669
1670 let mut app = create_app_with_server(
1671 "test_server",
1672 DbConnConfig {
1673 dsn: Some(
1674 "postgresql://global_user:global_pass@global_host:5432/global_db".to_owned(),
1675 ),
1676 ..Default::default()
1677 },
1678 );
1679
1680 add_gear_to_app(
1682 &mut app,
1683 "test_gear",
1684 &serde_json::json!({
1685 "server": "test_server"
1686 }),
1687 );
1688
1689 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
1690 assert!(result.is_some());
1691
1692 let (dsn, _pool) = result.unwrap();
1693 assert!(dsn.contains("global_user"));
1694 assert!(dsn.contains("global_host"));
1695 assert!(dsn.contains("global_db"));
1696 }
1697
1698 #[test]
1699 fn test_precedence_global_fields_only() {
1700 let tmp = tempdir().unwrap();
1701 let home_dir = tmp.path();
1702
1703 let mut app = create_app_with_server(
1704 "test_server",
1705 DbConnConfig {
1706 host: Some("field_host".to_owned()),
1707 port: Some(5433),
1708 user: Some("field_user".to_owned()),
1709 dbname: Some("field_db".to_owned()),
1710 ..Default::default()
1711 },
1712 );
1713
1714 add_gear_to_app(
1716 &mut app,
1717 "test_gear",
1718 &serde_json::json!({
1719 "server": "test_server"
1720 }),
1721 );
1722
1723 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
1724 assert!(result.is_some());
1725
1726 let (dsn, _pool) = result.unwrap();
1727 assert!(dsn.contains("field_host"));
1728 assert!(dsn.contains("5433"));
1729 assert!(dsn.contains("field_user"));
1730 assert!(dsn.contains("field_db"));
1731 }
1732
1733 #[test]
1734 fn test_precedence_gear_dsn_only() {
1735 let tmp = tempdir().unwrap();
1736 let home_dir = tmp.path();
1737
1738 let app = AppConfig {
1739 gears: {
1740 let mut gears = HashMap::new();
1741 gears.insert(
1742 "test_gear".to_owned(),
1743 serde_json::json!({
1744 "database": {
1745 "dsn": "sqlite://gear_test.db?wal=true&synchronous=NORMAL"
1746 },
1747 "config": {}
1748 }),
1749 );
1750 gears
1751 },
1752 ..Default::default()
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("gear_test.db"));
1760 assert!(dsn.contains("wal=true"));
1761 }
1762
1763 #[test]
1764 fn test_precedence_gear_fields_only() {
1765 let tmp = tempdir().unwrap();
1766 let home_dir = tmp.path();
1767
1768 let app = AppConfig {
1769 gears: {
1770 let mut gears = HashMap::new();
1771 gears.insert(
1772 "test_gear".to_owned(),
1773 serde_json::json!({
1774 "database": {
1775 "file": "gear_fields.db"
1776 },
1777 "config": {}
1778 }),
1779 );
1780 gears
1781 },
1782 ..Default::default()
1783 };
1784
1785 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
1786 assert!(result.is_some());
1787
1788 let (dsn, _pool) = result.unwrap();
1789 assert!(dsn.contains("gear_fields.db"));
1790 #[cfg(windows)]
1792 assert!(dsn.starts_with("sqlite:") && !dsn.starts_with("sqlite://"));
1793 #[cfg(unix)]
1794 assert!(dsn.starts_with("sqlite://"));
1795 }
1796
1797 #[test]
1798 fn test_precedence_fields_override_dsn() {
1799 let tmp = tempdir().unwrap();
1800 let home_dir = tmp.path();
1801
1802 let mut app = create_app_with_server(
1803 "test_server",
1804 DbConnConfig {
1805 dsn: Some("postgresql://old_user:old_pass@old_host:5432/old_db".to_owned()),
1806 host: Some("new_host".to_owned()), port: Some(5433), user: Some("new_user".to_owned()), dbname: Some("new_db".to_owned()), ..Default::default()
1811 },
1812 );
1813
1814 add_gear_to_app(
1816 &mut app,
1817 "test_gear",
1818 &serde_json::json!({
1819 "server": "test_server",
1820 "port": 5434 }),
1822 );
1823
1824 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
1825 assert!(result.is_some());
1826
1827 let (dsn, _pool) = result.unwrap();
1828 assert!(dsn.contains("new_host"));
1830 assert!(dsn.contains("5434")); assert!(dsn.contains("new_user"));
1832 assert!(dsn.contains("new_db"));
1833 assert!(!dsn.contains("old_host"));
1835 assert!(!dsn.contains("5432"));
1836 assert!(!dsn.contains("old_user"));
1837 assert!(!dsn.contains("old_db"));
1838 }
1839
1840 #[test]
1841 fn test_env_expansion_password() {
1842 let tmp = tempdir().unwrap();
1843 let home_dir = tmp.path();
1844
1845 with_var("TEST_DB_PASSWORD", Some("secret123"), || {
1846 let mut app = create_app_with_server(
1847 "test_server",
1848 DbConnConfig {
1849 host: Some("localhost".to_owned()),
1850 port: Some(5432),
1851 user: Some("testuser".to_owned()),
1852 password: Some("${TEST_DB_PASSWORD}".to_owned()), dbname: Some("testdb".to_owned()),
1854 ..Default::default()
1855 },
1856 );
1857
1858 add_gear_to_app(
1859 &mut app,
1860 "test_gear",
1861 &serde_json::json!({
1862 "server": "test_server"
1863 }),
1864 );
1865
1866 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
1867 assert!(result.is_some());
1868
1869 let (dsn, _pool) = result.unwrap();
1870 assert!(dsn.contains("secret123"));
1871 });
1872 }
1873
1874 #[test]
1875 fn test_env_expansion_in_dsn() {
1876 let tmp = tempdir().unwrap();
1877 let home_dir = tmp.path();
1878
1879 temp_env::with_vars(
1880 [
1881 ("DB_HOST", Some("test-server")),
1882 ("DB_PASSWORD", Some("env_secret")),
1883 ],
1884 || {
1885 let mut app = create_app_with_server(
1886 "test_server",
1887 DbConnConfig {
1888 dsn: Some(
1889 "postgresql://user:${DB_PASSWORD}@${DB_HOST}:5432/mydb".to_owned(),
1890 ),
1891 ..Default::default()
1892 },
1893 );
1894
1895 add_gear_to_app(
1896 &mut app,
1897 "test_gear",
1898 &serde_json::json!({
1899 "server": "test_server"
1900 }),
1901 );
1902
1903 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
1904 assert!(result.is_some());
1905
1906 let (dsn, _pool) = result.unwrap();
1907 assert!(dsn.contains("test-server"));
1908 assert!(dsn.contains("env_secret"));
1909 assert!(!dsn.contains("${DB_HOST}"));
1911 assert!(!dsn.contains("${DB_PASSWORD}"));
1912 },
1913 );
1914 }
1915
1916 #[test]
1917 fn test_sqlite_file_path_resolution() {
1918 let tmp = tempdir().unwrap();
1919 let home_dir = tmp.path();
1920
1921 let app1 = AppConfig {
1923 gears: {
1924 let mut gears = HashMap::new();
1925 gears.insert(
1926 "test_gear".to_owned(),
1927 serde_json::json!({
1928 "database": {
1929 "file": "test.db"
1930 },
1931 "config": {}
1932 }),
1933 );
1934 gears
1935 },
1936 ..Default::default()
1937 };
1938
1939 let result1 = build_final_db_for_gear(&app1, "test_gear", home_dir, false).unwrap();
1940 assert!(result1.is_some());
1941 let (dsn1, _) = result1.unwrap();
1942 assert!(dsn1.contains("test_gear"));
1943 assert!(dsn1.contains("test.db"));
1944
1945 let abs_path = tmp.path().join("absolute.db");
1947 let app2 = AppConfig {
1948 gears: {
1949 let mut gears = HashMap::new();
1950 gears.insert(
1951 "test_gear".to_owned(),
1952 serde_json::json!({
1953 "database": {
1954 "path": abs_path.to_string_lossy()
1955 },
1956 "config": {}
1957 }),
1958 );
1959 gears
1960 },
1961 ..Default::default()
1962 };
1963
1964 let result2 = build_final_db_for_gear(&app2, "test_gear", home_dir, false).unwrap();
1965 assert!(result2.is_some());
1966 let (dsn2, _) = result2.unwrap();
1967 assert!(dsn2.contains("absolute.db"));
1968
1969 let app3 = AppConfig {
1971 gears: {
1972 let mut gears = HashMap::new();
1973 gears.insert(
1974 "test_gear".to_owned(),
1975 serde_json::json!({
1976 "database": {},
1977 "config": {}
1978 }),
1979 );
1980 gears
1981 },
1982 ..Default::default()
1983 };
1984
1985 let result3 = build_final_db_for_gear(&app3, "test_gear", home_dir, false).unwrap();
1986 assert!(result3.is_some());
1987 let (dsn3, _) = result3.unwrap();
1988 assert!(dsn3.contains("test_gear.sqlite"));
1989 }
1990
1991 #[cfg(windows)]
1992 #[test]
1993 fn test_sqlite_path_resolution_windows() {
1994 let tmp = tempdir().unwrap();
1995 let home_dir = tmp.path();
1996
1997 let app = AppConfig {
1998 gears: {
1999 let mut gears = HashMap::new();
2000 gears.insert(
2001 "test_gear".to_owned(),
2002 serde_json::json!({
2003 "database": {
2004 "file": "test.db"
2005 },
2006 "config": {}
2007 }),
2008 );
2009 gears
2010 },
2011 ..Default::default()
2012 };
2013
2014 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2015 assert!(result.is_some());
2016 let (dsn, _) = result.unwrap();
2017
2018 assert!(!dsn.contains('\\'));
2020 assert!(dsn.contains('/'));
2021 }
2022
2023 #[test]
2024 fn test_sqlite_dsn_with_server_reference_and_dbname_override() {
2025 let tmp = tempdir().unwrap();
2026 let home_dir = tmp.path();
2027
2028 let mut app = AppConfig::default();
2029
2030 let mut servers = HashMap::new();
2032 servers.insert(
2033 "sqlite_users".to_owned(),
2034 DbConnConfig {
2035 engine: None,
2036 dsn: Some(
2037 "sqlite://users_info.db?WAL=true&synchronous=NORMAL&busy_timeout=5000"
2038 .to_owned(),
2039 ),
2040 host: None,
2041 port: None,
2042 user: None,
2043 password: None,
2044 dbname: None,
2045 params: None,
2046 pool: None,
2047 file: None,
2048 path: None,
2049 server: None,
2050 },
2051 );
2052
2053 app.database = Some(GlobalDatabaseConfig {
2054 servers,
2055 auto_provision: None,
2056 });
2057
2058 app.gears.insert(
2060 "users_info".to_owned(),
2061 serde_json::json!({
2062 "database": {
2063 "server": "sqlite_users",
2064 "dbname": "users_info.db"
2065 },
2066 "config": {}
2067 }),
2068 );
2069
2070 let result = build_final_db_for_gear(&app, "users_info", home_dir, false).unwrap();
2071 assert!(result.is_some());
2072 let (dsn, _) = result.unwrap();
2073
2074 assert!(dsn.contains("?WAL=true&synchronous=NORMAL&busy_timeout=5000"));
2076 assert!(dsn.contains("users_info/users_info.db"));
2077
2078 #[cfg(windows)]
2080 {
2081 assert!(dsn.starts_with("sqlite:"));
2083 assert!(!dsn.starts_with("sqlite://"));
2084 }
2085
2086 #[cfg(unix)]
2087 {
2088 assert!(dsn.starts_with("sqlite://"));
2090 }
2091 }
2092
2093 #[cfg(unix)]
2094 #[test]
2095 fn test_sqlite_path_resolution_unix() {
2096 let tmp = tempdir().unwrap();
2097 let home_dir = tmp.path();
2098
2099 let app = AppConfig {
2100 gears: {
2101 let mut gears = HashMap::new();
2102 gears.insert(
2103 "test_gear".to_owned(),
2104 serde_json::json!({
2105 "database": {
2106 "file": "test.db"
2107 },
2108 "config": {}
2109 }),
2110 );
2111 gears
2112 },
2113 ..Default::default()
2114 };
2115
2116 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2117 assert!(result.is_some());
2118 let (dsn, _) = result.unwrap();
2119
2120 assert!(dsn.starts_with("sqlite://"));
2122 assert!(dsn.contains("/test_gear/test.db"));
2123 }
2124
2125 #[test]
2126 fn test_server_based_db_missing_dbname_error() {
2127 let tmp = tempdir().unwrap();
2128 let home_dir = tmp.path();
2129
2130 let mut app = create_app_with_server(
2131 "test_server",
2132 DbConnConfig {
2133 host: Some("localhost".to_owned()),
2134 port: Some(5432),
2135 user: Some("testuser".to_owned()),
2136 ..Default::default()
2138 },
2139 );
2140
2141 add_gear_to_app(
2142 &mut app,
2143 "test_gear",
2144 &serde_json::json!({
2145 "server": "test_server"
2146 }),
2147 );
2148
2149 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false);
2150 assert!(result.is_err());
2151 let error_msg = result.unwrap_err().to_string();
2152 assert!(error_msg.contains("missing required 'dbname'"));
2153 }
2154
2155 #[test]
2156 fn test_gear_no_database_config() {
2157 let tmp = tempdir().unwrap();
2158 let home_dir = tmp.path();
2159
2160 let app = AppConfig {
2162 gears: {
2163 let mut gears = HashMap::new();
2164 gears.insert(
2165 "no_db_gear".to_owned(),
2166 serde_json::json!({
2167 "config": {
2168 "some_setting": "value"
2169 }
2170 }),
2171 );
2172 gears
2173 },
2174 ..Default::default()
2175 };
2176
2177 let result = build_final_db_for_gear(&app, "no_db_gear", home_dir, false).unwrap();
2178 assert!(result.is_none());
2179 }
2180
2181 #[test]
2182 fn test_gear_empty_database_config() {
2183 let tmp = tempdir().unwrap();
2184 let home_dir = tmp.path();
2185
2186 let app = AppConfig {
2188 gears: {
2189 let mut gears = HashMap::new();
2190 gears.insert(
2191 "empty_db_gear".to_owned(),
2192 serde_json::json!({
2193 "database": null,
2194 "config": {}
2195 }),
2196 );
2197 gears
2198 },
2199 ..Default::default()
2200 };
2201
2202 let result = build_final_db_for_gear(&app, "empty_db_gear", home_dir, false).unwrap();
2203 assert!(result.is_none());
2204 }
2205
2206 #[test]
2207 fn test_referenced_server_not_found() {
2208 let tmp = tempdir().unwrap();
2209 let home_dir = tmp.path();
2210
2211 let app = AppConfig {
2212 gears: {
2213 let mut gears = HashMap::new();
2214 gears.insert(
2215 "test_gear".to_owned(),
2216 serde_json::json!({
2217 "database": {
2218 "server": "nonexistent_server"
2219 },
2220 "config": {}
2221 }),
2222 );
2223 gears
2224 },
2225 ..Default::default()
2226 };
2227
2228 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false);
2229 assert!(result.is_err());
2230 let error_msg = result.unwrap_err().to_string();
2231 assert!(error_msg.contains("Referenced server 'nonexistent_server' not found"));
2232 }
2233
2234 #[test]
2235 fn test_dsn_validation_invalid_url() {
2236 let tmp = tempdir().unwrap();
2237 let home_dir = tmp.path();
2238
2239 let app = AppConfig {
2240 gears: {
2241 let mut gears = HashMap::new();
2242 gears.insert(
2243 "test_gear".to_owned(),
2244 serde_json::json!({
2245 "database": {
2246 "dsn": "invalid://not-a-valid[url"
2247 },
2248 "config": {}
2249 }),
2250 );
2251 gears
2252 },
2253 ..Default::default()
2254 };
2255
2256 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false);
2257 assert!(result.is_err());
2258 }
2259
2260 #[test]
2261 fn test_env_variable_not_found() {
2262 let tmp = tempdir().unwrap();
2263 let home_dir = tmp.path();
2264
2265 with_var("NONEXISTENT_PASSWORD", None::<&str>, || {
2267 let mut app = create_app_with_server(
2268 "test_server",
2269 DbConnConfig {
2270 host: Some("localhost".to_owned()),
2271 password: Some("${NONEXISTENT_PASSWORD}".to_owned()),
2272 dbname: Some("testdb".to_owned()),
2273 ..Default::default()
2274 },
2275 );
2276
2277 add_gear_to_app(
2278 &mut app,
2279 "test_gear",
2280 &serde_json::json!({
2281 "server": "test_server"
2282 }),
2283 );
2284
2285 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false);
2286 assert!(result.is_err());
2287 let error_msg = result.unwrap_err().to_string();
2288 assert!(error_msg.contains("NONEXISTENT_PASSWORD"));
2289 });
2290 }
2291
2292 #[test]
2293 fn test_sqlite_at_file_relative_path() {
2294 let tmp = tempdir().unwrap();
2295 let home_dir = tmp.path();
2296
2297 let app = AppConfig {
2298 gears: {
2299 let mut gears = HashMap::new();
2300 gears.insert(
2301 "test_gear".to_owned(),
2302 serde_json::json!({
2303 "database": {
2304 "dsn": "sqlite://@file(users.db)"
2305 },
2306 "config": {}
2307 }),
2308 );
2309 gears
2310 },
2311 ..Default::default()
2312 };
2313
2314 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2315 assert!(result.is_some());
2316
2317 let (dsn, _pool) = result.unwrap();
2318 assert!(dsn.contains("test_gear"));
2319 assert!(dsn.contains("users.db"));
2320 #[cfg(windows)]
2322 assert!(dsn.starts_with("sqlite:") && !dsn.starts_with("sqlite://"));
2323 #[cfg(unix)]
2324 assert!(dsn.starts_with("sqlite:///"));
2325 }
2326
2327 #[test]
2328 fn test_sqlite_at_file_absolute_path() {
2329 let tmp = tempdir().unwrap();
2330 let home_dir = tmp.path();
2331 let abs_path = tmp.path().join("absolute_db.sqlite");
2332
2333 let app = AppConfig {
2334 gears: {
2335 let mut gears = HashMap::new();
2336 gears.insert(
2337 "test_gear".to_owned(),
2338 serde_json::json!({
2339 "database": {
2340 "dsn": format!("sqlite://@file({})", abs_path.to_string_lossy())
2341 },
2342 "config": {}
2343 }),
2344 );
2345 gears
2346 },
2347 ..Default::default()
2348 };
2349
2350 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2351 assert!(result.is_some());
2352
2353 let (dsn, _pool) = result.unwrap();
2354 assert!(dsn.contains("absolute_db.sqlite"));
2355 #[cfg(windows)]
2357 assert!(dsn.starts_with("sqlite:") && !dsn.starts_with("sqlite://"));
2358 #[cfg(unix)]
2359 assert!(dsn.starts_with("sqlite:///"));
2360 }
2361
2362 #[test]
2363 fn test_sqlite_empty_dsn_default() {
2364 let tmp = tempdir().unwrap();
2365 let home_dir = tmp.path();
2366
2367 let app = AppConfig {
2368 gears: {
2369 let mut gears = HashMap::new();
2370 gears.insert(
2371 "test_gear".to_owned(),
2372 serde_json::json!({
2373 "database": {
2374 "dsn": "sqlite://"
2375 },
2376 "config": {}
2377 }),
2378 );
2379 gears
2380 },
2381 ..Default::default()
2382 };
2383
2384 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2385 assert!(result.is_some());
2386
2387 let (dsn, _pool) = result.unwrap();
2388 assert!(dsn.contains("test_gear"));
2389 assert!(dsn.contains("test_gear.sqlite"));
2390 #[cfg(windows)]
2392 assert!(dsn.starts_with("sqlite:") && !dsn.starts_with("sqlite://"));
2393 #[cfg(unix)]
2394 assert!(dsn.starts_with("sqlite:///"));
2395 }
2396
2397 #[test]
2398 fn test_sqlite_at_file_invalid_syntax() {
2399 let tmp = tempdir().unwrap();
2400 let home_dir = tmp.path();
2401
2402 let app = AppConfig {
2403 gears: {
2404 let mut gears = HashMap::new();
2405 gears.insert(
2406 "test_gear".to_owned(),
2407 serde_json::json!({
2408 "database": {
2409 "dsn": "sqlite://@file(missing_closing_paren"
2410 },
2411 "config": {}
2412 }),
2413 );
2414 gears
2415 },
2416 ..Default::default()
2417 };
2418
2419 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false);
2420 assert!(result.is_err());
2421 let error_msg = result.unwrap_err().to_string();
2422 assert!(error_msg.contains("Invalid @file() syntax"));
2423 }
2424
2425 #[test]
2426 fn test_dsn_special_characters_in_credentials() {
2427 let tmp = tempdir().unwrap();
2428 let home_dir = tmp.path();
2429
2430 let mut app = create_app_with_server(
2432 "test_server",
2433 DbConnConfig {
2434 host: Some("localhost".to_owned()),
2435 port: Some(5432),
2436 user: Some("user@domain".to_owned()),
2437 password: Some("pa@ss:w0rd/with%special&chars".to_owned()),
2438 dbname: Some("test/db".to_owned()),
2439 ..Default::default()
2440 },
2441 );
2442
2443 add_gear_to_app(
2444 &mut app,
2445 "test_gear",
2446 &serde_json::json!({
2447 "server": "test_server"
2448 }),
2449 );
2450
2451 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2452 assert!(result.is_some());
2453
2454 let (dsn, _pool) = result.unwrap();
2455
2456 assert!(dsn.starts_with("postgresql://"));
2458 assert!(dsn.contains("user%40domain")); assert!(dsn.contains("/test%2Fdb")); validate_dsn(&dsn).expect("DSN with special characters should be valid");
2463
2464 let parsed_dsn = dsn::parse(&dsn).expect("DSN should be parseable");
2466 assert_eq!(parsed_dsn.username.as_deref(), Some("user@domain"));
2467 assert_eq!(
2468 parsed_dsn.password.as_deref(),
2469 Some("pa@ss:w0rd/with%special&chars")
2470 );
2471 }
2474
2475 #[test]
2476 #[allow(clippy::non_ascii_literal)]
2477 fn test_dsn_unicode_characters() {
2478 let tmp = tempdir().unwrap();
2479 let home_dir = tmp.path();
2480
2481 let mut app = create_app_with_server(
2483 "test_server",
2484 DbConnConfig {
2485 host: Some("localhost".to_owned()),
2486 user: Some("ユーザー".to_owned()), dbname: Some("unicode_db".to_owned()),
2488 ..Default::default()
2489 },
2490 );
2491
2492 add_gear_to_app(
2493 &mut app,
2494 "test_gear",
2495 &serde_json::json!({
2496 "server": "test_server"
2497 }),
2498 );
2499
2500 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2501 assert!(result.is_some());
2502
2503 let (dsn, _pool) = result.unwrap();
2504
2505 assert!(dsn.starts_with("postgresql://"));
2507 assert!(dsn.contains('%')); validate_dsn(&dsn).expect("DSN with Unicode characters should be valid");
2512 }
2513
2514 #[test]
2515 fn test_dsn_query_parameters_encoding() {
2516 let tmp = tempdir().unwrap();
2517 let home_dir = tmp.path();
2518
2519 let mut params = HashMap::new();
2520 params.insert("ssl mode".to_owned(), "require & verify".to_owned());
2521 params.insert("application_name".to_owned(), "my-app/v1.0".to_owned());
2522
2523 let mut app = create_app_with_server(
2524 "test_server",
2525 DbConnConfig {
2526 host: Some("localhost".to_owned()),
2527 user: Some("testuser".to_owned()),
2528 dbname: Some("testdb".to_owned()),
2529 params: Some(params),
2530 ..Default::default()
2531 },
2532 );
2533
2534 add_gear_to_app(
2535 &mut app,
2536 "test_gear",
2537 &serde_json::json!({
2538 "server": "test_server"
2539 }),
2540 );
2541
2542 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2543 assert!(result.is_some());
2544
2545 let (dsn, _pool) = result.unwrap();
2546
2547 assert!(dsn.contains("ssl+mode=require+%26+verify"));
2549 assert!(dsn.contains("application_name=my-app%2Fv1.0"));
2550
2551 validate_dsn(&dsn).expect("DSN with encoded query parameters should be valid");
2553 }
2554
2555 #[test]
2556 fn test_pool_config_merging() {
2557 use std::time::Duration;
2558
2559 let tmp = tempdir().unwrap();
2560 let home_dir = tmp.path();
2561
2562 let mut app = create_app_with_server(
2564 "test_server",
2565 DbConnConfig {
2566 host: Some("localhost".to_owned()),
2567 dbname: Some("testdb".to_owned()),
2568 pool: Some(PoolCfg {
2569 max_conns: Some(10),
2570 min_conns: None,
2571 acquire_timeout: Some(Duration::from_secs(5)),
2572 idle_timeout: None,
2573 max_lifetime: None,
2574 test_before_acquire: None,
2575 }),
2576 ..Default::default()
2577 },
2578 );
2579
2580 add_gear_to_app(
2582 &mut app,
2583 "test_gear",
2584 &serde_json::json!({
2585 "server": "test_server",
2586 "pool": {
2587 "max_conns": 20
2588 }
2589 }),
2590 );
2591
2592 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2593 assert!(result.is_some());
2594
2595 let (_dsn, pool) = result.unwrap();
2596 assert_eq!(pool.max_conns, Some(20)); assert_eq!(pool.acquire_timeout, Some(Duration::from_secs(5))); }
2599
2600 #[test]
2601 fn test_pool_config_gear_overrides_all() {
2602 use std::time::Duration;
2603
2604 let tmp = tempdir().unwrap();
2605 let home_dir = tmp.path();
2606
2607 let mut app = create_app_with_server(
2609 "test_server",
2610 DbConnConfig {
2611 host: Some("localhost".to_owned()),
2612 dbname: Some("testdb".to_owned()),
2613 pool: Some(PoolCfg {
2614 max_conns: Some(10),
2615 min_conns: None,
2616 acquire_timeout: Some(Duration::from_secs(5)),
2617 idle_timeout: None,
2618 max_lifetime: None,
2619 test_before_acquire: None,
2620 }),
2621 ..Default::default()
2622 },
2623 );
2624
2625 add_gear_to_app(
2627 &mut app,
2628 "test_gear",
2629 &serde_json::json!({
2630 "server": "test_server",
2631 "pool": {
2632 "max_conns": 30,
2633 "acquire_timeout": "10s"
2634 }
2635 }),
2636 );
2637
2638 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2639 assert!(result.is_some());
2640
2641 let (_dsn, pool) = result.unwrap();
2642 assert_eq!(pool.max_conns, Some(30));
2643 assert_eq!(pool.acquire_timeout, Some(Duration::from_secs(10)));
2644 }
2645
2646 #[test]
2647 fn test_list_gear_names() {
2648 let mut app = create_minimal_app();
2649 add_gear_with_config(&mut app, "zebra_gear", &serde_json::json!({}));
2650 add_gear_with_config(&mut app, "alpha_gear", &serde_json::json!({}));
2651 add_gear_with_config(&mut app, "beta_gear", &serde_json::json!({}));
2652
2653 let gear_names = list_gear_names(&app);
2654
2655 assert_eq!(gear_names.len(), 3);
2657 assert_eq!(gear_names[0], "alpha_gear");
2658 assert_eq!(gear_names[1], "beta_gear");
2659 assert_eq!(gear_names[2], "zebra_gear");
2660 }
2661
2662 #[test]
2663 fn test_list_gear_names_empty() {
2664 let app = create_minimal_app();
2665 let gear_names = list_gear_names(&app);
2666 assert_eq!(gear_names.len(), 0);
2667 }
2668
2669 #[test]
2670 fn test_redact_dsn_password_postgres() {
2671 let dsn = "postgres://user:secretpass@localhost:5432/mydb";
2672 let redacted = redact_dsn_password(dsn).unwrap();
2673 assert_eq!(
2674 redacted,
2675 "postgres://user:***REDACTED***@localhost:5432/mydb"
2676 );
2677 }
2678
2679 #[test]
2680 fn test_redact_dsn_password_no_password() {
2681 let dsn = "postgres://user@localhost:5432/mydb";
2682 let redacted = redact_dsn_password(dsn).unwrap();
2683 assert_eq!(redacted, "postgres://user@localhost:5432/mydb");
2685 }
2686
2687 #[test]
2688 fn test_redact_dsn_password_special_chars() {
2689 let dsn = "postgres://user:p@ss%40word@localhost:5432/mydb";
2690 let redacted = redact_dsn_password(dsn).unwrap();
2691 assert_eq!(
2692 redacted,
2693 "postgres://user:***REDACTED***@localhost:5432/mydb"
2694 );
2695 }
2696
2697 #[test]
2698 fn test_render_effective_gears_config() {
2699 let mut app = create_minimal_app();
2700 add_gear_with_config(
2701 &mut app,
2702 "test_gear",
2703 &serde_json::json!({
2704 "my_setting": "my_value",
2705 "enabled": true
2706 }),
2707 );
2708
2709 let result = render_effective_gears_config(&app).unwrap();
2710
2711 assert!(result.is_object());
2713 let gears = result.as_object().unwrap();
2714 assert!(gears.contains_key("test_gear"));
2715
2716 let test_gear = gears.get("test_gear").unwrap();
2717 assert!(test_gear.is_object());
2718 let test_gear_obj = test_gear.as_object().unwrap();
2719
2720 assert!(test_gear_obj.contains_key("config"));
2722
2723 let config = test_gear_obj.get("config").unwrap();
2725 assert_eq!(config.get("my_setting").unwrap(), "my_value");
2726 assert_eq!(config.get("enabled").unwrap(), true);
2727 }
2728
2729 #[test]
2730 fn test_render_effective_gears_config_with_database() {
2731 let mut app = create_app_with_server(
2732 "test_server",
2733 DbConnConfig {
2734 host: Some("localhost".to_owned()),
2735 port: Some(5432),
2736 user: Some("user".to_owned()),
2737 password: Some("pass".to_owned()),
2738 dbname: Some("db".to_owned()),
2739 ..Default::default()
2740 },
2741 );
2742
2743 add_gear_to_app(
2745 &mut app,
2746 "test_gear",
2747 &serde_json::json!({
2748 "server": "test_server"
2749 }),
2750 );
2751
2752 let result = render_effective_gears_config(&app).unwrap();
2753 let gears = result.as_object().unwrap();
2754 let test_gear = gears.get("test_gear").unwrap().as_object().unwrap();
2755
2756 assert!(test_gear.contains_key("database"));
2758 let database = test_gear.get("database").unwrap().as_object().unwrap();
2759 assert!(database.contains_key("dsn"));
2760
2761 let dsn = database.get("dsn").unwrap().as_str().unwrap();
2763 assert!(dsn.contains("***REDACTED***"));
2764 assert!(!dsn.contains("pass"));
2765 }
2766
2767 #[test]
2768 fn test_render_effective_gears_config_minimal() {
2769 let mut app = create_minimal_app();
2771
2772 app.gears
2774 .insert("minimal_gear".to_owned(), serde_json::json!({}));
2775
2776 let result = render_effective_gears_config(&app).unwrap();
2777
2778 assert!(result.is_object());
2781 }
2782
2783 #[test]
2784 fn test_dump_effective_gears_config_yaml() {
2785 let mut app = create_minimal_app();
2786 add_gear_with_config(
2787 &mut app,
2788 "test_gear",
2789 &serde_json::json!({
2790 "setting": "value"
2791 }),
2792 );
2793
2794 let yaml = dump_effective_gears_config_yaml(&app).unwrap();
2795
2796 assert!(yaml.contains("test_gear:"));
2798 assert!(yaml.contains("config:"));
2799 assert!(yaml.contains("setting: value"));
2800 }
2801
2802 #[test]
2803 fn test_dump_effective_gears_config_json() {
2804 let mut app = create_minimal_app();
2805 add_gear_with_config(
2806 &mut app,
2807 "test_gear",
2808 &serde_json::json!({
2809 "setting": "value"
2810 }),
2811 );
2812
2813 let json = dump_effective_gears_config_json(&app).unwrap();
2814
2815 assert!(json.contains("\"test_gear\""));
2817 assert!(json.contains("\"config\""));
2818 assert!(json.contains("\"setting\""));
2819 assert!(json.contains("\"value\""));
2820
2821 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
2823 assert!(parsed.is_object());
2824 }
2825
2826 #[test]
2827 fn test_render_multiple_gears() {
2828 let mut app = create_minimal_app();
2829 add_gear_with_config(&mut app, "gear_a", &serde_json::json!({"a": 1}));
2830 add_gear_with_config(&mut app, "gear_b", &serde_json::json!({"b": 2}));
2831 add_gear_with_config(&mut app, "gear_c", &serde_json::json!({"c": 3}));
2832
2833 let result = render_effective_gears_config(&app).unwrap();
2834 let gears = result.as_object().unwrap();
2835
2836 assert_eq!(gears.len(), 3);
2837 assert!(gears.contains_key("gear_a"));
2838 assert!(gears.contains_key("gear_b"));
2839 assert!(gears.contains_key("gear_c"));
2840 }
2841
2842 #[derive(Debug, Deserialize, Default, PartialEq)]
2845 struct TestVendorConfig {
2846 #[serde(default)]
2847 api_token: String,
2848 #[serde(default)]
2849 api_url: String,
2850 }
2851
2852 #[test]
2853 fn test_vendor_section_parses_from_yaml() {
2854 let yaml = r#"
2855server:
2856 home_dir: "~/.test_vendor"
2857vendor:
2858 acme:
2859 api_token: "acme-token-123"
2860 api_url: "https://acme.example.com"
2861 other_corp:
2862 api_token: "other-token-789"
2863 api_url: "https://other.example.com"
2864"#;
2865 let config: AppConfig = serde_saphyr::from_str(yaml).unwrap();
2866 assert_eq!(config.vendor.len(), 2);
2867 assert!(config.vendor.contains_key("acme"));
2868 assert!(config.vendor.contains_key("other_corp"));
2869
2870 let acme: TestVendorConfig = config.vendor_config("acme").unwrap();
2871 assert_eq!(acme.api_token, "acme-token-123");
2872 assert_eq!(acme.api_url, "https://acme.example.com");
2873
2874 let other: TestVendorConfig = config.vendor_config("other_corp").unwrap();
2875 assert_eq!(other.api_token, "other-token-789");
2876 assert_eq!(other.api_url, "https://other.example.com");
2877 }
2878
2879 #[test]
2880 fn test_vendor_section_defaults_to_empty() {
2881 let config = AppConfig::default();
2882 assert!(config.vendor.is_empty());
2883 }
2884
2885 #[test]
2886 fn test_vendor_config_typed_access() {
2887 let mut config = AppConfig::default();
2888 config.vendor.insert(
2889 "acme".to_owned(),
2890 serde_json::json!({
2891 "api_token": "acme-token-123",
2892 "api_url": "https://acme.example.com"
2893 }),
2894 );
2895
2896 let acme: TestVendorConfig = config.vendor_config("acme").unwrap();
2897 assert_eq!(acme.api_token, "acme-token-123");
2898 assert_eq!(acme.api_url, "https://acme.example.com");
2899 }
2900
2901 #[test]
2902 fn test_vendor_config_not_found() {
2903 let config = AppConfig::default();
2904 let result: Result<TestVendorConfig, _> = config.vendor_config("nonexistent");
2905 assert!(matches!(
2906 result,
2907 Err(VendorConfigError::NotFound { ref vendor }) if vendor == "nonexistent"
2908 ));
2909 }
2910
2911 #[test]
2912 fn test_vendor_config_invalid_structure() {
2913 let mut config = AppConfig::default();
2914 config
2915 .vendor
2916 .insert("bad".to_owned(), serde_json::json!("not an object"));
2917
2918 let result: Result<TestVendorConfig, _> = config.vendor_config("bad");
2919 assert!(matches!(
2920 result,
2921 Err(VendorConfigError::InvalidConfig { ref vendor, .. }) if vendor == "bad"
2922 ));
2923 }
2924
2925 #[test]
2926 fn test_vendor_config_or_default_missing() {
2927 let config = AppConfig::default();
2928 let acme: TestVendorConfig = config.vendor_config_or_default("acme").unwrap();
2929 assert_eq!(acme, TestVendorConfig::default());
2930 }
2931
2932 #[test]
2933 fn test_vendor_config_or_default_present() {
2934 let mut config = AppConfig::default();
2935 config.vendor.insert(
2936 "acme".to_owned(),
2937 serde_json::json!({ "api_token": "acme-token-123" }),
2938 );
2939
2940 let acme: TestVendorConfig = config.vendor_config_or_default("acme").unwrap();
2941 assert_eq!(acme.api_token, "acme-token-123");
2942 }
2943
2944 #[test]
2945 #[serial]
2946 fn test_vendor_config_env_override() {
2947 let tmp = tempdir().unwrap();
2948 let cfg_path = tmp.path().join("cfg.yaml");
2949 let yaml = r#"
2950server:
2951 home_dir: "~/.test_vendor"
2952vendor:
2953 env_test_vendor:
2954 api_token: "from_yaml"
2955"#;
2956 fs::write(&cfg_path, yaml).unwrap();
2957
2958 with_var(
2959 "APP__VENDOR__ENV_TEST_VENDOR__API_TOKEN",
2960 Some("from_env"),
2961 || {
2962 let config = AppConfig::load_layered(&cfg_path).unwrap();
2963 let v: TestVendorConfig = config.vendor_config("env_test_vendor").unwrap();
2964 assert_eq!(v.api_token, "from_env");
2965 },
2966 );
2967 }
2968
2969 #[test]
2970 #[serial]
2971 fn test_gear_config_env_override_underscore_gear_name() {
2972 let tmp = tempdir().unwrap();
2975 let cfg_path = tmp.path().join("cfg.yaml");
2976 let yaml = r#"
2977server:
2978 home_dir: "~/.test_gear_env_underscore"
2979gears:
2980 static-authz-plugin:
2981 config:
2982 vendor: "from_yaml"
2983"#;
2984 fs::write(&cfg_path, yaml).unwrap();
2985
2986 with_var(
2987 "APP__GEARS__STATIC_AUTHZ_PLUGIN__CONFIG__VENDOR",
2988 Some("acme"),
2989 || {
2990 let config = AppConfig::load_layered(&cfg_path).unwrap();
2991 let gear = config.gears.get("static-authz-plugin").unwrap();
2992 assert_eq!(gear["config"]["vendor"], serde_json::json!("acme"));
2993 },
2994 );
2995 }
2996
2997 #[test]
2998 #[serial]
2999 fn test_gear_config_env_override_dash_gear_name_backcompat() {
3000 let tmp = tempdir().unwrap();
3002 let cfg_path = tmp.path().join("cfg.yaml");
3003 let yaml = r#"
3004server:
3005 home_dir: "~/.test_gear_env_dash"
3006gears:
3007 static-authz-plugin:
3008 config:
3009 vendor: "from_yaml"
3010"#;
3011 fs::write(&cfg_path, yaml).unwrap();
3012
3013 with_var(
3014 "APP__GEARS__static-authz-plugin__CONFIG__VENDOR",
3015 Some("acme"),
3016 || {
3017 let config = AppConfig::load_layered(&cfg_path).unwrap();
3018 let gear = config.gears.get("static-authz-plugin").unwrap();
3019 assert_eq!(gear["config"]["vendor"], serde_json::json!("acme"));
3020 },
3021 );
3022 }
3023
3024 #[test]
3025 #[serial]
3026 fn test_gear_config_env_override_preserves_field_underscores() {
3027 let tmp = tempdir().unwrap();
3029 let cfg_path = tmp.path().join("cfg.yaml");
3030 let yaml = r#"
3031server:
3032 home_dir: "~/.test_gear_env_field"
3033gears:
3034 static-authz-plugin:
3035 config:
3036 some_field: "from_yaml"
3037"#;
3038 fs::write(&cfg_path, yaml).unwrap();
3039
3040 with_var(
3041 "APP__GEARS__STATIC_AUTHZ_PLUGIN__CONFIG__SOME_FIELD",
3042 Some("from_env"),
3043 || {
3044 let config = AppConfig::load_layered(&cfg_path).unwrap();
3045 let gear = config.gears.get("static-authz-plugin").unwrap();
3046 assert_eq!(gear["config"]["some_field"], serde_json::json!("from_env"));
3047 },
3048 );
3049 }
3050
3051 #[test]
3052 #[serial]
3053 fn test_vendor_config_env_override_unaffected_by_gear_remap() {
3054 let tmp = tempdir().unwrap();
3059 let cfg_path = tmp.path().join("cfg.yaml");
3060 let yaml = r#"
3061server:
3062 home_dir: "~/.test_vendor_unaffected"
3063vendor:
3064 env_test_vendor:
3065 api_token: "from_yaml"
3066"#;
3067 fs::write(&cfg_path, yaml).unwrap();
3068
3069 with_var(
3070 "APP__VENDOR__ENV_TEST_VENDOR__API_TOKEN",
3071 Some("from_env"),
3072 || {
3073 let config = AppConfig::load_layered(&cfg_path).unwrap();
3074
3075 let v: TestVendorConfig = config.vendor_config("env_test_vendor").unwrap();
3077 assert_eq!(v.api_token, "from_env");
3078
3079 assert!(
3082 config.vendor.contains_key("env_test_vendor"),
3083 "underscore vendor key must be preserved"
3084 );
3085 assert!(
3086 !config.vendor.contains_key("env-test-vendor"),
3087 "gear remap leaked into the vendor branch"
3088 );
3089 },
3090 );
3091 }
3092
3093 #[test]
3094 fn test_vendor_multiple_vendors_typed_access() {
3095 let mut config = AppConfig::default();
3096 config.vendor.insert(
3097 "acme".to_owned(),
3098 serde_json::json!({ "api_token": "acme-token", "api_url": "https://acme.com" }),
3099 );
3100 config.vendor.insert(
3101 "other_corp".to_owned(),
3102 serde_json::json!({ "api_token": "other-token", "api_url": "https://other.com" }),
3103 );
3104
3105 let acme: TestVendorConfig = config.vendor_config("acme").unwrap();
3106 let other: TestVendorConfig = config.vendor_config("other_corp").unwrap();
3107
3108 assert_eq!(acme.api_token, "acme-token");
3109 assert_eq!(other.api_token, "other-token");
3110 assert_eq!(acme.api_url, "https://acme.com");
3111 assert_eq!(other.api_url, "https://other.com");
3112 }
3113
3114 #[test]
3115 fn test_vendor_nested_config() {
3116 #[derive(Debug, Deserialize, PartialEq)]
3117 struct NestedVendorConfig {
3118 api_url: String,
3119 feature_flags: FeatureFlags,
3120 }
3121
3122 #[derive(Debug, Deserialize, PartialEq)]
3123 struct FeatureFlags {
3124 beta_mode: bool,
3125 max_retries: u32,
3126 }
3127
3128 let mut config = AppConfig::default();
3129 config.vendor.insert(
3130 "acme".to_owned(),
3131 serde_json::json!({
3132 "api_url": "https://acme.com",
3133 "feature_flags": {
3134 "beta_mode": true,
3135 "max_retries": 3
3136 }
3137 }),
3138 );
3139
3140 let acme: NestedVendorConfig = config.vendor_config("acme").unwrap();
3141 assert_eq!(acme.api_url, "https://acme.com");
3142 assert!(acme.feature_flags.beta_mode);
3143 assert_eq!(acme.feature_flags.max_retries, 3);
3144 }
3145
3146 #[test]
3147 fn test_vendor_config_or_default_invalid_returns_error() {
3148 let mut config = AppConfig::default();
3149 config
3150 .vendor
3151 .insert("bad".to_owned(), serde_json::json!("not an object"));
3152
3153 let result: Result<TestVendorConfig, _> = config.vendor_config_or_default("bad");
3154 assert!(matches!(
3155 result,
3156 Err(VendorConfigError::InvalidConfig { ref vendor, .. }) if vendor == "bad"
3157 ));
3158 }
3159
3160 #[test]
3161 fn test_vendor_config_yaml_roundtrip() {
3162 let mut config = AppConfig::default();
3163 config.vendor.insert(
3164 "acme".to_owned(),
3165 serde_json::json!({ "api_token": "acme-token-123" }),
3166 );
3167
3168 let yaml = config.to_yaml().unwrap();
3169 assert!(yaml.contains("vendor"));
3170 assert!(yaml.contains("acme"));
3171 assert!(yaml.contains("acme-token-123"));
3172 }
3173
3174 #[test]
3175 fn test_vendor_coexists_with_gears() {
3176 let mut config = AppConfig::default();
3177 config.gears.insert(
3178 "my_gear".to_owned(),
3179 serde_json::json!({ "config": { "some_setting": true } }),
3180 );
3181 config.vendor.insert(
3182 "acme".to_owned(),
3183 serde_json::json!({ "api_token": "acme-token-123" }),
3184 );
3185
3186 assert!(config.gears.contains_key("my_gear"));
3187 assert!(config.vendor.contains_key("acme"));
3188
3189 let acme: TestVendorConfig = config.vendor_config("acme").unwrap();
3190 assert_eq!(acme.api_token, "acme-token-123");
3191 }
3192
3193 #[test]
3194 fn test_vendor_error_display_messages() {
3195 let not_found = VendorConfigError::NotFound {
3196 vendor: "acme".to_owned(),
3197 };
3198 assert_eq!(
3199 not_found.to_string(),
3200 "vendor 'acme' not found in configuration"
3201 );
3202
3203 let invalid = VendorConfigError::InvalidConfig {
3204 vendor: "bad".to_owned(),
3205 source: serde_json::from_str::<TestVendorConfig>("invalid").unwrap_err(),
3206 };
3207 let msg = invalid.to_string();
3208 assert!(msg.starts_with("invalid config for vendor 'bad':"));
3209 }
3210
3211 #[test]
3212 fn test_vendor_empty_object_in_yaml() {
3213 let yaml = r#"
3214server:
3215 home_dir: "~/.test_vendor"
3216vendor: {}
3217"#;
3218 let config: AppConfig = serde_saphyr::from_str(yaml).unwrap();
3219 assert!(config.vendor.is_empty());
3220 }
3221
3222 #[test]
3225 fn test_reject_duplicate_gear_names() {
3226 let tmp = tempdir().unwrap();
3227 let cfg_path = tmp.path().join("cfg.yaml");
3228 let yaml = r#"
3229server:
3230 home_dir: "~/.test_dup"
3231gears:
3232 gear1:
3233 config: {}
3234 gear2:
3235 config: {}
3236 gear1:
3237 config: {}
3238"#;
3239 fs::write(&cfg_path, yaml).unwrap();
3240
3241 let result = AppConfig::load_layered(&cfg_path);
3242 assert!(result.is_err(), "duplicate gear names should be rejected");
3243 let msg = format!("{:?}", result.unwrap_err());
3244 assert!(
3245 msg.contains("duplicate") || msg.contains("Duplicate"),
3246 "error should mention duplicates: {msg}"
3247 );
3248 }
3249
3250 #[test]
3251 fn test_reject_duplicate_keys_in_gear_file() {
3252 let tmp = tempdir().unwrap();
3253 let gears_dir = tmp.path().join("gears.d");
3254 fs::create_dir_all(&gears_dir).unwrap();
3255
3256 let gear_yaml = r#"
3258config:
3259 key1: "value1"
3260config:
3261 key2: "value2"
3262"#;
3263 fs::write(gears_dir.join("bad_gear.yaml"), gear_yaml).unwrap();
3264
3265 let cfg_yaml = format!(
3266 r#"
3267server:
3268 home_dir: "~/.test_dup_modfile"
3269gears_dir: "{}"
3270"#,
3271 normalize_path(&gears_dir)
3272 );
3273 let cfg_path = tmp.path().join("cfg.yaml");
3274 fs::write(&cfg_path, cfg_yaml).unwrap();
3275
3276 let result = AppConfig::load_layered(&cfg_path);
3277 assert!(
3278 result.is_err(),
3279 "duplicate keys in a gear file should be rejected"
3280 );
3281 let msg = format!("{:?}", result.unwrap_err());
3282 assert!(
3283 msg.contains("duplicate") || msg.contains("Duplicate"),
3284 "error should mention duplicates: {msg}"
3285 );
3286 }
3287
3288 #[test]
3289 fn test_no_false_positive_on_unique_gears() {
3290 let tmp = tempdir().unwrap();
3291 let cfg_path = tmp.path().join("cfg.yaml");
3292 let yaml = r#"
3293server:
3294 home_dir: "~/.test_ok"
3295gears:
3296 gear1:
3297 config: {}
3298 gear2:
3299 config: {}
3300 gear3:
3301 config: {}
3302"#;
3303 fs::write(&cfg_path, yaml).unwrap();
3304
3305 let result = AppConfig::load_layered(&cfg_path);
3306 assert!(
3307 result.is_ok(),
3308 "unique gear names should be accepted: {:?}",
3309 result.unwrap_err()
3310 );
3311 }
3312}
3313
3314