1use crate::error::{ConfigError, Result, ScopeError};
60use crate::market::HealthThresholds;
61use serde::{Deserialize, Serialize};
62use std::collections::HashMap;
63use std::path::{Path, PathBuf};
64
65#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
81#[serde(default)]
82pub struct Config {
83 pub chains: ChainsConfig,
85
86 pub output: OutputConfig,
88
89 #[serde(alias = "portfolio")]
91 pub address_book: AddressBookConfig,
92
93 pub monitor: MonitorConfig,
95
96 pub ghola: GholaConfig,
98
99 pub web: WebConfig,
101
102 pub market: MarketConfig,
104}
105
106#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
110#[serde(default)]
111pub struct ChainsConfig {
112 pub ethereum_rpc: Option<String>,
119
120 pub bsc_rpc: Option<String>,
124
125 #[doc(hidden)]
127 pub aegis_rpc: Option<String>,
128
129 pub solana_rpc: Option<String>,
136
137 pub tron_api: Option<String>,
141
142 pub api_keys: HashMap<String, String>,
150}
151
152#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
154#[serde(default)]
155pub struct OutputConfig {
156 pub format: OutputFormat,
158
159 pub color: bool,
161}
162
163#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
165#[serde(default)]
166pub struct AddressBookConfig {
167 pub data_dir: Option<PathBuf>,
171}
172
173#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
182#[serde(default)]
183pub struct GholaConfig {
184 pub enabled: bool,
187
188 pub stealth: bool,
191
192 pub buffer_size: u32,
196}
197
198impl Default for GholaConfig {
199 fn default() -> Self {
200 Self {
201 enabled: false,
202 stealth: false,
203 buffer_size: 4096,
204 }
205 }
206}
207
208#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
210#[serde(default)]
211pub struct MarketConfig {
212 pub health: HealthThresholds,
214}
215
216#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
218#[serde(default)]
219pub struct WebConfig {
220 pub auth_token: Option<String>,
224}
225
226#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, clap::ValueEnum)]
228#[serde(rename_all = "lowercase")]
229pub enum OutputFormat {
230 #[default]
232 Table,
233
234 Json,
236
237 Csv,
239
240 #[value(name = "markdown")]
242 Markdown,
243}
244
245impl Default for OutputConfig {
246 fn default() -> Self {
247 Self {
248 format: OutputFormat::Table,
249 color: true,
250 }
251 }
252}
253
254impl Config {
255 pub fn load(path: Option<&Path>) -> Result<Self> {
284 let config_path = path
286 .map(PathBuf::from)
287 .or_else(|| std::env::var("SCOPE_CONFIG").ok().map(PathBuf::from))
288 .unwrap_or_else(Self::default_path);
289
290 if !config_path.exists() {
293 tracing::debug!(
294 path = %config_path.display(),
295 "No config file found, using defaults"
296 );
297 return Ok(Self::default());
298 }
299
300 tracing::debug!(path = %config_path.display(), "Loading configuration");
301
302 let contents = std::fs::read_to_string(&config_path).map_err(|e| {
303 ScopeError::Config(ConfigError::Read {
304 path: config_path.clone(),
305 source: e,
306 })
307 })?;
308
309 let config: Config =
310 serde_yaml::from_str(&contents).map_err(|e| ConfigError::Parse { source: e })?;
311
312 Ok(config)
313 }
314
315 pub fn default_path() -> PathBuf {
323 if let Some(home) = dirs::home_dir() {
325 let xdg_path = home.join(".config").join("scope").join("config.yaml");
326 if xdg_path.exists() {
327 return xdg_path;
328 }
329 }
330
331 if let Some(config_dir) = dirs::config_dir() {
333 let platform_path = config_dir.join("scope").join("config.yaml");
334 if platform_path.exists() {
335 return platform_path;
336 }
337 }
338
339 dirs::home_dir()
341 .map(|h| h.join(".config").join("scope").join("config.yaml"))
342 .unwrap_or_else(|| PathBuf::from(".").join("scope").join("config.yaml"))
343 }
344
345 pub fn config_path() -> Option<PathBuf> {
351 dirs::home_dir().map(|h| h.join(".config").join("scope").join("config.yaml"))
352 }
353
354 pub fn default_data_dir() -> PathBuf {
358 dirs::home_dir()
359 .map(|h| h.join(".config").join("scope").join("data"))
360 .unwrap_or_else(|| PathBuf::from(".config").join("scope").join("data"))
361 }
362
363 pub fn data_dir(&self) -> PathBuf {
365 self.address_book
366 .data_dir
367 .clone()
368 .unwrap_or_else(Self::default_data_dir)
369 }
370}
371
372impl std::fmt::Display for OutputFormat {
373 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
374 match self {
375 OutputFormat::Table => write!(f, "table"),
376 OutputFormat::Json => write!(f, "json"),
377 OutputFormat::Csv => write!(f, "csv"),
378 OutputFormat::Markdown => write!(f, "markdown"),
379 }
380 }
381}
382
383pub const DEFAULT_MONITOR_REFRESH_SECS: u64 = 5;
393
394#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, clap::ValueEnum)]
399#[serde(rename_all = "kebab-case")]
400pub enum LayoutPreset {
401 #[default]
403 Dashboard,
404 ChartFocus,
406 Feed,
408 Compact,
410 Exchange,
412}
413
414impl LayoutPreset {
415 pub fn next(&self) -> Self {
417 match self {
418 LayoutPreset::Dashboard => LayoutPreset::ChartFocus,
419 LayoutPreset::ChartFocus => LayoutPreset::Feed,
420 LayoutPreset::Feed => LayoutPreset::Compact,
421 LayoutPreset::Compact => LayoutPreset::Exchange,
422 LayoutPreset::Exchange => LayoutPreset::Dashboard,
423 }
424 }
425
426 pub fn prev(&self) -> Self {
428 match self {
429 LayoutPreset::Dashboard => LayoutPreset::Exchange,
430 LayoutPreset::ChartFocus => LayoutPreset::Dashboard,
431 LayoutPreset::Feed => LayoutPreset::ChartFocus,
432 LayoutPreset::Compact => LayoutPreset::Feed,
433 LayoutPreset::Exchange => LayoutPreset::Compact,
434 }
435 }
436
437 pub fn label(&self) -> &'static str {
439 match self {
440 LayoutPreset::Dashboard => "Dashboard",
441 LayoutPreset::ChartFocus => "Chart",
442 LayoutPreset::Feed => "Feed",
443 LayoutPreset::Compact => "Compact",
444 LayoutPreset::Exchange => "Exchange",
445 }
446 }
447}
448
449#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
454#[serde(default)]
455pub struct WidgetVisibility {
456 pub price_chart: bool,
458 pub volume_chart: bool,
460 pub buy_sell_pressure: bool,
462 pub metrics_panel: bool,
464 pub activity_log: bool,
466 pub holder_count: bool,
468 pub liquidity_depth: bool,
470}
471
472impl Default for WidgetVisibility {
473 fn default() -> Self {
474 Self {
475 price_chart: true,
476 volume_chart: true,
477 buy_sell_pressure: true,
478 metrics_panel: true,
479 activity_log: true,
480 holder_count: true,
481 liquidity_depth: true,
482 }
483 }
484}
485
486impl WidgetVisibility {
487 pub fn visible_count(&self) -> usize {
489 [
490 self.price_chart,
491 self.volume_chart,
492 self.buy_sell_pressure,
493 self.metrics_panel,
494 self.activity_log,
495 ]
496 .iter()
497 .filter(|&&v| v)
498 .count()
499 }
500
501 pub fn toggle_by_index(&mut self, index: usize) {
503 match index {
504 1 => self.price_chart = !self.price_chart,
505 2 => self.volume_chart = !self.volume_chart,
506 3 => self.buy_sell_pressure = !self.buy_sell_pressure,
507 4 => self.metrics_panel = !self.metrics_panel,
508 5 => self.activity_log = !self.activity_log,
509 _ => {}
510 }
511 }
512}
513
514#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, clap::ValueEnum)]
516#[serde(rename_all = "kebab-case")]
517pub enum ScaleMode {
518 #[default]
520 Linear,
521 Log,
523}
524
525impl ScaleMode {
526 pub fn toggle(&self) -> Self {
528 match self {
529 ScaleMode::Linear => ScaleMode::Log,
530 ScaleMode::Log => ScaleMode::Linear,
531 }
532 }
533
534 pub fn label(&self) -> &'static str {
536 match self {
537 ScaleMode::Linear => "Lin",
538 ScaleMode::Log => "Log",
539 }
540 }
541}
542
543#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, clap::ValueEnum)]
545#[serde(rename_all = "kebab-case")]
546pub enum ColorScheme {
547 #[default]
549 GreenRed,
550 BlueOrange,
552 Monochrome,
554}
555
556impl ColorScheme {
557 pub fn next(&self) -> Self {
559 match self {
560 ColorScheme::GreenRed => ColorScheme::BlueOrange,
561 ColorScheme::BlueOrange => ColorScheme::Monochrome,
562 ColorScheme::Monochrome => ColorScheme::GreenRed,
563 }
564 }
565
566 pub fn label(&self) -> &'static str {
568 match self {
569 ColorScheme::GreenRed => "G/R",
570 ColorScheme::BlueOrange => "B/O",
571 ColorScheme::Monochrome => "Mono",
572 }
573 }
574}
575
576#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
578#[serde(default)]
579pub struct AlertConfig {
580 pub price_min: Option<f64>,
582 pub price_max: Option<f64>,
584 pub whale_min_usd: Option<f64>,
586 pub volume_spike_threshold_pct: Option<f64>,
588}
589
590impl Default for AlertConfig {
591 #[allow(clippy::derivable_impls)]
592 fn default() -> Self {
593 Self {
594 price_min: None,
595 price_max: None,
596 whale_min_usd: None,
597 volume_spike_threshold_pct: None,
598 }
599 }
600}
601
602#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
604#[serde(default)]
605pub struct ExportConfig {
606 pub path: Option<String>,
608}
609
610impl Default for ExportConfig {
611 #[allow(clippy::derivable_impls)]
612 fn default() -> Self {
613 Self { path: None }
614 }
615}
616
617#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
622#[serde(default)]
623pub struct MonitorConfig {
624 pub layout: LayoutPreset,
626 pub refresh_seconds: u64,
628 pub widgets: WidgetVisibility,
630 pub scale: ScaleMode,
632 pub color_scheme: ColorScheme,
634 pub alerts: AlertConfig,
636 pub export: ExportConfig,
638 pub auto_pause_on_input: bool,
640 #[serde(default)]
642 pub venue: Option<String>,
643}
644
645impl Default for MonitorConfig {
646 fn default() -> Self {
647 Self {
648 layout: LayoutPreset::Dashboard,
649 refresh_seconds: DEFAULT_MONITOR_REFRESH_SECS,
650 widgets: WidgetVisibility::default(),
651 scale: ScaleMode::Linear,
652 color_scheme: ColorScheme::GreenRed,
653 alerts: AlertConfig::default(),
654 export: ExportConfig::default(),
655 auto_pause_on_input: false,
656 venue: None,
657 }
658 }
659}
660
661#[cfg(test)]
666mod tests {
667 use super::*;
668 use std::io::Write;
669 use tempfile::NamedTempFile;
670
671 #[test]
672 fn test_default_config() {
673 let config = Config::default();
674
675 assert!(config.chains.api_keys.is_empty());
676 assert!(config.chains.ethereum_rpc.is_none());
677 assert!(config.chains.bsc_rpc.is_none());
678 assert!(config.chains.aegis_rpc.is_none());
679 assert!(config.chains.solana_rpc.is_none());
680 assert!(config.chains.tron_api.is_none());
681 assert_eq!(config.output.format, OutputFormat::Table);
682 assert!(config.output.color);
683 assert!(config.address_book.data_dir.is_none());
684 }
685
686 #[test]
687 fn test_load_from_yaml_full() {
688 let yaml = r#"
689chains:
690 ethereum_rpc: "https://example.com/rpc"
691 bsc_rpc: "https://bsc-dataseed.binance.org"
692 solana_rpc: "https://api.mainnet-beta.solana.com"
693 tron_api: "https://api.trongrid.io"
694 api_keys:
695 etherscan: "test-api-key"
696 polygonscan: "another-key"
697 bscscan: "bsc-key"
698 solscan: "sol-key"
699 tronscan: "tron-key"
700
701output:
702 format: json
703 color: false
704
705address_book:
706 data_dir: "/custom/data"
707"#;
708
709 let mut file = NamedTempFile::new().unwrap();
710 file.write_all(yaml.as_bytes()).unwrap();
711
712 let config = Config::load(Some(file.path())).unwrap();
713
714 assert_eq!(
716 config.chains.ethereum_rpc,
717 Some("https://example.com/rpc".into())
718 );
719 assert_eq!(
720 config.chains.bsc_rpc,
721 Some("https://bsc-dataseed.binance.org".into())
722 );
723
724 assert_eq!(
726 config.chains.solana_rpc,
727 Some("https://api.mainnet-beta.solana.com".into())
728 );
729 assert_eq!(
730 config.chains.tron_api,
731 Some("https://api.trongrid.io".into())
732 );
733
734 assert_eq!(
736 config.chains.api_keys.get("etherscan"),
737 Some(&"test-api-key".into())
738 );
739 assert_eq!(
740 config.chains.api_keys.get("polygonscan"),
741 Some(&"another-key".into())
742 );
743 assert_eq!(
744 config.chains.api_keys.get("bscscan"),
745 Some(&"bsc-key".into())
746 );
747 assert_eq!(
748 config.chains.api_keys.get("solscan"),
749 Some(&"sol-key".into())
750 );
751 assert_eq!(
752 config.chains.api_keys.get("tronscan"),
753 Some(&"tron-key".into())
754 );
755
756 assert_eq!(config.output.format, OutputFormat::Json);
757 assert!(!config.output.color);
758 assert_eq!(
759 config.address_book.data_dir,
760 Some(PathBuf::from("/custom/data"))
761 );
762 }
763
764 #[test]
765 fn test_load_partial_yaml_uses_defaults() {
766 let yaml = r#"
767chains:
768 ethereum_rpc: "https://partial.example.com"
769"#;
770
771 let mut file = NamedTempFile::new().unwrap();
772 file.write_all(yaml.as_bytes()).unwrap();
773
774 let config = Config::load(Some(file.path())).unwrap();
775
776 assert_eq!(
778 config.chains.ethereum_rpc,
779 Some("https://partial.example.com".into())
780 );
781
782 assert!(config.chains.api_keys.is_empty());
784 assert_eq!(config.output.format, OutputFormat::Table);
785 assert!(config.output.color);
786 assert_eq!(config.market.health, HealthThresholds::default());
787 }
788
789 #[test]
790 fn test_load_market_health_overrides_only_given_keys() {
791 let yaml = r#"
792market:
793 health:
794 min_levels: 20
795 max_spread_pct: 1.5
796"#;
797
798 let mut file = NamedTempFile::new().unwrap();
799 file.write_all(yaml.as_bytes()).unwrap();
800
801 let config = Config::load(Some(file.path())).unwrap();
802 let health = &config.market.health;
803
804 assert_eq!(health.min_levels, 20);
805 assert_eq!(health.max_spread_pct, 1.5);
806 let defaults = HealthThresholds::default();
808 assert_eq!(health.min_top3_depth, defaults.min_top3_depth);
809 assert_eq!(health.min_top10_depth, defaults.min_top10_depth);
810 assert_eq!(health.min_depth, defaults.min_depth);
811 }
812
813 #[test]
814 fn test_load_missing_file_returns_defaults() {
815 let config = Config::load(Some(Path::new("/nonexistent/path/config.yaml"))).unwrap();
816 assert_eq!(config, Config::default());
817 }
818
819 #[test]
820 fn test_load_invalid_yaml_returns_error() {
821 let mut file = NamedTempFile::new().unwrap();
822 file.write_all(b"invalid: yaml: : content: [").unwrap();
823
824 let result = Config::load(Some(file.path()));
825 assert!(result.is_err());
826 assert!(matches!(result.unwrap_err(), ScopeError::Config(_)));
827 }
828
829 #[test]
830 fn test_load_empty_file_returns_defaults() {
831 let file = NamedTempFile::new().unwrap();
832 let config = Config::load(Some(file.path())).unwrap();
835 assert_eq!(config, Config::default());
836 }
837
838 #[test]
839 fn test_output_format_serialization() {
840 let json_format = OutputFormat::Json;
841 let serialized = serde_yaml::to_string(&json_format).unwrap();
842 assert!(serialized.contains("json"));
843
844 let deserialized: OutputFormat = serde_yaml::from_str("csv").unwrap();
845 assert_eq!(deserialized, OutputFormat::Csv);
846 }
847
848 #[test]
849 fn test_output_format_display() {
850 assert_eq!(OutputFormat::Table.to_string(), "table");
851 assert_eq!(OutputFormat::Json.to_string(), "json");
852 assert_eq!(OutputFormat::Csv.to_string(), "csv");
853 assert_eq!(OutputFormat::Markdown.to_string(), "markdown");
854 }
855
856 #[test]
857 fn test_default_path_is_absolute_or_relative() {
858 let path = Config::default_path();
859 assert!(path.ends_with("scope/config.yaml") || path.ends_with("scope\\config.yaml"));
861 }
862
863 #[test]
864 fn test_default_data_dir() {
865 let data_dir = Config::default_data_dir();
866 assert!(data_dir.ends_with("scope") || data_dir.to_string_lossy().contains("scope"));
867 }
868
869 #[test]
870 fn test_data_dir_uses_config_value() {
871 let config = Config {
872 address_book: AddressBookConfig {
873 data_dir: Some(PathBuf::from("/custom/path")),
874 },
875 ..Default::default()
876 };
877
878 assert_eq!(config.data_dir(), PathBuf::from("/custom/path"));
879 }
880
881 #[test]
882 fn test_data_dir_falls_back_to_default() {
883 let config = Config::default();
884 assert_eq!(config.data_dir(), Config::default_data_dir());
885 }
886
887 #[test]
888 fn test_config_clone_and_eq() {
889 let config1 = Config::default();
890 let config2 = config1.clone();
891 assert_eq!(config1, config2);
892 }
893
894 #[test]
895 fn test_config_path_returns_some() {
896 let path = Config::config_path();
897 assert!(path.is_some());
899 assert!(path.unwrap().to_string_lossy().contains("scope"));
900 }
901
902 #[test]
903 fn test_config_debug() {
904 let config = Config::default();
905 let debug = format!("{:?}", config);
906 assert!(debug.contains("Config"));
907 assert!(debug.contains("ChainsConfig"));
908 }
909
910 #[test]
911 fn test_output_config_default() {
912 let output = OutputConfig::default();
913 assert_eq!(output.format, OutputFormat::Table);
914 assert!(output.color);
915 }
916
917 #[test]
918 fn test_config_serialization_roundtrip() {
919 let mut config = Config::default();
920 config
921 .chains
922 .api_keys
923 .insert("etherscan".to_string(), "test_key".to_string());
924 config.output.format = OutputFormat::Json;
925 config.output.color = false;
926 config.address_book.data_dir = Some(PathBuf::from("/custom"));
927
928 let yaml = serde_yaml::to_string(&config).unwrap();
929 let deserialized: Config = serde_yaml::from_str(&yaml).unwrap();
930 assert_eq!(config, deserialized);
931 }
932
933 #[test]
934 fn test_chains_config_with_multiple_api_keys() {
935 let mut api_keys = HashMap::new();
936 api_keys.insert("etherscan".into(), "key1".into());
937 api_keys.insert("polygonscan".into(), "key2".into());
938 api_keys.insert("bscscan".into(), "key3".into());
939
940 let chains = ChainsConfig {
941 ethereum_rpc: Some("https://rpc.example.com".into()),
942 api_keys,
943 ..Default::default()
944 };
945
946 assert_eq!(chains.api_keys.len(), 3);
947 assert!(chains.api_keys.contains_key("etherscan"));
948 }
949
950 #[test]
951 fn test_load_via_scope_config_env_var() {
952 let yaml = r#"
953chains:
954 ethereum_rpc: "https://env-test.example.com"
955output:
956 format: csv
957"#;
958 let mut file = NamedTempFile::new().unwrap();
959 file.write_all(yaml.as_bytes()).unwrap();
960
961 let path_str = file.path().to_string_lossy().to_string();
962 unsafe { std::env::set_var("SCOPE_CONFIG", &path_str) };
963
964 let config = Config::load(None).unwrap();
966 assert_eq!(
967 config.chains.ethereum_rpc,
968 Some("https://env-test.example.com".into())
969 );
970 assert_eq!(config.output.format, OutputFormat::Csv);
971
972 unsafe { std::env::remove_var("SCOPE_CONFIG") };
973 }
974
975 #[test]
976 fn test_output_format_default() {
977 let fmt = OutputFormat::default();
978 assert_eq!(fmt, OutputFormat::Table);
979 }
980
981 #[test]
982 fn test_address_book_config_default() {
983 let port = AddressBookConfig::default();
984 assert!(port.data_dir.is_none());
985 }
986
987 #[test]
988 fn test_chains_config_default() {
989 let chains = ChainsConfig::default();
990 assert!(chains.ethereum_rpc.is_none());
991 assert!(chains.bsc_rpc.is_none());
992 assert!(chains.aegis_rpc.is_none());
993 assert!(chains.solana_rpc.is_none());
994 assert!(chains.tron_api.is_none());
995 assert!(chains.api_keys.is_empty());
996 }
997
998 #[test]
999 fn test_load_unreadable_file_returns_error() {
1000 let dir = tempfile::tempdir().unwrap();
1002 let config_path = dir.path().join("config.yaml");
1003 std::fs::create_dir_all(&config_path).unwrap();
1005 let result = Config::load(Some(&config_path));
1006 assert!(result.is_err());
1007 }
1008
1009 #[test]
1010 fn test_default_path_returns_valid_path() {
1011 let path = Config::default_path();
1012 assert!(path.to_str().unwrap().contains("scope"));
1014 }
1015
1016 #[test]
1021 fn test_ghola_config_default() {
1022 let ghola = GholaConfig::default();
1023 assert!(!ghola.enabled);
1024 assert!(!ghola.stealth);
1025 assert_eq!(ghola.buffer_size, 4096);
1026 }
1027
1028 #[test]
1029 fn test_config_default_ghola_disabled() {
1030 let config = Config::default();
1031 assert!(!config.ghola.enabled);
1032 assert!(!config.ghola.stealth);
1033 assert_eq!(config.ghola.buffer_size, 4096);
1034 }
1035
1036 #[test]
1037 fn test_load_ghola_enabled() {
1038 let yaml = r#"
1039ghola:
1040 enabled: true
1041 stealth: true
1042 buffer_size: 8192
1043"#;
1044 let mut file = NamedTempFile::new().unwrap();
1045 file.write_all(yaml.as_bytes()).unwrap();
1046
1047 let config = Config::load(Some(file.path())).unwrap();
1048 assert!(config.ghola.enabled);
1049 assert!(config.ghola.stealth);
1050 assert_eq!(config.ghola.buffer_size, 8192);
1051 }
1052
1053 #[test]
1054 fn test_load_ghola_partial() {
1055 let yaml = r#"
1056ghola:
1057 enabled: true
1058"#;
1059 let mut file = NamedTempFile::new().unwrap();
1060 file.write_all(yaml.as_bytes()).unwrap();
1061
1062 let config = Config::load(Some(file.path())).unwrap();
1063 assert!(config.ghola.enabled);
1064 assert!(!config.ghola.stealth); assert_eq!(config.ghola.buffer_size, 4096); }
1067
1068 #[test]
1069 fn test_load_ghola_absent_uses_defaults() {
1070 let yaml = r#"
1071chains:
1072 ethereum_rpc: "https://example.com"
1073"#;
1074 let mut file = NamedTempFile::new().unwrap();
1075 file.write_all(yaml.as_bytes()).unwrap();
1076
1077 let config = Config::load(Some(file.path())).unwrap();
1078 assert!(!config.ghola.enabled);
1079 assert!(!config.ghola.stealth);
1080 assert_eq!(config.ghola.buffer_size, 4096);
1081 }
1082
1083 #[test]
1084 fn test_load_ghola_custom_buffer_size() {
1085 let yaml = r#"
1086ghola:
1087 enabled: false
1088 buffer_size: 16384
1089"#;
1090 let mut file = NamedTempFile::new().unwrap();
1091 file.write_all(yaml.as_bytes()).unwrap();
1092
1093 let config = Config::load(Some(file.path())).unwrap();
1094 assert!(!config.ghola.enabled);
1095 assert_eq!(config.ghola.buffer_size, 16384);
1096 }
1097
1098 #[test]
1099 fn test_ghola_config_serialization_roundtrip() {
1100 let mut config = Config::default();
1101 config.ghola.enabled = true;
1102 config.ghola.stealth = true;
1103 config.ghola.buffer_size = 8192;
1104
1105 let yaml = serde_yaml::to_string(&config).unwrap();
1106 let deserialized: Config = serde_yaml::from_str(&yaml).unwrap();
1107 assert_eq!(config.ghola, deserialized.ghola);
1108 }
1109}