Skip to main content

fallow_config/
external_plugin.rs

1use std::collections::BTreeMap;
2use std::path::{Path, PathBuf};
3
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6
7use crate::config::UsedClassMemberRule;
8
9/// Supported plugin file extensions.
10const PLUGIN_EXTENSIONS: &[&str] = &["toml", "json", "jsonc"];
11
12/// How a plugin's discovered entry points contribute to coverage reachability.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema, Default)]
14#[serde(rename_all = "camelCase")]
15pub enum EntryPointRole {
16    /// Runtime/application roots that should count toward runtime reachability.
17    Runtime,
18    /// Test roots that should count toward test reachability.
19    Test,
20    /// Support/setup/config roots that should keep files alive but not count as runtime/test.
21    #[default]
22    Support,
23}
24
25/// Which export shape a convention auto-import credits when its name is
26/// referenced without an explicit `import` statement.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum AutoImportKind {
29    /// `import { name } from source` (named export, e.g. a Nuxt composable/util).
30    Named,
31    /// `import name from source` (default export).
32    Default,
33    /// SFC default export consumed by a template tag (Nuxt `components/`).
34    DefaultComponent,
35}
36
37/// A single convention-based auto-import: a bare identifier name that resolves
38/// to an export in `source` by framework convention, with no explicit `import`
39/// statement in the consuming file.
40///
41/// Built by `Plugin::auto_imports` from a filesystem scan (e.g. Nuxt scanning
42/// `components/`), and consumed at graph-build time: the resolver matches a
43/// file's captured `auto_import_candidates` against these rules and synthesizes
44/// an edge to `source`. The table is a function of which files exist on disk,
45/// not of any single file's bytes, so it is computed fresh per run and never
46/// cached as part of per-file extraction.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct AutoImportRule {
49    /// Bare identifier name (e.g. `useCounter`, `Card001`, `BaseButton`,
50    /// `LazyCard001`). Component names are canonical PascalCase; the consuming
51    /// scanner normalizes kebab-case tags before matching.
52    pub name: String,
53    /// Absolute path to the source file providing the export.
54    pub source: PathBuf,
55    /// Which export to credit when the name is referenced.
56    pub kind: AutoImportKind,
57}
58
59/// How to detect if a plugin should be activated.
60///
61/// When set on an `ExternalPluginDef`, this takes priority over `enablers`.
62/// Supports dependency checks, file existence checks, and boolean combinators.
63#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
64#[serde(tag = "type", rename_all = "camelCase")]
65pub enum PluginDetection {
66    /// Plugin detected if this package is in dependencies.
67    Dependency {
68        /// Exact package name looked up in the project's declared dependencies.
69        package: String,
70    },
71    /// Plugin detected if this file pattern matches.
72    FileExists {
73        /// Project-root-relative glob; the plugin activates when any
74        /// discovered file matches it. Validated at load like other user
75        /// globs (no absolute paths or `..` segments).
76        pattern: String,
77    },
78    /// All conditions must be true.
79    All {
80        /// Sub-conditions combined with logical AND.
81        conditions: Vec<Self>,
82    },
83    /// Any condition must be true.
84    Any {
85        /// Sub-conditions combined with logical OR.
86        conditions: Vec<Self>,
87    },
88}
89
90/// A declarative plugin definition loaded from a standalone file or inline config.
91///
92/// External plugins provide the same static pattern capabilities as built-in
93/// plugins (entry points, always-used files, used exports, tooling dependencies),
94/// but are defined in standalone files or inline in the fallow config rather than
95/// compiled Rust code.
96///
97/// They cannot do AST-based config parsing (`resolve_config()`), but cover the
98/// vast majority of framework integration use cases.
99///
100/// Supports JSONC, JSON, and TOML formats. All use camelCase field names.
101///
102/// ```json
103/// {
104///   "$schema": "https://raw.githubusercontent.com/fallow-rs/fallow/main/plugin-schema.json",
105///   "name": "my-framework",
106///   "enablers": ["my-framework", "@my-framework/core"],
107///   "entryPoints": ["src/routes/**/*.{ts,tsx}"],
108///   "configPatterns": ["my-framework.config.{ts,js}"],
109///   "alwaysUsed": ["src/setup.ts"],
110///   "toolingDependencies": ["my-framework-cli"],
111///   "usedExports": [
112///     { "pattern": "src/routes/**/*.{ts,tsx}", "exports": ["default", "loader", "action"] }
113///   ]
114/// }
115/// ```
116#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
117#[serde(rename_all = "camelCase")]
118pub struct ExternalPluginDef {
119    /// JSON Schema reference (ignored during deserialization).
120    #[serde(rename = "$schema", default, skip_serializing)]
121    #[schemars(skip)]
122    pub schema: Option<String>,
123
124    /// Unique name for this plugin.
125    pub name: String,
126
127    /// Rich detection logic (dependency checks, file existence, boolean combinators).
128    /// Takes priority over `enablers` when set.
129    #[serde(default)]
130    pub detection: Option<PluginDetection>,
131
132    /// Package names that activate this plugin when found in package.json.
133    /// Supports exact matches and prefix patterns (ending with `/`).
134    /// Only used when `detection` is not set.
135    #[serde(default)]
136    pub enablers: Vec<String>,
137
138    /// Glob patterns for entry point files.
139    #[serde(default)]
140    pub entry_points: Vec<String>,
141
142    /// Coverage role for `entryPoints`.
143    ///
144    /// Defaults to `support`. Set to `runtime` for application entry points
145    /// or `test` for test framework entry points.
146    #[serde(default = "default_external_entry_point_role")]
147    pub entry_point_role: EntryPointRole,
148
149    /// Entry points DERIVED from framework manifest files.
150    ///
151    /// Unlike `entryPoints` (static globs), each rule finds manifest files by a
152    /// recursive glob, parses them, and seeds sibling entries resolved relative
153    /// to each manifest's directory, gated on the manifest's own fields. Seeded
154    /// entries use this plugin's `entryPointRole`.
155    #[serde(default)]
156    pub manifest_entries: Vec<ManifestEntryRule>,
157
158    /// Glob patterns for config files (marked as always-used when active).
159    #[serde(default)]
160    pub config_patterns: Vec<String>,
161
162    /// Files that are always considered "used" when this plugin is active.
163    #[serde(default)]
164    pub always_used: Vec<String>,
165
166    /// Dependencies that are tooling (used via CLI/config, not source imports).
167    /// These should not be flagged as unused devDependencies.
168    #[serde(default)]
169    pub tooling_dependencies: Vec<String>,
170
171    /// Exports that are always considered used for matching file patterns.
172    #[serde(default)]
173    pub used_exports: Vec<ExternalUsedExport>,
174
175    /// Class member method/property rules the framework invokes at runtime.
176    /// Supports plain member names for global suppression and scoped objects
177    /// with `extends` / `implements` constraints when the method name is too
178    /// common to suppress across the whole workspace.
179    #[serde(default)]
180    pub used_class_members: Vec<UsedClassMemberRule>,
181}
182
183/// Exports considered used for files matching a pattern.
184#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
185pub struct ExternalUsedExport {
186    /// Glob pattern for files.
187    pub pattern: String,
188    /// Export names always considered used.
189    pub exports: Vec<String>,
190}
191
192/// Format of the manifest files a [`ManifestEntryRule`] reads.
193///
194/// `jsonc` (the default) also parses plain JSON, so it is the tolerant choice.
195#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema, Default)]
196#[serde(rename_all = "lowercase")]
197pub enum ManifestFormat {
198    /// JSONC (comments + trailing commas). Also accepts plain JSON.
199    #[default]
200    Jsonc,
201    /// Strict JSON.
202    Json,
203}
204
205/// A rule that seeds entry points DERIVED from framework manifest files.
206///
207/// For every file matching `manifests` (a recursive glob) that passes the
208/// manifest-level `when` gate, each rule in `entries` is resolved relative to
209/// the manifest's directory (with `${dotted.field}` interpolation) into an entry
210/// point. Seeded entries use the owning plugin's `entryPointRole`.
211///
212/// ```jsonc
213/// {
214///   "manifests": "**/kibana.jsonc",
215///   "when": { "type": "plugin" },
216///   "entries": [
217///     { "path": "public/index.{ts,tsx}", "when": { "plugin.browser": true } },
218///     { "path": "server/index.{ts,tsx}", "when": { "plugin.server": true } },
219///     { "path": "${plugin.extraPublicDirs}/index.{ts,tsx}" }
220///   ]
221/// }
222/// ```
223#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
224#[serde(rename_all = "camelCase")]
225pub struct ManifestEntryRule {
226    /// Recursive glob selecting the manifest files to read (e.g. `**/kibana.jsonc`).
227    pub manifests: String,
228
229    /// Manifest format. Defaults to `jsonc` (which also parses plain JSON).
230    #[serde(default)]
231    pub format: ManifestFormat,
232
233    /// Manifest-level gate: a map of dotted field path to an expected scalar
234    /// value. ALL entries must match by STRICT EQUALITY for the manifest to be
235    /// processed. An empty map matches every manifest.
236    #[serde(default)]
237    pub when: BTreeMap<String, serde_json::Value>,
238
239    /// Entry rules seeded per matching manifest.
240    pub entries: Vec<ManifestSeedRule>,
241}
242
243/// A single entry seeded by a [`ManifestEntryRule`], resolved relative to the
244/// manifest's directory.
245#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
246#[serde(rename_all = "camelCase")]
247pub struct ManifestSeedRule {
248    /// Entry glob relative to the manifest directory. May contain
249    /// `${dotted.field}` interpolation that fans out over string / array
250    /// manifest field values (a missing or empty field seeds nothing). The glob
251    /// must encode its own extension (e.g. `public/index.{ts,tsx}`); glob entry
252    /// patterns are matched literally against discovered files without
253    /// source-extension probing.
254    pub path: String,
255
256    /// Per-entry gate (strict equality), evaluated against the same manifest.
257    /// An empty map always passes.
258    #[serde(default)]
259    pub when: BTreeMap<String, serde_json::Value>,
260}
261
262fn default_external_entry_point_role() -> EntryPointRole {
263    EntryPointRole::Support
264}
265
266impl ExternalPluginDef {
267    /// Generate JSON Schema for the external plugin format.
268    #[must_use]
269    pub fn json_schema() -> serde_json::Value {
270        serde_json::to_value(schemars::schema_for!(ExternalPluginDef)).unwrap_or_default()
271    }
272
273    /// Validate all user-supplied glob patterns on this plugin definition,
274    /// including patterns nested inside `detection` combinators (`all` / `any`).
275    ///
276    /// Pattern names use the same `framework[].<field>` notation used by
277    /// inline plugin definitions in `FallowConfig::validate_user_globs` so the
278    /// user sees consistent field paths whether the plugin is inline or
279    /// loaded from `.fallow/plugins/` / `fallow-plugin-*.{toml,json,jsonc}`.
280    ///
281    /// # Errors
282    ///
283    /// Returns a non-empty `Vec` of
284    /// [`GlobValidationError`](crate::config::glob_validation::GlobValidationError)
285    /// when any pattern is rejected.
286    pub fn validate_user_globs(
287        &self,
288    ) -> Result<(), Vec<crate::config::glob_validation::GlobValidationError>> {
289        use crate::config::glob_validation::{compile_user_glob, validate_user_globs};
290
291        let mut errors = Vec::new();
292        validate_user_globs(&self.entry_points, "framework[].entryPoints", &mut errors);
293        validate_user_globs(&self.always_used, "framework[].alwaysUsed", &mut errors);
294        validate_user_globs(
295            &self.config_patterns,
296            "framework[].configPatterns",
297            &mut errors,
298        );
299        for used in &self.used_exports {
300            if let Err(e) = compile_user_glob(&used.pattern, "framework[].usedExports[].pattern") {
301                errors.push(e);
302            }
303        }
304        for rule in &self.manifest_entries {
305            if let Err(e) =
306                compile_user_glob(&rule.manifests, "framework[].manifestEntries[].manifests")
307            {
308                errors.push(e);
309            }
310            for seed in &rule.entries {
311                // Substitute `${...}` interpolation with a placeholder segment so
312                // the surrounding glob (e.g. `${x}/index.{ts,tsx}`) validates.
313                let probe = substitute_interpolation_placeholder(&seed.path);
314                if let Err(e) =
315                    compile_user_glob(&probe, "framework[].manifestEntries[].entries[].path")
316                {
317                    errors.push(e);
318                }
319            }
320        }
321        if let Some(detection) = &self.detection {
322            validate_detection_user_globs(detection, "framework[].detection", &mut errors);
323        }
324        if errors.is_empty() {
325            Ok(())
326        } else {
327            Err(errors)
328        }
329    }
330}
331
332/// Replace every `${...}` interpolation span with a placeholder path segment so
333/// the surrounding glob can be validated. Used only for validation; the actual
334/// interpolation happens at evaluation time in the core crate. An unterminated
335/// `${` is left intact so it fails glob validation as a loud error.
336fn substitute_interpolation_placeholder(path: &str) -> String {
337    let mut out = String::with_capacity(path.len());
338    let mut rest = path;
339    while let Some(start) = rest.find("${") {
340        out.push_str(&rest[..start]);
341        if let Some(end_rel) = rest[start + 2..].find('}') {
342            out.push_str("fallowinterp");
343            rest = &rest[start + 2 + end_rel + 1..];
344        } else {
345            out.push_str(&rest[start..]);
346            return out;
347        }
348    }
349    out.push_str(rest);
350    out
351}
352
353/// Recursively validate `FileExists.pattern` fields inside a `PluginDetection`
354/// tree. `All` and `Any` combinators recurse into their nested conditions.
355fn validate_detection_user_globs(
356    detection: &PluginDetection,
357    field: &'static str,
358    errors: &mut Vec<crate::config::glob_validation::GlobValidationError>,
359) {
360    match detection {
361        PluginDetection::Dependency { .. } => {}
362        PluginDetection::FileExists { pattern } => {
363            if let Err(e) = crate::config::glob_validation::compile_user_glob(pattern, field) {
364                errors.push(e);
365            }
366        }
367        PluginDetection::All { conditions } | PluginDetection::Any { conditions } => {
368            for condition in conditions {
369                validate_detection_user_globs(condition, field, errors);
370            }
371        }
372    }
373}
374
375/// Discover external plugin definitions AND validate their user-supplied glob
376/// patterns. Accumulates all errors across all loaded plugins so the user sees
377/// every problem in one run.
378///
379/// Discovery is identical to [`discover_external_plugins`]; this wrapper adds
380/// the per-plugin glob validation step required for security
381/// (see issue #463: `framework[].detection.fileExists.pattern` reaches
382/// `glob::glob` on disk via `root.join(pattern)`, so a `..` segment loaded
383/// from `.fallow/plugins/` would be a real path traversal).
384///
385/// # Errors
386///
387/// Returns the list of validation errors when any discovered plugin contains
388/// a rejected pattern. The CLI surfaces these with exit code 2.
389pub fn discover_and_validate_external_plugins(
390    root: &Path,
391    config_plugin_paths: &[String],
392) -> Result<Vec<ExternalPluginDef>, Vec<crate::config::glob_validation::GlobValidationError>> {
393    let plugins = discover_external_plugins(root, config_plugin_paths);
394    let mut errors = Vec::new();
395    for plugin in &plugins {
396        if let Err(mut plugin_errors) = plugin.validate_user_globs() {
397            errors.append(&mut plugin_errors);
398        }
399    }
400    if errors.is_empty() {
401        Ok(plugins)
402    } else {
403        Err(errors)
404    }
405}
406
407/// Detect plugin format from file extension.
408enum PluginFormat {
409    Toml,
410    Json,
411    Jsonc,
412}
413
414impl PluginFormat {
415    fn from_path(path: &Path) -> Option<Self> {
416        match path.extension().and_then(|e| e.to_str()) {
417            Some("toml") => Some(Self::Toml),
418            Some("json") => Some(Self::Json),
419            Some("jsonc") => Some(Self::Jsonc),
420            _ => None,
421        }
422    }
423}
424
425/// Check if a file has a supported plugin extension.
426fn is_plugin_file(path: &Path) -> bool {
427    path.extension()
428        .and_then(|e| e.to_str())
429        .is_some_and(|ext| PLUGIN_EXTENSIONS.contains(&ext))
430}
431
432/// Parse a plugin definition from file content based on format.
433fn parse_plugin(content: &str, format: &PluginFormat, path: &Path) -> Option<ExternalPluginDef> {
434    match format {
435        PluginFormat::Toml => match toml::from_str::<ExternalPluginDef>(content) {
436            Ok(plugin) => Some(plugin),
437            Err(e) => {
438                tracing::warn!("failed to parse external plugin {}: {e}", path.display());
439                None
440            }
441        },
442        PluginFormat::Json => match serde_json::from_str::<ExternalPluginDef>(content) {
443            Ok(plugin) => Some(plugin),
444            Err(e) => {
445                tracing::warn!("failed to parse external plugin {}: {e}", path.display());
446                None
447            }
448        },
449        PluginFormat::Jsonc => match crate::jsonc::parse_to_value::<ExternalPluginDef>(content) {
450            Ok(plugin) => Some(plugin),
451            Err(e) => {
452                tracing::warn!("failed to parse external plugin {}: {e}", path.display());
453                None
454            }
455        },
456    }
457}
458
459/// Discover and load external plugin definitions for a project.
460///
461/// Discovery order (first occurrence of a plugin name wins):
462/// 1. Paths from the `plugins` config field (files or directories)
463/// 2. `.fallow/plugins/` directory (auto-discover `*.toml`, `*.json`, `*.jsonc` files)
464/// 3. Project root `fallow-plugin-*` files (`.toml`, `.json`, `.jsonc`)
465pub fn discover_external_plugins(
466    root: &Path,
467    config_plugin_paths: &[String],
468) -> Vec<ExternalPluginDef> {
469    let mut plugins = Vec::new();
470    let mut seen_names = rustc_hash::FxHashSet::default();
471
472    let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
473
474    load_configured_plugin_paths(
475        root,
476        config_plugin_paths,
477        &canonical_root,
478        &mut plugins,
479        &mut seen_names,
480    );
481    load_default_plugins_dir(root, &canonical_root, &mut plugins, &mut seen_names);
482    load_root_plugin_files(root, &canonical_root, &mut plugins, &mut seen_names);
483
484    plugins
485}
486
487fn load_configured_plugin_paths(
488    root: &Path,
489    config_plugin_paths: &[String],
490    canonical_root: &Path,
491    plugins: &mut Vec<ExternalPluginDef>,
492    seen_names: &mut rustc_hash::FxHashSet<String>,
493) {
494    for path_str in config_plugin_paths {
495        let path = root.join(path_str);
496        if !is_within_root(&path, canonical_root) {
497            tracing::warn!("plugin path '{path_str}' resolves outside project root, skipping");
498            continue;
499        }
500        if path.is_dir() {
501            load_plugins_from_dir(&path, canonical_root, plugins, seen_names);
502        } else if path.is_file() {
503            load_plugin_file(&path, canonical_root, plugins, seen_names);
504        }
505    }
506}
507
508fn load_default_plugins_dir(
509    root: &Path,
510    canonical_root: &Path,
511    plugins: &mut Vec<ExternalPluginDef>,
512    seen_names: &mut rustc_hash::FxHashSet<String>,
513) {
514    let plugins_dir = root.join(".fallow").join("plugins");
515    if plugins_dir.is_dir() && is_within_root(&plugins_dir, canonical_root) {
516        load_plugins_from_dir(&plugins_dir, canonical_root, plugins, seen_names);
517    }
518}
519
520fn load_root_plugin_files(
521    root: &Path,
522    canonical_root: &Path,
523    plugins: &mut Vec<ExternalPluginDef>,
524    seen_names: &mut rustc_hash::FxHashSet<String>,
525) {
526    if let Ok(entries) = std::fs::read_dir(root) {
527        let mut plugin_files: Vec<PathBuf> = entries
528            .filter_map(Result::ok)
529            .map(|e| e.path())
530            .filter(|p| {
531                p.is_file()
532                    && p.file_name().and_then(|n| n.to_str()).is_some_and(|n| {
533                        n.starts_with("fallow-plugin-") && is_plugin_file(Path::new(n))
534                    })
535            })
536            .collect();
537        plugin_files.sort();
538        for path in plugin_files {
539            load_plugin_file(&path, canonical_root, plugins, seen_names);
540        }
541    }
542}
543
544/// Check if a path resolves within the canonical root (follows symlinks).
545#[expect(
546    clippy::redundant_pub_crate,
547    reason = "this module is glob re-exported from lib.rs, so `pub` would leak this helper into the public API; pub(crate) is the minimal widening for the rule-pack loader"
548)]
549pub(crate) fn is_within_root(path: &Path, canonical_root: &Path) -> bool {
550    let canonical = dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
551    canonical.starts_with(canonical_root)
552}
553
554fn load_plugins_from_dir(
555    dir: &Path,
556    canonical_root: &Path,
557    plugins: &mut Vec<ExternalPluginDef>,
558    seen: &mut rustc_hash::FxHashSet<String>,
559) {
560    if let Ok(entries) = std::fs::read_dir(dir) {
561        let mut plugin_files: Vec<PathBuf> = entries
562            .filter_map(Result::ok)
563            .map(|e| e.path())
564            .filter(|p| p.is_file() && is_plugin_file(p))
565            .collect();
566        plugin_files.sort();
567        for path in plugin_files {
568            load_plugin_file(&path, canonical_root, plugins, seen);
569        }
570    }
571}
572
573fn load_plugin_file(
574    path: &Path,
575    canonical_root: &Path,
576    plugins: &mut Vec<ExternalPluginDef>,
577    seen: &mut rustc_hash::FxHashSet<String>,
578) {
579    if !is_within_root(path, canonical_root) {
580        tracing::warn!(
581            "plugin file '{}' resolves outside project root (symlink?), skipping",
582            path.display()
583        );
584        return;
585    }
586
587    let Some(format) = PluginFormat::from_path(path) else {
588        tracing::warn!(
589            "unsupported plugin file extension for {}, expected .toml, .json, or .jsonc",
590            path.display()
591        );
592        return;
593    };
594
595    let Some(content) = read_plugin_file(path) else {
596        return;
597    };
598
599    if let Some(plugin) = parse_plugin(&content, &format, path) {
600        push_plugin_if_unique(plugin, path, plugins, seen);
601    }
602}
603
604fn read_plugin_file(path: &Path) -> Option<String> {
605    match std::fs::read_to_string(path) {
606        Ok(content) => Some(content),
607        Err(e) => {
608            tracing::warn!(
609                "failed to read external plugin file {}: {e}",
610                path.display()
611            );
612            None
613        }
614    }
615}
616
617fn push_plugin_if_unique(
618    plugin: ExternalPluginDef,
619    path: &Path,
620    plugins: &mut Vec<ExternalPluginDef>,
621    seen: &mut rustc_hash::FxHashSet<String>,
622) {
623    if plugin.name.is_empty() {
624        tracing::warn!(
625            "external plugin in {} has an empty name, skipping",
626            path.display()
627        );
628        return;
629    }
630
631    if seen.insert(plugin.name.clone()) {
632        plugins.push(plugin);
633    } else {
634        tracing::warn!(
635            "duplicate external plugin '{}' in {}, skipping",
636            plugin.name,
637            path.display()
638        );
639    }
640}
641
642#[cfg(test)]
643mod tests {
644    use super::*;
645    use crate::ScopedUsedClassMemberRule;
646
647    #[test]
648    fn deserialize_minimal_plugin() {
649        let toml_str = r#"
650name = "my-plugin"
651enablers = ["my-pkg"]
652"#;
653        let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
654        assert_eq!(plugin.name, "my-plugin");
655        assert_eq!(plugin.enablers, vec!["my-pkg"]);
656        assert!(plugin.entry_points.is_empty());
657        assert!(plugin.always_used.is_empty());
658        assert!(plugin.config_patterns.is_empty());
659        assert!(plugin.tooling_dependencies.is_empty());
660        assert!(plugin.used_exports.is_empty());
661        assert!(plugin.used_class_members.is_empty());
662    }
663
664    #[test]
665    fn deserialize_plugin_with_used_class_members_json() {
666        let json_str = r#"{
667            "name": "ag-grid",
668            "enablers": ["ag-grid-angular"],
669            "usedClassMembers": ["agInit", "refresh"]
670        }"#;
671        let plugin: ExternalPluginDef = serde_json::from_str(json_str).unwrap();
672        assert_eq!(plugin.name, "ag-grid");
673        assert_eq!(
674            plugin.used_class_members,
675            vec![
676                UsedClassMemberRule::from("agInit"),
677                UsedClassMemberRule::from("refresh"),
678            ]
679        );
680    }
681
682    #[test]
683    fn deserialize_plugin_with_scoped_used_class_members_json() {
684        let json_str = r#"{
685            "name": "ag-grid",
686            "enablers": ["ag-grid-angular"],
687            "usedClassMembers": [
688                "agInit",
689                { "implements": "ICellRendererAngularComp", "members": ["refresh"] },
690                { "extends": "BaseCommand", "members": ["execute"] }
691            ]
692        }"#;
693        let plugin: ExternalPluginDef = serde_json::from_str(json_str).unwrap();
694        assert_eq!(
695            plugin.used_class_members,
696            vec![
697                UsedClassMemberRule::from("agInit"),
698                UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
699                    extends: None,
700                    implements: Some("ICellRendererAngularComp".to_string()),
701                    members: vec!["refresh".to_string()],
702                }),
703                UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
704                    extends: Some("BaseCommand".to_string()),
705                    implements: None,
706                    members: vec!["execute".to_string()],
707                }),
708            ]
709        );
710    }
711
712    #[test]
713    fn deserialize_plugin_with_used_class_members_toml() {
714        let toml_str = r#"
715name = "ag-grid"
716enablers = ["ag-grid-angular"]
717usedClassMembers = ["agInit", "refresh"]
718"#;
719        let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
720        assert_eq!(
721            plugin.used_class_members,
722            vec![
723                UsedClassMemberRule::from("agInit"),
724                UsedClassMemberRule::from("refresh"),
725            ]
726        );
727    }
728
729    #[test]
730    fn deserialize_plugin_with_scoped_used_class_members_toml() {
731        let toml_str = r#"
732name = "ag-grid"
733enablers = ["ag-grid-angular"]
734usedClassMembers = [
735  { implements = "ICellRendererAngularComp", members = ["refresh"] },
736  { extends = "BaseCommand", members = ["execute"] }
737]
738"#;
739        let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
740        assert_eq!(
741            plugin.used_class_members,
742            vec![
743                UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
744                    extends: None,
745                    implements: Some("ICellRendererAngularComp".to_string()),
746                    members: vec!["refresh".to_string()],
747                }),
748                UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
749                    extends: Some("BaseCommand".to_string()),
750                    implements: None,
751                    members: vec!["execute".to_string()],
752                }),
753            ]
754        );
755    }
756
757    #[test]
758    fn deserialize_plugin_rejects_unconstrained_scoped_used_class_members() {
759        let result = serde_json::from_str::<ExternalPluginDef>(
760            r#"{
761                "name": "ag-grid",
762                "enablers": ["ag-grid-angular"],
763                "usedClassMembers": [{ "members": ["refresh"] }]
764            }"#,
765        );
766        assert!(
767            result.is_err(),
768            "unconstrained scoped rule should be rejected"
769        );
770    }
771
772    #[test]
773    fn deserialize_full_plugin() {
774        let toml_str = r#"
775name = "my-framework"
776enablers = ["my-framework", "@my-framework/core"]
777entryPoints = ["src/routes/**/*.{ts,tsx}", "src/middleware.ts"]
778configPatterns = ["my-framework.config.{ts,js,mjs}"]
779alwaysUsed = ["src/setup.ts", "public/**/*"]
780toolingDependencies = ["my-framework-cli"]
781
782[[usedExports]]
783pattern = "src/routes/**/*.{ts,tsx}"
784exports = ["default", "loader", "action"]
785
786[[usedExports]]
787pattern = "src/middleware.ts"
788exports = ["default"]
789"#;
790        let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
791        assert_eq!(plugin.name, "my-framework");
792        assert_eq!(plugin.enablers.len(), 2);
793        assert_eq!(plugin.entry_points.len(), 2);
794        assert_eq!(
795            plugin.config_patterns,
796            vec!["my-framework.config.{ts,js,mjs}"]
797        );
798        assert_eq!(plugin.always_used.len(), 2);
799        assert_eq!(plugin.tooling_dependencies, vec!["my-framework-cli"]);
800        assert_eq!(plugin.used_exports.len(), 2);
801        assert_eq!(plugin.used_exports[0].pattern, "src/routes/**/*.{ts,tsx}");
802        assert_eq!(
803            plugin.used_exports[0].exports,
804            vec!["default", "loader", "action"]
805        );
806    }
807
808    #[test]
809    fn deserialize_json_plugin() {
810        let json_str = r#"{
811            "name": "my-json-plugin",
812            "enablers": ["my-pkg"],
813            "entryPoints": ["src/**/*.ts"],
814            "configPatterns": ["my-plugin.config.js"],
815            "alwaysUsed": ["src/setup.ts"],
816            "toolingDependencies": ["my-cli"],
817            "usedExports": [
818                { "pattern": "src/**/*.ts", "exports": ["default"] }
819            ]
820        }"#;
821        let plugin: ExternalPluginDef = serde_json::from_str(json_str).unwrap();
822        assert_eq!(plugin.name, "my-json-plugin");
823        assert_eq!(plugin.enablers, vec!["my-pkg"]);
824        assert_eq!(plugin.entry_points, vec!["src/**/*.ts"]);
825        assert_eq!(plugin.config_patterns, vec!["my-plugin.config.js"]);
826        assert_eq!(plugin.always_used, vec!["src/setup.ts"]);
827        assert_eq!(plugin.tooling_dependencies, vec!["my-cli"]);
828        assert_eq!(plugin.used_exports.len(), 1);
829        assert_eq!(plugin.used_exports[0].exports, vec!["default"]);
830    }
831
832    #[test]
833    fn deserialize_jsonc_plugin() {
834        let jsonc_str = r#"{
835            "name": "my-jsonc-plugin",
836            "enablers": ["my-pkg"],
837            /* Block comment */
838            "entryPoints": ["src/**/*.ts"]
839        }"#;
840        let plugin: ExternalPluginDef = crate::jsonc::parse_to_value(jsonc_str).unwrap();
841        assert_eq!(plugin.name, "my-jsonc-plugin");
842        assert_eq!(plugin.enablers, vec!["my-pkg"]);
843        assert_eq!(plugin.entry_points, vec!["src/**/*.ts"]);
844    }
845
846    #[test]
847    fn deserialize_json_with_schema_field() {
848        let json_str = r#"{
849            "$schema": "https://raw.githubusercontent.com/fallow-rs/fallow/main/plugin-schema.json",
850            "name": "schema-plugin",
851            "enablers": ["my-pkg"]
852        }"#;
853        let plugin: ExternalPluginDef = serde_json::from_str(json_str).unwrap();
854        assert_eq!(plugin.name, "schema-plugin");
855        assert_eq!(plugin.enablers, vec!["my-pkg"]);
856    }
857
858    #[test]
859    fn plugin_json_schema_generation() {
860        let schema = ExternalPluginDef::json_schema();
861        assert!(schema.is_object());
862        let obj = schema.as_object().unwrap();
863        assert!(obj.contains_key("properties"));
864    }
865
866    #[test]
867    fn discover_plugins_from_fallow_plugins_dir() {
868        let dir =
869            std::env::temp_dir().join(format!("fallow-test-ext-plugins-{}", std::process::id()));
870        let plugins_dir = dir.join(".fallow").join("plugins");
871        let _ = std::fs::create_dir_all(&plugins_dir);
872
873        std::fs::write(
874            plugins_dir.join("my-plugin.toml"),
875            r#"
876name = "my-plugin"
877enablers = ["my-pkg"]
878entryPoints = ["src/**/*.ts"]
879"#,
880        )
881        .unwrap();
882
883        let plugins = discover_external_plugins(&dir, &[]);
884        assert_eq!(plugins.len(), 1);
885        assert_eq!(plugins[0].name, "my-plugin");
886
887        let _ = std::fs::remove_dir_all(&dir);
888    }
889
890    #[test]
891    fn discover_json_plugins_from_fallow_plugins_dir() {
892        let dir = std::env::temp_dir().join(format!(
893            "fallow-test-ext-json-plugins-{}",
894            std::process::id()
895        ));
896        let plugins_dir = dir.join(".fallow").join("plugins");
897        let _ = std::fs::create_dir_all(&plugins_dir);
898
899        std::fs::write(
900            plugins_dir.join("my-plugin.json"),
901            r#"{"name": "json-plugin", "enablers": ["json-pkg"]}"#,
902        )
903        .unwrap();
904
905        std::fs::write(
906            plugins_dir.join("my-plugin.jsonc"),
907            r#"{
908                "name": "jsonc-plugin",
909                "enablers": ["jsonc-pkg"]
910            }"#,
911        )
912        .unwrap();
913
914        let plugins = discover_external_plugins(&dir, &[]);
915        assert_eq!(plugins.len(), 2);
916        assert_eq!(plugins[0].name, "json-plugin");
917        assert_eq!(plugins[1].name, "jsonc-plugin");
918
919        let _ = std::fs::remove_dir_all(&dir);
920    }
921
922    #[test]
923    fn discover_fallow_plugin_files_in_root() {
924        let dir =
925            std::env::temp_dir().join(format!("fallow-test-root-plugins-{}", std::process::id()));
926        let _ = std::fs::create_dir_all(&dir);
927
928        std::fs::write(
929            dir.join("fallow-plugin-custom.toml"),
930            r#"
931name = "custom"
932enablers = ["custom-pkg"]
933"#,
934        )
935        .unwrap();
936
937        std::fs::write(dir.join("some-other-file.toml"), r#"name = "ignored""#).unwrap();
938
939        let plugins = discover_external_plugins(&dir, &[]);
940        assert_eq!(plugins.len(), 1);
941        assert_eq!(plugins[0].name, "custom");
942
943        let _ = std::fs::remove_dir_all(&dir);
944    }
945
946    #[test]
947    fn discover_fallow_plugin_json_files_in_root() {
948        let dir = std::env::temp_dir().join(format!(
949            "fallow-test-root-json-plugins-{}",
950            std::process::id()
951        ));
952        let _ = std::fs::create_dir_all(&dir);
953
954        std::fs::write(
955            dir.join("fallow-plugin-custom.json"),
956            r#"{"name": "json-root", "enablers": ["json-pkg"]}"#,
957        )
958        .unwrap();
959
960        std::fs::write(
961            dir.join("fallow-plugin-custom2.jsonc"),
962            r#"{
963                "name": "jsonc-root",
964                "enablers": ["jsonc-pkg"]
965            }"#,
966        )
967        .unwrap();
968
969        std::fs::write(
970            dir.join("fallow-plugin-bad.yaml"),
971            "name: ignored\nenablers:\n  - pkg\n",
972        )
973        .unwrap();
974
975        let plugins = discover_external_plugins(&dir, &[]);
976        assert_eq!(plugins.len(), 2);
977
978        let _ = std::fs::remove_dir_all(&dir);
979    }
980
981    #[test]
982    fn discover_mixed_formats_in_dir() {
983        let dir =
984            std::env::temp_dir().join(format!("fallow-test-mixed-plugins-{}", std::process::id()));
985        let plugins_dir = dir.join(".fallow").join("plugins");
986        let _ = std::fs::create_dir_all(&plugins_dir);
987
988        std::fs::write(
989            plugins_dir.join("a-plugin.toml"),
990            r#"
991name = "toml-plugin"
992enablers = ["toml-pkg"]
993"#,
994        )
995        .unwrap();
996
997        std::fs::write(
998            plugins_dir.join("b-plugin.json"),
999            r#"{"name": "json-plugin", "enablers": ["json-pkg"]}"#,
1000        )
1001        .unwrap();
1002
1003        std::fs::write(
1004            plugins_dir.join("c-plugin.jsonc"),
1005            r#"{
1006                "name": "jsonc-plugin",
1007                "enablers": ["jsonc-pkg"]
1008            }"#,
1009        )
1010        .unwrap();
1011
1012        let plugins = discover_external_plugins(&dir, &[]);
1013        assert_eq!(plugins.len(), 3);
1014        assert_eq!(plugins[0].name, "toml-plugin");
1015        assert_eq!(plugins[1].name, "json-plugin");
1016        assert_eq!(plugins[2].name, "jsonc-plugin");
1017
1018        let _ = std::fs::remove_dir_all(&dir);
1019    }
1020
1021    #[test]
1022    fn deduplicates_by_name() {
1023        let dir =
1024            std::env::temp_dir().join(format!("fallow-test-dedup-plugins-{}", std::process::id()));
1025        let plugins_dir = dir.join(".fallow").join("plugins");
1026        let _ = std::fs::create_dir_all(&plugins_dir);
1027
1028        std::fs::write(
1029            plugins_dir.join("my-plugin.toml"),
1030            r#"
1031name = "my-plugin"
1032enablers = ["pkg-a"]
1033"#,
1034        )
1035        .unwrap();
1036
1037        std::fs::write(
1038            dir.join("fallow-plugin-my-plugin.toml"),
1039            r#"
1040name = "my-plugin"
1041enablers = ["pkg-b"]
1042"#,
1043        )
1044        .unwrap();
1045
1046        let plugins = discover_external_plugins(&dir, &[]);
1047        assert_eq!(plugins.len(), 1);
1048        assert_eq!(plugins[0].enablers, vec!["pkg-a"]);
1049
1050        let _ = std::fs::remove_dir_all(&dir);
1051    }
1052
1053    #[test]
1054    fn config_plugin_paths_take_priority() {
1055        let dir =
1056            std::env::temp_dir().join(format!("fallow-test-config-paths-{}", std::process::id()));
1057        let custom_dir = dir.join("custom-plugins");
1058        let _ = std::fs::create_dir_all(&custom_dir);
1059
1060        std::fs::write(
1061            custom_dir.join("explicit.toml"),
1062            r#"
1063name = "explicit"
1064enablers = ["explicit-pkg"]
1065"#,
1066        )
1067        .unwrap();
1068
1069        let plugins = discover_external_plugins(&dir, &["custom-plugins".to_string()]);
1070        assert_eq!(plugins.len(), 1);
1071        assert_eq!(plugins[0].name, "explicit");
1072
1073        let _ = std::fs::remove_dir_all(&dir);
1074    }
1075
1076    #[test]
1077    fn config_plugin_path_to_single_file() {
1078        let dir =
1079            std::env::temp_dir().join(format!("fallow-test-single-file-{}", std::process::id()));
1080        let _ = std::fs::create_dir_all(&dir);
1081
1082        std::fs::write(
1083            dir.join("my-plugin.toml"),
1084            r#"
1085name = "single-file"
1086enablers = ["single-pkg"]
1087"#,
1088        )
1089        .unwrap();
1090
1091        let plugins = discover_external_plugins(&dir, &["my-plugin.toml".to_string()]);
1092        assert_eq!(plugins.len(), 1);
1093        assert_eq!(plugins[0].name, "single-file");
1094
1095        let _ = std::fs::remove_dir_all(&dir);
1096    }
1097
1098    #[test]
1099    fn config_plugin_path_to_single_json_file() {
1100        let dir = std::env::temp_dir().join(format!(
1101            "fallow-test-single-json-file-{}",
1102            std::process::id()
1103        ));
1104        let _ = std::fs::create_dir_all(&dir);
1105
1106        std::fs::write(
1107            dir.join("my-plugin.json"),
1108            r#"{"name": "json-single", "enablers": ["json-pkg"]}"#,
1109        )
1110        .unwrap();
1111
1112        let plugins = discover_external_plugins(&dir, &["my-plugin.json".to_string()]);
1113        assert_eq!(plugins.len(), 1);
1114        assert_eq!(plugins[0].name, "json-single");
1115
1116        let _ = std::fs::remove_dir_all(&dir);
1117    }
1118
1119    #[test]
1120    fn skips_invalid_toml() {
1121        let dir =
1122            std::env::temp_dir().join(format!("fallow-test-invalid-plugin-{}", std::process::id()));
1123        let plugins_dir = dir.join(".fallow").join("plugins");
1124        let _ = std::fs::create_dir_all(&plugins_dir);
1125
1126        std::fs::write(plugins_dir.join("bad.toml"), r#"enablers = ["pkg"]"#).unwrap();
1127
1128        std::fs::write(
1129            plugins_dir.join("good.toml"),
1130            r#"
1131name = "good"
1132enablers = ["good-pkg"]
1133"#,
1134        )
1135        .unwrap();
1136
1137        let plugins = discover_external_plugins(&dir, &[]);
1138        assert_eq!(plugins.len(), 1);
1139        assert_eq!(plugins[0].name, "good");
1140
1141        let _ = std::fs::remove_dir_all(&dir);
1142    }
1143
1144    #[test]
1145    fn skips_invalid_json() {
1146        let dir = std::env::temp_dir().join(format!(
1147            "fallow-test-invalid-json-plugin-{}",
1148            std::process::id()
1149        ));
1150        let plugins_dir = dir.join(".fallow").join("plugins");
1151        let _ = std::fs::create_dir_all(&plugins_dir);
1152
1153        std::fs::write(plugins_dir.join("bad.json"), r#"{"enablers": ["pkg"]}"#).unwrap();
1154
1155        std::fs::write(
1156            plugins_dir.join("good.json"),
1157            r#"{"name": "good-json", "enablers": ["good-pkg"]}"#,
1158        )
1159        .unwrap();
1160
1161        let plugins = discover_external_plugins(&dir, &[]);
1162        assert_eq!(plugins.len(), 1);
1163        assert_eq!(plugins[0].name, "good-json");
1164
1165        let _ = std::fs::remove_dir_all(&dir);
1166    }
1167
1168    #[test]
1169    fn prefix_enablers() {
1170        let toml_str = r#"
1171name = "scoped"
1172enablers = ["@myorg/"]
1173"#;
1174        let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
1175        assert_eq!(plugin.enablers, vec!["@myorg/"]);
1176    }
1177
1178    #[test]
1179    fn skips_empty_name() {
1180        let dir =
1181            std::env::temp_dir().join(format!("fallow-test-empty-name-{}", std::process::id()));
1182        let plugins_dir = dir.join(".fallow").join("plugins");
1183        let _ = std::fs::create_dir_all(&plugins_dir);
1184
1185        std::fs::write(
1186            plugins_dir.join("empty.toml"),
1187            r#"
1188name = ""
1189enablers = ["pkg"]
1190"#,
1191        )
1192        .unwrap();
1193
1194        let plugins = discover_external_plugins(&dir, &[]);
1195        assert!(plugins.is_empty(), "empty-name plugin should be skipped");
1196
1197        let _ = std::fs::remove_dir_all(&dir);
1198    }
1199
1200    #[test]
1201    fn rejects_paths_outside_root() {
1202        let dir =
1203            std::env::temp_dir().join(format!("fallow-test-path-escape-{}", std::process::id()));
1204        let _ = std::fs::create_dir_all(&dir);
1205
1206        let plugins = discover_external_plugins(&dir, &["../../../etc".to_string()]);
1207        assert!(plugins.is_empty(), "paths outside root should be rejected");
1208
1209        let _ = std::fs::remove_dir_all(&dir);
1210    }
1211
1212    #[test]
1213    fn plugin_format_detection() {
1214        assert!(matches!(
1215            PluginFormat::from_path(Path::new("plugin.toml")),
1216            Some(PluginFormat::Toml)
1217        ));
1218        assert!(matches!(
1219            PluginFormat::from_path(Path::new("plugin.json")),
1220            Some(PluginFormat::Json)
1221        ));
1222        assert!(matches!(
1223            PluginFormat::from_path(Path::new("plugin.jsonc")),
1224            Some(PluginFormat::Jsonc)
1225        ));
1226        assert!(PluginFormat::from_path(Path::new("plugin.yaml")).is_none());
1227        assert!(PluginFormat::from_path(Path::new("plugin")).is_none());
1228    }
1229
1230    #[test]
1231    fn is_plugin_file_checks_extensions() {
1232        assert!(is_plugin_file(Path::new("plugin.toml")));
1233        assert!(is_plugin_file(Path::new("plugin.json")));
1234        assert!(is_plugin_file(Path::new("plugin.jsonc")));
1235        assert!(!is_plugin_file(Path::new("plugin.yaml")));
1236        assert!(!is_plugin_file(Path::new("plugin.txt")));
1237        assert!(!is_plugin_file(Path::new("plugin")));
1238    }
1239
1240    #[test]
1241    fn detection_deserialize_dependency() {
1242        let json = r#"{"type": "dependency", "package": "next"}"#;
1243        let detection: PluginDetection = serde_json::from_str(json).unwrap();
1244        assert!(matches!(detection, PluginDetection::Dependency { package } if package == "next"));
1245    }
1246
1247    #[test]
1248    fn detection_deserialize_file_exists() {
1249        let json = r#"{"type": "fileExists", "pattern": "tsconfig.json"}"#;
1250        let detection: PluginDetection = serde_json::from_str(json).unwrap();
1251        assert!(
1252            matches!(detection, PluginDetection::FileExists { pattern } if pattern == "tsconfig.json")
1253        );
1254    }
1255
1256    #[test]
1257    fn detection_deserialize_all() {
1258        let json = r#"{"type": "all", "conditions": [{"type": "dependency", "package": "a"}, {"type": "dependency", "package": "b"}]}"#;
1259        let detection: PluginDetection = serde_json::from_str(json).unwrap();
1260        assert!(matches!(detection, PluginDetection::All { conditions } if conditions.len() == 2));
1261    }
1262
1263    #[test]
1264    fn detection_deserialize_any() {
1265        let json = r#"{"type": "any", "conditions": [{"type": "dependency", "package": "a"}]}"#;
1266        let detection: PluginDetection = serde_json::from_str(json).unwrap();
1267        assert!(matches!(detection, PluginDetection::Any { conditions } if conditions.len() == 1));
1268    }
1269
1270    #[test]
1271    fn plugin_with_detection_field() {
1272        let json = r#"{
1273            "name": "my-plugin",
1274            "detection": {"type": "dependency", "package": "my-pkg"},
1275            "entryPoints": ["src/**/*.ts"]
1276        }"#;
1277        let plugin: ExternalPluginDef = serde_json::from_str(json).unwrap();
1278        assert_eq!(plugin.name, "my-plugin");
1279        assert!(plugin.detection.is_some());
1280        assert!(plugin.enablers.is_empty());
1281        assert_eq!(plugin.entry_points, vec!["src/**/*.ts"]);
1282    }
1283
1284    #[test]
1285    fn plugin_without_detection_uses_enablers() {
1286        let json = r#"{
1287            "name": "my-plugin",
1288            "enablers": ["my-pkg"]
1289        }"#;
1290        let plugin: ExternalPluginDef = serde_json::from_str(json).unwrap();
1291        assert!(plugin.detection.is_none());
1292        assert_eq!(plugin.enablers, vec!["my-pkg"]);
1293    }
1294
1295    #[test]
1296    fn detection_nested_all_with_any() {
1297        let json = r#"{
1298            "type": "all",
1299            "conditions": [
1300                {"type": "dependency", "package": "react"},
1301                {"type": "any", "conditions": [
1302                    {"type": "fileExists", "pattern": "next.config.js"},
1303                    {"type": "fileExists", "pattern": "next.config.mjs"}
1304                ]}
1305            ]
1306        }"#;
1307        let detection: PluginDetection = serde_json::from_str(json).unwrap();
1308        match detection {
1309            PluginDetection::All { conditions } => {
1310                assert_eq!(conditions.len(), 2);
1311                assert!(matches!(
1312                    &conditions[0],
1313                    PluginDetection::Dependency { package } if package == "react"
1314                ));
1315                match &conditions[1] {
1316                    PluginDetection::Any { conditions: inner } => {
1317                        assert_eq!(inner.len(), 2);
1318                    }
1319                    other => panic!("expected Any, got: {other:?}"),
1320                }
1321            }
1322            other => panic!("expected All, got: {other:?}"),
1323        }
1324    }
1325
1326    #[test]
1327    fn detection_empty_all_conditions() {
1328        let json = r#"{"type": "all", "conditions": []}"#;
1329        let detection: PluginDetection = serde_json::from_str(json).unwrap();
1330        assert!(matches!(
1331            detection,
1332            PluginDetection::All { conditions } if conditions.is_empty()
1333        ));
1334    }
1335
1336    #[test]
1337    fn detection_empty_any_conditions() {
1338        let json = r#"{"type": "any", "conditions": []}"#;
1339        let detection: PluginDetection = serde_json::from_str(json).unwrap();
1340        assert!(matches!(
1341            detection,
1342            PluginDetection::Any { conditions } if conditions.is_empty()
1343        ));
1344    }
1345
1346    #[test]
1347    fn detection_toml_dependency() {
1348        let toml_str = r#"
1349name = "my-plugin"
1350
1351[detection]
1352type = "dependency"
1353package = "next"
1354"#;
1355        let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
1356        assert!(plugin.detection.is_some());
1357        assert!(matches!(
1358            plugin.detection.unwrap(),
1359            PluginDetection::Dependency { package } if package == "next"
1360        ));
1361    }
1362
1363    #[test]
1364    fn detection_toml_file_exists() {
1365        let toml_str = r#"
1366name = "my-plugin"
1367
1368[detection]
1369type = "fileExists"
1370pattern = "next.config.js"
1371"#;
1372        let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
1373        assert!(matches!(
1374            plugin.detection.unwrap(),
1375            PluginDetection::FileExists { pattern } if pattern == "next.config.js"
1376        ));
1377    }
1378
1379    #[test]
1380    fn plugin_all_fields_json() {
1381        let json = r#"{
1382            "$schema": "https://raw.githubusercontent.com/fallow-rs/fallow/main/plugin-schema.json",
1383            "name": "full-plugin",
1384            "detection": {"type": "dependency", "package": "my-pkg"},
1385            "enablers": ["fallback-enabler"],
1386            "entryPoints": ["src/entry.ts"],
1387            "configPatterns": ["config.js"],
1388            "alwaysUsed": ["src/polyfills.ts"],
1389            "toolingDependencies": ["my-cli"],
1390            "usedExports": [{"pattern": "src/**", "exports": ["default", "setup"]}]
1391        }"#;
1392        let plugin: ExternalPluginDef = serde_json::from_str(json).unwrap();
1393        assert_eq!(plugin.name, "full-plugin");
1394        assert!(plugin.detection.is_some());
1395        assert_eq!(plugin.enablers, vec!["fallback-enabler"]);
1396        assert_eq!(plugin.entry_points, vec!["src/entry.ts"]);
1397        assert_eq!(plugin.config_patterns, vec!["config.js"]);
1398        assert_eq!(plugin.always_used, vec!["src/polyfills.ts"]);
1399        assert_eq!(plugin.tooling_dependencies, vec!["my-cli"]);
1400        assert_eq!(plugin.used_exports.len(), 1);
1401        assert_eq!(plugin.used_exports[0].pattern, "src/**");
1402        assert_eq!(plugin.used_exports[0].exports, vec!["default", "setup"]);
1403    }
1404
1405    #[test]
1406    fn plugin_with_special_chars_in_name() {
1407        let json = r#"{"name": "@scope/my-plugin-v2.0", "enablers": ["pkg"]}"#;
1408        let plugin: ExternalPluginDef = serde_json::from_str(json).unwrap();
1409        assert_eq!(plugin.name, "@scope/my-plugin-v2.0");
1410    }
1411
1412    #[test]
1413    fn parse_plugin_toml_format() {
1414        let content = r#"
1415name = "test-plugin"
1416enablers = ["test-pkg"]
1417entryPoints = ["src/**/*.ts"]
1418"#;
1419        let result = parse_plugin(content, &PluginFormat::Toml, Path::new("test.toml"));
1420        assert!(result.is_some());
1421        let plugin = result.unwrap();
1422        assert_eq!(plugin.name, "test-plugin");
1423    }
1424
1425    #[test]
1426    fn parse_plugin_json_format() {
1427        let content = r#"{"name": "json-test", "enablers": ["pkg"]}"#;
1428        let result = parse_plugin(content, &PluginFormat::Json, Path::new("test.json"));
1429        assert!(result.is_some());
1430        assert_eq!(result.unwrap().name, "json-test");
1431    }
1432
1433    #[test]
1434    fn parse_plugin_jsonc_format() {
1435        let content = r#"{
1436            "name": "jsonc-test",
1437            "enablers": ["pkg"]
1438        }"#;
1439        let result = parse_plugin(content, &PluginFormat::Jsonc, Path::new("test.jsonc"));
1440        assert!(result.is_some());
1441        assert_eq!(result.unwrap().name, "jsonc-test");
1442    }
1443
1444    #[test]
1445    fn parse_plugin_invalid_toml_returns_none() {
1446        let content = "not valid toml [[[";
1447        let result = parse_plugin(content, &PluginFormat::Toml, Path::new("bad.toml"));
1448        assert!(result.is_none());
1449    }
1450
1451    #[test]
1452    fn parse_plugin_invalid_json_returns_none() {
1453        let content = "{ not valid json }";
1454        let result = parse_plugin(content, &PluginFormat::Json, Path::new("bad.json"));
1455        assert!(result.is_none());
1456    }
1457
1458    #[test]
1459    fn parse_plugin_invalid_jsonc_returns_none() {
1460        let content = r#"{"enablers": ["pkg"]}"#;
1461        let result = parse_plugin(content, &PluginFormat::Jsonc, Path::new("bad.jsonc"));
1462        assert!(result.is_none());
1463    }
1464}