Skip to main content

fresh/services/
packages.rs

1//! Package manifest types and startup package scanning.
2//!
3//! This module handles loading installed packages (language packs, bundles) at
4//! startup from Rust, replacing the JS-based `loadInstalledPackages()` in the
5//! pkg plugin. This eliminates async grammar rebuilds from plugin callbacks.
6
7use std::path::{Path, PathBuf};
8
9use schemars::JsonSchema;
10use serde::Deserialize;
11
12use crate::config::{FormatterConfig, LanguageConfig};
13use crate::primitives::grammar::GrammarSpec;
14use crate::types::{LspServerConfig, ProcessLimits};
15
16// ── Manifest types ──────────────────────────────────────────────────────
17
18/// Top-level package.json manifest for Fresh packages.
19///
20/// Matches the schema in `plugins/schemas/package.schema.json`.
21/// All optional fields use `#[serde(default)]` so that unknown or missing
22/// fields are silently ignored — ensuring forward compatibility.
23#[derive(Debug, Clone, Deserialize, JsonSchema)]
24#[schemars(
25    title = "Fresh Package Manifest",
26    description = "Schema for Fresh plugin and theme package.json files"
27)]
28pub struct PackageManifest {
29    /// Package name (lowercase, hyphens allowed)
30    #[schemars(regex(pattern = r"^[a-z0-9-]+$"))]
31    pub name: String,
32
33    /// Semantic version (e.g., 1.0.0)
34    #[serde(default)]
35    #[schemars(regex(pattern = r"^\d+\.\d+\.\d+"))]
36    pub version: Option<String>,
37
38    /// Short package description
39    #[serde(default)]
40    pub description: Option<String>,
41
42    /// Package type
43    #[serde(rename = "type", default)]
44    pub package_type: Option<PackageType>,
45
46    /// Fresh-specific configuration
47    #[serde(default)]
48    pub fresh: Option<FreshManifestConfig>,
49
50    /// Author name
51    #[serde(default)]
52    pub author: Option<String>,
53
54    /// SPDX license identifier
55    #[serde(default)]
56    pub license: Option<String>,
57
58    /// Git repository URL
59    #[serde(default)]
60    pub repository: Option<String>,
61
62    /// Search keywords
63    #[serde(default)]
64    pub keywords: Vec<String>,
65
66    /// Package dependencies (reserved for future use)
67    #[serde(default)]
68    pub dependencies: std::collections::HashMap<String, String>,
69}
70
71/// Package type discriminator.
72#[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq, Eq)]
73#[serde(rename_all = "kebab-case")]
74pub enum PackageType {
75    Plugin,
76    Theme,
77    ThemePack,
78    Language,
79    Bundle,
80}
81
82/// The `fresh` configuration block inside a package manifest.
83#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
84pub struct FreshManifestConfig {
85    /// Minimum required Fresh version
86    #[serde(default)]
87    pub min_version: Option<String>,
88
89    /// Minimum required plugin API version
90    #[serde(default)]
91    pub min_api_version: Option<u32>,
92
93    /// Plugin entry point file
94    #[serde(default)]
95    pub entry: Option<String>,
96
97    /// Plugin entry point file (alias for entry)
98    #[serde(default)]
99    pub main: Option<String>,
100
101    /// Single theme JSON file path (for theme packages)
102    #[serde(default)]
103    pub theme: Option<String>,
104
105    /// Theme definitions (for theme packs and bundles)
106    #[serde(default)]
107    pub themes: Vec<BundleTheme>,
108
109    /// JSON Schema for plugin configuration options
110    #[serde(default)]
111    pub config_schema: Option<serde_json::Value>,
112
113    /// Grammar configuration (for language packs)
114    #[serde(default)]
115    pub grammar: Option<GrammarManifestConfig>,
116
117    /// Language configuration (for language packs)
118    #[serde(default)]
119    pub language: Option<LanguageManifestConfig>,
120
121    /// LSP server configuration (for language packs)
122    #[serde(default)]
123    pub lsp: Option<LspManifestConfig>,
124
125    /// Language definitions (for bundles)
126    #[serde(default)]
127    pub languages: Vec<BundleLanguage>,
128
129    /// Plugin definitions (for bundles)
130    #[serde(default)]
131    pub plugins: Vec<BundlePlugin>,
132}
133
134/// Grammar file configuration within a package manifest.
135#[derive(Debug, Clone, Deserialize, JsonSchema)]
136pub struct GrammarManifestConfig {
137    /// Path to grammar file (.sublime-syntax or .tmLanguage), relative to package
138    pub file: String,
139
140    /// File extensions this grammar handles (e.g., ["rs", "rust"])
141    #[serde(default)]
142    pub extensions: Vec<String>,
143
144    /// Regex pattern for shebang/first-line detection
145    #[serde(rename = "firstLine", default)]
146    pub first_line: Option<String>,
147
148    /// Optional short name alias for this grammar (e.g., "hare").
149    /// Must be unique across all grammars; collisions are rejected with a warning.
150    #[serde(rename = "shortName", default)]
151    pub short_name: Option<String>,
152}
153
154/// Language configuration within a package manifest (camelCase to match JSON schema).
155#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
156#[serde(rename_all = "camelCase")]
157pub struct LanguageManifestConfig {
158    /// Line comment prefix (e.g., "//" or "#")
159    #[serde(default)]
160    pub comment_prefix: Option<String>,
161
162    /// Block comment start marker (e.g., "/*")
163    #[serde(default)]
164    pub block_comment_start: Option<String>,
165
166    /// Block comment end marker (e.g., "*/")
167    #[serde(default)]
168    pub block_comment_end: Option<String>,
169
170    /// Default tab size for this language
171    #[serde(default)]
172    pub tab_size: Option<usize>,
173
174    /// Use tabs instead of spaces
175    #[serde(default)]
176    pub use_tabs: Option<bool>,
177
178    /// Enable automatic indentation
179    #[serde(default)]
180    pub auto_indent: Option<bool>,
181
182    /// Whether to show whitespace tab indicators
183    #[serde(default)]
184    pub show_whitespace_tabs: Option<bool>,
185
186    /// Formatter configuration
187    #[serde(default)]
188    pub formatter: Option<FormatterManifestConfig>,
189}
190
191/// Formatter configuration within a package manifest.
192#[derive(Debug, Clone, Deserialize, JsonSchema)]
193pub struct FormatterManifestConfig {
194    /// Formatter command (e.g., "rustfmt", "prettier")
195    pub command: String,
196
197    /// Arguments to pass to the formatter
198    #[serde(default)]
199    pub args: Vec<String>,
200}
201
202/// LSP server configuration within a package manifest (camelCase to match JSON schema).
203#[derive(Debug, Clone, Deserialize, JsonSchema)]
204#[serde(rename_all = "camelCase")]
205pub struct LspManifestConfig {
206    /// LSP server command
207    pub command: String,
208
209    /// Arguments to pass to the server
210    #[serde(default)]
211    pub args: Vec<String>,
212
213    /// Auto-start the server when a matching file is opened
214    #[serde(default)]
215    pub auto_start: Option<bool>,
216
217    /// LSP initialization options
218    #[serde(default)]
219    pub initialization_options: Option<serde_json::Value>,
220
221    /// Process resource limits
222    #[serde(default)]
223    pub process_limits: Option<ProcessLimitsManifestConfig>,
224}
225
226/// Process limits within a package manifest (camelCase to match JSON schema).
227#[derive(Debug, Clone, Deserialize, JsonSchema)]
228#[serde(rename_all = "camelCase")]
229pub struct ProcessLimitsManifestConfig {
230    #[serde(default)]
231    pub max_memory_percent: Option<u32>,
232    #[serde(default)]
233    pub max_cpu_percent: Option<u32>,
234    #[serde(default)]
235    pub enabled: Option<bool>,
236}
237
238/// A language entry within a bundle manifest.
239#[derive(Debug, Clone, Deserialize, JsonSchema)]
240pub struct BundleLanguage {
241    /// Language identifier (e.g., "elixir", "heex")
242    pub id: String,
243
244    /// Grammar configuration for this language
245    #[serde(default)]
246    pub grammar: Option<GrammarManifestConfig>,
247
248    /// Language configuration for this language
249    #[serde(default)]
250    pub language: Option<LanguageManifestConfig>,
251
252    /// LSP server configuration for this language
253    #[serde(default)]
254    pub lsp: Option<LspManifestConfig>,
255}
256
257/// A plugin entry within a bundle manifest.
258#[derive(Debug, Clone, Deserialize, JsonSchema)]
259pub struct BundlePlugin {
260    /// Plugin entry point file relative to package
261    pub entry: String,
262}
263
264/// A theme entry within a package manifest.
265#[derive(Debug, Clone, Deserialize, JsonSchema)]
266pub struct BundleTheme {
267    /// Theme JSON file path relative to package
268    pub file: String,
269
270    /// Display name for the theme
271    pub name: String,
272
273    /// Theme variant (dark or light)
274    #[serde(default)]
275    pub variant: Option<ThemeVariant>,
276}
277
278/// Theme variant (dark or light).
279#[derive(Debug, Clone, Deserialize, JsonSchema)]
280#[serde(rename_all = "lowercase")]
281pub enum ThemeVariant {
282    Dark,
283    Light,
284}
285
286// ── Conversion helpers ──────────────────────────────────────────────────
287
288impl LanguageManifestConfig {
289    /// Convert to the internal `LanguageConfig` used by the editor.
290    pub fn to_language_config(&self) -> LanguageConfig {
291        LanguageConfig {
292            comment_prefix: self.comment_prefix.clone(),
293            auto_indent: self.auto_indent.unwrap_or(true),
294            show_whitespace_tabs: self.show_whitespace_tabs.unwrap_or(true),
295            use_tabs: self.use_tabs,
296            tab_size: self.tab_size,
297            formatter: self.formatter.as_ref().map(|f| FormatterConfig {
298                command: f.command.clone(),
299                args: f.args.clone(),
300                stdin: true,
301                timeout_ms: 10000,
302            }),
303            ..Default::default()
304        }
305    }
306}
307
308impl LspManifestConfig {
309    /// Convert to the internal `LspServerConfig` used by the editor.
310    pub fn to_lsp_config(&self) -> LspServerConfig {
311        let process_limits = self
312            .process_limits
313            .as_ref()
314            .map(|pl| ProcessLimits {
315                max_memory_percent: pl.max_memory_percent,
316                max_cpu_percent: pl.max_cpu_percent,
317                enabled: pl
318                    .enabled
319                    .unwrap_or(pl.max_memory_percent.is_some() || pl.max_cpu_percent.is_some()),
320            })
321            .unwrap_or_default();
322
323        LspServerConfig {
324            command: self.command.clone(),
325            args: self.args.clone(),
326            enabled: true,
327            auto_start: self.auto_start.unwrap_or(true),
328            initialization_options: self.initialization_options.clone(),
329            process_limits,
330            ..Default::default()
331        }
332    }
333}
334
335// ── Package scanner ─────────────────────────────────────────────────────
336
337/// Results of scanning installed packages at startup.
338#[derive(Debug, Default)]
339pub struct PackageScanResult {
340    /// Language configs to insert into Config.languages (package defaults)
341    pub language_configs: Vec<(String, LanguageConfig)>,
342    /// LSP configs to apply (package defaults)
343    pub lsp_configs: Vec<(String, LspServerConfig)>,
344    /// Additional grammar files for the background build
345    pub additional_grammars: Vec<GrammarSpec>,
346    /// Bundle plugin directories to add to the plugin loading list
347    pub bundle_plugin_dirs: Vec<PathBuf>,
348    /// Bundle theme directories (for theme loader to scan)
349    pub bundle_theme_dirs: Vec<PathBuf>,
350}
351
352/// Scan all installed packages and collect configs, grammars, plugin dirs, and theme dirs.
353///
354/// This replaces the JS `loadInstalledPackages()` function, running synchronously
355/// during editor startup before plugin loading. The scan covers:
356/// - `languages/packages/` — language packs with grammar, language config, LSP config
357/// - `bundles/packages/` — bundles with multiple languages, plugins, and themes
358pub fn scan_installed_packages(config_dir: &Path) -> PackageScanResult {
359    let mut result = PackageScanResult::default();
360
361    // Scan language packs
362    let languages_dir = config_dir.join("languages/packages");
363    if languages_dir.is_dir() {
364        scan_language_packs(&languages_dir, &mut result);
365    }
366
367    // Scan bundles
368    let bundles_dir = config_dir.join("bundles/packages");
369    if bundles_dir.is_dir() {
370        scan_bundles(&bundles_dir, &mut result);
371    }
372
373    tracing::info!(
374        "[package-scan] Found {} language configs, {} LSP configs, {} grammars, {} bundle plugin dirs, {} bundle theme dirs",
375        result.language_configs.len(),
376        result.lsp_configs.len(),
377        result.additional_grammars.len(),
378        result.bundle_plugin_dirs.len(),
379        result.bundle_theme_dirs.len(),
380    );
381
382    result
383}
384
385/// Scan language packs from `languages/packages/`.
386fn scan_language_packs(dir: &Path, result: &mut PackageScanResult) {
387    let entries = match std::fs::read_dir(dir) {
388        Ok(e) => e,
389        Err(e) => {
390            tracing::debug!("[package-scan] Failed to read {:?}: {}", dir, e);
391            return;
392        }
393    };
394
395    for entry in entries.flatten() {
396        let pkg_dir = entry.path();
397        if !pkg_dir.is_dir() {
398            continue;
399        }
400        let manifest_path = pkg_dir.join("package.json");
401        if let Some(manifest) = read_manifest(&manifest_path) {
402            process_language_pack(&pkg_dir, &manifest, result);
403        }
404    }
405}
406
407/// Process a single language pack manifest.
408fn process_language_pack(
409    _pkg_dir: &Path,
410    manifest: &PackageManifest,
411    result: &mut PackageScanResult,
412) {
413    let fresh = match &manifest.fresh {
414        Some(f) => f,
415        None => return,
416    };
417
418    let lang_id = manifest.name.clone();
419
420    // Grammar (note: the grammar loader already handles languages/packages/ grammars
421    // via load_language_pack_grammars, so we don't add them to additional_grammars here)
422
423    // Language config
424    if let Some(lang_config) = &fresh.language {
425        result
426            .language_configs
427            .push((lang_id.clone(), lang_config.to_language_config()));
428    }
429
430    // LSP config
431    if let Some(lsp_config) = &fresh.lsp {
432        result
433            .lsp_configs
434            .push((lang_id.clone(), lsp_config.to_lsp_config()));
435    }
436}
437
438/// Scan bundles from `bundles/packages/`.
439fn scan_bundles(dir: &Path, result: &mut PackageScanResult) {
440    let entries = match std::fs::read_dir(dir) {
441        Ok(e) => e,
442        Err(e) => {
443            tracing::debug!("[package-scan] Failed to read {:?}: {}", dir, e);
444            return;
445        }
446    };
447
448    for entry in entries.flatten() {
449        let pkg_dir = entry.path();
450        if !pkg_dir.is_dir() {
451            continue;
452        }
453        let manifest_path = pkg_dir.join("package.json");
454        if let Some(manifest) = read_manifest(&manifest_path) {
455            process_bundle(&pkg_dir, &manifest, result);
456        }
457    }
458}
459
460/// Process a single bundle manifest.
461fn process_bundle(pkg_dir: &Path, manifest: &PackageManifest, result: &mut PackageScanResult) {
462    let fresh = match &manifest.fresh {
463        Some(f) => f,
464        None => return,
465    };
466
467    // Process each language in the bundle
468    for lang in &fresh.languages {
469        // Grammar
470        if let Some(grammar) = &lang.grammar {
471            let grammar_path = pkg_dir.join(&grammar.file);
472            if grammar_path.exists() {
473                result.additional_grammars.push(GrammarSpec {
474                    language: lang.id.clone(),
475                    path: grammar_path,
476                    extensions: grammar.extensions.clone(),
477                });
478            } else {
479                tracing::warn!(
480                    "[package-scan] Grammar file not found for '{}' in bundle '{}': {:?}",
481                    lang.id,
482                    manifest.name,
483                    grammar_path
484                );
485            }
486        }
487
488        // Language config
489        if let Some(lang_config) = &lang.language {
490            result
491                .language_configs
492                .push((lang.id.clone(), lang_config.to_language_config()));
493        }
494
495        // LSP config
496        if let Some(lsp_config) = &lang.lsp {
497            result
498                .lsp_configs
499                .push((lang.id.clone(), lsp_config.to_lsp_config()));
500        }
501    }
502
503    // Bundle plugins
504    for plugin in &fresh.plugins {
505        let entry_path = pkg_dir.join(&plugin.entry);
506        // The plugin loader expects the directory, not the entry file
507        if let Some(plugin_dir) = entry_path.parent() {
508            if plugin_dir.is_dir() {
509                result.bundle_plugin_dirs.push(plugin_dir.to_path_buf());
510            }
511        }
512    }
513
514    // Bundle themes — record the bundle directory for the theme loader to scan
515    if !fresh.themes.is_empty() {
516        result.bundle_theme_dirs.push(pkg_dir.to_path_buf());
517    }
518}
519
520/// Read and parse a package.json manifest, returning None on any error.
521fn read_manifest(path: &Path) -> Option<PackageManifest> {
522    let content = match std::fs::read_to_string(path) {
523        Ok(c) => c,
524        Err(e) => {
525            tracing::debug!("[package-scan] Failed to read {:?}: {}", path, e);
526            return None;
527        }
528    };
529
530    match serde_json::from_str(&content) {
531        Ok(m) => Some(m),
532        Err(e) => {
533            tracing::warn!("[package-scan] Failed to parse {:?}: {}", path, e);
534            None
535        }
536    }
537}
538
539// ── Tests ───────────────────────────────────────────────────────────────
540
541#[cfg(test)]
542mod tests {
543    use super::*;
544
545    #[test]
546    fn test_parse_language_pack_manifest() {
547        let json = r#"{
548            "name": "hare",
549            "version": "1.0.0",
550            "description": "Hare language support",
551            "type": "language",
552            "fresh": {
553                "grammar": {
554                    "file": "grammars/Hare.sublime-syntax",
555                    "extensions": ["ha"]
556                },
557                "language": {
558                    "commentPrefix": "//",
559                    "useTabs": true,
560                    "tabSize": 8,
561                    "showWhitespaceTabs": false,
562                    "autoIndent": true
563                },
564                "lsp": {
565                    "command": "hare-lsp",
566                    "args": ["--stdio"],
567                    "autoStart": true
568                }
569            }
570        }"#;
571
572        let manifest: PackageManifest = serde_json::from_str(json).unwrap();
573        assert_eq!(manifest.name, "hare");
574        assert_eq!(manifest.package_type, Some(PackageType::Language));
575
576        let fresh = manifest.fresh.unwrap();
577        let grammar = fresh.grammar.unwrap();
578        assert_eq!(grammar.file, "grammars/Hare.sublime-syntax");
579        assert_eq!(grammar.extensions, vec!["ha"]);
580
581        let lang = fresh.language.unwrap();
582        assert_eq!(lang.comment_prefix, Some("//".to_string()));
583        assert_eq!(lang.use_tabs, Some(true));
584        assert_eq!(lang.tab_size, Some(8));
585        assert_eq!(lang.show_whitespace_tabs, Some(false));
586
587        // Verify conversion to LanguageConfig
588        let lang_config = lang.to_language_config();
589        assert_eq!(lang_config.comment_prefix, Some("//".to_string()));
590        assert_eq!(lang_config.use_tabs, Some(true));
591        assert_eq!(lang_config.tab_size, Some(8));
592        assert!(!lang_config.show_whitespace_tabs);
593
594        let lsp = fresh.lsp.unwrap();
595        assert_eq!(lsp.command, "hare-lsp");
596        assert_eq!(lsp.args, vec!["--stdio"]);
597        assert_eq!(lsp.auto_start, Some(true));
598
599        // Verify conversion to LspServerConfig
600        let lsp_config = lsp.to_lsp_config();
601        assert_eq!(lsp_config.command, "hare-lsp");
602        assert!(lsp_config.auto_start);
603        assert!(lsp_config.enabled);
604    }
605
606    #[test]
607    fn test_parse_bundle_manifest() {
608        let json = r##"{
609            "name": "elixir-bundle",
610            "version": "1.0.0",
611            "description": "Elixir language bundle",
612            "type": "bundle",
613            "fresh": {
614                "languages": [
615                    {
616                        "id": "elixir",
617                        "grammar": {
618                            "file": "grammars/Elixir.sublime-syntax",
619                            "extensions": ["ex", "exs"]
620                        },
621                        "language": {
622                            "commentPrefix": "#",
623                            "tabSize": 2
624                        },
625                        "lsp": {
626                            "command": "elixir-ls",
627                            "autoStart": true
628                        }
629                    },
630                    {
631                        "id": "heex",
632                        "grammar": {
633                            "file": "grammars/HEEx.sublime-syntax",
634                            "extensions": ["heex"]
635                        }
636                    }
637                ],
638                "plugins": [
639                    { "entry": "plugins/elixir-plugin.ts" }
640                ],
641                "themes": [
642                    { "file": "themes/elixir-dark.json", "name": "Elixir Dark", "variant": "dark" }
643                ]
644            }
645        }"##;
646
647        let manifest: PackageManifest = serde_json::from_str(json).unwrap();
648        assert_eq!(manifest.name, "elixir-bundle");
649        assert_eq!(manifest.package_type, Some(PackageType::Bundle));
650
651        let fresh = manifest.fresh.unwrap();
652        assert_eq!(fresh.languages.len(), 2);
653        assert_eq!(fresh.plugins.len(), 1);
654        assert_eq!(fresh.themes.len(), 1);
655
656        let elixir = &fresh.languages[0];
657        assert_eq!(elixir.id, "elixir");
658        assert_eq!(
659            elixir.grammar.as_ref().unwrap().extensions,
660            vec!["ex", "exs"]
661        );
662
663        let heex = &fresh.languages[1];
664        assert_eq!(heex.id, "heex");
665        assert!(heex.language.is_none());
666        assert!(heex.lsp.is_none());
667    }
668
669    #[test]
670    fn test_parse_minimal_manifest() {
671        // Only required field is `name` — everything else should have defaults
672        let json = r#"{ "name": "minimal" }"#;
673        let manifest: PackageManifest = serde_json::from_str(json).unwrap();
674        assert_eq!(manifest.name, "minimal");
675        assert!(manifest.package_type.is_none());
676        assert!(manifest.fresh.is_none());
677    }
678
679    #[test]
680    fn test_parse_manifest_with_unknown_fields() {
681        // Forward compatibility: unknown fields should be silently ignored
682        let json = r#"{
683            "name": "future-pkg",
684            "version": "2.0.0",
685            "description": "From the future",
686            "type": "language",
687            "future_field": true,
688            "fresh": {
689                "grammar": { "file": "grammar.sublime-syntax" },
690                "future_nested": { "key": "value" }
691            }
692        }"#;
693        let manifest: PackageManifest = serde_json::from_str(json).unwrap();
694        assert_eq!(manifest.name, "future-pkg");
695        assert!(manifest.fresh.unwrap().grammar.is_some());
696    }
697
698    #[test]
699    fn test_scan_empty_directories() {
700        let temp_dir = tempfile::tempdir().unwrap();
701        let config_dir = temp_dir.path();
702
703        let result = scan_installed_packages(config_dir);
704        assert!(result.language_configs.is_empty());
705        assert!(result.lsp_configs.is_empty());
706        assert!(result.additional_grammars.is_empty());
707        assert!(result.bundle_plugin_dirs.is_empty());
708        assert!(result.bundle_theme_dirs.is_empty());
709    }
710
711    #[test]
712    fn test_scan_language_pack() {
713        let temp_dir = tempfile::tempdir().unwrap();
714        let config_dir = temp_dir.path();
715
716        // Create a language pack
717        let lang_dir = config_dir.join("languages/packages/hare");
718        std::fs::create_dir_all(&lang_dir).unwrap();
719        std::fs::write(
720            lang_dir.join("package.json"),
721            r#"{
722                "name": "hare",
723                "version": "1.0.0",
724                "description": "Hare language",
725                "type": "language",
726                "fresh": {
727                    "grammar": {
728                        "file": "grammars/Hare.sublime-syntax",
729                        "extensions": ["ha"]
730                    },
731                    "language": {
732                        "commentPrefix": "//",
733                        "useTabs": true
734                    },
735                    "lsp": {
736                        "command": "hare-lsp",
737                        "args": ["--stdio"]
738                    }
739                }
740            }"#,
741        )
742        .unwrap();
743
744        let result = scan_installed_packages(config_dir);
745
746        // Language config should be extracted
747        assert_eq!(result.language_configs.len(), 1);
748        assert_eq!(result.language_configs[0].0, "hare");
749        assert_eq!(
750            result.language_configs[0].1.comment_prefix,
751            Some("//".to_string())
752        );
753        assert_eq!(result.language_configs[0].1.use_tabs, Some(true));
754
755        // LSP config should be extracted
756        assert_eq!(result.lsp_configs.len(), 1);
757        assert_eq!(result.lsp_configs[0].0, "hare");
758        assert_eq!(result.lsp_configs[0].1.command, "hare-lsp");
759
760        // Grammar NOT in additional_grammars (handled by grammar loader)
761        assert!(result.additional_grammars.is_empty());
762    }
763
764    #[test]
765    fn test_scan_bundle() {
766        let temp_dir = tempfile::tempdir().unwrap();
767        let config_dir = temp_dir.path();
768
769        // Create a bundle
770        let bundle_dir = config_dir.join("bundles/packages/elixir-bundle");
771        let grammars_dir = bundle_dir.join("grammars");
772        let plugins_dir = bundle_dir.join("plugins");
773        std::fs::create_dir_all(&grammars_dir).unwrap();
774        std::fs::create_dir_all(&plugins_dir).unwrap();
775
776        // Create a dummy grammar file
777        std::fs::write(
778            grammars_dir.join("Elixir.sublime-syntax"),
779            "# dummy grammar",
780        )
781        .unwrap();
782
783        // Create a dummy plugin entry
784        std::fs::write(plugins_dir.join("elixir-plugin.ts"), "// dummy plugin").unwrap();
785
786        std::fs::write(
787            bundle_dir.join("package.json"),
788            r##"{
789                "name": "elixir-bundle",
790                "version": "1.0.0",
791                "description": "Elixir bundle",
792                "type": "bundle",
793                "fresh": {
794                    "languages": [
795                        {
796                            "id": "elixir",
797                            "grammar": {
798                                "file": "grammars/Elixir.sublime-syntax",
799                                "extensions": ["ex", "exs"]
800                            },
801                            "language": {
802                                "commentPrefix": "#",
803                                "tabSize": 2
804                            },
805                            "lsp": {
806                                "command": "elixir-ls",
807                                "autoStart": true
808                            }
809                        }
810                    ],
811                    "plugins": [
812                        { "entry": "plugins/elixir-plugin.ts" }
813                    ],
814                    "themes": [
815                        { "file": "themes/dark.json", "name": "Elixir Dark", "variant": "dark" }
816                    ]
817                }
818            }"##,
819        )
820        .unwrap();
821
822        let result = scan_installed_packages(config_dir);
823
824        // Bundle grammars should be in additional_grammars
825        assert_eq!(result.additional_grammars.len(), 1);
826        assert_eq!(result.additional_grammars[0].language, "elixir");
827        assert_eq!(result.additional_grammars[0].extensions, vec!["ex", "exs"]);
828
829        // Language config
830        assert_eq!(result.language_configs.len(), 1);
831        assert_eq!(result.language_configs[0].0, "elixir");
832
833        // LSP config
834        assert_eq!(result.lsp_configs.len(), 1);
835        assert_eq!(result.lsp_configs[0].1.command, "elixir-ls");
836
837        // Bundle plugin directory
838        assert_eq!(result.bundle_plugin_dirs.len(), 1);
839        assert_eq!(result.bundle_plugin_dirs[0], plugins_dir);
840
841        // Bundle theme directory
842        assert_eq!(result.bundle_theme_dirs.len(), 1);
843        assert_eq!(result.bundle_theme_dirs[0], bundle_dir);
844    }
845
846    #[test]
847    fn test_scan_skips_malformed_manifest() {
848        let temp_dir = tempfile::tempdir().unwrap();
849        let config_dir = temp_dir.path();
850
851        // Create a language pack with bad JSON
852        let lang_dir = config_dir.join("languages/packages/broken");
853        std::fs::create_dir_all(&lang_dir).unwrap();
854        std::fs::write(lang_dir.join("package.json"), "{ invalid json }").unwrap();
855
856        // Should not panic
857        let result = scan_installed_packages(config_dir);
858        assert!(result.language_configs.is_empty());
859    }
860
861    #[test]
862    fn test_formatter_conversion() {
863        let lang = LanguageManifestConfig {
864            formatter: Some(FormatterManifestConfig {
865                command: "prettier".to_string(),
866                args: vec!["--stdin-filepath".to_string(), "$FILE".to_string()],
867            }),
868            ..Default::default()
869        };
870
871        let config = lang.to_language_config();
872        let fmt = config.formatter.unwrap();
873        assert_eq!(fmt.command, "prettier");
874        assert_eq!(fmt.args, vec!["--stdin-filepath", "$FILE"]);
875        assert!(fmt.stdin);
876        assert_eq!(fmt.timeout_ms, 10000);
877    }
878
879    #[test]
880    fn test_process_limits_conversion() {
881        let lsp = LspManifestConfig {
882            command: "test-lsp".to_string(),
883            args: vec![],
884            auto_start: None,
885            initialization_options: None,
886            process_limits: Some(ProcessLimitsManifestConfig {
887                max_memory_percent: Some(30),
888                max_cpu_percent: Some(50),
889                enabled: Some(true),
890            }),
891        };
892
893        let config = lsp.to_lsp_config();
894        assert_eq!(config.process_limits.max_memory_percent, Some(30));
895        assert_eq!(config.process_limits.max_cpu_percent, Some(50));
896        assert!(config.process_limits.enabled);
897    }
898}