Skip to main content

mcpls_core/config/
mod.rs

1//! Configuration types and loading.
2//!
3//! This module provides configuration structures for MCPLS,
4//! including LSP server definitions and workspace settings.
5
6mod language;
7mod routing;
8mod server;
9
10use std::collections::{HashMap, HashSet};
11use std::path::{Path, PathBuf};
12
13pub use language::{base_language_id, react_variant_language_id};
14pub use routing::{ServerId, ToolKind, ToolRouter};
15use serde::{Deserialize, Serialize};
16pub use server::{DEFAULT_HEURISTICS_MAX_DEPTH, LspServerConfig, ServerHeuristics};
17
18use crate::error::{Error, Result};
19
20/// Maps file extensions to LSP language identifiers.
21///
22/// Used to detect the language ID for files based on their extension.
23/// Extensions are mapped to language IDs like "rust", "python", "cpp", etc.
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct LanguageExtensionMapping {
26    /// Array of extensions and their corresponding language ID.
27    pub extensions: Vec<String>,
28    /// Language ID to report to the LSP server.
29    pub language_id: String,
30}
31
32/// Main configuration for the MCPLS server.
33#[derive(Debug, Clone, Serialize, Deserialize)]
34#[serde(deny_unknown_fields)]
35pub struct ServerConfig {
36    /// Workspace configuration.
37    #[serde(default)]
38    pub workspace: WorkspaceConfig,
39
40    /// LSP server configurations.
41    #[serde(default)]
42    pub lsp_servers: Vec<LspServerConfig>,
43}
44
45/// Workspace-level configuration.
46#[derive(Debug, Clone, Serialize, Deserialize)]
47#[serde(deny_unknown_fields)]
48pub struct WorkspaceConfig {
49    /// Root directories for the workspace.
50    #[serde(default)]
51    pub roots: Vec<PathBuf>,
52
53    /// Position encoding preference order.
54    /// Valid values: "utf-8", "utf-16", "utf-32"
55    #[serde(default = "default_position_encodings")]
56    pub position_encodings: Vec<String>,
57
58    /// File extension to language ID mappings.
59    /// Allows users to customize which file extensions map to which language servers.
60    #[serde(default)]
61    pub language_extensions: Vec<LanguageExtensionMapping>,
62
63    /// Maximum depth for recursive project marker search.
64    /// Controls how deeply nested projects can be detected.
65    /// Default: 10
66    #[serde(default = "default_heuristics_max_depth")]
67    pub heuristics_max_depth: usize,
68}
69
70impl Default for WorkspaceConfig {
71    fn default() -> Self {
72        Self {
73            roots: Vec::new(),
74            position_encodings: default_position_encodings(),
75            language_extensions: default_language_extensions(),
76            heuristics_max_depth: default_heuristics_max_depth(),
77        }
78    }
79}
80
81const fn default_heuristics_max_depth() -> usize {
82    DEFAULT_HEURISTICS_MAX_DEPTH
83}
84
85impl WorkspaceConfig {
86    /// Build a map of file extensions to language IDs from the configuration.
87    ///
88    /// # Returns
89    ///
90    /// A `HashMap` where keys are file extensions (without the dot) and values
91    /// are the corresponding language IDs to report to LSP servers.
92    #[must_use]
93    pub fn build_extension_map(&self) -> HashMap<String, String> {
94        let mut map = HashMap::new();
95        for mapping in &self.language_extensions {
96            for ext in &mapping.extensions {
97                map.insert(ext.clone(), mapping.language_id.clone());
98            }
99        }
100        map
101    }
102
103    /// Get the language ID for a file extension.
104    ///
105    /// # Arguments
106    ///
107    /// * `extension` - The file extension (without the dot)
108    ///
109    /// # Returns
110    ///
111    /// The language ID if found, `None` otherwise.
112    #[must_use]
113    pub fn get_language_for_extension(&self, extension: &str) -> Option<String> {
114        for mapping in &self.language_extensions {
115            if mapping.extensions.contains(&extension.to_string()) {
116                return Some(mapping.language_id.clone());
117            }
118        }
119        None
120    }
121}
122
123/// Extract a file extension from a glob-like file pattern.
124///
125/// Supports common patterns such as `**/*.rs` and `*.h`.
126/// Returns `None` for patterns without a simple trailing extension.
127fn extract_extension_from_pattern(pattern: &str) -> Option<String> {
128    let basename = pattern.rsplit('/').next().unwrap_or(pattern);
129    if basename.starts_with('.') {
130        return None;
131    }
132
133    let (_, ext) = basename.rsplit_once('.')?;
134    if ext.is_empty() {
135        return None;
136    }
137
138    // Keep this conservative: only accept plain extension-like tokens.
139    if ext
140        .chars()
141        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
142    {
143        Some(ext.to_string())
144    } else {
145        None
146    }
147}
148
149fn language_id_for_pattern_extension(server_language_id: &str, extension: &str) -> String {
150    react_variant_language_id(server_language_id, extension)
151        .unwrap_or(server_language_id)
152        .to_string()
153}
154
155fn default_position_encodings() -> Vec<String> {
156    vec!["utf-8".to_string(), "utf-16".to_string()]
157}
158
159/// Build default language extension mappings.
160///
161/// Returns all built-in language extensions that MCPLS recognizes by default.
162/// These mappings are used when no custom configuration is provided.
163#[allow(clippy::too_many_lines)]
164fn default_language_extensions() -> Vec<LanguageExtensionMapping> {
165    vec![
166        LanguageExtensionMapping {
167            extensions: vec!["rs".to_string()],
168            language_id: "rust".to_string(),
169        },
170        LanguageExtensionMapping {
171            extensions: vec!["py".to_string(), "pyw".to_string(), "pyi".to_string()],
172            language_id: "python".to_string(),
173        },
174        LanguageExtensionMapping {
175            extensions: vec!["js".to_string(), "mjs".to_string(), "cjs".to_string()],
176            language_id: "javascript".to_string(),
177        },
178        LanguageExtensionMapping {
179            extensions: vec!["ts".to_string(), "mts".to_string(), "cts".to_string()],
180            language_id: "typescript".to_string(),
181        },
182        LanguageExtensionMapping {
183            extensions: vec!["tsx".to_string()],
184            language_id: "typescriptreact".to_string(),
185        },
186        LanguageExtensionMapping {
187            extensions: vec!["jsx".to_string()],
188            language_id: "javascriptreact".to_string(),
189        },
190        LanguageExtensionMapping {
191            extensions: vec!["go".to_string()],
192            language_id: "go".to_string(),
193        },
194        LanguageExtensionMapping {
195            extensions: vec!["c".to_string(), "h".to_string()],
196            language_id: "c".to_string(),
197        },
198        LanguageExtensionMapping {
199            extensions: vec![
200                "cpp".to_string(),
201                "cc".to_string(),
202                "cxx".to_string(),
203                "hpp".to_string(),
204                "hh".to_string(),
205                "hxx".to_string(),
206            ],
207            language_id: "cpp".to_string(),
208        },
209        LanguageExtensionMapping {
210            extensions: vec!["java".to_string()],
211            language_id: "java".to_string(),
212        },
213        LanguageExtensionMapping {
214            extensions: vec!["rb".to_string()],
215            language_id: "ruby".to_string(),
216        },
217        LanguageExtensionMapping {
218            extensions: vec!["php".to_string()],
219            language_id: "php".to_string(),
220        },
221        LanguageExtensionMapping {
222            extensions: vec!["swift".to_string()],
223            language_id: "swift".to_string(),
224        },
225        LanguageExtensionMapping {
226            extensions: vec!["kt".to_string(), "kts".to_string()],
227            language_id: "kotlin".to_string(),
228        },
229        LanguageExtensionMapping {
230            extensions: vec!["scala".to_string(), "sc".to_string()],
231            language_id: "scala".to_string(),
232        },
233        LanguageExtensionMapping {
234            extensions: vec!["zig".to_string()],
235            language_id: "zig".to_string(),
236        },
237        LanguageExtensionMapping {
238            extensions: vec!["lua".to_string()],
239            language_id: "lua".to_string(),
240        },
241        LanguageExtensionMapping {
242            extensions: vec!["sh".to_string(), "bash".to_string(), "zsh".to_string()],
243            language_id: "shellscript".to_string(),
244        },
245        LanguageExtensionMapping {
246            extensions: vec!["json".to_string()],
247            language_id: "json".to_string(),
248        },
249        LanguageExtensionMapping {
250            extensions: vec!["toml".to_string()],
251            language_id: "toml".to_string(),
252        },
253        LanguageExtensionMapping {
254            extensions: vec!["yaml".to_string(), "yml".to_string()],
255            language_id: "yaml".to_string(),
256        },
257        LanguageExtensionMapping {
258            extensions: vec!["xml".to_string()],
259            language_id: "xml".to_string(),
260        },
261        LanguageExtensionMapping {
262            extensions: vec!["html".to_string(), "htm".to_string()],
263            language_id: "html".to_string(),
264        },
265        LanguageExtensionMapping {
266            extensions: vec!["css".to_string()],
267            language_id: "css".to_string(),
268        },
269        LanguageExtensionMapping {
270            extensions: vec!["scss".to_string()],
271            language_id: "scss".to_string(),
272        },
273        LanguageExtensionMapping {
274            extensions: vec!["less".to_string()],
275            language_id: "less".to_string(),
276        },
277        LanguageExtensionMapping {
278            extensions: vec!["md".to_string(), "markdown".to_string()],
279            language_id: "markdown".to_string(),
280        },
281        LanguageExtensionMapping {
282            extensions: vec!["cs".to_string()],
283            language_id: "csharp".to_string(),
284        },
285        LanguageExtensionMapping {
286            extensions: vec!["fs".to_string(), "fsi".to_string(), "fsx".to_string()],
287            language_id: "fsharp".to_string(),
288        },
289        LanguageExtensionMapping {
290            extensions: vec!["r".to_string(), "R".to_string()],
291            language_id: "r".to_string(),
292        },
293    ]
294}
295
296/// Trust level applied to a `./mcpls.toml` discovered relative to the
297/// process's current working directory.
298///
299/// A CWD-discovered project-local config is not the same trust tier as an
300/// explicit `--config`/`MCPLS_CONFIG` path: it can be planted by whoever
301/// controls the checked-out repository, and it controls the `command` and
302/// `args` mcpls spawns as well as `[workspace]` (which can redirect the
303/// spawn target via `roots` or drive a filesystem-walk `DoS` via
304/// `heuristics_max_depth`). [`ServerConfig::load`] treats it as
305/// [`Untrusted`](Self::Untrusted) by default; callers that want it honored
306/// must opt in via [`ServerConfig::load_with_trust`].
307///
308/// An explicitly passed `--config`/`MCPLS_CONFIG` path is unaffected by this
309/// enum and is always trusted: naming a path is itself the user's consent.
310#[derive(Debug, Clone, Copy, PartialEq, Eq)]
311pub enum ProjectConfigTrust {
312    /// Ignore a CWD-discovered `./mcpls.toml` entirely; fall through to the
313    /// global config tier or built-in defaults.
314    Untrusted,
315    /// Load a CWD-discovered `./mcpls.toml` normally.
316    Trusted,
317}
318
319impl ServerConfig {
320    /// Build the effective extension map used for language detection.
321    ///
322    /// Starts with workspace mappings and overlays mappings inferred from
323    /// configured LSP server `file_patterns`.
324    #[must_use]
325    pub fn build_effective_extension_map(&self) -> HashMap<String, String> {
326        let mut map = self.workspace.build_extension_map();
327
328        for server in &self.lsp_servers {
329            for pattern in &server.file_patterns {
330                if let Some(ext) = extract_extension_from_pattern(pattern) {
331                    let language_id = language_id_for_pattern_extension(&server.language_id, &ext);
332                    map.insert(ext, language_id);
333                }
334            }
335        }
336
337        map
338    }
339
340    /// Load configuration from the default path, treating a CWD-discovered
341    /// `./mcpls.toml` as untrusted.
342    ///
343    /// Default paths checked in order:
344    /// 1. `$MCPLS_CONFIG` environment variable (always trusted)
345    /// 2. `./mcpls.toml` (current directory) — **skipped**; see
346    ///    [`load_with_trust`](Self::load_with_trust) to opt in
347    /// 3. `~/.config/mcpls/mcpls.toml` (Linux/macOS)
348    /// 4. `%APPDATA%\mcpls\mcpls.toml` (Windows)
349    ///
350    /// If no configuration file exists, creates a default configuration file
351    /// in the user's config directory with all default language extensions.
352    ///
353    /// This is a thin wrapper around
354    /// [`load_with_trust(ProjectConfigTrust::Untrusted)`](Self::load_with_trust) —
355    /// the safe default for library callers that haven't made a trust
356    /// decision.
357    ///
358    /// # Errors
359    ///
360    /// Returns an error if parsing an existing config fails.
361    /// If config creation fails, returns default config with graceful degradation.
362    pub fn load() -> Result<Self> {
363        Self::load_with_trust(ProjectConfigTrust::Untrusted)
364    }
365
366    /// Load configuration from the default path, with explicit control over
367    /// whether a CWD-discovered `./mcpls.toml` is honored.
368    ///
369    /// Behaves like [`load`](Self::load), except a `./mcpls.toml` found in
370    /// the current directory is only loaded when `trust` is
371    /// [`ProjectConfigTrust::Trusted`]. When untrusted, the file is skipped
372    /// entirely (including its `[workspace]` section) and a warning is
373    /// logged naming the ignored path; discovery falls through to the
374    /// global config tier or built-in defaults, so project-marker
375    /// heuristics (e.g. `Cargo.toml` → rust-analyzer) still apply normally.
376    ///
377    /// `$MCPLS_CONFIG` and an explicit path are unaffected by `trust` and
378    /// are always loaded: naming a path is itself the user's consent.
379    ///
380    /// # Errors
381    ///
382    /// Returns an error if parsing an existing config fails.
383    /// If config creation fails, returns default config with graceful degradation.
384    pub fn load_with_trust(trust: ProjectConfigTrust) -> Result<Self> {
385        // This `$MCPLS_CONFIG` check is unreachable from the `mcpls` binary:
386        // `crates/mcpls-cli/src/args.rs` already binds `env = "MCPLS_CONFIG"`
387        // to `--config`, so the CLI resolves that variable before `load`/
388        // `load_with_trust` is ever called. It only fires for library
389        // callers that invoke this function directly without going through
390        // `Args`. The actual, CLI-enforced guarantee that `$MCPLS_CONFIG` is
391        // always trusted lives in `main.rs`'s `--config` branch, not here.
392        if let Ok(path) = std::env::var("MCPLS_CONFIG") {
393            return Self::load_from(Path::new(&path));
394        }
395
396        let local_config = PathBuf::from("mcpls.toml");
397        if local_config.exists() {
398            match trust {
399                ProjectConfigTrust::Trusted => return Self::load_from(&local_config),
400                ProjectConfigTrust::Untrusted => {
401                    let display_path = local_config.canonicalize().unwrap_or_else(|_| {
402                        std::env::current_dir()
403                            .map_or_else(|_| local_config.clone(), |cwd| cwd.join(&local_config))
404                    });
405                    tracing::warn!(
406                        "ignoring untrusted project-local config at {}; pass \
407                         --trust-project-config (or set MCPLS_TRUST_PROJECT_CONFIG=true) to \
408                         load it",
409                        display_path.display()
410                    );
411                }
412            }
413        }
414
415        if let Some(config_dir) = dirs::config_dir() {
416            let user_config = config_dir.join("mcpls").join("mcpls.toml");
417            if user_config.exists() {
418                return Self::load_from(&user_config);
419            }
420
421            // No config found - create default config file
422            if let Err(e) = Self::create_default_config_file(&user_config) {
423                tracing::warn!(
424                    "Failed to create default config at {}: {}. Using in-memory defaults.",
425                    user_config.display(),
426                    e
427                );
428            } else {
429                tracing::info!("Created default config at {}", user_config.display());
430            }
431        }
432
433        // Return default configuration
434        Ok(Self::default())
435    }
436
437    /// Load configuration from a specific path.
438    ///
439    /// # Errors
440    ///
441    /// Returns an error if the file doesn't exist or parsing fails.
442    pub fn load_from(path: &Path) -> Result<Self> {
443        let content = std::fs::read_to_string(path).map_err(|e| {
444            if e.kind() == std::io::ErrorKind::NotFound {
445                Error::ConfigNotFound(path.to_path_buf())
446            } else {
447                Error::Io(e)
448            }
449        })?;
450
451        let config: Self = toml::from_str(&content)?;
452        config.validate()?;
453        Ok(config)
454    }
455
456    /// Create a default configuration file with all built-in extensions.
457    ///
458    /// Creates the parent directory if it doesn't exist.
459    ///
460    /// # Errors
461    ///
462    /// Returns an error if directory or file creation fails.
463    fn create_default_config_file(path: &Path) -> Result<()> {
464        if let Some(parent) = path.parent() {
465            std::fs::create_dir_all(parent)?;
466        }
467
468        let default_config = Self::default();
469        let toml_content = toml::to_string_pretty(&default_config)?;
470        std::fs::write(path, toml_content)?;
471
472        Ok(())
473    }
474
475    /// Validate the configuration.
476    ///
477    /// This covers only workspace-*independent* rules — checks that hold
478    /// regardless of which servers end up applicable in a given workspace.
479    /// Workspace-scoped routing rules (duplicate `ServerId`, conflicting
480    /// `handles` claims across applicable servers) are enforced later, by
481    /// `ToolRouter::from_configs` over the post-heuristics config subset in
482    /// `serve_with` — see that function's module docs for why the split
483    /// exists (two servers for one language with mutually exclusive
484    /// `heuristics` is a legitimate config that must still load here).
485    fn validate(&self) -> Result<()> {
486        let mut seen_names: HashMap<&str, &str> = HashMap::new();
487        for server in &self.lsp_servers {
488            if server.language_id.is_empty() {
489                return Err(Error::InvalidConfig(
490                    "language_id cannot be empty".to_string(),
491                ));
492            }
493            if server.command.is_empty() {
494                return Err(Error::InvalidConfig(format!(
495                    "command cannot be empty for language '{}'",
496                    server.language_id
497                )));
498            }
499            if let Some(name) = &server.name {
500                if name.is_empty() {
501                    return Err(Error::InvalidConfig(format!(
502                        "name cannot be empty for language '{}' (omit `name` to default to \
503                         the language id)",
504                        server.language_id
505                    )));
506                }
507                if let Some(prev_language) = seen_names.insert(name.as_str(), &server.language_id) {
508                    // Not a hard error here: whether this is actually ambiguous
509                    // depends on which of these servers end up applicable in a
510                    // given workspace, which this function cannot know. The
511                    // workspace-scoped check in `ToolRouter::from_configs` is
512                    // authoritative.
513                    tracing::warn!(
514                        "duplicate explicit server name '{name}' in config (language ids: \
515                         '{prev_language}', '{}'); this is only an error if both entries are \
516                         applicable in the same workspace",
517                        server.language_id
518                    );
519                }
520            }
521            if let Some(handles) = &server.handles {
522                if handles.is_empty() {
523                    return Err(Error::InvalidConfig(format!(
524                        "handles cannot be empty for language '{}' (omit `handles` for a \
525                         catch-all server)",
526                        server.language_id
527                    )));
528                }
529                let mut seen_tools = HashSet::new();
530                for tool in handles {
531                    if !seen_tools.insert(*tool) {
532                        return Err(Error::InvalidConfig(format!(
533                            "duplicate tool '{tool}' in `handles` for language '{}'",
534                            server.language_id
535                        )));
536                    }
537                }
538            }
539        }
540        Ok(())
541    }
542}
543
544impl Default for ServerConfig {
545    fn default() -> Self {
546        Self {
547            workspace: WorkspaceConfig::default(),
548            lsp_servers: vec![
549                LspServerConfig::rust_analyzer(),
550                LspServerConfig::pyright(),
551                LspServerConfig::typescript(),
552                LspServerConfig::gopls(),
553                LspServerConfig::clangd(),
554                LspServerConfig::zls(),
555            ],
556        }
557    }
558}
559
560#[cfg(test)]
561#[allow(clippy::unwrap_used)]
562mod tests {
563    use std::fs;
564
565    use tempfile::TempDir;
566
567    use super::*;
568
569    #[test]
570    fn test_default_config() {
571        let config = ServerConfig::default();
572        assert_eq!(config.lsp_servers.len(), 6);
573        assert_eq!(config.lsp_servers[0].language_id, "rust");
574        assert_eq!(config.lsp_servers[1].language_id, "python");
575        assert_eq!(config.lsp_servers[2].language_id, "typescript");
576        assert_eq!(config.lsp_servers[3].language_id, "go");
577        assert_eq!(config.lsp_servers[4].language_id, "cpp");
578        assert_eq!(config.lsp_servers[5].language_id, "zig");
579        assert_eq!(config.workspace.position_encodings, vec!["utf-8", "utf-16"]);
580    }
581
582    #[test]
583    fn test_default_position_encodings() {
584        let encodings = default_position_encodings();
585        assert_eq!(encodings, vec!["utf-8", "utf-16"]);
586    }
587
588    #[test]
589    fn test_load_from_valid_toml() {
590        let tmp_dir = TempDir::new().unwrap();
591        let config_path = tmp_dir.path().join("config.toml");
592
593        let toml_content = r#"
594            [workspace]
595            roots = ["/tmp/workspace"]
596            position_encodings = ["utf-8"]
597
598            [[lsp_servers]]
599            language_id = "rust"
600            command = "rust-analyzer"
601            timeout_seconds = 30
602        "#;
603
604        fs::write(&config_path, toml_content).unwrap();
605
606        let config = ServerConfig::load_from(&config_path).unwrap();
607        assert_eq!(
608            config.workspace.roots,
609            vec![PathBuf::from("/tmp/workspace")]
610        );
611        assert_eq!(config.workspace.position_encodings, vec!["utf-8"]);
612        assert_eq!(config.lsp_servers.len(), 1);
613        assert_eq!(config.lsp_servers[0].language_id, "rust");
614    }
615
616    #[test]
617    fn test_load_from_nonexistent_file() {
618        let result = ServerConfig::load_from(Path::new("/nonexistent/config.toml"));
619        assert!(result.is_err());
620
621        if let Err(Error::ConfigNotFound(path)) = result {
622            assert_eq!(path, PathBuf::from("/nonexistent/config.toml"));
623        } else {
624            panic!("Expected ConfigNotFound error");
625        }
626    }
627
628    #[test]
629    fn test_load_from_invalid_toml() {
630        let tmp_dir = TempDir::new().unwrap();
631        let config_path = tmp_dir.path().join("invalid.toml");
632
633        fs::write(&config_path, "invalid toml content {{}").unwrap();
634
635        let result = ServerConfig::load_from(&config_path);
636        assert!(result.is_err());
637    }
638
639    #[test]
640    fn test_validate_empty_language_id() {
641        let tmp_dir = TempDir::new().unwrap();
642        let config_path = tmp_dir.path().join("config.toml");
643
644        let toml_content = r#"
645            [[lsp_servers]]
646            language_id = ""
647            command = "test"
648        "#;
649
650        fs::write(&config_path, toml_content).unwrap();
651
652        let result = ServerConfig::load_from(&config_path);
653        assert!(result.is_err());
654
655        if let Err(Error::InvalidConfig(msg)) = result {
656            assert!(msg.contains("language_id cannot be empty"));
657        } else {
658            panic!("Expected InvalidConfig error");
659        }
660    }
661
662    #[test]
663    fn test_validate_empty_command() {
664        let tmp_dir = TempDir::new().unwrap();
665        let config_path = tmp_dir.path().join("config.toml");
666
667        let toml_content = r#"
668            [[lsp_servers]]
669            language_id = "rust"
670            command = ""
671        "#;
672
673        fs::write(&config_path, toml_content).unwrap();
674
675        let result = ServerConfig::load_from(&config_path);
676        assert!(result.is_err());
677
678        if let Err(Error::InvalidConfig(msg)) = result {
679            assert!(msg.contains("command cannot be empty"));
680        } else {
681            panic!("Expected InvalidConfig error");
682        }
683    }
684
685    #[test]
686    fn test_validate_empty_name() {
687        let tmp_dir = TempDir::new().unwrap();
688        let config_path = tmp_dir.path().join("config.toml");
689
690        let toml_content = r#"
691            [[lsp_servers]]
692            name = ""
693            language_id = "python"
694            command = "pyright-langserver"
695        "#;
696
697        fs::write(&config_path, toml_content).unwrap();
698
699        let result = ServerConfig::load_from(&config_path);
700        assert!(result.is_err());
701
702        if let Err(Error::InvalidConfig(msg)) = result {
703            assert!(msg.contains("name cannot be empty"));
704        } else {
705            panic!("Expected InvalidConfig error");
706        }
707    }
708
709    #[test]
710    fn test_validate_empty_handles() {
711        let tmp_dir = TempDir::new().unwrap();
712        let config_path = tmp_dir.path().join("config.toml");
713
714        let toml_content = r#"
715            [[lsp_servers]]
716            language_id = "python"
717            command = "pylsp"
718            handles = []
719        "#;
720
721        fs::write(&config_path, toml_content).unwrap();
722
723        let result = ServerConfig::load_from(&config_path);
724        assert!(result.is_err());
725
726        if let Err(Error::InvalidConfig(msg)) = result {
727            assert!(msg.contains("handles cannot be empty"));
728        } else {
729            panic!("Expected InvalidConfig error");
730        }
731    }
732
733    #[test]
734    fn test_validate_duplicate_tool_in_handles() {
735        let tmp_dir = TempDir::new().unwrap();
736        let config_path = tmp_dir.path().join("config.toml");
737
738        let toml_content = r#"
739            [[lsp_servers]]
740            language_id = "python"
741            command = "pylsp"
742            handles = ["diagnostics", "diagnostics"]
743        "#;
744
745        fs::write(&config_path, toml_content).unwrap();
746
747        let result = ServerConfig::load_from(&config_path);
748        assert!(result.is_err());
749
750        if let Err(Error::InvalidConfig(msg)) = result {
751            assert!(msg.contains("duplicate tool"));
752            assert!(msg.contains("diagnostics"));
753        } else {
754            panic!("Expected InvalidConfig error");
755        }
756    }
757
758    #[test]
759    fn test_validate_duplicate_name_warns_but_loads() {
760        // Duplicate explicit `name` is only an error if both entries end up
761        // applicable in the same workspace (enforced later by
762        // `ToolRouter::from_configs`, see routing.rs); at load time it must
763        // still succeed.
764        let tmp_dir = TempDir::new().unwrap();
765        let config_path = tmp_dir.path().join("config.toml");
766
767        let toml_content = r#"
768            [[lsp_servers]]
769            name = "dup"
770            language_id = "python"
771            command = "pyright-langserver"
772
773            [[lsp_servers]]
774            name = "dup"
775            language_id = "typescript"
776            command = "typescript-language-server"
777        "#;
778
779        fs::write(&config_path, toml_content).unwrap();
780
781        let result = ServerConfig::load_from(&config_path);
782        assert!(result.is_ok(), "duplicate name must only warn at load time");
783    }
784
785    #[test]
786    fn test_workspace_config_defaults() {
787        let workspace = WorkspaceConfig::default();
788        assert!(workspace.roots.is_empty());
789        assert_eq!(workspace.position_encodings, vec!["utf-8", "utf-16"]);
790        assert!(!workspace.language_extensions.is_empty());
791        assert_eq!(workspace.language_extensions.len(), 30);
792        assert_eq!(workspace.heuristics_max_depth, DEFAULT_HEURISTICS_MAX_DEPTH);
793    }
794
795    #[test]
796    fn test_load_multiple_servers() {
797        let tmp_dir = TempDir::new().unwrap();
798        let config_path = tmp_dir.path().join("multi.toml");
799
800        let toml_content = r#"
801            [[lsp_servers]]
802            language_id = "rust"
803            command = "rust-analyzer"
804
805            [[lsp_servers]]
806            language_id = "python"
807            command = "pyright-langserver"
808            args = ["--stdio"]
809        "#;
810
811        fs::write(&config_path, toml_content).unwrap();
812
813        let config = ServerConfig::load_from(&config_path).unwrap();
814        assert_eq!(config.lsp_servers.len(), 2);
815        assert_eq!(config.lsp_servers[0].language_id, "rust");
816        assert_eq!(config.lsp_servers[1].language_id, "python");
817        assert_eq!(config.lsp_servers[1].args, vec!["--stdio"]);
818    }
819
820    #[test]
821    fn test_deny_unknown_fields() {
822        let tmp_dir = TempDir::new().unwrap();
823        let config_path = tmp_dir.path().join("unknown.toml");
824
825        let toml_content = r#"
826            unknown_field = "value"
827
828            [workspace]
829            roots = []
830        "#;
831
832        fs::write(&config_path, toml_content).unwrap();
833
834        let result = ServerConfig::load_from(&config_path);
835        assert!(result.is_err(), "Should reject unknown fields");
836    }
837
838    #[test]
839    fn test_empty_config_file() {
840        let tmp_dir = TempDir::new().unwrap();
841        let config_path = tmp_dir.path().join("empty.toml");
842
843        fs::write(&config_path, "").unwrap();
844
845        let config = ServerConfig::load_from(&config_path).unwrap();
846        assert!(config.workspace.roots.is_empty());
847        assert!(config.lsp_servers.is_empty());
848    }
849
850    #[test]
851    fn test_config_with_initialization_options() {
852        let tmp_dir = TempDir::new().unwrap();
853        let config_path = tmp_dir.path().join("init_opts.toml");
854
855        let toml_content = r#"
856            [[lsp_servers]]
857            language_id = "rust"
858            command = "rust-analyzer"
859
860            [lsp_servers.initialization_options]
861            cargo = { allFeatures = true }
862        "#;
863
864        fs::write(&config_path, toml_content).unwrap();
865
866        let config = ServerConfig::load_from(&config_path).unwrap();
867        assert!(config.lsp_servers[0].initialization_options.is_some());
868    }
869
870    #[test]
871    fn test_language_extensions_in_config() {
872        let tmp_dir = TempDir::new().unwrap();
873        let config_path = tmp_dir.path().join("extensions.toml");
874
875        let toml_content = r#"
876            [[workspace.language_extensions]]
877            extensions = ["cpp", "cc", "cxx", "hpp", "hh", "hxx"]
878            language_id = "cpp"
879
880            [[workspace.language_extensions]]
881            extensions = ["nu"]
882            language_id = "nushell"
883
884            [[workspace.language_extensions]]
885            extensions = ["py", "pyw", "pyi"]
886            language_id = "python"
887        "#;
888
889        fs::write(&config_path, toml_content).unwrap();
890
891        let config = ServerConfig::load_from(&config_path).unwrap();
892        assert_eq!(config.workspace.language_extensions.len(), 3);
893
894        // Check C++ extensions
895        assert_eq!(config.workspace.language_extensions[0].language_id, "cpp");
896        assert_eq!(
897            config.workspace.language_extensions[0].extensions,
898            vec!["cpp", "cc", "cxx", "hpp", "hh", "hxx"]
899        );
900
901        // Check Nushell extension
902        assert_eq!(
903            config.workspace.language_extensions[1].language_id,
904            "nushell"
905        );
906        assert_eq!(
907            config.workspace.language_extensions[1].extensions,
908            vec!["nu"]
909        );
910    }
911
912    #[test]
913    fn test_build_extension_map() {
914        let workspace = WorkspaceConfig {
915            roots: vec![],
916            position_encodings: vec![],
917            language_extensions: vec![
918                LanguageExtensionMapping {
919                    extensions: vec!["cpp".to_string(), "cc".to_string(), "cxx".to_string()],
920                    language_id: "cpp".to_string(),
921                },
922                LanguageExtensionMapping {
923                    extensions: vec!["nu".to_string()],
924                    language_id: "nushell".to_string(),
925                },
926            ],
927            heuristics_max_depth: DEFAULT_HEURISTICS_MAX_DEPTH,
928        };
929
930        let map = workspace.build_extension_map();
931        assert_eq!(map.get("cpp"), Some(&"cpp".to_string()));
932        assert_eq!(map.get("cc"), Some(&"cpp".to_string()));
933        assert_eq!(map.get("cxx"), Some(&"cpp".to_string()));
934        assert_eq!(map.get("nu"), Some(&"nushell".to_string()));
935        assert_eq!(map.get("unknown"), None);
936    }
937
938    #[test]
939    fn test_extract_extension_from_pattern_empty_string() {
940        assert_eq!(extract_extension_from_pattern(""), None);
941    }
942
943    #[test]
944    fn test_extract_extension_from_pattern_without_dot() {
945        assert_eq!(extract_extension_from_pattern("**/*"), None);
946    }
947
948    #[test]
949    fn test_extract_extension_from_pattern_dotfile() {
950        assert_eq!(extract_extension_from_pattern(".gitignore"), None);
951    }
952
953    #[test]
954    fn test_extract_extension_from_pattern_multi_dot_extension() {
955        assert_eq!(
956            extract_extension_from_pattern("foo.tar.gz"),
957            Some("gz".to_string())
958        );
959    }
960
961    #[test]
962    fn test_build_effective_extension_map_overrides_with_file_patterns() {
963        let config = ServerConfig {
964            workspace: WorkspaceConfig::default(),
965            lsp_servers: vec![LspServerConfig {
966                language_id: "cpp".to_string(),
967                command: "clangd".to_string(),
968                args: vec![],
969                env: HashMap::new(),
970                file_patterns: vec!["**/*.c".to_string(), "**/*.h".to_string()],
971                initialization_options: None,
972                timeout_seconds: 30,
973                heuristics: None,
974                name: None,
975                handles: None,
976            }],
977        };
978
979        let map = config.build_effective_extension_map();
980        assert_eq!(map.get("c"), Some(&"cpp".to_string()));
981        assert_eq!(map.get("h"), Some(&"cpp".to_string()));
982    }
983
984    #[test]
985    fn test_build_effective_extension_map_derives_tsx_language_id() {
986        let config = ServerConfig {
987            workspace: WorkspaceConfig::default(),
988            lsp_servers: vec![LspServerConfig {
989                language_id: "typescript".to_string(),
990                command: "tsgo".to_string(),
991                args: vec!["--lsp".to_string(), "--stdio".to_string()],
992                env: HashMap::new(),
993                file_patterns: vec!["**/*.ts".to_string(), "**/*.tsx".to_string()],
994                initialization_options: None,
995                timeout_seconds: 30,
996                heuristics: None,
997                name: None,
998                handles: None,
999            }],
1000        };
1001
1002        let map = config.build_effective_extension_map();
1003        assert_eq!(map.get("ts"), Some(&"typescript".to_string()));
1004        assert_eq!(map.get("tsx"), Some(&"typescriptreact".to_string()));
1005    }
1006
1007    #[test]
1008    fn test_build_effective_extension_map_derives_jsx_language_id() {
1009        let config = ServerConfig {
1010            workspace: WorkspaceConfig::default(),
1011            lsp_servers: vec![LspServerConfig {
1012                language_id: "javascript".to_string(),
1013                command: "typescript-language-server".to_string(),
1014                args: vec!["--stdio".to_string()],
1015                env: HashMap::new(),
1016                file_patterns: vec!["**/*.js".to_string(), "**/*.jsx".to_string()],
1017                initialization_options: None,
1018                timeout_seconds: 30,
1019                heuristics: None,
1020                name: None,
1021                handles: None,
1022            }],
1023        };
1024
1025        let map = config.build_effective_extension_map();
1026        assert_eq!(map.get("js"), Some(&"javascript".to_string()));
1027        assert_eq!(map.get("jsx"), Some(&"javascriptreact".to_string()));
1028    }
1029
1030    #[test]
1031    fn test_build_effective_extension_map_ignores_complex_patterns_without_extension() {
1032        let config = ServerConfig {
1033            workspace: WorkspaceConfig::default(),
1034            lsp_servers: vec![LspServerConfig {
1035                language_id: "cpp".to_string(),
1036                command: "clangd".to_string(),
1037                args: vec![],
1038                env: HashMap::new(),
1039                file_patterns: vec!["**/*".to_string(), "**/*.{h,hpp}".to_string()],
1040                initialization_options: None,
1041                timeout_seconds: 30,
1042                heuristics: None,
1043                name: None,
1044                handles: None,
1045            }],
1046        };
1047
1048        let map = config.build_effective_extension_map();
1049        // Default C/C++ mappings remain unchanged when patterns cannot be parsed.
1050        assert_eq!(map.get("h"), Some(&"c".to_string()));
1051    }
1052
1053    #[test]
1054    fn test_get_language_for_extension() {
1055        let workspace = WorkspaceConfig {
1056            roots: vec![],
1057            position_encodings: vec![],
1058            language_extensions: vec![
1059                LanguageExtensionMapping {
1060                    extensions: vec!["hpp".to_string(), "hh".to_string()],
1061                    language_id: "cpp".to_string(),
1062                },
1063                LanguageExtensionMapping {
1064                    extensions: vec!["py".to_string()],
1065                    language_id: "python".to_string(),
1066                },
1067            ],
1068            heuristics_max_depth: DEFAULT_HEURISTICS_MAX_DEPTH,
1069        };
1070
1071        assert_eq!(
1072            workspace.get_language_for_extension("hpp"),
1073            Some("cpp".to_string())
1074        );
1075        assert_eq!(
1076            workspace.get_language_for_extension("hh"),
1077            Some("cpp".to_string())
1078        );
1079        assert_eq!(
1080            workspace.get_language_for_extension("py"),
1081            Some("python".to_string())
1082        );
1083        assert_eq!(workspace.get_language_for_extension("unknown"), None);
1084    }
1085
1086    #[test]
1087    fn test_default_language_extensions() {
1088        let workspace = WorkspaceConfig::default();
1089        let map = workspace.build_extension_map();
1090        assert!(!map.is_empty());
1091        assert_eq!(
1092            workspace.get_language_for_extension("rs"),
1093            Some("rust".to_string())
1094        );
1095        assert_eq!(
1096            workspace.get_language_for_extension("py"),
1097            Some("python".to_string())
1098        );
1099        assert_eq!(
1100            workspace.get_language_for_extension("cpp"),
1101            Some("cpp".to_string())
1102        );
1103    }
1104
1105    #[test]
1106    fn test_create_default_config_file() {
1107        let tmp_dir = TempDir::new().unwrap();
1108        let config_path = tmp_dir.path().join("mcpls").join("mcpls.toml");
1109
1110        ServerConfig::create_default_config_file(&config_path).unwrap();
1111
1112        assert!(config_path.exists());
1113
1114        let loaded_config = ServerConfig::load_from(&config_path).unwrap();
1115        assert_eq!(loaded_config.workspace.language_extensions.len(), 30);
1116        assert_eq!(loaded_config.lsp_servers.len(), 6);
1117        assert_eq!(loaded_config.lsp_servers[0].language_id, "rust");
1118    }
1119
1120    #[test]
1121    fn test_load_returns_default_config() {
1122        // When called directly, default() should return config with all language extensions
1123        let config = ServerConfig::default();
1124        assert_eq!(config.workspace.language_extensions.len(), 30);
1125        assert_eq!(config.lsp_servers.len(), 6);
1126        assert_eq!(config.lsp_servers[0].language_id, "rust");
1127    }
1128
1129    // These tests mutate the process-wide CWD via `set_current_dir`, so they
1130    // must not run concurrently with each other or with any other test that
1131    // relies on CWD (e.g. via a bare `load()`/`load_with_trust()` call).
1132    // Nextest runs each test in its own process, but `cargo test` in-process
1133    // would race; guard with a mutex.
1134    static CWD_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1135
1136    #[test]
1137    fn test_load_ignores_untrusted_project_local_config() {
1138        // `ServerConfig::default()` (what untrusted discovery falls back to
1139        // once neither an untrusted local file nor a global config apply)
1140        // still exposes rust-analyzer via built-in project-marker
1141        // heuristics — see `test_default_config` above, which already
1142        // covers this without any filesystem interaction. This test only
1143        // needs to prove the planted attacker file's content never leaks
1144        // through `load()`.
1145        let _guard = CWD_LOCK.lock().unwrap();
1146        let original_dir = std::env::current_dir().unwrap();
1147
1148        let tmp_dir = TempDir::new().unwrap();
1149        let config_path = tmp_dir.path().join("mcpls.toml");
1150
1151        // A marker language id / root that cannot collide with either the
1152        // built-in defaults or a machine-local global config, so this
1153        // assertion holds regardless of what `load()` actually falls
1154        // through to (built-in defaults on a clean machine, or the
1155        // machine's own customized global config in CI/dev environments).
1156        let custom_toml = r#"
1157            [workspace]
1158            roots = ["/should-never-load-attacker-path"]
1159
1160            [[lsp_servers]]
1161            language_id = "definitely-not-a-real-language-marker"
1162            command = "rm"
1163            args = ["-rf", "/"]
1164        "#;
1165
1166        fs::write(&config_path, custom_toml).unwrap();
1167
1168        std::env::set_current_dir(tmp_dir.path()).unwrap();
1169        let config = ServerConfig::load().unwrap();
1170        std::env::set_current_dir(&original_dir).unwrap();
1171
1172        assert!(
1173            !config
1174                .workspace
1175                .roots
1176                .contains(&PathBuf::from("/should-never-load-attacker-path"))
1177        );
1178        assert!(
1179            !config
1180                .lsp_servers
1181                .iter()
1182                .any(|s| s.language_id == "definitely-not-a-real-language-marker")
1183        );
1184    }
1185
1186    #[test]
1187    fn test_load_with_trust_loads_trusted_project_local_config() {
1188        let _guard = CWD_LOCK.lock().unwrap();
1189        let original_dir = std::env::current_dir().unwrap();
1190
1191        let tmp_dir = TempDir::new().unwrap();
1192        let config_path = tmp_dir.path().join("mcpls.toml");
1193
1194        let custom_toml = r#"
1195            [workspace]
1196            roots = ["/custom/path"]
1197
1198            [[lsp_servers]]
1199            language_id = "python"
1200            command = "pyright-langserver"
1201        "#;
1202
1203        fs::write(&config_path, custom_toml).unwrap();
1204
1205        std::env::set_current_dir(tmp_dir.path()).unwrap();
1206        let config = ServerConfig::load_with_trust(ProjectConfigTrust::Trusted).unwrap();
1207        std::env::set_current_dir(&original_dir).unwrap();
1208
1209        assert_eq!(config.workspace.roots, vec![PathBuf::from("/custom/path")]);
1210        assert_eq!(config.lsp_servers.len(), 1);
1211        assert_eq!(config.lsp_servers[0].language_id, "python");
1212    }
1213
1214    #[test]
1215    fn test_load_with_trust_untrusted_ignores_workspace_and_servers() {
1216        let _guard = CWD_LOCK.lock().unwrap();
1217        let original_dir = std::env::current_dir().unwrap();
1218
1219        let tmp_dir = TempDir::new().unwrap();
1220        let config_path = tmp_dir.path().join("mcpls.toml");
1221
1222        let custom_toml = r#"
1223            [workspace]
1224            roots = ["/attacker/controlled"]
1225            heuristics_max_depth = 999999
1226
1227            [[lsp_servers]]
1228            language_id = "evil"
1229            command = "rm"
1230            args = ["-rf", "/"]
1231        "#;
1232
1233        fs::write(&config_path, custom_toml).unwrap();
1234
1235        std::env::set_current_dir(tmp_dir.path()).unwrap();
1236        let config = ServerConfig::load_with_trust(ProjectConfigTrust::Untrusted).unwrap();
1237        std::env::set_current_dir(&original_dir).unwrap();
1238
1239        assert!(
1240            !config
1241                .workspace
1242                .roots
1243                .contains(&PathBuf::from("/attacker/controlled"))
1244        );
1245        assert_ne!(config.workspace.heuristics_max_depth, 999_999);
1246        assert!(!config.lsp_servers.iter().any(|s| s.language_id == "evil"));
1247    }
1248
1249    #[test]
1250    fn test_config_file_creation_with_proper_structure() {
1251        let tmp_dir = TempDir::new().unwrap();
1252        let config_path = tmp_dir.path().join("test_config").join("mcpls.toml");
1253
1254        ServerConfig::create_default_config_file(&config_path).unwrap();
1255
1256        let content = fs::read_to_string(&config_path).unwrap();
1257
1258        assert!(content.contains("[workspace]"));
1259        assert!(content.contains("[[workspace.language_extensions]]"));
1260        assert!(content.contains("[[lsp_servers]]"));
1261        assert!(content.contains("language_id = \"rust\""));
1262        assert!(content.contains("extensions = [\"rs\"]"));
1263    }
1264
1265    #[test]
1266    fn test_heuristics_max_depth_default() {
1267        let config = WorkspaceConfig::default();
1268        assert_eq!(config.heuristics_max_depth, 10);
1269    }
1270
1271    #[test]
1272    fn test_heuristics_max_depth_from_config() {
1273        let tmp_dir = TempDir::new().unwrap();
1274        let config_path = tmp_dir.path().join("depth.toml");
1275
1276        let toml_content = r"
1277            [workspace]
1278            heuristics_max_depth = 5
1279        ";
1280
1281        fs::write(&config_path, toml_content).unwrap();
1282
1283        let config = ServerConfig::load_from(&config_path).unwrap();
1284        assert_eq!(config.workspace.heuristics_max_depth, 5);
1285    }
1286
1287    #[test]
1288    fn test_heuristics_max_depth_uses_default_when_not_specified() {
1289        let tmp_dir = TempDir::new().unwrap();
1290        let config_path = tmp_dir.path().join("no_depth.toml");
1291
1292        let toml_content = r"
1293            [workspace]
1294            roots = []
1295        ";
1296
1297        fs::write(&config_path, toml_content).unwrap();
1298
1299        let config = ServerConfig::load_from(&config_path).unwrap();
1300        assert_eq!(
1301            config.workspace.heuristics_max_depth,
1302            DEFAULT_HEURISTICS_MAX_DEPTH
1303        );
1304    }
1305}