use glob::Pattern;
use serde::Deserialize;
use std::path::Path;
use tracing::{debug, warn};
#[derive(Debug, Clone, Default)]
pub struct Config {
pub exclude: Vec<Pattern>,
pub disabled_diagnostics: Vec<String>,
#[allow(dead_code)] pub fixture_paths: Vec<String>,
#[allow(dead_code)] pub skip_plugins: Vec<String>,
}
#[derive(Debug, Deserialize, Default)]
struct RawConfig {
#[serde(default)]
exclude: Vec<String>,
#[serde(default)]
disabled_diagnostics: Vec<String>,
#[serde(default)]
fixture_paths: Vec<String>,
#[serde(default)]
skip_plugins: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct PyProjectToml {
tool: Option<Tool>,
}
#[derive(Debug, Deserialize)]
struct Tool {
#[serde(rename = "pytest-language-server")]
pytest_language_server: Option<RawConfig>,
}
impl Config {
pub fn load(workspace_root: &Path) -> Self {
let pyproject_path = workspace_root.join("pyproject.toml");
if !pyproject_path.exists() {
debug!(
"No pyproject.toml found at {:?}, using defaults",
pyproject_path
);
return Self::default();
}
match std::fs::read_to_string(&pyproject_path) {
Ok(content) => Self::parse(&content, &pyproject_path),
Err(e) => {
warn!("Failed to read pyproject.toml: {}", e);
Self::default()
}
}
}
fn parse(content: &str, path: &Path) -> Self {
let parsed: PyProjectToml = match toml::from_str(content) {
Ok(p) => p,
Err(e) => {
warn!("Failed to parse pyproject.toml at {:?}: {}", path, e);
return Self::default();
}
};
let raw = parsed
.tool
.and_then(|t| t.pytest_language_server)
.unwrap_or_default();
Self::from_raw(raw, path)
}
fn from_raw(raw: RawConfig, path: &Path) -> Self {
let exclude: Vec<Pattern> = raw
.exclude
.into_iter()
.filter_map(|pattern| match Pattern::new(&pattern) {
Ok(p) => Some(p),
Err(e) => {
warn!("Invalid exclude pattern '{}' in {:?}: {}", pattern, path, e);
None
}
})
.collect();
let valid_diagnostics = [
"undeclared-fixture",
"scope-mismatch",
"circular-dependency",
];
let disabled_diagnostics: Vec<String> = raw
.disabled_diagnostics
.into_iter()
.filter(|code| {
if valid_diagnostics.contains(&code.as_str()) {
true
} else {
warn!(
"Unknown diagnostic code '{}' in {:?}, valid codes are: {:?}",
code, path, valid_diagnostics
);
false
}
})
.collect();
debug!(
"Loaded config from {:?}: {} exclude patterns, {} disabled diagnostics",
path,
exclude.len(),
disabled_diagnostics.len()
);
Self {
exclude,
disabled_diagnostics,
fixture_paths: raw.fixture_paths,
skip_plugins: raw.skip_plugins,
}
}
pub fn is_diagnostic_disabled(&self, code: &str) -> bool {
self.disabled_diagnostics.iter().any(|d| d == code)
}
#[allow(dead_code)] pub fn should_exclude(&self, path: &Path) -> bool {
let path_str = path.to_string_lossy();
self.exclude
.iter()
.any(|pattern| pattern.matches(&path_str))
}
#[allow(dead_code)] pub fn should_skip_plugin(&self, plugin_name: &str) -> bool {
self.skip_plugins.iter().any(|p| p == plugin_name)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_empty_config() {
let content = r#"
[project]
name = "myproject"
"#;
let config = Config::parse(content, Path::new("pyproject.toml"));
assert!(config.exclude.is_empty());
assert!(config.disabled_diagnostics.is_empty());
assert!(config.fixture_paths.is_empty());
assert!(config.skip_plugins.is_empty());
}
#[test]
fn test_parse_full_config() {
let content = r#"
[project]
name = "myproject"
[tool.pytest-language-server]
exclude = ["build", "dist/**", ".tox"]
disabled_diagnostics = ["undeclared-fixture"]
fixture_paths = ["fixtures/", "shared/fixtures/"]
skip_plugins = ["pytest-xdist"]
"#;
let config = Config::parse(content, Path::new("pyproject.toml"));
assert_eq!(config.exclude.len(), 3);
assert_eq!(config.disabled_diagnostics, vec!["undeclared-fixture"]);
assert_eq!(config.fixture_paths, vec!["fixtures/", "shared/fixtures/"]);
assert_eq!(config.skip_plugins, vec!["pytest-xdist"]);
}
#[test]
fn test_parse_partial_config() {
let content = r#"
[tool.pytest-language-server]
exclude = ["build"]
"#;
let config = Config::parse(content, Path::new("pyproject.toml"));
assert_eq!(config.exclude.len(), 1);
assert!(config.disabled_diagnostics.is_empty());
}
#[test]
fn test_invalid_glob_pattern_skipped() {
let content = r#"
[tool.pytest-language-server]
exclude = ["valid", "[invalid", "also_valid"]
"#;
let config = Config::parse(content, Path::new("pyproject.toml"));
assert_eq!(config.exclude.len(), 2);
}
#[test]
fn test_invalid_diagnostic_code_skipped() {
let content = r#"
[tool.pytest-language-server]
disabled_diagnostics = ["undeclared-fixture", "invalid-code", "scope-mismatch"]
"#;
let config = Config::parse(content, Path::new("pyproject.toml"));
assert_eq!(config.disabled_diagnostics.len(), 2);
assert!(config
.disabled_diagnostics
.contains(&"undeclared-fixture".to_string()));
assert!(config
.disabled_diagnostics
.contains(&"scope-mismatch".to_string()));
}
#[test]
fn test_is_diagnostic_disabled() {
let content = r#"
[tool.pytest-language-server]
disabled_diagnostics = ["undeclared-fixture"]
"#;
let config = Config::parse(content, Path::new("pyproject.toml"));
assert!(config.is_diagnostic_disabled("undeclared-fixture"));
assert!(!config.is_diagnostic_disabled("scope-mismatch"));
}
#[test]
fn test_should_exclude() {
let content = r#"
[tool.pytest-language-server]
exclude = ["build/**", "dist"]
"#;
let config = Config::parse(content, Path::new("pyproject.toml"));
assert!(config.should_exclude(Path::new("build/output/file.py")));
assert!(config.should_exclude(Path::new("dist")));
assert!(!config.should_exclude(Path::new("src/main.py")));
}
#[test]
fn test_should_skip_plugin() {
let content = r#"
[tool.pytest-language-server]
skip_plugins = ["pytest-xdist", "pytest-cov"]
"#;
let config = Config::parse(content, Path::new("pyproject.toml"));
assert!(config.should_skip_plugin("pytest-xdist"));
assert!(config.should_skip_plugin("pytest-cov"));
assert!(!config.should_skip_plugin("pytest-mock"));
}
#[test]
fn test_invalid_toml_returns_default() {
let content = "this is not valid toml [[[";
let config = Config::parse(content, Path::new("pyproject.toml"));
assert!(config.exclude.is_empty());
assert!(config.disabled_diagnostics.is_empty());
}
#[test]
fn test_default_config() {
let config = Config::default();
assert!(config.exclude.is_empty());
assert!(config.disabled_diagnostics.is_empty());
assert!(config.fixture_paths.is_empty());
assert!(config.skip_plugins.is_empty());
}
}