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