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
306impl AppConfig {
307 pub fn load_layered(config_path: &PathBuf) -> Result<Self> {
313 use figment::{
314 Figment,
315 providers::{Env, Format, Serialized},
316 };
317
318 let figment = Figment::new()
322 .merge(Serialized::defaults(AppConfig::default()))
323 .merge(StrictYaml::file(config_path))
324 .merge(Env::prefixed("APP__").split("__"));
326
327 let mut config: AppConfig = figment
328 .extract()
329 .with_context(|| "Failed to extract config from figment".to_owned())?;
330
331 config
333 .server
334 .normalize_home_dir_inplace()
335 .context("Failed to resolve server.home_dir")?;
336
337 if let Some(dir) = config.gears_dir.as_ref() {
339 merge_gear_files(&mut config.gears, dir)?;
340 }
341
342 Ok(config)
343 }
344
345 pub fn load_or_default(config_path: Option<&PathBuf>) -> Result<Self> {
351 if let Some(path) = config_path {
352 ensure!(
353 path.is_file(),
354 "config file does not exist: {}",
355 path.to_string_lossy()
356 );
357 Self::load_layered(path)
358 } else {
359 let mut c = Self::default();
360 c.server
361 .normalize_home_dir_inplace()
362 .context("Failed to resolve server.home_dir (defaults)")?;
363 Ok(c)
364 }
365 }
366
367 pub fn to_yaml(&self) -> Result<String> {
372 serde_saphyr::to_string(self).context("Failed to serialize config to YAML")
373 }
374
375 pub fn vendor_config<T: DeserializeOwned>(
381 &self,
382 vendor_name: &str,
383 ) -> Result<T, VendorConfigError> {
384 let raw = self
385 .vendor
386 .get(vendor_name)
387 .ok_or_else(|| VendorConfigError::NotFound {
388 vendor: vendor_name.to_owned(),
389 })?;
390 T::deserialize(raw).map_err(|e| VendorConfigError::InvalidConfig {
391 vendor: vendor_name.to_owned(),
392 source: e,
393 })
394 }
395
396 pub fn vendor_config_or_default<T: DeserializeOwned + Default>(
402 &self,
403 vendor_name: &str,
404 ) -> Result<T, VendorConfigError> {
405 let Some(raw) = self.vendor.get(vendor_name) else {
406 return Ok(T::default());
407 };
408 T::deserialize(raw).map_err(|e| VendorConfigError::InvalidConfig {
409 vendor: vendor_name.to_owned(),
410 source: e,
411 })
412 }
413
414 pub fn apply_cli_overrides(&mut self, verbose: u8) {
416 if let Some(default_section) = self.logging.get_mut("default") {
418 default_section.console_level = match verbose {
419 0 => default_section.console_level, 1 => Some(Level::DEBUG),
421 _ => Some(Level::TRACE),
422 };
423 }
424 }
425}
426
427#[derive(Debug, Clone)]
429pub struct CliArgs {
430 pub config: Option<String>,
431 pub print_config: bool,
432 pub verbose: u8,
433 pub mock: bool,
434}
435
436fn strict_yaml_parse<T: serde::de::DeserializeOwned>(s: &str) -> Result<T, serde_saphyr::Error> {
438 let opts = serde_saphyr::Options {
439 duplicate_keys: serde_saphyr::DuplicateKeyPolicy::Error,
440 ..serde_saphyr::Options::default()
441 };
442 serde_saphyr::from_str_with_options(s, opts)
443}
444
445struct StrictYaml;
451
452impl figment::providers::Format for StrictYaml {
453 type Error = serde_saphyr::Error;
454
455 const NAME: &'static str = "YAML";
456
457 fn from_str<T: serde::de::DeserializeOwned>(s: &str) -> Result<T, Self::Error> {
458 strict_yaml_parse(s)
459 }
460}
461
462fn merge_gear_files(
463 bag: &mut HashMap<String, serde_json::Value>,
464 dir: impl AsRef<Path>,
465) -> Result<()> {
466 use std::fs;
467 let dir = dir.as_ref();
468 if !dir.exists() {
469 return Ok(());
470 }
471 for entry in fs::read_dir(dir)? {
472 let entry = entry?;
473 let path = entry.path();
474 if !path.is_file() {
475 continue;
476 }
477 let ext = path
478 .extension()
479 .and_then(|s| s.to_str())
480 .unwrap_or("")
481 .to_ascii_lowercase();
482 if ext != "yml" && ext != "yaml" {
483 continue;
484 }
485 let name = path
486 .file_stem()
487 .and_then(|s| s.to_str())
488 .unwrap_or("")
489 .to_owned();
490 let raw = fs::read_to_string(&path)?;
491 let json: serde_json::Value = strict_yaml_parse(&raw)
492 .with_context(|| format!("failed to parse gear file: {}", path.display()))?;
493 bag.insert(name, json);
494 }
495 Ok(())
496}
497
498pub fn expand_env_in_dsn(dsn: &str) -> Result<String> {
506 toolkit_utils::var_expand::expand_env_vars(dsn).map_err(|e| anyhow::anyhow!("{e}"))
507}
508
509pub fn resolve_password(password: Option<&str>) -> Result<Option<String>> {
514 if let Some(pwd) = password {
515 if pwd.starts_with("${") && pwd.ends_with('}') {
516 let var_name = &pwd[2..pwd.len() - 1];
518 let resolved = std::env::var(var_name).with_context(|| {
519 format!("Environment variable '{var_name}' not found for password")
520 })?;
521 Ok(Some(resolved))
522 } else {
523 Ok(Some(pwd.to_owned()))
525 }
526 } else {
527 Ok(None)
528 }
529}
530
531pub fn validate_dsn(dsn: &str) -> Result<()> {
537 if dsn.starts_with("sqlite:") {
539 return Ok(());
540 }
541
542 let _parsed = dsn::parse(dsn).map_err(|e| anyhow::anyhow!("Invalid DSN '{dsn}': {e}"))?;
543
544 Ok(())
545}
546
547fn resolve_sqlite_dsn(
552 dsn: &str,
553 home_dir: &Path,
554 gear_name: &str,
555 dry_run: bool,
556) -> Result<String> {
557 if dsn.contains("@file(") {
558 if let Some(start) = dsn.find("@file(")
560 && let Some(end) = dsn[start..].find(')')
561 {
562 let file_path = &dsn[start + 6..start + end]; let resolved_path = if file_path.starts_with('/')
565 || (file_path.len() > 1 && file_path.chars().nth(1) == Some(':'))
566 {
567 PathBuf::from(file_path)
569 } else {
570 let gear_dir = home_dir.join(gear_name);
572 if !dry_run {
573 std::fs::create_dir_all(&gear_dir).with_context(|| {
574 format!("Failed to create gear directory: {}", gear_dir.display())
575 })?;
576 }
577 gear_dir.join(file_path)
578 };
579
580 let normalized_path = normalize_path(&resolved_path);
581 if normalized_path.len() > 1 && normalized_path.chars().nth(1) == Some(':') {
584 return Ok(format!("sqlite:{normalized_path}"));
586 }
587 return Ok(format!("sqlite://{normalized_path}"));
589 }
590 return Err(anyhow::anyhow!(
591 "Invalid @file() syntax in SQLite DSN: {dsn}"
592 ));
593 }
594
595 if dsn == "sqlite://" || dsn == "sqlite:///" || dsn == "sqlite:" {
597 let gear_dir = home_dir.join(gear_name);
598 if !dry_run {
599 std::fs::create_dir_all(&gear_dir).with_context(|| {
600 format!("Failed to create gear directory: {}", gear_dir.display())
601 })?;
602 }
603 let db_path = gear_dir.join(format!("{gear_name}.sqlite"));
604 let normalized_path = normalize_path(&db_path);
605 if normalized_path.len() > 1 && normalized_path.chars().nth(1) == Some(':') {
608 return Ok(format!("sqlite:{normalized_path}"));
610 }
611 return Ok(format!("sqlite://{normalized_path}"));
613 }
614
615 Ok(dsn.to_owned())
617}
618
619fn build_server_dsn(
623 scheme: &str,
624 host: Option<&str>,
625 port: Option<u16>,
626 user: Option<&str>,
627 password: Option<&str>,
628 dbname: Option<&str>,
629 params: &HashMap<String, String>,
630) -> Result<String> {
631 let host = host.unwrap_or("localhost");
632 let user = user.unwrap_or("postgres"); let mut url = Url::parse(&format!("{scheme}://dummy/"))
636 .with_context(|| format!("Invalid scheme: {scheme}"))?;
637
638 url.set_host(Some(host))
640 .with_context(|| format!("Invalid host: {host}"))?;
641
642 if let Some(port) = port {
644 url.set_port(Some(port))
645 .map_err(|()| anyhow::anyhow!("Invalid port: {port}"))?;
646 }
647
648 url.set_username(user)
650 .map_err(|()| anyhow::anyhow!("Failed to set username: {user}"))?;
651
652 if let Some(password) = password {
654 url.set_password(Some(password))
655 .map_err(|()| anyhow::anyhow!("Failed to set password"))?;
656 }
657
658 if let Some(dbname) = dbname {
660 let encoded_dbname = urlencoding::encode(dbname);
662 url.set_path(&format!("/{encoded_dbname}"));
663 } else {
664 url.set_path("/");
665 }
666
667 if !params.is_empty() {
669 let mut query_pairs = url.query_pairs_mut();
671 for (key, value) in params {
672 query_pairs.append_pair(key, value);
673 }
674 }
675
676 Ok(url.to_string())
677}
678
679fn build_sqlite_dsn_with_dbname_override(
681 original_dsn: &str,
682 dbname: &str,
683 gear_name: &str,
684 home_dir: &Path,
685 dry_run: bool,
686) -> Result<String> {
687 let query_params = if let Some(query_start) = original_dsn.find('?') {
689 &original_dsn[query_start..]
690 } else {
691 ""
692 };
693
694 let gear_dir = home_dir.join(gear_name);
696 if !dry_run {
697 std::fs::create_dir_all(&gear_dir)
698 .with_context(|| format!("Failed to create gear directory: {}", gear_dir.display()))?;
699 }
700 let db_path = gear_dir.join(dbname);
701 let normalized_path = normalize_path(&db_path);
702
703 let dsn_base = if normalized_path.len() > 1 && normalized_path.chars().nth(1) == Some(':') {
705 format!("sqlite:{normalized_path}")
707 } else {
708 format!("sqlite://{normalized_path}")
710 };
711
712 Ok(format!("{dsn_base}{query_params}"))
713}
714
715fn build_sqlite_dsn(
721 dsn: Option<&str>,
722 file: Option<&str>,
723 path: Option<&PathBuf>,
724 dbname: Option<&str>,
725 gear_name: &str,
726 home_dir: &Path,
727 dry_run: bool,
728) -> Result<String> {
729 if let Some(dsn) = dsn {
731 let resolved_dsn = resolve_sqlite_dsn(dsn, home_dir, gear_name, dry_run)?;
732
733 if let Some(dbname) = dbname {
735 return build_sqlite_dsn_with_dbname_override(
736 &resolved_dsn,
737 dbname,
738 gear_name,
739 home_dir,
740 dry_run,
741 );
742 }
743
744 validate_dsn(&resolved_dsn)?;
745 return Ok(resolved_dsn);
746 }
747
748 if let Some(path) = path {
750 let absolute_path = if path.is_absolute() {
751 path.clone()
752 } else {
753 home_dir.join(path)
754 };
755 let normalized_path = normalize_path(&absolute_path);
756 if normalized_path.len() > 1 && normalized_path.chars().nth(1) == Some(':') {
759 return Ok(format!("sqlite:{normalized_path}"));
761 }
762 return Ok(format!("sqlite://{normalized_path}"));
764 }
765
766 if let Some(file) = file {
768 let gear_dir = home_dir.join(gear_name);
769 if !dry_run {
770 std::fs::create_dir_all(&gear_dir).with_context(|| {
771 format!("Failed to create gear directory: {}", gear_dir.display())
772 })?;
773 }
774 let db_path = gear_dir.join(file);
775 let normalized_path = normalize_path(&db_path);
776 if normalized_path.len() > 1 && normalized_path.chars().nth(1) == Some(':') {
779 return Ok(format!("sqlite:{normalized_path}"));
781 }
782 return Ok(format!("sqlite://{normalized_path}"));
784 }
785
786 let gear_dir = home_dir.join(gear_name);
788 if !dry_run {
789 std::fs::create_dir_all(&gear_dir)
790 .with_context(|| format!("Failed to create gear directory: {}", gear_dir.display()))?;
791 }
792 let db_path = gear_dir.join(format!("{gear_name}.sqlite"));
793 let normalized_path = normalize_path(&db_path);
794 if normalized_path.len() > 1 && normalized_path.chars().nth(1) == Some(':') {
797 Ok(format!("sqlite:{normalized_path}"))
799 } else {
800 Ok(format!("sqlite://{normalized_path}"))
802 }
803}
804
805type DbConfigResult = Result<Option<(String , PoolCfg)>>;
807
808#[derive(Default)]
810struct DbConfigBuilder {
811 dsn: Option<String>,
812 host: Option<String>,
813 port: Option<u16>,
814 user: Option<String>,
815 password: Option<String>,
816 dbname: Option<String>,
817 params: HashMap<String, String>,
818 pool: PoolCfg,
819}
820
821impl DbConfigBuilder {
822 fn new() -> Self {
823 Self::default()
824 }
825
826 fn apply_global_server(
828 &mut self,
829 global_server: &DbConnConfig,
830 home_dir: &Path,
831 gear_name: &str,
832 dry_run: bool,
833 ) -> Result<()> {
834 if let Some(global_dsn) = &global_server.dsn {
836 let expanded_dsn = expand_env_in_dsn(global_dsn)?;
837 let resolved_dsn = if expanded_dsn.starts_with("sqlite") {
839 resolve_sqlite_dsn(&expanded_dsn, home_dir, gear_name, dry_run)?
840 } else {
841 expanded_dsn
842 };
843 validate_dsn(&resolved_dsn)?;
844 self.dsn = Some(resolved_dsn);
845 }
846
847 if let Some(host) = &global_server.host {
849 self.host = Some(host.clone());
850 }
851 if let Some(port) = global_server.port {
852 self.port = Some(port);
853 }
854 if let Some(user) = &global_server.user {
855 self.user = Some(user.clone());
856 }
857 if let Some(password) = resolve_password(global_server.password.as_deref())? {
858 self.password = Some(password);
859 }
860 if let Some(dbname) = &global_server.dbname {
861 self.dbname = Some(dbname.clone());
862 }
863 if let Some(params) = &global_server.params {
864 self.params.extend(params.clone());
865 }
866 if let Some(pool) = &global_server.pool {
867 self.pool = pool.clone();
868 }
869
870 Ok(())
871 }
872
873 fn apply_gear_dsn(
875 &mut self,
876 gear_dsn: &str,
877 home_dir: &Path,
878 gear_name: &str,
879 dry_run: bool,
880 ) -> Result<()> {
881 let resolved_dsn = if gear_dsn.starts_with("sqlite") {
883 resolve_sqlite_dsn(gear_dsn, home_dir, gear_name, dry_run)?
884 } else {
885 gear_dsn.to_owned()
886 };
887 validate_dsn(&resolved_dsn)?;
888 self.dsn = Some(resolved_dsn);
889 Ok(())
890 }
891
892 fn apply_gear_fields(&mut self, gear_db_config: &DbConnConfig) -> Result<()> {
894 if let Some(host) = &gear_db_config.host {
895 self.host = Some(host.clone());
896 }
897 if let Some(port) = gear_db_config.port {
898 self.port = Some(port);
899 }
900 if let Some(user) = &gear_db_config.user {
901 self.user = Some(user.clone());
902 }
903 if let Some(password) = resolve_password(gear_db_config.password.as_deref())? {
904 self.password = Some(password);
905 }
906 if let Some(dbname) = &gear_db_config.dbname {
907 self.dbname = Some(dbname.clone());
908 }
909 if let Some(params) = &gear_db_config.params {
910 self.params.extend(params.clone());
911 }
912 if let Some(pool) = &gear_db_config.pool {
913 if let Some(max_conns) = pool.max_conns {
915 self.pool.max_conns = Some(max_conns);
916 }
917 if let Some(acquire_timeout) = pool.acquire_timeout {
918 self.pool.acquire_timeout = Some(acquire_timeout);
919 }
920 }
921 Ok(())
922 }
923
924 fn has_field_overrides(&self) -> bool {
926 self.host.is_some()
927 || self.port.is_some()
928 || self.user.is_some()
929 || self.password.is_some()
930 || !self.params.is_empty()
931 }
932}
933
934fn decide_backend(builder: &DbConfigBuilder, gear_db_config: &DbConnConfig) -> bool {
936 gear_db_config.file.is_some()
939 || gear_db_config.path.is_some()
940 || builder
941 .dsn
942 .as_ref()
943 .is_some_and(|dsn| dsn.starts_with("sqlite"))
944 || (gear_db_config.server.is_none() && builder.dsn.is_none())
945}
946
947fn finalize_sqlite_dsn(
949 builder: &DbConfigBuilder,
950 gear_db_config: &DbConnConfig,
951 gear_name: &str,
952 home_dir: &Path,
953 dry_run: bool,
954) -> Result<String> {
955 build_sqlite_dsn(
956 builder.dsn.as_deref(),
957 gear_db_config.file.as_deref(),
958 gear_db_config.path.as_ref(),
959 builder.dbname.as_deref(),
960 gear_name,
961 home_dir,
962 dry_run,
963 )
964}
965
966fn finalize_server_dsn(builder: &DbConfigBuilder, gear_name: &str) -> Result<String> {
968 let dbname = if let Some(dbname) = builder.dbname.as_deref() {
970 dbname.to_owned()
971 } else if let Some(dsn) = builder.dsn.as_ref() {
972 if let Ok(parsed) = url::Url::parse(dsn) {
974 let path = parsed.path();
975 if path.len() > 1 {
976 path[1..].to_string()
978 } else {
979 return Err(anyhow::anyhow!(
980 "Server-based database config for gear '{gear_name}' missing required 'dbname'"
981 ));
982 }
983 } else {
984 return Err(anyhow::anyhow!(
985 "Server-based database config for gear '{gear_name}' missing required 'dbname'"
986 ));
987 }
988 } else {
989 return Err(anyhow::anyhow!(
990 "Server-based database config for gear '{gear_name}' missing required 'dbname'"
991 ));
992 };
993
994 if builder.has_field_overrides() || builder.dsn.is_none() {
995 let scheme = if let Some(dsn) = &builder.dsn {
997 let parsed = Url::parse(dsn)?;
998 parsed.scheme().to_owned()
999 } else {
1000 "postgresql".to_owned() };
1002
1003 build_server_dsn(
1004 &scheme,
1005 builder.host.as_deref(),
1006 builder.port,
1007 builder.user.as_deref(),
1008 builder.password.as_deref(),
1009 Some(&dbname),
1010 &builder.params,
1011 )
1012 } else if let Some(original_dsn) = &builder.dsn {
1013 if let Ok(mut parsed) = Url::parse(original_dsn) {
1015 let original_dbname = parsed.path().trim_start_matches('/');
1017 if original_dbname != dbname {
1018 parsed.set_path(&format!("/{dbname}"));
1019 }
1020 Ok(parsed.to_string())
1021 } else {
1022 build_server_dsn(
1024 "postgresql",
1025 builder.host.as_deref(),
1026 builder.port,
1027 builder.user.as_deref(),
1028 builder.password.as_deref(),
1029 Some(&dbname),
1030 &builder.params,
1031 )
1032 }
1033 } else {
1034 unreachable!("final_dsn should not be None when has_field_overrides is false")
1036 }
1037}
1038
1039fn redact_dsn_for_logging(dsn: &str) -> Result<String> {
1041 if dsn.contains('@') {
1042 let parsed = Url::parse(dsn)?;
1043 let mut log_url = parsed;
1044 if log_url.password().is_some() {
1045 log_url.set_password(Some("***")).ok();
1046 }
1047 Ok(log_url.to_string())
1048 } else {
1049 Ok(dsn.to_owned())
1050 }
1051}
1052
1053pub const TOOLKIT_MODULE_CONFIG_ENV: &str = "TOOLKIT_MODULE_CONFIG";
1057
1058#[derive(Debug, Clone, Serialize, Deserialize)]
1061pub struct RenderedDbConfig {
1062 #[serde(skip_serializing_if = "Option::is_none")]
1065 pub global: Option<GlobalDatabaseConfig>,
1066 #[serde(skip_serializing_if = "Option::is_none")]
1069 pub gear: Option<DbConnConfig>,
1070}
1071
1072impl RenderedDbConfig {
1073 #[must_use]
1075 pub fn new(global: Option<GlobalDatabaseConfig>, gear: Option<DbConnConfig>) -> Self {
1076 Self { global, gear }
1077 }
1078}
1079
1080#[derive(Debug, Clone, Serialize, Deserialize)]
1090pub struct RenderedGearConfig {
1091 #[serde(skip_serializing_if = "Option::is_none")]
1094 pub database: Option<RenderedDbConfig>,
1095 #[serde(default)]
1097 pub config: serde_json::Value,
1098 #[serde(skip_serializing_if = "Option::is_none")]
1101 pub logging: Option<LoggingConfig>,
1102 #[serde(skip_serializing_if = "Option::is_none")]
1104 pub opentelemetry: Option<OpenTelemetryConfig>,
1105}
1106
1107impl RenderedGearConfig {
1108 pub fn from_json(json: &str) -> Result<Self> {
1113 serde_json::from_str(json).context("Failed to parse RenderedGearConfig from JSON")
1114 }
1115
1116 pub fn to_json(&self) -> Result<String> {
1121 serde_json::to_string(self).context("Failed to serialize RenderedGearConfig to JSON")
1122 }
1123}
1124
1125pub fn render_gear_config_for_oop(
1142 app: &AppConfig,
1143 gear_name: &str,
1144 _home_dir: &std::path::Path,
1145) -> Result<RenderedGearConfig> {
1146 let gear_db_config = parse_gear_config(app, gear_name)
1149 .ok()
1150 .and_then(|entry| entry.database);
1151
1152 let database = if gear_db_config.is_some() || app.database.is_some() {
1154 Some(RenderedDbConfig::new(app.database.clone(), gear_db_config))
1155 } else {
1156 None
1157 };
1158
1159 let config = parse_gear_config(app, gear_name)
1161 .map(|entry| entry.config)
1162 .unwrap_or_default();
1163
1164 let logging = app.logging.clone();
1166
1167 let opentelemetry = if app.opentelemetry.tracing.enabled || app.opentelemetry.metrics.enabled {
1169 Some(app.opentelemetry.clone())
1170 } else {
1171 None
1172 };
1173
1174 Ok(RenderedGearConfig {
1175 database,
1176 config,
1177 logging: Some(logging),
1178 opentelemetry,
1179 })
1180}
1181
1182pub fn parse_gear_config(app: &AppConfig, gear_name: &str) -> Result<GearConfig> {
1187 let gear_raw = app
1188 .gears
1189 .get(gear_name)
1190 .cloned()
1191 .ok_or_else(|| anyhow::anyhow!("Gear '{gear_name}' not found in config"))?;
1192
1193 let gear_config: GearConfig = serde_json::from_value(gear_raw)?;
1194 Ok(gear_config)
1195}
1196
1197pub fn get_gear_runtime_config(app: &AppConfig, gear_name: &str) -> Result<Option<GearRuntime>> {
1202 let entry = parse_gear_config(app, gear_name)?;
1203 Ok(entry.runtime)
1204}
1205
1206pub fn build_final_db_for_gear(
1217 app: &AppConfig,
1218 gear_name: &str,
1219 home_dir: &Path,
1220 dry_run: bool,
1221) -> DbConfigResult {
1222 let Some(gear_raw) = app.gears.get(gear_name) else {
1224 return Ok(None); };
1226
1227 let gear_entry: GearConfig = serde_json::from_value(gear_raw.clone())
1228 .with_context(|| format!("Invalid gear config structure for '{gear_name}'"))?;
1229
1230 let Some(gear_db_config) = gear_entry.database else {
1231 tracing::warn!(
1232 "Gear '{}' has no database configuration; DB capability disabled",
1233 gear_name
1234 );
1235 return Ok(None);
1236 };
1237
1238 let global_db_config = app.database.as_ref();
1240
1241 let mut builder = DbConfigBuilder::new();
1243
1244 if let Some(server_name) = &gear_db_config.server {
1246 let global_server = global_db_config
1247 .and_then(|gc| gc.servers.get(server_name))
1248 .ok_or_else(|| {
1249 anyhow::anyhow!("Referenced server '{server_name}' not found in global config")
1250 })?;
1251
1252 builder.apply_global_server(global_server, home_dir, gear_name, dry_run)?;
1253 }
1254
1255 if let Some(gear_dsn) = &gear_db_config.dsn {
1257 builder.apply_gear_dsn(gear_dsn, home_dir, gear_name, dry_run)?;
1258 }
1259
1260 builder.apply_gear_fields(&gear_db_config)?;
1262
1263 let is_sqlite = decide_backend(&builder, &gear_db_config);
1265
1266 let result_dsn = if is_sqlite {
1267 finalize_sqlite_dsn(&builder, &gear_db_config, gear_name, home_dir, dry_run)?
1268 } else {
1269 finalize_server_dsn(&builder, gear_name)?
1270 };
1271
1272 validate_dsn(&result_dsn)?;
1274
1275 let log_dsn = redact_dsn_for_logging(&result_dsn)?;
1277
1278 tracing::info!(
1279 "Built final DB config for gear '{}': {}",
1280 gear_name,
1281 log_dsn
1282 );
1283
1284 Ok(Some((result_dsn, builder.pool)))
1285}
1286
1287#[must_use]
1290pub fn get_gear_db_config(app: &AppConfig, gear_name: &str) -> Option<DbConnConfig> {
1291 let gear_raw = app.gears.get(gear_name)?;
1292 let gear_entry: GearConfig = serde_json::from_value(gear_raw.clone()).ok()?;
1293 gear_entry.database
1294}
1295
1296#[must_use]
1299pub fn gear_home(app: &AppConfig, gear_name: &str) -> PathBuf {
1300 PathBuf::from(&app.server.home_dir).join(gear_name)
1301}
1302
1303#[cfg(test)]
1304#[cfg_attr(coverage_nightly, coverage(off))]
1305mod tests {
1306 use super::*;
1307 use std::fs;
1308 use temp_env::with_var;
1309 use tempfile::tempdir;
1310
1311 fn is_normalized_path(p: &Path) -> bool {
1313 p.is_absolute() && !p.starts_with("~")
1314 }
1315
1316 fn default_subdir() -> &'static str {
1318 ".cf-gears"
1319 }
1320
1321 #[test]
1322 fn test_default_config_structure() {
1323 let config = AppConfig::default();
1324
1325 assert!(config.database.is_none());
1327
1328 let logging = config.logging;
1330 assert!(logging.contains_key("default"));
1331
1332 let default_section = &logging["default"];
1333 assert_eq!(default_section.console_level, Some(Level::INFO));
1334 assert_eq!(default_section.file().unwrap(), "logs/cf-gears.log");
1335
1336 assert!(config.gears.is_empty());
1338 }
1339
1340 #[test]
1341 fn test_load_layered_normalizes_home_dir() {
1342 let tmp = tempdir().unwrap();
1343 let cfg_path = tmp.path().join("cfg.yaml");
1344
1345 let yaml = r#"
1347server:
1348 home_dir: "~/.test_cfgears"
1349
1350database:
1351 servers:
1352 test_postgres:
1353 dsn: "postgres://user:pass@localhost/db"
1354 pool:
1355 max_conns: 20
1356
1357logging:
1358 default:
1359 console_level: debug
1360 file: "logs/default.log"
1361"#;
1362 fs::write(&cfg_path, yaml).unwrap();
1363
1364 let config = AppConfig::load_layered(&cfg_path).unwrap();
1365
1366 assert!(is_normalized_path(&config.server.home_dir));
1368 assert!(config.server.home_dir.ends_with(".test_cfgears"));
1369
1370 let logging = &config.logging;
1376 let def = &logging["default"];
1377 assert_eq!(def.console_level, Some(Level::DEBUG));
1378 assert_eq!(def.section_file.as_ref().unwrap().file, "logs/default.log");
1379 }
1380
1381 #[test]
1382 fn test_load_or_default_normalizes_home_dir_when_none() {
1383 let tmp = tempdir().unwrap();
1386 let env_var = if cfg!(target_os = "windows") {
1387 "APPDATA"
1388 } else {
1389 "HOME"
1390 };
1391 with_var(env_var, Some(tmp.path().to_str().unwrap()), || {
1392 let config = AppConfig::load_or_default(None).unwrap();
1393 assert!(is_normalized_path(&config.server.home_dir));
1394 assert!(config.server.home_dir.ends_with(default_subdir()));
1395 });
1396 }
1397
1398 #[test]
1399 fn test_minimal_yaml_config() {
1400 let tmp = tempdir().unwrap();
1401 let cfg_path = tmp.path().join("cfg.yaml");
1402
1403 let yaml = r#"
1404server:
1405 home_dir: "~/.minimal"
1406"#;
1407 fs::write(&cfg_path, yaml).unwrap();
1408
1409 let config = AppConfig::load_layered(&cfg_path).unwrap();
1410
1411 assert!(is_normalized_path(&config.server.home_dir));
1413 assert!(config.server.home_dir.ends_with(".minimal"));
1414
1415 assert!(config.database.is_none());
1417 assert!(config.gears.is_empty());
1418 }
1419
1420 #[test]
1421 fn test_cli_overrides() {
1422 let mut config = AppConfig::default();
1423
1424 let args = CliArgs {
1425 config: None,
1426 print_config: false,
1427 verbose: 2, mock: false,
1429 };
1430
1431 config.apply_cli_overrides(args.verbose);
1432
1433 let logging = &config.logging;
1437 let default_section = &logging["default"];
1438 assert_eq!(default_section.console_level, Some(Level::TRACE));
1439 }
1440
1441 #[test]
1442 fn test_cli_verbose_levels_matrix() {
1443 for (verbose_level, expected_log_level) in [
1444 (0, Some(Level::INFO)), (1, Some(Level::DEBUG)),
1446 (2, Some(Level::TRACE)),
1447 (3, Some(Level::TRACE)), ] {
1449 let mut config = AppConfig::default();
1450 let args = CliArgs {
1451 config: None,
1452 print_config: false,
1453 verbose: verbose_level,
1454 mock: false,
1455 };
1456
1457 config.apply_cli_overrides(args.verbose);
1458
1459 let logging = &config.logging;
1460 let default_section = &logging["default"];
1461
1462 if verbose_level == 0 {
1463 assert_eq!(default_section.console_level, Some(Level::INFO));
1464 } else {
1465 assert_eq!(default_section.console_level, expected_log_level);
1466 }
1467 }
1468 }
1469
1470 #[test]
1471 fn test_layered_config_loading_with_gears_dir() {
1472 let tmp = tempdir().unwrap();
1473 let cfg_path = tmp.path().join("gears_dir.yaml");
1474 let gears_dir = tmp.path().join("gears");
1475
1476 fs::create_dir_all(&gears_dir).unwrap();
1477 let gear_cfg = gears_dir.join("test_gear.yaml");
1478 fs::write(
1479 &gear_cfg,
1480 r#"
1481setting1: "value1"
1482setting2: 42
1483"#,
1484 )
1485 .unwrap();
1486
1487 let gears_dir_str = normalize_path(&gears_dir);
1489 let yaml = format!(
1490 r#"
1491server:
1492 home_dir: "~/.gears_test"
1493
1494gears_dir: "{gears_dir_str}"
1495
1496gears:
1497 existing_gear:
1498 key: "value"
1499"#
1500 );
1501
1502 fs::write(&cfg_path, yaml).unwrap();
1503
1504 let config = AppConfig::load_layered(&cfg_path).unwrap();
1505
1506 assert!(config.gears.contains_key("existing_gear"));
1508
1509 assert!(config.gears.contains_key("test_gear"));
1511
1512 let test_gear = &config.gears["test_gear"];
1514 assert_eq!(test_gear["setting1"], "value1");
1515 assert_eq!(test_gear["setting2"], 42);
1516 }
1517
1518 #[test]
1519 fn test_load_and_init_logging_smoke() {
1520 let tmp = tempdir().unwrap();
1522 let cfg_path = tmp.path().join("logging.yaml");
1523 let yaml = r#"
1524server:
1525 home_dir: "~/.logging_test"
1526
1527logging:
1528 default:
1529 console_level: debug
1530 file: ""
1531 file_level: info
1532"#;
1533 fs::write(&cfg_path, yaml).unwrap();
1534
1535 let config = AppConfig::load_layered(&cfg_path).unwrap();
1536 let logging = &config.logging;
1537 assert!(logging.contains_key("default"));
1538
1539 let default_section = &logging["default"];
1540 assert_eq!(default_section.console_level, Some(Level::DEBUG));
1541 assert_eq!(default_section.file_level(), Some(Level::INFO));
1542 }
1544
1545 fn create_app_with_server(server_name: &str, db_config: DbConnConfig) -> AppConfig {
1549 let mut servers = HashMap::new();
1550 servers.insert(server_name.to_owned(), db_config);
1551
1552 AppConfig {
1553 database: Some(GlobalDatabaseConfig {
1554 servers,
1555 auto_provision: None,
1556 }),
1557 ..Default::default()
1558 }
1559 }
1560
1561 fn add_gear_to_app(app: &mut AppConfig, gear_name: &str, database_config: &serde_json::Value) {
1563 app.gears.insert(
1564 gear_name.to_owned(),
1565 serde_json::json!({
1566 "database": database_config,
1567 "config": {}
1568 }),
1569 );
1570 }
1571
1572 fn add_gear_with_config(app: &mut AppConfig, gear_name: &str, config: &serde_json::Value) {
1574 app.gears.insert(
1575 gear_name.to_owned(),
1576 serde_json::json!({
1577 "database": {},
1578 "config": config
1579 }),
1580 );
1581 }
1582
1583 fn create_minimal_app() -> AppConfig {
1585 AppConfig {
1586 database: None,
1587 gears: HashMap::new(),
1588 ..Default::default()
1589 }
1590 }
1591
1592 #[test]
1593 fn test_precedence_global_dsn_only() {
1594 let tmp = tempdir().unwrap();
1595 let home_dir = tmp.path();
1596
1597 let mut app = create_app_with_server(
1598 "test_server",
1599 DbConnConfig {
1600 dsn: Some(
1601 "postgresql://global_user:global_pass@global_host:5432/global_db".to_owned(),
1602 ),
1603 ..Default::default()
1604 },
1605 );
1606
1607 add_gear_to_app(
1609 &mut app,
1610 "test_gear",
1611 &serde_json::json!({
1612 "server": "test_server"
1613 }),
1614 );
1615
1616 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
1617 assert!(result.is_some());
1618
1619 let (dsn, _pool) = result.unwrap();
1620 assert!(dsn.contains("global_user"));
1621 assert!(dsn.contains("global_host"));
1622 assert!(dsn.contains("global_db"));
1623 }
1624
1625 #[test]
1626 fn test_precedence_global_fields_only() {
1627 let tmp = tempdir().unwrap();
1628 let home_dir = tmp.path();
1629
1630 let mut app = create_app_with_server(
1631 "test_server",
1632 DbConnConfig {
1633 host: Some("field_host".to_owned()),
1634 port: Some(5433),
1635 user: Some("field_user".to_owned()),
1636 dbname: Some("field_db".to_owned()),
1637 ..Default::default()
1638 },
1639 );
1640
1641 add_gear_to_app(
1643 &mut app,
1644 "test_gear",
1645 &serde_json::json!({
1646 "server": "test_server"
1647 }),
1648 );
1649
1650 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
1651 assert!(result.is_some());
1652
1653 let (dsn, _pool) = result.unwrap();
1654 assert!(dsn.contains("field_host"));
1655 assert!(dsn.contains("5433"));
1656 assert!(dsn.contains("field_user"));
1657 assert!(dsn.contains("field_db"));
1658 }
1659
1660 #[test]
1661 fn test_precedence_gear_dsn_only() {
1662 let tmp = tempdir().unwrap();
1663 let home_dir = tmp.path();
1664
1665 let app = AppConfig {
1666 gears: {
1667 let mut gears = HashMap::new();
1668 gears.insert(
1669 "test_gear".to_owned(),
1670 serde_json::json!({
1671 "database": {
1672 "dsn": "sqlite://gear_test.db?wal=true&synchronous=NORMAL"
1673 },
1674 "config": {}
1675 }),
1676 );
1677 gears
1678 },
1679 ..Default::default()
1680 };
1681
1682 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
1683 assert!(result.is_some());
1684
1685 let (dsn, _pool) = result.unwrap();
1686 assert!(dsn.contains("gear_test.db"));
1687 assert!(dsn.contains("wal=true"));
1688 }
1689
1690 #[test]
1691 fn test_precedence_gear_fields_only() {
1692 let tmp = tempdir().unwrap();
1693 let home_dir = tmp.path();
1694
1695 let app = AppConfig {
1696 gears: {
1697 let mut gears = HashMap::new();
1698 gears.insert(
1699 "test_gear".to_owned(),
1700 serde_json::json!({
1701 "database": {
1702 "file": "gear_fields.db"
1703 },
1704 "config": {}
1705 }),
1706 );
1707 gears
1708 },
1709 ..Default::default()
1710 };
1711
1712 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
1713 assert!(result.is_some());
1714
1715 let (dsn, _pool) = result.unwrap();
1716 assert!(dsn.contains("gear_fields.db"));
1717 #[cfg(windows)]
1719 assert!(dsn.starts_with("sqlite:") && !dsn.starts_with("sqlite://"));
1720 #[cfg(unix)]
1721 assert!(dsn.starts_with("sqlite://"));
1722 }
1723
1724 #[test]
1725 fn test_precedence_fields_override_dsn() {
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("postgresql://old_user:old_pass@old_host:5432/old_db".to_owned()),
1733 host: Some("new_host".to_owned()), port: Some(5433), user: Some("new_user".to_owned()), dbname: Some("new_db".to_owned()), ..Default::default()
1738 },
1739 );
1740
1741 add_gear_to_app(
1743 &mut app,
1744 "test_gear",
1745 &serde_json::json!({
1746 "server": "test_server",
1747 "port": 5434 }),
1749 );
1750
1751 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
1752 assert!(result.is_some());
1753
1754 let (dsn, _pool) = result.unwrap();
1755 assert!(dsn.contains("new_host"));
1757 assert!(dsn.contains("5434")); assert!(dsn.contains("new_user"));
1759 assert!(dsn.contains("new_db"));
1760 assert!(!dsn.contains("old_host"));
1762 assert!(!dsn.contains("5432"));
1763 assert!(!dsn.contains("old_user"));
1764 assert!(!dsn.contains("old_db"));
1765 }
1766
1767 #[test]
1768 fn test_env_expansion_password() {
1769 let tmp = tempdir().unwrap();
1770 let home_dir = tmp.path();
1771
1772 with_var("TEST_DB_PASSWORD", Some("secret123"), || {
1773 let mut app = create_app_with_server(
1774 "test_server",
1775 DbConnConfig {
1776 host: Some("localhost".to_owned()),
1777 port: Some(5432),
1778 user: Some("testuser".to_owned()),
1779 password: Some("${TEST_DB_PASSWORD}".to_owned()), dbname: Some("testdb".to_owned()),
1781 ..Default::default()
1782 },
1783 );
1784
1785 add_gear_to_app(
1786 &mut app,
1787 "test_gear",
1788 &serde_json::json!({
1789 "server": "test_server"
1790 }),
1791 );
1792
1793 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
1794 assert!(result.is_some());
1795
1796 let (dsn, _pool) = result.unwrap();
1797 assert!(dsn.contains("secret123"));
1798 });
1799 }
1800
1801 #[test]
1802 fn test_env_expansion_in_dsn() {
1803 let tmp = tempdir().unwrap();
1804 let home_dir = tmp.path();
1805
1806 temp_env::with_vars(
1807 [
1808 ("DB_HOST", Some("test-server")),
1809 ("DB_PASSWORD", Some("env_secret")),
1810 ],
1811 || {
1812 let mut app = create_app_with_server(
1813 "test_server",
1814 DbConnConfig {
1815 dsn: Some(
1816 "postgresql://user:${DB_PASSWORD}@${DB_HOST}:5432/mydb".to_owned(),
1817 ),
1818 ..Default::default()
1819 },
1820 );
1821
1822 add_gear_to_app(
1823 &mut app,
1824 "test_gear",
1825 &serde_json::json!({
1826 "server": "test_server"
1827 }),
1828 );
1829
1830 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
1831 assert!(result.is_some());
1832
1833 let (dsn, _pool) = result.unwrap();
1834 assert!(dsn.contains("test-server"));
1835 assert!(dsn.contains("env_secret"));
1836 assert!(!dsn.contains("${DB_HOST}"));
1838 assert!(!dsn.contains("${DB_PASSWORD}"));
1839 },
1840 );
1841 }
1842
1843 #[test]
1844 fn test_sqlite_file_path_resolution() {
1845 let tmp = tempdir().unwrap();
1846 let home_dir = tmp.path();
1847
1848 let app1 = AppConfig {
1850 gears: {
1851 let mut gears = HashMap::new();
1852 gears.insert(
1853 "test_gear".to_owned(),
1854 serde_json::json!({
1855 "database": {
1856 "file": "test.db"
1857 },
1858 "config": {}
1859 }),
1860 );
1861 gears
1862 },
1863 ..Default::default()
1864 };
1865
1866 let result1 = build_final_db_for_gear(&app1, "test_gear", home_dir, false).unwrap();
1867 assert!(result1.is_some());
1868 let (dsn1, _) = result1.unwrap();
1869 assert!(dsn1.contains("test_gear"));
1870 assert!(dsn1.contains("test.db"));
1871
1872 let abs_path = tmp.path().join("absolute.db");
1874 let app2 = AppConfig {
1875 gears: {
1876 let mut gears = HashMap::new();
1877 gears.insert(
1878 "test_gear".to_owned(),
1879 serde_json::json!({
1880 "database": {
1881 "path": abs_path.to_string_lossy()
1882 },
1883 "config": {}
1884 }),
1885 );
1886 gears
1887 },
1888 ..Default::default()
1889 };
1890
1891 let result2 = build_final_db_for_gear(&app2, "test_gear", home_dir, false).unwrap();
1892 assert!(result2.is_some());
1893 let (dsn2, _) = result2.unwrap();
1894 assert!(dsn2.contains("absolute.db"));
1895
1896 let app3 = AppConfig {
1898 gears: {
1899 let mut gears = HashMap::new();
1900 gears.insert(
1901 "test_gear".to_owned(),
1902 serde_json::json!({
1903 "database": {},
1904 "config": {}
1905 }),
1906 );
1907 gears
1908 },
1909 ..Default::default()
1910 };
1911
1912 let result3 = build_final_db_for_gear(&app3, "test_gear", home_dir, false).unwrap();
1913 assert!(result3.is_some());
1914 let (dsn3, _) = result3.unwrap();
1915 assert!(dsn3.contains("test_gear.sqlite"));
1916 }
1917
1918 #[cfg(windows)]
1919 #[test]
1920 fn test_sqlite_path_resolution_windows() {
1921 let tmp = tempdir().unwrap();
1922 let home_dir = tmp.path();
1923
1924 let app = AppConfig {
1925 gears: {
1926 let mut gears = HashMap::new();
1927 gears.insert(
1928 "test_gear".to_owned(),
1929 serde_json::json!({
1930 "database": {
1931 "file": "test.db"
1932 },
1933 "config": {}
1934 }),
1935 );
1936 gears
1937 },
1938 ..Default::default()
1939 };
1940
1941 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
1942 assert!(result.is_some());
1943 let (dsn, _) = result.unwrap();
1944
1945 assert!(!dsn.contains('\\'));
1947 assert!(dsn.contains('/'));
1948 }
1949
1950 #[test]
1951 fn test_sqlite_dsn_with_server_reference_and_dbname_override() {
1952 let tmp = tempdir().unwrap();
1953 let home_dir = tmp.path();
1954
1955 let mut app = AppConfig::default();
1956
1957 let mut servers = HashMap::new();
1959 servers.insert(
1960 "sqlite_users".to_owned(),
1961 DbConnConfig {
1962 engine: None,
1963 dsn: Some(
1964 "sqlite://users_info.db?WAL=true&synchronous=NORMAL&busy_timeout=5000"
1965 .to_owned(),
1966 ),
1967 host: None,
1968 port: None,
1969 user: None,
1970 password: None,
1971 dbname: None,
1972 params: None,
1973 pool: None,
1974 file: None,
1975 path: None,
1976 server: None,
1977 },
1978 );
1979
1980 app.database = Some(GlobalDatabaseConfig {
1981 servers,
1982 auto_provision: None,
1983 });
1984
1985 app.gears.insert(
1987 "users_info".to_owned(),
1988 serde_json::json!({
1989 "database": {
1990 "server": "sqlite_users",
1991 "dbname": "users_info.db"
1992 },
1993 "config": {}
1994 }),
1995 );
1996
1997 let result = build_final_db_for_gear(&app, "users_info", home_dir, false).unwrap();
1998 assert!(result.is_some());
1999 let (dsn, _) = result.unwrap();
2000
2001 assert!(dsn.contains("?WAL=true&synchronous=NORMAL&busy_timeout=5000"));
2003 assert!(dsn.contains("users_info/users_info.db"));
2004
2005 #[cfg(windows)]
2007 {
2008 assert!(dsn.starts_with("sqlite:"));
2010 assert!(!dsn.starts_with("sqlite://"));
2011 }
2012
2013 #[cfg(unix)]
2014 {
2015 assert!(dsn.starts_with("sqlite://"));
2017 }
2018 }
2019
2020 #[cfg(unix)]
2021 #[test]
2022 fn test_sqlite_path_resolution_unix() {
2023 let tmp = tempdir().unwrap();
2024 let home_dir = tmp.path();
2025
2026 let app = AppConfig {
2027 gears: {
2028 let mut gears = HashMap::new();
2029 gears.insert(
2030 "test_gear".to_owned(),
2031 serde_json::json!({
2032 "database": {
2033 "file": "test.db"
2034 },
2035 "config": {}
2036 }),
2037 );
2038 gears
2039 },
2040 ..Default::default()
2041 };
2042
2043 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2044 assert!(result.is_some());
2045 let (dsn, _) = result.unwrap();
2046
2047 assert!(dsn.starts_with("sqlite://"));
2049 assert!(dsn.contains("/test_gear/test.db"));
2050 }
2051
2052 #[test]
2053 fn test_server_based_db_missing_dbname_error() {
2054 let tmp = tempdir().unwrap();
2055 let home_dir = tmp.path();
2056
2057 let mut app = create_app_with_server(
2058 "test_server",
2059 DbConnConfig {
2060 host: Some("localhost".to_owned()),
2061 port: Some(5432),
2062 user: Some("testuser".to_owned()),
2063 ..Default::default()
2065 },
2066 );
2067
2068 add_gear_to_app(
2069 &mut app,
2070 "test_gear",
2071 &serde_json::json!({
2072 "server": "test_server"
2073 }),
2074 );
2075
2076 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false);
2077 assert!(result.is_err());
2078 let error_msg = result.unwrap_err().to_string();
2079 assert!(error_msg.contains("missing required 'dbname'"));
2080 }
2081
2082 #[test]
2083 fn test_gear_no_database_config() {
2084 let tmp = tempdir().unwrap();
2085 let home_dir = tmp.path();
2086
2087 let app = AppConfig {
2089 gears: {
2090 let mut gears = HashMap::new();
2091 gears.insert(
2092 "no_db_gear".to_owned(),
2093 serde_json::json!({
2094 "config": {
2095 "some_setting": "value"
2096 }
2097 }),
2098 );
2099 gears
2100 },
2101 ..Default::default()
2102 };
2103
2104 let result = build_final_db_for_gear(&app, "no_db_gear", home_dir, false).unwrap();
2105 assert!(result.is_none());
2106 }
2107
2108 #[test]
2109 fn test_gear_empty_database_config() {
2110 let tmp = tempdir().unwrap();
2111 let home_dir = tmp.path();
2112
2113 let app = AppConfig {
2115 gears: {
2116 let mut gears = HashMap::new();
2117 gears.insert(
2118 "empty_db_gear".to_owned(),
2119 serde_json::json!({
2120 "database": null,
2121 "config": {}
2122 }),
2123 );
2124 gears
2125 },
2126 ..Default::default()
2127 };
2128
2129 let result = build_final_db_for_gear(&app, "empty_db_gear", home_dir, false).unwrap();
2130 assert!(result.is_none());
2131 }
2132
2133 #[test]
2134 fn test_referenced_server_not_found() {
2135 let tmp = tempdir().unwrap();
2136 let home_dir = tmp.path();
2137
2138 let app = AppConfig {
2139 gears: {
2140 let mut gears = HashMap::new();
2141 gears.insert(
2142 "test_gear".to_owned(),
2143 serde_json::json!({
2144 "database": {
2145 "server": "nonexistent_server"
2146 },
2147 "config": {}
2148 }),
2149 );
2150 gears
2151 },
2152 ..Default::default()
2153 };
2154
2155 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false);
2156 assert!(result.is_err());
2157 let error_msg = result.unwrap_err().to_string();
2158 assert!(error_msg.contains("Referenced server 'nonexistent_server' not found"));
2159 }
2160
2161 #[test]
2162 fn test_dsn_validation_invalid_url() {
2163 let tmp = tempdir().unwrap();
2164 let home_dir = tmp.path();
2165
2166 let app = AppConfig {
2167 gears: {
2168 let mut gears = HashMap::new();
2169 gears.insert(
2170 "test_gear".to_owned(),
2171 serde_json::json!({
2172 "database": {
2173 "dsn": "invalid://not-a-valid[url"
2174 },
2175 "config": {}
2176 }),
2177 );
2178 gears
2179 },
2180 ..Default::default()
2181 };
2182
2183 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false);
2184 assert!(result.is_err());
2185 }
2186
2187 #[test]
2188 fn test_env_variable_not_found() {
2189 let tmp = tempdir().unwrap();
2190 let home_dir = tmp.path();
2191
2192 with_var("NONEXISTENT_PASSWORD", None::<&str>, || {
2194 let mut app = create_app_with_server(
2195 "test_server",
2196 DbConnConfig {
2197 host: Some("localhost".to_owned()),
2198 password: Some("${NONEXISTENT_PASSWORD}".to_owned()),
2199 dbname: Some("testdb".to_owned()),
2200 ..Default::default()
2201 },
2202 );
2203
2204 add_gear_to_app(
2205 &mut app,
2206 "test_gear",
2207 &serde_json::json!({
2208 "server": "test_server"
2209 }),
2210 );
2211
2212 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false);
2213 assert!(result.is_err());
2214 let error_msg = result.unwrap_err().to_string();
2215 assert!(error_msg.contains("NONEXISTENT_PASSWORD"));
2216 });
2217 }
2218
2219 #[test]
2220 fn test_sqlite_at_file_relative_path() {
2221 let tmp = tempdir().unwrap();
2222 let home_dir = tmp.path();
2223
2224 let app = AppConfig {
2225 gears: {
2226 let mut gears = HashMap::new();
2227 gears.insert(
2228 "test_gear".to_owned(),
2229 serde_json::json!({
2230 "database": {
2231 "dsn": "sqlite://@file(users.db)"
2232 },
2233 "config": {}
2234 }),
2235 );
2236 gears
2237 },
2238 ..Default::default()
2239 };
2240
2241 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2242 assert!(result.is_some());
2243
2244 let (dsn, _pool) = result.unwrap();
2245 assert!(dsn.contains("test_gear"));
2246 assert!(dsn.contains("users.db"));
2247 #[cfg(windows)]
2249 assert!(dsn.starts_with("sqlite:") && !dsn.starts_with("sqlite://"));
2250 #[cfg(unix)]
2251 assert!(dsn.starts_with("sqlite:///"));
2252 }
2253
2254 #[test]
2255 fn test_sqlite_at_file_absolute_path() {
2256 let tmp = tempdir().unwrap();
2257 let home_dir = tmp.path();
2258 let abs_path = tmp.path().join("absolute_db.sqlite");
2259
2260 let app = AppConfig {
2261 gears: {
2262 let mut gears = HashMap::new();
2263 gears.insert(
2264 "test_gear".to_owned(),
2265 serde_json::json!({
2266 "database": {
2267 "dsn": format!("sqlite://@file({})", abs_path.to_string_lossy())
2268 },
2269 "config": {}
2270 }),
2271 );
2272 gears
2273 },
2274 ..Default::default()
2275 };
2276
2277 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2278 assert!(result.is_some());
2279
2280 let (dsn, _pool) = result.unwrap();
2281 assert!(dsn.contains("absolute_db.sqlite"));
2282 #[cfg(windows)]
2284 assert!(dsn.starts_with("sqlite:") && !dsn.starts_with("sqlite://"));
2285 #[cfg(unix)]
2286 assert!(dsn.starts_with("sqlite:///"));
2287 }
2288
2289 #[test]
2290 fn test_sqlite_empty_dsn_default() {
2291 let tmp = tempdir().unwrap();
2292 let home_dir = tmp.path();
2293
2294 let app = AppConfig {
2295 gears: {
2296 let mut gears = HashMap::new();
2297 gears.insert(
2298 "test_gear".to_owned(),
2299 serde_json::json!({
2300 "database": {
2301 "dsn": "sqlite://"
2302 },
2303 "config": {}
2304 }),
2305 );
2306 gears
2307 },
2308 ..Default::default()
2309 };
2310
2311 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2312 assert!(result.is_some());
2313
2314 let (dsn, _pool) = result.unwrap();
2315 assert!(dsn.contains("test_gear"));
2316 assert!(dsn.contains("test_gear.sqlite"));
2317 #[cfg(windows)]
2319 assert!(dsn.starts_with("sqlite:") && !dsn.starts_with("sqlite://"));
2320 #[cfg(unix)]
2321 assert!(dsn.starts_with("sqlite:///"));
2322 }
2323
2324 #[test]
2325 fn test_sqlite_at_file_invalid_syntax() {
2326 let tmp = tempdir().unwrap();
2327 let home_dir = tmp.path();
2328
2329 let app = AppConfig {
2330 gears: {
2331 let mut gears = HashMap::new();
2332 gears.insert(
2333 "test_gear".to_owned(),
2334 serde_json::json!({
2335 "database": {
2336 "dsn": "sqlite://@file(missing_closing_paren"
2337 },
2338 "config": {}
2339 }),
2340 );
2341 gears
2342 },
2343 ..Default::default()
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("Invalid @file() syntax"));
2350 }
2351
2352 #[test]
2353 fn test_dsn_special_characters_in_credentials() {
2354 let tmp = tempdir().unwrap();
2355 let home_dir = tmp.path();
2356
2357 let mut app = create_app_with_server(
2359 "test_server",
2360 DbConnConfig {
2361 host: Some("localhost".to_owned()),
2362 port: Some(5432),
2363 user: Some("user@domain".to_owned()),
2364 password: Some("pa@ss:w0rd/with%special&chars".to_owned()),
2365 dbname: Some("test/db".to_owned()),
2366 ..Default::default()
2367 },
2368 );
2369
2370 add_gear_to_app(
2371 &mut app,
2372 "test_gear",
2373 &serde_json::json!({
2374 "server": "test_server"
2375 }),
2376 );
2377
2378 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2379 assert!(result.is_some());
2380
2381 let (dsn, _pool) = result.unwrap();
2382
2383 assert!(dsn.starts_with("postgresql://"));
2385 assert!(dsn.contains("user%40domain")); assert!(dsn.contains("/test%2Fdb")); validate_dsn(&dsn).expect("DSN with special characters should be valid");
2390
2391 let parsed_dsn = dsn::parse(&dsn).expect("DSN should be parseable");
2393 assert_eq!(parsed_dsn.username.as_deref(), Some("user@domain"));
2394 assert_eq!(
2395 parsed_dsn.password.as_deref(),
2396 Some("pa@ss:w0rd/with%special&chars")
2397 );
2398 }
2401
2402 #[test]
2403 #[allow(clippy::non_ascii_literal)]
2404 fn test_dsn_unicode_characters() {
2405 let tmp = tempdir().unwrap();
2406 let home_dir = tmp.path();
2407
2408 let mut app = create_app_with_server(
2410 "test_server",
2411 DbConnConfig {
2412 host: Some("localhost".to_owned()),
2413 user: Some("ユーザー".to_owned()), dbname: Some("unicode_db".to_owned()),
2415 ..Default::default()
2416 },
2417 );
2418
2419 add_gear_to_app(
2420 &mut app,
2421 "test_gear",
2422 &serde_json::json!({
2423 "server": "test_server"
2424 }),
2425 );
2426
2427 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2428 assert!(result.is_some());
2429
2430 let (dsn, _pool) = result.unwrap();
2431
2432 assert!(dsn.starts_with("postgresql://"));
2434 assert!(dsn.contains('%')); validate_dsn(&dsn).expect("DSN with Unicode characters should be valid");
2439 }
2440
2441 #[test]
2442 fn test_dsn_query_parameters_encoding() {
2443 let tmp = tempdir().unwrap();
2444 let home_dir = tmp.path();
2445
2446 let mut params = HashMap::new();
2447 params.insert("ssl mode".to_owned(), "require & verify".to_owned());
2448 params.insert("application_name".to_owned(), "my-app/v1.0".to_owned());
2449
2450 let mut app = create_app_with_server(
2451 "test_server",
2452 DbConnConfig {
2453 host: Some("localhost".to_owned()),
2454 user: Some("testuser".to_owned()),
2455 dbname: Some("testdb".to_owned()),
2456 params: Some(params),
2457 ..Default::default()
2458 },
2459 );
2460
2461 add_gear_to_app(
2462 &mut app,
2463 "test_gear",
2464 &serde_json::json!({
2465 "server": "test_server"
2466 }),
2467 );
2468
2469 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2470 assert!(result.is_some());
2471
2472 let (dsn, _pool) = result.unwrap();
2473
2474 assert!(dsn.contains("ssl+mode=require+%26+verify"));
2476 assert!(dsn.contains("application_name=my-app%2Fv1.0"));
2477
2478 validate_dsn(&dsn).expect("DSN with encoded query parameters should be valid");
2480 }
2481
2482 #[test]
2483 fn test_pool_config_merging() {
2484 use std::time::Duration;
2485
2486 let tmp = tempdir().unwrap();
2487 let home_dir = tmp.path();
2488
2489 let mut app = create_app_with_server(
2491 "test_server",
2492 DbConnConfig {
2493 host: Some("localhost".to_owned()),
2494 dbname: Some("testdb".to_owned()),
2495 pool: Some(PoolCfg {
2496 max_conns: Some(10),
2497 min_conns: None,
2498 acquire_timeout: Some(Duration::from_secs(5)),
2499 idle_timeout: None,
2500 max_lifetime: None,
2501 test_before_acquire: None,
2502 }),
2503 ..Default::default()
2504 },
2505 );
2506
2507 add_gear_to_app(
2509 &mut app,
2510 "test_gear",
2511 &serde_json::json!({
2512 "server": "test_server",
2513 "pool": {
2514 "max_conns": 20
2515 }
2516 }),
2517 );
2518
2519 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2520 assert!(result.is_some());
2521
2522 let (_dsn, pool) = result.unwrap();
2523 assert_eq!(pool.max_conns, Some(20)); assert_eq!(pool.acquire_timeout, Some(Duration::from_secs(5))); }
2526
2527 #[test]
2528 fn test_pool_config_gear_overrides_all() {
2529 use std::time::Duration;
2530
2531 let tmp = tempdir().unwrap();
2532 let home_dir = tmp.path();
2533
2534 let mut app = create_app_with_server(
2536 "test_server",
2537 DbConnConfig {
2538 host: Some("localhost".to_owned()),
2539 dbname: Some("testdb".to_owned()),
2540 pool: Some(PoolCfg {
2541 max_conns: Some(10),
2542 min_conns: None,
2543 acquire_timeout: Some(Duration::from_secs(5)),
2544 idle_timeout: None,
2545 max_lifetime: None,
2546 test_before_acquire: None,
2547 }),
2548 ..Default::default()
2549 },
2550 );
2551
2552 add_gear_to_app(
2554 &mut app,
2555 "test_gear",
2556 &serde_json::json!({
2557 "server": "test_server",
2558 "pool": {
2559 "max_conns": 30,
2560 "acquire_timeout": "10s"
2561 }
2562 }),
2563 );
2564
2565 let result = build_final_db_for_gear(&app, "test_gear", home_dir, false).unwrap();
2566 assert!(result.is_some());
2567
2568 let (_dsn, pool) = result.unwrap();
2569 assert_eq!(pool.max_conns, Some(30));
2570 assert_eq!(pool.acquire_timeout, Some(Duration::from_secs(10)));
2571 }
2572
2573 #[test]
2574 fn test_list_gear_names() {
2575 let mut app = create_minimal_app();
2576 add_gear_with_config(&mut app, "zebra_gear", &serde_json::json!({}));
2577 add_gear_with_config(&mut app, "alpha_gear", &serde_json::json!({}));
2578 add_gear_with_config(&mut app, "beta_gear", &serde_json::json!({}));
2579
2580 let gear_names = list_gear_names(&app);
2581
2582 assert_eq!(gear_names.len(), 3);
2584 assert_eq!(gear_names[0], "alpha_gear");
2585 assert_eq!(gear_names[1], "beta_gear");
2586 assert_eq!(gear_names[2], "zebra_gear");
2587 }
2588
2589 #[test]
2590 fn test_list_gear_names_empty() {
2591 let app = create_minimal_app();
2592 let gear_names = list_gear_names(&app);
2593 assert_eq!(gear_names.len(), 0);
2594 }
2595
2596 #[test]
2597 fn test_redact_dsn_password_postgres() {
2598 let dsn = "postgres://user:secretpass@localhost:5432/mydb";
2599 let redacted = redact_dsn_password(dsn).unwrap();
2600 assert_eq!(
2601 redacted,
2602 "postgres://user:***REDACTED***@localhost:5432/mydb"
2603 );
2604 }
2605
2606 #[test]
2607 fn test_redact_dsn_password_no_password() {
2608 let dsn = "postgres://user@localhost:5432/mydb";
2609 let redacted = redact_dsn_password(dsn).unwrap();
2610 assert_eq!(redacted, "postgres://user@localhost:5432/mydb");
2612 }
2613
2614 #[test]
2615 fn test_redact_dsn_password_special_chars() {
2616 let dsn = "postgres://user:p@ss%40word@localhost:5432/mydb";
2617 let redacted = redact_dsn_password(dsn).unwrap();
2618 assert_eq!(
2619 redacted,
2620 "postgres://user:***REDACTED***@localhost:5432/mydb"
2621 );
2622 }
2623
2624 #[test]
2625 fn test_render_effective_gears_config() {
2626 let mut app = create_minimal_app();
2627 add_gear_with_config(
2628 &mut app,
2629 "test_gear",
2630 &serde_json::json!({
2631 "my_setting": "my_value",
2632 "enabled": true
2633 }),
2634 );
2635
2636 let result = render_effective_gears_config(&app).unwrap();
2637
2638 assert!(result.is_object());
2640 let gears = result.as_object().unwrap();
2641 assert!(gears.contains_key("test_gear"));
2642
2643 let test_gear = gears.get("test_gear").unwrap();
2644 assert!(test_gear.is_object());
2645 let test_gear_obj = test_gear.as_object().unwrap();
2646
2647 assert!(test_gear_obj.contains_key("config"));
2649
2650 let config = test_gear_obj.get("config").unwrap();
2652 assert_eq!(config.get("my_setting").unwrap(), "my_value");
2653 assert_eq!(config.get("enabled").unwrap(), true);
2654 }
2655
2656 #[test]
2657 fn test_render_effective_gears_config_with_database() {
2658 let mut app = create_app_with_server(
2659 "test_server",
2660 DbConnConfig {
2661 host: Some("localhost".to_owned()),
2662 port: Some(5432),
2663 user: Some("user".to_owned()),
2664 password: Some("pass".to_owned()),
2665 dbname: Some("db".to_owned()),
2666 ..Default::default()
2667 },
2668 );
2669
2670 add_gear_to_app(
2672 &mut app,
2673 "test_gear",
2674 &serde_json::json!({
2675 "server": "test_server"
2676 }),
2677 );
2678
2679 let result = render_effective_gears_config(&app).unwrap();
2680 let gears = result.as_object().unwrap();
2681 let test_gear = gears.get("test_gear").unwrap().as_object().unwrap();
2682
2683 assert!(test_gear.contains_key("database"));
2685 let database = test_gear.get("database").unwrap().as_object().unwrap();
2686 assert!(database.contains_key("dsn"));
2687
2688 let dsn = database.get("dsn").unwrap().as_str().unwrap();
2690 assert!(dsn.contains("***REDACTED***"));
2691 assert!(!dsn.contains("pass"));
2692 }
2693
2694 #[test]
2695 fn test_render_effective_gears_config_minimal() {
2696 let mut app = create_minimal_app();
2698
2699 app.gears
2701 .insert("minimal_gear".to_owned(), serde_json::json!({}));
2702
2703 let result = render_effective_gears_config(&app).unwrap();
2704
2705 assert!(result.is_object());
2708 }
2709
2710 #[test]
2711 fn test_dump_effective_gears_config_yaml() {
2712 let mut app = create_minimal_app();
2713 add_gear_with_config(
2714 &mut app,
2715 "test_gear",
2716 &serde_json::json!({
2717 "setting": "value"
2718 }),
2719 );
2720
2721 let yaml = dump_effective_gears_config_yaml(&app).unwrap();
2722
2723 assert!(yaml.contains("test_gear:"));
2725 assert!(yaml.contains("config:"));
2726 assert!(yaml.contains("setting: value"));
2727 }
2728
2729 #[test]
2730 fn test_dump_effective_gears_config_json() {
2731 let mut app = create_minimal_app();
2732 add_gear_with_config(
2733 &mut app,
2734 "test_gear",
2735 &serde_json::json!({
2736 "setting": "value"
2737 }),
2738 );
2739
2740 let json = dump_effective_gears_config_json(&app).unwrap();
2741
2742 assert!(json.contains("\"test_gear\""));
2744 assert!(json.contains("\"config\""));
2745 assert!(json.contains("\"setting\""));
2746 assert!(json.contains("\"value\""));
2747
2748 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
2750 assert!(parsed.is_object());
2751 }
2752
2753 #[test]
2754 fn test_render_multiple_gears() {
2755 let mut app = create_minimal_app();
2756 add_gear_with_config(&mut app, "gear_a", &serde_json::json!({"a": 1}));
2757 add_gear_with_config(&mut app, "gear_b", &serde_json::json!({"b": 2}));
2758 add_gear_with_config(&mut app, "gear_c", &serde_json::json!({"c": 3}));
2759
2760 let result = render_effective_gears_config(&app).unwrap();
2761 let gears = result.as_object().unwrap();
2762
2763 assert_eq!(gears.len(), 3);
2764 assert!(gears.contains_key("gear_a"));
2765 assert!(gears.contains_key("gear_b"));
2766 assert!(gears.contains_key("gear_c"));
2767 }
2768
2769 #[derive(Debug, Deserialize, Default, PartialEq)]
2772 struct TestVendorConfig {
2773 #[serde(default)]
2774 api_token: String,
2775 #[serde(default)]
2776 api_url: String,
2777 }
2778
2779 #[test]
2780 fn test_vendor_section_parses_from_yaml() {
2781 let yaml = r#"
2782server:
2783 home_dir: "~/.test_vendor"
2784vendor:
2785 acme:
2786 api_token: "acme-token-123"
2787 api_url: "https://acme.example.com"
2788 other_corp:
2789 api_token: "other-token-789"
2790 api_url: "https://other.example.com"
2791"#;
2792 let config: AppConfig = serde_saphyr::from_str(yaml).unwrap();
2793 assert_eq!(config.vendor.len(), 2);
2794 assert!(config.vendor.contains_key("acme"));
2795 assert!(config.vendor.contains_key("other_corp"));
2796
2797 let acme: TestVendorConfig = config.vendor_config("acme").unwrap();
2798 assert_eq!(acme.api_token, "acme-token-123");
2799 assert_eq!(acme.api_url, "https://acme.example.com");
2800
2801 let other: TestVendorConfig = config.vendor_config("other_corp").unwrap();
2802 assert_eq!(other.api_token, "other-token-789");
2803 assert_eq!(other.api_url, "https://other.example.com");
2804 }
2805
2806 #[test]
2807 fn test_vendor_section_defaults_to_empty() {
2808 let config = AppConfig::default();
2809 assert!(config.vendor.is_empty());
2810 }
2811
2812 #[test]
2813 fn test_vendor_config_typed_access() {
2814 let mut config = AppConfig::default();
2815 config.vendor.insert(
2816 "acme".to_owned(),
2817 serde_json::json!({
2818 "api_token": "acme-token-123",
2819 "api_url": "https://acme.example.com"
2820 }),
2821 );
2822
2823 let acme: TestVendorConfig = config.vendor_config("acme").unwrap();
2824 assert_eq!(acme.api_token, "acme-token-123");
2825 assert_eq!(acme.api_url, "https://acme.example.com");
2826 }
2827
2828 #[test]
2829 fn test_vendor_config_not_found() {
2830 let config = AppConfig::default();
2831 let result: Result<TestVendorConfig, _> = config.vendor_config("nonexistent");
2832 assert!(matches!(
2833 result,
2834 Err(VendorConfigError::NotFound { ref vendor }) if vendor == "nonexistent"
2835 ));
2836 }
2837
2838 #[test]
2839 fn test_vendor_config_invalid_structure() {
2840 let mut config = AppConfig::default();
2841 config
2842 .vendor
2843 .insert("bad".to_owned(), serde_json::json!("not an object"));
2844
2845 let result: Result<TestVendorConfig, _> = config.vendor_config("bad");
2846 assert!(matches!(
2847 result,
2848 Err(VendorConfigError::InvalidConfig { ref vendor, .. }) if vendor == "bad"
2849 ));
2850 }
2851
2852 #[test]
2853 fn test_vendor_config_or_default_missing() {
2854 let config = AppConfig::default();
2855 let acme: TestVendorConfig = config.vendor_config_or_default("acme").unwrap();
2856 assert_eq!(acme, TestVendorConfig::default());
2857 }
2858
2859 #[test]
2860 fn test_vendor_config_or_default_present() {
2861 let mut config = AppConfig::default();
2862 config.vendor.insert(
2863 "acme".to_owned(),
2864 serde_json::json!({ "api_token": "acme-token-123" }),
2865 );
2866
2867 let acme: TestVendorConfig = config.vendor_config_or_default("acme").unwrap();
2868 assert_eq!(acme.api_token, "acme-token-123");
2869 }
2870
2871 #[test]
2872 fn test_vendor_config_env_override() {
2873 let tmp = tempdir().unwrap();
2874 let cfg_path = tmp.path().join("cfg.yaml");
2875 let yaml = r#"
2876server:
2877 home_dir: "~/.test_vendor"
2878vendor:
2879 env_test_vendor:
2880 api_token: "from_yaml"
2881"#;
2882 fs::write(&cfg_path, yaml).unwrap();
2883
2884 with_var(
2885 "APP__VENDOR__ENV_TEST_VENDOR__API_TOKEN",
2886 Some("from_env"),
2887 || {
2888 let config = AppConfig::load_layered(&cfg_path).unwrap();
2889 let v: TestVendorConfig = config.vendor_config("env_test_vendor").unwrap();
2890 assert_eq!(v.api_token, "from_env");
2891 },
2892 );
2893 }
2894
2895 #[test]
2896 fn test_vendor_multiple_vendors_typed_access() {
2897 let mut config = AppConfig::default();
2898 config.vendor.insert(
2899 "acme".to_owned(),
2900 serde_json::json!({ "api_token": "acme-token", "api_url": "https://acme.com" }),
2901 );
2902 config.vendor.insert(
2903 "other_corp".to_owned(),
2904 serde_json::json!({ "api_token": "other-token", "api_url": "https://other.com" }),
2905 );
2906
2907 let acme: TestVendorConfig = config.vendor_config("acme").unwrap();
2908 let other: TestVendorConfig = config.vendor_config("other_corp").unwrap();
2909
2910 assert_eq!(acme.api_token, "acme-token");
2911 assert_eq!(other.api_token, "other-token");
2912 assert_eq!(acme.api_url, "https://acme.com");
2913 assert_eq!(other.api_url, "https://other.com");
2914 }
2915
2916 #[test]
2917 fn test_vendor_nested_config() {
2918 #[derive(Debug, Deserialize, PartialEq)]
2919 struct NestedVendorConfig {
2920 api_url: String,
2921 feature_flags: FeatureFlags,
2922 }
2923
2924 #[derive(Debug, Deserialize, PartialEq)]
2925 struct FeatureFlags {
2926 beta_mode: bool,
2927 max_retries: u32,
2928 }
2929
2930 let mut config = AppConfig::default();
2931 config.vendor.insert(
2932 "acme".to_owned(),
2933 serde_json::json!({
2934 "api_url": "https://acme.com",
2935 "feature_flags": {
2936 "beta_mode": true,
2937 "max_retries": 3
2938 }
2939 }),
2940 );
2941
2942 let acme: NestedVendorConfig = config.vendor_config("acme").unwrap();
2943 assert_eq!(acme.api_url, "https://acme.com");
2944 assert!(acme.feature_flags.beta_mode);
2945 assert_eq!(acme.feature_flags.max_retries, 3);
2946 }
2947
2948 #[test]
2949 fn test_vendor_config_or_default_invalid_returns_error() {
2950 let mut config = AppConfig::default();
2951 config
2952 .vendor
2953 .insert("bad".to_owned(), serde_json::json!("not an object"));
2954
2955 let result: Result<TestVendorConfig, _> = config.vendor_config_or_default("bad");
2956 assert!(matches!(
2957 result,
2958 Err(VendorConfigError::InvalidConfig { ref vendor, .. }) if vendor == "bad"
2959 ));
2960 }
2961
2962 #[test]
2963 fn test_vendor_config_yaml_roundtrip() {
2964 let mut config = AppConfig::default();
2965 config.vendor.insert(
2966 "acme".to_owned(),
2967 serde_json::json!({ "api_token": "acme-token-123" }),
2968 );
2969
2970 let yaml = config.to_yaml().unwrap();
2971 assert!(yaml.contains("vendor"));
2972 assert!(yaml.contains("acme"));
2973 assert!(yaml.contains("acme-token-123"));
2974 }
2975
2976 #[test]
2977 fn test_vendor_coexists_with_gears() {
2978 let mut config = AppConfig::default();
2979 config.gears.insert(
2980 "my_gear".to_owned(),
2981 serde_json::json!({ "config": { "some_setting": true } }),
2982 );
2983 config.vendor.insert(
2984 "acme".to_owned(),
2985 serde_json::json!({ "api_token": "acme-token-123" }),
2986 );
2987
2988 assert!(config.gears.contains_key("my_gear"));
2989 assert!(config.vendor.contains_key("acme"));
2990
2991 let acme: TestVendorConfig = config.vendor_config("acme").unwrap();
2992 assert_eq!(acme.api_token, "acme-token-123");
2993 }
2994
2995 #[test]
2996 fn test_vendor_error_display_messages() {
2997 let not_found = VendorConfigError::NotFound {
2998 vendor: "acme".to_owned(),
2999 };
3000 assert_eq!(
3001 not_found.to_string(),
3002 "vendor 'acme' not found in configuration"
3003 );
3004
3005 let invalid = VendorConfigError::InvalidConfig {
3006 vendor: "bad".to_owned(),
3007 source: serde_json::from_str::<TestVendorConfig>("invalid").unwrap_err(),
3008 };
3009 let msg = invalid.to_string();
3010 assert!(msg.starts_with("invalid config for vendor 'bad':"));
3011 }
3012
3013 #[test]
3014 fn test_vendor_empty_object_in_yaml() {
3015 let yaml = r#"
3016server:
3017 home_dir: "~/.test_vendor"
3018vendor: {}
3019"#;
3020 let config: AppConfig = serde_saphyr::from_str(yaml).unwrap();
3021 assert!(config.vendor.is_empty());
3022 }
3023
3024 #[test]
3027 fn test_reject_duplicate_gear_names() {
3028 let tmp = tempdir().unwrap();
3029 let cfg_path = tmp.path().join("cfg.yaml");
3030 let yaml = r#"
3031server:
3032 home_dir: "~/.test_dup"
3033gears:
3034 gear1:
3035 config: {}
3036 gear2:
3037 config: {}
3038 gear1:
3039 config: {}
3040"#;
3041 fs::write(&cfg_path, yaml).unwrap();
3042
3043 let result = AppConfig::load_layered(&cfg_path);
3044 assert!(result.is_err(), "duplicate gear names should be rejected");
3045 let msg = format!("{:?}", result.unwrap_err());
3046 assert!(
3047 msg.contains("duplicate") || msg.contains("Duplicate"),
3048 "error should mention duplicates: {msg}"
3049 );
3050 }
3051
3052 #[test]
3053 fn test_reject_duplicate_keys_in_gear_file() {
3054 let tmp = tempdir().unwrap();
3055 let gears_dir = tmp.path().join("gears.d");
3056 fs::create_dir_all(&gears_dir).unwrap();
3057
3058 let gear_yaml = r#"
3060config:
3061 key1: "value1"
3062config:
3063 key2: "value2"
3064"#;
3065 fs::write(gears_dir.join("bad_gear.yaml"), gear_yaml).unwrap();
3066
3067 let cfg_yaml = format!(
3068 r#"
3069server:
3070 home_dir: "~/.test_dup_modfile"
3071gears_dir: "{}"
3072"#,
3073 normalize_path(&gears_dir)
3074 );
3075 let cfg_path = tmp.path().join("cfg.yaml");
3076 fs::write(&cfg_path, cfg_yaml).unwrap();
3077
3078 let result = AppConfig::load_layered(&cfg_path);
3079 assert!(
3080 result.is_err(),
3081 "duplicate keys in a gear file should be rejected"
3082 );
3083 let msg = format!("{:?}", result.unwrap_err());
3084 assert!(
3085 msg.contains("duplicate") || msg.contains("Duplicate"),
3086 "error should mention duplicates: {msg}"
3087 );
3088 }
3089
3090 #[test]
3091 fn test_no_false_positive_on_unique_gears() {
3092 let tmp = tempdir().unwrap();
3093 let cfg_path = tmp.path().join("cfg.yaml");
3094 let yaml = r#"
3095server:
3096 home_dir: "~/.test_ok"
3097gears:
3098 gear1:
3099 config: {}
3100 gear2:
3101 config: {}
3102 gear3:
3103 config: {}
3104"#;
3105 fs::write(&cfg_path, yaml).unwrap();
3106
3107 let result = AppConfig::load_layered(&cfg_path);
3108 assert!(
3109 result.is_ok(),
3110 "unique gear names should be accepted: {:?}",
3111 result.unwrap_err()
3112 );
3113 }
3114}
3115
3116