1pub mod HotReload;
90
91use std::{
92 collections::HashMap,
93 env,
94 path::{Path, PathBuf},
95};
96
97use serde::{Deserialize, Serialize};
98use serde_json::{Value as JsonValue, json};
99use sha2::Digest;
100
101use crate::{AirError, DefaultConfigFile, Result, dev_log};
102
103#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct AirConfiguration {
110 #[serde(default = "default_schema_version")]
112 pub SchemaVersion:String,
113
114 #[serde(default = "default_profile")]
116 pub Profile:String,
117
118 pub gRPC:gRPCConfig,
120
121 pub Authentication:AuthConfig,
123
124 pub Updates:UpdateConfig,
126
127 pub Downloader:DownloadConfig,
129
130 pub Indexing:IndexingConfig,
132
133 pub Logging:LoggingConfig,
135
136 pub Performance:PerformanceConfig,
138}
139
140fn default_schema_version() -> String { "1.0.0".to_string() }
141
142fn default_profile() -> String { "dev".to_string() }
143
144#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct gRPCConfig {
147 #[serde(default = "default_grpc_bind_address")]
152 pub BindAddress:String,
153
154 #[serde(default = "default_grpc_max_connections")]
158 pub MaxConnections:u32,
159
160 #[serde(default = "default_grpc_request_timeout")]
164 pub RequestTimeoutSecs:u64,
165}
166
167fn default_grpc_bind_address() -> String { "[::1]:50053".to_string() }
168
169fn default_grpc_max_connections() -> u32 { 100 }
170
171fn default_grpc_request_timeout() -> u64 { 30 }
172
173#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct AuthConfig {
176 #[serde(default = "default_auth_enabled")]
178 pub Enabled:bool,
179
180 #[serde(default = "default_auth_credentials_path")]
185 pub CredentialsPath:String,
186
187 #[serde(default = "default_auth_token_expiration")]
191 pub TokenExpirationHours:u32,
192
193 #[serde(default = "default_auth_max_sessions")]
197 pub MaxSessions:u32,
198}
199
200fn default_auth_enabled() -> bool { true }
201
202fn default_auth_credentials_path() -> String { "~/.Air/credentials".to_string() }
203
204fn default_auth_token_expiration() -> u32 { 24 }
205
206fn default_auth_max_sessions() -> u32 { 10 }
207
208#[derive(Debug, Clone, Serialize, Deserialize)]
210pub struct UpdateConfig {
211 #[serde(default = "default_update_enabled")]
213 pub Enabled:bool,
214
215 #[serde(default = "default_update_check_interval")]
219 pub CheckIntervalHours:u32,
220
221 #[serde(default = "default_update_server_url")]
226 pub UpdateServerUrl:String,
227
228 #[serde(default = "default_update_auto_download")]
230 pub AutoDownload:bool,
231
232 #[serde(default = "default_update_auto_install")]
235 pub AutoInstall:bool,
236
237 #[serde(default = "default_update_channel")]
241 pub Channel:String,
242}
243
244fn default_update_enabled() -> bool { true }
245
246fn default_update_check_interval() -> u32 { 6 }
247
248fn default_update_server_url() -> String { "https://updates.editor.land".to_string() }
249
250fn default_update_auto_download() -> bool { true }
251
252fn default_update_auto_install() -> bool { false }
253
254fn default_update_channel() -> String { "stable".to_string() }
255
256#[derive(Debug, Clone, Serialize, Deserialize)]
258pub struct DownloadConfig {
259 #[serde(default = "default_download_enabled")]
261 pub Enabled:bool,
262
263 #[serde(default = "default_download_max_concurrent")]
267 pub MaxConcurrentDownloads:u32,
268
269 #[serde(default = "default_download_timeout")]
273 pub DownloadTimeoutSecs:u64,
274
275 #[serde(default = "default_download_max_retries")]
279 pub MaxRetries:u32,
280
281 #[serde(default = "default_download_cache_dir")]
285 pub CacheDirectory:String,
286}
287
288fn default_download_enabled() -> bool { true }
289
290fn default_download_max_concurrent() -> u32 { 5 }
291
292fn default_download_timeout() -> u64 { 300 }
293
294fn default_download_max_retries() -> u32 { 3 }
295
296fn default_download_cache_dir() -> String { "~/.Air/cache".to_string() }
297
298#[derive(Debug, Clone, Serialize, Deserialize)]
300pub struct IndexingConfig {
301 #[serde(default = "default_indexing_enabled")]
303 pub Enabled:bool,
304
305 #[serde(default = "default_indexing_max_file_size")]
309 pub MaxFileSizeMb:u32,
310
311 #[serde(default = "default_indexing_file_types")]
316 pub FileTypes:Vec<String>,
317
318 #[serde(default = "default_indexing_update_interval")]
322 pub UpdateIntervalMinutes:u32,
323
324 #[serde(default = "default_indexing_directory")]
328 pub IndexDirectory:String,
329
330 #[serde(default = "default_max_parallel_indexing")]
334 pub MaxParallelIndexing:u32,
335}
336
337fn default_indexing_enabled() -> bool { true }
338
339fn default_indexing_max_file_size() -> u32 { 10 }
340
341fn default_indexing_file_types() -> Vec<String> {
342 vec![
343 "*.rs".to_string(),
344 "*.ts".to_string(),
345 "*.js".to_string(),
346 "*.json".to_string(),
347 "*.toml".to_string(),
348 "*.md".to_string(),
349 ]
350}
351
352fn default_indexing_update_interval() -> u32 { 30 }
353
354fn default_indexing_directory() -> String { "~/.Air/index".to_string() }
355
356fn default_max_parallel_indexing() -> u32 { 10 }
357
358#[derive(Debug, Clone, Serialize, Deserialize)]
360pub struct LoggingConfig {
361 #[serde(default = "default_logging_level")]
365 pub Level:String,
366
367 #[serde(default = "default_logging_file_path")]
371 pub FilePath:Option<String>,
372
373 #[serde(default = "default_logging_console_enabled")]
375 pub ConsoleEnabled:bool,
376
377 #[serde(default = "default_logging_max_file_size")]
381 pub MaxFileSizeMb:u32,
382
383 #[serde(default = "default_logging_max_files")]
387 pub MaxFiles:u32,
388}
389
390fn default_logging_level() -> String { "info".to_string() }
391
392fn default_logging_file_path() -> Option<String> { Some("~/.Air/logs/Air.log".to_string()) }
393
394fn default_logging_console_enabled() -> bool { true }
395
396fn default_logging_max_file_size() -> u32 { 10 }
397
398fn default_logging_max_files() -> u32 { 5 }
399
400#[derive(Debug, Clone, Serialize, Deserialize)]
402pub struct PerformanceConfig {
403 #[serde(default = "default_perf_memory_limit")]
407 pub MemoryLimitMb:u32,
408
409 #[serde(default = "default_perf_cpu_limit")]
413 pub CPULimitPercent:u32,
414
415 #[serde(default = "default_perf_disk_limit")]
419 pub DiskLimitMb:u32,
420
421 #[serde(default = "default_perf_task_interval")]
425 pub BackgroundTaskIntervalSecs:u64,
426}
427
428fn default_perf_memory_limit() -> u32 { 512 }
429
430fn default_perf_cpu_limit() -> u32 { 50 }
431
432fn default_perf_disk_limit() -> u32 { 1024 }
433
434fn default_perf_task_interval() -> u64 { 60 }
435
436impl Default for AirConfiguration {
437 fn default() -> Self {
438 Self {
439 SchemaVersion:default_schema_version(),
440
441 Profile:default_profile(),
442
443 gRPC:gRPCConfig {
444 BindAddress:default_grpc_bind_address(),
445
446 MaxConnections:default_grpc_max_connections(),
447
448 RequestTimeoutSecs:default_grpc_request_timeout(),
449 },
450
451 Authentication:AuthConfig {
452 Enabled:default_auth_enabled(),
453
454 CredentialsPath:default_auth_credentials_path(),
455
456 TokenExpirationHours:default_auth_token_expiration(),
457
458 MaxSessions:default_auth_max_sessions(),
459 },
460
461 Updates:UpdateConfig {
462 Enabled:default_update_enabled(),
463
464 CheckIntervalHours:default_update_check_interval(),
465
466 UpdateServerUrl:default_update_server_url(),
467
468 AutoDownload:default_update_auto_download(),
469
470 AutoInstall:default_update_auto_install(),
471
472 Channel:default_update_channel(),
473 },
474
475 Downloader:DownloadConfig {
476 Enabled:default_download_enabled(),
477
478 MaxConcurrentDownloads:default_download_max_concurrent(),
479
480 DownloadTimeoutSecs:default_download_timeout(),
481
482 MaxRetries:default_download_max_retries(),
483
484 CacheDirectory:default_download_cache_dir(),
485 },
486
487 Indexing:IndexingConfig {
488 Enabled:default_indexing_enabled(),
489
490 MaxFileSizeMb:default_indexing_max_file_size(),
491
492 FileTypes:default_indexing_file_types(),
493
494 UpdateIntervalMinutes:default_indexing_update_interval(),
495
496 IndexDirectory:default_indexing_directory(),
497
498 MaxParallelIndexing:default_max_parallel_indexing(),
499 },
500
501 Logging:LoggingConfig {
502 Level:default_logging_level(),
503
504 FilePath:default_logging_file_path(),
505
506 ConsoleEnabled:default_logging_console_enabled(),
507
508 MaxFileSizeMb:default_logging_max_file_size(),
509
510 MaxFiles:default_logging_max_files(),
511 },
512
513 Performance:PerformanceConfig {
514 MemoryLimitMb:default_perf_memory_limit(),
515
516 CPULimitPercent:default_perf_cpu_limit(),
517
518 DiskLimitMb:default_perf_disk_limit(),
519
520 BackgroundTaskIntervalSecs:default_perf_task_interval(),
521 },
522 }
523 }
524}
525
526pub fn generate_schema() -> JsonValue {
532 json!({
533 "$schema": "http://json-schema.org/draft-07/schema#",
534 "title": "Air Configuration Schema",
535 "description": "Configuration schema for Air daemon",
536 "type": "object",
537 "required": ["SchemaVersion", "profile"],
538 "properties": {
539 "SchemaVersion": {
540 "type": "string",
541 "description": "Configuration schema version for migration tracking",
542 "pattern": "^\\d+\\.\\d+\\.\\d+$"
543 },
544 "profile": {
545 "type": "string",
546 "description": "Profile name (dev, staging, prod, custom)",
547 "enum": ["dev", "staging", "prod", "custom"]
548 },
549 "grpc": {
550 "type": "object",
551 "description": "gRPC server configuration",
552 "properties": {
553 "BindAddress": {
554 "type": "string",
555 "description": "gRPC server bind address",
556 "format": "hostname-port"
557 },
558 "MaxConnections": {
559 "type": "integer",
560 "minimum": 10,
561 "maximum": 10000
562 },
563 "RequestTimeoutSecs": {
564 "type": "integer",
565 "minimum": 1,
566 "maximum": 3600
567 }
568 }
569 },
570 "authentication": {
571 "type": "object",
572 "description": "Authentication configuration",
573 "properties": {
574 "enabled": {"type": "boolean"},
575 "CredentialsPath": {"type": "string"},
576 "TokenExpirationHours": {
577 "type": "integer",
578 "minimum": 1,
579 "maximum": 8760
580 },
581 "MaxSessions": {
582 "type": "integer",
583 "minimum": 1,
584 "maximum": 1000
585 }
586 }
587 },
588 "updates": {
589 "type": "object",
590 "properties": {
591 "enabled": {"type": "boolean"},
592 "CheckIntervalHours": {
593 "type": "integer",
594 "minimum": 1,
595 "maximum": 168
596 },
597 "UpdateServerUrl": {
598 "type": "string",
599 "pattern": "^https://"
600 },
601 "AutoDownload": {"type": "boolean"},
602 "AutoInstall": {"type": "boolean"},
603 "channel": {
604 "type": "string",
605 "enum": ["stable", "insiders", "preview"]
606 }
607 }
608 },
609 "downloader": {
610 "type": "object",
611 "properties": {
612 "enabled": {"type": "boolean"},
613 "MaxConcurrentDownloads": {
614 "type": "integer",
615 "minimum": 1,
616 "maximum": 50
617 },
618 "DownloadTimeoutSecs": {
619 "type": "integer",
620 "minimum": 10,
621 "maximum": 3600
622 },
623 "MaxRetries": {
624 "type": "integer",
625 "minimum": 0,
626 "maximum": 10
627 },
628 "CacheDirectory": {"type": "string"}
629 }
630 },
631 "indexing": {
632 "type": "object",
633 "properties": {
634 "enabled": {"type": "boolean"},
635 "MaxFileSizeMb": {
636 "type": "integer",
637 "minimum": 1,
638 "maximum": 1024
639 },
640 "FileTypes": {
641 "type": "array",
642 "items": {"type": "string"}
643 },
644 "UpdateIntervalMinutes": {
645 "type": "integer",
646 "minimum": 1,
647 "maximum": 1440
648 },
649 "IndexDirectory": {"type": "string"}
650 }
651 },
652 "logging": {
653 "type": "object",
654 "properties": {
655 "level": {
656 "type": "string",
657 "enum": ["trace", "debug", "info", "warn", "error"]
658 },
659 "FilePath": {"type": ["string", "null"]},
660 "ConsoleEnabled": {"type": "boolean"},
661 "MaxFileSizeMb": {
662 "type": "integer",
663 "minimum": 1,
664 "maximum": 1000
665 },
666 "MaxFiles": {
667 "type": "integer",
668 "minimum": 1,
669 "maximum": 50
670 }
671 }
672 },
673 "performance": {
674 "type": "object",
675 "properties": {
676 "MemoryLimitMb": {
677 "type": "integer",
678 "minimum": 64,
679 "maximum": 16384
680 },
681 "CPULimitPercent": {
682 "type": "integer",
683 "minimum": 10,
684 "maximum": 100
685 },
686 "DiskLimitMb": {
687 "type": "integer",
688 "minimum": 100,
689 "maximum": 102400
690 },
691 "BackgroundTaskIntervalSecs": {
692 "type": "integer",
693 "minimum": 1,
694 "maximum": 3600
695 }
696 }
697 }
698 }
699 })
700}
701
702pub struct ConfigurationManager {
709 ConfigPath:Option<PathBuf>,
711
712 BackupDir:Option<PathBuf>,
714
715 EnableBackup:bool,
717
718 EnvPrefix:String,
720}
721
722impl ConfigurationManager {
723 pub fn New(ConfigPath:Option<String>) -> Result<Self> {
734 let path = ConfigPath.map(PathBuf::from);
735
736 let BackupDir = path
737 .as_ref()
738 .and_then(|p| p.parent())
739 .map(|parent| parent.join(".ConfigBackups"));
740
741 Ok(Self { ConfigPath:path, BackupDir, EnableBackup:true, EnvPrefix:"AIR_".to_string() })
742 }
743
744 pub fn NewWithSettings(ConfigPath:Option<String>, EnableBackup:bool, EnvPrefix:String) -> Result<Self> {
752 let path = ConfigPath.map(PathBuf::from);
753
754 let BackupDir = if EnableBackup {
755 path.as_ref()
756 .and_then(|p| p.parent())
757 .map(|parent| parent.join(".ConfigBackups"))
758 } else {
759 None
760 };
761
762 Ok(Self { ConfigPath:path, BackupDir, EnableBackup, EnvPrefix })
763 }
764
765 pub async fn LoadConfiguration(&self) -> Result<AirConfiguration> {
776 let mut config = AirConfiguration::default();
778
779 let ConfigPath = self.GetConfigPath()?;
781
782 if ConfigPath.exists() {
783 dev_log!("config", "Loading configuration from: {}", ConfigPath.display());
784
785 config = self.LoadFromFile(&ConfigPath).await?;
786 } else {
787 dev_log!("config", "No configuration file found, using defaults");
788 }
789
790 self.ApplyEnvironmentOverrides(&mut config)?;
792
793 self.SchemaValidate(&config)?;
795
796 self.ValidateConfiguration(&config)?;
798
799 dev_log!("config", "Configuration loaded successfully (profile: {})", config.Profile);
800
801 Ok(config)
802 }
803
804 async fn LoadFromFile(&self, path:&Path) -> Result<AirConfiguration> {
814 let content = tokio::fs::read_to_string(path)
815 .await
816 .map_err(|e| AirError::Configuration(format!("Failed to read config file '{}': {}", path.display(), e)))?;
817
818 let config:AirConfiguration = toml::from_str(&content).map_err(|e| {
819 AirError::Configuration(format!("Failed to parse TOML config file '{}': {}", path.display(), e))
820 })?;
821
822 dev_log!("config", "Configuration file parsed successfully");
824
825 Ok(config)
826 }
827
828 pub async fn SaveConfiguration(&self, config:&AirConfiguration) -> Result<()> {
841 self.ValidateConfiguration(config)?;
843
844 let ConfigPath = self.GetConfigPath()?;
845
846 if self.EnableBackup && ConfigPath.exists() {
848 self.BackupConfiguration(&ConfigPath).await?;
849 }
850
851 if let Some(parent) = ConfigPath.parent() {
853 tokio::fs::create_dir_all(parent).await.map_err(|e| {
854 AirError::Configuration(format!("Failed to create config directory '{}': {}", parent.display(), e))
855 })?;
856 }
857
858 let TempPath = ConfigPath.with_extension("tmp");
860
861 let content = toml::to_string_pretty(config)
862 .map_err(|e| AirError::Configuration(format!("Failed to serialize config: {}", e)))?;
863
864 tokio::fs::write(&TempPath, content).await.map_err(|e| {
865 AirError::Configuration(format!("Failed to write temp config file '{}': {}", TempPath.display(), e))
866 })?;
867
868 tokio::fs::rename(&TempPath, &ConfigPath).await.map_err(|e| {
870 AirError::Configuration(format!("Failed to rename temp config to '{}': {}", ConfigPath.display(), e))
871 })?;
872
873 dev_log!("config", "Configuration saved to: {}", ConfigPath.display());
874
875 Ok(())
876 }
877
878 fn ValidateConfiguration(&self, config:&AirConfiguration) -> Result<()> {
887 self.ValidateSchemaVersion(&config.SchemaVersion)?;
889
890 self.ValidateProfile(&config.Profile)?;
892
893 self.ValidategRPCConfig(&config.gRPC)?;
895
896 self.ValidateAuthConfig(&config.Authentication)?;
898
899 self.ValidateUpdateConfig(&config.Updates)?;
901
902 self.ValidateDownloadConfig(&config.Downloader)?;
904
905 self.ValidateIndexingConfig(&config.Indexing)?;
907
908 self.ValidateLoggingConfig(&config.Logging)?;
910
911 self.ValidatePerformanceConfig(&config.Performance)?;
913
914 dev_log!("config", "All configuration validation checks passed");
915
916 Ok(())
917 }
918
919 fn ValidateSchemaVersion(&self, version:&str) -> Result<()> {
921 if !version.chars().all(|c| c.is_digit(10) || c == '.') {
922 return Err(AirError::Configuration(format!(
923 "Invalid schema version '{}': must be in format X.Y.Z",
924 version
925 )));
926 }
927
928 let parts:Vec<&str> = version.split('.').collect();
929
930 if parts.len() != 3 {
931 return Err(AirError::Configuration(format!(
932 "Invalid schema version '{}': must have 3 parts (X.Y.Z)",
933 version
934 )));
935 }
936
937 for (i, part) in parts.iter().enumerate() {
938 if part.is_empty() {
939 return Err(AirError::Configuration(format!(
940 "Invalid schema version '{}': part {} is empty",
941 version,
942 i + 1
943 )));
944 }
945 }
946
947 Ok(())
948 }
949
950 fn ValidateProfile(&self, profile:&str) -> Result<()> {
952 let ValidProfiles = ["dev", "staging", "prod", "custom"];
953
954 if !ValidProfiles.contains(&profile) {
955 return Err(AirError::Configuration(format!(
956 "Invalid profile '{}': must be one of: {}",
957 profile,
958 ValidProfiles.join(", ")
959 )));
960 }
961
962 Ok(())
963 }
964
965 fn ValidategRPCConfig(&self, grpc:&gRPCConfig) -> Result<()> {
967 if grpc.BindAddress.is_empty() {
969 return Err(AirError::Configuration("gRPC bind address cannot be empty".to_string()));
970 }
971
972 if !Self::IsValidAddress(&grpc.BindAddress) {
974 return Err(AirError::Configuration(format!(
975 "Invalid gRPC bind address '{}': must be in format host:port or [IPv6]:port",
976 grpc.BindAddress
977 )));
978 }
979
980 if grpc.MaxConnections < 10 {
982 return Err(AirError::Configuration(format!(
983 "gRPC MaxConnections {} is below minimum (10)",
984 grpc.MaxConnections
985 )));
986 }
987
988 if grpc.MaxConnections > 10000 {
989 return Err(AirError::Configuration(format!(
990 "gRPC MaxConnections {} exceeds maximum (10000)",
991 grpc.MaxConnections
992 )));
993 }
994
995 if grpc.RequestTimeoutSecs < 1 {
997 return Err(AirError::Configuration(format!(
998 "gRPC RequestTimeoutSecs {} is below minimum (1 second)",
999 grpc.RequestTimeoutSecs
1000 )));
1001 }
1002
1003 if grpc.RequestTimeoutSecs > 3600 {
1004 return Err(AirError::Configuration(format!(
1005 "gRPC RequestTimeoutSecs {} exceeds maximum (3600 seconds = 1 hour)",
1006 grpc.RequestTimeoutSecs
1007 )));
1008 }
1009
1010 Ok(())
1011 }
1012
1013 fn ValidateAuthConfig(&self, auth:&AuthConfig) -> Result<()> {
1015 if auth.Enabled {
1017 if auth.CredentialsPath.is_empty() {
1018 return Err(AirError::Configuration(
1019 "Authentication credentials path cannot be empty when authentication is enabled".to_string(),
1020 ));
1021 }
1022
1023 self.ValidatePath(&auth.CredentialsPath)?;
1025 }
1026
1027 if auth.TokenExpirationHours < 1 {
1029 return Err(AirError::Configuration(format!(
1030 "Token expiration hours {} is below minimum (1 hour)",
1031 auth.TokenExpirationHours
1032 )));
1033 }
1034
1035 if auth.TokenExpirationHours > 8760 {
1036 return Err(AirError::Configuration(format!(
1037 "Token expiration hours {} exceeds maximum (8760 hours = 1 year)",
1038 auth.TokenExpirationHours
1039 )));
1040 }
1041
1042 if auth.MaxSessions < 1 {
1044 return Err(AirError::Configuration(format!(
1045 "Max sessions {} is below minimum (1)",
1046 auth.MaxSessions
1047 )));
1048 }
1049
1050 if auth.MaxSessions > 1000 {
1051 return Err(AirError::Configuration(format!(
1052 "Max sessions {} exceeds maximum (1000)",
1053 auth.MaxSessions
1054 )));
1055 }
1056
1057 Ok(())
1058 }
1059
1060 fn ValidateUpdateConfig(&self, updates:&UpdateConfig) -> Result<()> {
1062 if updates.Enabled {
1063 if updates.UpdateServerUrl.is_empty() {
1065 return Err(AirError::Configuration(
1066 "Update server URL cannot be empty when updates are enabled".to_string(),
1067 ));
1068 }
1069
1070 if !updates.UpdateServerUrl.starts_with("https://") {
1072 return Err(AirError::Configuration(format!(
1073 "Update server URL must use HTTPS, got: {}",
1074 updates.UpdateServerUrl
1075 )));
1076 }
1077
1078 if !Self::IsValidUrl(&updates.UpdateServerUrl) {
1080 return Err(AirError::Configuration(format!(
1081 "Invalid update server URL '{}'",
1082 updates.UpdateServerUrl
1083 )));
1084 }
1085 }
1086
1087 if updates.CheckIntervalHours < 1 {
1089 return Err(AirError::Configuration(format!(
1090 "Update check interval {} hours is below minimum (1 hour)",
1091 updates.CheckIntervalHours
1092 )));
1093 }
1094
1095 if updates.CheckIntervalHours > 168 {
1096 return Err(AirError::Configuration(format!(
1097 "Update check interval {} hours exceeds maximum (168 hours = 1 week)",
1098 updates.CheckIntervalHours
1099 )));
1100 }
1101
1102 Ok(())
1103 }
1104
1105 fn ValidateDownloadConfig(&self, downloader:&DownloadConfig) -> Result<()> {
1107 if downloader.Enabled {
1108 if downloader.CacheDirectory.is_empty() {
1109 return Err(AirError::Configuration(
1110 "Download cache directory cannot be empty when downloader is enabled".to_string(),
1111 ));
1112 }
1113
1114 self.ValidatePath(&downloader.CacheDirectory)?;
1116 }
1117
1118 if downloader.MaxConcurrentDownloads < 1 {
1120 return Err(AirError::Configuration(format!(
1121 "Max concurrent downloads {} is below minimum (1)",
1122 downloader.MaxConcurrentDownloads
1123 )));
1124 }
1125
1126 if downloader.MaxConcurrentDownloads > 50 {
1127 return Err(AirError::Configuration(format!(
1128 "Max concurrent downloads {} exceeds maximum (50)",
1129 downloader.MaxConcurrentDownloads
1130 )));
1131 }
1132
1133 if downloader.DownloadTimeoutSecs < 10 {
1135 return Err(AirError::Configuration(format!(
1136 "Download timeout {} seconds is below minimum (10 seconds)",
1137 downloader.DownloadTimeoutSecs
1138 )));
1139 }
1140
1141 if downloader.DownloadTimeoutSecs > 3600 {
1142 return Err(AirError::Configuration(format!(
1143 "Download timeout {} seconds exceeds maximum (3600 seconds = 1 hour)",
1144 downloader.DownloadTimeoutSecs
1145 )));
1146 }
1147
1148 if downloader.MaxRetries > 10 {
1150 return Err(AirError::Configuration(format!(
1151 "Max retries {} exceeds maximum (10)",
1152 downloader.MaxRetries
1153 )));
1154 }
1155
1156 Ok(())
1157 }
1158
1159 fn ValidateIndexingConfig(&self, indexing:&IndexingConfig) -> Result<()> {
1161 if indexing.Enabled {
1162 if indexing.IndexDirectory.is_empty() {
1163 return Err(AirError::Configuration(
1164 "Index directory cannot be empty when indexing is enabled".to_string(),
1165 ));
1166 }
1167
1168 self.ValidatePath(&indexing.IndexDirectory)?;
1170
1171 if indexing.FileTypes.is_empty() {
1173 return Err(AirError::Configuration(
1174 "File types to index cannot be empty when indexing is enabled".to_string(),
1175 ));
1176 }
1177
1178 for FileType in &indexing.FileTypes {
1180 if FileType.is_empty() {
1181 return Err(AirError::Configuration("File type pattern cannot be empty".to_string()));
1182 }
1183
1184 if !FileType.contains('*') {
1185 dev_log!(
1186 "config",
1187 "warn: File type pattern '{}' does not contain wildcards, may not match as expected",
1188 FileType
1189 );
1190 }
1191 }
1192 }
1193
1194 if indexing.MaxFileSizeMb < 1 {
1196 return Err(AirError::Configuration(format!(
1197 "Max file size {} MB is below minimum (1 MB)",
1198 indexing.MaxFileSizeMb
1199 )));
1200 }
1201
1202 if indexing.MaxFileSizeMb > 1024 {
1203 return Err(AirError::Configuration(format!(
1204 "Max file size {} MB exceeds maximum (1024 MB = 1 GB)",
1205 indexing.MaxFileSizeMb
1206 )));
1207 }
1208
1209 if indexing.UpdateIntervalMinutes < 1 {
1211 return Err(AirError::Configuration(format!(
1212 "Index update interval {} minutes is below minimum (1 minute)",
1213 indexing.UpdateIntervalMinutes
1214 )));
1215 }
1216
1217 if indexing.UpdateIntervalMinutes > 1440 {
1218 return Err(AirError::Configuration(format!(
1219 "Index update interval {} minutes exceeds maximum (1440 minutes = 1 day)",
1220 indexing.UpdateIntervalMinutes
1221 )));
1222 }
1223
1224 Ok(())
1225 }
1226
1227 fn ValidateLoggingConfig(&self, logging:&LoggingConfig) -> Result<()> {
1229 let ValidLevels = ["trace", "debug", "info", "warn", "error"];
1231
1232 if !ValidLevels.contains(&logging.Level.as_str()) {
1233 return Err(AirError::Configuration(format!(
1234 "Invalid log level '{}': must be one of: {}",
1235 logging.Level,
1236 ValidLevels.join(", ")
1237 )));
1238 }
1239
1240 if let Some(ref FilePath) = logging.FilePath {
1242 if !FilePath.is_empty() {
1243 self.ValidatePath(FilePath)?;
1244 }
1245 }
1246
1247 if logging.MaxFileSizeMb < 1 {
1249 return Err(AirError::Configuration(format!(
1250 "Max log file size {} MB is below minimum (1 MB)",
1251 logging.MaxFileSizeMb
1252 )));
1253 }
1254
1255 if logging.MaxFileSizeMb > 1000 {
1256 return Err(AirError::Configuration(format!(
1257 "Max log file size {} MB exceeds maximum (1000 MB = 1 GB)",
1258 logging.MaxFileSizeMb
1259 )));
1260 }
1261
1262 if logging.MaxFiles < 1 {
1264 return Err(AirError::Configuration(format!(
1265 "Max log files {} is below minimum (1)",
1266 logging.MaxFiles
1267 )));
1268 }
1269
1270 if logging.MaxFiles > 50 {
1271 return Err(AirError::Configuration(format!(
1272 "Max log files {} exceeds maximum (50)",
1273 logging.MaxFiles
1274 )));
1275 }
1276
1277 Ok(())
1278 }
1279
1280 fn ValidatePerformanceConfig(&self, performance:&PerformanceConfig) -> Result<()> {
1282 if performance.MemoryLimitMb < 64 {
1284 return Err(AirError::Configuration(format!(
1285 "Memory limit {} MB is below minimum (64 MB)",
1286 performance.MemoryLimitMb
1287 )));
1288 }
1289
1290 if performance.MemoryLimitMb > 16384 {
1291 return Err(AirError::Configuration(format!(
1292 "Memory limit {} MB exceeds maximum (16384 MB = 16 GB)",
1293 performance.MemoryLimitMb
1294 )));
1295 }
1296
1297 if performance.CPULimitPercent < 10 {
1299 return Err(AirError::Configuration(format!(
1300 "CPU limit {}% is below minimum (10%)",
1301 performance.CPULimitPercent
1302 )));
1303 }
1304
1305 if performance.CPULimitPercent > 100 {
1306 return Err(AirError::Configuration(format!(
1307 "CPU limit {}% exceeds maximum (100%)",
1308 performance.CPULimitPercent
1309 )));
1310 }
1311
1312 if performance.DiskLimitMb < 100 {
1314 return Err(AirError::Configuration(format!(
1315 "Disk limit {} MB is below minimum (100 MB)",
1316 performance.DiskLimitMb
1317 )));
1318 }
1319
1320 if performance.DiskLimitMb > 102400 {
1321 return Err(AirError::Configuration(format!(
1322 "Disk limit {} MB exceeds maximum (102400 MB = 100 GB)",
1323 performance.DiskLimitMb
1324 )));
1325 }
1326
1327 if performance.BackgroundTaskIntervalSecs < 1 {
1329 return Err(AirError::Configuration(format!(
1330 "Background task interval {} seconds is below minimum (1 second)",
1331 performance.BackgroundTaskIntervalSecs
1332 )));
1333 }
1334
1335 if performance.BackgroundTaskIntervalSecs > 3600 {
1336 return Err(AirError::Configuration(format!(
1337 "Background task interval {} seconds exceeds maximum (3600 seconds = 1 hour)",
1338 performance.BackgroundTaskIntervalSecs
1339 )));
1340 }
1341
1342 Ok(())
1343 }
1344
1345 fn ValidatePath(&self, path:&str) -> Result<()> {
1347 if path.is_empty() {
1348 return Err(AirError::Configuration("Path cannot be empty".to_string()));
1349 }
1350
1351 if path.contains("..") {
1353 return Err(AirError::Configuration(format!(
1354 "Path '{}' contains '..' which is not allowed for security reasons",
1355 path
1356 )));
1357 }
1358
1359 if path.starts_with("\\\\") || path.starts_with("//") {
1361 return Err(AirError::Configuration(format!(
1362 "Path '{}' uses UNC/network path format which may not be supported",
1363 path
1364 )));
1365 }
1366
1367 if path.contains('\0') {
1369 return Err(AirError::Configuration(
1370 "Path contains null bytes which is not allowed".to_string(),
1371 ));
1372 }
1373
1374 Ok(())
1375 }
1376
1377 fn IsValidAddress(addr:&str) -> bool {
1379 if addr.starts_with('[') && addr.contains("]:") {
1381 return true;
1382 }
1383
1384 if addr.contains(':') {
1386 let parts:Vec<&str> = addr.split(':').collect();
1387
1388 if parts.len() != 2 {
1389 return false;
1390 }
1391
1392 if let Ok(port) = parts[1].parse::<u16>() {
1394 return port > 0;
1395 }
1396
1397 return false;
1398 }
1399
1400 false
1401 }
1402
1403 fn IsValidUrl(url:&str) -> bool { url::Url::parse(url).is_ok() }
1405
1406 fn SchemaValidate(&self, config:&AirConfiguration) -> Result<()> {
1408 let _schema = generate_schema();
1409
1410 let ConfigJson = serde_json::to_value(config)
1412 .map_err(|e| AirError::Configuration(format!("Failed to serialize config for schema validation: {}", e)))?;
1413
1414 if !ConfigJson.is_object() {
1417 return Err(AirError::Configuration("Configuration must be an object".to_string()));
1418 }
1419
1420 dev_log!("config", "Schema validation passed");
1421
1422 Ok(())
1423 }
1424
1425 fn ApplyEnvironmentOverrides(&self, config:&mut AirConfiguration) -> Result<()> {
1434 let mut override_count = 0;
1435
1436 if let Ok(val) = env::var(&format!("{}GRPC_BIND_ADDRESS", self.EnvPrefix)) {
1438 config.gRPC.BindAddress = val;
1439
1440 override_count += 1;
1441 }
1442
1443 if let Ok(val) = env::var(&format!("{}GRPC_MAX_CONNECTIONS", self.EnvPrefix)) {
1444 config.gRPC.MaxConnections = val
1445 .parse()
1446 .map_err(|e| AirError::Configuration(format!("Invalid GRPC_MAX_CONNECTIONS value: {}", e)))?;
1447
1448 override_count += 1;
1449 }
1450
1451 if let Ok(val) = env::var(&format!("{}AUTH_ENABLED", self.EnvPrefix)) {
1453 config.Authentication.Enabled = val
1454 .parse()
1455 .map_err(|e| AirError::Configuration(format!("Invalid AUTH_ENABLED value: {}", e)))?;
1456
1457 override_count += 1;
1458 }
1459
1460 if let Ok(val) = env::var(&format!("{}AUTH_CREDENTIALS_PATH", self.EnvPrefix)) {
1461 config.Authentication.CredentialsPath = val;
1462
1463 override_count += 1;
1464 }
1465
1466 if let Ok(val) = env::var(&format!("{}UPDATE_ENABLED", self.EnvPrefix)) {
1468 config.Updates.Enabled = val
1469 .parse()
1470 .map_err(|e| AirError::Configuration(format!("Invalid UPDATE_ENABLED value: {}", e)))?;
1471
1472 override_count += 1;
1473 }
1474
1475 if let Ok(val) = env::var(&format!("{}UPDATE_AUTO_DOWNLOAD", self.EnvPrefix)) {
1476 config.Updates.AutoDownload = val
1477 .parse()
1478 .map_err(|e| AirError::Configuration(format!("Invalid UPDATE_AUTO_DOWNLOAD value: {}", e)))?;
1479
1480 override_count += 1;
1481 }
1482
1483 if let Ok(val) = env::var(&format!("{}LOGGING_LEVEL", self.EnvPrefix)) {
1485 config.Logging.Level = val.to_lowercase();
1486
1487 override_count += 1;
1488 }
1489
1490 if override_count > 0 {
1491 dev_log!("config", "Applied {} environment variable override(s)", override_count);
1492 }
1493
1494 Ok(())
1495 }
1496
1497 async fn BackupConfiguration(&self, config_path:&Path) -> Result<()> {
1502 let backup_dir = self
1503 .BackupDir
1504 .as_ref()
1505 .ok_or_else(|| AirError::Configuration("Backup directory not configured".to_string()))?;
1506
1507 tokio::fs::create_dir_all(backup_dir).await.map_err(|e| {
1509 AirError::Configuration(format!("Failed to create backup directory '{}': {}", backup_dir.display(), e))
1510 })?;
1511
1512 let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
1514
1515 let backup_filename = format!(
1516 "{}_config_{}.toml.bak",
1517 config_path.file_stem().and_then(|s| s.to_str()).unwrap_or("config"),
1518 timestamp
1519 );
1520
1521 let backup_path = backup_dir.join(&backup_filename);
1522
1523 tokio::fs::copy(config_path, &backup_path).await.map_err(|e| {
1525 AirError::Configuration(format!("Failed to create backup '{}': {}", backup_path.display(), e))
1526 })?;
1527
1528 dev_log!("config", "Configuration backed up to: {}", backup_path.display());
1529
1530 Ok(())
1531 }
1532
1533 pub async fn RollbackConfiguration(&self) -> Result<PathBuf> {
1539 let config_path = self.GetConfigPath()?;
1540
1541 let backup_dir = self
1542 .BackupDir
1543 .as_ref()
1544 .ok_or_else(|| AirError::Configuration("Backup directory not configured".to_string()))?;
1545
1546 let mut backups = tokio::fs::read_dir(backup_dir).await.map_err(|e| {
1548 AirError::Configuration(format!("Failed to read backup directory '{}': {}", backup_dir.display(), e))
1549 })?;
1550
1551 let mut most_recent:Option<(tokio::fs::DirEntry, std::time::SystemTime)> = None;
1552
1553 while let Some(entry) = backups
1554 .next_entry()
1555 .await
1556 .map_err(|e| AirError::Configuration(format!("Failed to read backup entry: {}", e)))?
1557 {
1558 let metadata = entry
1559 .metadata()
1560 .await
1561 .map_err(|e| AirError::Configuration(format!("Failed to get metadata: {}", e)))?;
1562
1563 if let Ok(modified) = metadata.modified() {
1564 if most_recent.is_none() || modified > most_recent.as_ref().unwrap().1 {
1565 most_recent = Some((entry, modified));
1566 }
1567 }
1568 }
1569
1570 let (backup_entry, _) =
1571 most_recent.ok_or_else(|| AirError::Configuration("No backup files found".to_string()))?;
1572
1573 let backup_path = backup_entry.path();
1574
1575 tokio::fs::copy(&backup_path, &config_path).await.map_err(|e| {
1577 AirError::Configuration(format!("Failed to restore from backup '{}': {}", backup_path.display(), e))
1578 })?;
1579
1580 dev_log!("config", "Configuration rolled back from: {}", backup_path.display());
1581
1582 Ok(backup_path)
1583 }
1584
1585 fn GetConfigPath(&self) -> Result<PathBuf> {
1589 if let Some(ref path) = self.ConfigPath {
1590 Ok(path.clone())
1591 } else {
1592 Self::GetDefaultConfigPath()
1593 }
1594 }
1595
1596 fn GetDefaultConfigPath() -> Result<PathBuf> {
1601 let config_dir = dirs::config_dir()
1602 .ok_or_else(|| AirError::Configuration("Cannot determine config directory".to_string()))?;
1603
1604 Ok(config_dir.join("Air").join(DefaultConfigFile))
1605 }
1606
1607 pub fn GetProfileDefaults(profile:&str) -> AirConfiguration {
1617 let mut config = AirConfiguration::default();
1618
1619 config.Profile = profile.to_string();
1620
1621 match profile {
1622 "prod" => {
1623 config.Logging.Level = "warn".to_string();
1624
1625 config.Logging.ConsoleEnabled = false;
1626
1627 config.Performance.MemoryLimitMb = 1024;
1628
1629 config.Performance.CPULimitPercent = 80;
1630 },
1631
1632 "staging" => {
1633 config.Logging.Level = "info".to_string();
1634
1635 config.Performance.MemoryLimitMb = 768;
1636
1637 config.Performance.CPULimitPercent = 70;
1638 },
1639
1640 "dev" | _ => {
1641 config.Logging.Level = "debug".to_string();
1643
1644 config.Logging.ConsoleEnabled = true;
1645
1646 config.Performance.MemoryLimitMb = 512;
1647
1648 config.Performance.CPULimitPercent = 50;
1649 },
1650 }
1651
1652 config
1653 }
1654
1655 pub fn ExpandPath(path:&str) -> Result<PathBuf> {
1665 if path.is_empty() {
1666 return Err(AirError::Configuration("Cannot expand empty path".to_string()));
1667 }
1668
1669 if path.starts_with('~') {
1670 let home = dirs::home_dir()
1671 .ok_or_else(|| AirError::Configuration("Cannot determine home directory".to_string()))?;
1672
1673 let rest = &path[1..]; if rest.starts_with('/') || rest.starts_with('\\') {
1675 Ok(home.join(&rest[1..]))
1676 } else {
1677 Ok(home.join(rest))
1678 }
1679 } else {
1680 Ok(PathBuf::from(path))
1681 }
1682 }
1683
1684 pub fn ComputeHash(config:&AirConfiguration) -> Result<String> {
1694 let config_str = toml::to_string_pretty(config)
1695 .map_err(|e| AirError::Configuration(format!("Failed to serialize config: {}", e)))?;
1696
1697 let mut hasher = sha2::Sha256::new();
1698
1699 hasher.update(config_str.as_bytes());
1700
1701 let hash = hasher.finalize();
1702
1703 Ok(hex::encode(hash))
1704 }
1705
1706 pub fn ExportToJson(config:&AirConfiguration) -> Result<String> {
1716 serde_json::to_string_pretty(config)
1717 .map_err(|e| AirError::Configuration(format!("Failed to export to JSON: {}", e)))
1718 }
1719
1720 pub fn ImportFromJson(json_str:&str) -> Result<AirConfiguration> {
1730 let config:AirConfiguration = serde_json::from_str(json_str)
1731 .map_err(|e| AirError::Configuration(format!("Failed to import from JSON: {}", e)))?;
1732
1733 Ok(config)
1734 }
1735
1736 pub fn GetEnvironmentMappings(&self) -> HashMap<String, String> {
1740 let prefix = &self.EnvPrefix;
1741
1742 let mut mappings = HashMap::new();
1743
1744 mappings.insert("grpc.bind_address".to_string(), format!("{}GRPC_BIND_ADDRESS", prefix));
1745
1746 mappings.insert("grpc.max_connections".to_string(), format!("{}GRPC_MAX_CONNECTIONS", prefix));
1747
1748 mappings.insert(
1749 "grpc.request_timeout_secs".to_string(),
1750 format!("{}GRPC_REQUEST_TIMEOUT_SECS", prefix),
1751 );
1752
1753 mappings.insert("authentication.enabled".to_string(), format!("{}AUTH_ENABLED", prefix));
1754
1755 mappings.insert(
1756 "authentication.credentials_path".to_string(),
1757 format!("{}AUTH_CREDENTIALS_PATH", prefix),
1758 );
1759
1760 mappings.insert(
1761 "authentication.token_expiration_hours".to_string(),
1762 format!("{}AUTH_TOKEN_EXPIRATION_HOURS", prefix),
1763 );
1764
1765 mappings.insert("updates.enabled".to_string(), format!("{}UPDATE_ENABLED", prefix));
1766
1767 mappings.insert("updates.auto_download".to_string(), format!("{}UPDATE_AUTO_DOWNLOAD", prefix));
1768
1769 mappings.insert("updates.auto_install".to_string(), format!("{}UPDATE_AUTO_INSTALL", prefix));
1770
1771 mappings.insert("logging.level".to_string(), format!("{}LOGGING_LEVEL", prefix));
1772
1773 mappings.insert(
1774 "logging.console_enabled".to_string(),
1775 format!("{}LOGGING_CONSOLE_ENABLED", prefix),
1776 );
1777
1778 mappings
1779 }
1780}
1781
1782#[cfg(test)]
1783mod tests {
1784
1785 use super::*;
1786
1787 #[test]
1788 fn test_default_configuration() {
1789 let config = AirConfiguration::default();
1790
1791 assert_eq!(config.SchemaVersion, "1.0.0");
1792
1793 assert_eq!(config.Profile, "dev");
1794
1795 assert!(config.Authentication.Enabled);
1796
1797 assert!(config.Logging.ConsoleEnabled);
1798 }
1799
1800 #[test]
1801 fn test_profile_defaults() {
1802 let DevConfig = ConfigurationManager::GetProfileDefaults("dev");
1803
1804 assert_eq!(DevConfig.Profile, "dev");
1805
1806 assert_eq!(DevConfig.Logging.Level, "debug");
1807
1808 let ProdConfig = ConfigurationManager::GetProfileDefaults("prod");
1809
1810 assert_eq!(ProdConfig.Profile, "prod");
1811
1812 assert_eq!(ProdConfig.Logging.Level, "warn");
1813
1814 assert!(!ProdConfig.Logging.ConsoleEnabled);
1815 }
1816
1817 #[test]
1818 fn test_path_expansion() {
1819 let Home = dirs::home_dir().expect("Cannot determine home directory");
1820
1821 let Expanded = ConfigurationManager::ExpandPath("~/test").unwrap();
1822
1823 assert_eq!(Expanded, Home.join("test"));
1824
1825 let Absolute = ConfigurationManager::ExpandPath("/tmp/test").unwrap();
1826
1827 assert_eq!(Absolute, PathBuf::from("/tmp/test"));
1828 }
1829
1830 #[test]
1831 fn test_address_validation() {
1832 assert!(ConfigurationManager::IsValidAddress("[::1]:50053"));
1833
1834 assert!(ConfigurationManager::IsValidAddress("127.0.0.1:50053"));
1835
1836 assert!(ConfigurationManager::IsValidAddress("localhost:50053"));
1837
1838 assert!(!ConfigurationManager::IsValidAddress("invalid"));
1839 }
1840
1841 #[test]
1842 fn test_url_validation() {
1843 assert!(ConfigurationManager::IsValidUrl("https://example.com"));
1844
1845 assert!(ConfigurationManager::IsValidUrl("https://updates.editor.land"));
1846
1847 assert!(!ConfigurationManager::IsValidUrl("not-a-url"));
1848
1849 assert!(!ConfigurationManager::IsValidUrl("http://insecure.com"));
1850 }
1851
1852 #[test]
1853 fn test_path_validation() {
1854 let manager = ConfigurationManager::New(None).unwrap();
1855
1856 assert!(manager.ValidatePath("~/config").is_ok());
1857
1858 assert!(manager.ValidatePath("/tmp/config").is_ok());
1859
1860 assert!(manager.ValidatePath("../escaped").is_err());
1861
1862 assert!(manager.ValidatePath("").is_err());
1863 }
1864
1865 #[tokio::test]
1866 async fn test_export_import_json() {
1867 let config = AirConfiguration::default();
1868
1869 let json_str = ConfigurationManager::ExportToJson(&config).unwrap();
1870
1871 let imported = ConfigurationManager::ImportFromJson(&json_str).unwrap();
1872
1873 assert_eq!(imported.SchemaVersion, config.SchemaVersion);
1874
1875 assert_eq!(imported.Profile, config.Profile);
1876
1877 assert_eq!(imported.gRPC.BindAddress, config.gRPC.BindAddress);
1878 }
1879
1880 #[test]
1881 fn test_compute_hash() {
1882 let config = AirConfiguration::default();
1883
1884 let hash1 = ConfigurationManager::ComputeHash(&config).unwrap();
1885
1886 let hash2 = ConfigurationManager::ComputeHash(&config).unwrap();
1887
1888 assert_eq!(hash1, hash2);
1889
1890 let mut modified = config;
1891
1892 modified.gRPC.BindAddress = "[::1]:50054".to_string();
1893
1894 let hash3 = ConfigurationManager::ComputeHash(&modified).unwrap();
1895
1896 assert_ne!(hash1, hash3);
1897 }
1898
1899 #[test]
1900 fn test_generate_schema() {
1901 let schema = generate_schema();
1902
1903 assert!(schema.is_object());
1904
1905 assert!(schema.get("$schema").is_some());
1906
1907 assert!(schema.get("properties").is_some());
1908 }
1909}