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