Skip to main content

fallow_config/
external_plugin.rs

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