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).
381fn is_within_root(path: &Path, canonical_root: &Path) -> bool {
382    let canonical = dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
383    canonical.starts_with(canonical_root)
384}
385
386fn load_plugins_from_dir(
387    dir: &Path,
388    canonical_root: &Path,
389    plugins: &mut Vec<ExternalPluginDef>,
390    seen: &mut rustc_hash::FxHashSet<String>,
391) {
392    if let Ok(entries) = std::fs::read_dir(dir) {
393        let mut plugin_files: Vec<PathBuf> = entries
394            .filter_map(Result::ok)
395            .map(|e| e.path())
396            .filter(|p| p.is_file() && is_plugin_file(p))
397            .collect();
398        plugin_files.sort();
399        for path in plugin_files {
400            load_plugin_file(&path, canonical_root, plugins, seen);
401        }
402    }
403}
404
405fn load_plugin_file(
406    path: &Path,
407    canonical_root: &Path,
408    plugins: &mut Vec<ExternalPluginDef>,
409    seen: &mut rustc_hash::FxHashSet<String>,
410) {
411    if !is_within_root(path, canonical_root) {
412        tracing::warn!(
413            "plugin file '{}' resolves outside project root (symlink?), skipping",
414            path.display()
415        );
416        return;
417    }
418
419    let Some(format) = PluginFormat::from_path(path) else {
420        tracing::warn!(
421            "unsupported plugin file extension for {}, expected .toml, .json, or .jsonc",
422            path.display()
423        );
424        return;
425    };
426
427    match std::fs::read_to_string(path) {
428        Ok(content) => {
429            if let Some(plugin) = parse_plugin(&content, &format, path) {
430                if plugin.name.is_empty() {
431                    tracing::warn!(
432                        "external plugin in {} has an empty name, skipping",
433                        path.display()
434                    );
435                    return;
436                }
437                if seen.insert(plugin.name.clone()) {
438                    plugins.push(plugin);
439                } else {
440                    tracing::warn!(
441                        "duplicate external plugin '{}' in {}, skipping",
442                        plugin.name,
443                        path.display()
444                    );
445                }
446            }
447        }
448        Err(e) => {
449            tracing::warn!(
450                "failed to read external plugin file {}: {e}",
451                path.display()
452            );
453        }
454    }
455}
456
457#[cfg(test)]
458mod tests {
459    use super::*;
460    use crate::ScopedUsedClassMemberRule;
461
462    #[test]
463    fn deserialize_minimal_plugin() {
464        let toml_str = r#"
465name = "my-plugin"
466enablers = ["my-pkg"]
467"#;
468        let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
469        assert_eq!(plugin.name, "my-plugin");
470        assert_eq!(plugin.enablers, vec!["my-pkg"]);
471        assert!(plugin.entry_points.is_empty());
472        assert!(plugin.always_used.is_empty());
473        assert!(plugin.config_patterns.is_empty());
474        assert!(plugin.tooling_dependencies.is_empty());
475        assert!(plugin.used_exports.is_empty());
476        assert!(plugin.used_class_members.is_empty());
477    }
478
479    #[test]
480    fn deserialize_plugin_with_used_class_members_json() {
481        let json_str = r#"{
482            "name": "ag-grid",
483            "enablers": ["ag-grid-angular"],
484            "usedClassMembers": ["agInit", "refresh"]
485        }"#;
486        let plugin: ExternalPluginDef = serde_json::from_str(json_str).unwrap();
487        assert_eq!(plugin.name, "ag-grid");
488        assert_eq!(
489            plugin.used_class_members,
490            vec![
491                UsedClassMemberRule::from("agInit"),
492                UsedClassMemberRule::from("refresh"),
493            ]
494        );
495    }
496
497    #[test]
498    fn deserialize_plugin_with_scoped_used_class_members_json() {
499        let json_str = r#"{
500            "name": "ag-grid",
501            "enablers": ["ag-grid-angular"],
502            "usedClassMembers": [
503                "agInit",
504                { "implements": "ICellRendererAngularComp", "members": ["refresh"] },
505                { "extends": "BaseCommand", "members": ["execute"] }
506            ]
507        }"#;
508        let plugin: ExternalPluginDef = serde_json::from_str(json_str).unwrap();
509        assert_eq!(
510            plugin.used_class_members,
511            vec![
512                UsedClassMemberRule::from("agInit"),
513                UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
514                    extends: None,
515                    implements: Some("ICellRendererAngularComp".to_string()),
516                    members: vec!["refresh".to_string()],
517                }),
518                UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
519                    extends: Some("BaseCommand".to_string()),
520                    implements: None,
521                    members: vec!["execute".to_string()],
522                }),
523            ]
524        );
525    }
526
527    #[test]
528    fn deserialize_plugin_with_used_class_members_toml() {
529        let toml_str = r#"
530name = "ag-grid"
531enablers = ["ag-grid-angular"]
532usedClassMembers = ["agInit", "refresh"]
533"#;
534        let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
535        assert_eq!(
536            plugin.used_class_members,
537            vec![
538                UsedClassMemberRule::from("agInit"),
539                UsedClassMemberRule::from("refresh"),
540            ]
541        );
542    }
543
544    #[test]
545    fn deserialize_plugin_with_scoped_used_class_members_toml() {
546        let toml_str = r#"
547name = "ag-grid"
548enablers = ["ag-grid-angular"]
549usedClassMembers = [
550  { implements = "ICellRendererAngularComp", members = ["refresh"] },
551  { extends = "BaseCommand", members = ["execute"] }
552]
553"#;
554        let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
555        assert_eq!(
556            plugin.used_class_members,
557            vec![
558                UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
559                    extends: None,
560                    implements: Some("ICellRendererAngularComp".to_string()),
561                    members: vec!["refresh".to_string()],
562                }),
563                UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
564                    extends: Some("BaseCommand".to_string()),
565                    implements: None,
566                    members: vec!["execute".to_string()],
567                }),
568            ]
569        );
570    }
571
572    #[test]
573    fn deserialize_plugin_rejects_unconstrained_scoped_used_class_members() {
574        let result = serde_json::from_str::<ExternalPluginDef>(
575            r#"{
576                "name": "ag-grid",
577                "enablers": ["ag-grid-angular"],
578                "usedClassMembers": [{ "members": ["refresh"] }]
579            }"#,
580        );
581        assert!(
582            result.is_err(),
583            "unconstrained scoped rule should be rejected"
584        );
585    }
586
587    #[test]
588    fn deserialize_full_plugin() {
589        let toml_str = r#"
590name = "my-framework"
591enablers = ["my-framework", "@my-framework/core"]
592entryPoints = ["src/routes/**/*.{ts,tsx}", "src/middleware.ts"]
593configPatterns = ["my-framework.config.{ts,js,mjs}"]
594alwaysUsed = ["src/setup.ts", "public/**/*"]
595toolingDependencies = ["my-framework-cli"]
596
597[[usedExports]]
598pattern = "src/routes/**/*.{ts,tsx}"
599exports = ["default", "loader", "action"]
600
601[[usedExports]]
602pattern = "src/middleware.ts"
603exports = ["default"]
604"#;
605        let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
606        assert_eq!(plugin.name, "my-framework");
607        assert_eq!(plugin.enablers.len(), 2);
608        assert_eq!(plugin.entry_points.len(), 2);
609        assert_eq!(
610            plugin.config_patterns,
611            vec!["my-framework.config.{ts,js,mjs}"]
612        );
613        assert_eq!(plugin.always_used.len(), 2);
614        assert_eq!(plugin.tooling_dependencies, vec!["my-framework-cli"]);
615        assert_eq!(plugin.used_exports.len(), 2);
616        assert_eq!(plugin.used_exports[0].pattern, "src/routes/**/*.{ts,tsx}");
617        assert_eq!(
618            plugin.used_exports[0].exports,
619            vec!["default", "loader", "action"]
620        );
621    }
622
623    #[test]
624    fn deserialize_json_plugin() {
625        let json_str = r#"{
626            "name": "my-json-plugin",
627            "enablers": ["my-pkg"],
628            "entryPoints": ["src/**/*.ts"],
629            "configPatterns": ["my-plugin.config.js"],
630            "alwaysUsed": ["src/setup.ts"],
631            "toolingDependencies": ["my-cli"],
632            "usedExports": [
633                { "pattern": "src/**/*.ts", "exports": ["default"] }
634            ]
635        }"#;
636        let plugin: ExternalPluginDef = serde_json::from_str(json_str).unwrap();
637        assert_eq!(plugin.name, "my-json-plugin");
638        assert_eq!(plugin.enablers, vec!["my-pkg"]);
639        assert_eq!(plugin.entry_points, vec!["src/**/*.ts"]);
640        assert_eq!(plugin.config_patterns, vec!["my-plugin.config.js"]);
641        assert_eq!(plugin.always_used, vec!["src/setup.ts"]);
642        assert_eq!(plugin.tooling_dependencies, vec!["my-cli"]);
643        assert_eq!(plugin.used_exports.len(), 1);
644        assert_eq!(plugin.used_exports[0].exports, vec!["default"]);
645    }
646
647    #[test]
648    fn deserialize_jsonc_plugin() {
649        let jsonc_str = r#"{
650            "name": "my-jsonc-plugin",
651            "enablers": ["my-pkg"],
652            /* Block comment */
653            "entryPoints": ["src/**/*.ts"]
654        }"#;
655        let plugin: ExternalPluginDef = crate::jsonc::parse_to_value(jsonc_str).unwrap();
656        assert_eq!(plugin.name, "my-jsonc-plugin");
657        assert_eq!(plugin.enablers, vec!["my-pkg"]);
658        assert_eq!(plugin.entry_points, vec!["src/**/*.ts"]);
659    }
660
661    #[test]
662    fn deserialize_json_with_schema_field() {
663        let json_str = r#"{
664            "$schema": "https://fallow.dev/plugin-schema.json",
665            "name": "schema-plugin",
666            "enablers": ["my-pkg"]
667        }"#;
668        let plugin: ExternalPluginDef = serde_json::from_str(json_str).unwrap();
669        assert_eq!(plugin.name, "schema-plugin");
670        assert_eq!(plugin.enablers, vec!["my-pkg"]);
671    }
672
673    #[test]
674    fn plugin_json_schema_generation() {
675        let schema = ExternalPluginDef::json_schema();
676        assert!(schema.is_object());
677        let obj = schema.as_object().unwrap();
678        assert!(obj.contains_key("properties"));
679    }
680
681    #[test]
682    fn discover_plugins_from_fallow_plugins_dir() {
683        let dir =
684            std::env::temp_dir().join(format!("fallow-test-ext-plugins-{}", std::process::id()));
685        let plugins_dir = dir.join(".fallow").join("plugins");
686        let _ = std::fs::create_dir_all(&plugins_dir);
687
688        std::fs::write(
689            plugins_dir.join("my-plugin.toml"),
690            r#"
691name = "my-plugin"
692enablers = ["my-pkg"]
693entryPoints = ["src/**/*.ts"]
694"#,
695        )
696        .unwrap();
697
698        let plugins = discover_external_plugins(&dir, &[]);
699        assert_eq!(plugins.len(), 1);
700        assert_eq!(plugins[0].name, "my-plugin");
701
702        let _ = std::fs::remove_dir_all(&dir);
703    }
704
705    #[test]
706    fn discover_json_plugins_from_fallow_plugins_dir() {
707        let dir = std::env::temp_dir().join(format!(
708            "fallow-test-ext-json-plugins-{}",
709            std::process::id()
710        ));
711        let plugins_dir = dir.join(".fallow").join("plugins");
712        let _ = std::fs::create_dir_all(&plugins_dir);
713
714        std::fs::write(
715            plugins_dir.join("my-plugin.json"),
716            r#"{"name": "json-plugin", "enablers": ["json-pkg"]}"#,
717        )
718        .unwrap();
719
720        std::fs::write(
721            plugins_dir.join("my-plugin.jsonc"),
722            r#"{
723                "name": "jsonc-plugin",
724                "enablers": ["jsonc-pkg"]
725            }"#,
726        )
727        .unwrap();
728
729        let plugins = discover_external_plugins(&dir, &[]);
730        assert_eq!(plugins.len(), 2);
731        assert_eq!(plugins[0].name, "json-plugin");
732        assert_eq!(plugins[1].name, "jsonc-plugin");
733
734        let _ = std::fs::remove_dir_all(&dir);
735    }
736
737    #[test]
738    fn discover_fallow_plugin_files_in_root() {
739        let dir =
740            std::env::temp_dir().join(format!("fallow-test-root-plugins-{}", std::process::id()));
741        let _ = std::fs::create_dir_all(&dir);
742
743        std::fs::write(
744            dir.join("fallow-plugin-custom.toml"),
745            r#"
746name = "custom"
747enablers = ["custom-pkg"]
748"#,
749        )
750        .unwrap();
751
752        std::fs::write(dir.join("some-other-file.toml"), r#"name = "ignored""#).unwrap();
753
754        let plugins = discover_external_plugins(&dir, &[]);
755        assert_eq!(plugins.len(), 1);
756        assert_eq!(plugins[0].name, "custom");
757
758        let _ = std::fs::remove_dir_all(&dir);
759    }
760
761    #[test]
762    fn discover_fallow_plugin_json_files_in_root() {
763        let dir = std::env::temp_dir().join(format!(
764            "fallow-test-root-json-plugins-{}",
765            std::process::id()
766        ));
767        let _ = std::fs::create_dir_all(&dir);
768
769        std::fs::write(
770            dir.join("fallow-plugin-custom.json"),
771            r#"{"name": "json-root", "enablers": ["json-pkg"]}"#,
772        )
773        .unwrap();
774
775        std::fs::write(
776            dir.join("fallow-plugin-custom2.jsonc"),
777            r#"{
778                "name": "jsonc-root",
779                "enablers": ["jsonc-pkg"]
780            }"#,
781        )
782        .unwrap();
783
784        std::fs::write(
785            dir.join("fallow-plugin-bad.yaml"),
786            "name: ignored\nenablers:\n  - pkg\n",
787        )
788        .unwrap();
789
790        let plugins = discover_external_plugins(&dir, &[]);
791        assert_eq!(plugins.len(), 2);
792
793        let _ = std::fs::remove_dir_all(&dir);
794    }
795
796    #[test]
797    fn discover_mixed_formats_in_dir() {
798        let dir =
799            std::env::temp_dir().join(format!("fallow-test-mixed-plugins-{}", std::process::id()));
800        let plugins_dir = dir.join(".fallow").join("plugins");
801        let _ = std::fs::create_dir_all(&plugins_dir);
802
803        std::fs::write(
804            plugins_dir.join("a-plugin.toml"),
805            r#"
806name = "toml-plugin"
807enablers = ["toml-pkg"]
808"#,
809        )
810        .unwrap();
811
812        std::fs::write(
813            plugins_dir.join("b-plugin.json"),
814            r#"{"name": "json-plugin", "enablers": ["json-pkg"]}"#,
815        )
816        .unwrap();
817
818        std::fs::write(
819            plugins_dir.join("c-plugin.jsonc"),
820            r#"{
821                "name": "jsonc-plugin",
822                "enablers": ["jsonc-pkg"]
823            }"#,
824        )
825        .unwrap();
826
827        let plugins = discover_external_plugins(&dir, &[]);
828        assert_eq!(plugins.len(), 3);
829        assert_eq!(plugins[0].name, "toml-plugin");
830        assert_eq!(plugins[1].name, "json-plugin");
831        assert_eq!(plugins[2].name, "jsonc-plugin");
832
833        let _ = std::fs::remove_dir_all(&dir);
834    }
835
836    #[test]
837    fn deduplicates_by_name() {
838        let dir =
839            std::env::temp_dir().join(format!("fallow-test-dedup-plugins-{}", std::process::id()));
840        let plugins_dir = dir.join(".fallow").join("plugins");
841        let _ = std::fs::create_dir_all(&plugins_dir);
842
843        std::fs::write(
844            plugins_dir.join("my-plugin.toml"),
845            r#"
846name = "my-plugin"
847enablers = ["pkg-a"]
848"#,
849        )
850        .unwrap();
851
852        std::fs::write(
853            dir.join("fallow-plugin-my-plugin.toml"),
854            r#"
855name = "my-plugin"
856enablers = ["pkg-b"]
857"#,
858        )
859        .unwrap();
860
861        let plugins = discover_external_plugins(&dir, &[]);
862        assert_eq!(plugins.len(), 1);
863        assert_eq!(plugins[0].enablers, vec!["pkg-a"]);
864
865        let _ = std::fs::remove_dir_all(&dir);
866    }
867
868    #[test]
869    fn config_plugin_paths_take_priority() {
870        let dir =
871            std::env::temp_dir().join(format!("fallow-test-config-paths-{}", std::process::id()));
872        let custom_dir = dir.join("custom-plugins");
873        let _ = std::fs::create_dir_all(&custom_dir);
874
875        std::fs::write(
876            custom_dir.join("explicit.toml"),
877            r#"
878name = "explicit"
879enablers = ["explicit-pkg"]
880"#,
881        )
882        .unwrap();
883
884        let plugins = discover_external_plugins(&dir, &["custom-plugins".to_string()]);
885        assert_eq!(plugins.len(), 1);
886        assert_eq!(plugins[0].name, "explicit");
887
888        let _ = std::fs::remove_dir_all(&dir);
889    }
890
891    #[test]
892    fn config_plugin_path_to_single_file() {
893        let dir =
894            std::env::temp_dir().join(format!("fallow-test-single-file-{}", std::process::id()));
895        let _ = std::fs::create_dir_all(&dir);
896
897        std::fs::write(
898            dir.join("my-plugin.toml"),
899            r#"
900name = "single-file"
901enablers = ["single-pkg"]
902"#,
903        )
904        .unwrap();
905
906        let plugins = discover_external_plugins(&dir, &["my-plugin.toml".to_string()]);
907        assert_eq!(plugins.len(), 1);
908        assert_eq!(plugins[0].name, "single-file");
909
910        let _ = std::fs::remove_dir_all(&dir);
911    }
912
913    #[test]
914    fn config_plugin_path_to_single_json_file() {
915        let dir = std::env::temp_dir().join(format!(
916            "fallow-test-single-json-file-{}",
917            std::process::id()
918        ));
919        let _ = std::fs::create_dir_all(&dir);
920
921        std::fs::write(
922            dir.join("my-plugin.json"),
923            r#"{"name": "json-single", "enablers": ["json-pkg"]}"#,
924        )
925        .unwrap();
926
927        let plugins = discover_external_plugins(&dir, &["my-plugin.json".to_string()]);
928        assert_eq!(plugins.len(), 1);
929        assert_eq!(plugins[0].name, "json-single");
930
931        let _ = std::fs::remove_dir_all(&dir);
932    }
933
934    #[test]
935    fn skips_invalid_toml() {
936        let dir =
937            std::env::temp_dir().join(format!("fallow-test-invalid-plugin-{}", std::process::id()));
938        let plugins_dir = dir.join(".fallow").join("plugins");
939        let _ = std::fs::create_dir_all(&plugins_dir);
940
941        std::fs::write(plugins_dir.join("bad.toml"), r#"enablers = ["pkg"]"#).unwrap();
942
943        std::fs::write(
944            plugins_dir.join("good.toml"),
945            r#"
946name = "good"
947enablers = ["good-pkg"]
948"#,
949        )
950        .unwrap();
951
952        let plugins = discover_external_plugins(&dir, &[]);
953        assert_eq!(plugins.len(), 1);
954        assert_eq!(plugins[0].name, "good");
955
956        let _ = std::fs::remove_dir_all(&dir);
957    }
958
959    #[test]
960    fn skips_invalid_json() {
961        let dir = std::env::temp_dir().join(format!(
962            "fallow-test-invalid-json-plugin-{}",
963            std::process::id()
964        ));
965        let plugins_dir = dir.join(".fallow").join("plugins");
966        let _ = std::fs::create_dir_all(&plugins_dir);
967
968        std::fs::write(plugins_dir.join("bad.json"), r#"{"enablers": ["pkg"]}"#).unwrap();
969
970        std::fs::write(
971            plugins_dir.join("good.json"),
972            r#"{"name": "good-json", "enablers": ["good-pkg"]}"#,
973        )
974        .unwrap();
975
976        let plugins = discover_external_plugins(&dir, &[]);
977        assert_eq!(plugins.len(), 1);
978        assert_eq!(plugins[0].name, "good-json");
979
980        let _ = std::fs::remove_dir_all(&dir);
981    }
982
983    #[test]
984    fn prefix_enablers() {
985        let toml_str = r#"
986name = "scoped"
987enablers = ["@myorg/"]
988"#;
989        let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
990        assert_eq!(plugin.enablers, vec!["@myorg/"]);
991    }
992
993    #[test]
994    fn skips_empty_name() {
995        let dir =
996            std::env::temp_dir().join(format!("fallow-test-empty-name-{}", std::process::id()));
997        let plugins_dir = dir.join(".fallow").join("plugins");
998        let _ = std::fs::create_dir_all(&plugins_dir);
999
1000        std::fs::write(
1001            plugins_dir.join("empty.toml"),
1002            r#"
1003name = ""
1004enablers = ["pkg"]
1005"#,
1006        )
1007        .unwrap();
1008
1009        let plugins = discover_external_plugins(&dir, &[]);
1010        assert!(plugins.is_empty(), "empty-name plugin should be skipped");
1011
1012        let _ = std::fs::remove_dir_all(&dir);
1013    }
1014
1015    #[test]
1016    fn rejects_paths_outside_root() {
1017        let dir =
1018            std::env::temp_dir().join(format!("fallow-test-path-escape-{}", std::process::id()));
1019        let _ = std::fs::create_dir_all(&dir);
1020
1021        let plugins = discover_external_plugins(&dir, &["../../../etc".to_string()]);
1022        assert!(plugins.is_empty(), "paths outside root should be rejected");
1023
1024        let _ = std::fs::remove_dir_all(&dir);
1025    }
1026
1027    #[test]
1028    fn plugin_format_detection() {
1029        assert!(matches!(
1030            PluginFormat::from_path(Path::new("plugin.toml")),
1031            Some(PluginFormat::Toml)
1032        ));
1033        assert!(matches!(
1034            PluginFormat::from_path(Path::new("plugin.json")),
1035            Some(PluginFormat::Json)
1036        ));
1037        assert!(matches!(
1038            PluginFormat::from_path(Path::new("plugin.jsonc")),
1039            Some(PluginFormat::Jsonc)
1040        ));
1041        assert!(PluginFormat::from_path(Path::new("plugin.yaml")).is_none());
1042        assert!(PluginFormat::from_path(Path::new("plugin")).is_none());
1043    }
1044
1045    #[test]
1046    fn is_plugin_file_checks_extensions() {
1047        assert!(is_plugin_file(Path::new("plugin.toml")));
1048        assert!(is_plugin_file(Path::new("plugin.json")));
1049        assert!(is_plugin_file(Path::new("plugin.jsonc")));
1050        assert!(!is_plugin_file(Path::new("plugin.yaml")));
1051        assert!(!is_plugin_file(Path::new("plugin.txt")));
1052        assert!(!is_plugin_file(Path::new("plugin")));
1053    }
1054
1055    #[test]
1056    fn detection_deserialize_dependency() {
1057        let json = r#"{"type": "dependency", "package": "next"}"#;
1058        let detection: PluginDetection = serde_json::from_str(json).unwrap();
1059        assert!(matches!(detection, PluginDetection::Dependency { package } if package == "next"));
1060    }
1061
1062    #[test]
1063    fn detection_deserialize_file_exists() {
1064        let json = r#"{"type": "fileExists", "pattern": "tsconfig.json"}"#;
1065        let detection: PluginDetection = serde_json::from_str(json).unwrap();
1066        assert!(
1067            matches!(detection, PluginDetection::FileExists { pattern } if pattern == "tsconfig.json")
1068        );
1069    }
1070
1071    #[test]
1072    fn detection_deserialize_all() {
1073        let json = r#"{"type": "all", "conditions": [{"type": "dependency", "package": "a"}, {"type": "dependency", "package": "b"}]}"#;
1074        let detection: PluginDetection = serde_json::from_str(json).unwrap();
1075        assert!(matches!(detection, PluginDetection::All { conditions } if conditions.len() == 2));
1076    }
1077
1078    #[test]
1079    fn detection_deserialize_any() {
1080        let json = r#"{"type": "any", "conditions": [{"type": "dependency", "package": "a"}]}"#;
1081        let detection: PluginDetection = serde_json::from_str(json).unwrap();
1082        assert!(matches!(detection, PluginDetection::Any { conditions } if conditions.len() == 1));
1083    }
1084
1085    #[test]
1086    fn plugin_with_detection_field() {
1087        let json = r#"{
1088            "name": "my-plugin",
1089            "detection": {"type": "dependency", "package": "my-pkg"},
1090            "entryPoints": ["src/**/*.ts"]
1091        }"#;
1092        let plugin: ExternalPluginDef = serde_json::from_str(json).unwrap();
1093        assert_eq!(plugin.name, "my-plugin");
1094        assert!(plugin.detection.is_some());
1095        assert!(plugin.enablers.is_empty());
1096        assert_eq!(plugin.entry_points, vec!["src/**/*.ts"]);
1097    }
1098
1099    #[test]
1100    fn plugin_without_detection_uses_enablers() {
1101        let json = r#"{
1102            "name": "my-plugin",
1103            "enablers": ["my-pkg"]
1104        }"#;
1105        let plugin: ExternalPluginDef = serde_json::from_str(json).unwrap();
1106        assert!(plugin.detection.is_none());
1107        assert_eq!(plugin.enablers, vec!["my-pkg"]);
1108    }
1109
1110    #[test]
1111    fn detection_nested_all_with_any() {
1112        let json = r#"{
1113            "type": "all",
1114            "conditions": [
1115                {"type": "dependency", "package": "react"},
1116                {"type": "any", "conditions": [
1117                    {"type": "fileExists", "pattern": "next.config.js"},
1118                    {"type": "fileExists", "pattern": "next.config.mjs"}
1119                ]}
1120            ]
1121        }"#;
1122        let detection: PluginDetection = serde_json::from_str(json).unwrap();
1123        match detection {
1124            PluginDetection::All { conditions } => {
1125                assert_eq!(conditions.len(), 2);
1126                assert!(matches!(
1127                    &conditions[0],
1128                    PluginDetection::Dependency { package } if package == "react"
1129                ));
1130                match &conditions[1] {
1131                    PluginDetection::Any { conditions: inner } => {
1132                        assert_eq!(inner.len(), 2);
1133                    }
1134                    other => panic!("expected Any, got: {other:?}"),
1135                }
1136            }
1137            other => panic!("expected All, got: {other:?}"),
1138        }
1139    }
1140
1141    #[test]
1142    fn detection_empty_all_conditions() {
1143        let json = r#"{"type": "all", "conditions": []}"#;
1144        let detection: PluginDetection = serde_json::from_str(json).unwrap();
1145        assert!(matches!(
1146            detection,
1147            PluginDetection::All { conditions } if conditions.is_empty()
1148        ));
1149    }
1150
1151    #[test]
1152    fn detection_empty_any_conditions() {
1153        let json = r#"{"type": "any", "conditions": []}"#;
1154        let detection: PluginDetection = serde_json::from_str(json).unwrap();
1155        assert!(matches!(
1156            detection,
1157            PluginDetection::Any { conditions } if conditions.is_empty()
1158        ));
1159    }
1160
1161    #[test]
1162    fn detection_toml_dependency() {
1163        let toml_str = r#"
1164name = "my-plugin"
1165
1166[detection]
1167type = "dependency"
1168package = "next"
1169"#;
1170        let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
1171        assert!(plugin.detection.is_some());
1172        assert!(matches!(
1173            plugin.detection.unwrap(),
1174            PluginDetection::Dependency { package } if package == "next"
1175        ));
1176    }
1177
1178    #[test]
1179    fn detection_toml_file_exists() {
1180        let toml_str = r#"
1181name = "my-plugin"
1182
1183[detection]
1184type = "fileExists"
1185pattern = "next.config.js"
1186"#;
1187        let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
1188        assert!(matches!(
1189            plugin.detection.unwrap(),
1190            PluginDetection::FileExists { pattern } if pattern == "next.config.js"
1191        ));
1192    }
1193
1194    #[test]
1195    fn plugin_all_fields_json() {
1196        let json = r#"{
1197            "$schema": "https://fallow.dev/plugin-schema.json",
1198            "name": "full-plugin",
1199            "detection": {"type": "dependency", "package": "my-pkg"},
1200            "enablers": ["fallback-enabler"],
1201            "entryPoints": ["src/entry.ts"],
1202            "configPatterns": ["config.js"],
1203            "alwaysUsed": ["src/polyfills.ts"],
1204            "toolingDependencies": ["my-cli"],
1205            "usedExports": [{"pattern": "src/**", "exports": ["default", "setup"]}]
1206        }"#;
1207        let plugin: ExternalPluginDef = serde_json::from_str(json).unwrap();
1208        assert_eq!(plugin.name, "full-plugin");
1209        assert!(plugin.detection.is_some());
1210        assert_eq!(plugin.enablers, vec!["fallback-enabler"]);
1211        assert_eq!(plugin.entry_points, vec!["src/entry.ts"]);
1212        assert_eq!(plugin.config_patterns, vec!["config.js"]);
1213        assert_eq!(plugin.always_used, vec!["src/polyfills.ts"]);
1214        assert_eq!(plugin.tooling_dependencies, vec!["my-cli"]);
1215        assert_eq!(plugin.used_exports.len(), 1);
1216        assert_eq!(plugin.used_exports[0].pattern, "src/**");
1217        assert_eq!(plugin.used_exports[0].exports, vec!["default", "setup"]);
1218    }
1219
1220    #[test]
1221    fn plugin_with_special_chars_in_name() {
1222        let json = r#"{"name": "@scope/my-plugin-v2.0", "enablers": ["pkg"]}"#;
1223        let plugin: ExternalPluginDef = serde_json::from_str(json).unwrap();
1224        assert_eq!(plugin.name, "@scope/my-plugin-v2.0");
1225    }
1226
1227    #[test]
1228    fn parse_plugin_toml_format() {
1229        let content = r#"
1230name = "test-plugin"
1231enablers = ["test-pkg"]
1232entryPoints = ["src/**/*.ts"]
1233"#;
1234        let result = parse_plugin(content, &PluginFormat::Toml, Path::new("test.toml"));
1235        assert!(result.is_some());
1236        let plugin = result.unwrap();
1237        assert_eq!(plugin.name, "test-plugin");
1238    }
1239
1240    #[test]
1241    fn parse_plugin_json_format() {
1242        let content = r#"{"name": "json-test", "enablers": ["pkg"]}"#;
1243        let result = parse_plugin(content, &PluginFormat::Json, Path::new("test.json"));
1244        assert!(result.is_some());
1245        assert_eq!(result.unwrap().name, "json-test");
1246    }
1247
1248    #[test]
1249    fn parse_plugin_jsonc_format() {
1250        let content = r#"{
1251            "name": "jsonc-test",
1252            "enablers": ["pkg"]
1253        }"#;
1254        let result = parse_plugin(content, &PluginFormat::Jsonc, Path::new("test.jsonc"));
1255        assert!(result.is_some());
1256        assert_eq!(result.unwrap().name, "jsonc-test");
1257    }
1258
1259    #[test]
1260    fn parse_plugin_invalid_toml_returns_none() {
1261        let content = "not valid toml [[[";
1262        let result = parse_plugin(content, &PluginFormat::Toml, Path::new("bad.toml"));
1263        assert!(result.is_none());
1264    }
1265
1266    #[test]
1267    fn parse_plugin_invalid_json_returns_none() {
1268        let content = "{ not valid json }";
1269        let result = parse_plugin(content, &PluginFormat::Json, Path::new("bad.json"));
1270        assert!(result.is_none());
1271    }
1272
1273    #[test]
1274    fn parse_plugin_invalid_jsonc_returns_none() {
1275        let content = r#"{"enablers": ["pkg"]}"#;
1276        let result = parse_plugin(content, &PluginFormat::Jsonc, Path::new("bad.jsonc"));
1277        assert!(result.is_none());
1278    }
1279}