saorsa_core/
config.rs

1// Copyright 2024 Saorsa Labs Limited
2//
3// This software is dual-licensed under:
4// - GNU Affero General Public License v3.0 or later (AGPL-3.0-or-later)
5// - Commercial License
6//
7// For AGPL-3.0 license, see LICENSE-AGPL-3.0
8// For commercial licensing, contact: david@saorsalabs.com
9//
10// Unless required by applicable law or agreed to in writing, software
11// distributed under these licenses is distributed on an "AS IS" BASIS,
12// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
14//! # Configuration Management System
15//!
16//! This module provides comprehensive configuration management for the P2P network,
17//! supporting layered configuration (environment > file > defaults) with validation.
18//!
19//! ## Features
20//! - Environment variable override support
21//! - TOML/JSON configuration file support
22//! - Production and development profiles
23//! - IPv4/IPv6 address validation
24//! - Secure defaults for production
25
26use crate::address::NetworkAddress;
27use crate::error::ConfigError;
28use crate::validation::{
29    ValidationContext, validate_config_value, validate_file_path, validate_network_address,
30};
31use crate::{P2PError, Result};
32use regex::Regex;
33use serde::{Deserialize, Serialize};
34use std::env;
35use std::fs;
36use std::net::SocketAddr;
37use std::path::{Path, PathBuf};
38use std::str::FromStr;
39use tracing::info;
40
41/// Main configuration structure for the P2P network
42#[derive(Debug, Clone, Serialize, Deserialize)]
43#[serde(default)]
44#[derive(Default)]
45pub struct Config {
46    /// Network configuration
47    pub network: NetworkConfig,
48    /// Security configuration
49    pub security: SecurityConfig,
50    /// Storage configuration
51    pub storage: StorageConfig,
52
53    /// DHT configuration
54    pub dht: DhtConfig,
55    /// Transport configuration
56    pub transport: TransportConfig,
57    /// Identity configuration
58    pub identity: IdentityConfig,
59}
60
61/// Network configuration
62#[derive(Debug, Clone, Serialize, Deserialize)]
63#[serde(default)]
64pub struct NetworkConfig {
65    /// Bootstrap nodes for network discovery
66    pub bootstrap_nodes: Vec<String>,
67    /// Local listen address (0.0.0.0:9000 for all interfaces)
68    pub listen_address: String,
69    /// Public address for external connections (auto-detected if empty)
70    pub public_address: Option<String>,
71    /// Enable IPv6 support
72    pub ipv6_enabled: bool,
73    /// Maximum concurrent connections
74    pub max_connections: usize,
75    /// Connection timeout in seconds
76    pub connection_timeout: u64,
77    /// Keepalive interval in seconds
78    pub keepalive_interval: u64,
79}
80
81/// Security configuration
82#[derive(Debug, Clone, Serialize, Deserialize)]
83#[serde(default)]
84pub struct SecurityConfig {
85    /// Rate limit (requests per second)
86    pub rate_limit: u32,
87    /// Connection limit per IP
88    pub connection_limit: u32,
89    /// Enable TLS/encryption
90    pub encryption_enabled: bool,
91    /// Minimum TLS version (e.g., "1.3")
92    pub min_tls_version: String,
93    /// Security level for identity management
94    pub identity_security_level: String,
95}
96
97/// Storage configuration
98#[derive(Debug, Clone, Serialize, Deserialize)]
99#[serde(default)]
100pub struct StorageConfig {
101    /// Base path for data storage
102    pub path: PathBuf,
103    /// Maximum storage size (e.g., "10GB")
104    pub max_size: String,
105    /// Cache size in MB
106    pub cache_size: u64,
107    /// Enable compression
108    pub compression_enabled: bool,
109}
110
111/// DHT configuration
112#[derive(Debug, Clone, Serialize, Deserialize)]
113#[serde(default)]
114pub struct DhtConfig {
115    /// Replication factor (K value)
116    pub replication_factor: u8,
117    /// Alpha value for parallel queries
118    pub alpha: u8,
119    /// Beta value for routing
120    pub beta: u8,
121    /// Record TTL in seconds
122    pub record_ttl: u64,
123    /// Enable adaptive routing
124    pub adaptive_routing: bool,
125}
126
127/// Transport configuration
128#[derive(Debug, Clone, Serialize, Deserialize)]
129#[serde(default)]
130pub struct TransportConfig {
131    /// Preferred transport protocol
132    pub protocol: String,
133    /// Enable QUIC transport
134    pub quic_enabled: bool,
135    /// Enable TCP transport
136    pub tcp_enabled: bool,
137    /// Enable WebRTC transport
138    pub webrtc_enabled: bool,
139    /// Transport buffer size
140    pub buffer_size: usize,
141    /// Server name for TLS (SNI)
142    pub server_name: String,
143}
144
145/// Identity configuration
146#[derive(Debug, Clone, Serialize, Deserialize)]
147#[serde(default)]
148pub struct IdentityConfig {
149    /// Default key derivation path
150    pub derivation_path: String,
151    /// Key rotation interval in days
152    pub rotation_interval: u32,
153    /// Enable automatic backups
154    pub backup_enabled: bool,
155    /// Backup interval in hours
156    pub backup_interval: u32,
157}
158
159// Default implementations
160
161impl Default for NetworkConfig {
162    fn default() -> Self {
163        Self {
164            bootstrap_nodes: vec![],
165            // Bind all interfaces by default; env can override via Config::load()
166            listen_address: "0.0.0.0:9000".to_string(),
167            public_address: None,
168            ipv6_enabled: true,
169            max_connections: 10000,
170            connection_timeout: 30,
171            keepalive_interval: 60,
172        }
173    }
174}
175
176impl Default for SecurityConfig {
177    fn default() -> Self {
178        Self {
179            rate_limit: 1000,
180            connection_limit: 100,
181            encryption_enabled: true,
182            min_tls_version: "1.3".to_string(),
183            identity_security_level: "High".to_string(),
184        }
185    }
186}
187
188impl Default for StorageConfig {
189    fn default() -> Self {
190        Self {
191            path: PathBuf::from("./data"),
192            max_size: "10GB".to_string(),
193            cache_size: 256,
194            compression_enabled: true,
195        }
196    }
197}
198
199impl Default for DhtConfig {
200    fn default() -> Self {
201        Self {
202            replication_factor: 8,
203            alpha: 3,
204            beta: 1,
205            record_ttl: 3600,
206            adaptive_routing: true,
207        }
208    }
209}
210
211impl Default for TransportConfig {
212    fn default() -> Self {
213        Self {
214            protocol: "quic".to_string(),
215            quic_enabled: true,
216            tcp_enabled: true,
217            webrtc_enabled: false,
218            buffer_size: 65536,
219            server_name: "p2p.local".to_string(),
220        }
221    }
222}
223
224impl Default for IdentityConfig {
225    fn default() -> Self {
226        Self {
227            derivation_path: "m/44'/0'/0'/0/0".to_string(),
228            rotation_interval: 90,
229            backup_enabled: true,
230            backup_interval: 24,
231        }
232    }
233}
234
235impl Config {
236    /// Load configuration from multiple sources with precedence:
237    /// 1. Environment variables (highest)
238    /// 2. Configuration file
239    /// 3. Default values (lowest)
240    ///
241    /// # Examples
242    ///
243    /// ```no_run
244    /// use saorsa_core::config::Config;
245    ///
246    /// // Load with default locations
247    /// let config = Config::load()?;
248    ///
249    /// // Access configuration values
250    /// println!("Listen address: {}", config.network.listen_address);
251    /// println!("Rate limit: {}", config.security.rate_limit);
252    /// # Ok::<(), saorsa_core::P2PError>(())
253    /// ```
254    pub fn load() -> Result<Self> {
255        Self::load_with_path::<&str>(None)
256    }
257
258    /// Load configuration with a specific config file path
259    ///
260    /// # Examples
261    ///
262    /// ```no_run
263    /// use saorsa_core::config::Config;
264    ///
265    /// // Load from specific file
266    /// let config = Config::load_with_path(Some("custom.toml"))?;
267    ///
268    /// // Load from optional path
269    /// let path = std::env::var("CONFIG_PATH").ok();
270    /// let config = Config::load_with_path(path.as_ref())?;
271    /// # Ok::<(), saorsa_core::P2PError>(())
272    /// ```
273    pub fn load_with_path<P: AsRef<Path>>(path: Option<P>) -> Result<Self> {
274        // Start with defaults
275        let mut config = Self::default();
276
277        // Load from file if provided or look for default locations
278        if let Some(path) = path {
279            config = Self::load_from_file(path)?;
280        } else {
281            // Try default config locations
282            for location in &["saorsa.toml", "config.toml", "/etc/saorsa/config.toml"] {
283                if Path::new(location).exists() {
284                    info!("Loading config from: {}", location);
285                    config = Self::load_from_file(location)?;
286                    break;
287                }
288            }
289        }
290
291        // Override with environment variables
292        config.apply_env_overrides()?;
293
294        // Validate the final configuration
295        config.validate()?;
296
297        Ok(config)
298    }
299
300    /// Load configuration from a TOML file
301    pub fn load_from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
302        let content = fs::read_to_string(&path).map_err(|e| {
303            P2PError::Config(ConfigError::IoError {
304                path: path.as_ref().to_string_lossy().to_string().into(),
305                source: e,
306            })
307        })?;
308
309        toml::from_str(&content)
310            .map_err(|e| P2PError::Config(ConfigError::ParseError(e.to_string().into())))
311    }
312
313    /// Save configuration to a TOML file
314    pub fn save_to_file<P: AsRef<Path>>(&self, path: P) -> Result<()> {
315        let content = toml::to_string_pretty(self)
316            .map_err(|e| P2PError::Config(ConfigError::ParseError(e.to_string().into())))?;
317
318        fs::write(&path, content).map_err(|e| {
319            P2PError::Config(ConfigError::IoError {
320                path: path.as_ref().to_string_lossy().to_string().into(),
321                source: e,
322            })
323        })?;
324
325        Ok(())
326    }
327
328    /// Apply environment variable overrides
329    fn apply_env_overrides(&mut self) -> Result<()> {
330        // Network overrides
331        if let Ok(val) = env::var("SAORSA_LISTEN_ADDRESS") {
332            self.network.listen_address = val;
333        }
334        if let Ok(val) = env::var("SAORSA_PUBLIC_ADDRESS") {
335            self.network.public_address = Some(val);
336        }
337        if let Ok(val) = env::var("SAORSA_BOOTSTRAP_NODES") {
338            self.network.bootstrap_nodes = val.split(',').map(String::from).collect();
339        }
340        if let Ok(val) = env::var("SAORSA_MAX_CONNECTIONS") {
341            self.network.max_connections = val.parse().map_err(|_| {
342                P2PError::Config(ConfigError::InvalidValue {
343                    field: "max_connections".to_string().into(),
344                    reason: "Invalid value".to_string().into(),
345                })
346            })?;
347        }
348
349        // Security overrides
350        if let Ok(val) = env::var("SAORSA_RATE_LIMIT") {
351            self.security.rate_limit = val.parse().map_err(|_| {
352                P2PError::Config(ConfigError::InvalidValue {
353                    field: "rate_limit".to_string().into(),
354                    reason: "Invalid value".to_string().into(),
355                })
356            })?;
357        }
358        if let Ok(val) = env::var("SAORSA_ENCRYPTION_ENABLED") {
359            self.security.encryption_enabled = val.parse().map_err(|_| {
360                P2PError::Config(ConfigError::InvalidValue {
361                    field: "encryption_enabled".to_string().into(),
362                    reason: "Invalid value".to_string().into(),
363                })
364            })?;
365        }
366
367        // Storage overrides
368        if let Ok(val) = env::var("SAORSA_DATA_PATH") {
369            self.storage.path = PathBuf::from(val);
370        }
371        if let Ok(val) = env::var("SAORSA_MAX_STORAGE") {
372            self.storage.max_size = val;
373        }
374
375        Ok(())
376    }
377
378    /// Validate the configuration
379    pub fn validate(&self) -> Result<()> {
380        let mut errors = Vec::new();
381
382        // Validate network addresses
383        if let Err(e) = self.validate_address(&self.network.listen_address, "listen_address") {
384            errors.push(e);
385        }
386
387        if let Some(addr) = &self.network.public_address
388            && let Err(e) = self.validate_address(addr, "public_address")
389        {
390            errors.push(e);
391        }
392
393        for (i, node) in self.network.bootstrap_nodes.iter().enumerate() {
394            if let Err(e) = self.validate_address(node, &format!("bootstrap_node[{}]", i)) {
395                errors.push(e);
396            }
397        }
398
399        // Validate ranges using validation framework
400        if let Err(e) = validate_config_value(
401            &self.network.max_connections.to_string(),
402            Some(1_usize),
403            Some(100_000_usize),
404        ) {
405            errors.push(P2PError::Config(ConfigError::InvalidValue {
406                field: "max_connections".to_string().into(),
407                reason: e.to_string().into(),
408            }));
409        }
410
411        if let Err(e) = validate_config_value(
412            &self.security.rate_limit.to_string(),
413            Some(1_u32),
414            Some(1_000_000_u32),
415        ) {
416            errors.push(P2PError::Config(ConfigError::InvalidValue {
417                field: "rate_limit".to_string().into(),
418                reason: e.to_string().into(),
419            }));
420        }
421
422        // Validate storage path only if it exists; skip strict checks in non-existent dirs
423        if self.storage.path.exists()
424            && let Err(e) = validate_file_path(&self.storage.path)
425        {
426            errors.push(P2PError::Config(ConfigError::InvalidValue {
427                field: "storage.path".to_string().into(),
428                reason: format!("{:?}: {}", self.storage.path, e).into(),
429            }));
430        }
431
432        // Validate storage size format
433        if !self.validate_size_format(&self.storage.max_size) {
434            errors.push(P2PError::Config(ConfigError::InvalidValue {
435                field: "max_size".to_string().into(),
436                reason: format!("Invalid storage size format: {}", self.storage.max_size).into(),
437            }));
438        }
439
440        // Validate transport protocol
441        match self.transport.protocol.as_str() {
442            "quic" | "tcp" | "webrtc" => {}
443            _ => errors.push(P2PError::Config(ConfigError::InvalidValue {
444                field: "protocol".to_string().into(),
445                reason: format!("Invalid transport protocol: {}", self.transport.protocol).into(),
446            })),
447        }
448
449        if errors.is_empty() {
450            Ok(())
451        } else {
452            // Return the first error, or a generic error if somehow the vec is empty
453            Err(errors.into_iter().next().unwrap_or_else(|| {
454                P2PError::Config(ConfigError::InvalidValue {
455                    field: "unknown".to_string().into(),
456                    reason: "Validation failed with unknown error".to_string().into(),
457                })
458            }))
459        }
460    }
461
462    /// Validate network address format
463    fn validate_address(&self, addr: &str, field: &str) -> Result<()> {
464        // Try parsing as SocketAddr first
465        if let Ok(socket_addr) = SocketAddr::from_str(addr) {
466            // Use our validation framework
467            let ctx = ValidationContext::default()
468                .allow_localhost() // Allow localhost for development
469                .allow_private_ips(); // Allow private IPs for development
470
471            return validate_network_address(&socket_addr, &ctx).map_err(|e| {
472                P2PError::Config(ConfigError::InvalidValue {
473                    field: field.to_string().into(),
474                    reason: e.to_string().into(),
475                })
476            });
477        }
478
479        // Try parsing as four-word address format (always enabled)
480        if let Ok(network_addr) = crate::NetworkAddress::from_four_words(addr) {
481            // Validate the parsed socket address
482            let ctx = ValidationContext::default()
483                .allow_localhost()
484                .allow_private_ips();
485
486            return validate_network_address(&network_addr.socket_addr(), &ctx).map_err(|e| {
487                P2PError::Config(ConfigError::InvalidValue {
488                    field: field.to_string().into(),
489                    reason: e.to_string().into(),
490                })
491            });
492        }
493
494        // Try parsing as multiaddr format
495        if addr.starts_with("/ip4/") || addr.starts_with("/ip6/") {
496            // Basic multiaddr validation
497            return Ok(());
498        }
499
500        Err(P2PError::Config(ConfigError::InvalidValue {
501            field: field.to_string().into(),
502            reason: format!("Invalid address format: {}", addr).into(),
503        }))
504    }
505
506    /// Validate size format (e.g., "10GB", "500MB")
507    fn validate_size_format(&self, size: &str) -> bool {
508        thread_local! {
509            // Raw string with single backslashes for regex tokens
510            static SIZE_REGEX: std::result::Result<Regex, P2PError> = Regex::new(r"^\d+(?:\.\d+)?\s*(?:B|KB|MB|GB|TB)$")
511                .map_err(|e| P2PError::Config(ConfigError::InvalidValue { field: "size".to_string().into(), reason: e.to_string().into() }));
512        }
513        SIZE_REGEX.with(|re| re.as_ref().ok().map(|r| r.is_match(size)).unwrap_or(false))
514    }
515
516    /// Create development configuration
517    pub fn development() -> Self {
518        let mut config = Self::default();
519        config.network.listen_address = "127.0.0.1:9000".to_string();
520        config.security.rate_limit = 10000;
521        config.security.connection_limit = 1000;
522        config.storage.path = PathBuf::from("./dev-data");
523        config
524    }
525
526    /// Create production configuration with secure defaults
527    pub fn production() -> Self {
528        let mut config = Self::default();
529        // Use environment variable or fallback to secure default
530        config.network.listen_address =
531            env::var("SAORSA_LISTEN_ADDRESS").unwrap_or_else(|_| "0.0.0.0:9000".to_string());
532        config.security.rate_limit = 1000;
533        config.security.connection_limit = 100;
534        config.storage.path = PathBuf::from("/var/lib/saorsa");
535        // Larger buffers in production
536        config.transport.buffer_size = 131072;
537        config
538    }
539
540    /// Get parsed listen address
541    pub fn listen_socket_addr(&self) -> Result<SocketAddr> {
542        SocketAddr::from_str(&self.network.listen_address).map_err(|e| {
543            P2PError::Config(ConfigError::InvalidValue {
544                field: "listen_address".to_string().into(),
545                reason: format!("Invalid address: {}", e).into(),
546            })
547        })
548    }
549
550    /// Get parsed bootstrap addresses
551    pub fn bootstrap_addrs(&self) -> Result<Vec<NetworkAddress>> {
552        self.network
553            .bootstrap_nodes
554            .iter()
555            .map(|addr| {
556                addr.parse::<NetworkAddress>().map_err(|e| {
557                    P2PError::Config(ConfigError::InvalidValue {
558                        field: "bootstrap_nodes".to_string().into(),
559                        reason: format!("Invalid address: {}", e).into(),
560                    })
561                })
562            })
563            .collect()
564    }
565
566    /// Parse a size string (e.g., "10GB", "500MB") to bytes
567    ///
568    /// # Examples
569    ///
570    /// ```ignore
571    /// use saorsa_core::config::Config;
572    ///
573    /// assert_eq!(Config::parse_size("10B").unwrap(), 10);
574    /// assert_eq!(Config::parse_size("1KB").unwrap(), 1024);
575    /// assert_eq!(Config::parse_size("5MB").unwrap(), 5 * 1024 * 1024);
576    /// ```
577    pub fn parse_size(size: &str) -> Result<u64> {
578        thread_local! {
579            static SIZE_REGEX: std::result::Result<Regex, P2PError> = Regex::new(r"^(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)$")
580                .map_err(|e| P2PError::Config(ConfigError::InvalidValue { field: "size".to_string().into(), reason: e.to_string().into() }));
581        }
582
583        SIZE_REGEX.with(|re| -> Result<u64> {
584            let re = match re {
585                Ok(r) => r,
586                Err(e) => {
587                    return Err(P2PError::Config(ConfigError::InvalidValue {
588                        field: "size".to_string().into(),
589                        reason: e.to_string().into(),
590                    }));
591                }
592            };
593            if let Some(captures) = re.captures(size) {
594                let value: f64 = captures
595                    .get(1)
596                    .and_then(|m| m.as_str().parse().ok())
597                    .ok_or_else(|| {
598                        P2PError::Config(ConfigError::InvalidValue {
599                            field: "size".to_string().into(),
600                            reason: "Invalid numeric value".to_string().into(),
601                        })
602                    })?;
603
604                let unit = captures.get(2).map(|m| m.as_str()).unwrap_or("B");
605                let multiplier = match unit {
606                    "B" => 1u64,
607                    "KB" => 1024,
608                    "MB" => 1024 * 1024,
609                    "GB" => 1024 * 1024 * 1024,
610                    "TB" => 1024u64.pow(4),
611                    _ => {
612                        return Err(P2PError::Config(ConfigError::InvalidValue {
613                            field: "size".to_string().into(),
614                            reason: format!("Unknown unit: {}", unit).into(),
615                        }));
616                    }
617                };
618
619                Ok((value * multiplier as f64) as u64)
620            } else {
621                Err(P2PError::Config(ConfigError::InvalidValue {
622                    field: "size".to_string().into(),
623                    reason: format!("Invalid size format: {}", size).into(),
624                }))
625            }
626        })
627    }
628
629    /// Get storage max size in bytes
630    pub fn storage_max_size_bytes(&self) -> Result<u64> {
631        Self::parse_size(&self.storage.max_size)
632    }
633}
634
635#[cfg(test)]
636mod tests {
637    use super::*;
638    use tempfile::NamedTempFile;
639
640    #[test]
641    fn test_default_config() {
642        let config = Config::default();
643        assert_eq!(config.network.listen_address, "0.0.0.0:9000");
644        assert_eq!(config.security.rate_limit, 1000);
645        assert!(config.security.encryption_enabled);
646    }
647
648    #[test]
649    fn test_development_config() {
650        let config = Config::development();
651        assert_eq!(config.network.listen_address, "127.0.0.1:9000");
652        assert_eq!(config.security.rate_limit, 10000);
653    }
654
655    #[test]
656    fn test_production_config() {
657        let config = Config::production();
658        // Production config should have larger buffer size
659        assert_eq!(config.transport.buffer_size, 131072);
660        // Listen address should contain a port
661        assert!(config.network.listen_address.contains(':'));
662    }
663
664    #[test]
665    fn test_config_validation() {
666        let mut config = Config::default();
667        assert!(config.validate().is_ok());
668
669        // Invalid address
670        config.network.listen_address = "invalid".to_string();
671        assert!(config.validate().is_err());
672
673        // Valid multiaddr
674        config.network.listen_address = "/ip4/127.0.0.1/tcp/9000".to_string();
675        assert!(config.validate().is_ok());
676    }
677
678    #[test]
679    fn test_save_and_load_config() {
680        let config = Config::development();
681        let file = NamedTempFile::new().unwrap();
682
683        config.save_to_file(file.path()).unwrap();
684
685        let loaded = Config::load_from_file(file.path()).unwrap();
686        assert_eq!(loaded.network.listen_address, config.network.listen_address);
687    }
688
689    #[test]
690    #[serial_test::serial]
691    #[allow(unsafe_code)] // Required for env::set_var in tests only
692    fn test_env_overrides() {
693        use std::sync::Mutex;
694
695        // Use a static mutex to ensure thread safety
696        static ENV_MUTEX: Mutex<()> = Mutex::new(());
697        let _guard = ENV_MUTEX.lock().unwrap();
698
699        // Save original values
700        let orig_listen = env::var("SAORSA_LISTEN_ADDRESS").ok();
701        let orig_rate = env::var("SAORSA_RATE_LIMIT").ok();
702
703        // Set test values - unsafe blocks required in Rust 2024
704        unsafe {
705            env::set_var("SAORSA_LISTEN_ADDRESS", "127.0.0.1:8000");
706            env::set_var("SAORSA_RATE_LIMIT", "5000");
707        }
708
709        let config = Config::load().unwrap();
710        assert_eq!(config.network.listen_address, "127.0.0.1:8000");
711        assert_eq!(config.security.rate_limit, 5000);
712
713        // Restore original values
714        unsafe {
715            match orig_listen {
716                Some(val) => env::set_var("SAORSA_LISTEN_ADDRESS", val),
717                None => env::remove_var("SAORSA_LISTEN_ADDRESS"),
718            }
719            match orig_rate {
720                Some(val) => env::set_var("SAORSA_RATE_LIMIT", val),
721                None => env::remove_var("SAORSA_RATE_LIMIT"),
722            }
723        }
724    }
725
726    #[test]
727    fn test_size_validation() {
728        let config = Config::default();
729        assert!(config.validate_size_format("10GB"));
730        assert!(config.validate_size_format("500MB"));
731        assert!(config.validate_size_format("1.5TB"));
732        assert!(!config.validate_size_format("10XB"));
733        assert!(!config.validate_size_format("invalid"));
734    }
735
736    #[test]
737    fn test_size_parsing() {
738        assert_eq!(Config::parse_size("10B").unwrap(), 10);
739        assert_eq!(Config::parse_size("1KB").unwrap(), 1024);
740        assert_eq!(Config::parse_size("5MB").unwrap(), 5 * 1024 * 1024);
741        assert_eq!(Config::parse_size("1GB").unwrap(), 1024 * 1024 * 1024);
742        assert_eq!(Config::parse_size("1.5GB").unwrap(), 1610612736);
743        assert_eq!(Config::parse_size("1TB").unwrap(), 1024u64.pow(4));
744
745        // Test error cases
746        assert!(Config::parse_size("invalid").is_err());
747        assert!(Config::parse_size("10XB").is_err());
748        assert!(Config::parse_size("GB").is_err());
749    }
750}