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>,
174}
175
176#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
185#[serde(default)]
186pub struct GholaConfig {
187 pub enabled: bool,
190
191 pub stealth: bool,
194
195 pub buffer_size: u32,
199}
200
201impl Default for GholaConfig {
202 fn default() -> Self {
203 Self {
204 enabled: false,
205 stealth: false,
206 buffer_size: 4096,
207 }
208 }
209}
210
211#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
213#[serde(default)]
214pub struct MarketConfig {
215 pub health: HealthThresholds,
217}
218
219#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
221#[serde(default)]
222pub struct WebConfig {
223 pub auth_token: Option<String>,
227}
228
229#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, clap::ValueEnum)]
231#[serde(rename_all = "lowercase")]
232pub enum OutputFormat {
233 #[default]
235 Table,
236
237 Json,
239
240 Csv,
242
243 #[value(name = "markdown")]
245 Markdown,
246}
247
248impl Default for OutputConfig {
249 fn default() -> Self {
250 Self {
251 format: OutputFormat::Table,
252 color: true,
253 }
254 }
255}
256
257impl Config {
258 pub fn load(path: Option<&Path>) -> Result<Self> {
287 let config_path = path
289 .map(PathBuf::from)
290 .or_else(|| std::env::var("SCOPE_CONFIG").ok().map(PathBuf::from))
291 .unwrap_or_else(Self::default_path);
292
293 if !config_path.exists() {
296 tracing::debug!(
297 path = %config_path.display(),
298 "No config file found, using defaults"
299 );
300 return Ok(Self::default());
301 }
302
303 tracing::debug!(path = %config_path.display(), "Loading configuration");
304
305 let contents = std::fs::read_to_string(&config_path).map_err(|e| {
306 ScopeError::Config(ConfigError::Read {
307 path: config_path.clone(),
308 source: e,
309 })
310 })?;
311
312 let config: Config =
313 serde_yaml::from_str(&contents).map_err(|e| ConfigError::Parse { source: e })?;
314
315 Ok(config)
316 }
317
318 pub fn default_path() -> PathBuf {
326 if let Some(home) = crate::paths::home_dir() {
328 let xdg_path = home.join(".config").join("scope").join("config.yaml");
329 if xdg_path.exists() {
330 return xdg_path;
331 }
332 }
333
334 if let Some(config_dir) = crate::paths::config_dir() {
336 let platform_path = config_dir.join("scope").join("config.yaml");
337 if platform_path.exists() {
338 return platform_path;
339 }
340 }
341
342 crate::paths::home_dir()
344 .map(|h| h.join(".config").join("scope").join("config.yaml"))
345 .unwrap_or_else(|| PathBuf::from(".").join("scope").join("config.yaml"))
346 }
347
348 pub fn config_path() -> Option<PathBuf> {
354 crate::paths::home_dir().map(|h| h.join(".config").join("scope").join("config.yaml"))
355 }
356
357 pub fn default_data_dir() -> PathBuf {
361 crate::paths::home_dir()
362 .map(|h| h.join(".config").join("scope").join("data"))
363 .unwrap_or_else(|| PathBuf::from(".config").join("scope").join("data"))
364 }
365
366 pub fn data_dir(&self) -> PathBuf {
368 self.address_book
369 .data_dir
370 .clone()
371 .unwrap_or_else(Self::default_data_dir)
372 }
373}
374
375impl std::fmt::Display for OutputFormat {
376 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
377 match self {
378 OutputFormat::Table => write!(f, "table"),
379 OutputFormat::Json => write!(f, "json"),
380 OutputFormat::Csv => write!(f, "csv"),
381 OutputFormat::Markdown => write!(f, "markdown"),
382 }
383 }
384}
385
386pub const DEFAULT_MONITOR_REFRESH_SECS: u64 = 5;
396
397#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, clap::ValueEnum)]
402#[serde(rename_all = "kebab-case")]
403pub enum LayoutPreset {
404 #[default]
406 Dashboard,
407 ChartFocus,
409 Feed,
411 Compact,
413 Exchange,
415}
416
417impl LayoutPreset {
418 pub fn next(&self) -> Self {
420 match self {
421 LayoutPreset::Dashboard => LayoutPreset::ChartFocus,
422 LayoutPreset::ChartFocus => LayoutPreset::Feed,
423 LayoutPreset::Feed => LayoutPreset::Compact,
424 LayoutPreset::Compact => LayoutPreset::Exchange,
425 LayoutPreset::Exchange => LayoutPreset::Dashboard,
426 }
427 }
428
429 pub fn prev(&self) -> Self {
431 match self {
432 LayoutPreset::Dashboard => LayoutPreset::Exchange,
433 LayoutPreset::ChartFocus => LayoutPreset::Dashboard,
434 LayoutPreset::Feed => LayoutPreset::ChartFocus,
435 LayoutPreset::Compact => LayoutPreset::Feed,
436 LayoutPreset::Exchange => LayoutPreset::Compact,
437 }
438 }
439
440 pub fn label(&self) -> &'static str {
442 match self {
443 LayoutPreset::Dashboard => "Dashboard",
444 LayoutPreset::ChartFocus => "Chart",
445 LayoutPreset::Feed => "Feed",
446 LayoutPreset::Compact => "Compact",
447 LayoutPreset::Exchange => "Exchange",
448 }
449 }
450}
451
452#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
457#[serde(default)]
458pub struct WidgetVisibility {
459 pub price_chart: bool,
461 pub volume_chart: bool,
463 pub buy_sell_pressure: bool,
465 pub metrics_panel: bool,
467 pub activity_log: bool,
469 pub holder_count: bool,
471 pub liquidity_depth: bool,
473}
474
475impl Default for WidgetVisibility {
476 fn default() -> Self {
477 Self {
478 price_chart: true,
479 volume_chart: true,
480 buy_sell_pressure: true,
481 metrics_panel: true,
482 activity_log: true,
483 holder_count: true,
484 liquidity_depth: true,
485 }
486 }
487}
488
489impl WidgetVisibility {
490 pub fn visible_count(&self) -> usize {
492 [
493 self.price_chart,
494 self.volume_chart,
495 self.buy_sell_pressure,
496 self.metrics_panel,
497 self.activity_log,
498 ]
499 .iter()
500 .filter(|&&v| v)
501 .count()
502 }
503
504 pub fn toggle_by_index(&mut self, index: usize) {
506 match index {
507 1 => self.price_chart = !self.price_chart,
508 2 => self.volume_chart = !self.volume_chart,
509 3 => self.buy_sell_pressure = !self.buy_sell_pressure,
510 4 => self.metrics_panel = !self.metrics_panel,
511 5 => self.activity_log = !self.activity_log,
512 _ => {}
513 }
514 }
515}
516
517#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, clap::ValueEnum)]
519#[serde(rename_all = "kebab-case")]
520pub enum ScaleMode {
521 #[default]
523 Linear,
524 Log,
526}
527
528impl ScaleMode {
529 pub fn toggle(&self) -> Self {
531 match self {
532 ScaleMode::Linear => ScaleMode::Log,
533 ScaleMode::Log => ScaleMode::Linear,
534 }
535 }
536
537 pub fn label(&self) -> &'static str {
539 match self {
540 ScaleMode::Linear => "Lin",
541 ScaleMode::Log => "Log",
542 }
543 }
544}
545
546#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, clap::ValueEnum)]
548#[serde(rename_all = "kebab-case")]
549pub enum ColorScheme {
550 #[default]
552 GreenRed,
553 BlueOrange,
555 Monochrome,
557}
558
559impl ColorScheme {
560 pub fn next(&self) -> Self {
562 match self {
563 ColorScheme::GreenRed => ColorScheme::BlueOrange,
564 ColorScheme::BlueOrange => ColorScheme::Monochrome,
565 ColorScheme::Monochrome => ColorScheme::GreenRed,
566 }
567 }
568
569 pub fn label(&self) -> &'static str {
571 match self {
572 ColorScheme::GreenRed => "green-red",
573 ColorScheme::BlueOrange => "blue-orange",
574 ColorScheme::Monochrome => "mono",
575 }
576 }
577}
578
579#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
581#[serde(default)]
582pub struct AlertConfig {
583 pub price_min: Option<f64>,
585 pub price_max: Option<f64>,
587 pub whale_min_usd: Option<f64>,
589 pub volume_spike_threshold_pct: Option<f64>,
591}
592
593impl Default for AlertConfig {
594 #[allow(clippy::derivable_impls)]
595 fn default() -> Self {
596 Self {
597 price_min: None,
598 price_max: None,
599 whale_min_usd: None,
600 volume_spike_threshold_pct: None,
601 }
602 }
603}
604
605#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
607#[serde(default)]
608pub struct ExportConfig {
609 pub path: Option<String>,
611}
612
613impl Default for ExportConfig {
614 #[allow(clippy::derivable_impls)]
615 fn default() -> Self {
616 Self { path: None }
617 }
618}
619
620#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
625#[serde(default)]
626pub struct MonitorConfig {
627 pub layout: LayoutPreset,
629 pub refresh_seconds: u64,
631 pub widgets: WidgetVisibility,
633 pub scale: ScaleMode,
635 pub color_scheme: ColorScheme,
637 pub alerts: AlertConfig,
639 pub export: ExportConfig,
641 pub auto_pause_on_input: bool,
643 #[serde(default)]
645 pub venue: Option<String>,
646}
647
648impl Default for MonitorConfig {
649 fn default() -> Self {
650 Self {
651 layout: LayoutPreset::Dashboard,
652 refresh_seconds: DEFAULT_MONITOR_REFRESH_SECS,
653 widgets: WidgetVisibility::default(),
654 scale: ScaleMode::Linear,
655 color_scheme: ColorScheme::GreenRed,
656 alerts: AlertConfig::default(),
657 export: ExportConfig::default(),
658 auto_pause_on_input: false,
659 venue: None,
660 }
661 }
662}
663
664#[cfg(test)]
669mod tests {
670 use super::*;
671 use std::io::Write;
672 use tempfile::NamedTempFile;
673
674 #[test]
675 fn test_default_config() {
676 let config = Config::default();
677
678 assert!(config.chains.api_keys.is_empty());
679 assert!(config.chains.ethereum_rpc.is_none());
680 assert!(config.chains.bsc_rpc.is_none());
681 assert!(config.chains.aegis_rpc.is_none());
682 assert!(config.chains.solana_rpc.is_none());
683 assert!(config.chains.tron_api.is_none());
684 assert_eq!(config.output.format, OutputFormat::Table);
685 assert!(config.output.color);
686 assert!(config.address_book.data_dir.is_none());
687 }
688
689 #[test]
690 fn test_load_from_yaml_full() {
691 let yaml = r#"
692chains:
693 ethereum_rpc: "https://example.com/rpc"
694 bsc_rpc: "https://bsc-dataseed.binance.org"
695 solana_rpc: "https://api.mainnet-beta.solana.com"
696 tron_api: "https://api.trongrid.io"
697 api_keys:
698 etherscan: "test-api-key"
699 polygonscan: "another-key"
700 bscscan: "bsc-key"
701 solscan: "sol-key"
702 tronscan: "tron-key"
703
704output:
705 format: json
706 color: false
707
708address_book:
709 data_dir: "/custom/data"
710"#;
711
712 let mut file = NamedTempFile::new().unwrap();
713 file.write_all(yaml.as_bytes()).unwrap();
714
715 let config = Config::load(Some(file.path())).unwrap();
716
717 assert_eq!(
719 config.chains.ethereum_rpc,
720 Some("https://example.com/rpc".into())
721 );
722 assert_eq!(
723 config.chains.bsc_rpc,
724 Some("https://bsc-dataseed.binance.org".into())
725 );
726
727 assert_eq!(
729 config.chains.solana_rpc,
730 Some("https://api.mainnet-beta.solana.com".into())
731 );
732 assert_eq!(
733 config.chains.tron_api,
734 Some("https://api.trongrid.io".into())
735 );
736
737 assert_eq!(
739 config.chains.api_keys.get("etherscan"),
740 Some(&"test-api-key".into())
741 );
742 assert_eq!(
743 config.chains.api_keys.get("polygonscan"),
744 Some(&"another-key".into())
745 );
746 assert_eq!(
747 config.chains.api_keys.get("bscscan"),
748 Some(&"bsc-key".into())
749 );
750 assert_eq!(
751 config.chains.api_keys.get("solscan"),
752 Some(&"sol-key".into())
753 );
754 assert_eq!(
755 config.chains.api_keys.get("tronscan"),
756 Some(&"tron-key".into())
757 );
758
759 assert_eq!(config.output.format, OutputFormat::Json);
760 assert!(!config.output.color);
761 assert_eq!(
762 config.address_book.data_dir,
763 Some(PathBuf::from("/custom/data"))
764 );
765 }
766
767 #[test]
768 fn test_load_partial_yaml_uses_defaults() {
769 let yaml = r#"
770chains:
771 ethereum_rpc: "https://partial.example.com"
772"#;
773
774 let mut file = NamedTempFile::new().unwrap();
775 file.write_all(yaml.as_bytes()).unwrap();
776
777 let config = Config::load(Some(file.path())).unwrap();
778
779 assert_eq!(
781 config.chains.ethereum_rpc,
782 Some("https://partial.example.com".into())
783 );
784
785 assert!(config.chains.api_keys.is_empty());
787 assert_eq!(config.output.format, OutputFormat::Table);
788 assert!(config.output.color);
789 assert_eq!(config.market.health, HealthThresholds::default());
790 }
791
792 #[test]
793 fn test_load_market_health_overrides_only_given_keys() {
794 let yaml = r#"
795market:
796 health:
797 min_levels: 20
798 max_spread_pct: 1.5
799"#;
800
801 let mut file = NamedTempFile::new().unwrap();
802 file.write_all(yaml.as_bytes()).unwrap();
803
804 let config = Config::load(Some(file.path())).unwrap();
805 let health = &config.market.health;
806
807 assert_eq!(health.min_levels, 20);
808 assert_eq!(health.max_spread_pct, 1.5);
809 let defaults = HealthThresholds::default();
811 assert_eq!(health.min_top3_depth, defaults.min_top3_depth);
812 assert_eq!(health.min_top10_depth, defaults.min_top10_depth);
813 assert_eq!(health.min_depth, defaults.min_depth);
814 }
815
816 #[test]
817 fn test_load_missing_file_returns_defaults() {
818 let config = Config::load(Some(Path::new("/nonexistent/path/config.yaml"))).unwrap();
819 assert_eq!(config, Config::default());
820 }
821
822 #[test]
823 fn test_load_invalid_yaml_returns_error() {
824 let mut file = NamedTempFile::new().unwrap();
825 file.write_all(b"invalid: yaml: : content: [").unwrap();
826
827 let result = Config::load(Some(file.path()));
828 assert!(result.is_err());
829 assert!(matches!(result.unwrap_err(), ScopeError::Config(_)));
830 }
831
832 #[test]
833 fn test_load_empty_file_returns_defaults() {
834 let file = NamedTempFile::new().unwrap();
835 let config = Config::load(Some(file.path())).unwrap();
838 assert_eq!(config, Config::default());
839 }
840
841 #[test]
842 fn test_output_format_serialization() {
843 let json_format = OutputFormat::Json;
844 let serialized = serde_yaml::to_string(&json_format).unwrap();
845 assert!(serialized.contains("json"));
846
847 let deserialized: OutputFormat = serde_yaml::from_str("csv").unwrap();
848 assert_eq!(deserialized, OutputFormat::Csv);
849 }
850
851 #[test]
852 fn test_output_format_display() {
853 assert_eq!(OutputFormat::Table.to_string(), "table");
854 assert_eq!(OutputFormat::Json.to_string(), "json");
855 assert_eq!(OutputFormat::Csv.to_string(), "csv");
856 assert_eq!(OutputFormat::Markdown.to_string(), "markdown");
857 }
858
859 #[test]
860 fn test_default_path_is_absolute_or_relative() {
861 let path = Config::default_path();
862 assert!(path.ends_with("scope/config.yaml") || path.ends_with("scope\\config.yaml"));
864 }
865
866 #[test]
867 fn test_default_data_dir() {
868 let data_dir = Config::default_data_dir();
869 assert!(data_dir.ends_with("scope") || data_dir.to_string_lossy().contains("scope"));
870 }
871
872 #[test]
873 fn test_data_dir_uses_config_value() {
874 let config = Config {
875 address_book: AddressBookConfig {
876 data_dir: Some(PathBuf::from("/custom/path")),
877 },
878 ..Default::default()
879 };
880
881 assert_eq!(config.data_dir(), PathBuf::from("/custom/path"));
882 }
883
884 #[test]
885 fn test_data_dir_falls_back_to_default() {
886 let config = Config::default();
887 assert_eq!(config.data_dir(), Config::default_data_dir());
888 }
889
890 #[test]
891 fn test_config_clone_and_eq() {
892 let config1 = Config::default();
893 let config2 = config1.clone();
894 assert_eq!(config1, config2);
895 }
896
897 #[test]
898 fn test_config_path_returns_some() {
899 let path = Config::config_path();
900 assert!(path.is_some());
902 assert!(path.unwrap().to_string_lossy().contains("scope"));
903 }
904
905 #[test]
906 fn test_config_debug() {
907 let config = Config::default();
908 let debug = format!("{:?}", config);
909 assert!(debug.contains("Config"));
910 assert!(debug.contains("ChainsConfig"));
911 }
912
913 #[test]
914 fn test_output_config_default() {
915 let output = OutputConfig::default();
916 assert_eq!(output.format, OutputFormat::Table);
917 assert!(output.color);
918 }
919
920 #[test]
921 fn test_config_serialization_roundtrip() {
922 let mut config = Config::default();
923 config
924 .chains
925 .api_keys
926 .insert("etherscan".to_string(), "test_key".to_string());
927 config.output.format = OutputFormat::Json;
928 config.output.color = false;
929 config.address_book.data_dir = Some(PathBuf::from("/custom"));
930
931 let yaml = serde_yaml::to_string(&config).unwrap();
932 let deserialized: Config = serde_yaml::from_str(&yaml).unwrap();
933 assert_eq!(config, deserialized);
934 }
935
936 #[test]
937 fn test_chains_config_with_multiple_api_keys() {
938 let mut api_keys = HashMap::new();
939 api_keys.insert("etherscan".into(), "key1".into());
940 api_keys.insert("polygonscan".into(), "key2".into());
941 api_keys.insert("bscscan".into(), "key3".into());
942
943 let chains = ChainsConfig {
944 ethereum_rpc: Some("https://rpc.example.com".into()),
945 api_keys,
946 ..Default::default()
947 };
948
949 assert_eq!(chains.api_keys.len(), 3);
950 assert!(chains.api_keys.contains_key("etherscan"));
951 }
952
953 #[test]
954 fn test_load_via_scope_config_env_var() {
955 let yaml = r#"
956chains:
957 ethereum_rpc: "https://env-test.example.com"
958output:
959 format: csv
960"#;
961 let mut file = NamedTempFile::new().unwrap();
962 file.write_all(yaml.as_bytes()).unwrap();
963
964 let path_str = file.path().to_string_lossy().to_string();
965 unsafe { std::env::set_var("SCOPE_CONFIG", &path_str) };
966
967 let config = Config::load(None).unwrap();
969 assert_eq!(
970 config.chains.ethereum_rpc,
971 Some("https://env-test.example.com".into())
972 );
973 assert_eq!(config.output.format, OutputFormat::Csv);
974
975 unsafe { std::env::remove_var("SCOPE_CONFIG") };
976 }
977
978 #[test]
979 fn test_output_format_default() {
980 let fmt = OutputFormat::default();
981 assert_eq!(fmt, OutputFormat::Table);
982 }
983
984 #[test]
985 fn test_address_book_config_default() {
986 let port = AddressBookConfig::default();
987 assert!(port.data_dir.is_none());
988 }
989
990 #[test]
991 fn test_chains_config_default() {
992 let chains = ChainsConfig::default();
993 assert!(chains.ethereum_rpc.is_none());
994 assert!(chains.bsc_rpc.is_none());
995 assert!(chains.aegis_rpc.is_none());
996 assert!(chains.solana_rpc.is_none());
997 assert!(chains.tron_api.is_none());
998 assert!(chains.api_keys.is_empty());
999 }
1000
1001 #[test]
1002 fn test_load_unreadable_file_returns_error() {
1003 let dir = tempfile::tempdir().unwrap();
1005 let config_path = dir.path().join("config.yaml");
1006 std::fs::create_dir_all(&config_path).unwrap();
1008 let result = Config::load(Some(&config_path));
1009 assert!(result.is_err());
1010 }
1011
1012 #[test]
1013 fn test_default_path_returns_valid_path() {
1014 let path = Config::default_path();
1015 assert!(path.to_str().unwrap().contains("scope"));
1017 }
1018
1019 #[test]
1024 fn test_ghola_config_default() {
1025 let ghola = GholaConfig::default();
1026 assert!(!ghola.enabled);
1027 assert!(!ghola.stealth);
1028 assert_eq!(ghola.buffer_size, 4096);
1029 }
1030
1031 #[test]
1032 fn test_config_default_ghola_disabled() {
1033 let config = Config::default();
1034 assert!(!config.ghola.enabled);
1035 assert!(!config.ghola.stealth);
1036 assert_eq!(config.ghola.buffer_size, 4096);
1037 }
1038
1039 #[test]
1040 fn test_load_ghola_enabled() {
1041 let yaml = r#"
1042ghola:
1043 enabled: true
1044 stealth: true
1045 buffer_size: 8192
1046"#;
1047 let mut file = NamedTempFile::new().unwrap();
1048 file.write_all(yaml.as_bytes()).unwrap();
1049
1050 let config = Config::load(Some(file.path())).unwrap();
1051 assert!(config.ghola.enabled);
1052 assert!(config.ghola.stealth);
1053 assert_eq!(config.ghola.buffer_size, 8192);
1054 }
1055
1056 #[test]
1057 fn test_load_ghola_partial() {
1058 let yaml = r#"
1059ghola:
1060 enabled: true
1061"#;
1062 let mut file = NamedTempFile::new().unwrap();
1063 file.write_all(yaml.as_bytes()).unwrap();
1064
1065 let config = Config::load(Some(file.path())).unwrap();
1066 assert!(config.ghola.enabled);
1067 assert!(!config.ghola.stealth); assert_eq!(config.ghola.buffer_size, 4096); }
1070
1071 #[test]
1072 fn test_load_ghola_absent_uses_defaults() {
1073 let yaml = r#"
1074chains:
1075 ethereum_rpc: "https://example.com"
1076"#;
1077 let mut file = NamedTempFile::new().unwrap();
1078 file.write_all(yaml.as_bytes()).unwrap();
1079
1080 let config = Config::load(Some(file.path())).unwrap();
1081 assert!(!config.ghola.enabled);
1082 assert!(!config.ghola.stealth);
1083 assert_eq!(config.ghola.buffer_size, 4096);
1084 }
1085
1086 #[test]
1087 fn test_load_ghola_custom_buffer_size() {
1088 let yaml = r#"
1089ghola:
1090 enabled: false
1091 buffer_size: 16384
1092"#;
1093 let mut file = NamedTempFile::new().unwrap();
1094 file.write_all(yaml.as_bytes()).unwrap();
1095
1096 let config = Config::load(Some(file.path())).unwrap();
1097 assert!(!config.ghola.enabled);
1098 assert_eq!(config.ghola.buffer_size, 16384);
1099 }
1100
1101 #[test]
1102 fn test_ghola_config_serialization_roundtrip() {
1103 let mut config = Config::default();
1104 config.ghola.enabled = true;
1105 config.ghola.stealth = true;
1106 config.ghola.buffer_size = 8192;
1107
1108 let yaml = serde_yaml::to_string(&config).unwrap();
1109 let deserialized: Config = serde_yaml::from_str(&yaml).unwrap();
1110 assert_eq!(config.ghola, deserialized.ghola);
1111 }
1112}