1use crate::error::{ConfigError, Result, ScopeError};
63use crate::market::HealthThresholds;
64use serde::{Deserialize, Serialize};
65use std::collections::HashMap;
66use std::path::{Path, PathBuf};
67
68#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
84#[serde(default)]
85pub struct Config {
86 pub chains: ChainsConfig,
88
89 pub output: OutputConfig,
91
92 #[serde(alias = "portfolio")]
94 pub address_book: AddressBookConfig,
95
96 pub monitor: MonitorConfig,
98
99 pub ghola: GholaConfig,
101
102 pub web: WebConfig,
104
105 pub market: MarketConfig,
107}
108
109#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
113#[serde(default)]
114pub struct ChainsConfig {
115 pub ethereum_rpc: Option<String>,
122
123 pub bsc_rpc: Option<String>,
127
128 #[doc(hidden)]
130 pub aegis_rpc: Option<String>,
131
132 pub solana_rpc: Option<String>,
139
140 pub tron_api: Option<String>,
144
145 pub api_keys: HashMap<String, String>,
153}
154
155#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
157#[serde(default)]
158pub struct OutputConfig {
159 pub format: OutputFormat,
161
162 pub color: bool,
164}
165
166#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
168#[serde(default)]
169pub struct AddressBookConfig {
170 pub data_dir: Option<PathBuf>,
175}
176
177#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
186#[serde(default)]
187pub struct GholaConfig {
188 pub enabled: bool,
191
192 pub stealth: bool,
195
196 pub buffer_size: u32,
200}
201
202impl Default for GholaConfig {
203 fn default() -> Self {
204 Self {
205 enabled: false,
206 stealth: false,
207 buffer_size: 4096,
208 }
209 }
210}
211
212#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
214#[serde(default)]
215pub struct MarketConfig {
216 pub health: HealthThresholds,
218 #[serde(skip_serializing_if = "Option::is_none")]
221 pub venue_catalog_url: Option<String>,
222}
223
224impl MarketConfig {
225 pub fn catalog_url(&self) -> &str {
227 self.venue_catalog_url
228 .as_deref()
229 .filter(|u| !u.trim().is_empty())
230 .unwrap_or(crate::market::catalog::DEFAULT_CATALOG_URL)
231 }
232}
233
234#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
236#[serde(default)]
237pub struct WebConfig {
238 pub auth_token: Option<String>,
242}
243
244#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, clap::ValueEnum)]
246#[serde(rename_all = "lowercase")]
247pub enum OutputFormat {
248 #[default]
250 Table,
251
252 Json,
254
255 Csv,
257
258 #[value(name = "markdown")]
260 Markdown,
261}
262
263impl Default for OutputConfig {
264 fn default() -> Self {
265 Self {
266 format: OutputFormat::Table,
267 color: true,
268 }
269 }
270}
271
272impl Config {
273 pub fn load(path: Option<&Path>) -> Result<Self> {
302 let config_path = path
304 .map(PathBuf::from)
305 .or_else(|| std::env::var("SCOPE_CONFIG").ok().map(PathBuf::from))
306 .unwrap_or_else(Self::default_path);
307
308 if !config_path.exists() {
311 tracing::debug!(
312 path = %config_path.display(),
313 "No config file found, using defaults"
314 );
315 return Ok(Self::default());
316 }
317
318 tracing::debug!(path = %config_path.display(), "Loading configuration");
319
320 let contents = std::fs::read_to_string(&config_path).map_err(|e| {
321 ScopeError::Config(ConfigError::Read {
322 path: config_path.clone(),
323 source: e,
324 })
325 })?;
326
327 let config: Config =
328 serde_saphyr::from_str(&contents).map_err(|e| ConfigError::Parse { source: e })?;
329
330 Ok(config)
331 }
332
333 pub fn default_path() -> PathBuf {
336 crate::paths::config_file()
337 }
338
339 pub fn config_path() -> Option<PathBuf> {
342 Some(crate::paths::config_file())
343 }
344
345 pub fn default_data_dir() -> PathBuf {
348 crate::paths::data_dir()
349 }
350
351 pub fn data_dir(&self) -> PathBuf {
354 match &self.address_book.data_dir {
355 Some(dir) => expand_home(dir, crate::paths::user_home()),
356 None => Self::default_data_dir(),
357 }
358 }
359}
360
361fn expand_home(dir: &Path, home: Option<PathBuf>) -> PathBuf {
364 match (dir.strip_prefix("~"), home) {
365 (Ok(rest), Some(home)) => home.join(rest),
366 _ => dir.to_path_buf(),
367 }
368}
369
370impl std::fmt::Display for OutputFormat {
371 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
372 match self {
373 OutputFormat::Table => write!(f, "table"),
374 OutputFormat::Json => write!(f, "json"),
375 OutputFormat::Csv => write!(f, "csv"),
376 OutputFormat::Markdown => write!(f, "markdown"),
377 }
378 }
379}
380
381pub const DEFAULT_MONITOR_REFRESH_SECS: u64 = 5;
391
392#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, clap::ValueEnum)]
397#[serde(rename_all = "kebab-case")]
398pub enum LayoutPreset {
399 #[default]
401 Dashboard,
402 ChartFocus,
404 Feed,
406 Compact,
408 Exchange,
410}
411
412impl LayoutPreset {
413 pub fn next(&self) -> Self {
415 match self {
416 LayoutPreset::Dashboard => LayoutPreset::ChartFocus,
417 LayoutPreset::ChartFocus => LayoutPreset::Feed,
418 LayoutPreset::Feed => LayoutPreset::Compact,
419 LayoutPreset::Compact => LayoutPreset::Exchange,
420 LayoutPreset::Exchange => LayoutPreset::Dashboard,
421 }
422 }
423
424 pub fn prev(&self) -> Self {
426 match self {
427 LayoutPreset::Dashboard => LayoutPreset::Exchange,
428 LayoutPreset::ChartFocus => LayoutPreset::Dashboard,
429 LayoutPreset::Feed => LayoutPreset::ChartFocus,
430 LayoutPreset::Compact => LayoutPreset::Feed,
431 LayoutPreset::Exchange => LayoutPreset::Compact,
432 }
433 }
434
435 pub fn label(&self) -> &'static str {
437 match self {
438 LayoutPreset::Dashboard => "Dashboard",
439 LayoutPreset::ChartFocus => "Chart",
440 LayoutPreset::Feed => "Feed",
441 LayoutPreset::Compact => "Compact",
442 LayoutPreset::Exchange => "Exchange",
443 }
444 }
445}
446
447#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
452#[serde(default)]
453pub struct WidgetVisibility {
454 pub price_chart: bool,
456 pub volume_chart: bool,
458 pub buy_sell_pressure: bool,
460 pub metrics_panel: bool,
462 pub activity_log: bool,
464 pub holder_count: bool,
466 pub liquidity_depth: bool,
468}
469
470impl Default for WidgetVisibility {
471 fn default() -> Self {
472 Self {
473 price_chart: true,
474 volume_chart: true,
475 buy_sell_pressure: true,
476 metrics_panel: true,
477 activity_log: true,
478 holder_count: true,
479 liquidity_depth: true,
480 }
481 }
482}
483
484impl WidgetVisibility {
485 pub fn visible_count(&self) -> usize {
487 [
488 self.price_chart,
489 self.volume_chart,
490 self.buy_sell_pressure,
491 self.metrics_panel,
492 self.activity_log,
493 ]
494 .iter()
495 .filter(|&&v| v)
496 .count()
497 }
498
499 pub fn toggle_by_index(&mut self, index: usize) {
501 match index {
502 1 => self.price_chart = !self.price_chart,
503 2 => self.volume_chart = !self.volume_chart,
504 3 => self.buy_sell_pressure = !self.buy_sell_pressure,
505 4 => self.metrics_panel = !self.metrics_panel,
506 5 => self.activity_log = !self.activity_log,
507 _ => {}
508 }
509 }
510}
511
512#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, clap::ValueEnum)]
514#[serde(rename_all = "kebab-case")]
515pub enum ScaleMode {
516 #[default]
518 Linear,
519 Log,
521}
522
523impl ScaleMode {
524 pub fn toggle(&self) -> Self {
526 match self {
527 ScaleMode::Linear => ScaleMode::Log,
528 ScaleMode::Log => ScaleMode::Linear,
529 }
530 }
531
532 pub fn label(&self) -> &'static str {
534 match self {
535 ScaleMode::Linear => "Lin",
536 ScaleMode::Log => "Log",
537 }
538 }
539}
540
541#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, clap::ValueEnum)]
543#[serde(rename_all = "kebab-case")]
544pub enum ColorScheme {
545 #[default]
547 GreenRed,
548 BlueOrange,
550 Monochrome,
552}
553
554impl ColorScheme {
555 pub fn next(&self) -> Self {
557 match self {
558 ColorScheme::GreenRed => ColorScheme::BlueOrange,
559 ColorScheme::BlueOrange => ColorScheme::Monochrome,
560 ColorScheme::Monochrome => ColorScheme::GreenRed,
561 }
562 }
563
564 pub fn label(&self) -> &'static str {
566 match self {
567 ColorScheme::GreenRed => "green-red",
568 ColorScheme::BlueOrange => "blue-orange",
569 ColorScheme::Monochrome => "mono",
570 }
571 }
572}
573
574#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
576#[serde(default)]
577pub struct AlertConfig {
578 pub price_min: Option<f64>,
580 pub price_max: Option<f64>,
582 pub whale_min_usd: Option<f64>,
584 pub volume_spike_threshold_pct: Option<f64>,
586}
587
588impl Default for AlertConfig {
589 #[allow(clippy::derivable_impls)]
590 fn default() -> Self {
591 Self {
592 price_min: None,
593 price_max: None,
594 whale_min_usd: None,
595 volume_spike_threshold_pct: None,
596 }
597 }
598}
599
600#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
602#[serde(default)]
603pub struct ExportConfig {
604 pub path: Option<String>,
606}
607
608impl Default for ExportConfig {
609 #[allow(clippy::derivable_impls)]
610 fn default() -> Self {
611 Self { path: None }
612 }
613}
614
615#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
620#[serde(default)]
621pub struct MonitorConfig {
622 pub layout: LayoutPreset,
624 pub refresh_seconds: u64,
626 pub widgets: WidgetVisibility,
628 pub scale: ScaleMode,
630 pub color_scheme: ColorScheme,
632 pub alerts: AlertConfig,
634 pub export: ExportConfig,
636 pub auto_pause_on_input: bool,
638 #[serde(default)]
640 pub venue: Option<String>,
641}
642
643impl Default for MonitorConfig {
644 fn default() -> Self {
645 Self {
646 layout: LayoutPreset::Dashboard,
647 refresh_seconds: DEFAULT_MONITOR_REFRESH_SECS,
648 widgets: WidgetVisibility::default(),
649 scale: ScaleMode::Linear,
650 color_scheme: ColorScheme::GreenRed,
651 alerts: AlertConfig::default(),
652 export: ExportConfig::default(),
653 auto_pause_on_input: false,
654 venue: None,
655 }
656 }
657}
658
659#[cfg(test)]
664mod tests {
665 use super::*;
666 use std::io::Write;
667 use tempfile::NamedTempFile;
668
669 #[test]
670 fn test_a_leading_tilde_in_data_dir_is_the_home_folder() {
671 let home = Some(PathBuf::from("/home/u"));
672 assert_eq!(
673 expand_home(Path::new("~/.scope-bca/data"), home.clone()),
674 PathBuf::from("/home/u").join(".scope-bca/data")
675 );
676 assert_eq!(
677 expand_home(Path::new("~"), home.clone()),
678 PathBuf::from("/home/u")
679 );
680 assert_eq!(
682 expand_home(Path::new("~x/d"), home.clone()),
683 PathBuf::from("~x/d")
684 );
685 assert_eq!(
686 expand_home(Path::new("/a/~/d"), home),
687 PathBuf::from("/a/~/d")
688 );
689 assert_eq!(expand_home(Path::new("~/d"), None), PathBuf::from("~/d"));
691 }
692
693 #[test]
694 fn test_default_config() {
695 let config = Config::default();
696
697 assert!(config.chains.api_keys.is_empty());
698 assert!(config.chains.ethereum_rpc.is_none());
699 assert!(config.chains.bsc_rpc.is_none());
700 assert!(config.chains.aegis_rpc.is_none());
701 assert!(config.chains.solana_rpc.is_none());
702 assert!(config.chains.tron_api.is_none());
703 assert_eq!(config.output.format, OutputFormat::Table);
704 assert!(config.output.color);
705 assert!(config.address_book.data_dir.is_none());
706 }
707
708 #[test]
709 fn test_load_from_yaml_full() {
710 let yaml = r#"
711chains:
712 ethereum_rpc: "https://example.com/rpc"
713 bsc_rpc: "https://bsc-dataseed.binance.org"
714 solana_rpc: "https://api.mainnet-beta.solana.com"
715 tron_api: "https://api.trongrid.io"
716 api_keys:
717 etherscan: "test-api-key"
718 polygonscan: "another-key"
719 bscscan: "bsc-key"
720 solscan: "sol-key"
721 tronscan: "tron-key"
722
723output:
724 format: json
725 color: false
726
727address_book:
728 data_dir: "/custom/data"
729"#;
730
731 let mut file = NamedTempFile::new().unwrap();
732 file.write_all(yaml.as_bytes()).unwrap();
733
734 let config = Config::load(Some(file.path())).unwrap();
735
736 assert_eq!(
738 config.chains.ethereum_rpc,
739 Some("https://example.com/rpc".into())
740 );
741 assert_eq!(
742 config.chains.bsc_rpc,
743 Some("https://bsc-dataseed.binance.org".into())
744 );
745
746 assert_eq!(
748 config.chains.solana_rpc,
749 Some("https://api.mainnet-beta.solana.com".into())
750 );
751 assert_eq!(
752 config.chains.tron_api,
753 Some("https://api.trongrid.io".into())
754 );
755
756 assert_eq!(
758 config.chains.api_keys.get("etherscan"),
759 Some(&"test-api-key".into())
760 );
761 assert_eq!(
762 config.chains.api_keys.get("polygonscan"),
763 Some(&"another-key".into())
764 );
765 assert_eq!(
766 config.chains.api_keys.get("bscscan"),
767 Some(&"bsc-key".into())
768 );
769 assert_eq!(
770 config.chains.api_keys.get("solscan"),
771 Some(&"sol-key".into())
772 );
773 assert_eq!(
774 config.chains.api_keys.get("tronscan"),
775 Some(&"tron-key".into())
776 );
777
778 assert_eq!(config.output.format, OutputFormat::Json);
779 assert!(!config.output.color);
780 assert_eq!(
781 config.address_book.data_dir,
782 Some(PathBuf::from("/custom/data"))
783 );
784 }
785
786 #[test]
787 fn test_load_partial_yaml_uses_defaults() {
788 let yaml = r#"
789chains:
790 ethereum_rpc: "https://partial.example.com"
791"#;
792
793 let mut file = NamedTempFile::new().unwrap();
794 file.write_all(yaml.as_bytes()).unwrap();
795
796 let config = Config::load(Some(file.path())).unwrap();
797
798 assert_eq!(
800 config.chains.ethereum_rpc,
801 Some("https://partial.example.com".into())
802 );
803
804 assert!(config.chains.api_keys.is_empty());
806 assert_eq!(config.output.format, OutputFormat::Table);
807 assert!(config.output.color);
808 assert_eq!(config.market.health, HealthThresholds::default());
809 }
810
811 #[test]
812 fn test_load_market_health_overrides_only_given_keys() {
813 let yaml = r#"
814market:
815 health:
816 min_levels: 20
817 max_spread_pct: 1.5
818"#;
819
820 let mut file = NamedTempFile::new().unwrap();
821 file.write_all(yaml.as_bytes()).unwrap();
822
823 let config = Config::load(Some(file.path())).unwrap();
824 let health = &config.market.health;
825
826 assert_eq!(health.min_levels, 20);
827 assert_eq!(health.max_spread_pct, 1.5);
828 let defaults = HealthThresholds::default();
830 assert_eq!(health.min_top3_depth, defaults.min_top3_depth);
831 assert_eq!(health.min_top10_depth, defaults.min_top10_depth);
832 assert_eq!(health.min_depth, defaults.min_depth);
833 }
834
835 #[test]
836 fn test_load_missing_file_returns_defaults() {
837 let config = Config::load(Some(Path::new("/nonexistent/path/config.yaml"))).unwrap();
838 assert_eq!(config, Config::default());
839 }
840
841 #[test]
842 fn test_load_invalid_yaml_returns_error() {
843 let mut file = NamedTempFile::new().unwrap();
844 file.write_all(b"invalid: yaml: : content: [").unwrap();
845
846 let result = Config::load(Some(file.path()));
847 assert!(result.is_err());
848 assert!(matches!(result.unwrap_err(), ScopeError::Config(_)));
849 }
850
851 #[test]
852 fn test_load_empty_file_returns_defaults() {
853 let file = NamedTempFile::new().unwrap();
854 let config = Config::load(Some(file.path())).unwrap();
857 assert_eq!(config, Config::default());
858 }
859
860 #[test]
861 fn test_output_format_serialization() {
862 let json_format = OutputFormat::Json;
863 let serialized = serde_saphyr::to_string(&json_format).unwrap();
864 assert!(serialized.contains("json"));
865
866 let deserialized: OutputFormat = serde_saphyr::from_str("csv").unwrap();
867 assert_eq!(deserialized, OutputFormat::Csv);
868 }
869
870 #[test]
871 fn test_output_format_display() {
872 assert_eq!(OutputFormat::Table.to_string(), "table");
873 assert_eq!(OutputFormat::Json.to_string(), "json");
874 assert_eq!(OutputFormat::Csv.to_string(), "csv");
875 assert_eq!(OutputFormat::Markdown.to_string(), "markdown");
876 }
877
878 #[test]
879 fn test_default_path_is_absolute_or_relative() {
880 assert_eq!(Config::default_path(), crate::paths::config_file());
882 assert!(Config::default_path().ends_with(".scope-bca/config.yaml"));
883 }
884
885 #[test]
886 fn test_default_data_dir() {
887 let data_dir = Config::default_data_dir();
888 assert!(data_dir.ends_with("scope") || data_dir.to_string_lossy().contains("scope"));
889 }
890
891 #[test]
892 fn test_data_dir_uses_config_value() {
893 let config = Config {
894 address_book: AddressBookConfig {
895 data_dir: Some(PathBuf::from("/custom/path")),
896 },
897 ..Default::default()
898 };
899
900 assert_eq!(config.data_dir(), PathBuf::from("/custom/path"));
901 }
902
903 #[test]
904 fn test_data_dir_falls_back_to_default() {
905 let config = Config::default();
906 assert_eq!(config.data_dir(), Config::default_data_dir());
907 }
908
909 #[test]
910 fn test_config_clone_and_eq() {
911 let config1 = Config::default();
912 let config2 = config1.clone();
913 assert_eq!(config1, config2);
914 }
915
916 #[test]
917 fn test_config_path_returns_some() {
918 let path = Config::config_path();
919 assert!(path.is_some());
921 assert!(path.unwrap().to_string_lossy().contains("scope"));
922 }
923
924 #[test]
925 fn test_config_debug() {
926 let config = Config::default();
927 let debug = format!("{:?}", config);
928 assert!(debug.contains("Config"));
929 assert!(debug.contains("ChainsConfig"));
930 }
931
932 #[test]
933 fn test_output_config_default() {
934 let output = OutputConfig::default();
935 assert_eq!(output.format, OutputFormat::Table);
936 assert!(output.color);
937 }
938
939 #[test]
940 fn test_config_serialization_roundtrip() {
941 let mut config = Config::default();
942 config
943 .chains
944 .api_keys
945 .insert("etherscan".to_string(), "test_key".to_string());
946 config.output.format = OutputFormat::Json;
947 config.output.color = false;
948 config.address_book.data_dir = Some(PathBuf::from("/custom"));
949
950 let yaml = serde_saphyr::to_string(&config).unwrap();
951 let deserialized: Config = serde_saphyr::from_str(&yaml).unwrap();
952 assert_eq!(config, deserialized);
953 }
954
955 #[test]
956 fn test_chains_config_with_multiple_api_keys() {
957 let mut api_keys = HashMap::new();
958 api_keys.insert("etherscan".into(), "key1".into());
959 api_keys.insert("polygonscan".into(), "key2".into());
960 api_keys.insert("bscscan".into(), "key3".into());
961
962 let chains = ChainsConfig {
963 ethereum_rpc: Some("https://rpc.example.com".into()),
964 api_keys,
965 ..Default::default()
966 };
967
968 assert_eq!(chains.api_keys.len(), 3);
969 assert!(chains.api_keys.contains_key("etherscan"));
970 }
971
972 #[test]
973 fn test_load_via_scope_config_env_var() {
974 let yaml = r#"
975chains:
976 ethereum_rpc: "https://env-test.example.com"
977output:
978 format: csv
979"#;
980 let mut file = NamedTempFile::new().unwrap();
981 file.write_all(yaml.as_bytes()).unwrap();
982
983 let path_str = file.path().to_string_lossy().to_string();
984 unsafe { std::env::set_var("SCOPE_CONFIG", &path_str) };
985
986 let config = Config::load(None).unwrap();
988 assert_eq!(
989 config.chains.ethereum_rpc,
990 Some("https://env-test.example.com".into())
991 );
992 assert_eq!(config.output.format, OutputFormat::Csv);
993
994 unsafe { std::env::remove_var("SCOPE_CONFIG") };
995 }
996
997 #[test]
998 fn test_output_format_default() {
999 let fmt = OutputFormat::default();
1000 assert_eq!(fmt, OutputFormat::Table);
1001 }
1002
1003 #[test]
1004 fn test_address_book_config_default() {
1005 let port = AddressBookConfig::default();
1006 assert!(port.data_dir.is_none());
1007 }
1008
1009 #[test]
1010 fn test_chains_config_default() {
1011 let chains = ChainsConfig::default();
1012 assert!(chains.ethereum_rpc.is_none());
1013 assert!(chains.bsc_rpc.is_none());
1014 assert!(chains.aegis_rpc.is_none());
1015 assert!(chains.solana_rpc.is_none());
1016 assert!(chains.tron_api.is_none());
1017 assert!(chains.api_keys.is_empty());
1018 }
1019
1020 #[test]
1021 fn test_load_unreadable_file_returns_error() {
1022 let dir = tempfile::tempdir().unwrap();
1024 let config_path = dir.path().join("config.yaml");
1025 std::fs::create_dir_all(&config_path).unwrap();
1027 let result = Config::load(Some(&config_path));
1028 assert!(result.is_err());
1029 }
1030
1031 #[test]
1032 fn test_default_path_returns_valid_path() {
1033 let path = Config::default_path();
1034 assert!(path.to_str().unwrap().contains("scope"));
1036 }
1037
1038 #[test]
1043 fn test_ghola_config_default() {
1044 let ghola = GholaConfig::default();
1045 assert!(!ghola.enabled);
1046 assert!(!ghola.stealth);
1047 assert_eq!(ghola.buffer_size, 4096);
1048 }
1049
1050 #[test]
1051 fn test_config_default_ghola_disabled() {
1052 let config = Config::default();
1053 assert!(!config.ghola.enabled);
1054 assert!(!config.ghola.stealth);
1055 assert_eq!(config.ghola.buffer_size, 4096);
1056 }
1057
1058 #[test]
1059 fn test_load_ghola_enabled() {
1060 let yaml = r#"
1061ghola:
1062 enabled: true
1063 stealth: true
1064 buffer_size: 8192
1065"#;
1066 let mut file = NamedTempFile::new().unwrap();
1067 file.write_all(yaml.as_bytes()).unwrap();
1068
1069 let config = Config::load(Some(file.path())).unwrap();
1070 assert!(config.ghola.enabled);
1071 assert!(config.ghola.stealth);
1072 assert_eq!(config.ghola.buffer_size, 8192);
1073 }
1074
1075 #[test]
1076 fn test_load_ghola_partial() {
1077 let yaml = r#"
1078ghola:
1079 enabled: true
1080"#;
1081 let mut file = NamedTempFile::new().unwrap();
1082 file.write_all(yaml.as_bytes()).unwrap();
1083
1084 let config = Config::load(Some(file.path())).unwrap();
1085 assert!(config.ghola.enabled);
1086 assert!(!config.ghola.stealth); assert_eq!(config.ghola.buffer_size, 4096); }
1089
1090 #[test]
1091 fn test_load_ghola_absent_uses_defaults() {
1092 let yaml = r#"
1093chains:
1094 ethereum_rpc: "https://example.com"
1095"#;
1096 let mut file = NamedTempFile::new().unwrap();
1097 file.write_all(yaml.as_bytes()).unwrap();
1098
1099 let config = Config::load(Some(file.path())).unwrap();
1100 assert!(!config.ghola.enabled);
1101 assert!(!config.ghola.stealth);
1102 assert_eq!(config.ghola.buffer_size, 4096);
1103 }
1104
1105 #[test]
1106 fn test_load_ghola_custom_buffer_size() {
1107 let yaml = r#"
1108ghola:
1109 enabled: false
1110 buffer_size: 16384
1111"#;
1112 let mut file = NamedTempFile::new().unwrap();
1113 file.write_all(yaml.as_bytes()).unwrap();
1114
1115 let config = Config::load(Some(file.path())).unwrap();
1116 assert!(!config.ghola.enabled);
1117 assert_eq!(config.ghola.buffer_size, 16384);
1118 }
1119
1120 #[test]
1121 fn test_ghola_config_serialization_roundtrip() {
1122 let mut config = Config::default();
1123 config.ghola.enabled = true;
1124 config.ghola.stealth = true;
1125 config.ghola.buffer_size = 8192;
1126
1127 let yaml = serde_saphyr::to_string(&config).unwrap();
1128 let deserialized: Config = serde_saphyr::from_str(&yaml).unwrap();
1129 assert_eq!(config.ghola, deserialized.ghola);
1130 }
1131
1132 #[test]
1133 fn test_the_venue_catalog_url_defaults_unless_set() {
1134 let mut market = MarketConfig::default();
1135 assert_eq!(
1136 market.catalog_url(),
1137 crate::market::catalog::DEFAULT_CATALOG_URL
1138 );
1139 market.venue_catalog_url = Some(" ".into());
1140 assert_eq!(
1141 market.catalog_url(),
1142 crate::market::catalog::DEFAULT_CATALOG_URL
1143 );
1144 market.venue_catalog_url = Some("https://example.com/cat".into());
1145 assert_eq!(market.catalog_url(), "https://example.com/cat");
1146 let read: Config =
1147 serde_saphyr::from_str("market:\n venue_catalog_url: https://x/c\n").unwrap();
1148 assert_eq!(read.market.catalog_url(), "https://x/c");
1149 }
1150}