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.expose())?;
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(
885 global_server
886 .password
887 .as_ref()
888 .map(toolkit_utils::SecretString::expose),
889 )? {
890 self.password = Some(password);
891 }
892 if let Some(dbname) = &global_server.dbname {
893 self.dbname = Some(dbname.clone());
894 }
895 if let Some(params) = &global_server.params {
896 self.params.extend(params.clone());
897 }
898 if let Some(pool) = &global_server.pool {
899 self.pool = pool.clone();
900 }
901
902 Ok(())
903 }
904
905 fn apply_gear_dsn(
907 &mut self,
908 gear_dsn: &str,
909 home_dir: &Path,
910 gear_name: &str,
911 dry_run: bool,
912 ) -> Result<()> {
913 let resolved_dsn = if gear_dsn.starts_with("sqlite") {
915 resolve_sqlite_dsn(gear_dsn, home_dir, gear_name, dry_run)?
916 } else {
917 gear_dsn.to_owned()
918 };
919 validate_dsn(&resolved_dsn)?;
920 self.dsn = Some(resolved_dsn);
921 Ok(())
922 }
923
924 fn apply_gear_fields(&mut self, gear_db_config: &DbConnConfig) -> Result<()> {
926 if let Some(host) = &gear_db_config.host {
927 self.host = Some(host.clone());
928 }
929 if let Some(port) = gear_db_config.port {
930 self.port = Some(port);
931 }
932 if let Some(user) = &gear_db_config.user {
933 self.user = Some(user.clone());
934 }
935 if let Some(password) = resolve_password(
936 gear_db_config
937 .password
938 .as_ref()
939 .map(toolkit_utils::SecretString::expose),
940 )? {
941 self.password = Some(password);
942 }
943 if let Some(dbname) = &gear_db_config.dbname {
944 self.dbname = Some(dbname.clone());
945 }
946 if let Some(params) = &gear_db_config.params {
947 self.params.extend(params.clone());
948 }
949 if let Some(pool) = &gear_db_config.pool {
950 if let Some(max_conns) = pool.max_conns {
952 self.pool.max_conns = Some(max_conns);
953 }
954 if let Some(acquire_timeout) = pool.acquire_timeout {
955 self.pool.acquire_timeout = Some(acquire_timeout);
956 }
957 }
958 Ok(())
959 }
960
961 fn has_field_overrides(&self) -> bool {
963 self.host.is_some()
964 || self.port.is_some()
965 || self.user.is_some()
966 || self.password.is_some()
967 || !self.params.is_empty()
968 }
969}
970
971fn decide_backend(builder: &DbConfigBuilder, gear_db_config: &DbConnConfig) -> bool {
973 gear_db_config.file.is_some()
976 || gear_db_config.path.is_some()
977 || builder
978 .dsn
979 .as_ref()
980 .is_some_and(|dsn| dsn.starts_with("sqlite"))
981 || (gear_db_config.server.is_none() && builder.dsn.is_none())
982}
983
984fn finalize_sqlite_dsn(
986 builder: &DbConfigBuilder,
987 gear_db_config: &DbConnConfig,
988 gear_name: &str,
989 home_dir: &Path,
990 dry_run: bool,
991) -> Result<String> {
992 build_sqlite_dsn(
993 builder.dsn.as_deref(),
994 gear_db_config.file.as_deref(),
995 gear_db_config.path.as_ref(),
996 builder.dbname.as_deref(),
997 gear_name,
998 home_dir,
999 dry_run,
1000 )
1001}
1002
1003fn finalize_server_dsn(builder: &DbConfigBuilder, gear_name: &str) -> Result<String> {
1005 let dbname = if let Some(dbname) = builder.dbname.as_deref() {
1007 dbname.to_owned()
1008 } else if let Some(dsn) = builder.dsn.as_ref() {
1009 if let Ok(parsed) = url::Url::parse(dsn) {
1011 let path = parsed.path();
1012 if path.len() > 1 {
1013 path[1..].to_string()
1015 } else {
1016 return Err(anyhow::anyhow!(
1017 "Server-based database config for gear '{gear_name}' missing required 'dbname'"
1018 ));
1019 }
1020 } else {
1021 return Err(anyhow::anyhow!(
1022 "Server-based database config for gear '{gear_name}' missing required 'dbname'"
1023 ));
1024 }
1025 } else {
1026 return Err(anyhow::anyhow!(
1027 "Server-based database config for gear '{gear_name}' missing required 'dbname'"
1028 ));
1029 };
1030
1031 if builder.has_field_overrides() || builder.dsn.is_none() {
1032 let scheme = if let Some(dsn) = &builder.dsn {
1034 let parsed = Url::parse(dsn)?;
1035 parsed.scheme().to_owned()
1036 } else {
1037 "postgresql".to_owned() };
1039
1040 build_server_dsn(
1041 &scheme,
1042 builder.host.as_deref(),
1043 builder.port,
1044 builder.user.as_deref(),
1045 builder.password.as_deref(),
1046 Some(&dbname),
1047 &builder.params,
1048 )
1049 } else if let Some(original_dsn) = &builder.dsn {
1050 if let Ok(mut parsed) = Url::parse(original_dsn) {
1052 let original_dbname = parsed.path().trim_start_matches('/');
1054 if original_dbname != dbname {
1055 parsed.set_path(&format!("/{dbname}"));
1056 }
1057 Ok(parsed.to_string())
1058 } else {
1059 build_server_dsn(
1061 "postgresql",
1062 builder.host.as_deref(),
1063 builder.port,
1064 builder.user.as_deref(),
1065 builder.password.as_deref(),
1066 Some(&dbname),
1067 &builder.params,
1068 )
1069 }
1070 } else {
1071 unreachable!("final_dsn should not be None when has_field_overrides is false")
1073 }
1074}
1075
1076fn redact_dsn_for_logging(dsn: &str) -> Result<String> {
1078 if dsn.contains('@') {
1079 let parsed = Url::parse(dsn)?;
1080 let mut log_url = parsed;
1081 if log_url.password().is_some() {
1082 log_url.set_password(Some("***")).ok();
1083 }
1084 Ok(log_url.to_string())
1085 } else {
1086 Ok(dsn.to_owned())
1087 }
1088}
1089
1090pub const TOOLKIT_MODULE_CONFIG_ENV: &str = "TOOLKIT_MODULE_CONFIG";
1094
1095#[derive(Debug, Clone, Serialize, Deserialize)]
1098pub struct RenderedDbConfig {
1099 #[serde(skip_serializing_if = "Option::is_none")]
1102 pub global: Option<GlobalDatabaseConfig>,
1103 #[serde(skip_serializing_if = "Option::is_none")]
1106 pub gear: Option<DbConnConfig>,
1107}
1108
1109impl RenderedDbConfig {
1110 #[must_use]
1112 pub fn new(global: Option<GlobalDatabaseConfig>, gear: Option<DbConnConfig>) -> Self {
1113 Self { global, gear }
1114 }
1115}
1116
1117#[derive(Debug, Clone, Serialize, Deserialize)]
1127pub struct RenderedGearConfig {
1128 #[serde(skip_serializing_if = "Option::is_none")]
1131 pub database: Option<RenderedDbConfig>,
1132 #[serde(default)]
1134 pub config: serde_json::Value,
1135 #[serde(skip_serializing_if = "Option::is_none")]
1138 pub logging: Option<LoggingConfig>,
1139 #[serde(skip_serializing_if = "Option::is_none")]
1141 pub opentelemetry: Option<OpenTelemetryConfig>,
1142}
1143
1144impl RenderedGearConfig {
1145 pub fn from_json(json: &str) -> Result<Self> {
1150 serde_json::from_str(json).context("Failed to parse RenderedGearConfig from JSON")
1151 }
1152
1153 pub fn to_json(&self) -> Result<String> {
1158 serde_json::to_string(self).context("Failed to serialize RenderedGearConfig to JSON")
1159 }
1160}
1161
1162pub fn render_gear_config_for_oop(
1179 app: &AppConfig,
1180 gear_name: &str,
1181 _home_dir: &std::path::Path,
1182) -> Result<RenderedGearConfig> {
1183 let gear_db_config = parse_gear_config(app, gear_name)
1186 .ok()
1187 .and_then(|entry| entry.database);
1188
1189 let database = if gear_db_config.is_some() || app.database.is_some() {
1191 Some(RenderedDbConfig::new(app.database.clone(), gear_db_config))
1192 } else {
1193 None
1194 };
1195
1196 let config = parse_gear_config(app, gear_name)
1198 .map(|entry| entry.config)
1199 .unwrap_or_default();
1200
1201 let logging = app.logging.clone();
1203
1204 let opentelemetry = if app.opentelemetry.tracing.enabled || app.opentelemetry.metrics.enabled {
1206 Some(app.opentelemetry.clone())
1207 } else {
1208 None
1209 };
1210
1211 Ok(RenderedGearConfig {
1212 database,
1213 config,
1214 logging: Some(logging),
1215 opentelemetry,
1216 })
1217}
1218
1219pub fn parse_gear_config(app: &AppConfig, gear_name: &str) -> Result<GearConfig> {
1224 let gear_raw = app
1225 .gears
1226 .get(gear_name)
1227 .cloned()
1228 .ok_or_else(|| anyhow::anyhow!("Gear '{gear_name}' not found in config"))?;
1229
1230 let gear_config: GearConfig = serde_json::from_value(gear_raw)?;
1231 Ok(gear_config)
1232}
1233
1234pub fn get_gear_runtime_config(app: &AppConfig, gear_name: &str) -> Result<Option<GearRuntime>> {
1239 let entry = parse_gear_config(app, gear_name)?;
1240 Ok(entry.runtime)
1241}
1242
1243pub fn build_final_db_for_gear(
1254 app: &AppConfig,
1255 gear_name: &str,
1256 home_dir: &Path,
1257 dry_run: bool,
1258) -> DbConfigResult {
1259 let Some(gear_raw) = app.gears.get(gear_name) else {
1261 return Ok(None); };
1263
1264 let gear_entry: GearConfig = serde_json::from_value(gear_raw.clone())
1265 .with_context(|| format!("Invalid gear config structure for '{gear_name}'"))?;
1266
1267 let Some(gear_db_config) = gear_entry.database else {
1268 tracing::warn!(
1269 "Gear '{}' has no database configuration; DB capability disabled",
1270 gear_name
1271 );
1272 return Ok(None);
1273 };
1274
1275 let global_db_config = app.database.as_ref();
1277
1278 let mut builder = DbConfigBuilder::new();
1280
1281 if let Some(server_name) = &gear_db_config.server {
1283 let global_server = global_db_config
1284 .and_then(|gc| gc.servers.get(server_name))
1285 .ok_or_else(|| {
1286 anyhow::anyhow!("Referenced server '{server_name}' not found in global config")
1287 })?;
1288
1289 builder.apply_global_server(global_server, home_dir, gear_name, dry_run)?;
1290 }
1291
1292 if let Some(gear_dsn) = &gear_db_config.dsn {
1294 builder.apply_gear_dsn(gear_dsn.expose(), home_dir, gear_name, dry_run)?;
1295 }
1296
1297 builder.apply_gear_fields(&gear_db_config)?;
1299
1300 let is_sqlite = decide_backend(&builder, &gear_db_config);
1302
1303 let result_dsn = if is_sqlite {
1304 finalize_sqlite_dsn(&builder, &gear_db_config, gear_name, home_dir, dry_run)?
1305 } else {
1306 finalize_server_dsn(&builder, gear_name)?
1307 };
1308
1309 validate_dsn(&result_dsn)?;
1311
1312 let log_dsn = redact_dsn_for_logging(&result_dsn)?;
1314
1315 tracing::info!(
1316 "Built final DB config for gear '{}': {}",
1317 gear_name,
1318 log_dsn
1319 );
1320
1321 Ok(Some((result_dsn, builder.pool)))
1322}
1323
1324#[must_use]
1327pub fn get_gear_db_config(app: &AppConfig, gear_name: &str) -> Option<DbConnConfig> {
1328 let gear_raw = app.gears.get(gear_name)?;
1329 let gear_entry: GearConfig = serde_json::from_value(gear_raw.clone()).ok()?;
1330 gear_entry.database
1331}
1332
1333#[must_use]
1336pub fn gear_home(app: &AppConfig, gear_name: &str) -> PathBuf {
1337 PathBuf::from(&app.server.home_dir).join(gear_name)
1338}
1339
1340#[cfg(test)]
1341#[cfg_attr(coverage_nightly, coverage(off))]
1342mod tests {
1343 use super::*;
1344 use serial_test::serial;
1345 use std::fs;
1346 use temp_env::with_var;
1347 use tempfile::tempdir;
1348
1349 fn is_normalized_path(p: &Path) -> bool {
1351 p.is_absolute() && !p.starts_with("~")
1352 }
1353
1354 fn default_subdir() -> &'static str {
1356 ".cf-gears"
1357 }
1358
1359 #[test]
1360 fn test_remap_gear_env_key() {
1361 let cases = [
1364 ("gears.my_gear.port", "gears.my-gear.port"),
1366 ("gears.my_gear.max_age_days", "gears.my-gear.max_age_days"),
1368 ("gears.a_b_c.field", "gears.a-b-c.field"),
1370 ("gears.my-gear.port", "gears.my-gear.port"),
1372 ("vendor.my_vendor.key", "vendor.my_vendor.key"),
1374 ("server.home_dir", "server.home_dir"),
1375 ("gears", "gears"),
1377 ("GEARS.MY_GEAR.PORT", "gears.my-gear.port"),
1379 ("server", "server"),
1381 ];
1382
1383 for (input, expected) in cases {
1384 assert_eq!(
1385 remap_gear_env_key(input),
1386 expected,
1387 "remap_gear_env_key({input:?})"
1388 );
1389 }
1390 }
1391
1392 #[test]
1393 fn test_default_config_structure() {
1394 let config = AppConfig::default();
1395
1396 assert!(config.database.is_none());
1398
1399 let logging = config.logging;
1401 assert!(logging.contains_key("default"));
1402
1403 let default_section = &logging["default"];
1404 assert_eq!(default_section.console_level, Some(Level::INFO));
1405 assert_eq!(default_section.file().unwrap(), "logs/cf-gears.log");
1406
1407 assert!(config.gears.is_empty());
1409 }
1410
1411 #[test]
1414 #[serial]
1415 fn test_load_layered_normalizes_home_dir() {
1416 let tmp = tempdir().unwrap();
1417 let cfg_path = tmp.path().join("cfg.yaml");
1418
1419 let yaml = r#"
1421server:
1422 home_dir: "~/.test_cfgears"
1423
1424database:
1425 servers:
1426 test_postgres:
1427 dsn: "postgres://user:pass@localhost/db"
1428 pool:
1429 max_conns: 20
1430
1431logging:
1432 default:
1433 console_level: debug
1434 file: "logs/default.log"
1435"#;
1436 fs::write(&cfg_path, yaml).unwrap();
1437
1438 let config = AppConfig::load_layered(&cfg_path).unwrap();
1439
1440 assert!(is_normalized_path(&config.server.home_dir));
1442 assert!(config.server.home_dir.ends_with(".test_cfgears"));
1443
1444 let logging = &config.logging;
1450 let def = &logging["default"];
1451 assert_eq!(def.console_level, Some(Level::DEBUG));
1452 assert_eq!(def.section_file.as_ref().unwrap().file, "logs/default.log");
1453 }
1454
1455 #[test]
1456 fn test_load_or_default_normalizes_home_dir_when_none() {
1457 let tmp = tempdir().unwrap();
1460 let env_var = if cfg!(target_os = "windows") {
1461 "APPDATA"
1462 } else {
1463 "HOME"
1464 };
1465 with_var(env_var, Some(tmp.path().to_str().unwrap()), || {
1466 let config = AppConfig::load_or_default(None).unwrap();
1467 assert!(is_normalized_path(&config.server.home_dir));
1468 assert!(config.server.home_dir.ends_with(default_subdir()));
1469 });
1470 }
1471
1472 #[test]
1475 #[serial]
1476 fn test_minimal_yaml_config() {
1477 let tmp = tempdir().unwrap();
1478 let cfg_path = tmp.path().join("cfg.yaml");
1479
1480 let yaml = r#"
1481server:
1482 home_dir: "~/.minimal"
1483"#;
1484 fs::write(&cfg_path, yaml).unwrap();
1485
1486 let config = AppConfig::load_layered(&cfg_path).unwrap();
1487
1488 assert!(is_normalized_path(&config.server.home_dir));
1490 assert!(config.server.home_dir.ends_with(".minimal"));
1491
1492 assert!(config.database.is_none());
1494 assert!(config.gears.is_empty());
1495 }
1496
1497 #[test]
1498 fn test_cli_overrides() {
1499 let mut config = AppConfig::default();
1500
1501 let args = CliArgs {
1502 config: None,
1503 print_config: false,
1504 verbose: 2, mock: false,
1506 };
1507
1508 config.apply_cli_overrides(args.verbose);
1509
1510 let logging = &config.logging;
1514 let default_section = &logging["default"];
1515 assert_eq!(default_section.console_level, Some(Level::TRACE));
1516 }
1517
1518 #[test]
1519 fn test_cli_verbose_levels_matrix() {
1520 for (verbose_level, expected_log_level) in [
1521 (0, Some(Level::INFO)), (1, Some(Level::DEBUG)),
1523 (2, Some(Level::TRACE)),
1524 (3, Some(Level::TRACE)), ] {
1526 let mut config = AppConfig::default();
1527 let args = CliArgs {
1528 config: None,
1529 print_config: false,
1530 verbose: verbose_level,
1531 mock: false,
1532 };
1533
1534 config.apply_cli_overrides(args.verbose);
1535
1536 let logging = &config.logging;
1537 let default_section = &logging["default"];
1538
1539 if verbose_level == 0 {
1540 assert_eq!(default_section.console_level, Some(Level::INFO));
1541 } else {
1542 assert_eq!(default_section.console_level, expected_log_level);
1543 }
1544 }
1545 }
1546
1547 #[test]
1550 #[serial]
1551 fn test_layered_config_loading_with_gears_dir() {
1552 let tmp = tempdir().unwrap();
1553 let cfg_path = tmp.path().join("gears_dir.yaml");
1554 let gears_dir = tmp.path().join("gears");
1555
1556 fs::create_dir_all(&gears_dir).unwrap();
1557 let gear_cfg = gears_dir.join("test_gear.yaml");
1558 fs::write(
1559 &gear_cfg,
1560 r#"
1561setting1: "value1"
1562setting2: 42
1563"#,
1564 )
1565 .unwrap();
1566
1567 let gears_dir_str = normalize_path(&gears_dir);
1569 let yaml = format!(
1570 r#"
1571server:
1572 home_dir: "~/.gears_test"
1573
1574gears_dir: "{gears_dir_str}"
1575
1576gears:
1577 existing_gear:
1578 key: "value"
1579"#
1580 );
1581
1582 fs::write(&cfg_path, yaml).unwrap();
1583
1584 let config = AppConfig::load_layered(&cfg_path).unwrap();
1585
1586 assert!(config.gears.contains_key("existing_gear"));
1588
1589 assert!(config.gears.contains_key("test_gear"));
1591
1592 let test_gear = &config.gears["test_gear"];
1594 assert_eq!(test_gear["setting1"], "value1");
1595 assert_eq!(test_gear["setting2"], 42);
1596 }
1597
1598 #[test]
1601 #[serial]
1602 fn test_load_and_init_logging_smoke() {
1603 let tmp = tempdir().unwrap();
1605 let cfg_path = tmp.path().join("logging.yaml");
1606 let yaml = r#"
1607server:
1608 home_dir: "~/.logging_test"
1609
1610logging:
1611 default:
1612 console_level: debug
1613 file: ""
1614 file_level: info
1615"#;
1616 fs::write(&cfg_path, yaml).unwrap();
1617
1618 let config = AppConfig::load_layered(&cfg_path).unwrap();
1619 let logging = &config.logging;
1620 assert!(logging.contains_key("default"));
1621
1622 let default_section = &logging["default"];
1623 assert_eq!(default_section.console_level, Some(Level::DEBUG));
1624 assert_eq!(default_section.file_level(), Some(Level::INFO));
1625 }
1627
1628 fn create_app_with_server(server_name: &str, db_config: DbConnConfig) -> AppConfig {
1632 let mut servers = HashMap::new();
1633 servers.insert(server_name.to_owned(), db_config);
1634
1635 AppConfig {
1636 database: Some(GlobalDatabaseConfig {
1637 servers,
1638 auto_provision: None,
1639 }),
1640 ..Default::default()
1641 }
1642 }
1643
1644 fn add_gear_to_app(app: &mut AppConfig, gear_name: &str, database_config: &serde_json::Value) {
1646 app.gears.insert(
1647 gear_name.to_owned(),
1648 serde_json::json!({
1649 "database": database_config,
1650 "config": {}
1651 }),
1652 );
1653 }
1654
1655 fn add_gear_with_config(app: &mut AppConfig, gear_name: &str, config: &serde_json::Value) {
1657 app.gears.insert(
1658 gear_name.to_owned(),
1659 serde_json::json!({
1660 "database": {},
1661 "config": config
1662 }),
1663 );
1664 }
1665
1666 fn create_minimal_app() -> AppConfig {
1668 AppConfig {
1669 database: None,
1670 gears: HashMap::new(),
1671 ..Default::default()
1672 }
1673 }
1674
1675 #[test]
1676 fn test_precedence_global_dsn_only() {
1677 let tmp = tempdir().unwrap();
1678 let home_dir = tmp.path();
1679
1680 let mut app = create_app_with_server(
1681 "test_server",
1682 DbConnConfig {
1683 dsn: Some(toolkit_utils::SecretString::new(
1684 "postgresql://global_user:global_pass@global_host:5432/global_db",
1685 )),
1686 ..Default::default()
1687 },
1688 );
1689
1690 add_gear_to_app(
1692 &mut app,
1693 "test_gear",
1694 &serde_json::json!({
1695 "server": "test_server"
1696 }),
1697 );
1698
1699 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
1700 assert!(result.is_some());
1701
1702 let (dsn, _pool) = result.unwrap();
1703 assert!(dsn.contains("global_user"));
1704 assert!(dsn.contains("global_host"));
1705 assert!(dsn.contains("global_db"));
1706 }
1707
1708 #[test]
1709 fn test_precedence_global_fields_only() {
1710 let tmp = tempdir().unwrap();
1711 let home_dir = tmp.path();
1712
1713 let mut app = create_app_with_server(
1714 "test_server",
1715 DbConnConfig {
1716 host: Some("field_host".to_owned()),
1717 port: Some(5433),
1718 user: Some("field_user".to_owned()),
1719 dbname: Some("field_db".to_owned()),
1720 ..Default::default()
1721 },
1722 );
1723
1724 add_gear_to_app(
1726 &mut app,
1727 "test_gear",
1728 &serde_json::json!({
1729 "server": "test_server"
1730 }),
1731 );
1732
1733 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
1734 assert!(result.is_some());
1735
1736 let (dsn, _pool) = result.unwrap();
1737 assert!(dsn.contains("field_host"));
1738 assert!(dsn.contains("5433"));
1739 assert!(dsn.contains("field_user"));
1740 assert!(dsn.contains("field_db"));
1741 }
1742
1743 #[test]
1744 fn test_precedence_gear_dsn_only() {
1745 let tmp = tempdir().unwrap();
1746 let home_dir = tmp.path();
1747
1748 let app = AppConfig {
1749 gears: {
1750 let mut gears = HashMap::new();
1751 gears.insert(
1752 "test_gear".to_owned(),
1753 serde_json::json!({
1754 "database": {
1755 "dsn": "sqlite://gear_test.db?wal=true&synchronous=NORMAL"
1756 },
1757 "config": {}
1758 }),
1759 );
1760 gears
1761 },
1762 ..Default::default()
1763 };
1764
1765 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
1766 assert!(result.is_some());
1767
1768 let (dsn, _pool) = result.unwrap();
1769 assert!(dsn.contains("gear_test.db"));
1770 assert!(dsn.contains("wal=true"));
1771 }
1772
1773 #[test]
1774 fn test_precedence_gear_fields_only() {
1775 let tmp = tempdir().unwrap();
1776 let home_dir = tmp.path();
1777
1778 let app = AppConfig {
1779 gears: {
1780 let mut gears = HashMap::new();
1781 gears.insert(
1782 "test_gear".to_owned(),
1783 serde_json::json!({
1784 "database": {
1785 "file": "gear_fields.db"
1786 },
1787 "config": {}
1788 }),
1789 );
1790 gears
1791 },
1792 ..Default::default()
1793 };
1794
1795 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
1796 assert!(result.is_some());
1797
1798 let (dsn, _pool) = result.unwrap();
1799 assert!(dsn.contains("gear_fields.db"));
1800 #[cfg(windows)]
1802 assert!(dsn.starts_with("sqlite:") && !dsn.starts_with("sqlite://"));
1803 #[cfg(unix)]
1804 assert!(dsn.starts_with("sqlite://"));
1805 }
1806
1807 #[test]
1808 fn test_precedence_fields_override_dsn() {
1809 let tmp = tempdir().unwrap();
1810 let home_dir = tmp.path();
1811
1812 let mut app = create_app_with_server(
1813 "test_server",
1814 DbConnConfig {
1815 dsn: Some(toolkit_utils::SecretString::new(
1816 "postgresql://old_user:old_pass@old_host:5432/old_db",
1817 )),
1818 host: Some("new_host".to_owned()), port: Some(5433), user: Some("new_user".to_owned()), dbname: Some("new_db".to_owned()), ..Default::default()
1823 },
1824 );
1825
1826 add_gear_to_app(
1828 &mut app,
1829 "test_gear",
1830 &serde_json::json!({
1831 "server": "test_server",
1832 "port": 5434 }),
1834 );
1835
1836 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
1837 assert!(result.is_some());
1838
1839 let (dsn, _pool) = result.unwrap();
1840 assert!(dsn.contains("new_host"));
1842 assert!(dsn.contains("5434")); assert!(dsn.contains("new_user"));
1844 assert!(dsn.contains("new_db"));
1845 assert!(!dsn.contains("old_host"));
1847 assert!(!dsn.contains("5432"));
1848 assert!(!dsn.contains("old_user"));
1849 assert!(!dsn.contains("old_db"));
1850 }
1851
1852 #[test]
1853 fn test_env_expansion_password() {
1854 let tmp = tempdir().unwrap();
1855 let home_dir = tmp.path();
1856
1857 with_var("TEST_DB_PASSWORD", Some("secret123"), || {
1858 let mut app = create_app_with_server(
1859 "test_server",
1860 DbConnConfig {
1861 host: Some("localhost".to_owned()),
1862 port: Some(5432),
1863 user: Some("testuser".to_owned()),
1864 password: Some(toolkit_utils::SecretString::new("${TEST_DB_PASSWORD}")), dbname: Some("testdb".to_owned()),
1866 ..Default::default()
1867 },
1868 );
1869
1870 add_gear_to_app(
1871 &mut app,
1872 "test_gear",
1873 &serde_json::json!({
1874 "server": "test_server"
1875 }),
1876 );
1877
1878 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
1879 assert!(result.is_some());
1880
1881 let (dsn, _pool) = result.unwrap();
1882 assert!(dsn.contains("secret123"));
1883 });
1884 }
1885
1886 #[test]
1887 fn test_env_expansion_in_dsn() {
1888 let tmp = tempdir().unwrap();
1889 let home_dir = tmp.path();
1890
1891 temp_env::with_vars(
1892 [
1893 ("DB_HOST", Some("test-server")),
1894 ("DB_PASSWORD", Some("env_secret")),
1895 ],
1896 || {
1897 let mut app = create_app_with_server(
1898 "test_server",
1899 DbConnConfig {
1900 dsn: Some(toolkit_utils::SecretString::new(
1901 "postgresql://user:${DB_PASSWORD}@${DB_HOST}:5432/mydb",
1902 )),
1903 ..Default::default()
1904 },
1905 );
1906
1907 add_gear_to_app(
1908 &mut app,
1909 "test_gear",
1910 &serde_json::json!({
1911 "server": "test_server"
1912 }),
1913 );
1914
1915 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
1916 assert!(result.is_some());
1917
1918 let (dsn, _pool) = result.unwrap();
1919 assert!(dsn.contains("test-server"));
1920 assert!(dsn.contains("env_secret"));
1921 assert!(!dsn.contains("${DB_HOST}"));
1923 assert!(!dsn.contains("${DB_PASSWORD}"));
1924 },
1925 );
1926 }
1927
1928 #[test]
1929 fn test_sqlite_file_path_resolution() {
1930 let tmp = tempdir().unwrap();
1931 let home_dir = tmp.path();
1932
1933 let app1 = AppConfig {
1935 gears: {
1936 let mut gears = HashMap::new();
1937 gears.insert(
1938 "test_gear".to_owned(),
1939 serde_json::json!({
1940 "database": {
1941 "file": "test.db"
1942 },
1943 "config": {}
1944 }),
1945 );
1946 gears
1947 },
1948 ..Default::default()
1949 };
1950
1951 let result1 = build_final_db_for_gear(&app1, "test_gear", home_dir, false).unwrap();
1952 assert!(result1.is_some());
1953 let (dsn1, _) = result1.unwrap();
1954 assert!(dsn1.contains("test_gear"));
1955 assert!(dsn1.contains("test.db"));
1956
1957 let abs_path = tmp.path().join("absolute.db");
1959 let app2 = AppConfig {
1960 gears: {
1961 let mut gears = HashMap::new();
1962 gears.insert(
1963 "test_gear".to_owned(),
1964 serde_json::json!({
1965 "database": {
1966 "path": abs_path.to_string_lossy()
1967 },
1968 "config": {}
1969 }),
1970 );
1971 gears
1972 },
1973 ..Default::default()
1974 };
1975
1976 let result2 = build_final_db_for_gear(&app2, "test_gear", home_dir, false).unwrap();
1977 assert!(result2.is_some());
1978 let (dsn2, _) = result2.unwrap();
1979 assert!(dsn2.contains("absolute.db"));
1980
1981 let app3 = AppConfig {
1983 gears: {
1984 let mut gears = HashMap::new();
1985 gears.insert(
1986 "test_gear".to_owned(),
1987 serde_json::json!({
1988 "database": {},
1989 "config": {}
1990 }),
1991 );
1992 gears
1993 },
1994 ..Default::default()
1995 };
1996
1997 let result3 = build_final_db_for_gear(&app3, "test_gear", home_dir, false).unwrap();
1998 assert!(result3.is_some());
1999 let (dsn3, _) = result3.unwrap();
2000 assert!(dsn3.contains("test_gear.sqlite"));
2001 }
2002
2003 #[cfg(windows)]
2004 #[test]
2005 fn test_sqlite_path_resolution_windows() {
2006 let tmp = tempdir().unwrap();
2007 let home_dir = tmp.path();
2008
2009 let app = AppConfig {
2010 gears: {
2011 let mut gears = HashMap::new();
2012 gears.insert(
2013 "test_gear".to_owned(),
2014 serde_json::json!({
2015 "database": {
2016 "file": "test.db"
2017 },
2018 "config": {}
2019 }),
2020 );
2021 gears
2022 },
2023 ..Default::default()
2024 };
2025
2026 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2027 assert!(result.is_some());
2028 let (dsn, _) = result.unwrap();
2029
2030 assert!(!dsn.contains('\\'));
2032 assert!(dsn.contains('/'));
2033 }
2034
2035 #[test]
2036 fn test_sqlite_dsn_with_server_reference_and_dbname_override() {
2037 let tmp = tempdir().unwrap();
2038 let home_dir = tmp.path();
2039
2040 let mut app = AppConfig::default();
2041
2042 let mut servers = HashMap::new();
2044 servers.insert(
2045 "sqlite_users".to_owned(),
2046 DbConnConfig {
2047 engine: None,
2048 dsn: Some(toolkit_utils::SecretString::new(
2049 "sqlite://users_info.db?WAL=true&synchronous=NORMAL&busy_timeout=5000",
2050 )),
2051 host: None,
2052 port: None,
2053 user: None,
2054 password: None,
2055 dbname: None,
2056 params: None,
2057 pool: None,
2058 file: None,
2059 path: None,
2060 server: None,
2061 },
2062 );
2063
2064 app.database = Some(GlobalDatabaseConfig {
2065 servers,
2066 auto_provision: None,
2067 });
2068
2069 app.gears.insert(
2071 "users_info".to_owned(),
2072 serde_json::json!({
2073 "database": {
2074 "server": "sqlite_users",
2075 "dbname": "users_info.db"
2076 },
2077 "config": {}
2078 }),
2079 );
2080
2081 let result = build_final_db_for_gear(&app, "users_info", home_dir, false).unwrap();
2082 assert!(result.is_some());
2083 let (dsn, _) = result.unwrap();
2084
2085 assert!(dsn.contains("?WAL=true&synchronous=NORMAL&busy_timeout=5000"));
2087 assert!(dsn.contains("users_info/users_info.db"));
2088
2089 #[cfg(windows)]
2091 {
2092 assert!(dsn.starts_with("sqlite:"));
2094 assert!(!dsn.starts_with("sqlite://"));
2095 }
2096
2097 #[cfg(unix)]
2098 {
2099 assert!(dsn.starts_with("sqlite://"));
2101 }
2102 }
2103
2104 #[cfg(unix)]
2105 #[test]
2106 fn test_sqlite_path_resolution_unix() {
2107 let tmp = tempdir().unwrap();
2108 let home_dir = tmp.path();
2109
2110 let app = AppConfig {
2111 gears: {
2112 let mut gears = HashMap::new();
2113 gears.insert(
2114 "test_gear".to_owned(),
2115 serde_json::json!({
2116 "database": {
2117 "file": "test.db"
2118 },
2119 "config": {}
2120 }),
2121 );
2122 gears
2123 },
2124 ..Default::default()
2125 };
2126
2127 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2128 assert!(result.is_some());
2129 let (dsn, _) = result.unwrap();
2130
2131 assert!(dsn.starts_with("sqlite://"));
2133 assert!(dsn.contains("/test_gear/test.db"));
2134 }
2135
2136 #[test]
2137 fn test_server_based_db_missing_dbname_error() {
2138 let tmp = tempdir().unwrap();
2139 let home_dir = tmp.path();
2140
2141 let mut app = create_app_with_server(
2142 "test_server",
2143 DbConnConfig {
2144 host: Some("localhost".to_owned()),
2145 port: Some(5432),
2146 user: Some("testuser".to_owned()),
2147 ..Default::default()
2149 },
2150 );
2151
2152 add_gear_to_app(
2153 &mut app,
2154 "test_gear",
2155 &serde_json::json!({
2156 "server": "test_server"
2157 }),
2158 );
2159
2160 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false);
2161 assert!(result.is_err());
2162 let error_msg = result.unwrap_err().to_string();
2163 assert!(error_msg.contains("missing required 'dbname'"));
2164 }
2165
2166 #[test]
2167 fn test_gear_no_database_config() {
2168 let tmp = tempdir().unwrap();
2169 let home_dir = tmp.path();
2170
2171 let app = AppConfig {
2173 gears: {
2174 let mut gears = HashMap::new();
2175 gears.insert(
2176 "no_db_gear".to_owned(),
2177 serde_json::json!({
2178 "config": {
2179 "some_setting": "value"
2180 }
2181 }),
2182 );
2183 gears
2184 },
2185 ..Default::default()
2186 };
2187
2188 let result = build_final_db_for_gear(&app, "no_db_gear", home_dir, false).unwrap();
2189 assert!(result.is_none());
2190 }
2191
2192 #[test]
2193 fn test_gear_empty_database_config() {
2194 let tmp = tempdir().unwrap();
2195 let home_dir = tmp.path();
2196
2197 let app = AppConfig {
2199 gears: {
2200 let mut gears = HashMap::new();
2201 gears.insert(
2202 "empty_db_gear".to_owned(),
2203 serde_json::json!({
2204 "database": null,
2205 "config": {}
2206 }),
2207 );
2208 gears
2209 },
2210 ..Default::default()
2211 };
2212
2213 let result = build_final_db_for_gear(&app, "empty_db_gear", home_dir, false).unwrap();
2214 assert!(result.is_none());
2215 }
2216
2217 #[test]
2218 fn test_referenced_server_not_found() {
2219 let tmp = tempdir().unwrap();
2220 let home_dir = tmp.path();
2221
2222 let app = AppConfig {
2223 gears: {
2224 let mut gears = HashMap::new();
2225 gears.insert(
2226 "test_gear".to_owned(),
2227 serde_json::json!({
2228 "database": {
2229 "server": "nonexistent_server"
2230 },
2231 "config": {}
2232 }),
2233 );
2234 gears
2235 },
2236 ..Default::default()
2237 };
2238
2239 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false);
2240 assert!(result.is_err());
2241 let error_msg = result.unwrap_err().to_string();
2242 assert!(error_msg.contains("Referenced server 'nonexistent_server' not found"));
2243 }
2244
2245 #[test]
2246 fn test_dsn_validation_invalid_url() {
2247 let tmp = tempdir().unwrap();
2248 let home_dir = tmp.path();
2249
2250 let app = AppConfig {
2251 gears: {
2252 let mut gears = HashMap::new();
2253 gears.insert(
2254 "test_gear".to_owned(),
2255 serde_json::json!({
2256 "database": {
2257 "dsn": "invalid://not-a-valid[url"
2258 },
2259 "config": {}
2260 }),
2261 );
2262 gears
2263 },
2264 ..Default::default()
2265 };
2266
2267 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false);
2268 assert!(result.is_err());
2269 }
2270
2271 #[test]
2272 fn test_env_variable_not_found() {
2273 let tmp = tempdir().unwrap();
2274 let home_dir = tmp.path();
2275
2276 with_var("NONEXISTENT_PASSWORD", None::<&str>, || {
2278 let mut app = create_app_with_server(
2279 "test_server",
2280 DbConnConfig {
2281 host: Some("localhost".to_owned()),
2282 password: Some(toolkit_utils::SecretString::new("${NONEXISTENT_PASSWORD}")),
2283 dbname: Some("testdb".to_owned()),
2284 ..Default::default()
2285 },
2286 );
2287
2288 add_gear_to_app(
2289 &mut app,
2290 "test_gear",
2291 &serde_json::json!({
2292 "server": "test_server"
2293 }),
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("NONEXISTENT_PASSWORD"));
2300 });
2301 }
2302
2303 #[test]
2304 fn test_sqlite_at_file_relative_path() {
2305 let tmp = tempdir().unwrap();
2306 let home_dir = tmp.path();
2307
2308 let app = AppConfig {
2309 gears: {
2310 let mut gears = HashMap::new();
2311 gears.insert(
2312 "test_gear".to_owned(),
2313 serde_json::json!({
2314 "database": {
2315 "dsn": "sqlite://@file(users.db)"
2316 },
2317 "config": {}
2318 }),
2319 );
2320 gears
2321 },
2322 ..Default::default()
2323 };
2324
2325 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2326 assert!(result.is_some());
2327
2328 let (dsn, _pool) = result.unwrap();
2329 assert!(dsn.contains("test_gear"));
2330 assert!(dsn.contains("users.db"));
2331 #[cfg(windows)]
2333 assert!(dsn.starts_with("sqlite:") && !dsn.starts_with("sqlite://"));
2334 #[cfg(unix)]
2335 assert!(dsn.starts_with("sqlite:///"));
2336 }
2337
2338 #[test]
2339 fn test_sqlite_at_file_absolute_path() {
2340 let tmp = tempdir().unwrap();
2341 let home_dir = tmp.path();
2342 let abs_path = tmp.path().join("absolute_db.sqlite");
2343
2344 let app = AppConfig {
2345 gears: {
2346 let mut gears = HashMap::new();
2347 gears.insert(
2348 "test_gear".to_owned(),
2349 serde_json::json!({
2350 "database": {
2351 "dsn": format!("sqlite://@file({})", abs_path.to_string_lossy())
2352 },
2353 "config": {}
2354 }),
2355 );
2356 gears
2357 },
2358 ..Default::default()
2359 };
2360
2361 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2362 assert!(result.is_some());
2363
2364 let (dsn, _pool) = result.unwrap();
2365 assert!(dsn.contains("absolute_db.sqlite"));
2366 #[cfg(windows)]
2368 assert!(dsn.starts_with("sqlite:") && !dsn.starts_with("sqlite://"));
2369 #[cfg(unix)]
2370 assert!(dsn.starts_with("sqlite:///"));
2371 }
2372
2373 #[test]
2374 fn test_sqlite_empty_dsn_default() {
2375 let tmp = tempdir().unwrap();
2376 let home_dir = tmp.path();
2377
2378 let app = AppConfig {
2379 gears: {
2380 let mut gears = HashMap::new();
2381 gears.insert(
2382 "test_gear".to_owned(),
2383 serde_json::json!({
2384 "database": {
2385 "dsn": "sqlite://"
2386 },
2387 "config": {}
2388 }),
2389 );
2390 gears
2391 },
2392 ..Default::default()
2393 };
2394
2395 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2396 assert!(result.is_some());
2397
2398 let (dsn, _pool) = result.unwrap();
2399 assert!(dsn.contains("test_gear"));
2400 assert!(dsn.contains("test_gear.sqlite"));
2401 #[cfg(windows)]
2403 assert!(dsn.starts_with("sqlite:") && !dsn.starts_with("sqlite://"));
2404 #[cfg(unix)]
2405 assert!(dsn.starts_with("sqlite:///"));
2406 }
2407
2408 #[test]
2409 fn test_sqlite_at_file_invalid_syntax() {
2410 let tmp = tempdir().unwrap();
2411 let home_dir = tmp.path();
2412
2413 let app = AppConfig {
2414 gears: {
2415 let mut gears = HashMap::new();
2416 gears.insert(
2417 "test_gear".to_owned(),
2418 serde_json::json!({
2419 "database": {
2420 "dsn": "sqlite://@file(missing_closing_paren"
2421 },
2422 "config": {}
2423 }),
2424 );
2425 gears
2426 },
2427 ..Default::default()
2428 };
2429
2430 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false);
2431 assert!(result.is_err());
2432 let error_msg = result.unwrap_err().to_string();
2433 assert!(error_msg.contains("Invalid @file() syntax"));
2434 }
2435
2436 #[test]
2437 fn test_dsn_special_characters_in_credentials() {
2438 let tmp = tempdir().unwrap();
2439 let home_dir = tmp.path();
2440
2441 let mut app = create_app_with_server(
2443 "test_server",
2444 DbConnConfig {
2445 host: Some("localhost".to_owned()),
2446 port: Some(5432),
2447 user: Some("user@domain".to_owned()),
2448 password: Some(toolkit_utils::SecretString::new(
2449 "pa@ss:w0rd/with%special&chars",
2450 )),
2451 dbname: Some("test/db".to_owned()),
2452 ..Default::default()
2453 },
2454 );
2455
2456 add_gear_to_app(
2457 &mut app,
2458 "test_gear",
2459 &serde_json::json!({
2460 "server": "test_server"
2461 }),
2462 );
2463
2464 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2465 assert!(result.is_some());
2466
2467 let (dsn, _pool) = result.unwrap();
2468
2469 assert!(dsn.starts_with("postgresql://"));
2471 assert!(dsn.contains("user%40domain")); assert!(dsn.contains("/test%2Fdb")); validate_dsn(&dsn).expect("DSN with special characters should be valid");
2476
2477 let parsed_dsn = dsn::parse(&dsn).expect("DSN should be parseable");
2479 assert_eq!(parsed_dsn.username.as_deref(), Some("user@domain"));
2480 assert_eq!(
2481 parsed_dsn.password.as_deref(),
2482 Some("pa@ss:w0rd/with%special&chars")
2483 );
2484 }
2487
2488 #[test]
2489 #[allow(clippy::non_ascii_literal)]
2490 fn test_dsn_unicode_characters() {
2491 let tmp = tempdir().unwrap();
2492 let home_dir = tmp.path();
2493
2494 let mut app = create_app_with_server(
2496 "test_server",
2497 DbConnConfig {
2498 host: Some("localhost".to_owned()),
2499 user: Some("ユーザー".to_owned()), dbname: Some("unicode_db".to_owned()),
2501 ..Default::default()
2502 },
2503 );
2504
2505 add_gear_to_app(
2506 &mut app,
2507 "test_gear",
2508 &serde_json::json!({
2509 "server": "test_server"
2510 }),
2511 );
2512
2513 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2514 assert!(result.is_some());
2515
2516 let (dsn, _pool) = result.unwrap();
2517
2518 assert!(dsn.starts_with("postgresql://"));
2520 assert!(dsn.contains('%')); validate_dsn(&dsn).expect("DSN with Unicode characters should be valid");
2525 }
2526
2527 #[test]
2528 fn test_dsn_query_parameters_encoding() {
2529 let tmp = tempdir().unwrap();
2530 let home_dir = tmp.path();
2531
2532 let mut params = HashMap::new();
2533 params.insert("ssl mode".to_owned(), "require & verify".to_owned());
2534 params.insert("application_name".to_owned(), "my-app/v1.0".to_owned());
2535
2536 let mut app = create_app_with_server(
2537 "test_server",
2538 DbConnConfig {
2539 host: Some("localhost".to_owned()),
2540 user: Some("testuser".to_owned()),
2541 dbname: Some("testdb".to_owned()),
2542 params: Some(params),
2543 ..Default::default()
2544 },
2545 );
2546
2547 add_gear_to_app(
2548 &mut app,
2549 "test_gear",
2550 &serde_json::json!({
2551 "server": "test_server"
2552 }),
2553 );
2554
2555 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2556 assert!(result.is_some());
2557
2558 let (dsn, _pool) = result.unwrap();
2559
2560 assert!(dsn.contains("ssl+mode=require+%26+verify"));
2562 assert!(dsn.contains("application_name=my-app%2Fv1.0"));
2563
2564 validate_dsn(&dsn).expect("DSN with encoded query parameters should be valid");
2566 }
2567
2568 #[test]
2569 fn test_pool_config_merging() {
2570 use std::time::Duration;
2571
2572 let tmp = tempdir().unwrap();
2573 let home_dir = tmp.path();
2574
2575 let mut app = create_app_with_server(
2577 "test_server",
2578 DbConnConfig {
2579 host: Some("localhost".to_owned()),
2580 dbname: Some("testdb".to_owned()),
2581 pool: Some(PoolCfg {
2582 max_conns: Some(10),
2583 min_conns: None,
2584 acquire_timeout: Some(Duration::from_secs(5)),
2585 idle_timeout: None,
2586 max_lifetime: None,
2587 test_before_acquire: None,
2588 }),
2589 ..Default::default()
2590 },
2591 );
2592
2593 add_gear_to_app(
2595 &mut app,
2596 "test_gear",
2597 &serde_json::json!({
2598 "server": "test_server",
2599 "pool": {
2600 "max_conns": 20
2601 }
2602 }),
2603 );
2604
2605 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2606 assert!(result.is_some());
2607
2608 let (_dsn, pool) = result.unwrap();
2609 assert_eq!(pool.max_conns, Some(20)); assert_eq!(pool.acquire_timeout, Some(Duration::from_secs(5))); }
2612
2613 #[test]
2614 fn test_pool_config_gear_overrides_all() {
2615 use std::time::Duration;
2616
2617 let tmp = tempdir().unwrap();
2618 let home_dir = tmp.path();
2619
2620 let mut app = create_app_with_server(
2622 "test_server",
2623 DbConnConfig {
2624 host: Some("localhost".to_owned()),
2625 dbname: Some("testdb".to_owned()),
2626 pool: Some(PoolCfg {
2627 max_conns: Some(10),
2628 min_conns: None,
2629 acquire_timeout: Some(Duration::from_secs(5)),
2630 idle_timeout: None,
2631 max_lifetime: None,
2632 test_before_acquire: None,
2633 }),
2634 ..Default::default()
2635 },
2636 );
2637
2638 add_gear_to_app(
2640 &mut app,
2641 "test_gear",
2642 &serde_json::json!({
2643 "server": "test_server",
2644 "pool": {
2645 "max_conns": 30,
2646 "acquire_timeout": "10s"
2647 }
2648 }),
2649 );
2650
2651 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2652 assert!(result.is_some());
2653
2654 let (_dsn, pool) = result.unwrap();
2655 assert_eq!(pool.max_conns, Some(30));
2656 assert_eq!(pool.acquire_timeout, Some(Duration::from_secs(10)));
2657 }
2658
2659 #[test]
2660 fn test_list_gear_names() {
2661 let mut app = create_minimal_app();
2662 add_gear_with_config(&mut app, "zebra_gear", &serde_json::json!({}));
2663 add_gear_with_config(&mut app, "alpha_gear", &serde_json::json!({}));
2664 add_gear_with_config(&mut app, "beta_gear", &serde_json::json!({}));
2665
2666 let gear_names = list_gear_names(&app);
2667
2668 assert_eq!(gear_names.len(), 3);
2670 assert_eq!(gear_names[0], "alpha_gear");
2671 assert_eq!(gear_names[1], "beta_gear");
2672 assert_eq!(gear_names[2], "zebra_gear");
2673 }
2674
2675 #[test]
2676 fn test_list_gear_names_empty() {
2677 let app = create_minimal_app();
2678 let gear_names = list_gear_names(&app);
2679 assert_eq!(gear_names.len(), 0);
2680 }
2681
2682 #[test]
2683 fn test_redact_dsn_password_postgres() {
2684 let dsn = "postgres://user:secretpass@localhost:5432/mydb";
2685 let redacted = redact_dsn_password(dsn).unwrap();
2686 assert_eq!(
2687 redacted,
2688 "postgres://user:***REDACTED***@localhost:5432/mydb"
2689 );
2690 }
2691
2692 #[test]
2693 fn test_redact_dsn_password_no_password() {
2694 let dsn = "postgres://user@localhost:5432/mydb";
2695 let redacted = redact_dsn_password(dsn).unwrap();
2696 assert_eq!(redacted, "postgres://user@localhost:5432/mydb");
2698 }
2699
2700 #[test]
2701 fn test_redact_dsn_password_special_chars() {
2702 let dsn = "postgres://user:p@ss%40word@localhost:5432/mydb";
2703 let redacted = redact_dsn_password(dsn).unwrap();
2704 assert_eq!(
2705 redacted,
2706 "postgres://user:***REDACTED***@localhost:5432/mydb"
2707 );
2708 }
2709
2710 #[test]
2711 fn test_render_effective_gears_config() {
2712 let mut app = create_minimal_app();
2713 add_gear_with_config(
2714 &mut app,
2715 "test_gear",
2716 &serde_json::json!({
2717 "my_setting": "my_value",
2718 "enabled": true
2719 }),
2720 );
2721
2722 let result = render_effective_gears_config(&app).unwrap();
2723
2724 assert!(result.is_object());
2726 let gears = result.as_object().unwrap();
2727 assert!(gears.contains_key("test_gear"));
2728
2729 let test_gear = gears.get("test_gear").unwrap();
2730 assert!(test_gear.is_object());
2731 let test_gear_obj = test_gear.as_object().unwrap();
2732
2733 assert!(test_gear_obj.contains_key("config"));
2735
2736 let config = test_gear_obj.get("config").unwrap();
2738 assert_eq!(config.get("my_setting").unwrap(), "my_value");
2739 assert_eq!(config.get("enabled").unwrap(), true);
2740 }
2741
2742 #[test]
2743 fn test_render_effective_gears_config_with_database() {
2744 let mut app = create_app_with_server(
2745 "test_server",
2746 DbConnConfig {
2747 host: Some("localhost".to_owned()),
2748 port: Some(5432),
2749 user: Some("user".to_owned()),
2750 password: Some(toolkit_utils::SecretString::new("pass")),
2751 dbname: Some("db".to_owned()),
2752 ..Default::default()
2753 },
2754 );
2755
2756 add_gear_to_app(
2758 &mut app,
2759 "test_gear",
2760 &serde_json::json!({
2761 "server": "test_server"
2762 }),
2763 );
2764
2765 let result = render_effective_gears_config(&app).unwrap();
2766 let gears = result.as_object().unwrap();
2767 let test_gear = gears.get("test_gear").unwrap().as_object().unwrap();
2768
2769 assert!(test_gear.contains_key("database"));
2771 let database = test_gear.get("database").unwrap().as_object().unwrap();
2772 assert!(database.contains_key("dsn"));
2773
2774 let dsn = database.get("dsn").unwrap().as_str().unwrap();
2776 assert!(dsn.contains("***REDACTED***"));
2777 assert!(!dsn.contains("pass"));
2778 }
2779
2780 #[test]
2781 fn test_render_effective_gears_config_minimal() {
2782 let mut app = create_minimal_app();
2784
2785 app.gears
2787 .insert("minimal_gear".to_owned(), serde_json::json!({}));
2788
2789 let result = render_effective_gears_config(&app).unwrap();
2790
2791 assert!(result.is_object());
2794 }
2795
2796 #[test]
2797 fn test_dump_effective_gears_config_yaml() {
2798 let mut app = create_minimal_app();
2799 add_gear_with_config(
2800 &mut app,
2801 "test_gear",
2802 &serde_json::json!({
2803 "setting": "value"
2804 }),
2805 );
2806
2807 let yaml = dump_effective_gears_config_yaml(&app).unwrap();
2808
2809 assert!(yaml.contains("test_gear:"));
2811 assert!(yaml.contains("config:"));
2812 assert!(yaml.contains("setting: value"));
2813 }
2814
2815 #[test]
2816 fn test_dump_effective_gears_config_json() {
2817 let mut app = create_minimal_app();
2818 add_gear_with_config(
2819 &mut app,
2820 "test_gear",
2821 &serde_json::json!({
2822 "setting": "value"
2823 }),
2824 );
2825
2826 let json = dump_effective_gears_config_json(&app).unwrap();
2827
2828 assert!(json.contains("\"test_gear\""));
2830 assert!(json.contains("\"config\""));
2831 assert!(json.contains("\"setting\""));
2832 assert!(json.contains("\"value\""));
2833
2834 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
2836 assert!(parsed.is_object());
2837 }
2838
2839 #[test]
2840 fn test_render_multiple_gears() {
2841 let mut app = create_minimal_app();
2842 add_gear_with_config(&mut app, "gear_a", &serde_json::json!({"a": 1}));
2843 add_gear_with_config(&mut app, "gear_b", &serde_json::json!({"b": 2}));
2844 add_gear_with_config(&mut app, "gear_c", &serde_json::json!({"c": 3}));
2845
2846 let result = render_effective_gears_config(&app).unwrap();
2847 let gears = result.as_object().unwrap();
2848
2849 assert_eq!(gears.len(), 3);
2850 assert!(gears.contains_key("gear_a"));
2851 assert!(gears.contains_key("gear_b"));
2852 assert!(gears.contains_key("gear_c"));
2853 }
2854
2855 #[derive(Debug, Deserialize, Default, PartialEq)]
2858 struct TestVendorConfig {
2859 #[serde(default)]
2860 api_token: String,
2861 #[serde(default)]
2862 api_url: String,
2863 }
2864
2865 #[test]
2866 fn test_vendor_section_parses_from_yaml() {
2867 let yaml = r#"
2868server:
2869 home_dir: "~/.test_vendor"
2870vendor:
2871 acme:
2872 api_token: "acme-token-123"
2873 api_url: "https://acme.example.com"
2874 other_corp:
2875 api_token: "other-token-789"
2876 api_url: "https://other.example.com"
2877"#;
2878 let config: AppConfig = serde_saphyr::from_str(yaml).unwrap();
2879 assert_eq!(config.vendor.len(), 2);
2880 assert!(config.vendor.contains_key("acme"));
2881 assert!(config.vendor.contains_key("other_corp"));
2882
2883 let acme: TestVendorConfig = config.vendor_config("acme").unwrap();
2884 assert_eq!(acme.api_token, "acme-token-123");
2885 assert_eq!(acme.api_url, "https://acme.example.com");
2886
2887 let other: TestVendorConfig = config.vendor_config("other_corp").unwrap();
2888 assert_eq!(other.api_token, "other-token-789");
2889 assert_eq!(other.api_url, "https://other.example.com");
2890 }
2891
2892 #[test]
2893 fn test_vendor_section_defaults_to_empty() {
2894 let config = AppConfig::default();
2895 assert!(config.vendor.is_empty());
2896 }
2897
2898 #[test]
2899 fn test_vendor_config_typed_access() {
2900 let mut config = AppConfig::default();
2901 config.vendor.insert(
2902 "acme".to_owned(),
2903 serde_json::json!({
2904 "api_token": "acme-token-123",
2905 "api_url": "https://acme.example.com"
2906 }),
2907 );
2908
2909 let acme: TestVendorConfig = config.vendor_config("acme").unwrap();
2910 assert_eq!(acme.api_token, "acme-token-123");
2911 assert_eq!(acme.api_url, "https://acme.example.com");
2912 }
2913
2914 #[test]
2915 fn test_vendor_config_not_found() {
2916 let config = AppConfig::default();
2917 let result: Result<TestVendorConfig, _> = config.vendor_config("nonexistent");
2918 assert!(matches!(
2919 result,
2920 Err(VendorConfigError::NotFound { ref vendor }) if vendor == "nonexistent"
2921 ));
2922 }
2923
2924 #[test]
2925 fn test_vendor_config_invalid_structure() {
2926 let mut config = AppConfig::default();
2927 config
2928 .vendor
2929 .insert("bad".to_owned(), serde_json::json!("not an object"));
2930
2931 let result: Result<TestVendorConfig, _> = config.vendor_config("bad");
2932 assert!(matches!(
2933 result,
2934 Err(VendorConfigError::InvalidConfig { ref vendor, .. }) if vendor == "bad"
2935 ));
2936 }
2937
2938 #[test]
2939 fn test_vendor_config_or_default_missing() {
2940 let config = AppConfig::default();
2941 let acme: TestVendorConfig = config.vendor_config_or_default("acme").unwrap();
2942 assert_eq!(acme, TestVendorConfig::default());
2943 }
2944
2945 #[test]
2946 fn test_vendor_config_or_default_present() {
2947 let mut config = AppConfig::default();
2948 config.vendor.insert(
2949 "acme".to_owned(),
2950 serde_json::json!({ "api_token": "acme-token-123" }),
2951 );
2952
2953 let acme: TestVendorConfig = config.vendor_config_or_default("acme").unwrap();
2954 assert_eq!(acme.api_token, "acme-token-123");
2955 }
2956
2957 #[test]
2958 #[serial]
2959 fn test_vendor_config_env_override() {
2960 let tmp = tempdir().unwrap();
2961 let cfg_path = tmp.path().join("cfg.yaml");
2962 let yaml = r#"
2963server:
2964 home_dir: "~/.test_vendor"
2965vendor:
2966 env_test_vendor:
2967 api_token: "from_yaml"
2968"#;
2969 fs::write(&cfg_path, yaml).unwrap();
2970
2971 with_var(
2972 "APP__VENDOR__ENV_TEST_VENDOR__API_TOKEN",
2973 Some("from_env"),
2974 || {
2975 let config = AppConfig::load_layered(&cfg_path).unwrap();
2976 let v: TestVendorConfig = config.vendor_config("env_test_vendor").unwrap();
2977 assert_eq!(v.api_token, "from_env");
2978 },
2979 );
2980 }
2981
2982 #[test]
2983 #[serial]
2984 fn test_gear_config_env_override_underscore_gear_name() {
2985 let tmp = tempdir().unwrap();
2988 let cfg_path = tmp.path().join("cfg.yaml");
2989 let yaml = r#"
2990server:
2991 home_dir: "~/.test_gear_env_underscore"
2992gears:
2993 static-authz-plugin:
2994 config:
2995 vendor: "from_yaml"
2996"#;
2997 fs::write(&cfg_path, yaml).unwrap();
2998
2999 with_var(
3000 "APP__GEARS__STATIC_AUTHZ_PLUGIN__CONFIG__VENDOR",
3001 Some("acme"),
3002 || {
3003 let config = AppConfig::load_layered(&cfg_path).unwrap();
3004 let gear = config.gears.get("static-authz-plugin").unwrap();
3005 assert_eq!(gear["config"]["vendor"], serde_json::json!("acme"));
3006 },
3007 );
3008 }
3009
3010 #[test]
3011 #[serial]
3012 fn test_gear_config_env_override_dash_gear_name_backcompat() {
3013 let tmp = tempdir().unwrap();
3015 let cfg_path = tmp.path().join("cfg.yaml");
3016 let yaml = r#"
3017server:
3018 home_dir: "~/.test_gear_env_dash"
3019gears:
3020 static-authz-plugin:
3021 config:
3022 vendor: "from_yaml"
3023"#;
3024 fs::write(&cfg_path, yaml).unwrap();
3025
3026 with_var(
3027 "APP__GEARS__static-authz-plugin__CONFIG__VENDOR",
3028 Some("acme"),
3029 || {
3030 let config = AppConfig::load_layered(&cfg_path).unwrap();
3031 let gear = config.gears.get("static-authz-plugin").unwrap();
3032 assert_eq!(gear["config"]["vendor"], serde_json::json!("acme"));
3033 },
3034 );
3035 }
3036
3037 #[test]
3038 #[serial]
3039 fn test_gear_config_env_override_preserves_field_underscores() {
3040 let tmp = tempdir().unwrap();
3042 let cfg_path = tmp.path().join("cfg.yaml");
3043 let yaml = r#"
3044server:
3045 home_dir: "~/.test_gear_env_field"
3046gears:
3047 static-authz-plugin:
3048 config:
3049 some_field: "from_yaml"
3050"#;
3051 fs::write(&cfg_path, yaml).unwrap();
3052
3053 with_var(
3054 "APP__GEARS__STATIC_AUTHZ_PLUGIN__CONFIG__SOME_FIELD",
3055 Some("from_env"),
3056 || {
3057 let config = AppConfig::load_layered(&cfg_path).unwrap();
3058 let gear = config.gears.get("static-authz-plugin").unwrap();
3059 assert_eq!(gear["config"]["some_field"], serde_json::json!("from_env"));
3060 },
3061 );
3062 }
3063
3064 #[test]
3065 #[serial]
3066 fn test_vendor_config_env_override_unaffected_by_gear_remap() {
3067 let tmp = tempdir().unwrap();
3072 let cfg_path = tmp.path().join("cfg.yaml");
3073 let yaml = r#"
3074server:
3075 home_dir: "~/.test_vendor_unaffected"
3076vendor:
3077 env_test_vendor:
3078 api_token: "from_yaml"
3079"#;
3080 fs::write(&cfg_path, yaml).unwrap();
3081
3082 with_var(
3083 "APP__VENDOR__ENV_TEST_VENDOR__API_TOKEN",
3084 Some("from_env"),
3085 || {
3086 let config = AppConfig::load_layered(&cfg_path).unwrap();
3087
3088 let v: TestVendorConfig = config.vendor_config("env_test_vendor").unwrap();
3090 assert_eq!(v.api_token, "from_env");
3091
3092 assert!(
3095 config.vendor.contains_key("env_test_vendor"),
3096 "underscore vendor key must be preserved"
3097 );
3098 assert!(
3099 !config.vendor.contains_key("env-test-vendor"),
3100 "gear remap leaked into the vendor branch"
3101 );
3102 },
3103 );
3104 }
3105
3106 #[test]
3107 fn test_vendor_multiple_vendors_typed_access() {
3108 let mut config = AppConfig::default();
3109 config.vendor.insert(
3110 "acme".to_owned(),
3111 serde_json::json!({ "api_token": "acme-token", "api_url": "https://acme.com" }),
3112 );
3113 config.vendor.insert(
3114 "other_corp".to_owned(),
3115 serde_json::json!({ "api_token": "other-token", "api_url": "https://other.com" }),
3116 );
3117
3118 let acme: TestVendorConfig = config.vendor_config("acme").unwrap();
3119 let other: TestVendorConfig = config.vendor_config("other_corp").unwrap();
3120
3121 assert_eq!(acme.api_token, "acme-token");
3122 assert_eq!(other.api_token, "other-token");
3123 assert_eq!(acme.api_url, "https://acme.com");
3124 assert_eq!(other.api_url, "https://other.com");
3125 }
3126
3127 #[test]
3128 fn test_vendor_nested_config() {
3129 #[derive(Debug, Deserialize, PartialEq)]
3130 struct NestedVendorConfig {
3131 api_url: String,
3132 feature_flags: FeatureFlags,
3133 }
3134
3135 #[derive(Debug, Deserialize, PartialEq)]
3136 struct FeatureFlags {
3137 beta_mode: bool,
3138 max_retries: u32,
3139 }
3140
3141 let mut config = AppConfig::default();
3142 config.vendor.insert(
3143 "acme".to_owned(),
3144 serde_json::json!({
3145 "api_url": "https://acme.com",
3146 "feature_flags": {
3147 "beta_mode": true,
3148 "max_retries": 3
3149 }
3150 }),
3151 );
3152
3153 let acme: NestedVendorConfig = config.vendor_config("acme").unwrap();
3154 assert_eq!(acme.api_url, "https://acme.com");
3155 assert!(acme.feature_flags.beta_mode);
3156 assert_eq!(acme.feature_flags.max_retries, 3);
3157 }
3158
3159 #[test]
3160 fn test_vendor_config_or_default_invalid_returns_error() {
3161 let mut config = AppConfig::default();
3162 config
3163 .vendor
3164 .insert("bad".to_owned(), serde_json::json!("not an object"));
3165
3166 let result: Result<TestVendorConfig, _> = config.vendor_config_or_default("bad");
3167 assert!(matches!(
3168 result,
3169 Err(VendorConfigError::InvalidConfig { ref vendor, .. }) if vendor == "bad"
3170 ));
3171 }
3172
3173 #[test]
3174 fn test_vendor_config_yaml_roundtrip() {
3175 let mut config = AppConfig::default();
3176 config.vendor.insert(
3177 "acme".to_owned(),
3178 serde_json::json!({ "api_token": "acme-token-123" }),
3179 );
3180
3181 let yaml = config.to_yaml().unwrap();
3182 assert!(yaml.contains("vendor"));
3183 assert!(yaml.contains("acme"));
3184 assert!(yaml.contains("acme-token-123"));
3185 }
3186
3187 #[test]
3188 fn test_vendor_coexists_with_gears() {
3189 let mut config = AppConfig::default();
3190 config.gears.insert(
3191 "my_gear".to_owned(),
3192 serde_json::json!({ "config": { "some_setting": true } }),
3193 );
3194 config.vendor.insert(
3195 "acme".to_owned(),
3196 serde_json::json!({ "api_token": "acme-token-123" }),
3197 );
3198
3199 assert!(config.gears.contains_key("my_gear"));
3200 assert!(config.vendor.contains_key("acme"));
3201
3202 let acme: TestVendorConfig = config.vendor_config("acme").unwrap();
3203 assert_eq!(acme.api_token, "acme-token-123");
3204 }
3205
3206 #[test]
3207 fn test_vendor_error_display_messages() {
3208 let not_found = VendorConfigError::NotFound {
3209 vendor: "acme".to_owned(),
3210 };
3211 assert_eq!(
3212 not_found.to_string(),
3213 "vendor 'acme' not found in configuration"
3214 );
3215
3216 let invalid = VendorConfigError::InvalidConfig {
3217 vendor: "bad".to_owned(),
3218 source: serde_json::from_str::<TestVendorConfig>("invalid").unwrap_err(),
3219 };
3220 let msg = invalid.to_string();
3221 assert!(msg.starts_with("invalid config for vendor 'bad':"));
3222 }
3223
3224 #[test]
3225 fn test_vendor_empty_object_in_yaml() {
3226 let yaml = r#"
3227server:
3228 home_dir: "~/.test_vendor"
3229vendor: {}
3230"#;
3231 let config: AppConfig = serde_saphyr::from_str(yaml).unwrap();
3232 assert!(config.vendor.is_empty());
3233 }
3234
3235 #[test]
3238 fn test_reject_duplicate_gear_names() {
3239 let tmp = tempdir().unwrap();
3240 let cfg_path = tmp.path().join("cfg.yaml");
3241 let yaml = r#"
3242server:
3243 home_dir: "~/.test_dup"
3244gears:
3245 gear1:
3246 config: {}
3247 gear2:
3248 config: {}
3249 gear1:
3250 config: {}
3251"#;
3252 fs::write(&cfg_path, yaml).unwrap();
3253
3254 let result = AppConfig::load_layered(&cfg_path);
3255 assert!(result.is_err(), "duplicate gear names should be rejected");
3256 let msg = format!("{:?}", result.unwrap_err());
3257 assert!(
3258 msg.contains("duplicate") || msg.contains("Duplicate"),
3259 "error should mention duplicates: {msg}"
3260 );
3261 }
3262
3263 #[test]
3264 fn test_reject_duplicate_keys_in_gear_file() {
3265 let tmp = tempdir().unwrap();
3266 let gears_dir = tmp.path().join("gears.d");
3267 fs::create_dir_all(&gears_dir).unwrap();
3268
3269 let gear_yaml = r#"
3271config:
3272 key1: "value1"
3273config:
3274 key2: "value2"
3275"#;
3276 fs::write(gears_dir.join("bad_gear.yaml"), gear_yaml).unwrap();
3277
3278 let cfg_yaml = format!(
3279 r#"
3280server:
3281 home_dir: "~/.test_dup_modfile"
3282gears_dir: "{}"
3283"#,
3284 normalize_path(&gears_dir)
3285 );
3286 let cfg_path = tmp.path().join("cfg.yaml");
3287 fs::write(&cfg_path, cfg_yaml).unwrap();
3288
3289 let result = AppConfig::load_layered(&cfg_path);
3290 assert!(
3291 result.is_err(),
3292 "duplicate keys in a gear file should be rejected"
3293 );
3294 let msg = format!("{:?}", result.unwrap_err());
3295 assert!(
3296 msg.contains("duplicate") || msg.contains("Duplicate"),
3297 "error should mention duplicates: {msg}"
3298 );
3299 }
3300
3301 #[test]
3302 fn test_no_false_positive_on_unique_gears() {
3303 let tmp = tempdir().unwrap();
3304 let cfg_path = tmp.path().join("cfg.yaml");
3305 let yaml = r#"
3306server:
3307 home_dir: "~/.test_ok"
3308gears:
3309 gear1:
3310 config: {}
3311 gear2:
3312 config: {}
3313 gear3:
3314 config: {}
3315"#;
3316 fs::write(&cfg_path, yaml).unwrap();
3317
3318 let result = AppConfig::load_layered(&cfg_path);
3319 assert!(
3320 result.is_ok(),
3321 "unique gear names should be accepted: {:?}",
3322 result.unwrap_err()
3323 );
3324 }
3325}
3326
3327