1pub mod HotReload;
90
91pub mod Schema;
92
93use std::{
94 collections::HashMap,
95 env,
96 path::{Path, PathBuf},
97};
98
99pub use Schema::generate_schema;
100use serde::{Deserialize, Serialize};
101use sha2::Digest;
102
103use crate::{AirError, DefaultConfigFile, Result, dev_log};
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct AirConfiguration {
112 #[serde(default = "default_schema_version")]
114 pub SchemaVersion:String,
115
116 #[serde(default = "default_profile")]
118 pub Profile:String,
119
120 pub gRPC:gRPCConfig,
122
123 pub Authentication:AuthConfig,
125
126 pub Updates:UpdateConfig,
128
129 pub Downloader:DownloadConfig,
131
132 pub Indexing:IndexingConfig,
134
135 pub Logging:LoggingConfig,
137
138 pub Performance:PerformanceConfig,
140}
141
142fn default_schema_version() -> String { "1.0.0".to_string() }
143
144fn default_profile() -> String { "dev".to_string() }
145
146#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct gRPCConfig {
149 #[serde(default = "default_grpc_bind_address")]
154 pub BindAddress:String,
155
156 #[serde(default = "default_grpc_max_connections")]
160 pub MaxConnections:u32,
161
162 #[serde(default = "default_grpc_request_timeout")]
166 pub RequestTimeoutSecs:u64,
167}
168
169fn default_grpc_bind_address() -> String { "[::1]:50053".to_string() }
170
171fn default_grpc_max_connections() -> u32 { 100 }
172
173fn default_grpc_request_timeout() -> u64 { 30 }
174
175#[derive(Debug, Clone, Serialize, Deserialize)]
177pub struct AuthConfig {
178 #[serde(default = "default_auth_enabled")]
180 pub Enabled:bool,
181
182 #[serde(default = "default_auth_credentials_path")]
187 pub CredentialsPath:String,
188
189 #[serde(default = "default_auth_token_expiration")]
193 pub TokenExpirationHours:u32,
194
195 #[serde(default = "default_auth_max_sessions")]
199 pub MaxSessions:u32,
200}
201
202fn default_auth_enabled() -> bool { true }
203
204fn default_auth_credentials_path() -> String { "~/.Air/credentials".to_string() }
205
206fn default_auth_token_expiration() -> u32 { 24 }
207
208fn default_auth_max_sessions() -> u32 { 10 }
209
210#[derive(Debug, Clone, Serialize, Deserialize)]
212pub struct UpdateConfig {
213 #[serde(default = "default_update_enabled")]
215 pub Enabled:bool,
216
217 #[serde(default = "default_update_check_interval")]
221 pub CheckIntervalHours:u32,
222
223 #[serde(default = "default_update_server_url")]
228 pub UpdateServerUrl:String,
229
230 #[serde(default = "default_update_auto_download")]
232 pub AutoDownload:bool,
233
234 #[serde(default = "default_update_auto_install")]
237 pub AutoInstall:bool,
238
239 #[serde(default = "default_update_channel")]
243 pub Channel:String,
244}
245
246fn default_update_enabled() -> bool { true }
247
248fn default_update_check_interval() -> u32 { 6 }
249
250fn default_update_server_url() -> String { "https://updates.land.playform.cloud".to_string() }
251
252fn default_update_auto_download() -> bool { true }
253
254fn default_update_auto_install() -> bool { false }
255
256fn default_update_channel() -> String { "stable".to_string() }
257
258#[derive(Debug, Clone, Serialize, Deserialize)]
260pub struct DownloadConfig {
261 #[serde(default = "default_download_enabled")]
263 pub Enabled:bool,
264
265 #[serde(default = "default_download_max_concurrent")]
269 pub MaxConcurrentDownloads:u32,
270
271 #[serde(default = "default_download_timeout")]
275 pub DownloadTimeoutSecs:u64,
276
277 #[serde(default = "default_download_max_retries")]
281 pub MaxRetries:u32,
282
283 #[serde(default = "default_download_cache_dir")]
287 pub CacheDirectory:String,
288}
289
290fn default_download_enabled() -> bool { true }
291
292fn default_download_max_concurrent() -> u32 { 5 }
293
294fn default_download_timeout() -> u64 { 300 }
295
296fn default_download_max_retries() -> u32 { 3 }
297
298fn default_download_cache_dir() -> String { "~/.Air/cache".to_string() }
299
300#[derive(Debug, Clone, Serialize, Deserialize)]
302pub struct IndexingConfig {
303 #[serde(default = "default_indexing_enabled")]
305 pub Enabled:bool,
306
307 #[serde(default = "default_indexing_max_file_size")]
311 pub MaxFileSizeMb:u32,
312
313 #[serde(default = "default_indexing_file_types")]
318 pub FileTypes:Vec<String>,
319
320 #[serde(default = "default_indexing_update_interval")]
324 pub UpdateIntervalMinutes:u32,
325
326 #[serde(default = "default_indexing_directory")]
330 pub IndexDirectory:String,
331
332 #[serde(default = "default_max_parallel_indexing")]
336 pub MaxParallelIndexing:u32,
337}
338
339fn default_indexing_enabled() -> bool { true }
340
341fn default_indexing_max_file_size() -> u32 { 10 }
342
343fn default_indexing_file_types() -> Vec<String> {
344 vec![
345 "*.rs".to_string(),
346 "*.ts".to_string(),
347 "*.js".to_string(),
348 "*.json".to_string(),
349 "*.toml".to_string(),
350 "*.md".to_string(),
351 ]
352}
353
354fn default_indexing_update_interval() -> u32 { 30 }
355
356fn default_indexing_directory() -> String { "~/.Air/index".to_string() }
357
358fn default_max_parallel_indexing() -> u32 { 10 }
359
360#[derive(Debug, Clone, Serialize, Deserialize)]
362pub struct LoggingConfig {
363 #[serde(default = "default_logging_level")]
367 pub Level:String,
368
369 #[serde(default = "default_logging_file_path")]
373 pub FilePath:Option<String>,
374
375 #[serde(default = "default_logging_console_enabled")]
377 pub ConsoleEnabled:bool,
378
379 #[serde(default = "default_logging_max_file_size")]
383 pub MaxFileSizeMb:u32,
384
385 #[serde(default = "default_logging_max_files")]
389 pub MaxFiles:u32,
390}
391
392fn default_logging_level() -> String { "info".to_string() }
393
394fn default_logging_file_path() -> Option<String> { Some("~/.Air/logs/Air.log".to_string()) }
395
396fn default_logging_console_enabled() -> bool { true }
397
398fn default_logging_max_file_size() -> u32 { 10 }
399
400fn default_logging_max_files() -> u32 { 5 }
401
402#[derive(Debug, Clone, Serialize, Deserialize)]
404pub struct PerformanceConfig {
405 #[serde(default = "default_perf_memory_limit")]
409 pub MemoryLimitMb:u32,
410
411 #[serde(default = "default_perf_cpu_limit")]
415 pub CPULimitPercent:u32,
416
417 #[serde(default = "default_perf_disk_limit")]
421 pub DiskLimitMb:u32,
422
423 #[serde(default = "default_perf_task_interval")]
427 pub BackgroundTaskIntervalSecs:u64,
428}
429
430fn default_perf_memory_limit() -> u32 { 512 }
431
432fn default_perf_cpu_limit() -> u32 { 50 }
433
434fn default_perf_disk_limit() -> u32 { 1024 }
435
436fn default_perf_task_interval() -> u64 { 60 }
437
438impl Default for AirConfiguration {
439 fn default() -> Self {
440 Self {
441 SchemaVersion:default_schema_version(),
442
443 Profile:default_profile(),
444
445 gRPC:gRPCConfig {
446 BindAddress:default_grpc_bind_address(),
447
448 MaxConnections:default_grpc_max_connections(),
449
450 RequestTimeoutSecs:default_grpc_request_timeout(),
451 },
452
453 Authentication:AuthConfig {
454 Enabled:default_auth_enabled(),
455
456 CredentialsPath:default_auth_credentials_path(),
457
458 TokenExpirationHours:default_auth_token_expiration(),
459
460 MaxSessions:default_auth_max_sessions(),
461 },
462
463 Updates:UpdateConfig {
464 Enabled:default_update_enabled(),
465
466 CheckIntervalHours:default_update_check_interval(),
467
468 UpdateServerUrl:default_update_server_url(),
469
470 AutoDownload:default_update_auto_download(),
471
472 AutoInstall:default_update_auto_install(),
473
474 Channel:default_update_channel(),
475 },
476
477 Downloader:DownloadConfig {
478 Enabled:default_download_enabled(),
479
480 MaxConcurrentDownloads:default_download_max_concurrent(),
481
482 DownloadTimeoutSecs:default_download_timeout(),
483
484 MaxRetries:default_download_max_retries(),
485
486 CacheDirectory:default_download_cache_dir(),
487 },
488
489 Indexing:IndexingConfig {
490 Enabled:default_indexing_enabled(),
491
492 MaxFileSizeMb:default_indexing_max_file_size(),
493
494 FileTypes:default_indexing_file_types(),
495
496 UpdateIntervalMinutes:default_indexing_update_interval(),
497
498 IndexDirectory:default_indexing_directory(),
499
500 MaxParallelIndexing:default_max_parallel_indexing(),
501 },
502
503 Logging:LoggingConfig {
504 Level:default_logging_level(),
505
506 FilePath:default_logging_file_path(),
507
508 ConsoleEnabled:default_logging_console_enabled(),
509
510 MaxFileSizeMb:default_logging_max_file_size(),
511
512 MaxFiles:default_logging_max_files(),
513 },
514
515 Performance:PerformanceConfig {
516 MemoryLimitMb:default_perf_memory_limit(),
517
518 CPULimitPercent:default_perf_cpu_limit(),
519
520 DiskLimitMb:default_perf_disk_limit(),
521
522 BackgroundTaskIntervalSecs:default_perf_task_interval(),
523 },
524 }
525 }
526}
527
528pub struct ConfigurationManager {
539 ConfigPath:Option<PathBuf>,
541
542 BackupDir:Option<PathBuf>,
544
545 EnableBackup:bool,
547
548 EnvPrefix:String,
550}
551
552impl ConfigurationManager {
553 pub fn New(ConfigPath:Option<String>) -> Result<Self> {
564 let path = ConfigPath.map(PathBuf::from);
565
566 let BackupDir = path
567 .as_ref()
568 .and_then(|p| p.parent())
569 .map(|parent| parent.join(".ConfigBackups"));
570
571 Ok(Self { ConfigPath:path, BackupDir, EnableBackup:true, EnvPrefix:"AIR_".to_string() })
572 }
573
574 pub fn NewWithSettings(ConfigPath:Option<String>, EnableBackup:bool, EnvPrefix:String) -> Result<Self> {
582 let path = ConfigPath.map(PathBuf::from);
583
584 let BackupDir = if EnableBackup {
585 path.as_ref()
586 .and_then(|p| p.parent())
587 .map(|parent| parent.join(".ConfigBackups"))
588 } else {
589 None
590 };
591
592 Ok(Self { ConfigPath:path, BackupDir, EnableBackup, EnvPrefix })
593 }
594
595 pub async fn LoadConfiguration(&self) -> Result<AirConfiguration> {
606 let mut config = AirConfiguration::default();
608
609 let ConfigPath = self.GetConfigPath()?;
611
612 if ConfigPath.exists() {
613 dev_log!("config", "Loading configuration from: {}", ConfigPath.display());
614
615 config = self.LoadFromFile(&ConfigPath).await?;
616 } else {
617 dev_log!("config", "No configuration file found, using defaults");
618 }
619
620 self.ApplyEnvironmentOverrides(&mut config)?;
622
623 self.SchemaValidate(&config)?;
625
626 self.ValidateConfiguration(&config)?;
628
629 dev_log!("config", "Configuration loaded successfully (profile: {})", config.Profile);
630
631 Ok(config)
632 }
633
634 async fn LoadFromFile(&self, path:&Path) -> Result<AirConfiguration> {
644 let content = tokio::fs::read_to_string(path)
645 .await
646 .map_err(|e| AirError::Configuration(format!("Failed to read config file '{}': {}", path.display(), e)))?;
647
648 let config:AirConfiguration = toml::from_str(&content).map_err(|e| {
649 AirError::Configuration(format!("Failed to parse TOML config file '{}': {}", path.display(), e))
650 })?;
651
652 dev_log!("config", "Configuration file parsed successfully");
654
655 Ok(config)
656 }
657
658 pub async fn SaveConfiguration(&self, config:&AirConfiguration) -> Result<()> {
671 self.ValidateConfiguration(config)?;
673
674 let ConfigPath = self.GetConfigPath()?;
675
676 if self.EnableBackup && ConfigPath.exists() {
678 self.BackupConfiguration(&ConfigPath).await?;
679 }
680
681 if let Some(parent) = ConfigPath.parent() {
683 tokio::fs::create_dir_all(parent).await.map_err(|e| {
684 AirError::Configuration(format!("Failed to create config directory '{}': {}", parent.display(), e))
685 })?;
686 }
687
688 let TempPath = ConfigPath.with_extension("tmp");
690
691 let content = toml::to_string_pretty(config)
692 .map_err(|e| AirError::Configuration(format!("Failed to serialize config: {}", e)))?;
693
694 tokio::fs::write(&TempPath, content).await.map_err(|e| {
695 AirError::Configuration(format!("Failed to write temp config file '{}': {}", TempPath.display(), e))
696 })?;
697
698 tokio::fs::rename(&TempPath, &ConfigPath).await.map_err(|e| {
700 AirError::Configuration(format!("Failed to rename temp config to '{}': {}", ConfigPath.display(), e))
701 })?;
702
703 dev_log!("config", "Configuration saved to: {}", ConfigPath.display());
704
705 Ok(())
706 }
707
708 fn ValidateConfiguration(&self, config:&AirConfiguration) -> Result<()> {
717 self.ValidateSchemaVersion(&config.SchemaVersion)?;
719
720 self.ValidateProfile(&config.Profile)?;
722
723 self.ValidategRPCConfig(&config.gRPC)?;
725
726 self.ValidateAuthConfig(&config.Authentication)?;
728
729 self.ValidateUpdateConfig(&config.Updates)?;
731
732 self.ValidateDownloadConfig(&config.Downloader)?;
734
735 self.ValidateIndexingConfig(&config.Indexing)?;
737
738 self.ValidateLoggingConfig(&config.Logging)?;
740
741 self.ValidatePerformanceConfig(&config.Performance)?;
743
744 dev_log!("config", "All configuration validation checks passed");
745
746 Ok(())
747 }
748
749 fn ValidateSchemaVersion(&self, version:&str) -> Result<()> {
751 if !version.chars().all(|c| c.is_digit(10) || c == '.') {
752 return Err(AirError::Configuration(format!(
753 "Invalid schema version '{}': must be in format X.Y.Z",
754 version
755 )));
756 }
757
758 let parts:Vec<&str> = version.split('.').collect();
759
760 if parts.len() != 3 {
761 return Err(AirError::Configuration(format!(
762 "Invalid schema version '{}': must have 3 parts (X.Y.Z)",
763 version
764 )));
765 }
766
767 for (i, part) in parts.iter().enumerate() {
768 if part.is_empty() {
769 return Err(AirError::Configuration(format!(
770 "Invalid schema version '{}': part {} is empty",
771 version,
772 i + 1
773 )));
774 }
775 }
776
777 Ok(())
778 }
779
780 fn ValidateProfile(&self, profile:&str) -> Result<()> {
782 let ValidProfiles = ["dev", "staging", "prod", "custom"];
783
784 if !ValidProfiles.contains(&profile) {
785 return Err(AirError::Configuration(format!(
786 "Invalid profile '{}': must be one of: {}",
787 profile,
788 ValidProfiles.join(", ")
789 )));
790 }
791
792 Ok(())
793 }
794
795 fn ValidategRPCConfig(&self, grpc:&gRPCConfig) -> Result<()> {
797 if grpc.BindAddress.is_empty() {
799 return Err(AirError::Configuration("gRPC bind address cannot be empty".to_string()));
800 }
801
802 if !Self::IsValidAddress(&grpc.BindAddress) {
804 return Err(AirError::Configuration(format!(
805 "Invalid gRPC bind address '{}': must be in format host:port or [IPv6]:port",
806 grpc.BindAddress
807 )));
808 }
809
810 if grpc.MaxConnections < 10 {
812 return Err(AirError::Configuration(format!(
813 "gRPC MaxConnections {} is below minimum (10)",
814 grpc.MaxConnections
815 )));
816 }
817
818 if grpc.MaxConnections > 10000 {
819 return Err(AirError::Configuration(format!(
820 "gRPC MaxConnections {} exceeds maximum (10000)",
821 grpc.MaxConnections
822 )));
823 }
824
825 if grpc.RequestTimeoutSecs < 1 {
827 return Err(AirError::Configuration(format!(
828 "gRPC RequestTimeoutSecs {} is below minimum (1 second)",
829 grpc.RequestTimeoutSecs
830 )));
831 }
832
833 if grpc.RequestTimeoutSecs > 3600 {
834 return Err(AirError::Configuration(format!(
835 "gRPC RequestTimeoutSecs {} exceeds maximum (3600 seconds = 1 hour)",
836 grpc.RequestTimeoutSecs
837 )));
838 }
839
840 Ok(())
841 }
842
843 fn ValidateAuthConfig(&self, auth:&AuthConfig) -> Result<()> {
845 if auth.Enabled {
847 if auth.CredentialsPath.is_empty() {
848 return Err(AirError::Configuration(
849 "Authentication credentials path cannot be empty when authentication is enabled".to_string(),
850 ));
851 }
852
853 self.ValidatePath(&auth.CredentialsPath)?;
855 }
856
857 if auth.TokenExpirationHours < 1 {
859 return Err(AirError::Configuration(format!(
860 "Token expiration hours {} is below minimum (1 hour)",
861 auth.TokenExpirationHours
862 )));
863 }
864
865 if auth.TokenExpirationHours > 8760 {
866 return Err(AirError::Configuration(format!(
867 "Token expiration hours {} exceeds maximum (8760 hours = 1 year)",
868 auth.TokenExpirationHours
869 )));
870 }
871
872 if auth.MaxSessions < 1 {
874 return Err(AirError::Configuration(format!(
875 "Max sessions {} is below minimum (1)",
876 auth.MaxSessions
877 )));
878 }
879
880 if auth.MaxSessions > 1000 {
881 return Err(AirError::Configuration(format!(
882 "Max sessions {} exceeds maximum (1000)",
883 auth.MaxSessions
884 )));
885 }
886
887 Ok(())
888 }
889
890 fn ValidateUpdateConfig(&self, updates:&UpdateConfig) -> Result<()> {
892 if updates.Enabled {
893 if updates.UpdateServerUrl.is_empty() {
895 return Err(AirError::Configuration(
896 "Update server URL cannot be empty when updates are enabled".to_string(),
897 ));
898 }
899
900 if !updates.UpdateServerUrl.starts_with("https://") {
902 return Err(AirError::Configuration(format!(
903 "Update server URL must use HTTPS, got: {}",
904 updates.UpdateServerUrl
905 )));
906 }
907
908 if !Self::IsValidUrl(&updates.UpdateServerUrl) {
910 return Err(AirError::Configuration(format!(
911 "Invalid update server URL '{}'",
912 updates.UpdateServerUrl
913 )));
914 }
915 }
916
917 if updates.CheckIntervalHours < 1 {
919 return Err(AirError::Configuration(format!(
920 "Update check interval {} hours is below minimum (1 hour)",
921 updates.CheckIntervalHours
922 )));
923 }
924
925 if updates.CheckIntervalHours > 168 {
926 return Err(AirError::Configuration(format!(
927 "Update check interval {} hours exceeds maximum (168 hours = 1 week)",
928 updates.CheckIntervalHours
929 )));
930 }
931
932 Ok(())
933 }
934
935 fn ValidateDownloadConfig(&self, downloader:&DownloadConfig) -> Result<()> {
937 if downloader.Enabled {
938 if downloader.CacheDirectory.is_empty() {
939 return Err(AirError::Configuration(
940 "Download cache directory cannot be empty when downloader is enabled".to_string(),
941 ));
942 }
943
944 self.ValidatePath(&downloader.CacheDirectory)?;
946 }
947
948 if downloader.MaxConcurrentDownloads < 1 {
950 return Err(AirError::Configuration(format!(
951 "Max concurrent downloads {} is below minimum (1)",
952 downloader.MaxConcurrentDownloads
953 )));
954 }
955
956 if downloader.MaxConcurrentDownloads > 50 {
957 return Err(AirError::Configuration(format!(
958 "Max concurrent downloads {} exceeds maximum (50)",
959 downloader.MaxConcurrentDownloads
960 )));
961 }
962
963 if downloader.DownloadTimeoutSecs < 10 {
965 return Err(AirError::Configuration(format!(
966 "Download timeout {} seconds is below minimum (10 seconds)",
967 downloader.DownloadTimeoutSecs
968 )));
969 }
970
971 if downloader.DownloadTimeoutSecs > 3600 {
972 return Err(AirError::Configuration(format!(
973 "Download timeout {} seconds exceeds maximum (3600 seconds = 1 hour)",
974 downloader.DownloadTimeoutSecs
975 )));
976 }
977
978 if downloader.MaxRetries > 10 {
980 return Err(AirError::Configuration(format!(
981 "Max retries {} exceeds maximum (10)",
982 downloader.MaxRetries
983 )));
984 }
985
986 Ok(())
987 }
988
989 fn ValidateIndexingConfig(&self, indexing:&IndexingConfig) -> Result<()> {
991 if indexing.Enabled {
992 if indexing.IndexDirectory.is_empty() {
993 return Err(AirError::Configuration(
994 "Index directory cannot be empty when indexing is enabled".to_string(),
995 ));
996 }
997
998 self.ValidatePath(&indexing.IndexDirectory)?;
1000
1001 if indexing.FileTypes.is_empty() {
1003 return Err(AirError::Configuration(
1004 "File types to index cannot be empty when indexing is enabled".to_string(),
1005 ));
1006 }
1007
1008 for FileType in &indexing.FileTypes {
1010 if FileType.is_empty() {
1011 return Err(AirError::Configuration("File type pattern cannot be empty".to_string()));
1012 }
1013
1014 if !FileType.contains('*') {
1015 dev_log!(
1016 "config",
1017 "warn: File type pattern '{}' does not contain wildcards, may not match as expected",
1018 FileType
1019 );
1020 }
1021 }
1022 }
1023
1024 if indexing.MaxFileSizeMb < 1 {
1026 return Err(AirError::Configuration(format!(
1027 "Max file size {} MB is below minimum (1 MB)",
1028 indexing.MaxFileSizeMb
1029 )));
1030 }
1031
1032 if indexing.MaxFileSizeMb > 1024 {
1033 return Err(AirError::Configuration(format!(
1034 "Max file size {} MB exceeds maximum (1024 MB = 1 GB)",
1035 indexing.MaxFileSizeMb
1036 )));
1037 }
1038
1039 if indexing.UpdateIntervalMinutes < 1 {
1041 return Err(AirError::Configuration(format!(
1042 "Index update interval {} minutes is below minimum (1 minute)",
1043 indexing.UpdateIntervalMinutes
1044 )));
1045 }
1046
1047 if indexing.UpdateIntervalMinutes > 1440 {
1048 return Err(AirError::Configuration(format!(
1049 "Index update interval {} minutes exceeds maximum (1440 minutes = 1 day)",
1050 indexing.UpdateIntervalMinutes
1051 )));
1052 }
1053
1054 Ok(())
1055 }
1056
1057 fn ValidateLoggingConfig(&self, logging:&LoggingConfig) -> Result<()> {
1059 let ValidLevels = ["trace", "debug", "info", "warn", "error"];
1061
1062 if !ValidLevels.contains(&logging.Level.as_str()) {
1063 return Err(AirError::Configuration(format!(
1064 "Invalid log level '{}': must be one of: {}",
1065 logging.Level,
1066 ValidLevels.join(", ")
1067 )));
1068 }
1069
1070 if let Some(ref FilePath) = logging.FilePath {
1072 if !FilePath.is_empty() {
1073 self.ValidatePath(FilePath)?;
1074 }
1075 }
1076
1077 if logging.MaxFileSizeMb < 1 {
1079 return Err(AirError::Configuration(format!(
1080 "Max log file size {} MB is below minimum (1 MB)",
1081 logging.MaxFileSizeMb
1082 )));
1083 }
1084
1085 if logging.MaxFileSizeMb > 1000 {
1086 return Err(AirError::Configuration(format!(
1087 "Max log file size {} MB exceeds maximum (1000 MB = 1 GB)",
1088 logging.MaxFileSizeMb
1089 )));
1090 }
1091
1092 if logging.MaxFiles < 1 {
1094 return Err(AirError::Configuration(format!(
1095 "Max log files {} is below minimum (1)",
1096 logging.MaxFiles
1097 )));
1098 }
1099
1100 if logging.MaxFiles > 50 {
1101 return Err(AirError::Configuration(format!(
1102 "Max log files {} exceeds maximum (50)",
1103 logging.MaxFiles
1104 )));
1105 }
1106
1107 Ok(())
1108 }
1109
1110 fn ValidatePerformanceConfig(&self, performance:&PerformanceConfig) -> Result<()> {
1112 if performance.MemoryLimitMb < 64 {
1114 return Err(AirError::Configuration(format!(
1115 "Memory limit {} MB is below minimum (64 MB)",
1116 performance.MemoryLimitMb
1117 )));
1118 }
1119
1120 if performance.MemoryLimitMb > 16384 {
1121 return Err(AirError::Configuration(format!(
1122 "Memory limit {} MB exceeds maximum (16384 MB = 16 GB)",
1123 performance.MemoryLimitMb
1124 )));
1125 }
1126
1127 if performance.CPULimitPercent < 10 {
1129 return Err(AirError::Configuration(format!(
1130 "CPU limit {}% is below minimum (10%)",
1131 performance.CPULimitPercent
1132 )));
1133 }
1134
1135 if performance.CPULimitPercent > 100 {
1136 return Err(AirError::Configuration(format!(
1137 "CPU limit {}% exceeds maximum (100%)",
1138 performance.CPULimitPercent
1139 )));
1140 }
1141
1142 if performance.DiskLimitMb < 100 {
1144 return Err(AirError::Configuration(format!(
1145 "Disk limit {} MB is below minimum (100 MB)",
1146 performance.DiskLimitMb
1147 )));
1148 }
1149
1150 if performance.DiskLimitMb > 102400 {
1151 return Err(AirError::Configuration(format!(
1152 "Disk limit {} MB exceeds maximum (102400 MB = 100 GB)",
1153 performance.DiskLimitMb
1154 )));
1155 }
1156
1157 if performance.BackgroundTaskIntervalSecs < 1 {
1159 return Err(AirError::Configuration(format!(
1160 "Background task interval {} seconds is below minimum (1 second)",
1161 performance.BackgroundTaskIntervalSecs
1162 )));
1163 }
1164
1165 if performance.BackgroundTaskIntervalSecs > 3600 {
1166 return Err(AirError::Configuration(format!(
1167 "Background task interval {} seconds exceeds maximum (3600 seconds = 1 hour)",
1168 performance.BackgroundTaskIntervalSecs
1169 )));
1170 }
1171
1172 Ok(())
1173 }
1174
1175 fn ValidatePath(&self, path:&str) -> Result<()> {
1177 if path.is_empty() {
1178 return Err(AirError::Configuration("Path cannot be empty".to_string()));
1179 }
1180
1181 if path.contains("..") {
1183 return Err(AirError::Configuration(format!(
1184 "Path '{}' contains '..' which is not allowed for security reasons",
1185 path
1186 )));
1187 }
1188
1189 if path.starts_with("\\\\") || path.starts_with("//") {
1191 return Err(AirError::Configuration(format!(
1192 "Path '{}' uses UNC/network path format which may not be supported",
1193 path
1194 )));
1195 }
1196
1197 if path.contains('\0') {
1199 return Err(AirError::Configuration(
1200 "Path contains null bytes which is not allowed".to_string(),
1201 ));
1202 }
1203
1204 Ok(())
1205 }
1206
1207 fn IsValidAddress(addr:&str) -> bool {
1209 if addr.starts_with('[') && addr.contains("]:") {
1211 return true;
1212 }
1213
1214 if addr.contains(':') {
1216 let parts:Vec<&str> = addr.split(':').collect();
1217
1218 if parts.len() != 2 {
1219 return false;
1220 }
1221
1222 if let Ok(port) = parts[1].parse::<u16>() {
1224 return port > 0;
1225 }
1226
1227 return false;
1228 }
1229
1230 false
1231 }
1232
1233 fn IsValidUrl(url:&str) -> bool { url::Url::parse(url).is_ok() }
1235
1236 fn SchemaValidate(&self, config:&AirConfiguration) -> Result<()> {
1238 let _schema = generate_schema();
1239
1240 let ConfigJson = serde_json::to_value(config)
1242 .map_err(|e| AirError::Configuration(format!("Failed to serialize config for schema validation: {}", e)))?;
1243
1244 if !ConfigJson.is_object() {
1247 return Err(AirError::Configuration("Configuration must be an object".to_string()));
1248 }
1249
1250 dev_log!("config", "Schema validation passed");
1251
1252 Ok(())
1253 }
1254
1255 fn ApplyEnvironmentOverrides(&self, config:&mut AirConfiguration) -> Result<()> {
1264 let mut override_count = 0;
1265
1266 if let Ok(val) = env::var(&format!("{}GRPC_BIND_ADDRESS", self.EnvPrefix)) {
1268 config.gRPC.BindAddress = val;
1269
1270 override_count += 1;
1271 }
1272
1273 if let Ok(val) = env::var(&format!("{}GRPC_MAX_CONNECTIONS", self.EnvPrefix)) {
1274 config.gRPC.MaxConnections = val
1275 .parse()
1276 .map_err(|e| AirError::Configuration(format!("Invalid GRPC_MAX_CONNECTIONS value: {}", e)))?;
1277
1278 override_count += 1;
1279 }
1280
1281 if let Ok(val) = env::var(&format!("{}AUTH_ENABLED", self.EnvPrefix)) {
1283 config.Authentication.Enabled = val
1284 .parse()
1285 .map_err(|e| AirError::Configuration(format!("Invalid AUTH_ENABLED value: {}", e)))?;
1286
1287 override_count += 1;
1288 }
1289
1290 if let Ok(val) = env::var(&format!("{}AUTH_CREDENTIALS_PATH", self.EnvPrefix)) {
1291 config.Authentication.CredentialsPath = val;
1292
1293 override_count += 1;
1294 }
1295
1296 if let Ok(val) = env::var(&format!("{}UPDATE_ENABLED", self.EnvPrefix)) {
1298 config.Updates.Enabled = val
1299 .parse()
1300 .map_err(|e| AirError::Configuration(format!("Invalid UPDATE_ENABLED value: {}", e)))?;
1301
1302 override_count += 1;
1303 }
1304
1305 if let Ok(val) = env::var(&format!("{}UPDATE_AUTO_DOWNLOAD", self.EnvPrefix)) {
1306 config.Updates.AutoDownload = val
1307 .parse()
1308 .map_err(|e| AirError::Configuration(format!("Invalid UPDATE_AUTO_DOWNLOAD value: {}", e)))?;
1309
1310 override_count += 1;
1311 }
1312
1313 if let Ok(val) = env::var(&format!("{}LOGGING_LEVEL", self.EnvPrefix)) {
1315 config.Logging.Level = val.to_lowercase();
1316
1317 override_count += 1;
1318 }
1319
1320 if override_count > 0 {
1321 dev_log!("config", "Applied {} environment variable override(s)", override_count);
1322 }
1323
1324 Ok(())
1325 }
1326
1327 async fn BackupConfiguration(&self, config_path:&Path) -> Result<()> {
1332 let backup_dir = self
1333 .BackupDir
1334 .as_ref()
1335 .ok_or_else(|| AirError::Configuration("Backup directory not configured".to_string()))?;
1336
1337 tokio::fs::create_dir_all(backup_dir).await.map_err(|e| {
1339 AirError::Configuration(format!("Failed to create backup directory '{}': {}", backup_dir.display(), e))
1340 })?;
1341
1342 let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
1344
1345 let backup_filename = format!(
1346 "{}_config_{}.toml.bak",
1347 config_path.file_stem().and_then(|s| s.to_str()).unwrap_or("config"),
1348 timestamp
1349 );
1350
1351 let backup_path = backup_dir.join(&backup_filename);
1352
1353 tokio::fs::copy(config_path, &backup_path).await.map_err(|e| {
1355 AirError::Configuration(format!("Failed to create backup '{}': {}", backup_path.display(), e))
1356 })?;
1357
1358 dev_log!("config", "Configuration backed up to: {}", backup_path.display());
1359
1360 Ok(())
1361 }
1362
1363 pub async fn RollbackConfiguration(&self) -> Result<PathBuf> {
1369 let config_path = self.GetConfigPath()?;
1370
1371 let backup_dir = self
1372 .BackupDir
1373 .as_ref()
1374 .ok_or_else(|| AirError::Configuration("Backup directory not configured".to_string()))?;
1375
1376 let mut backups = tokio::fs::read_dir(backup_dir).await.map_err(|e| {
1378 AirError::Configuration(format!("Failed to read backup directory '{}': {}", backup_dir.display(), e))
1379 })?;
1380
1381 let mut most_recent:Option<(tokio::fs::DirEntry, std::time::SystemTime)> = None;
1382
1383 while let Some(entry) = backups
1384 .next_entry()
1385 .await
1386 .map_err(|e| AirError::Configuration(format!("Failed to read backup entry: {}", e)))?
1387 {
1388 let metadata = entry
1389 .metadata()
1390 .await
1391 .map_err(|e| AirError::Configuration(format!("Failed to get metadata: {}", e)))?;
1392
1393 if let Ok(modified) = metadata.modified() {
1394 if most_recent.is_none() || modified > most_recent.as_ref().unwrap().1 {
1395 most_recent = Some((entry, modified));
1396 }
1397 }
1398 }
1399
1400 let (backup_entry, _) =
1401 most_recent.ok_or_else(|| AirError::Configuration("No backup files found".to_string()))?;
1402
1403 let backup_path = backup_entry.path();
1404
1405 tokio::fs::copy(&backup_path, &config_path).await.map_err(|e| {
1407 AirError::Configuration(format!("Failed to restore from backup '{}': {}", backup_path.display(), e))
1408 })?;
1409
1410 dev_log!("config", "Configuration rolled back from: {}", backup_path.display());
1411
1412 Ok(backup_path)
1413 }
1414
1415 fn GetConfigPath(&self) -> Result<PathBuf> {
1419 if let Some(ref path) = self.ConfigPath {
1420 Ok(path.clone())
1421 } else {
1422 Self::GetDefaultConfigPath()
1423 }
1424 }
1425
1426 fn GetDefaultConfigPath() -> Result<PathBuf> {
1431 let config_dir = dirs::config_dir()
1432 .ok_or_else(|| AirError::Configuration("Cannot determine config directory".to_string()))?;
1433
1434 Ok(config_dir.join("Air").join(DefaultConfigFile))
1435 }
1436
1437 pub fn GetProfileDefaults(profile:&str) -> AirConfiguration {
1447 let mut config = AirConfiguration::default();
1448
1449 config.Profile = profile.to_string();
1450
1451 match profile {
1452 "prod" => {
1453 config.Logging.Level = "warn".to_string();
1454
1455 config.Logging.ConsoleEnabled = false;
1456
1457 config.Performance.MemoryLimitMb = 1024;
1458
1459 config.Performance.CPULimitPercent = 80;
1460 },
1461
1462 "staging" => {
1463 config.Logging.Level = "info".to_string();
1464
1465 config.Performance.MemoryLimitMb = 768;
1466
1467 config.Performance.CPULimitPercent = 70;
1468 },
1469
1470 "dev" | _ => {
1471 config.Logging.Level = "debug".to_string();
1473
1474 config.Logging.ConsoleEnabled = true;
1475
1476 config.Performance.MemoryLimitMb = 512;
1477
1478 config.Performance.CPULimitPercent = 50;
1479 },
1480 }
1481
1482 config
1483 }
1484
1485 pub fn ExpandPath(path:&str) -> Result<PathBuf> {
1495 if path.is_empty() {
1496 return Err(AirError::Configuration("Cannot expand empty path".to_string()));
1497 }
1498
1499 if path.starts_with('~') {
1500 let home = dirs::home_dir()
1501 .ok_or_else(|| AirError::Configuration("Cannot determine home directory".to_string()))?;
1502
1503 let rest = &path[1..]; if rest.starts_with('/') || rest.starts_with('\\') {
1505 Ok(home.join(&rest[1..]))
1506 } else {
1507 Ok(home.join(rest))
1508 }
1509 } else {
1510 Ok(PathBuf::from(path))
1511 }
1512 }
1513
1514 pub fn ComputeHash(config:&AirConfiguration) -> Result<String> {
1524 let config_str = toml::to_string_pretty(config)
1525 .map_err(|e| AirError::Configuration(format!("Failed to serialize config: {}", e)))?;
1526
1527 let mut hasher = sha2::Sha256::new();
1528
1529 hasher.update(config_str.as_bytes());
1530
1531 let hash = hasher.finalize();
1532
1533 Ok(hex::encode(hash))
1534 }
1535
1536 pub fn ExportToJson(config:&AirConfiguration) -> Result<String> {
1546 serde_json::to_string_pretty(config)
1547 .map_err(|e| AirError::Configuration(format!("Failed to export to JSON: {}", e)))
1548 }
1549
1550 pub fn ImportFromJson(json_str:&str) -> Result<AirConfiguration> {
1560 let config:AirConfiguration = serde_json::from_str(json_str)
1561 .map_err(|e| AirError::Configuration(format!("Failed to import from JSON: {}", e)))?;
1562
1563 Ok(config)
1564 }
1565
1566 pub fn GetEnvironmentMappings(&self) -> HashMap<String, String> {
1570 let prefix = &self.EnvPrefix;
1571
1572 let mut mappings = HashMap::new();
1573
1574 mappings.insert("grpc.bind_address".to_string(), format!("{}GRPC_BIND_ADDRESS", prefix));
1575
1576 mappings.insert("grpc.max_connections".to_string(), format!("{}GRPC_MAX_CONNECTIONS", prefix));
1577
1578 mappings.insert(
1579 "grpc.request_timeout_secs".to_string(),
1580 format!("{}GRPC_REQUEST_TIMEOUT_SECS", prefix),
1581 );
1582
1583 mappings.insert("authentication.enabled".to_string(), format!("{}AUTH_ENABLED", prefix));
1584
1585 mappings.insert(
1586 "authentication.credentials_path".to_string(),
1587 format!("{}AUTH_CREDENTIALS_PATH", prefix),
1588 );
1589
1590 mappings.insert(
1591 "authentication.token_expiration_hours".to_string(),
1592 format!("{}AUTH_TOKEN_EXPIRATION_HOURS", prefix),
1593 );
1594
1595 mappings.insert("updates.enabled".to_string(), format!("{}UPDATE_ENABLED", prefix));
1596
1597 mappings.insert("updates.auto_download".to_string(), format!("{}UPDATE_AUTO_DOWNLOAD", prefix));
1598
1599 mappings.insert("updates.auto_install".to_string(), format!("{}UPDATE_AUTO_INSTALL", prefix));
1600
1601 mappings.insert("logging.level".to_string(), format!("{}LOGGING_LEVEL", prefix));
1602
1603 mappings.insert(
1604 "logging.console_enabled".to_string(),
1605 format!("{}LOGGING_CONSOLE_ENABLED", prefix),
1606 );
1607
1608 mappings
1609 }
1610}
1611
1612#[cfg(test)]
1613mod tests {
1614
1615 use super::*;
1616
1617 #[test]
1618 fn test_default_configuration() {
1619 let config = AirConfiguration::default();
1620
1621 assert_eq!(config.SchemaVersion, "1.0.0");
1622
1623 assert_eq!(config.Profile, "dev");
1624
1625 assert!(config.Authentication.Enabled);
1626
1627 assert!(config.Logging.ConsoleEnabled);
1628 }
1629
1630 #[test]
1631 fn test_profile_defaults() {
1632 let DevConfig = ConfigurationManager::GetProfileDefaults("dev");
1633
1634 assert_eq!(DevConfig.Profile, "dev");
1635
1636 assert_eq!(DevConfig.Logging.Level, "debug");
1637
1638 let ProdConfig = ConfigurationManager::GetProfileDefaults("prod");
1639
1640 assert_eq!(ProdConfig.Profile, "prod");
1641
1642 assert_eq!(ProdConfig.Logging.Level, "warn");
1643
1644 assert!(!ProdConfig.Logging.ConsoleEnabled);
1645 }
1646
1647 #[test]
1648 fn test_path_expansion() {
1649 let Home = dirs::home_dir().expect("Cannot determine home directory");
1650
1651 let Expanded = ConfigurationManager::ExpandPath("~/test").unwrap();
1652
1653 assert_eq!(Expanded, Home.join("test"));
1654
1655 let Absolute = ConfigurationManager::ExpandPath("/tmp/test").unwrap();
1656
1657 assert_eq!(Absolute, PathBuf::from("/tmp/test"));
1658 }
1659
1660 #[test]
1661 fn test_address_validation() {
1662 assert!(ConfigurationManager::IsValidAddress("[::1]:50053"));
1663
1664 assert!(ConfigurationManager::IsValidAddress("127.0.0.1:50053"));
1665
1666 assert!(ConfigurationManager::IsValidAddress("localhost:50053"));
1667
1668 assert!(!ConfigurationManager::IsValidAddress("invalid"));
1669 }
1670
1671 #[test]
1672 fn test_url_validation() {
1673 assert!(ConfigurationManager::IsValidUrl("https://example.com"));
1674
1675 assert!(ConfigurationManager::IsValidUrl("https://updates.land.playform.cloud"));
1676
1677 assert!(!ConfigurationManager::IsValidUrl("not-a-url"));
1678
1679 assert!(!ConfigurationManager::IsValidUrl("http://insecure.com"));
1680 }
1681
1682 #[test]
1683 fn test_path_validation() {
1684 let manager = ConfigurationManager::New(None).unwrap();
1685
1686 assert!(manager.ValidatePath("~/config").is_ok());
1687
1688 assert!(manager.ValidatePath("/tmp/config").is_ok());
1689
1690 assert!(manager.ValidatePath("../escaped").is_err());
1691
1692 assert!(manager.ValidatePath("").is_err());
1693 }
1694
1695 #[tokio::test]
1696 async fn test_export_import_json() {
1697 let config = AirConfiguration::default();
1698
1699 let json_str = ConfigurationManager::ExportToJson(&config).unwrap();
1700
1701 let imported = ConfigurationManager::ImportFromJson(&json_str).unwrap();
1702
1703 assert_eq!(imported.SchemaVersion, config.SchemaVersion);
1704
1705 assert_eq!(imported.Profile, config.Profile);
1706
1707 assert_eq!(imported.gRPC.BindAddress, config.gRPC.BindAddress);
1708 }
1709
1710 #[test]
1711 fn test_compute_hash() {
1712 let config = AirConfiguration::default();
1713
1714 let hash1 = ConfigurationManager::ComputeHash(&config).unwrap();
1715
1716 let hash2 = ConfigurationManager::ComputeHash(&config).unwrap();
1717
1718 assert_eq!(hash1, hash2);
1719
1720 let mut modified = config;
1721
1722 modified.gRPC.BindAddress = "[::1]:50054".to_string();
1723
1724 let hash3 = ConfigurationManager::ComputeHash(&modified).unwrap();
1725
1726 assert_ne!(hash1, hash3);
1727 }
1728
1729 #[test]
1730 fn test_generate_schema() {
1731 let schema = generate_schema();
1732
1733 assert!(schema.is_object());
1734
1735 assert!(schema.get("$schema").is_some());
1736
1737 assert!(schema.get("properties").is_some());
1738 }
1739}