Skip to main content

scope/
config.rs

1//! # Configuration Management
2//!
3//! This module handles loading, merging, and validating configuration
4//! from multiple sources with the following priority (highest to lowest):
5//!
6//! 1. CLI arguments
7//! 2. Environment variables (`SCOPE_*` prefix)
8//! 3. User config file (`~/.config/scope/config.yaml`)
9//! 4. Built-in defaults
10//!
11//! ## Configuration File Format
12//!
13//! ```yaml
14//! chains:
15//!   # EVM-compatible chains
16//!   ethereum_rpc: "https://mainnet.infura.io/v3/YOUR_KEY"
17//!   bsc_rpc: "https://bsc-dataseed.binance.org"
18//!
19//!   # Non-EVM chains
20//!   solana_rpc: "https://api.mainnet-beta.solana.com"
21//!   tron_api: "https://api.trongrid.io"
22//!
23//!   api_keys:
24//!     etherscan: "YOUR_API_KEY"
25//!     bscscan: "YOUR_API_KEY"
26//!     solscan: "YOUR_API_KEY"
27//!     tronscan: "YOUR_API_KEY"
28//!
29//! output:
30//!   format: table  # table, json, csv, markdown
31//!   color: true
32//!
33//! address_book:
34//!   data_dir: "~/.local/share/scope"
35//!
36//! ghola:
37//!   enabled: false        # route HTTP through Ghola sidecar
38//!   stealth: false        # apply temporal drift + ghost signing
39//!   buffer_size: 4096     # read buffer for large response headers
40//! ```
41//!
42//! ## Error Handling
43//!
44//! Configuration errors are wrapped in [`ScopeError::Config`] and include
45//! context about which source caused the failure.
46
47use crate::error::{ConfigError, Result, ScopeError};
48use serde::{Deserialize, Serialize};
49use std::collections::HashMap;
50use std::path::{Path, PathBuf};
51
52/// Application configuration.
53///
54/// Contains all settings for blockchain clients, output formatting,
55/// and address book management. Use [`Config::load`] to load from file
56/// or [`Config::default`] for sensible defaults.
57///
58/// # Examples
59///
60/// ```rust
61/// use scope::Config;
62///
63/// // Load from default location or custom path
64/// let config = Config::load(None).unwrap_or_default();
65/// println!("Output format: {:?}", config.output.format);
66/// ```
67#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
68#[serde(default)]
69pub struct Config {
70    /// Blockchain chain client configuration.
71    pub chains: ChainsConfig,
72
73    /// Output formatting configuration.
74    pub output: OutputConfig,
75
76    /// Address book configuration (label → address mapping storage).
77    #[serde(alias = "portfolio")]
78    pub address_book: AddressBookConfig,
79
80    /// Monitor TUI configuration (layout, widgets, refresh rate).
81    pub monitor: crate::cli::monitor::MonitorConfig,
82
83    /// Ghola sidecar configuration for stealth HTTP transport.
84    pub ghola: GholaConfig,
85}
86
87/// Blockchain client configuration.
88///
89/// Contains RPC endpoints and API keys for various blockchain networks.
90#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
91#[serde(default)]
92pub struct ChainsConfig {
93    // =========================================================================
94    // EVM-Compatible Chains
95    // =========================================================================
96    /// Ethereum JSON-RPC endpoint URL.
97    ///
98    /// Example: `https://mainnet.infura.io/v3/YOUR_PROJECT_ID`
99    pub ethereum_rpc: Option<String>,
100
101    /// BSC (BNB Smart Chain) JSON-RPC endpoint URL.
102    ///
103    /// Example: `https://bsc-dataseed.binance.org`
104    pub bsc_rpc: Option<String>,
105
106    /// Custom EVM chain JSON-RPC endpoint (reserved for internal use).
107    #[doc(hidden)]
108    pub aegis_rpc: Option<String>,
109
110    // =========================================================================
111    // Non-EVM Chains
112    // =========================================================================
113    /// Solana JSON-RPC endpoint URL.
114    ///
115    /// Example: `https://api.mainnet-beta.solana.com`
116    pub solana_rpc: Option<String>,
117
118    /// Tron API endpoint URL (TronGrid).
119    ///
120    /// Example: `https://api.trongrid.io`
121    pub tron_api: Option<String>,
122
123    // =========================================================================
124    // API Keys
125    // =========================================================================
126    /// API keys for block explorer services.
127    ///
128    /// Keys are service names (e.g., "etherscan", "polygonscan", "bscscan",
129    /// "solscan", "tronscan").
130    pub api_keys: HashMap<String, String>,
131}
132
133/// Output formatting configuration.
134#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
135#[serde(default)]
136pub struct OutputConfig {
137    /// Output format for analysis results.
138    pub format: OutputFormat,
139
140    /// Whether to use colored output in the terminal.
141    pub color: bool,
142}
143
144/// Address book configuration.
145#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
146#[serde(default)]
147pub struct AddressBookConfig {
148    /// Directory for storing address book data.
149    ///
150    /// Defaults to `~/.local/share/scope` on Linux/macOS.
151    pub data_dir: Option<PathBuf>,
152}
153
154/// Ghola sidecar configuration.
155///
156/// Controls whether HTTP requests are routed through the
157/// [Ghola](https://github.com/robot-accomplice/ghola) sidecar for
158/// stealth analysis (temporal drift, ghost signing, chain-aware headers).
159///
160/// Ghola is an external Go binary; all fields default to `false` / sensible
161/// values so existing installations see no behavior change.
162#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
163#[serde(default)]
164pub struct GholaConfig {
165    /// When `true`, route HTTP requests through the Ghola sidecar
166    /// (`127.0.0.1:18789`) instead of using `reqwest` directly.
167    pub enabled: bool,
168
169    /// When `true` (and `enabled` is `true`), the sidecar applies
170    /// temporal drift and ghost signing to every outgoing request.
171    pub stealth: bool,
172
173    /// Read buffer size (in bytes) for handling large response headers.
174    /// Passed to the Ghola sidecar which uses it as `fasthttp.Client.ReadBufferSize`.
175    /// Default: 4096. Increase for APIs that return very large headers.
176    pub buffer_size: u32,
177}
178
179impl Default for GholaConfig {
180    fn default() -> Self {
181        Self {
182            enabled: false,
183            stealth: false,
184            buffer_size: 4096,
185        }
186    }
187}
188
189/// Available output formats for analysis results.
190#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, clap::ValueEnum)]
191#[serde(rename_all = "lowercase")]
192pub enum OutputFormat {
193    /// Human-readable table format (default).
194    #[default]
195    Table,
196
197    /// JSON format for programmatic consumption.
198    Json,
199
200    /// CSV format for spreadsheet import.
201    Csv,
202
203    /// Markdown format for agent parsing (console output).
204    #[value(name = "markdown")]
205    Markdown,
206}
207
208impl Default for OutputConfig {
209    fn default() -> Self {
210        Self {
211            format: OutputFormat::Table,
212            color: true,
213        }
214    }
215}
216
217impl Config {
218    /// Loads configuration from a file path or the default location.
219    ///
220    /// # Arguments
221    ///
222    /// * `path` - Optional path to a configuration file. If `None`, looks
223    ///   for config at `~/.config/scope/config.yaml`.
224    ///
225    /// # Returns
226    ///
227    /// Returns the loaded configuration, or defaults if no config file exists.
228    ///
229    /// # Errors
230    ///
231    /// Returns [`ScopeError::Config`] if the file exists but cannot be read
232    /// or contains invalid YAML.
233    ///
234    /// # Examples
235    ///
236    /// ```rust
237    /// use scope::Config;
238    /// use std::path::Path;
239    ///
240    /// // Load from default location
241    /// let config = Config::load(None).unwrap_or_default();
242    ///
243    /// // Load from custom path
244    /// let config = Config::load(Some(Path::new("/custom/config.yaml")));
245    /// ```
246    pub fn load(path: Option<&Path>) -> Result<Self> {
247        // Determine config path: CLI arg > env var > default location
248        let config_path = path
249            .map(PathBuf::from)
250            .or_else(|| std::env::var("SCOPE_CONFIG").ok().map(PathBuf::from))
251            .unwrap_or_else(Self::default_path);
252
253        // Return defaults if no config file exists
254        // This allows first-run without manual setup
255        if !config_path.exists() {
256            tracing::debug!(
257                path = %config_path.display(),
258                "No config file found, using defaults"
259            );
260            return Ok(Self::default());
261        }
262
263        tracing::debug!(path = %config_path.display(), "Loading configuration");
264
265        let contents = std::fs::read_to_string(&config_path).map_err(|e| {
266            ScopeError::Config(ConfigError::Read {
267                path: config_path.clone(),
268                source: e,
269            })
270        })?;
271
272        let config: Config =
273            serde_yaml::from_str(&contents).map_err(|e| ConfigError::Parse { source: e })?;
274
275        Ok(config)
276    }
277
278    /// Returns the default configuration file path.
279    ///
280    /// Checks multiple locations in order:
281    /// 1. `~/.config/scope/config.yaml` (XDG-style, preferred for CLI tools)
282    /// 2. Platform-specific config dir (e.g., `~/Library/Application Support/` on macOS)
283    ///
284    /// Returns the first location that exists, or the XDG-style path if neither exists.
285    pub fn default_path() -> PathBuf {
286        // Prefer XDG-style ~/.config/scope/ which is common for CLI tools
287        if let Some(home) = dirs::home_dir() {
288            let xdg_path = home.join(".config").join("scope").join("config.yaml");
289            if xdg_path.exists() {
290                return xdg_path;
291            }
292        }
293
294        // Check platform-specific config dir
295        if let Some(config_dir) = dirs::config_dir() {
296            let platform_path = config_dir.join("scope").join("config.yaml");
297            if platform_path.exists() {
298                return platform_path;
299            }
300        }
301
302        // Default to XDG-style path for new configs
303        dirs::home_dir()
304            .map(|h| h.join(".config").join("scope").join("config.yaml"))
305            .unwrap_or_else(|| PathBuf::from(".").join("scope").join("config.yaml"))
306    }
307
308    /// Returns the configuration file path, if it can be determined.
309    ///
310    /// Returns `Some(path)` for the config file location, or `None` if
311    /// the path cannot be determined (e.g., no home directory).
312    /// Prefers the XDG-style `~/.config/scope/` location.
313    pub fn config_path() -> Option<PathBuf> {
314        dirs::home_dir().map(|h| h.join(".config").join("scope").join("config.yaml"))
315    }
316
317    /// Returns the default data directory for address book storage.
318    ///
319    /// Uses `~/.config/scope/data` on all platforms for consistency.
320    pub fn default_data_dir() -> PathBuf {
321        dirs::home_dir()
322            .map(|h| h.join(".config").join("scope").join("data"))
323            .unwrap_or_else(|| PathBuf::from(".config").join("scope").join("data"))
324    }
325
326    /// Returns the effective data directory, using config or default.
327    pub fn data_dir(&self) -> PathBuf {
328        self.address_book
329            .data_dir
330            .clone()
331            .unwrap_or_else(Self::default_data_dir)
332    }
333}
334
335impl std::fmt::Display for OutputFormat {
336    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
337        match self {
338            OutputFormat::Table => write!(f, "table"),
339            OutputFormat::Json => write!(f, "json"),
340            OutputFormat::Csv => write!(f, "csv"),
341            OutputFormat::Markdown => write!(f, "markdown"),
342        }
343    }
344}
345
346// ============================================================================
347// Unit Tests
348// ============================================================================
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353    use std::io::Write;
354    use tempfile::NamedTempFile;
355
356    #[test]
357    fn test_default_config() {
358        let config = Config::default();
359
360        assert!(config.chains.api_keys.is_empty());
361        assert!(config.chains.ethereum_rpc.is_none());
362        assert!(config.chains.bsc_rpc.is_none());
363        assert!(config.chains.aegis_rpc.is_none());
364        assert!(config.chains.solana_rpc.is_none());
365        assert!(config.chains.tron_api.is_none());
366        assert_eq!(config.output.format, OutputFormat::Table);
367        assert!(config.output.color);
368        assert!(config.address_book.data_dir.is_none());
369    }
370
371    #[test]
372    fn test_load_from_yaml_full() {
373        let yaml = r#"
374chains:
375  ethereum_rpc: "https://example.com/rpc"
376  bsc_rpc: "https://bsc-dataseed.binance.org"
377  solana_rpc: "https://api.mainnet-beta.solana.com"
378  tron_api: "https://api.trongrid.io"
379  api_keys:
380    etherscan: "test-api-key"
381    polygonscan: "another-key"
382    bscscan: "bsc-key"
383    solscan: "sol-key"
384    tronscan: "tron-key"
385
386output:
387  format: json
388  color: false
389
390address_book:
391  data_dir: "/custom/data"
392"#;
393
394        let mut file = NamedTempFile::new().unwrap();
395        file.write_all(yaml.as_bytes()).unwrap();
396
397        let config = Config::load(Some(file.path())).unwrap();
398
399        // EVM chains
400        assert_eq!(
401            config.chains.ethereum_rpc,
402            Some("https://example.com/rpc".into())
403        );
404        assert_eq!(
405            config.chains.bsc_rpc,
406            Some("https://bsc-dataseed.binance.org".into())
407        );
408
409        // Non-EVM chains
410        assert_eq!(
411            config.chains.solana_rpc,
412            Some("https://api.mainnet-beta.solana.com".into())
413        );
414        assert_eq!(
415            config.chains.tron_api,
416            Some("https://api.trongrid.io".into())
417        );
418
419        // API keys
420        assert_eq!(
421            config.chains.api_keys.get("etherscan"),
422            Some(&"test-api-key".into())
423        );
424        assert_eq!(
425            config.chains.api_keys.get("polygonscan"),
426            Some(&"another-key".into())
427        );
428        assert_eq!(
429            config.chains.api_keys.get("bscscan"),
430            Some(&"bsc-key".into())
431        );
432        assert_eq!(
433            config.chains.api_keys.get("solscan"),
434            Some(&"sol-key".into())
435        );
436        assert_eq!(
437            config.chains.api_keys.get("tronscan"),
438            Some(&"tron-key".into())
439        );
440
441        assert_eq!(config.output.format, OutputFormat::Json);
442        assert!(!config.output.color);
443        assert_eq!(
444            config.address_book.data_dir,
445            Some(PathBuf::from("/custom/data"))
446        );
447    }
448
449    #[test]
450    fn test_load_partial_yaml_uses_defaults() {
451        let yaml = r#"
452chains:
453  ethereum_rpc: "https://partial.example.com"
454"#;
455
456        let mut file = NamedTempFile::new().unwrap();
457        file.write_all(yaml.as_bytes()).unwrap();
458
459        let config = Config::load(Some(file.path())).unwrap();
460
461        // Specified value
462        assert_eq!(
463            config.chains.ethereum_rpc,
464            Some("https://partial.example.com".into())
465        );
466
467        // Defaults
468        assert!(config.chains.api_keys.is_empty());
469        assert_eq!(config.output.format, OutputFormat::Table);
470        assert!(config.output.color);
471    }
472
473    #[test]
474    fn test_load_missing_file_returns_defaults() {
475        let config = Config::load(Some(Path::new("/nonexistent/path/config.yaml"))).unwrap();
476        assert_eq!(config, Config::default());
477    }
478
479    #[test]
480    fn test_load_invalid_yaml_returns_error() {
481        let mut file = NamedTempFile::new().unwrap();
482        file.write_all(b"invalid: yaml: : content: [").unwrap();
483
484        let result = Config::load(Some(file.path()));
485        assert!(result.is_err());
486        assert!(matches!(result.unwrap_err(), ScopeError::Config(_)));
487    }
488
489    #[test]
490    fn test_load_empty_file_returns_defaults() {
491        let file = NamedTempFile::new().unwrap();
492        // Empty file
493
494        let config = Config::load(Some(file.path())).unwrap();
495        assert_eq!(config, Config::default());
496    }
497
498    #[test]
499    fn test_output_format_serialization() {
500        let json_format = OutputFormat::Json;
501        let serialized = serde_yaml::to_string(&json_format).unwrap();
502        assert!(serialized.contains("json"));
503
504        let deserialized: OutputFormat = serde_yaml::from_str("csv").unwrap();
505        assert_eq!(deserialized, OutputFormat::Csv);
506    }
507
508    #[test]
509    fn test_output_format_display() {
510        assert_eq!(OutputFormat::Table.to_string(), "table");
511        assert_eq!(OutputFormat::Json.to_string(), "json");
512        assert_eq!(OutputFormat::Csv.to_string(), "csv");
513        assert_eq!(OutputFormat::Markdown.to_string(), "markdown");
514    }
515
516    #[test]
517    fn test_default_path_is_absolute_or_relative() {
518        let path = Config::default_path();
519        // Should end with expected structure
520        assert!(path.ends_with("scope/config.yaml") || path.ends_with("scope\\config.yaml"));
521    }
522
523    #[test]
524    fn test_default_data_dir() {
525        let data_dir = Config::default_data_dir();
526        assert!(data_dir.ends_with("scope") || data_dir.to_string_lossy().contains("scope"));
527    }
528
529    #[test]
530    fn test_data_dir_uses_config_value() {
531        let config = Config {
532            address_book: AddressBookConfig {
533                data_dir: Some(PathBuf::from("/custom/path")),
534            },
535            ..Default::default()
536        };
537
538        assert_eq!(config.data_dir(), PathBuf::from("/custom/path"));
539    }
540
541    #[test]
542    fn test_data_dir_falls_back_to_default() {
543        let config = Config::default();
544        assert_eq!(config.data_dir(), Config::default_data_dir());
545    }
546
547    #[test]
548    fn test_config_clone_and_eq() {
549        let config1 = Config::default();
550        let config2 = config1.clone();
551        assert_eq!(config1, config2);
552    }
553
554    #[test]
555    fn test_config_path_returns_some() {
556        let path = Config::config_path();
557        // Should return Some on systems with a home dir
558        assert!(path.is_some());
559        assert!(path.unwrap().to_string_lossy().contains("scope"));
560    }
561
562    #[test]
563    fn test_config_debug() {
564        let config = Config::default();
565        let debug = format!("{:?}", config);
566        assert!(debug.contains("Config"));
567        assert!(debug.contains("ChainsConfig"));
568    }
569
570    #[test]
571    fn test_output_config_default() {
572        let output = OutputConfig::default();
573        assert_eq!(output.format, OutputFormat::Table);
574        assert!(output.color);
575    }
576
577    #[test]
578    fn test_config_serialization_roundtrip() {
579        let mut config = Config::default();
580        config
581            .chains
582            .api_keys
583            .insert("etherscan".to_string(), "test_key".to_string());
584        config.output.format = OutputFormat::Json;
585        config.output.color = false;
586        config.address_book.data_dir = Some(PathBuf::from("/custom"));
587
588        let yaml = serde_yaml::to_string(&config).unwrap();
589        let deserialized: Config = serde_yaml::from_str(&yaml).unwrap();
590        assert_eq!(config, deserialized);
591    }
592
593    #[test]
594    fn test_chains_config_with_multiple_api_keys() {
595        let mut api_keys = HashMap::new();
596        api_keys.insert("etherscan".into(), "key1".into());
597        api_keys.insert("polygonscan".into(), "key2".into());
598        api_keys.insert("bscscan".into(), "key3".into());
599
600        let chains = ChainsConfig {
601            ethereum_rpc: Some("https://rpc.example.com".into()),
602            api_keys,
603            ..Default::default()
604        };
605
606        assert_eq!(chains.api_keys.len(), 3);
607        assert!(chains.api_keys.contains_key("etherscan"));
608    }
609
610    #[test]
611    fn test_load_via_scope_config_env_var() {
612        let yaml = r#"
613chains:
614  ethereum_rpc: "https://env-test.example.com"
615output:
616  format: csv
617"#;
618        let mut file = NamedTempFile::new().unwrap();
619        file.write_all(yaml.as_bytes()).unwrap();
620
621        let path_str = file.path().to_string_lossy().to_string();
622        unsafe { std::env::set_var("SCOPE_CONFIG", &path_str) };
623
624        // Load with None path — should pick up the env var
625        let config = Config::load(None).unwrap();
626        assert_eq!(
627            config.chains.ethereum_rpc,
628            Some("https://env-test.example.com".into())
629        );
630        assert_eq!(config.output.format, OutputFormat::Csv);
631
632        unsafe { std::env::remove_var("SCOPE_CONFIG") };
633    }
634
635    #[test]
636    fn test_output_format_default() {
637        let fmt = OutputFormat::default();
638        assert_eq!(fmt, OutputFormat::Table);
639    }
640
641    #[test]
642    fn test_address_book_config_default() {
643        let port = AddressBookConfig::default();
644        assert!(port.data_dir.is_none());
645    }
646
647    #[test]
648    fn test_chains_config_default() {
649        let chains = ChainsConfig::default();
650        assert!(chains.ethereum_rpc.is_none());
651        assert!(chains.bsc_rpc.is_none());
652        assert!(chains.aegis_rpc.is_none());
653        assert!(chains.solana_rpc.is_none());
654        assert!(chains.tron_api.is_none());
655        assert!(chains.api_keys.is_empty());
656    }
657
658    #[test]
659    fn test_load_unreadable_file_returns_error() {
660        // Create a directory where a file is expected to trigger read error
661        let dir = tempfile::tempdir().unwrap();
662        let config_path = dir.path().join("config.yaml");
663        // Create it as a directory so reading it as a file fails
664        std::fs::create_dir_all(&config_path).unwrap();
665        let result = Config::load(Some(&config_path));
666        assert!(result.is_err());
667    }
668
669    #[test]
670    fn test_default_path_returns_valid_path() {
671        let path = Config::default_path();
672        // Should always return some path (may or may not exist)
673        assert!(path.to_str().unwrap().contains("scope"));
674    }
675
676    // ========================================================================
677    // Ghola Configuration Tests
678    // ========================================================================
679
680    #[test]
681    fn test_ghola_config_default() {
682        let ghola = GholaConfig::default();
683        assert!(!ghola.enabled);
684        assert!(!ghola.stealth);
685        assert_eq!(ghola.buffer_size, 4096);
686    }
687
688    #[test]
689    fn test_config_default_ghola_disabled() {
690        let config = Config::default();
691        assert!(!config.ghola.enabled);
692        assert!(!config.ghola.stealth);
693        assert_eq!(config.ghola.buffer_size, 4096);
694    }
695
696    #[test]
697    fn test_load_ghola_enabled() {
698        let yaml = r#"
699ghola:
700  enabled: true
701  stealth: true
702  buffer_size: 8192
703"#;
704        let mut file = NamedTempFile::new().unwrap();
705        file.write_all(yaml.as_bytes()).unwrap();
706
707        let config = Config::load(Some(file.path())).unwrap();
708        assert!(config.ghola.enabled);
709        assert!(config.ghola.stealth);
710        assert_eq!(config.ghola.buffer_size, 8192);
711    }
712
713    #[test]
714    fn test_load_ghola_partial() {
715        let yaml = r#"
716ghola:
717  enabled: true
718"#;
719        let mut file = NamedTempFile::new().unwrap();
720        file.write_all(yaml.as_bytes()).unwrap();
721
722        let config = Config::load(Some(file.path())).unwrap();
723        assert!(config.ghola.enabled);
724        assert!(!config.ghola.stealth); // defaults to false
725        assert_eq!(config.ghola.buffer_size, 4096); // defaults to 4096
726    }
727
728    #[test]
729    fn test_load_ghola_absent_uses_defaults() {
730        let yaml = r#"
731chains:
732  ethereum_rpc: "https://example.com"
733"#;
734        let mut file = NamedTempFile::new().unwrap();
735        file.write_all(yaml.as_bytes()).unwrap();
736
737        let config = Config::load(Some(file.path())).unwrap();
738        assert!(!config.ghola.enabled);
739        assert!(!config.ghola.stealth);
740        assert_eq!(config.ghola.buffer_size, 4096);
741    }
742
743    #[test]
744    fn test_load_ghola_custom_buffer_size() {
745        let yaml = r#"
746ghola:
747  enabled: false
748  buffer_size: 16384
749"#;
750        let mut file = NamedTempFile::new().unwrap();
751        file.write_all(yaml.as_bytes()).unwrap();
752
753        let config = Config::load(Some(file.path())).unwrap();
754        assert!(!config.ghola.enabled);
755        assert_eq!(config.ghola.buffer_size, 16384);
756    }
757
758    #[test]
759    fn test_ghola_config_serialization_roundtrip() {
760        let mut config = Config::default();
761        config.ghola.enabled = true;
762        config.ghola.stealth = true;
763        config.ghola.buffer_size = 8192;
764
765        let yaml = serde_yaml::to_string(&config).unwrap();
766        let deserialized: Config = serde_yaml::from_str(&yaml).unwrap();
767        assert_eq!(config.ghola, deserialized.ghola);
768    }
769}