1use serde::{Deserialize, Serialize};
52use std::collections::HashSet;
53use std::env;
54use std::fs;
55use std::path::{Path, PathBuf};
56
57#[derive(Debug, Clone, Serialize, Deserialize, Default)]
59#[serde(default)]
60pub struct ServerConfig {
61 pub logging: LoggingConfig,
63 pub completion: CompletionConfig,
65 pub diagnostics: DiagnosticsConfig,
67 pub schema: SchemaConfig,
69}
70
71impl ServerConfig {
72 pub fn load(workspace_root: Option<&Path>) -> Self {
80 let mut config = Self::default();
81
82 if let Some(user_config_path) = Self::user_config_path() {
84 if let Ok(user_config) = Self::load_from_file(&user_config_path) {
85 config = config.merge(user_config);
86 }
87 }
88
89 if let Some(workspace_root) = workspace_root {
91 let workspace_config_path = workspace_root.join(".spring-lsp.toml");
92 if let Ok(workspace_config) = Self::load_from_file(&workspace_config_path) {
93 config = config.merge(workspace_config);
94 }
95 }
96
97 config = config.apply_env_overrides();
99
100 config
101 }
102
103 fn load_from_file(path: &Path) -> Result<Self, Box<dyn std::error::Error>> {
105 let content = fs::read_to_string(path)?;
106 let config: Self = toml::from_str(&content)?;
107 tracing::debug!("Loaded configuration from: {}", path.display());
108 Ok(config)
109 }
110
111 fn user_config_path() -> Option<PathBuf> {
113 dirs::config_dir().map(|dir| dir.join("spring-lsp").join("config.toml"))
114 }
115
116 pub fn merge(mut self, other: Self) -> Self {
118 self.logging = self.logging.merge(other.logging);
119 self.completion = self.completion.merge(other.completion);
120 self.diagnostics = self.diagnostics.merge(other.diagnostics);
121 self.schema = self.schema.merge(other.schema);
122 self
123 }
124
125 fn apply_env_overrides(mut self) -> Self {
127 self.logging = self.logging.apply_env_overrides();
128 self.schema = self.schema.apply_env_overrides();
129 self
130 }
131
132 pub fn validate(&self) -> Result<(), String> {
134 self.logging.validate()?;
135 self.completion.validate()?;
136 self.schema.validate()?;
137 Ok(())
138 }
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize)]
143#[serde(default)]
144pub struct LoggingConfig {
145 pub level: String,
147 pub verbose: bool,
149 pub log_file: Option<PathBuf>,
151}
152
153impl Default for LoggingConfig {
154 fn default() -> Self {
155 Self {
156 level: "info".to_string(),
157 verbose: false,
158 log_file: None,
159 }
160 }
161}
162
163impl LoggingConfig {
164 pub fn merge(self, other: Self) -> Self {
165 Self {
166 level: other.level,
167 verbose: other.verbose,
168 log_file: other.log_file.or(self.log_file),
169 }
170 }
171
172 fn apply_env_overrides(mut self) -> Self {
173 if let Ok(level) = env::var("SPRING_LSP_LOG_LEVEL") {
174 self.level = level.to_lowercase();
175 }
176 if let Ok(verbose) = env::var("SPRING_LSP_VERBOSE") {
177 self.verbose = verbose == "1" || verbose.to_lowercase() == "true";
178 }
179 if let Ok(log_file) = env::var("SPRING_LSP_LOG_FILE") {
180 self.log_file = Some(PathBuf::from(log_file));
181 }
182 self
183 }
184
185 pub fn validate(&self) -> Result<(), String> {
186 match self.level.as_str() {
187 "trace" | "debug" | "info" | "warn" | "error" => Ok(()),
188 _ => Err(format!(
189 "Invalid log level: {}. Valid levels are: trace, debug, info, warn, error",
190 self.level
191 )),
192 }
193 }
194}
195
196#[derive(Debug, Clone, Serialize, Deserialize)]
198#[serde(default)]
199pub struct CompletionConfig {
200 pub trigger_characters: Vec<String>,
202}
203
204impl Default for CompletionConfig {
205 fn default() -> Self {
206 Self {
207 trigger_characters: vec![
208 "[".to_string(), ".".to_string(), "$".to_string(), "{".to_string(), "#".to_string(), "(".to_string(), ],
215 }
216 }
217}
218
219impl CompletionConfig {
220 pub fn merge(self, other: Self) -> Self {
221 Self {
222 trigger_characters: if other.trigger_characters.is_empty() {
223 self.trigger_characters
224 } else {
225 other.trigger_characters
226 },
227 }
228 }
229
230 pub fn validate(&self) -> Result<(), String> {
231 if self.trigger_characters.is_empty() {
232 return Err("Trigger characters list cannot be empty".to_string());
233 }
234 Ok(())
235 }
236}
237
238#[derive(Debug, Clone, Serialize, Deserialize, Default)]
240#[serde(default)]
241pub struct DiagnosticsConfig {
242 pub disabled: HashSet<String>,
244}
245
246impl DiagnosticsConfig {
247 pub fn merge(self, other: Self) -> Self {
248 Self {
249 disabled: if other.disabled.is_empty() {
250 self.disabled
251 } else {
252 other.disabled
253 },
254 }
255 }
256
257 pub fn is_disabled(&self, diagnostic_type: &str) -> bool {
259 self.disabled.contains(diagnostic_type)
260 }
261}
262
263#[derive(Debug, Clone, Serialize, Deserialize)]
265#[serde(default)]
266pub struct SchemaConfig {
267 pub url: String,
269}
270
271impl Default for SchemaConfig {
272 fn default() -> Self {
273 Self {
274 url: "https://spring-rs.github.io/config-schema.json".to_string(),
275 }
276 }
277}
278
279impl SchemaConfig {
280 pub fn merge(self, other: Self) -> Self {
281 Self { url: other.url }
282 }
283
284 fn apply_env_overrides(mut self) -> Self {
285 if let Ok(url) = env::var("SPRING_LSP_SCHEMA_URL") {
286 self.url = url;
287 }
288 self
289 }
290
291 pub fn validate(&self) -> Result<(), String> {
292 if self.url.is_empty() {
293 return Err("Schema URL cannot be empty".to_string());
294 }
295
296 if !self.url.starts_with("http://")
298 && !self.url.starts_with("https://")
299 && !self.url.starts_with("file://")
300 {
301 return Err(format!(
302 "Invalid Schema URL: {}. Must start with http://, https://, or file://",
303 self.url
304 ));
305 }
306
307 Ok(())
308 }
309}
310
311#[cfg(test)]
312mod tests {
313 use super::*;
314 use std::env;
315
316 #[test]
317 fn test_default_config() {
318 let config = ServerConfig::default();
319 assert_eq!(config.logging.level, "info");
320 assert!(!config.logging.verbose);
321 assert!(config.logging.log_file.is_none());
322 assert_eq!(config.completion.trigger_characters.len(), 6);
323 assert!(config.diagnostics.disabled.is_empty());
324 assert_eq!(
325 config.schema.url,
326 "https://spring-rs.github.io/config-schema.json"
327 );
328 }
329
330 #[test]
331 fn test_logging_config_validation() {
332 let valid_config = LoggingConfig {
333 level: "debug".to_string(),
334 verbose: false,
335 log_file: None,
336 };
337 assert!(valid_config.validate().is_ok());
338
339 let invalid_config = LoggingConfig {
340 level: "invalid".to_string(),
341 verbose: false,
342 log_file: None,
343 };
344 assert!(invalid_config.validate().is_err());
345 }
346
347 #[test]
348 fn test_completion_config_validation() {
349 let valid_config = CompletionConfig {
350 trigger_characters: vec!["[".to_string()],
351 };
352 assert!(valid_config.validate().is_ok());
353
354 let invalid_config = CompletionConfig {
355 trigger_characters: vec![],
356 };
357 assert!(invalid_config.validate().is_err());
358 }
359
360 #[test]
361 fn test_schema_config_validation() {
362 let valid_http = SchemaConfig {
363 url: "https://example.com/schema.json".to_string(),
364 };
365 assert!(valid_http.validate().is_ok());
366
367 let valid_file = SchemaConfig {
368 url: "file:///path/to/schema.json".to_string(),
369 };
370 assert!(valid_file.validate().is_ok());
371
372 let invalid_empty = SchemaConfig {
373 url: "".to_string(),
374 };
375 assert!(invalid_empty.validate().is_err());
376
377 let invalid_protocol = SchemaConfig {
378 url: "ftp://example.com/schema.json".to_string(),
379 };
380 assert!(invalid_protocol.validate().is_err());
381 }
382
383 #[test]
384 fn test_diagnostics_is_disabled() {
385 let mut config = DiagnosticsConfig::default();
386 assert!(!config.is_disabled("deprecated_warning"));
387
388 config.disabled.insert("deprecated_warning".to_string());
389 assert!(config.is_disabled("deprecated_warning"));
390 assert!(!config.is_disabled("type_error"));
391 }
392
393 #[test]
394 fn test_config_merge() {
395 let base = ServerConfig {
396 logging: LoggingConfig {
397 level: "info".to_string(),
398 verbose: false,
399 log_file: None,
400 },
401 completion: CompletionConfig {
402 trigger_characters: vec!["[".to_string()],
403 },
404 diagnostics: DiagnosticsConfig {
405 disabled: HashSet::new(),
406 },
407 schema: SchemaConfig {
408 url: "https://default.com/schema.json".to_string(),
409 },
410 };
411
412 let override_config = ServerConfig {
413 logging: LoggingConfig {
414 level: "debug".to_string(),
415 verbose: true,
416 log_file: Some(PathBuf::from("/tmp/test.log")),
417 },
418 completion: CompletionConfig {
419 trigger_characters: vec!["[".to_string(), ".".to_string()],
420 },
421 diagnostics: DiagnosticsConfig {
422 disabled: {
423 let mut set = HashSet::new();
424 set.insert("deprecated_warning".to_string());
425 set
426 },
427 },
428 schema: SchemaConfig {
429 url: "https://custom.com/schema.json".to_string(),
430 },
431 };
432
433 let merged = base.merge(override_config);
434
435 assert_eq!(merged.logging.level, "debug");
436 assert!(merged.logging.verbose);
437 assert_eq!(
438 merged.logging.log_file,
439 Some(PathBuf::from("/tmp/test.log"))
440 );
441 assert_eq!(merged.completion.trigger_characters.len(), 2);
442 assert!(merged.diagnostics.is_disabled("deprecated_warning"));
443 assert_eq!(merged.schema.url, "https://custom.com/schema.json");
444 }
445
446 #[test]
447 fn test_env_overrides() {
448 let original_level = env::var("SPRING_LSP_LOG_LEVEL").ok();
450 let original_verbose = env::var("SPRING_LSP_VERBOSE").ok();
451 let original_schema = env::var("SPRING_LSP_SCHEMA_URL").ok();
452
453 env::set_var("SPRING_LSP_LOG_LEVEL", "trace");
455 env::set_var("SPRING_LSP_VERBOSE", "true");
456 env::set_var("SPRING_LSP_SCHEMA_URL", "https://test.com/schema.json");
457
458 let config = ServerConfig::default().apply_env_overrides();
459
460 assert_eq!(config.logging.level, "trace");
461 assert!(config.logging.verbose);
462 assert_eq!(config.schema.url, "https://test.com/schema.json");
463
464 match original_level {
466 Some(v) => env::set_var("SPRING_LSP_LOG_LEVEL", v),
467 None => env::remove_var("SPRING_LSP_LOG_LEVEL"),
468 }
469 match original_verbose {
470 Some(v) => env::set_var("SPRING_LSP_VERBOSE", v),
471 None => env::remove_var("SPRING_LSP_VERBOSE"),
472 }
473 match original_schema {
474 Some(v) => env::set_var("SPRING_LSP_SCHEMA_URL", v),
475 None => env::remove_var("SPRING_LSP_SCHEMA_URL"),
476 }
477 }
478
479 #[test]
480 fn test_load_from_toml() {
481 let toml_content = r#"
482[logging]
483level = "debug"
484verbose = true
485log_file = "/tmp/spring-lsp.log"
486
487[completion]
488trigger_characters = ["[", ".", "$"]
489
490[diagnostics]
491disabled = ["deprecated_warning", "restful_style"]
492
493[schema]
494url = "https://custom.com/schema.json"
495"#;
496
497 let config: ServerConfig = toml::from_str(toml_content).unwrap();
498
499 assert_eq!(config.logging.level, "debug");
500 assert!(config.logging.verbose);
501 assert_eq!(
502 config.logging.log_file,
503 Some(PathBuf::from("/tmp/spring-lsp.log"))
504 );
505 assert_eq!(config.completion.trigger_characters.len(), 3);
506 assert!(config.diagnostics.is_disabled("deprecated_warning"));
507 assert!(config.diagnostics.is_disabled("restful_style"));
508 assert_eq!(config.schema.url, "https://custom.com/schema.json");
509 }
510
511 #[test]
512 fn test_partial_toml_config() {
513 let toml_content = r#"
515[logging]
516level = "warn"
517
518[schema]
519url = "file:///local/schema.json"
520"#;
521
522 let config: ServerConfig = toml::from_str(toml_content).unwrap();
523
524 assert_eq!(config.logging.level, "warn");
525 assert!(!config.logging.verbose); assert_eq!(config.completion.trigger_characters.len(), 6); assert!(config.diagnostics.disabled.is_empty()); assert_eq!(config.schema.url, "file:///local/schema.json");
529 }
530
531 #[test]
532 fn test_config_validation() {
533 let valid_config = ServerConfig::default();
534 assert!(valid_config.validate().is_ok());
535
536 let invalid_config = ServerConfig {
537 logging: LoggingConfig {
538 level: "invalid".to_string(),
539 verbose: false,
540 log_file: None,
541 },
542 ..Default::default()
543 };
544 assert!(invalid_config.validate().is_err());
545 }
546}