deps_lsp/
config.rs

1use serde::Deserialize;
2use tower_lsp_server::ls_types::DiagnosticSeverity;
3
4/// Root configuration for the deps-lsp server.
5///
6/// This configuration can be provided by the LSP client via initialization options
7/// or workspace settings. All fields use sensible defaults if not specified.
8///
9/// # Examples
10///
11/// ```
12/// use deps_lsp::config::DepsConfig;
13///
14/// let json = r#"{
15///     "inlay_hints": {
16///         "enabled": true,
17///         "up_to_date_text": "✅",
18///         "needs_update_text": "❌ {}"
19///     }
20/// }"#;
21///
22/// let config: DepsConfig = serde_json::from_str(json).unwrap();
23/// assert!(config.inlay_hints.enabled);
24/// ```
25#[derive(Debug, Deserialize, Default)]
26pub struct DepsConfig {
27    #[serde(default)]
28    pub inlay_hints: InlayHintsConfig,
29    #[serde(default)]
30    pub diagnostics: DiagnosticsConfig,
31    #[serde(default)]
32    pub cache: CacheConfig,
33    #[serde(default)]
34    pub cold_start: ColdStartConfig,
35    #[serde(default)]
36    pub loading_indicator: LoadingIndicatorConfig,
37}
38
39/// Configuration for inlay hints (inline version annotations).
40///
41/// Controls whether inlay hints are displayed and customizes their appearance.
42/// Inlay hints show version information next to dependency declarations.
43///
44/// # Defaults
45///
46/// - `enabled`: `true`
47/// - `up_to_date_text`: `"✅"`
48/// - `needs_update_text`: `"❌ {}"` (where `{}` is replaced with the latest version)
49///
50/// # Examples
51///
52/// ```
53/// use deps_lsp::config::InlayHintsConfig;
54///
55/// let config = InlayHintsConfig {
56///     enabled: true,
57///     up_to_date_text: "OK".into(),
58///     needs_update_text: "UPDATE {}".into(),
59/// };
60///
61/// assert_eq!(config.up_to_date_text, "OK");
62/// ```
63#[derive(Debug, Clone, Deserialize)]
64pub struct InlayHintsConfig {
65    #[serde(default = "default_true")]
66    pub enabled: bool,
67    #[serde(default = "default_up_to_date")]
68    pub up_to_date_text: String,
69    #[serde(default = "default_needs_update")]
70    pub needs_update_text: String,
71}
72
73impl Default for InlayHintsConfig {
74    fn default() -> Self {
75        Self {
76            enabled: true,
77            up_to_date_text: default_up_to_date(),
78            needs_update_text: default_needs_update(),
79        }
80    }
81}
82
83/// Configuration for diagnostic severity levels.
84///
85/// Controls the severity level reported for different types of dependency issues.
86/// This allows users to customize whether issues appear as errors, warnings, hints, etc.
87///
88/// # Defaults
89///
90/// - `outdated_severity`: `HINT` - Dependencies with available updates
91/// - `unknown_severity`: `WARNING` - Dependencies not found in registry
92/// - `yanked_severity`: `WARNING` - Dependencies using yanked versions
93///
94/// # Examples
95///
96/// ```
97/// use deps_lsp::config::DiagnosticsConfig;
98/// use tower_lsp_server::ls_types::DiagnosticSeverity;
99///
100/// let config = DiagnosticsConfig {
101///     outdated_severity: DiagnosticSeverity::INFORMATION,
102///     unknown_severity: DiagnosticSeverity::ERROR,
103///     yanked_severity: DiagnosticSeverity::ERROR,
104/// };
105///
106/// assert_eq!(config.unknown_severity, DiagnosticSeverity::ERROR);
107/// ```
108#[derive(Debug, Clone, Deserialize)]
109pub struct DiagnosticsConfig {
110    #[serde(default = "default_outdated_severity")]
111    pub outdated_severity: DiagnosticSeverity,
112    #[serde(default = "default_unknown_severity")]
113    pub unknown_severity: DiagnosticSeverity,
114    #[serde(default = "default_yanked_severity")]
115    pub yanked_severity: DiagnosticSeverity,
116}
117
118impl Default for DiagnosticsConfig {
119    fn default() -> Self {
120        Self {
121            outdated_severity: default_outdated_severity(),
122            unknown_severity: default_unknown_severity(),
123            yanked_severity: default_yanked_severity(),
124        }
125    }
126}
127
128/// Configuration for HTTP caching behavior.
129///
130/// Controls cache settings for registry requests. The cache uses ETag and
131/// Last-Modified headers for validation, minimizing network traffic.
132///
133/// # Defaults
134///
135/// - `enabled`: `true`
136/// - `refresh_interval_secs`: `300` (5 minutes)
137/// - `fetch_timeout_secs`: `10` (10 seconds per package)
138/// - `max_concurrent_fetches`: `20` (20 concurrent requests)
139///
140/// # Examples
141///
142/// ```
143/// use deps_lsp::config::CacheConfig;
144///
145/// let config = CacheConfig {
146///     refresh_interval_secs: 600, // 10 minutes
147///     enabled: true,
148///     fetch_timeout_secs: 5,
149///     max_concurrent_fetches: 20,
150/// };
151///
152/// assert_eq!(config.refresh_interval_secs, 600);
153/// ```
154#[derive(Debug, Clone, Deserialize)]
155pub struct CacheConfig {
156    #[serde(default = "default_refresh_interval")]
157    pub refresh_interval_secs: u64,
158    #[serde(default = "default_true")]
159    pub enabled: bool,
160    /// Timeout for fetching a single package's versions (default: 10 seconds)
161    #[serde(
162        default = "default_fetch_timeout_secs",
163        deserialize_with = "deserialize_fetch_timeout"
164    )]
165    pub fetch_timeout_secs: u64,
166    /// Maximum concurrent package fetches (default: 20)
167    #[serde(
168        default = "default_max_concurrent_fetches",
169        deserialize_with = "deserialize_max_concurrent"
170    )]
171    pub max_concurrent_fetches: usize,
172}
173
174impl Default for CacheConfig {
175    fn default() -> Self {
176        Self {
177            refresh_interval_secs: default_refresh_interval(),
178            enabled: true,
179            fetch_timeout_secs: default_fetch_timeout_secs(),
180            max_concurrent_fetches: default_max_concurrent_fetches(),
181        }
182    }
183}
184
185/// Configuration for loading indicator behavior.
186///
187/// Controls how the server shows loading feedback when fetching registry data.
188///
189/// # Defaults
190///
191/// - `enabled`: `true`
192/// - `fallback_to_hints`: `true`
193/// - `loading_text`: `"⏳"`
194#[derive(Debug, Clone, Deserialize)]
195pub struct LoadingIndicatorConfig {
196    /// Enable loading indicators (default: true)
197    #[serde(default = "default_true")]
198    pub enabled: bool,
199
200    /// Show progress in inlay hints if LSP progress not supported (default: true)
201    #[serde(default = "default_true")]
202    pub fallback_to_hints: bool,
203
204    /// Loading text to show in inlay hints (default: "⏳")
205    /// Maximum length: 100 characters (truncated with warning if exceeded)
206    #[serde(
207        default = "default_loading_text",
208        deserialize_with = "deserialize_loading_text"
209    )]
210    pub loading_text: String,
211}
212
213impl Default for LoadingIndicatorConfig {
214    fn default() -> Self {
215        Self {
216            enabled: true,
217            fallback_to_hints: true,
218            loading_text: default_loading_text(),
219        }
220    }
221}
222
223// Default value functions
224const fn default_true() -> bool {
225    true
226}
227
228fn default_up_to_date() -> String {
229    "✅".to_string()
230}
231
232fn default_needs_update() -> String {
233    "❌ {}".to_string()
234}
235
236fn default_loading_text() -> String {
237    "⏳".to_string()
238}
239
240/// Maximum length for loading_text (security limit)
241const MAX_LOADING_TEXT_LENGTH: usize = 100;
242
243/// Truncates and validates loading_text to prevent abuse
244fn validate_loading_text(text: String) -> String {
245    if text.len() > MAX_LOADING_TEXT_LENGTH {
246        tracing::warn!(
247            "loading_text exceeded max length of {} chars, truncating from {} to {}",
248            MAX_LOADING_TEXT_LENGTH,
249            text.len(),
250            MAX_LOADING_TEXT_LENGTH
251        );
252        text.chars().take(MAX_LOADING_TEXT_LENGTH).collect()
253    } else {
254        text
255    }
256}
257
258/// Custom deserializer for loading_text that validates length
259fn deserialize_loading_text<'de, D>(deserializer: D) -> Result<String, D::Error>
260where
261    D: serde::Deserializer<'de>,
262{
263    let text = String::deserialize(deserializer)?;
264    Ok(validate_loading_text(text))
265}
266
267const fn default_outdated_severity() -> DiagnosticSeverity {
268    DiagnosticSeverity::HINT
269}
270
271const fn default_unknown_severity() -> DiagnosticSeverity {
272    DiagnosticSeverity::WARNING
273}
274
275const fn default_yanked_severity() -> DiagnosticSeverity {
276    DiagnosticSeverity::WARNING
277}
278
279const fn default_refresh_interval() -> u64 {
280    300 // 5 minutes
281}
282
283const fn default_fetch_timeout_secs() -> u64 {
284    10
285}
286
287const fn default_max_concurrent_fetches() -> usize {
288    5
289}
290
291/// Minimum timeout (seconds) to prevent zero-timeout edge case
292const MIN_FETCH_TIMEOUT_SECS: u64 = 1;
293/// Maximum timeout (seconds) - 5 minutes is generous
294const MAX_FETCH_TIMEOUT_SECS: u64 = 300;
295
296/// Minimum concurrent fetches (must be at least 1)
297const MIN_CONCURRENT_FETCHES: usize = 1;
298/// Maximum concurrent fetches
299const MAX_CONCURRENT_FETCHES: usize = 100;
300
301/// Custom deserializer for fetch_timeout_secs that validates bounds
302fn deserialize_fetch_timeout<'de, D>(deserializer: D) -> Result<u64, D::Error>
303where
304    D: serde::Deserializer<'de>,
305{
306    let secs = u64::deserialize(deserializer)?;
307    let clamped = secs.clamp(MIN_FETCH_TIMEOUT_SECS, MAX_FETCH_TIMEOUT_SECS);
308    if clamped != secs {
309        tracing::warn!(
310            "fetch_timeout_secs {} clamped to {} (valid range: {}-{})",
311            secs,
312            clamped,
313            MIN_FETCH_TIMEOUT_SECS,
314            MAX_FETCH_TIMEOUT_SECS
315        );
316    }
317    Ok(clamped)
318}
319
320/// Custom deserializer for max_concurrent_fetches that validates bounds
321fn deserialize_max_concurrent<'de, D>(deserializer: D) -> Result<usize, D::Error>
322where
323    D: serde::Deserializer<'de>,
324{
325    let count = usize::deserialize(deserializer)?;
326    let clamped = count.clamp(MIN_CONCURRENT_FETCHES, MAX_CONCURRENT_FETCHES);
327    if clamped != count {
328        tracing::warn!(
329            "max_concurrent_fetches {} clamped to {} (valid range: {}-{})",
330            count,
331            clamped,
332            MIN_CONCURRENT_FETCHES,
333            MAX_CONCURRENT_FETCHES
334        );
335    }
336    Ok(clamped)
337}
338
339/// Configuration for cold start behavior.
340///
341/// Controls how the server handles loading documents from disk when
342/// they haven't been explicitly opened via didOpen notifications.
343///
344/// # Defaults
345///
346/// - `enabled`: `true`
347/// - `rate_limit_ms`: `100` (10 req/sec per URI)
348///
349/// # Security
350///
351/// File size limit (10MB) is hardcoded and NOT configurable for security reasons.
352/// See `loader::MAX_FILE_SIZE` constant.
353///
354/// # Examples
355///
356/// ```
357/// use deps_lsp::config::ColdStartConfig;
358///
359/// let config = ColdStartConfig {
360///     enabled: true,
361///     rate_limit_ms: 200,
362/// };
363///
364/// assert_eq!(config.rate_limit_ms, 200);
365/// ```
366#[derive(Debug, Clone, Deserialize)]
367pub struct ColdStartConfig {
368    #[serde(default = "default_true")]
369    pub enabled: bool,
370    #[serde(default = "default_rate_limit_ms")]
371    pub rate_limit_ms: u64,
372}
373
374impl Default for ColdStartConfig {
375    fn default() -> Self {
376        Self {
377            enabled: true,
378            rate_limit_ms: default_rate_limit_ms(),
379        }
380    }
381}
382
383const fn default_rate_limit_ms() -> u64 {
384    100 // 10 req/sec per URI
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390
391    #[test]
392    fn test_default_config() {
393        let config = DepsConfig::default();
394        assert!(config.inlay_hints.enabled);
395        assert_eq!(config.inlay_hints.up_to_date_text, "✅");
396        assert_eq!(config.inlay_hints.needs_update_text, "❌ {}");
397    }
398
399    #[test]
400    fn test_inlay_hints_config_deserialization() {
401        let json = r#"{
402            "enabled": false,
403            "up_to_date_text": "OK",
404            "needs_update_text": "UPDATE {}"
405        }"#;
406
407        let config: InlayHintsConfig = serde_json::from_str(json).unwrap();
408        assert!(!config.enabled);
409        assert_eq!(config.up_to_date_text, "OK");
410        assert_eq!(config.needs_update_text, "UPDATE {}");
411    }
412
413    #[test]
414    fn test_diagnostics_config_deserialization() {
415        let json = r#"{
416            "outdated_severity": 1,
417            "unknown_severity": 2,
418            "yanked_severity": 2
419        }"#;
420
421        let config: DiagnosticsConfig = serde_json::from_str(json).unwrap();
422        assert_eq!(config.outdated_severity, DiagnosticSeverity::ERROR);
423        assert_eq!(config.unknown_severity, DiagnosticSeverity::WARNING);
424        assert_eq!(config.yanked_severity, DiagnosticSeverity::WARNING);
425    }
426
427    #[test]
428    fn test_cache_config_deserialization() {
429        let json = r#"{
430            "refresh_interval_secs": 600,
431            "enabled": false
432        }"#;
433
434        let config: CacheConfig = serde_json::from_str(json).unwrap();
435        assert_eq!(config.refresh_interval_secs, 600);
436        assert!(!config.enabled);
437    }
438
439    #[test]
440    fn test_cache_config_defaults() {
441        let config = CacheConfig::default();
442        assert!(config.enabled);
443        assert_eq!(config.refresh_interval_secs, 300);
444        assert_eq!(config.fetch_timeout_secs, 10);
445        assert_eq!(config.max_concurrent_fetches, 5);
446    }
447
448    #[test]
449    fn test_cache_config_with_timeout_and_concurrency() {
450        let json = r#"{
451            "refresh_interval_secs": 600,
452            "enabled": true,
453            "fetch_timeout_secs": 10,
454            "max_concurrent_fetches": 50
455        }"#;
456
457        let config: CacheConfig = serde_json::from_str(json).unwrap();
458        assert_eq!(config.refresh_interval_secs, 600);
459        assert!(config.enabled);
460        assert_eq!(config.fetch_timeout_secs, 10);
461        assert_eq!(config.max_concurrent_fetches, 50);
462    }
463
464    #[test]
465    fn test_full_config_deserialization() {
466        let json = r#"{
467            "inlay_hints": {
468                "enabled": true,
469                "up_to_date_text": "✅",
470                "needs_update_text": "❌ {}"
471            },
472            "diagnostics": {
473                "outdated_severity": 4,
474                "unknown_severity": 2,
475                "yanked_severity": 2
476            },
477            "cache": {
478                "refresh_interval_secs": 300,
479                "enabled": true
480            }
481        }"#;
482
483        let config: DepsConfig = serde_json::from_str(json).unwrap();
484        assert!(config.inlay_hints.enabled);
485        assert_eq!(
486            config.diagnostics.outdated_severity,
487            DiagnosticSeverity::HINT
488        );
489        assert_eq!(config.cache.refresh_interval_secs, 300);
490    }
491
492    #[test]
493    fn test_partial_config_deserialization() {
494        let json = r#"{
495            "inlay_hints": {
496                "enabled": false
497            }
498        }"#;
499
500        let config: DepsConfig = serde_json::from_str(json).unwrap();
501        assert!(!config.inlay_hints.enabled);
502        // Other fields should use defaults
503        assert_eq!(config.inlay_hints.up_to_date_text, "✅");
504        assert_eq!(
505            config.diagnostics.outdated_severity,
506            DiagnosticSeverity::HINT
507        );
508    }
509
510    #[test]
511    fn test_empty_config_deserialization() {
512        let json = r"{}";
513        let config: DepsConfig = serde_json::from_str(json).unwrap();
514        // All fields should use defaults
515        assert!(config.inlay_hints.enabled);
516        assert!(config.cache.enabled);
517    }
518
519    #[test]
520    fn test_cold_start_config_defaults() {
521        let config = ColdStartConfig::default();
522        assert!(config.enabled);
523        assert_eq!(config.rate_limit_ms, 100);
524    }
525
526    #[test]
527    fn test_cold_start_config_deserialization() {
528        let json = r#"{
529            "enabled": false,
530            "rate_limit_ms": 200
531        }"#;
532
533        let config: ColdStartConfig = serde_json::from_str(json).unwrap();
534        assert!(!config.enabled);
535        assert_eq!(config.rate_limit_ms, 200);
536    }
537
538    #[test]
539    fn test_full_config_with_cold_start() {
540        let json = r#"{
541            "cold_start": {
542                "enabled": true,
543                "rate_limit_ms": 150
544            }
545        }"#;
546
547        let config: DepsConfig = serde_json::from_str(json).unwrap();
548        assert!(config.cold_start.enabled);
549        assert_eq!(config.cold_start.rate_limit_ms, 150);
550    }
551
552    #[test]
553    fn test_loading_indicator_config_defaults() {
554        let config = LoadingIndicatorConfig::default();
555        assert!(config.enabled);
556        assert!(config.fallback_to_hints);
557        assert_eq!(config.loading_text, "⏳");
558    }
559
560    #[test]
561    fn test_loading_indicator_config_deserialization() {
562        let json = r#"{
563            "enabled": false,
564            "fallback_to_hints": false,
565            "loading_text": "Loading..."
566        }"#;
567
568        let config: LoadingIndicatorConfig = serde_json::from_str(json).unwrap();
569        assert!(!config.enabled);
570        assert!(!config.fallback_to_hints);
571        assert_eq!(config.loading_text, "Loading...");
572    }
573
574    #[test]
575    fn test_loading_text_truncation() {
576        let long_text = "a".repeat(150);
577        let json = format!(
578            r#"{{
579            "enabled": true,
580            "fallback_to_hints": true,
581            "loading_text": "{}"
582        }}"#,
583            long_text
584        );
585
586        let config: LoadingIndicatorConfig = serde_json::from_str(&json).unwrap();
587        assert_eq!(config.loading_text.len(), 100);
588        assert_eq!(config.loading_text, "a".repeat(100));
589    }
590
591    #[test]
592    fn test_loading_text_exactly_100_chars() {
593        let text = "a".repeat(100);
594        let json = format!(
595            r#"{{
596            "enabled": true,
597            "fallback_to_hints": true,
598            "loading_text": "{}"
599        }}"#,
600            text
601        );
602
603        let config: LoadingIndicatorConfig = serde_json::from_str(&json).unwrap();
604        assert_eq!(config.loading_text.len(), 100);
605        assert_eq!(config.loading_text, text);
606    }
607
608    #[test]
609    fn test_loading_text_under_limit() {
610        let json = r#"{
611            "enabled": true,
612            "fallback_to_hints": true,
613            "loading_text": "⏳ Loading dependencies..."
614        }"#;
615
616        let config: LoadingIndicatorConfig = serde_json::from_str(json).unwrap();
617        assert_eq!(config.loading_text, "⏳ Loading dependencies...");
618        assert!(config.loading_text.len() < 100);
619    }
620
621    #[test]
622    fn test_loading_text_default() {
623        let json = r#"{
624            "enabled": true,
625            "fallback_to_hints": true
626        }"#;
627
628        let config: LoadingIndicatorConfig = serde_json::from_str(json).unwrap();
629        assert_eq!(config.loading_text, "⏳");
630    }
631
632    #[test]
633    fn test_cache_config_fetch_timeout_clamped_min() {
634        let json = r#"{"fetch_timeout_secs": 0}"#;
635        let config: CacheConfig = serde_json::from_str(json).unwrap();
636        assert_eq!(config.fetch_timeout_secs, 1, "Should clamp 0 to MIN");
637    }
638
639    #[test]
640    fn test_cache_config_fetch_timeout_clamped_max() {
641        let json = r#"{"fetch_timeout_secs": 999999}"#;
642        let config: CacheConfig = serde_json::from_str(json).unwrap();
643        assert_eq!(config.fetch_timeout_secs, 300, "Should clamp to MAX");
644    }
645
646    #[test]
647    fn test_cache_config_fetch_timeout_valid_range() {
648        let json = r#"{"fetch_timeout_secs": 10}"#;
649        let config: CacheConfig = serde_json::from_str(json).unwrap();
650        assert_eq!(
651            config.fetch_timeout_secs, 10,
652            "Valid value should not be clamped"
653        );
654    }
655
656    #[test]
657    fn test_cache_config_max_concurrent_clamped_min() {
658        let json = r#"{"max_concurrent_fetches": 0}"#;
659        let config: CacheConfig = serde_json::from_str(json).unwrap();
660        assert_eq!(config.max_concurrent_fetches, 1, "Should clamp 0 to MIN");
661    }
662
663    #[test]
664    fn test_cache_config_max_concurrent_clamped_max() {
665        let json = r#"{"max_concurrent_fetches": 100000}"#;
666        let config: CacheConfig = serde_json::from_str(json).unwrap();
667        assert_eq!(config.max_concurrent_fetches, 100, "Should clamp to MAX");
668    }
669
670    #[test]
671    fn test_cache_config_max_concurrent_valid_range() {
672        let json = r#"{"max_concurrent_fetches": 50}"#;
673        let config: CacheConfig = serde_json::from_str(json).unwrap();
674        assert_eq!(
675            config.max_concurrent_fetches, 50,
676            "Valid value should not be clamped"
677        );
678    }
679}