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