Skip to main content

perl_lsp_config/
lib.rs

1#![warn(missing_docs)]
2//! Configuration models for perl-lsp server runtime state.
3//!
4//! This microcrate isolates configuration parsing and defaults from the main
5//! server crate so they can evolve independently and be reused by tooling.
6
7use std::path::PathBuf;
8#[cfg(not(target_arch = "wasm32"))]
9use std::process::Command;
10
11/// Server configuration
12///
13/// Runtime configuration for the LSP server features including inlay hints
14/// and test runner integration. Updated dynamically via `didChangeConfiguration`.
15#[derive(Debug, Clone)]
16pub struct ServerConfig {
17    /// Whether inlay hints are globally enabled.
18    pub inlay_hints_enabled: bool,
19    /// Show parameter name hints at call sites.
20    pub inlay_hints_parameter_hints: bool,
21    /// Show inferred type hints for variables.
22    pub inlay_hints_type_hints: bool,
23    /// Show hints for method chains.
24    pub inlay_hints_chained_hints: bool,
25    /// Maximum character length for hint labels before truncation.
26    pub inlay_hints_max_length: usize,
27
28    /// Whether the integrated test runner is enabled.
29    pub test_runner_enabled: bool,
30    /// Command to execute tests (e.g., "perl", "prove").
31    pub test_runner_command: String,
32    /// Additional arguments passed to the test command.
33    pub test_runner_args: Vec<String>,
34    /// Test execution timeout in milliseconds.
35    pub test_runner_timeout: u64,
36
37    /// Whether telemetry events are enabled.
38    pub telemetry_enabled: bool,
39
40    /// Whether external perlcritic diagnostics are enabled (opt-in).
41    ///
42    /// When enabled, the server will run `perlcritic` on open documents and
43    /// merge violations into the diagnostic stream. Requires `perlcritic` to
44    /// be installed on the system; silently skipped if not available.
45    pub perlcritic_enabled: bool,
46
47    /// Minimum severity level to report (1-5, where 1 = most severe).
48    ///
49    /// Violations below this threshold are suppressed. Default is 3 (Harsh).
50    /// Equivalent to `perlcritic --severity`.
51    pub perlcritic_severity: u8,
52
53    /// Path to a `.perlcriticrc` profile file.
54    ///
55    /// When `Some`, passes `--profile=<path>` to perlcritic. When `None`,
56    /// the auto-discovery logic looks for `.perlcriticrc` in the workspace root.
57    pub perlcritic_profile: Option<String>,
58
59    /// AI-powered inline completion configuration.
60    pub ai_completion: AiCompletionConfig,
61}
62
63/// Configuration for AI-powered inline completions.
64///
65/// Disabled by default. When enabled, the server calls an external AI provider
66/// for inline completion suggestions, falling back to deterministic rules on
67/// timeout, error, or when AI is disabled.
68#[derive(Debug, Clone)]
69pub struct AiCompletionConfig {
70    /// Whether AI completions are enabled. Default: false.
71    pub enabled: bool,
72    /// Provider type. Currently only "openai_compat" is supported.
73    pub provider: String,
74    /// API endpoint URL.
75    pub endpoint: String,
76    /// Model identifier (e.g., "gpt-4o-mini").
77    pub model: String,
78    /// Environment variable name containing the API key.
79    pub api_key_env: String,
80    /// Request timeout in milliseconds. Default: 1800.
81    pub timeout_ms: u64,
82    /// Maximum output tokens per request. Default: 64.
83    pub max_output_tokens: u32,
84    /// Maximum requests per second. Default: 1.
85    pub rate_limit_rps: f64,
86    /// Maximum concurrent in-flight requests. Default: 1.
87    pub max_inflight: u32,
88    /// Whether to fall back to deterministic completions on AI failure. Default: true.
89    pub fallback: bool,
90    /// Streaming-specific configuration.
91    pub streaming: AiStreamingConfig,
92}
93
94/// Streaming sub-configuration for AI completions.
95#[derive(Debug, Clone)]
96pub struct AiStreamingConfig {
97    /// Whether streaming mode is enabled. Default: true.
98    pub enabled: bool,
99    /// Minimum milliseconds between emitted updates. Default: 60.
100    pub update_debounce_ms: u64,
101}
102
103impl Default for AiCompletionConfig {
104    fn default() -> Self {
105        Self {
106            enabled: false,
107            provider: "openai_compat".to_string(),
108            endpoint: String::new(),
109            model: "gpt-4o-mini".to_string(),
110            api_key_env: "OPENAI_API_KEY".to_string(),
111            timeout_ms: 1800,
112            max_output_tokens: 64,
113            rate_limit_rps: 1.0,
114            max_inflight: 1,
115            fallback: true,
116            streaming: AiStreamingConfig::default(),
117        }
118    }
119}
120
121impl Default for AiStreamingConfig {
122    fn default() -> Self {
123        Self { enabled: true, update_debounce_ms: 60 }
124    }
125}
126
127impl Default for ServerConfig {
128    fn default() -> Self {
129        Self {
130            inlay_hints_enabled: true,
131            inlay_hints_parameter_hints: true,
132            inlay_hints_type_hints: true,
133            inlay_hints_chained_hints: false,
134            inlay_hints_max_length: 30,
135            test_runner_enabled: true,
136            test_runner_command: "perl".to_string(),
137            test_runner_args: vec![],
138            test_runner_timeout: 60000,
139            telemetry_enabled: false,
140            perlcritic_enabled: false,
141            perlcritic_severity: 3,
142            perlcritic_profile: None,
143            ai_completion: AiCompletionConfig::default(),
144        }
145    }
146}
147
148impl ServerConfig {
149    /// Update configuration from LSP settings
150    pub fn update_from_value(&mut self, settings: &serde_json::Value) {
151        if let Some(inlay) = settings.get("inlayHints") {
152            if let Some(enabled) = inlay.get("enabled").and_then(|v| v.as_bool()) {
153                self.inlay_hints_enabled = enabled;
154            }
155            if let Some(param) = inlay.get("parameterHints").and_then(|v| v.as_bool()) {
156                self.inlay_hints_parameter_hints = param;
157            }
158            if let Some(type_hints) = inlay.get("typeHints").and_then(|v| v.as_bool()) {
159                self.inlay_hints_type_hints = type_hints;
160            }
161            if let Some(chained) = inlay.get("chainedHints").and_then(|v| v.as_bool()) {
162                self.inlay_hints_chained_hints = chained;
163            }
164            if let Some(max_len) = inlay.get("maxLength").and_then(|v| v.as_u64()) {
165                self.inlay_hints_max_length = max_len as usize;
166            }
167        }
168
169        if let Some(test) = settings.get("testRunner") {
170            if let Some(enabled) = test.get("enabled").and_then(|v| v.as_bool()) {
171                self.test_runner_enabled = enabled;
172            }
173            if let Some(cmd) = test.get("command").and_then(|v| v.as_str()) {
174                self.test_runner_command = cmd.to_string();
175            }
176            if let Some(args) = test.get("args").and_then(|v| v.as_array()) {
177                self.test_runner_args =
178                    args.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect();
179            }
180            if let Some(timeout) = test.get("timeout").and_then(|v| v.as_u64()) {
181                self.test_runner_timeout = timeout;
182            }
183        }
184
185        if let Some(telemetry) = settings.get("telemetry")
186            && let Some(enabled) = telemetry.get("enabled").and_then(|v| v.as_bool())
187        {
188            self.telemetry_enabled = enabled;
189        }
190
191        if let Some(critic) = settings.get("perlcritic") {
192            if let Some(enabled) = critic.get("enabled").and_then(|v| v.as_bool()) {
193                self.perlcritic_enabled = enabled;
194            }
195            if let Some(severity) = critic.get("severity").and_then(|v| v.as_u64()) {
196                self.perlcritic_severity = severity.clamp(1, 5) as u8;
197            }
198            if let Some(profile) = critic.get("profile").and_then(|v| v.as_str()) {
199                self.perlcritic_profile = Some(profile.to_string());
200            }
201        }
202
203        if let Some(ai) = settings.get("aiCompletion") {
204            if let Some(enabled) = ai.get("enabled").and_then(|v| v.as_bool()) {
205                self.ai_completion.enabled = enabled;
206            }
207            if let Some(provider) = ai.get("provider").and_then(|v| v.as_str()) {
208                self.ai_completion.provider = provider.to_string();
209            }
210            if let Some(endpoint) = ai.get("endpoint").and_then(|v| v.as_str()) {
211                self.ai_completion.endpoint = endpoint.to_string();
212            }
213            if let Some(model) = ai.get("model").and_then(|v| v.as_str()) {
214                self.ai_completion.model = model.to_string();
215            }
216            if let Some(key_env) = ai.get("apiKeyEnv").and_then(|v| v.as_str()) {
217                self.ai_completion.api_key_env = key_env.to_string();
218            }
219            if let Some(timeout) = ai.get("timeoutMs").and_then(|v| v.as_u64()) {
220                self.ai_completion.timeout_ms = timeout;
221            }
222            if let Some(tokens) = ai.get("maxOutputTokens").and_then(|v| v.as_u64()) {
223                self.ai_completion.max_output_tokens = tokens as u32;
224            }
225            if let Some(rps) = ai.get("rateLimitRps").and_then(|v| v.as_f64()) {
226                self.ai_completion.rate_limit_rps = rps;
227            }
228            if let Some(inflight) = ai.get("maxInflight").and_then(|v| v.as_u64()) {
229                self.ai_completion.max_inflight = inflight as u32;
230            }
231            if let Some(fallback) = ai.get("fallback").and_then(|v| v.as_bool()) {
232                self.ai_completion.fallback = fallback;
233            }
234            if let Some(streaming) = ai.get("streaming") {
235                if let Some(enabled) = streaming.get("enabled").and_then(|v| v.as_bool()) {
236                    self.ai_completion.streaming.enabled = enabled;
237                }
238                if let Some(debounce) = streaming.get("updateDebounceMs").and_then(|v| v.as_u64()) {
239                    self.ai_completion.streaming.update_debounce_ms = debounce;
240                }
241            }
242        }
243    }
244}
245
246/// Workspace configuration for module resolution
247///
248/// Controls how the LSP server resolves module imports and finds
249/// Perl module files across the workspace.
250#[derive(Debug, Clone)]
251pub struct WorkspaceConfig {
252    /// Custom include paths for module resolution (relative to workspace root)
253    /// Default: `["lib", ".", "local/lib/perl5"]`
254    pub include_paths: Vec<String>,
255
256    /// Whether to include system @INC paths in module resolution
257    /// Default: false (avoids blocking on network filesystems)
258    pub use_system_inc: bool,
259
260    /// Cached system @INC paths (populated lazily when use_system_inc is true)
261    system_inc_cache: Option<Vec<PathBuf>>,
262
263    /// Resolution timeout in milliseconds
264    /// Default: 50ms
265    pub resolution_timeout_ms: u64,
266}
267
268impl Default for WorkspaceConfig {
269    fn default() -> Self {
270        Self {
271            include_paths: vec!["lib".to_string(), ".".to_string(), "local/lib/perl5".to_string()],
272            use_system_inc: false,
273            system_inc_cache: None,
274            resolution_timeout_ms: 50,
275        }
276    }
277}
278
279impl WorkspaceConfig {
280    /// Update workspace configuration from LSP settings.
281    pub fn update_from_value(&mut self, settings: &serde_json::Value) {
282        if let Some(workspace) = settings.get("workspace") {
283            if let Some(paths) = workspace.get("includePaths").and_then(|v| v.as_array()) {
284                self.include_paths =
285                    paths.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect();
286            }
287            if let Some(use_inc) = workspace.get("useSystemInc").and_then(|v| v.as_bool()) {
288                if use_inc != self.use_system_inc {
289                    self.system_inc_cache = None;
290                }
291                self.use_system_inc = use_inc;
292            }
293            if let Some(timeout) = workspace.get("resolutionTimeout").and_then(|v| v.as_u64()) {
294                self.resolution_timeout_ms = timeout;
295            }
296        }
297    }
298
299    /// Get system @INC paths (lazily populated).
300    pub fn get_system_inc(&mut self) -> &[PathBuf] {
301        if !self.use_system_inc {
302            return &[];
303        }
304
305        if self.system_inc_cache.is_none() {
306            self.system_inc_cache = Some(Self::fetch_perl_inc());
307        }
308
309        self.system_inc_cache.as_deref().unwrap_or(&[])
310    }
311
312    #[cfg(not(target_arch = "wasm32"))]
313    fn fetch_perl_inc() -> Vec<PathBuf> {
314        let output = Command::new("perl").args(["-e", "print join(\"\\n\", @INC)"]).output();
315
316        match output {
317            Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout)
318                .lines()
319                .filter(|line| !line.is_empty() && *line != ".")
320                .map(PathBuf::from)
321                .collect(),
322            _ => Vec::new(),
323        }
324    }
325
326    #[cfg(target_arch = "wasm32")]
327    fn fetch_perl_inc() -> Vec<PathBuf> {
328        Vec::new()
329    }
330}
331
332// ── ProjectConfig ─────────────────────────────────────────────────────────────
333
334/// Project configuration loaded from `.perl-lsp.toml` in the workspace root.
335///
336/// Committed to the repo; provides editor-agnostic, team-wide defaults.
337/// LSP `initializationOptions` / `didChangeConfiguration` always win over this file.
338///
339/// Unknown TOML keys are silently ignored for forward compatibility.
340///
341/// `[formatting]` is reserved for future perltidy configuration (not yet wired).
342#[non_exhaustive]
343#[derive(Debug, Clone, Default, serde::Deserialize)]
344#[serde(default)]
345pub struct ProjectConfig {
346    /// `[perl]` section: module resolution settings.
347    pub perl: ProjectPerlConfig,
348    /// `[diagnostics]` section: linting settings.
349    pub diagnostics: ProjectDiagnosticsConfig,
350    /// `[features]` section: LSP feature toggles.
351    pub features: ProjectFeaturesConfig,
352    /// `[ai_completion]` section: AI completion settings.
353    pub ai_completion: ProjectAiCompletionConfig,
354}
355
356/// `[perl]` section of `.perl-lsp.toml`.
357#[derive(Debug, Clone, Default, serde::Deserialize)]
358#[serde(default)]
359pub struct ProjectPerlConfig {
360    /// Additional include paths for module resolution (relative to workspace root).
361    pub include_paths: Vec<String>,
362    /// Perl version string (e.g. "5.38") — parsed but not yet wired to diagnostics.
363    /// Reserved for future use; ignored in this implementation.
364    pub version: Option<String>,
365}
366
367/// `[diagnostics]` section of `.perl-lsp.toml`.
368#[derive(Debug, Clone, Default, serde::Deserialize)]
369#[serde(default)]
370pub struct ProjectDiagnosticsConfig {
371    /// Whether perlcritic is enabled. Maps to `ServerConfig.perlcritic_enabled`.
372    pub perlcritic: Option<bool>,
373    /// Minimum perlcritic severity (1-5). Maps to `ServerConfig.perlcritic_severity`.
374    pub perlcritic_severity: Option<u8>,
375}
376
377/// `[features]` section of `.perl-lsp.toml`.
378#[derive(Debug, Clone, Default, serde::Deserialize)]
379#[serde(default)]
380pub struct ProjectFeaturesConfig {
381    /// Whether inlay hints are enabled globally. Maps to `ServerConfig.inlay_hints_enabled`.
382    pub inlay_hints: Option<bool>,
383}
384
385/// `[ai_completion]` section of `.perl-lsp.toml`.
386#[derive(Debug, Clone, Default, serde::Deserialize)]
387#[serde(default)]
388pub struct ProjectAiCompletionConfig {
389    /// Whether AI completions are enabled.
390    pub enabled: Option<bool>,
391    /// Provider type.
392    pub provider: Option<String>,
393    /// API endpoint URL.
394    pub endpoint: Option<String>,
395    /// Model identifier.
396    pub model: Option<String>,
397    /// Environment variable name for API key.
398    pub api_key_env: Option<String>,
399}
400
401/// Load project config from `<workspace_root>/.perl-lsp.toml`.
402///
403/// Returns `None` if the file does not exist (normal case — most projects won't have one).
404/// Returns `Err` only on TOML parse failure; caller should emit a `window/showMessage` warning.
405pub fn load_project_config(
406    workspace_root: &std::path::Path,
407) -> Result<Option<ProjectConfig>, String> {
408    let path = workspace_root.join(".perl-lsp.toml");
409    match std::fs::read_to_string(&path) {
410        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
411        Err(e) => Err(format!("Failed to read .perl-lsp.toml: {}", e)),
412        Ok(content) => toml::from_str::<ProjectConfig>(&content)
413            .map(Some)
414            .map_err(|e| format!(".perl-lsp.toml parse error: {}", e)),
415    }
416}
417
418impl ProjectConfig {
419    /// Apply project config to `ServerConfig` as the base layer.
420    ///
421    /// Only fields explicitly set in the TOML override defaults; unset fields are untouched.
422    /// LSP `didChangeConfiguration` is expected to run after this, overriding any values here.
423    pub fn apply_to_server_config(&self, config: &mut ServerConfig) {
424        if let Some(enabled) = self.diagnostics.perlcritic {
425            config.perlcritic_enabled = enabled;
426        }
427        if let Some(severity) = self.diagnostics.perlcritic_severity {
428            config.perlcritic_severity = severity.clamp(1, 5);
429        }
430        if let Some(hints) = self.features.inlay_hints {
431            config.inlay_hints_enabled = hints;
432        }
433        if let Some(enabled) = self.ai_completion.enabled {
434            config.ai_completion.enabled = enabled;
435        }
436        if let Some(ref provider) = self.ai_completion.provider {
437            config.ai_completion.provider = provider.clone();
438        }
439        if let Some(ref endpoint) = self.ai_completion.endpoint {
440            config.ai_completion.endpoint = endpoint.clone();
441        }
442        if let Some(ref model) = self.ai_completion.model {
443            config.ai_completion.model = model.clone();
444        }
445        if let Some(ref key_env) = self.ai_completion.api_key_env {
446            config.ai_completion.api_key_env = key_env.clone();
447        }
448    }
449
450    /// Apply project config to `WorkspaceConfig` as the base layer.
451    ///
452    /// Only applies `include_paths` when the TOML list is non-empty, so that
453    /// an absent key leaves the defaults unchanged (distinct from an explicit `[]`).
454    pub fn apply_to_workspace_config(&self, config: &mut WorkspaceConfig) {
455        if !self.perl.include_paths.is_empty() {
456            config.include_paths = self.perl.include_paths.clone();
457        }
458    }
459}
460
461#[cfg(test)]
462mod tests {
463    use super::{ServerConfig, WorkspaceConfig};
464    use serde_json::json;
465
466    // ── ServerConfig defaults ─────────────────────────────────
467
468    #[test]
469    fn server_config_default_inlay_hints_enabled() {
470        let config = ServerConfig::default();
471        assert!(config.inlay_hints_enabled, "inlay hints enabled by default");
472        assert!(config.inlay_hints_parameter_hints, "parameter hints enabled by default");
473        assert!(config.inlay_hints_type_hints, "type hints enabled by default");
474        assert!(!config.inlay_hints_chained_hints, "chained hints disabled by default");
475        assert_eq!(config.inlay_hints_max_length, 30);
476    }
477
478    #[test]
479    fn server_config_default_test_runner() {
480        let config = ServerConfig::default();
481        assert!(config.test_runner_enabled, "test runner enabled by default");
482        assert_eq!(config.test_runner_command, "perl");
483        assert!(config.test_runner_args.is_empty(), "no default test runner args");
484        assert_eq!(config.test_runner_timeout, 60000);
485    }
486
487    #[test]
488    fn server_config_default_telemetry_disabled() {
489        let config = ServerConfig::default();
490        assert!(!config.telemetry_enabled, "telemetry disabled by default");
491    }
492
493    #[test]
494    fn server_config_default_perlcritic_disabled() {
495        let config = ServerConfig::default();
496        assert!(!config.perlcritic_enabled, "perlcritic disabled by default (opt-in)");
497    }
498
499    #[test]
500    fn server_config_perlcritic_enabled_via_update() {
501        let mut config = ServerConfig::default();
502        config.update_from_value(&json!({
503            "perlcritic": { "enabled": true }
504        }));
505        assert!(config.perlcritic_enabled);
506    }
507
508    // ── ServerConfig::update_from_value ──────────────────────
509
510    #[test]
511    fn server_config_updates_selected_fields() {
512        let mut config = ServerConfig::default();
513        config.update_from_value(&json!({
514            "inlayHints": { "enabled": false, "maxLength": 42 },
515            "testRunner": { "enabled": false, "command": "prove", "args": ["-l"] },
516            "telemetry": { "enabled": true }
517        }));
518
519        assert!(!config.inlay_hints_enabled);
520        assert_eq!(config.inlay_hints_max_length, 42);
521        assert!(!config.test_runner_enabled);
522        assert_eq!(config.test_runner_command, "prove");
523        assert_eq!(config.test_runner_args, vec!["-l"]);
524        assert!(config.telemetry_enabled);
525    }
526
527    #[test]
528    fn server_config_partial_update_leaves_unspecified_fields_unchanged() {
529        let mut config = ServerConfig::default();
530        // Only update one inlay hint field
531        config.update_from_value(&json!({
532            "inlayHints": { "enabled": false }
533        }));
534        assert!(!config.inlay_hints_enabled, "updated field changes");
535        assert!(config.inlay_hints_parameter_hints, "unspecified field unchanged");
536        assert_eq!(config.inlay_hints_max_length, 30, "unspecified field unchanged");
537        assert_eq!(config.test_runner_command, "perl", "unrelated section unchanged");
538    }
539
540    #[test]
541    fn server_config_empty_update_leaves_all_defaults_unchanged() {
542        let mut config = ServerConfig::default();
543        config.update_from_value(&json!({}));
544        assert!(config.inlay_hints_enabled);
545        assert_eq!(config.test_runner_command, "perl");
546        assert!(!config.telemetry_enabled);
547    }
548
549    #[test]
550    fn server_config_test_runner_timeout_updated() {
551        let mut config = ServerConfig::default();
552        config.update_from_value(&json!({
553            "testRunner": { "timeout": 30000 }
554        }));
555        assert_eq!(config.test_runner_timeout, 30000);
556    }
557
558    // ── Perlcritic extended config ────────────────────────────
559
560    #[test]
561    fn server_config_default_perlcritic_severity_is_three() {
562        let config = ServerConfig::default();
563        assert_eq!(config.perlcritic_severity, 3, "default severity should be 3 (Harsh)");
564    }
565
566    #[test]
567    fn server_config_default_perlcritic_profile_is_none() {
568        let config = ServerConfig::default();
569        assert!(config.perlcritic_profile.is_none(), "profile is None by default");
570    }
571
572    #[test]
573    fn server_config_perlcritic_severity_updated_via_settings() {
574        let mut config = ServerConfig::default();
575        config.update_from_value(&json!({ "perlcritic": { "severity": 1 } }));
576        assert_eq!(config.perlcritic_severity, 1);
577    }
578
579    #[test]
580    fn server_config_perlcritic_severity_clamped_to_five() {
581        let mut config = ServerConfig::default();
582        config.update_from_value(&json!({ "perlcritic": { "severity": 99 } }));
583        assert_eq!(config.perlcritic_severity, 5, "severity clamped to max 5");
584    }
585
586    #[test]
587    fn server_config_perlcritic_severity_clamped_to_one() {
588        let mut config = ServerConfig::default();
589        config.update_from_value(&json!({ "perlcritic": { "severity": 0 } }));
590        assert_eq!(config.perlcritic_severity, 1, "severity clamped to min 1");
591    }
592
593    #[test]
594    fn server_config_perlcritic_profile_updated_via_settings() {
595        let mut config = ServerConfig::default();
596        config.update_from_value(&json!({ "perlcritic": { "profile": "/path/to/.perlcriticrc" } }));
597        assert_eq!(config.perlcritic_profile, Some("/path/to/.perlcriticrc".to_string()));
598    }
599
600    #[test]
601    fn server_config_perlcritic_all_fields_together() {
602        let mut config = ServerConfig::default();
603        config.update_from_value(&json!({
604            "perlcritic": {
605                "enabled": true,
606                "severity": 2,
607                "profile": "/workspace/.perlcriticrc"
608            }
609        }));
610        assert!(config.perlcritic_enabled);
611        assert_eq!(config.perlcritic_severity, 2);
612        assert_eq!(config.perlcritic_profile, Some("/workspace/.perlcriticrc".to_string()));
613    }
614
615    #[test]
616    fn server_config_perlcritic_partial_update_preserves_other_fields() {
617        let mut config = ServerConfig::default();
618        config.update_from_value(&json!({ "perlcritic": { "enabled": true } }));
619        // severity and profile should still be at defaults
620        assert_eq!(config.perlcritic_severity, 3);
621        assert!(config.perlcritic_profile.is_none());
622    }
623
624    // ── WorkspaceConfig defaults ──────────────────────────────
625
626    #[test]
627    fn workspace_config_defaults_include_common_paths() {
628        let config = WorkspaceConfig::default();
629        assert_eq!(config.include_paths, vec!["lib", ".", "local/lib/perl5"]);
630        assert!(!config.use_system_inc);
631        assert_eq!(config.resolution_timeout_ms, 50);
632    }
633
634    // ── WorkspaceConfig::update_from_value ───────────────────
635
636    #[test]
637    fn workspace_config_updates_include_paths() {
638        let mut config = WorkspaceConfig::default();
639        config.update_from_value(&json!({
640            "workspace": { "includePaths": ["/custom/lib", "/other/lib"] }
641        }));
642        assert_eq!(config.include_paths, vec!["/custom/lib", "/other/lib"]);
643    }
644
645    #[test]
646    fn workspace_config_updates_resolution_timeout() {
647        let mut config = WorkspaceConfig::default();
648        config.update_from_value(&json!({
649            "workspace": { "resolutionTimeout": 100 }
650        }));
651        assert_eq!(config.resolution_timeout_ms, 100);
652    }
653
654    #[test]
655    fn workspace_config_empty_update_leaves_defaults() {
656        let mut config = WorkspaceConfig::default();
657        config.update_from_value(&json!({}));
658        assert_eq!(config.include_paths, vec!["lib", ".", "local/lib/perl5"]);
659        assert!(!config.use_system_inc);
660    }
661
662    // ── WorkspaceConfig::get_system_inc ──────────────────────
663
664    #[test]
665    fn workspace_config_get_system_inc_returns_empty_when_disabled() {
666        let mut config = WorkspaceConfig::default();
667        // use_system_inc = false (default)
668        let inc = config.get_system_inc();
669        assert!(inc.is_empty(), "system inc is empty when use_system_inc=false");
670    }
671
672    // ── AiCompletionConfig ──────────────────────────────────────
673
674    #[test]
675    fn server_config_default_ai_completion_disabled() {
676        let config = ServerConfig::default();
677        assert!(!config.ai_completion.enabled, "AI completion disabled by default");
678        assert_eq!(config.ai_completion.provider, "openai_compat");
679        assert!(config.ai_completion.endpoint.is_empty());
680        assert_eq!(config.ai_completion.timeout_ms, 1800);
681        assert_eq!(config.ai_completion.max_output_tokens, 64);
682        assert!(config.ai_completion.fallback);
683        assert!(config.ai_completion.streaming.enabled);
684        assert_eq!(config.ai_completion.streaming.update_debounce_ms, 60);
685    }
686
687    #[test]
688    fn server_config_ai_completion_updated_via_settings() {
689        let mut config = ServerConfig::default();
690        config.update_from_value(&json!({
691            "aiCompletion": {
692                "enabled": true,
693                "provider": "openai_compat",
694                "endpoint": "https://api.openai.com/v1/chat/completions",
695                "model": "gpt-4o",
696                "apiKeyEnv": "MY_KEY",
697                "timeoutMs": 3000,
698                "maxOutputTokens": 128,
699                "rateLimitRps": 2.0,
700                "maxInflight": 2,
701                "fallback": false,
702                "streaming": {
703                    "enabled": false,
704                    "updateDebounceMs": 100
705                }
706            }
707        }));
708        assert!(config.ai_completion.enabled);
709        assert_eq!(config.ai_completion.endpoint, "https://api.openai.com/v1/chat/completions");
710        assert_eq!(config.ai_completion.model, "gpt-4o");
711        assert_eq!(config.ai_completion.api_key_env, "MY_KEY");
712        assert_eq!(config.ai_completion.timeout_ms, 3000);
713        assert_eq!(config.ai_completion.max_output_tokens, 128);
714        assert!(!config.ai_completion.fallback);
715        assert!(!config.ai_completion.streaming.enabled);
716        assert_eq!(config.ai_completion.streaming.update_debounce_ms, 100);
717    }
718}