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