airsprotocols_mcpserver_filesystem/config/
loader.rs1use std::env;
12use std::path::{Path, PathBuf};
13
14use anyhow::{Context, Result};
16use config::{Config, Environment, File, FileFormat};
17use serde::{Deserialize, Serialize};
18
19use super::settings::Settings;
21
22#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum ConfigEnvironment {
25 Development,
27 Staging,
29 Production,
31 Test,
33}
34
35impl ConfigEnvironment {
36 pub fn detect() -> Self {
38 match env::var("AIRSPROTOCOLS_MCPSERVER_FS_ENV")
39 .or_else(|_| env::var("AIRSPROTOCOLS_MCP_FS_ENV")) .or_else(|_| env::var("NODE_ENV"))
41 .or_else(|_| env::var("ENVIRONMENT"))
42 .as_deref()
43 {
44 Ok("development") | Ok("dev") => Self::Development,
45 Ok("staging") | Ok("stage") => Self::Staging,
46 Ok("production") | Ok("prod") => Self::Production,
47 Ok("test") => Self::Test,
48 _ => {
49 if cfg!(test) {
51 Self::Test
52 } else if cfg!(debug_assertions) {
53 Self::Development
54 } else {
55 Self::Production
56 }
57 }
58 }
59 }
60
61 pub fn config_filename(&self) -> &'static str {
63 match self {
64 Self::Development => "development.toml",
65 Self::Staging => "staging.toml",
66 Self::Production => "production.toml",
67 Self::Test => "test.toml",
68 }
69 }
70
71 pub fn as_str(&self) -> &'static str {
73 match self {
74 Self::Development => "development",
75 Self::Staging => "staging",
76 Self::Production => "production",
77 Self::Test => "test",
78 }
79 }
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct ConfigurationSource {
85 pub files: Vec<String>,
87 pub env_vars: Vec<String>,
89 pub environment: String,
91 pub uses_defaults: bool,
93}
94
95pub struct ConfigurationLoader {
97 environment: ConfigEnvironment,
99 config_dir: PathBuf,
101 env_prefix: String,
103}
104
105impl ConfigurationLoader {
106 pub fn new() -> Self {
108 Self::with_environment(ConfigEnvironment::detect())
109 }
110
111 pub fn with_environment(environment: ConfigEnvironment) -> Self {
113 let config_dir = Self::default_config_dir();
114 Self {
115 environment,
116 config_dir,
117 env_prefix: "AIRSPROTOCOLS_MCPSERVER_FS".to_string(),
118 }
119 }
120
121 pub fn with_config_dir<P: Into<PathBuf>>(mut self, config_dir: P) -> Self {
123 self.config_dir = config_dir.into();
124 self
125 }
126
127 pub fn with_env_prefix<S: Into<String>>(mut self, prefix: S) -> Self {
129 self.env_prefix = prefix.into();
130 self
131 }
132
133 pub fn load(&self) -> Result<(Settings, ConfigurationSource)> {
135 let mut builder = Config::builder();
136 let mut source_info = ConfigurationSource {
137 files: Vec::new(),
138 env_vars: Vec::new(),
139 environment: self.environment.as_str().to_string(),
140 uses_defaults: false,
141 };
142
143 let default_settings = Settings::default();
145 builder = builder.add_source(
146 config::Config::try_from(&default_settings)
147 .context("Failed to convert default settings to config source")?,
148 );
149 source_info.uses_defaults = true;
150
151 let base_config_path = self.config_dir.join("config.toml");
153 if base_config_path.exists() {
154 builder = builder.add_source(
155 File::from(base_config_path.as_path())
156 .format(FileFormat::Toml)
157 .required(false),
158 );
159 source_info
160 .files
161 .push(base_config_path.display().to_string());
162 }
163
164 let env_config_path = self.config_dir.join(self.environment.config_filename());
166 if env_config_path.exists() {
167 builder = builder.add_source(
168 File::from(env_config_path.as_path())
169 .format(FileFormat::Toml)
170 .required(false),
171 );
172 source_info
173 .files
174 .push(env_config_path.display().to_string());
175 }
176
177 if matches!(self.environment, ConfigEnvironment::Development) {
179 let local_config_path = self.config_dir.join("local.toml");
180 if local_config_path.exists() {
181 builder = builder.add_source(
182 File::from(local_config_path.as_path())
183 .format(FileFormat::Toml)
184 .required(false),
185 );
186 source_info
187 .files
188 .push(local_config_path.display().to_string());
189 }
190 }
191
192 builder = builder.add_source(
194 Environment::with_prefix(&self.env_prefix)
195 .separator("__") .prefix_separator("_"),
197 );
198
199 for (key, _value) in env::vars() {
201 if key.starts_with(&format!("{}_", self.env_prefix)) {
202 source_info.env_vars.push(key);
203 }
204 }
205
206 let config = builder.build().context("Failed to build configuration")?;
208
209 let settings: Settings = config
211 .try_deserialize()
212 .context("Failed to deserialize configuration into Settings struct")?;
213
214 Ok((settings, source_info))
215 }
216
217 pub fn load_from_file<P: AsRef<Path>>(file_path: P) -> Result<Settings> {
219 let path = file_path.as_ref();
220
221 let format = match path.extension().and_then(|s| s.to_str()) {
223 Some("toml") => FileFormat::Toml,
224 Some("yaml") | Some("yml") => FileFormat::Yaml,
225 Some("json") => FileFormat::Json,
226 _ => {
227 return Err(anyhow::anyhow!(
228 "Unsupported configuration file format. Supported: .toml, .yaml, .yml, .json"
229 ))
230 }
231 };
232
233 let config = Config::builder()
234 .add_source(File::from(path).format(format))
235 .build()
236 .with_context(|| format!("Failed to load configuration from {}", path.display()))?;
237
238 let settings: Settings = config
239 .try_deserialize()
240 .with_context(|| format!("Failed to parse configuration file {}", path.display()))?;
241
242 Ok(settings)
243 }
244
245 fn default_config_dir() -> PathBuf {
247 if let Ok(config_dir) =
249 env::var("AIRSPROTOCOLS_MCPSERVER_FS_CONFIG_DIR").or_else(|_| env::var("AIRSPROTOCOLS_MCP_FS_CONFIG_DIR"))
250 {
252 return PathBuf::from(config_dir);
253 }
254
255 PathBuf::from("config")
258 }
259
260 pub fn validate_file<P: AsRef<Path>>(file_path: P) -> Result<Vec<String>> {
262 let settings = Self::load_from_file(file_path)?;
263
264 let validation_result = settings
266 .validate()
267 .context("Failed to validate configuration")?;
268
269 let mut issues = Vec::new();
270 issues.extend(validation_result.errors);
271 issues.extend(validation_result.warnings);
272
273 Ok(issues)
274 }
275}
276
277impl Default for ConfigurationLoader {
278 fn default() -> Self {
279 Self::new()
280 }
281}
282
283#[cfg(test)]
284#[allow(clippy::unwrap_used, clippy::uninlined_format_args)]
285mod tests {
286 use super::*;
287 use std::fs;
288 use tempfile::TempDir;
289
290 fn create_test_config_dir() -> TempDir {
291 let temp_dir = TempDir::new().unwrap();
292
293 let config_content = r#"
295[server]
296name = "test-airsprotocols-mcpserver-filesystem"
297version = "0.1.0"
298
299[binary]
300max_file_size = 52428800 # 50MB for text files only
301binary_processing_disabled = true # Security hardening - binary processing disabled
302
303[security.filesystem]
304allowed_paths = ["./test/**/*"]
305denied_paths = ["**/.env*"]
306
307[security.operations]
308read_allowed = true
309write_requires_policy = false
310delete_requires_explicit_allow = false
311create_dir_allowed = true
312
313[security.policies.test_policy]
314patterns = ["*.txt"]
315operations = ["read", "write"]
316risk_level = "low"
317description = "Test policy for txt files"
318"#;
319
320 let config_path = temp_dir.path().join("config.toml");
321 fs::write(&config_path, config_content).unwrap();
322
323 temp_dir
324 }
325
326 #[test]
327 fn test_environment_detection() {
328 let env = ConfigEnvironment::detect();
330 assert!(matches!(
331 env,
332 ConfigEnvironment::Test | ConfigEnvironment::Development
333 ));
334 }
335
336 #[test]
337 fn test_environment_config_filenames() {
338 assert_eq!(
339 ConfigEnvironment::Development.config_filename(),
340 "development.toml"
341 );
342 assert_eq!(ConfigEnvironment::Staging.config_filename(), "staging.toml");
343 assert_eq!(
344 ConfigEnvironment::Production.config_filename(),
345 "production.toml"
346 );
347 assert_eq!(ConfigEnvironment::Test.config_filename(), "test.toml");
348 }
349
350 #[test]
351 fn test_configuration_loader_creation() {
352 let loader = ConfigurationLoader::new();
353 assert!(matches!(
354 loader.environment,
355 ConfigEnvironment::Test | ConfigEnvironment::Development
356 ));
357
358 let prod_loader = ConfigurationLoader::with_environment(ConfigEnvironment::Production);
359 assert!(matches!(
360 prod_loader.environment,
361 ConfigEnvironment::Production
362 ));
363 }
364
365 #[test]
366 fn test_load_from_file() {
367 let temp_dir = create_test_config_dir();
368 let config_path = temp_dir.path().join("config.toml");
369
370 let settings = ConfigurationLoader::load_from_file(&config_path).unwrap();
371 assert_eq!(settings.server.name, "test-airsprotocols-mcpserver-filesystem");
372 assert_eq!(settings.binary.max_file_size, 52428800);
373 assert!(settings.binary.binary_processing_disabled); }
375
376 #[test]
377 fn test_load_with_config_dir() {
378 let temp_dir = create_test_config_dir();
379
380 let loader = ConfigurationLoader::with_environment(ConfigEnvironment::Test)
381 .with_config_dir(temp_dir.path());
382
383 let (settings, source_info) = loader.load().unwrap();
384
385 assert_eq!(settings.server.name, "test-airsprotocols-mcpserver-filesystem");
387 assert!(source_info.uses_defaults);
388 assert!(!source_info.files.is_empty());
389 assert_eq!(source_info.environment, "test");
390 }
391
392 #[test]
393 fn test_validate_file() {
394 let temp_dir = create_test_config_dir();
395 let config_path = temp_dir.path().join("config.toml");
396
397 let issues = ConfigurationLoader::validate_file(&config_path).unwrap();
398 println!("Validation issues found: {:?}", issues);
401
402 }
405
406 #[test]
407 fn test_unsupported_file_format() {
408 let temp_dir = TempDir::new().unwrap();
409 let invalid_path = temp_dir.path().join("config.txt");
410 fs::write(&invalid_path, "invalid content").unwrap();
411
412 let result = ConfigurationLoader::load_from_file(&invalid_path);
413 assert!(result.is_err());
414 assert!(result
415 .unwrap_err()
416 .to_string()
417 .contains("Unsupported configuration file format"));
418 }
419}