use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct ServerConfig {
pub logging: LoggingConfig,
pub completion: CompletionConfig,
pub diagnostics: DiagnosticsConfig,
pub schema: SchemaConfig,
}
impl ServerConfig {
pub fn load(workspace_root: Option<&Path>) -> Self {
let mut config = Self::default();
if let Some(user_config_path) = Self::user_config_path() {
if let Ok(user_config) = Self::load_from_file(&user_config_path) {
config = config.merge(user_config);
}
}
if let Some(workspace_root) = workspace_root {
let workspace_config_path = workspace_root.join(".summer-lsp.toml");
if let Ok(workspace_config) = Self::load_from_file(&workspace_config_path) {
config = config.merge(workspace_config);
}
}
config = config.apply_env_overrides();
config
}
fn load_from_file(path: &Path) -> Result<Self, Box<dyn std::error::Error>> {
let content = fs::read_to_string(path)?;
let config: Self = toml::from_str(&content)?;
tracing::debug!("Loaded configuration from: {}", path.display());
Ok(config)
}
fn user_config_path() -> Option<PathBuf> {
dirs::config_dir().map(|dir| dir.join("summer-lsp").join("config.toml"))
}
pub fn merge(mut self, other: Self) -> Self {
self.logging = self.logging.merge(other.logging);
self.completion = self.completion.merge(other.completion);
self.diagnostics = self.diagnostics.merge(other.diagnostics);
self.schema = self.schema.merge(other.schema);
self
}
fn apply_env_overrides(mut self) -> Self {
self.logging = self.logging.apply_env_overrides();
self.schema = self.schema.apply_env_overrides();
self
}
pub fn validate(&self) -> Result<(), String> {
self.logging.validate()?;
self.completion.validate()?;
self.schema.validate()?;
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct LoggingConfig {
pub level: String,
pub verbose: bool,
pub log_file: Option<PathBuf>,
}
impl Default for LoggingConfig {
fn default() -> Self {
Self {
level: "info".to_string(),
verbose: false,
log_file: None,
}
}
}
impl LoggingConfig {
pub fn merge(self, other: Self) -> Self {
Self {
level: other.level,
verbose: other.verbose,
log_file: other.log_file.or(self.log_file),
}
}
fn apply_env_overrides(mut self) -> Self {
if let Ok(level) = env::var("SUMMER_LSP_LOG_LEVEL") {
self.level = level.to_lowercase();
}
if let Ok(verbose) = env::var("SUMMER_LSP_VERBOSE") {
self.verbose = verbose == "1" || verbose.to_lowercase() == "true";
}
if let Ok(log_file) = env::var("SUMMER_LSP_LOG_FILE") {
self.log_file = Some(PathBuf::from(log_file));
}
self
}
pub fn validate(&self) -> Result<(), String> {
match self.level.as_str() {
"trace" | "debug" | "info" | "warn" | "error" => Ok(()),
_ => Err(format!(
"Invalid log level: {}. Valid levels are: trace, debug, info, warn, error",
self.level
)),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct CompletionConfig {
pub trigger_characters: Vec<String>,
}
impl Default for CompletionConfig {
fn default() -> Self {
Self {
trigger_characters: vec![
"[".to_string(), ".".to_string(), "$".to_string(), "{".to_string(), "#".to_string(), "(".to_string(), ],
}
}
}
impl CompletionConfig {
pub fn merge(self, other: Self) -> Self {
Self {
trigger_characters: if other.trigger_characters.is_empty() {
self.trigger_characters
} else {
other.trigger_characters
},
}
}
pub fn validate(&self) -> Result<(), String> {
if self.trigger_characters.is_empty() {
return Err("Trigger characters list cannot be empty".to_string());
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct DiagnosticsConfig {
pub disabled: HashSet<String>,
}
impl DiagnosticsConfig {
pub fn merge(self, other: Self) -> Self {
Self {
disabled: if other.disabled.is_empty() {
self.disabled
} else {
other.disabled
},
}
}
pub fn is_disabled(&self, diagnostic_type: &str) -> bool {
self.disabled.contains(diagnostic_type)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct SchemaConfig {
pub url: String,
}
impl Default for SchemaConfig {
fn default() -> Self {
Self {
url: "https://summer-rs.github.io/config-schema.json".to_string(),
}
}
}
impl SchemaConfig {
pub fn merge(self, other: Self) -> Self {
Self { url: other.url }
}
fn apply_env_overrides(mut self) -> Self {
if let Ok(url) = env::var("SUMMER_LSP_SCHEMA_URL") {
self.url = url;
}
self
}
pub fn validate(&self) -> Result<(), String> {
if self.url.is_empty() {
return Err("Schema URL cannot be empty".to_string());
}
if !self.url.starts_with("http://")
&& !self.url.starts_with("https://")
&& !self.url.starts_with("file://")
{
return Err(format!(
"Invalid Schema URL: {}. Must start with http://, https://, or file://",
self.url
));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::env;
#[test]
fn test_default_config() {
let config = ServerConfig::default();
assert_eq!(config.logging.level, "info");
assert!(!config.logging.verbose);
assert!(config.logging.log_file.is_none());
assert_eq!(config.completion.trigger_characters.len(), 6);
assert!(config.diagnostics.disabled.is_empty());
assert_eq!(
config.schema.url,
"https://summer-rs.github.io/config-schema.json"
);
}
#[test]
fn test_logging_config_validation() {
let valid_config = LoggingConfig {
level: "debug".to_string(),
verbose: false,
log_file: None,
};
assert!(valid_config.validate().is_ok());
let invalid_config = LoggingConfig {
level: "invalid".to_string(),
verbose: false,
log_file: None,
};
assert!(invalid_config.validate().is_err());
}
#[test]
fn test_completion_config_validation() {
let valid_config = CompletionConfig {
trigger_characters: vec!["[".to_string()],
};
assert!(valid_config.validate().is_ok());
let invalid_config = CompletionConfig {
trigger_characters: vec![],
};
assert!(invalid_config.validate().is_err());
}
#[test]
fn test_schema_config_validation() {
let valid_http = SchemaConfig {
url: "https://example.com/schema.json".to_string(),
};
assert!(valid_http.validate().is_ok());
let valid_file = SchemaConfig {
url: "file:///path/to/schema.json".to_string(),
};
assert!(valid_file.validate().is_ok());
let invalid_empty = SchemaConfig {
url: "".to_string(),
};
assert!(invalid_empty.validate().is_err());
let invalid_protocol = SchemaConfig {
url: "ftp://example.com/schema.json".to_string(),
};
assert!(invalid_protocol.validate().is_err());
}
#[test]
fn test_diagnostics_is_disabled() {
let mut config = DiagnosticsConfig::default();
assert!(!config.is_disabled("deprecated_warning"));
config.disabled.insert("deprecated_warning".to_string());
assert!(config.is_disabled("deprecated_warning"));
assert!(!config.is_disabled("type_error"));
}
#[test]
fn test_config_merge() {
let base = ServerConfig {
logging: LoggingConfig {
level: "info".to_string(),
verbose: false,
log_file: None,
},
completion: CompletionConfig {
trigger_characters: vec!["[".to_string()],
},
diagnostics: DiagnosticsConfig {
disabled: HashSet::new(),
},
schema: SchemaConfig {
url: "https://default.com/schema.json".to_string(),
},
};
let override_config = ServerConfig {
logging: LoggingConfig {
level: "debug".to_string(),
verbose: true,
log_file: Some(PathBuf::from("/tmp/test.log")),
},
completion: CompletionConfig {
trigger_characters: vec!["[".to_string(), ".".to_string()],
},
diagnostics: DiagnosticsConfig {
disabled: {
let mut set = HashSet::new();
set.insert("deprecated_warning".to_string());
set
},
},
schema: SchemaConfig {
url: "https://custom.com/schema.json".to_string(),
},
};
let merged = base.merge(override_config);
assert_eq!(merged.logging.level, "debug");
assert!(merged.logging.verbose);
assert_eq!(
merged.logging.log_file,
Some(PathBuf::from("/tmp/test.log"))
);
assert_eq!(merged.completion.trigger_characters.len(), 2);
assert!(merged.diagnostics.is_disabled("deprecated_warning"));
assert_eq!(merged.schema.url, "https://custom.com/schema.json");
}
#[test]
fn test_env_overrides() {
let original_level = env::var("SUMMER_LSP_LOG_LEVEL").ok();
let original_verbose = env::var("SUMMER_LSP_VERBOSE").ok();
let original_schema = env::var("SUMMER_LSP_SCHEMA_URL").ok();
env::set_var("SUMMER_LSP_LOG_LEVEL", "trace");
env::set_var("SUMMER_LSP_VERBOSE", "true");
env::set_var("SUMMER_LSP_SCHEMA_URL", "https://test.com/schema.json");
let config = ServerConfig::default().apply_env_overrides();
assert_eq!(config.logging.level, "trace");
assert!(config.logging.verbose);
assert_eq!(config.schema.url, "https://test.com/schema.json");
match original_level {
Some(v) => env::set_var("SUMMER_LSP_LOG_LEVEL", v),
None => env::remove_var("SUMMER_LSP_LOG_LEVEL"),
}
match original_verbose {
Some(v) => env::set_var("SUMMER_LSP_VERBOSE", v),
None => env::remove_var("SUMMER_LSP_VERBOSE"),
}
match original_schema {
Some(v) => env::set_var("SUMMER_LSP_SCHEMA_URL", v),
None => env::remove_var("SUMMER_LSP_SCHEMA_URL"),
}
}
#[test]
fn test_load_from_toml() {
let toml_content = r#"
[logging]
level = "debug"
verbose = true
log_file = "/tmp/summer-lsp.log"
[completion]
trigger_characters = ["[", ".", "$"]
[diagnostics]
disabled = ["deprecated_warning", "restful_style"]
[schema]
url = "https://custom.com/schema.json"
"#;
let config: ServerConfig = toml::from_str(toml_content).unwrap();
assert_eq!(config.logging.level, "debug");
assert!(config.logging.verbose);
assert_eq!(
config.logging.log_file,
Some(PathBuf::from("/tmp/summer-lsp.log"))
);
assert_eq!(config.completion.trigger_characters.len(), 3);
assert!(config.diagnostics.is_disabled("deprecated_warning"));
assert!(config.diagnostics.is_disabled("restful_style"));
assert_eq!(config.schema.url, "https://custom.com/schema.json");
}
#[test]
fn test_partial_toml_config() {
let toml_content = r#"
[logging]
level = "warn"
[schema]
url = "file:///local/schema.json"
"#;
let config: ServerConfig = toml::from_str(toml_content).unwrap();
assert_eq!(config.logging.level, "warn");
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");
}
#[test]
fn test_config_validation() {
let valid_config = ServerConfig::default();
assert!(valid_config.validate().is_ok());
let invalid_config = ServerConfig {
logging: LoggingConfig {
level: "invalid".to_string(),
verbose: false,
log_file: None,
},
..Default::default()
};
assert!(invalid_config.validate().is_err());
}
}