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};
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 Profile:default_profile(),
441 Grpc:GrpcConfig {
442 BindAddress:default_grpc_bind_address(),
443 MaxConnections:default_grpc_max_connections(),
444 RequestTimeoutSecs:default_grpc_request_timeout(),
445 },
446 Authentication:AuthConfig {
447 Enabled:default_auth_enabled(),
448 CredentialsPath:default_auth_credentials_path(),
449 TokenExpirationHours:default_auth_token_expiration(),
450 MaxSessions:default_auth_max_sessions(),
451 },
452 Updates:UpdateConfig {
453 Enabled:default_update_enabled(),
454 CheckIntervalHours:default_update_check_interval(),
455 UpdateServerUrl:default_update_server_url(),
456 AutoDownload:default_update_auto_download(),
457 AutoInstall:default_update_auto_install(),
458 Channel:default_update_channel(),
459 },
460 Downloader:DownloadConfig {
461 Enabled:default_download_enabled(),
462 MaxConcurrentDownloads:default_download_max_concurrent(),
463 DownloadTimeoutSecs:default_download_timeout(),
464 MaxRetries:default_download_max_retries(),
465 CacheDirectory:default_download_cache_dir(),
466 },
467 Indexing:IndexingConfig {
468 Enabled:default_indexing_enabled(),
469 MaxFileSizeMb:default_indexing_max_file_size(),
470 FileTypes:default_indexing_file_types(),
471 UpdateIntervalMinutes:default_indexing_update_interval(),
472 IndexDirectory:default_indexing_directory(),
473 MaxParallelIndexing:default_max_parallel_indexing(),
474 },
475 Logging:LoggingConfig {
476 Level:default_logging_level(),
477 FilePath:default_logging_file_path(),
478 ConsoleEnabled:default_logging_console_enabled(),
479 MaxFileSizeMb:default_logging_max_file_size(),
480 MaxFiles:default_logging_max_files(),
481 },
482 Performance:PerformanceConfig {
483 MemoryLimitMb:default_perf_memory_limit(),
484 CPULimitPercent:default_perf_cpu_limit(),
485 DiskLimitMb:default_perf_disk_limit(),
486 BackgroundTaskIntervalSecs:default_perf_task_interval(),
487 },
488 }
489 }
490}
491
492pub fn generate_schema() -> JsonValue {
498 json!({
499 "$schema": "http://json-schema.org/draft-07/schema#",
500 "title": "Air Configuration Schema",
501 "description": "Configuration schema for Air daemon",
502 "type": "object",
503 "required": ["SchemaVersion", "profile"],
504 "properties": {
505 "SchemaVersion": {
506 "type": "string",
507 "description": "Configuration schema version for migration tracking",
508 "pattern": "^\\d+\\.\\d+\\.\\d+$"
509 },
510 "profile": {
511 "type": "string",
512 "description": "Profile name (dev, staging, prod, custom)",
513 "enum": ["dev", "staging", "prod", "custom"]
514 },
515 "grpc": {
516 "type": "object",
517 "description": "gRPC server configuration",
518 "properties": {
519 "BindAddress": {
520 "type": "string",
521 "description": "gRPC server bind address",
522 "format": "hostname-port"
523 },
524 "MaxConnections": {
525 "type": "integer",
526 "minimum": 10,
527 "maximum": 10000
528 },
529 "RequestTimeoutSecs": {
530 "type": "integer",
531 "minimum": 1,
532 "maximum": 3600
533 }
534 }
535 },
536 "authentication": {
537 "type": "object",
538 "description": "Authentication configuration",
539 "properties": {
540 "enabled": {"type": "boolean"},
541 "CredentialsPath": {"type": "string"},
542 "TokenExpirationHours": {
543 "type": "integer",
544 "minimum": 1,
545 "maximum": 8760
546 },
547 "MaxSessions": {
548 "type": "integer",
549 "minimum": 1,
550 "maximum": 1000
551 }
552 }
553 },
554 "updates": {
555 "type": "object",
556 "properties": {
557 "enabled": {"type": "boolean"},
558 "CheckIntervalHours": {
559 "type": "integer",
560 "minimum": 1,
561 "maximum": 168
562 },
563 "UpdateServerUrl": {
564 "type": "string",
565 "pattern": "^https://"
566 },
567 "AutoDownload": {"type": "boolean"},
568 "AutoInstall": {"type": "boolean"},
569 "channel": {
570 "type": "string",
571 "enum": ["stable", "insiders", "preview"]
572 }
573 }
574 },
575 "downloader": {
576 "type": "object",
577 "properties": {
578 "enabled": {"type": "boolean"},
579 "MaxConcurrentDownloads": {
580 "type": "integer",
581 "minimum": 1,
582 "maximum": 50
583 },
584 "DownloadTimeoutSecs": {
585 "type": "integer",
586 "minimum": 10,
587 "maximum": 3600
588 },
589 "MaxRetries": {
590 "type": "integer",
591 "minimum": 0,
592 "maximum": 10
593 },
594 "CacheDirectory": {"type": "string"}
595 }
596 },
597 "indexing": {
598 "type": "object",
599 "properties": {
600 "enabled": {"type": "boolean"},
601 "MaxFileSizeMb": {
602 "type": "integer",
603 "minimum": 1,
604 "maximum": 1024
605 },
606 "FileTypes": {
607 "type": "array",
608 "items": {"type": "string"}
609 },
610 "UpdateIntervalMinutes": {
611 "type": "integer",
612 "minimum": 1,
613 "maximum": 1440
614 },
615 "IndexDirectory": {"type": "string"}
616 }
617 },
618 "logging": {
619 "type": "object",
620 "properties": {
621 "level": {
622 "type": "string",
623 "enum": ["trace", "debug", "info", "warn", "error"]
624 },
625 "FilePath": {"type": ["string", "null"]},
626 "ConsoleEnabled": {"type": "boolean"},
627 "MaxFileSizeMb": {
628 "type": "integer",
629 "minimum": 1,
630 "maximum": 1000
631 },
632 "MaxFiles": {
633 "type": "integer",
634 "minimum": 1,
635 "maximum": 50
636 }
637 }
638 },
639 "performance": {
640 "type": "object",
641 "properties": {
642 "MemoryLimitMb": {
643 "type": "integer",
644 "minimum": 64,
645 "maximum": 16384
646 },
647 "CPULimitPercent": {
648 "type": "integer",
649 "minimum": 10,
650 "maximum": 100
651 },
652 "DiskLimitMb": {
653 "type": "integer",
654 "minimum": 100,
655 "maximum": 102400
656 },
657 "BackgroundTaskIntervalSecs": {
658 "type": "integer",
659 "minimum": 1,
660 "maximum": 3600
661 }
662 }
663 }
664 }
665 })
666}
667
668pub struct ConfigurationManager {
675 ConfigPath:Option<PathBuf>,
677
678 BackupDir:Option<PathBuf>,
680
681 EnableBackup:bool,
683
684 EnvPrefix:String,
686}
687
688impl ConfigurationManager {
689 pub fn New(ConfigPath:Option<String>) -> Result<Self> {
700 let path = ConfigPath.map(PathBuf::from);
701 let BackupDir = path
702 .as_ref()
703 .and_then(|p| p.parent())
704 .map(|parent| parent.join(".ConfigBackups"));
705
706 Ok(Self { ConfigPath:path, BackupDir, EnableBackup:true, EnvPrefix:"AIR_".to_string() })
707 }
708
709 pub fn NewWithSettings(ConfigPath:Option<String>, EnableBackup:bool, EnvPrefix:String) -> Result<Self> {
717 let path = ConfigPath.map(PathBuf::from);
718 let BackupDir = if EnableBackup {
719 path.as_ref()
720 .and_then(|p| p.parent())
721 .map(|parent| parent.join(".ConfigBackups"))
722 } else {
723 None
724 };
725
726 Ok(Self { ConfigPath:path, BackupDir, EnableBackup, EnvPrefix })
727 }
728
729 pub async fn LoadConfiguration(&self) -> Result<AirConfiguration> {
740 let mut config = AirConfiguration::default();
742
743 let ConfigPath = self.GetConfigPath()?;
745
746 if ConfigPath.exists() {
747 log::info!("Loading configuration from: {}", ConfigPath.display());
748 config = self.LoadFromFile(&ConfigPath).await?;
749 } else {
750 log::info!("No configuration file found, using defaults");
751 }
752
753 self.ApplyEnvironmentOverrides(&mut config)?;
755
756 self.SchemaValidate(&config)?;
758
759 self.ValidateConfiguration(&config)?;
761
762 log::info!("Configuration loaded successfully (profile: {})", config.Profile);
763 Ok(config)
764 }
765
766 async fn LoadFromFile(&self, path:&Path) -> Result<AirConfiguration> {
776 let content = tokio::fs::read_to_string(path)
777 .await
778 .map_err(|e| AirError::Configuration(format!("Failed to read config file '{}': {}", path.display(), e)))?;
779
780 let config:AirConfiguration = toml::from_str(&content).map_err(|e| {
781 AirError::Configuration(format!("Failed to parse TOML config file '{}': {}", path.display(), e))
782 })?;
783
784 log::debug!("Configuration file parsed successfully");
786 Ok(config)
787 }
788
789 pub async fn SaveConfiguration(&self, config:&AirConfiguration) -> Result<()> {
802 self.ValidateConfiguration(config)?;
804
805 let ConfigPath = self.GetConfigPath()?;
806
807 if self.EnableBackup && ConfigPath.exists() {
809 self.BackupConfiguration(&ConfigPath).await?;
810 }
811
812 if let Some(parent) = ConfigPath.parent() {
814 tokio::fs::create_dir_all(parent).await.map_err(|e| {
815 AirError::Configuration(format!("Failed to create config directory '{}': {}", parent.display(), e))
816 })?;
817 }
818
819 let TempPath = ConfigPath.with_extension("tmp");
821 let content = toml::to_string_pretty(config)
822 .map_err(|e| AirError::Configuration(format!("Failed to serialize config: {}", e)))?;
823
824 tokio::fs::write(&TempPath, content).await.map_err(|e| {
825 AirError::Configuration(format!("Failed to write temp config file '{}': {}", TempPath.display(), e))
826 })?;
827
828 tokio::fs::rename(&TempPath, &ConfigPath).await.map_err(|e| {
830 AirError::Configuration(format!("Failed to rename temp config to '{}': {}", ConfigPath.display(), e))
831 })?;
832
833 log::info!("Configuration saved to: {}", ConfigPath.display());
834 Ok(())
835 }
836
837 fn ValidateConfiguration(&self, config:&AirConfiguration) -> Result<()> {
846 self.ValidateSchemaVersion(&config.SchemaVersion)?;
848
849 self.ValidateProfile(&config.Profile)?;
851
852 self.ValidateGrpcConfig(&config.Grpc)?;
854
855 self.ValidateAuthConfig(&config.Authentication)?;
857
858 self.ValidateUpdateConfig(&config.Updates)?;
860
861 self.ValidateDownloadConfig(&config.Downloader)?;
863
864 self.ValidateIndexingConfig(&config.Indexing)?;
866
867 self.ValidateLoggingConfig(&config.Logging)?;
869
870 self.ValidatePerformanceConfig(&config.Performance)?;
872
873 log::debug!("All configuration validation checks passed");
874 Ok(())
875 }
876
877 fn ValidateSchemaVersion(&self, version:&str) -> Result<()> {
879 if !version.chars().all(|c| c.is_digit(10) || c == '.') {
880 return Err(AirError::Configuration(format!(
881 "Invalid schema version '{}': must be in format X.Y.Z",
882 version
883 )));
884 }
885
886 let parts:Vec<&str> = version.split('.').collect();
887 if parts.len() != 3 {
888 return Err(AirError::Configuration(format!(
889 "Invalid schema version '{}': must have 3 parts (X.Y.Z)",
890 version
891 )));
892 }
893
894 for (i, part) in parts.iter().enumerate() {
895 if part.is_empty() {
896 return Err(AirError::Configuration(format!(
897 "Invalid schema version '{}': part {} is empty",
898 version,
899 i + 1
900 )));
901 }
902 }
903
904 Ok(())
905 }
906
907 fn ValidateProfile(&self, profile:&str) -> Result<()> {
909 let ValidProfiles = ["dev", "staging", "prod", "custom"];
910
911 if !ValidProfiles.contains(&profile) {
912 return Err(AirError::Configuration(format!(
913 "Invalid profile '{}': must be one of: {}",
914 profile,
915 ValidProfiles.join(", ")
916 )));
917 }
918
919 Ok(())
920 }
921
922 fn ValidateGrpcConfig(&self, grpc:&GrpcConfig) -> Result<()> {
924 if grpc.BindAddress.is_empty() {
926 return Err(AirError::Configuration("gRPC bind address cannot be empty".to_string()));
927 }
928
929 if !Self::IsValidAddress(&grpc.BindAddress) {
931 return Err(AirError::Configuration(format!(
932 "Invalid gRPC bind address '{}': must be in format host:port or [IPv6]:port",
933 grpc.BindAddress
934 )));
935 }
936
937 if grpc.MaxConnections < 10 {
939 return Err(AirError::Configuration(format!(
940 "gRPC MaxConnections {} is below minimum (10)",
941 grpc.MaxConnections
942 )));
943 }
944
945 if grpc.MaxConnections > 10000 {
946 return Err(AirError::Configuration(format!(
947 "gRPC MaxConnections {} exceeds maximum (10000)",
948 grpc.MaxConnections
949 )));
950 }
951
952 if grpc.RequestTimeoutSecs < 1 {
954 return Err(AirError::Configuration(format!(
955 "gRPC RequestTimeoutSecs {} is below minimum (1 second)",
956 grpc.RequestTimeoutSecs
957 )));
958 }
959
960 if grpc.RequestTimeoutSecs > 3600 {
961 return Err(AirError::Configuration(format!(
962 "gRPC RequestTimeoutSecs {} exceeds maximum (3600 seconds = 1 hour)",
963 grpc.RequestTimeoutSecs
964 )));
965 }
966
967 Ok(())
968 }
969
970 fn ValidateAuthConfig(&self, auth:&AuthConfig) -> Result<()> {
972 if auth.Enabled {
974 if auth.CredentialsPath.is_empty() {
975 return Err(AirError::Configuration(
976 "Authentication credentials path cannot be empty when authentication is enabled".to_string(),
977 ));
978 }
979
980 self.ValidatePath(&auth.CredentialsPath)?;
982 }
983
984 if auth.TokenExpirationHours < 1 {
986 return Err(AirError::Configuration(format!(
987 "Token expiration hours {} is below minimum (1 hour)",
988 auth.TokenExpirationHours
989 )));
990 }
991
992 if auth.TokenExpirationHours > 8760 {
993 return Err(AirError::Configuration(format!(
994 "Token expiration hours {} exceeds maximum (8760 hours = 1 year)",
995 auth.TokenExpirationHours
996 )));
997 }
998
999 if auth.MaxSessions < 1 {
1001 return Err(AirError::Configuration(format!(
1002 "Max sessions {} is below minimum (1)",
1003 auth.MaxSessions
1004 )));
1005 }
1006
1007 if auth.MaxSessions > 1000 {
1008 return Err(AirError::Configuration(format!(
1009 "Max sessions {} exceeds maximum (1000)",
1010 auth.MaxSessions
1011 )));
1012 }
1013
1014 Ok(())
1015 }
1016
1017 fn ValidateUpdateConfig(&self, updates:&UpdateConfig) -> Result<()> {
1019 if updates.Enabled {
1020 if updates.UpdateServerUrl.is_empty() {
1022 return Err(AirError::Configuration(
1023 "Update server URL cannot be empty when updates are enabled".to_string(),
1024 ));
1025 }
1026
1027 if !updates.UpdateServerUrl.starts_with("https://") {
1029 return Err(AirError::Configuration(format!(
1030 "Update server URL must use HTTPS, got: {}",
1031 updates.UpdateServerUrl
1032 )));
1033 }
1034
1035 if !Self::IsValidUrl(&updates.UpdateServerUrl) {
1037 return Err(AirError::Configuration(format!(
1038 "Invalid update server URL '{}'",
1039 updates.UpdateServerUrl
1040 )));
1041 }
1042 }
1043
1044 if updates.CheckIntervalHours < 1 {
1046 return Err(AirError::Configuration(format!(
1047 "Update check interval {} hours is below minimum (1 hour)",
1048 updates.CheckIntervalHours
1049 )));
1050 }
1051
1052 if updates.CheckIntervalHours > 168 {
1053 return Err(AirError::Configuration(format!(
1054 "Update check interval {} hours exceeds maximum (168 hours = 1 week)",
1055 updates.CheckIntervalHours
1056 )));
1057 }
1058
1059 Ok(())
1060 }
1061
1062 fn ValidateDownloadConfig(&self, downloader:&DownloadConfig) -> Result<()> {
1064 if downloader.Enabled {
1065 if downloader.CacheDirectory.is_empty() {
1066 return Err(AirError::Configuration(
1067 "Download cache directory cannot be empty when downloader is enabled".to_string(),
1068 ));
1069 }
1070
1071 self.ValidatePath(&downloader.CacheDirectory)?;
1073 }
1074
1075 if downloader.MaxConcurrentDownloads < 1 {
1077 return Err(AirError::Configuration(format!(
1078 "Max concurrent downloads {} is below minimum (1)",
1079 downloader.MaxConcurrentDownloads
1080 )));
1081 }
1082
1083 if downloader.MaxConcurrentDownloads > 50 {
1084 return Err(AirError::Configuration(format!(
1085 "Max concurrent downloads {} exceeds maximum (50)",
1086 downloader.MaxConcurrentDownloads
1087 )));
1088 }
1089
1090 if downloader.DownloadTimeoutSecs < 10 {
1092 return Err(AirError::Configuration(format!(
1093 "Download timeout {} seconds is below minimum (10 seconds)",
1094 downloader.DownloadTimeoutSecs
1095 )));
1096 }
1097
1098 if downloader.DownloadTimeoutSecs > 3600 {
1099 return Err(AirError::Configuration(format!(
1100 "Download timeout {} seconds exceeds maximum (3600 seconds = 1 hour)",
1101 downloader.DownloadTimeoutSecs
1102 )));
1103 }
1104
1105 if downloader.MaxRetries > 10 {
1107 return Err(AirError::Configuration(format!(
1108 "Max retries {} exceeds maximum (10)",
1109 downloader.MaxRetries
1110 )));
1111 }
1112
1113 Ok(())
1114 }
1115
1116 fn ValidateIndexingConfig(&self, indexing:&IndexingConfig) -> Result<()> {
1118 if indexing.Enabled {
1119 if indexing.IndexDirectory.is_empty() {
1120 return Err(AirError::Configuration(
1121 "Index directory cannot be empty when indexing is enabled".to_string(),
1122 ));
1123 }
1124
1125 self.ValidatePath(&indexing.IndexDirectory)?;
1127
1128 if indexing.FileTypes.is_empty() {
1130 return Err(AirError::Configuration(
1131 "File types to index cannot be empty when indexing is enabled".to_string(),
1132 ));
1133 }
1134
1135 for FileType in &indexing.FileTypes {
1137 if FileType.is_empty() {
1138 return Err(AirError::Configuration("File type pattern cannot be empty".to_string()));
1139 }
1140
1141 if !FileType.contains('*') {
1142 log::warn!(
1143 "File type pattern '{}' does not contain wildcards, may not match as expected",
1144 FileType
1145 );
1146 }
1147 }
1148 }
1149
1150 if indexing.MaxFileSizeMb < 1 {
1152 return Err(AirError::Configuration(format!(
1153 "Max file size {} MB is below minimum (1 MB)",
1154 indexing.MaxFileSizeMb
1155 )));
1156 }
1157
1158 if indexing.MaxFileSizeMb > 1024 {
1159 return Err(AirError::Configuration(format!(
1160 "Max file size {} MB exceeds maximum (1024 MB = 1 GB)",
1161 indexing.MaxFileSizeMb
1162 )));
1163 }
1164
1165 if indexing.UpdateIntervalMinutes < 1 {
1167 return Err(AirError::Configuration(format!(
1168 "Index update interval {} minutes is below minimum (1 minute)",
1169 indexing.UpdateIntervalMinutes
1170 )));
1171 }
1172
1173 if indexing.UpdateIntervalMinutes > 1440 {
1174 return Err(AirError::Configuration(format!(
1175 "Index update interval {} minutes exceeds maximum (1440 minutes = 1 day)",
1176 indexing.UpdateIntervalMinutes
1177 )));
1178 }
1179
1180 Ok(())
1181 }
1182
1183 fn ValidateLoggingConfig(&self, logging:&LoggingConfig) -> Result<()> {
1185 let ValidLevels = ["trace", "debug", "info", "warn", "error"];
1187 if !ValidLevels.contains(&logging.Level.as_str()) {
1188 return Err(AirError::Configuration(format!(
1189 "Invalid log level '{}': must be one of: {}",
1190 logging.Level,
1191 ValidLevels.join(", ")
1192 )));
1193 }
1194
1195 if let Some(ref FilePath) = logging.FilePath {
1197 if !FilePath.is_empty() {
1198 self.ValidatePath(FilePath)?;
1199 }
1200 }
1201
1202 if logging.MaxFileSizeMb < 1 {
1204 return Err(AirError::Configuration(format!(
1205 "Max log file size {} MB is below minimum (1 MB)",
1206 logging.MaxFileSizeMb
1207 )));
1208 }
1209
1210 if logging.MaxFileSizeMb > 1000 {
1211 return Err(AirError::Configuration(format!(
1212 "Max log file size {} MB exceeds maximum (1000 MB = 1 GB)",
1213 logging.MaxFileSizeMb
1214 )));
1215 }
1216
1217 if logging.MaxFiles < 1 {
1219 return Err(AirError::Configuration(format!(
1220 "Max log files {} is below minimum (1)",
1221 logging.MaxFiles
1222 )));
1223 }
1224
1225 if logging.MaxFiles > 50 {
1226 return Err(AirError::Configuration(format!(
1227 "Max log files {} exceeds maximum (50)",
1228 logging.MaxFiles
1229 )));
1230 }
1231
1232 Ok(())
1233 }
1234
1235 fn ValidatePerformanceConfig(&self, performance:&PerformanceConfig) -> Result<()> {
1237 if performance.MemoryLimitMb < 64 {
1239 return Err(AirError::Configuration(format!(
1240 "Memory limit {} MB is below minimum (64 MB)",
1241 performance.MemoryLimitMb
1242 )));
1243 }
1244
1245 if performance.MemoryLimitMb > 16384 {
1246 return Err(AirError::Configuration(format!(
1247 "Memory limit {} MB exceeds maximum (16384 MB = 16 GB)",
1248 performance.MemoryLimitMb
1249 )));
1250 }
1251
1252 if performance.CPULimitPercent < 10 {
1254 return Err(AirError::Configuration(format!(
1255 "CPU limit {}% is below minimum (10%)",
1256 performance.CPULimitPercent
1257 )));
1258 }
1259
1260 if performance.CPULimitPercent > 100 {
1261 return Err(AirError::Configuration(format!(
1262 "CPU limit {}% exceeds maximum (100%)",
1263 performance.CPULimitPercent
1264 )));
1265 }
1266
1267 if performance.DiskLimitMb < 100 {
1269 return Err(AirError::Configuration(format!(
1270 "Disk limit {} MB is below minimum (100 MB)",
1271 performance.DiskLimitMb
1272 )));
1273 }
1274
1275 if performance.DiskLimitMb > 102400 {
1276 return Err(AirError::Configuration(format!(
1277 "Disk limit {} MB exceeds maximum (102400 MB = 100 GB)",
1278 performance.DiskLimitMb
1279 )));
1280 }
1281
1282 if performance.BackgroundTaskIntervalSecs < 1 {
1284 return Err(AirError::Configuration(format!(
1285 "Background task interval {} seconds is below minimum (1 second)",
1286 performance.BackgroundTaskIntervalSecs
1287 )));
1288 }
1289
1290 if performance.BackgroundTaskIntervalSecs > 3600 {
1291 return Err(AirError::Configuration(format!(
1292 "Background task interval {} seconds exceeds maximum (3600 seconds = 1 hour)",
1293 performance.BackgroundTaskIntervalSecs
1294 )));
1295 }
1296
1297 Ok(())
1298 }
1299
1300 fn ValidatePath(&self, path:&str) -> Result<()> {
1302 if path.is_empty() {
1303 return Err(AirError::Configuration("Path cannot be empty".to_string()));
1304 }
1305
1306 if path.contains("..") {
1308 return Err(AirError::Configuration(format!(
1309 "Path '{}' contains '..' which is not allowed for security reasons",
1310 path
1311 )));
1312 }
1313
1314 if path.starts_with("\\\\") || path.starts_with("//") {
1316 return Err(AirError::Configuration(format!(
1317 "Path '{}' uses UNC/network path format which may not be supported",
1318 path
1319 )));
1320 }
1321
1322 if path.contains('\0') {
1324 return Err(AirError::Configuration(
1325 "Path contains null bytes which is not allowed".to_string(),
1326 ));
1327 }
1328
1329 Ok(())
1330 }
1331
1332 fn IsValidAddress(addr:&str) -> bool {
1334 if addr.starts_with('[') && addr.contains("]:") {
1336 return true;
1337 }
1338
1339 if addr.contains(':') {
1341 let parts:Vec<&str> = addr.split(':').collect();
1342 if parts.len() != 2 {
1343 return false;
1344 }
1345
1346 if let Ok(port) = parts[1].parse::<u16>() {
1348 return port > 0;
1349 }
1350
1351 return false;
1352 }
1353
1354 false
1355 }
1356
1357 fn IsValidUrl(url:&str) -> bool { url::Url::parse(url).is_ok() }
1359
1360 fn SchemaValidate(&self, config:&AirConfiguration) -> Result<()> {
1362 let _schema = generate_schema();
1363
1364 let ConfigJson = serde_json::to_value(config)
1366 .map_err(|e| AirError::Configuration(format!("Failed to serialize config for schema validation: {}", e)))?;
1367
1368 if !ConfigJson.is_object() {
1371 return Err(AirError::Configuration("Configuration must be an object".to_string()));
1372 }
1373
1374 log::debug!("Schema validation passed");
1375 Ok(())
1376 }
1377
1378 fn ApplyEnvironmentOverrides(&self, config:&mut AirConfiguration) -> Result<()> {
1387 let mut override_count = 0;
1388
1389 if let Ok(val) = env::var(&format!("{}GRPC_BIND_ADDRESS", self.EnvPrefix)) {
1391 config.Grpc.BindAddress = val;
1392 override_count += 1;
1393 }
1394
1395 if let Ok(val) = env::var(&format!("{}GRPC_MAX_CONNECTIONS", self.EnvPrefix)) {
1396 config.Grpc.MaxConnections = val
1397 .parse()
1398 .map_err(|e| AirError::Configuration(format!("Invalid GRPC_MAX_CONNECTIONS value: {}", e)))?;
1399 override_count += 1;
1400 }
1401
1402 if let Ok(val) = env::var(&format!("{}AUTH_ENABLED", self.EnvPrefix)) {
1404 config.Authentication.Enabled = val
1405 .parse()
1406 .map_err(|e| AirError::Configuration(format!("Invalid AUTH_ENABLED value: {}", e)))?;
1407 override_count += 1;
1408 }
1409
1410 if let Ok(val) = env::var(&format!("{}AUTH_CREDENTIALS_PATH", self.EnvPrefix)) {
1411 config.Authentication.CredentialsPath = val;
1412 override_count += 1;
1413 }
1414
1415 if let Ok(val) = env::var(&format!("{}UPDATE_ENABLED", self.EnvPrefix)) {
1417 config.Updates.Enabled = val
1418 .parse()
1419 .map_err(|e| AirError::Configuration(format!("Invalid UPDATE_ENABLED value: {}", e)))?;
1420 override_count += 1;
1421 }
1422
1423 if let Ok(val) = env::var(&format!("{}UPDATE_AUTO_DOWNLOAD", self.EnvPrefix)) {
1424 config.Updates.AutoDownload = val
1425 .parse()
1426 .map_err(|e| AirError::Configuration(format!("Invalid UPDATE_AUTO_DOWNLOAD value: {}", e)))?;
1427 override_count += 1;
1428 }
1429
1430 if let Ok(val) = env::var(&format!("{}LOGGING_LEVEL", self.EnvPrefix)) {
1432 config.Logging.Level = val.to_lowercase();
1433 override_count += 1;
1434 }
1435
1436 if override_count > 0 {
1437 log::info!("Applied {} environment variable override(s)", override_count);
1438 }
1439
1440 Ok(())
1441 }
1442
1443 async fn BackupConfiguration(&self, config_path:&Path) -> Result<()> {
1448 let backup_dir = self
1449 .BackupDir
1450 .as_ref()
1451 .ok_or_else(|| AirError::Configuration("Backup directory not configured".to_string()))?;
1452
1453 tokio::fs::create_dir_all(backup_dir).await.map_err(|e| {
1455 AirError::Configuration(format!("Failed to create backup directory '{}': {}", backup_dir.display(), e))
1456 })?;
1457
1458 let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
1460 let backup_filename = format!(
1461 "{}_config_{}.toml.bak",
1462 config_path.file_stem().and_then(|s| s.to_str()).unwrap_or("config"),
1463 timestamp
1464 );
1465 let backup_path = backup_dir.join(&backup_filename);
1466
1467 tokio::fs::copy(config_path, &backup_path).await.map_err(|e| {
1469 AirError::Configuration(format!("Failed to create backup '{}': {}", backup_path.display(), e))
1470 })?;
1471
1472 log::info!("Configuration backed up to: {}", backup_path.display());
1473 Ok(())
1474 }
1475
1476 pub async fn RollbackConfiguration(&self) -> Result<PathBuf> {
1482 let config_path = self.GetConfigPath()?;
1483
1484 let backup_dir = self
1485 .BackupDir
1486 .as_ref()
1487 .ok_or_else(|| AirError::Configuration("Backup directory not configured".to_string()))?;
1488
1489 let mut backups = tokio::fs::read_dir(backup_dir).await.map_err(|e| {
1491 AirError::Configuration(format!("Failed to read backup directory '{}': {}", backup_dir.display(), e))
1492 })?;
1493
1494 let mut most_recent:Option<(tokio::fs::DirEntry, std::time::SystemTime)> = None;
1495
1496 while let Some(entry) = backups
1497 .next_entry()
1498 .await
1499 .map_err(|e| AirError::Configuration(format!("Failed to read backup entry: {}", e)))?
1500 {
1501 let metadata = entry
1502 .metadata()
1503 .await
1504 .map_err(|e| AirError::Configuration(format!("Failed to get metadata: {}", e)))?;
1505
1506 if let Ok(modified) = metadata.modified() {
1507 if most_recent.is_none() || modified > most_recent.as_ref().unwrap().1 {
1508 most_recent = Some((entry, modified));
1509 }
1510 }
1511 }
1512
1513 let (backup_entry, _) =
1514 most_recent.ok_or_else(|| AirError::Configuration("No backup files found".to_string()))?;
1515
1516 let backup_path = backup_entry.path();
1517
1518 tokio::fs::copy(&backup_path, &config_path).await.map_err(|e| {
1520 AirError::Configuration(format!("Failed to restore from backup '{}': {}", backup_path.display(), e))
1521 })?;
1522
1523 log::info!("Configuration rolled back from: {}", backup_path.display());
1524 Ok(backup_path)
1525 }
1526
1527 fn GetConfigPath(&self) -> Result<PathBuf> {
1531 if let Some(ref path) = self.ConfigPath {
1532 Ok(path.clone())
1533 } else {
1534 Self::GetDefaultConfigPath()
1535 }
1536 }
1537
1538 fn GetDefaultConfigPath() -> Result<PathBuf> {
1543 let config_dir = dirs::config_dir()
1544 .ok_or_else(|| AirError::Configuration("Cannot determine config directory".to_string()))?;
1545
1546 Ok(config_dir.join("Air").join(DefaultConfigFile))
1547 }
1548
1549 pub fn GetProfileDefaults(profile:&str) -> AirConfiguration {
1559 let mut config = AirConfiguration::default();
1560 config.Profile = profile.to_string();
1561
1562 match profile {
1563 "prod" => {
1564 config.Logging.Level = "warn".to_string();
1565 config.Logging.ConsoleEnabled = false;
1566 config.Performance.MemoryLimitMb = 1024;
1567 config.Performance.CPULimitPercent = 80;
1568 },
1569 "staging" => {
1570 config.Logging.Level = "info".to_string();
1571 config.Performance.MemoryLimitMb = 768;
1572 config.Performance.CPULimitPercent = 70;
1573 },
1574 "dev" | _ => {
1575 config.Logging.Level = "debug".to_string();
1577 config.Logging.ConsoleEnabled = true;
1578 config.Performance.MemoryLimitMb = 512;
1579 config.Performance.CPULimitPercent = 50;
1580 },
1581 }
1582
1583 config
1584 }
1585
1586 pub fn ExpandPath(path:&str) -> Result<PathBuf> {
1596 if path.is_empty() {
1597 return Err(AirError::Configuration("Cannot expand empty path".to_string()));
1598 }
1599
1600 if path.starts_with('~') {
1601 let home = dirs::home_dir()
1602 .ok_or_else(|| AirError::Configuration("Cannot determine home directory".to_string()))?;
1603
1604 let rest = &path[1..]; if rest.starts_with('/') || rest.starts_with('\\') {
1606 Ok(home.join(&rest[1..]))
1607 } else {
1608 Ok(home.join(rest))
1609 }
1610 } else {
1611 Ok(PathBuf::from(path))
1612 }
1613 }
1614
1615 pub fn ComputeHash(config:&AirConfiguration) -> Result<String> {
1625 let config_str = toml::to_string_pretty(config)
1626 .map_err(|e| AirError::Configuration(format!("Failed to serialize config: {}", e)))?;
1627
1628 let mut hasher = sha2::Sha256::new();
1629 hasher.update(config_str.as_bytes());
1630 let hash = hasher.finalize();
1631
1632 Ok(hex::encode(hash))
1633 }
1634
1635 pub fn ExportToJson(config:&AirConfiguration) -> Result<String> {
1645 serde_json::to_string_pretty(config)
1646 .map_err(|e| AirError::Configuration(format!("Failed to export to JSON: {}", e)))
1647 }
1648
1649 pub fn ImportFromJson(json_str:&str) -> Result<AirConfiguration> {
1659 let config:AirConfiguration = serde_json::from_str(json_str)
1660 .map_err(|e| AirError::Configuration(format!("Failed to import from JSON: {}", e)))?;
1661
1662 Ok(config)
1663 }
1664
1665 pub fn GetEnvironmentMappings(&self) -> HashMap<String, String> {
1669 let prefix = &self.EnvPrefix;
1670 let mut mappings = HashMap::new();
1671
1672 mappings.insert("grpc.bind_address".to_string(), format!("{}GRPC_BIND_ADDRESS", prefix));
1673 mappings.insert("grpc.max_connections".to_string(), format!("{}GRPC_MAX_CONNECTIONS", prefix));
1674 mappings.insert(
1675 "grpc.request_timeout_secs".to_string(),
1676 format!("{}GRPC_REQUEST_TIMEOUT_SECS", prefix),
1677 );
1678
1679 mappings.insert("authentication.enabled".to_string(), format!("{}AUTH_ENABLED", prefix));
1680 mappings.insert(
1681 "authentication.credentials_path".to_string(),
1682 format!("{}AUTH_CREDENTIALS_PATH", prefix),
1683 );
1684 mappings.insert(
1685 "authentication.token_expiration_hours".to_string(),
1686 format!("{}AUTH_TOKEN_EXPIRATION_HOURS", prefix),
1687 );
1688
1689 mappings.insert("updates.enabled".to_string(), format!("{}UPDATE_ENABLED", prefix));
1690 mappings.insert("updates.auto_download".to_string(), format!("{}UPDATE_AUTO_DOWNLOAD", prefix));
1691 mappings.insert("updates.auto_install".to_string(), format!("{}UPDATE_AUTO_INSTALL", prefix));
1692
1693 mappings.insert("logging.level".to_string(), format!("{}LOGGING_LEVEL", prefix));
1694 mappings.insert(
1695 "logging.console_enabled".to_string(),
1696 format!("{}LOGGING_CONSOLE_ENABLED", prefix),
1697 );
1698
1699 mappings
1700 }
1701}
1702
1703#[cfg(test)]
1704mod tests {
1705 use super::*;
1706
1707 #[test]
1708 fn test_default_configuration() {
1709 let config = AirConfiguration::default();
1710 assert_eq!(config.SchemaVersion, "1.0.0");
1711 assert_eq!(config.Profile, "dev");
1712 assert!(config.Authentication.Enabled);
1713 assert!(config.Logging.ConsoleEnabled);
1714 }
1715
1716 #[test]
1717 fn test_profile_defaults() {
1718 let DevConfig = ConfigurationManager::GetProfileDefaults("dev");
1719 assert_eq!(DevConfig.Profile, "dev");
1720 assert_eq!(DevConfig.Logging.Level, "debug");
1721
1722 let ProdConfig = ConfigurationManager::GetProfileDefaults("prod");
1723 assert_eq!(ProdConfig.Profile, "prod");
1724 assert_eq!(ProdConfig.Logging.Level, "warn");
1725 assert!(!ProdConfig.Logging.ConsoleEnabled);
1726 }
1727
1728 #[test]
1729 fn test_path_expansion() {
1730 let Home = dirs::home_dir().expect("Cannot determine home directory");
1731 let Expanded = ConfigurationManager::ExpandPath("~/test").unwrap();
1732 assert_eq!(Expanded, Home.join("test"));
1733
1734 let Absolute = ConfigurationManager::ExpandPath("/tmp/test").unwrap();
1735 assert_eq!(Absolute, PathBuf::from("/tmp/test"));
1736 }
1737
1738 #[test]
1739 fn test_address_validation() {
1740 assert!(ConfigurationManager::IsValidAddress("[::1]:50053"));
1741 assert!(ConfigurationManager::IsValidAddress("127.0.0.1:50053"));
1742 assert!(ConfigurationManager::IsValidAddress("localhost:50053"));
1743 assert!(!ConfigurationManager::IsValidAddress("invalid"));
1744 }
1745
1746 #[test]
1747 fn test_url_validation() {
1748 assert!(ConfigurationManager::IsValidUrl("https://example.com"));
1749 assert!(ConfigurationManager::IsValidUrl("https://updates.editor.land"));
1750 assert!(!ConfigurationManager::IsValidUrl("not-a-url"));
1751 assert!(!ConfigurationManager::IsValidUrl("http://insecure.com"));
1752 }
1753
1754 #[test]
1755 fn test_path_validation() {
1756 let manager = ConfigurationManager::New(None).unwrap();
1757 assert!(manager.ValidatePath("~/config").is_ok());
1758 assert!(manager.ValidatePath("/tmp/config").is_ok());
1759 assert!(manager.ValidatePath("../escaped").is_err());
1760 assert!(manager.ValidatePath("").is_err());
1761 }
1762
1763 #[tokio::test]
1764 async fn test_export_import_json() {
1765 let config = AirConfiguration::default();
1766 let json_str = ConfigurationManager::ExportToJson(&config).unwrap();
1767
1768 let imported = ConfigurationManager::ImportFromJson(&json_str).unwrap();
1769 assert_eq!(imported.SchemaVersion, config.SchemaVersion);
1770 assert_eq!(imported.Profile, config.Profile);
1771 assert_eq!(imported.Grpc.BindAddress, config.Grpc.BindAddress);
1772 }
1773
1774 #[test]
1775 fn test_compute_hash() {
1776 let config = AirConfiguration::default();
1777 let hash1 = ConfigurationManager::ComputeHash(&config).unwrap();
1778 let hash2 = ConfigurationManager::ComputeHash(&config).unwrap();
1779 assert_eq!(hash1, hash2);
1780
1781 let mut modified = config;
1782 modified.Grpc.BindAddress = "[::1]:50054".to_string();
1783 let hash3 = ConfigurationManager::ComputeHash(&modified).unwrap();
1784 assert_ne!(hash1, hash3);
1785 }
1786
1787 #[test]
1788 fn test_generate_schema() {
1789 let schema = generate_schema();
1790 assert!(schema.is_object());
1791 assert!(schema.get("$schema").is_some());
1792 assert!(schema.get("properties").is_some());
1793 }
1794}