Skip to main content

pytest_language_server/config/
mod.rs

1//! Configuration file support for pytest-language-server.
2//!
3//! Reads settings from `[tool.pytest-language-server]` section in `pyproject.toml`.
4
5use glob::Pattern;
6use serde::Deserialize;
7use std::path::Path;
8use tracing::{debug, warn};
9
10/// Configuration for pytest-language-server.
11#[derive(Debug, Clone, Default)]
12pub struct Config {
13    /// Glob patterns for directories/files to exclude from scanning.
14    pub exclude: Vec<Pattern>,
15
16    /// Diagnostic codes to disable (e.g., "undeclared-fixture", "scope-mismatch").
17    pub disabled_diagnostics: Vec<String>,
18
19    /// Additional directories to scan for fixtures (beyond conftest.py hierarchy).
20    #[allow(dead_code)] // Planned feature
21    pub fixture_paths: Vec<String>,
22
23    /// Third-party plugins to skip when scanning virtual environment.
24    #[allow(dead_code)] // Used in tests, venv scanning integration planned
25    pub skip_plugins: Vec<String>,
26}
27
28/// Raw configuration as parsed from TOML (before validation).
29#[derive(Debug, Deserialize, Default)]
30struct RawConfig {
31    #[serde(default)]
32    exclude: Vec<String>,
33
34    #[serde(default)]
35    disabled_diagnostics: Vec<String>,
36
37    #[serde(default)]
38    fixture_paths: Vec<String>,
39
40    #[serde(default)]
41    skip_plugins: Vec<String>,
42}
43
44/// Wrapper for the pyproject.toml structure.
45#[derive(Debug, Deserialize)]
46struct PyProjectToml {
47    tool: Option<Tool>,
48}
49
50#[derive(Debug, Deserialize)]
51struct Tool {
52    #[serde(rename = "pytest-language-server")]
53    pytest_language_server: Option<RawConfig>,
54}
55
56impl Config {
57    /// Load configuration from pyproject.toml in the given workspace root.
58    /// Returns default configuration if file doesn't exist or has errors.
59    pub fn load(workspace_root: &Path) -> Self {
60        let pyproject_path = workspace_root.join("pyproject.toml");
61
62        if !pyproject_path.exists() {
63            debug!(
64                "No pyproject.toml found at {:?}, using defaults",
65                pyproject_path
66            );
67            return Self::default();
68        }
69
70        match std::fs::read_to_string(&pyproject_path) {
71            Ok(content) => Self::parse(&content, &pyproject_path),
72            Err(e) => {
73                warn!("Failed to read pyproject.toml: {}", e);
74                Self::default()
75            }
76        }
77    }
78
79    /// Parse configuration from TOML content.
80    fn parse(content: &str, path: &Path) -> Self {
81        let parsed: PyProjectToml = match toml::from_str(content) {
82            Ok(p) => p,
83            Err(e) => {
84                warn!("Failed to parse pyproject.toml at {:?}: {}", path, e);
85                return Self::default();
86            }
87        };
88
89        let raw = parsed
90            .tool
91            .and_then(|t| t.pytest_language_server)
92            .unwrap_or_default();
93
94        Self::from_raw(raw, path)
95    }
96
97    /// Convert raw config to validated config.
98    fn from_raw(raw: RawConfig, path: &Path) -> Self {
99        // Parse exclude patterns, warning on invalid ones
100        let exclude: Vec<Pattern> = raw
101            .exclude
102            .into_iter()
103            .filter_map(|pattern| match Pattern::new(&pattern) {
104                Ok(p) => Some(p),
105                Err(e) => {
106                    warn!("Invalid exclude pattern '{}' in {:?}: {}", pattern, path, e);
107                    None
108                }
109            })
110            .collect();
111
112        // Validate diagnostic codes
113        let valid_diagnostics = [
114            "undeclared-fixture",
115            "scope-mismatch",
116            "circular-dependency",
117        ];
118        let disabled_diagnostics: Vec<String> = raw
119            .disabled_diagnostics
120            .into_iter()
121            .filter(|code| {
122                if valid_diagnostics.contains(&code.as_str()) {
123                    true
124                } else {
125                    warn!(
126                        "Unknown diagnostic code '{}' in {:?}, valid codes are: {:?}",
127                        code, path, valid_diagnostics
128                    );
129                    false
130                }
131            })
132            .collect();
133
134        debug!(
135            "Loaded config from {:?}: {} exclude patterns, {} disabled diagnostics",
136            path,
137            exclude.len(),
138            disabled_diagnostics.len()
139        );
140
141        Self {
142            exclude,
143            disabled_diagnostics,
144            fixture_paths: raw.fixture_paths,
145            skip_plugins: raw.skip_plugins,
146        }
147    }
148
149    /// Check if a diagnostic code is disabled.
150    pub fn is_diagnostic_disabled(&self, code: &str) -> bool {
151        self.disabled_diagnostics.iter().any(|d| d == code)
152    }
153
154    /// Check if a path should be excluded from scanning.
155    #[allow(dead_code)] // Used in tests and will be used for file-level exclusion
156    pub fn should_exclude(&self, path: &Path) -> bool {
157        let path_str = path.to_string_lossy();
158        self.exclude
159            .iter()
160            .any(|pattern| pattern.matches(&path_str))
161    }
162
163    /// Check if a plugin should be skipped when scanning venv.
164    #[allow(dead_code)] // Used in tests, venv scanning integration planned
165    pub fn should_skip_plugin(&self, plugin_name: &str) -> bool {
166        self.skip_plugins.iter().any(|p| p == plugin_name)
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    #[test]
175    fn test_parse_empty_config() {
176        let content = r#"
177[project]
178name = "myproject"
179"#;
180        let config = Config::parse(content, Path::new("pyproject.toml"));
181        assert!(config.exclude.is_empty());
182        assert!(config.disabled_diagnostics.is_empty());
183        assert!(config.fixture_paths.is_empty());
184        assert!(config.skip_plugins.is_empty());
185    }
186
187    #[test]
188    fn test_parse_full_config() {
189        let content = r#"
190[project]
191name = "myproject"
192
193[tool.pytest-language-server]
194exclude = ["build", "dist/**", ".tox"]
195disabled_diagnostics = ["undeclared-fixture"]
196fixture_paths = ["fixtures/", "shared/fixtures/"]
197skip_plugins = ["pytest-xdist"]
198"#;
199        let config = Config::parse(content, Path::new("pyproject.toml"));
200        assert_eq!(config.exclude.len(), 3);
201        assert_eq!(config.disabled_diagnostics, vec!["undeclared-fixture"]);
202        assert_eq!(config.fixture_paths, vec!["fixtures/", "shared/fixtures/"]);
203        assert_eq!(config.skip_plugins, vec!["pytest-xdist"]);
204    }
205
206    #[test]
207    fn test_parse_partial_config() {
208        let content = r#"
209[tool.pytest-language-server]
210exclude = ["build"]
211"#;
212        let config = Config::parse(content, Path::new("pyproject.toml"));
213        assert_eq!(config.exclude.len(), 1);
214        assert!(config.disabled_diagnostics.is_empty());
215    }
216
217    #[test]
218    fn test_invalid_glob_pattern_skipped() {
219        let content = r#"
220[tool.pytest-language-server]
221exclude = ["valid", "[invalid", "also_valid"]
222"#;
223        let config = Config::parse(content, Path::new("pyproject.toml"));
224        // Invalid pattern "[invalid" should be skipped
225        assert_eq!(config.exclude.len(), 2);
226    }
227
228    #[test]
229    fn test_invalid_diagnostic_code_skipped() {
230        let content = r#"
231[tool.pytest-language-server]
232disabled_diagnostics = ["undeclared-fixture", "invalid-code", "scope-mismatch"]
233"#;
234        let config = Config::parse(content, Path::new("pyproject.toml"));
235        // "invalid-code" should be filtered out
236        assert_eq!(config.disabled_diagnostics.len(), 2);
237        assert!(config
238            .disabled_diagnostics
239            .contains(&"undeclared-fixture".to_string()));
240        assert!(config
241            .disabled_diagnostics
242            .contains(&"scope-mismatch".to_string()));
243    }
244
245    #[test]
246    fn test_is_diagnostic_disabled() {
247        let content = r#"
248[tool.pytest-language-server]
249disabled_diagnostics = ["undeclared-fixture"]
250"#;
251        let config = Config::parse(content, Path::new("pyproject.toml"));
252        assert!(config.is_diagnostic_disabled("undeclared-fixture"));
253        assert!(!config.is_diagnostic_disabled("scope-mismatch"));
254    }
255
256    #[test]
257    fn test_should_exclude() {
258        let content = r#"
259[tool.pytest-language-server]
260exclude = ["build/**", "dist"]
261"#;
262        let config = Config::parse(content, Path::new("pyproject.toml"));
263        assert!(config.should_exclude(Path::new("build/output/file.py")));
264        assert!(config.should_exclude(Path::new("dist")));
265        assert!(!config.should_exclude(Path::new("src/main.py")));
266    }
267
268    #[test]
269    fn test_should_skip_plugin() {
270        let content = r#"
271[tool.pytest-language-server]
272skip_plugins = ["pytest-xdist", "pytest-cov"]
273"#;
274        let config = Config::parse(content, Path::new("pyproject.toml"));
275        assert!(config.should_skip_plugin("pytest-xdist"));
276        assert!(config.should_skip_plugin("pytest-cov"));
277        assert!(!config.should_skip_plugin("pytest-mock"));
278    }
279
280    #[test]
281    fn test_invalid_toml_returns_default() {
282        let content = "this is not valid toml [[[";
283        let config = Config::parse(content, Path::new("pyproject.toml"));
284        assert!(config.exclude.is_empty());
285        assert!(config.disabled_diagnostics.is_empty());
286    }
287
288    #[test]
289    fn test_default_config() {
290        let config = Config::default();
291        assert!(config.exclude.is_empty());
292        assert!(config.disabled_diagnostics.is_empty());
293        assert!(config.fixture_paths.is_empty());
294        assert!(config.skip_plugins.is_empty());
295    }
296}