Skip to main content

fallow_config/
external_plugin.rs

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