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 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 dev_log!("config", "Loading configuration from: {}", ConfigPath.display());
748 config = self.LoadFromFile(&ConfigPath).await?;
749 } else {
750 dev_log!("config", "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 dev_log!("config", "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 dev_log!("config", "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 dev_log!("config", "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 dev_log!("config", "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 dev_log!(
1143 "config",
1144 "warn: File type pattern '{}' does not contain wildcards, may not match as expected",
1145 FileType
1146 );
1147 }
1148 }
1149 }
1150
1151 if indexing.MaxFileSizeMb < 1 {
1153 return Err(AirError::Configuration(format!(
1154 "Max file size {} MB is below minimum (1 MB)",
1155 indexing.MaxFileSizeMb
1156 )));
1157 }
1158
1159 if indexing.MaxFileSizeMb > 1024 {
1160 return Err(AirError::Configuration(format!(
1161 "Max file size {} MB exceeds maximum (1024 MB = 1 GB)",
1162 indexing.MaxFileSizeMb
1163 )));
1164 }
1165
1166 if indexing.UpdateIntervalMinutes < 1 {
1168 return Err(AirError::Configuration(format!(
1169 "Index update interval {} minutes is below minimum (1 minute)",
1170 indexing.UpdateIntervalMinutes
1171 )));
1172 }
1173
1174 if indexing.UpdateIntervalMinutes > 1440 {
1175 return Err(AirError::Configuration(format!(
1176 "Index update interval {} minutes exceeds maximum (1440 minutes = 1 day)",
1177 indexing.UpdateIntervalMinutes
1178 )));
1179 }
1180
1181 Ok(())
1182 }
1183
1184 fn ValidateLoggingConfig(&self, logging:&LoggingConfig) -> Result<()> {
1186 let ValidLevels = ["trace", "debug", "info", "warn", "error"];
1188 if !ValidLevels.contains(&logging.Level.as_str()) {
1189 return Err(AirError::Configuration(format!(
1190 "Invalid log level '{}': must be one of: {}",
1191 logging.Level,
1192 ValidLevels.join(", ")
1193 )));
1194 }
1195
1196 if let Some(ref FilePath) = logging.FilePath {
1198 if !FilePath.is_empty() {
1199 self.ValidatePath(FilePath)?;
1200 }
1201 }
1202
1203 if logging.MaxFileSizeMb < 1 {
1205 return Err(AirError::Configuration(format!(
1206 "Max log file size {} MB is below minimum (1 MB)",
1207 logging.MaxFileSizeMb
1208 )));
1209 }
1210
1211 if logging.MaxFileSizeMb > 1000 {
1212 return Err(AirError::Configuration(format!(
1213 "Max log file size {} MB exceeds maximum (1000 MB = 1 GB)",
1214 logging.MaxFileSizeMb
1215 )));
1216 }
1217
1218 if logging.MaxFiles < 1 {
1220 return Err(AirError::Configuration(format!(
1221 "Max log files {} is below minimum (1)",
1222 logging.MaxFiles
1223 )));
1224 }
1225
1226 if logging.MaxFiles > 50 {
1227 return Err(AirError::Configuration(format!(
1228 "Max log files {} exceeds maximum (50)",
1229 logging.MaxFiles
1230 )));
1231 }
1232
1233 Ok(())
1234 }
1235
1236 fn ValidatePerformanceConfig(&self, performance:&PerformanceConfig) -> Result<()> {
1238 if performance.MemoryLimitMb < 64 {
1240 return Err(AirError::Configuration(format!(
1241 "Memory limit {} MB is below minimum (64 MB)",
1242 performance.MemoryLimitMb
1243 )));
1244 }
1245
1246 if performance.MemoryLimitMb > 16384 {
1247 return Err(AirError::Configuration(format!(
1248 "Memory limit {} MB exceeds maximum (16384 MB = 16 GB)",
1249 performance.MemoryLimitMb
1250 )));
1251 }
1252
1253 if performance.CPULimitPercent < 10 {
1255 return Err(AirError::Configuration(format!(
1256 "CPU limit {}% is below minimum (10%)",
1257 performance.CPULimitPercent
1258 )));
1259 }
1260
1261 if performance.CPULimitPercent > 100 {
1262 return Err(AirError::Configuration(format!(
1263 "CPU limit {}% exceeds maximum (100%)",
1264 performance.CPULimitPercent
1265 )));
1266 }
1267
1268 if performance.DiskLimitMb < 100 {
1270 return Err(AirError::Configuration(format!(
1271 "Disk limit {} MB is below minimum (100 MB)",
1272 performance.DiskLimitMb
1273 )));
1274 }
1275
1276 if performance.DiskLimitMb > 102400 {
1277 return Err(AirError::Configuration(format!(
1278 "Disk limit {} MB exceeds maximum (102400 MB = 100 GB)",
1279 performance.DiskLimitMb
1280 )));
1281 }
1282
1283 if performance.BackgroundTaskIntervalSecs < 1 {
1285 return Err(AirError::Configuration(format!(
1286 "Background task interval {} seconds is below minimum (1 second)",
1287 performance.BackgroundTaskIntervalSecs
1288 )));
1289 }
1290
1291 if performance.BackgroundTaskIntervalSecs > 3600 {
1292 return Err(AirError::Configuration(format!(
1293 "Background task interval {} seconds exceeds maximum (3600 seconds = 1 hour)",
1294 performance.BackgroundTaskIntervalSecs
1295 )));
1296 }
1297
1298 Ok(())
1299 }
1300
1301 fn ValidatePath(&self, path:&str) -> Result<()> {
1303 if path.is_empty() {
1304 return Err(AirError::Configuration("Path cannot be empty".to_string()));
1305 }
1306
1307 if path.contains("..") {
1309 return Err(AirError::Configuration(format!(
1310 "Path '{}' contains '..' which is not allowed for security reasons",
1311 path
1312 )));
1313 }
1314
1315 if path.starts_with("\\\\") || path.starts_with("//") {
1317 return Err(AirError::Configuration(format!(
1318 "Path '{}' uses UNC/network path format which may not be supported",
1319 path
1320 )));
1321 }
1322
1323 if path.contains('\0') {
1325 return Err(AirError::Configuration(
1326 "Path contains null bytes which is not allowed".to_string(),
1327 ));
1328 }
1329
1330 Ok(())
1331 }
1332
1333 fn IsValidAddress(addr:&str) -> bool {
1335 if addr.starts_with('[') && addr.contains("]:") {
1337 return true;
1338 }
1339
1340 if addr.contains(':') {
1342 let parts:Vec<&str> = addr.split(':').collect();
1343 if parts.len() != 2 {
1344 return false;
1345 }
1346
1347 if let Ok(port) = parts[1].parse::<u16>() {
1349 return port > 0;
1350 }
1351
1352 return false;
1353 }
1354
1355 false
1356 }
1357
1358 fn IsValidUrl(url:&str) -> bool { url::Url::parse(url).is_ok() }
1360
1361 fn SchemaValidate(&self, config:&AirConfiguration) -> Result<()> {
1363 let _schema = generate_schema();
1364
1365 let ConfigJson = serde_json::to_value(config)
1367 .map_err(|e| AirError::Configuration(format!("Failed to serialize config for schema validation: {}", e)))?;
1368
1369 if !ConfigJson.is_object() {
1372 return Err(AirError::Configuration("Configuration must be an object".to_string()));
1373 }
1374
1375 dev_log!("config", "Schema validation passed");
1376 Ok(())
1377 }
1378
1379 fn ApplyEnvironmentOverrides(&self, config:&mut AirConfiguration) -> Result<()> {
1388 let mut override_count = 0;
1389
1390 if let Ok(val) = env::var(&format!("{}GRPC_BIND_ADDRESS", self.EnvPrefix)) {
1392 config.gRPC.BindAddress = val;
1393 override_count += 1;
1394 }
1395
1396 if let Ok(val) = env::var(&format!("{}GRPC_MAX_CONNECTIONS", self.EnvPrefix)) {
1397 config.gRPC.MaxConnections = val
1398 .parse()
1399 .map_err(|e| AirError::Configuration(format!("Invalid GRPC_MAX_CONNECTIONS value: {}", e)))?;
1400 override_count += 1;
1401 }
1402
1403 if let Ok(val) = env::var(&format!("{}AUTH_ENABLED", self.EnvPrefix)) {
1405 config.Authentication.Enabled = val
1406 .parse()
1407 .map_err(|e| AirError::Configuration(format!("Invalid AUTH_ENABLED value: {}", e)))?;
1408 override_count += 1;
1409 }
1410
1411 if let Ok(val) = env::var(&format!("{}AUTH_CREDENTIALS_PATH", self.EnvPrefix)) {
1412 config.Authentication.CredentialsPath = val;
1413 override_count += 1;
1414 }
1415
1416 if let Ok(val) = env::var(&format!("{}UPDATE_ENABLED", self.EnvPrefix)) {
1418 config.Updates.Enabled = val
1419 .parse()
1420 .map_err(|e| AirError::Configuration(format!("Invalid UPDATE_ENABLED value: {}", e)))?;
1421 override_count += 1;
1422 }
1423
1424 if let Ok(val) = env::var(&format!("{}UPDATE_AUTO_DOWNLOAD", self.EnvPrefix)) {
1425 config.Updates.AutoDownload = val
1426 .parse()
1427 .map_err(|e| AirError::Configuration(format!("Invalid UPDATE_AUTO_DOWNLOAD value: {}", e)))?;
1428 override_count += 1;
1429 }
1430
1431 if let Ok(val) = env::var(&format!("{}LOGGING_LEVEL", self.EnvPrefix)) {
1433 config.Logging.Level = val.to_lowercase();
1434 override_count += 1;
1435 }
1436
1437 if override_count > 0 {
1438 dev_log!("config", "Applied {} environment variable override(s)", override_count);
1439 }
1440
1441 Ok(())
1442 }
1443
1444 async fn BackupConfiguration(&self, config_path:&Path) -> Result<()> {
1449 let backup_dir = self
1450 .BackupDir
1451 .as_ref()
1452 .ok_or_else(|| AirError::Configuration("Backup directory not configured".to_string()))?;
1453
1454 tokio::fs::create_dir_all(backup_dir).await.map_err(|e| {
1456 AirError::Configuration(format!("Failed to create backup directory '{}': {}", backup_dir.display(), e))
1457 })?;
1458
1459 let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
1461 let backup_filename = format!(
1462 "{}_config_{}.toml.bak",
1463 config_path.file_stem().and_then(|s| s.to_str()).unwrap_or("config"),
1464 timestamp
1465 );
1466 let backup_path = backup_dir.join(&backup_filename);
1467
1468 tokio::fs::copy(config_path, &backup_path).await.map_err(|e| {
1470 AirError::Configuration(format!("Failed to create backup '{}': {}", backup_path.display(), e))
1471 })?;
1472
1473 dev_log!("config", "Configuration backed up to: {}", backup_path.display());
1474 Ok(())
1475 }
1476
1477 pub async fn RollbackConfiguration(&self) -> Result<PathBuf> {
1483 let config_path = self.GetConfigPath()?;
1484
1485 let backup_dir = self
1486 .BackupDir
1487 .as_ref()
1488 .ok_or_else(|| AirError::Configuration("Backup directory not configured".to_string()))?;
1489
1490 let mut backups = tokio::fs::read_dir(backup_dir).await.map_err(|e| {
1492 AirError::Configuration(format!("Failed to read backup directory '{}': {}", backup_dir.display(), e))
1493 })?;
1494
1495 let mut most_recent:Option<(tokio::fs::DirEntry, std::time::SystemTime)> = None;
1496
1497 while let Some(entry) = backups
1498 .next_entry()
1499 .await
1500 .map_err(|e| AirError::Configuration(format!("Failed to read backup entry: {}", e)))?
1501 {
1502 let metadata = entry
1503 .metadata()
1504 .await
1505 .map_err(|e| AirError::Configuration(format!("Failed to get metadata: {}", e)))?;
1506
1507 if let Ok(modified) = metadata.modified() {
1508 if most_recent.is_none() || modified > most_recent.as_ref().unwrap().1 {
1509 most_recent = Some((entry, modified));
1510 }
1511 }
1512 }
1513
1514 let (backup_entry, _) =
1515 most_recent.ok_or_else(|| AirError::Configuration("No backup files found".to_string()))?;
1516
1517 let backup_path = backup_entry.path();
1518
1519 tokio::fs::copy(&backup_path, &config_path).await.map_err(|e| {
1521 AirError::Configuration(format!("Failed to restore from backup '{}': {}", backup_path.display(), e))
1522 })?;
1523
1524 dev_log!("config", "Configuration rolled back from: {}", backup_path.display());
1525 Ok(backup_path)
1526 }
1527
1528 fn GetConfigPath(&self) -> Result<PathBuf> {
1532 if let Some(ref path) = self.ConfigPath {
1533 Ok(path.clone())
1534 } else {
1535 Self::GetDefaultConfigPath()
1536 }
1537 }
1538
1539 fn GetDefaultConfigPath() -> Result<PathBuf> {
1544 let config_dir = dirs::config_dir()
1545 .ok_or_else(|| AirError::Configuration("Cannot determine config directory".to_string()))?;
1546
1547 Ok(config_dir.join("Air").join(DefaultConfigFile))
1548 }
1549
1550 pub fn GetProfileDefaults(profile:&str) -> AirConfiguration {
1560 let mut config = AirConfiguration::default();
1561 config.Profile = profile.to_string();
1562
1563 match profile {
1564 "prod" => {
1565 config.Logging.Level = "warn".to_string();
1566 config.Logging.ConsoleEnabled = false;
1567 config.Performance.MemoryLimitMb = 1024;
1568 config.Performance.CPULimitPercent = 80;
1569 },
1570 "staging" => {
1571 config.Logging.Level = "info".to_string();
1572 config.Performance.MemoryLimitMb = 768;
1573 config.Performance.CPULimitPercent = 70;
1574 },
1575 "dev" | _ => {
1576 config.Logging.Level = "debug".to_string();
1578 config.Logging.ConsoleEnabled = true;
1579 config.Performance.MemoryLimitMb = 512;
1580 config.Performance.CPULimitPercent = 50;
1581 },
1582 }
1583
1584 config
1585 }
1586
1587 pub fn ExpandPath(path:&str) -> Result<PathBuf> {
1597 if path.is_empty() {
1598 return Err(AirError::Configuration("Cannot expand empty path".to_string()));
1599 }
1600
1601 if path.starts_with('~') {
1602 let home = dirs::home_dir()
1603 .ok_or_else(|| AirError::Configuration("Cannot determine home directory".to_string()))?;
1604
1605 let rest = &path[1..]; if rest.starts_with('/') || rest.starts_with('\\') {
1607 Ok(home.join(&rest[1..]))
1608 } else {
1609 Ok(home.join(rest))
1610 }
1611 } else {
1612 Ok(PathBuf::from(path))
1613 }
1614 }
1615
1616 pub fn ComputeHash(config:&AirConfiguration) -> Result<String> {
1626 let config_str = toml::to_string_pretty(config)
1627 .map_err(|e| AirError::Configuration(format!("Failed to serialize config: {}", e)))?;
1628
1629 let mut hasher = sha2::Sha256::new();
1630 hasher.update(config_str.as_bytes());
1631 let hash = hasher.finalize();
1632
1633 Ok(hex::encode(hash))
1634 }
1635
1636 pub fn ExportToJson(config:&AirConfiguration) -> Result<String> {
1646 serde_json::to_string_pretty(config)
1647 .map_err(|e| AirError::Configuration(format!("Failed to export to JSON: {}", e)))
1648 }
1649
1650 pub fn ImportFromJson(json_str:&str) -> Result<AirConfiguration> {
1660 let config:AirConfiguration = serde_json::from_str(json_str)
1661 .map_err(|e| AirError::Configuration(format!("Failed to import from JSON: {}", e)))?;
1662
1663 Ok(config)
1664 }
1665
1666 pub fn GetEnvironmentMappings(&self) -> HashMap<String, String> {
1670 let prefix = &self.EnvPrefix;
1671 let mut mappings = HashMap::new();
1672
1673 mappings.insert("grpc.bind_address".to_string(), format!("{}GRPC_BIND_ADDRESS", prefix));
1674 mappings.insert("grpc.max_connections".to_string(), format!("{}GRPC_MAX_CONNECTIONS", prefix));
1675 mappings.insert(
1676 "grpc.request_timeout_secs".to_string(),
1677 format!("{}GRPC_REQUEST_TIMEOUT_SECS", prefix),
1678 );
1679
1680 mappings.insert("authentication.enabled".to_string(), format!("{}AUTH_ENABLED", prefix));
1681 mappings.insert(
1682 "authentication.credentials_path".to_string(),
1683 format!("{}AUTH_CREDENTIALS_PATH", prefix),
1684 );
1685 mappings.insert(
1686 "authentication.token_expiration_hours".to_string(),
1687 format!("{}AUTH_TOKEN_EXPIRATION_HOURS", prefix),
1688 );
1689
1690 mappings.insert("updates.enabled".to_string(), format!("{}UPDATE_ENABLED", prefix));
1691 mappings.insert("updates.auto_download".to_string(), format!("{}UPDATE_AUTO_DOWNLOAD", prefix));
1692 mappings.insert("updates.auto_install".to_string(), format!("{}UPDATE_AUTO_INSTALL", prefix));
1693
1694 mappings.insert("logging.level".to_string(), format!("{}LOGGING_LEVEL", prefix));
1695 mappings.insert(
1696 "logging.console_enabled".to_string(),
1697 format!("{}LOGGING_CONSOLE_ENABLED", prefix),
1698 );
1699
1700 mappings
1701 }
1702}
1703
1704#[cfg(test)]
1705mod tests {
1706 use super::*;
1707
1708 #[test]
1709 fn test_default_configuration() {
1710 let config = AirConfiguration::default();
1711 assert_eq!(config.SchemaVersion, "1.0.0");
1712 assert_eq!(config.Profile, "dev");
1713 assert!(config.Authentication.Enabled);
1714 assert!(config.Logging.ConsoleEnabled);
1715 }
1716
1717 #[test]
1718 fn test_profile_defaults() {
1719 let DevConfig = ConfigurationManager::GetProfileDefaults("dev");
1720 assert_eq!(DevConfig.Profile, "dev");
1721 assert_eq!(DevConfig.Logging.Level, "debug");
1722
1723 let ProdConfig = ConfigurationManager::GetProfileDefaults("prod");
1724 assert_eq!(ProdConfig.Profile, "prod");
1725 assert_eq!(ProdConfig.Logging.Level, "warn");
1726 assert!(!ProdConfig.Logging.ConsoleEnabled);
1727 }
1728
1729 #[test]
1730 fn test_path_expansion() {
1731 let Home = dirs::home_dir().expect("Cannot determine home directory");
1732 let Expanded = ConfigurationManager::ExpandPath("~/test").unwrap();
1733 assert_eq!(Expanded, Home.join("test"));
1734
1735 let Absolute = ConfigurationManager::ExpandPath("/tmp/test").unwrap();
1736 assert_eq!(Absolute, PathBuf::from("/tmp/test"));
1737 }
1738
1739 #[test]
1740 fn test_address_validation() {
1741 assert!(ConfigurationManager::IsValidAddress("[::1]:50053"));
1742 assert!(ConfigurationManager::IsValidAddress("127.0.0.1:50053"));
1743 assert!(ConfigurationManager::IsValidAddress("localhost:50053"));
1744 assert!(!ConfigurationManager::IsValidAddress("invalid"));
1745 }
1746
1747 #[test]
1748 fn test_url_validation() {
1749 assert!(ConfigurationManager::IsValidUrl("https://example.com"));
1750 assert!(ConfigurationManager::IsValidUrl("https://updates.editor.land"));
1751 assert!(!ConfigurationManager::IsValidUrl("not-a-url"));
1752 assert!(!ConfigurationManager::IsValidUrl("http://insecure.com"));
1753 }
1754
1755 #[test]
1756 fn test_path_validation() {
1757 let manager = ConfigurationManager::New(None).unwrap();
1758 assert!(manager.ValidatePath("~/config").is_ok());
1759 assert!(manager.ValidatePath("/tmp/config").is_ok());
1760 assert!(manager.ValidatePath("../escaped").is_err());
1761 assert!(manager.ValidatePath("").is_err());
1762 }
1763
1764 #[tokio::test]
1765 async fn test_export_import_json() {
1766 let config = AirConfiguration::default();
1767 let json_str = ConfigurationManager::ExportToJson(&config).unwrap();
1768
1769 let imported = ConfigurationManager::ImportFromJson(&json_str).unwrap();
1770 assert_eq!(imported.SchemaVersion, config.SchemaVersion);
1771 assert_eq!(imported.Profile, config.Profile);
1772 assert_eq!(imported.gRPC.BindAddress, config.gRPC.BindAddress);
1773 }
1774
1775 #[test]
1776 fn test_compute_hash() {
1777 let config = AirConfiguration::default();
1778 let hash1 = ConfigurationManager::ComputeHash(&config).unwrap();
1779 let hash2 = ConfigurationManager::ComputeHash(&config).unwrap();
1780 assert_eq!(hash1, hash2);
1781
1782 let mut modified = config;
1783 modified.gRPC.BindAddress = "[::1]:50054".to_string();
1784 let hash3 = ConfigurationManager::ComputeHash(&modified).unwrap();
1785 assert_ne!(hash1, hash3);
1786 }
1787
1788 #[test]
1789 fn test_generate_schema() {
1790 let schema = generate_schema();
1791 assert!(schema.is_object());
1792 assert!(schema.get("$schema").is_some());
1793 assert!(schema.get("properties").is_some());
1794 }
1795}