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