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 to detect if a plugin should be activated.
11///
12/// When set on an `ExternalPluginDef`, this takes priority over `enablers`.
13/// Supports dependency checks, file existence checks, and boolean combinators.
14#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
15#[serde(tag = "type", rename_all = "camelCase")]
16pub enum PluginDetection {
17    /// Plugin detected if this package is in dependencies.
18    Dependency { package: String },
19    /// Plugin detected if this file pattern matches.
20    FileExists { pattern: String },
21    /// All conditions must be true.
22    All { conditions: Vec<Self> },
23    /// Any condition must be true.
24    Any { conditions: Vec<Self> },
25}
26
27/// A declarative plugin definition loaded from a standalone file or inline config.
28///
29/// External plugins provide the same static pattern capabilities as built-in
30/// plugins (entry points, always-used files, used exports, tooling dependencies),
31/// but are defined in standalone files or inline in the fallow config rather than
32/// compiled Rust code.
33///
34/// They cannot do AST-based config parsing (`resolve_config()`), but cover the
35/// vast majority of framework integration use cases.
36///
37/// Supports JSONC, JSON, and TOML formats. All use camelCase field names.
38///
39/// ```json
40/// {
41///   "$schema": "https://raw.githubusercontent.com/fallow-rs/fallow/main/plugin-schema.json",
42///   "name": "my-framework",
43///   "enablers": ["my-framework", "@my-framework/core"],
44///   "entryPoints": ["src/routes/**/*.{ts,tsx}"],
45///   "configPatterns": ["my-framework.config.{ts,js}"],
46///   "alwaysUsed": ["src/setup.ts"],
47///   "toolingDependencies": ["my-framework-cli"],
48///   "usedExports": [
49///     { "pattern": "src/routes/**/*.{ts,tsx}", "exports": ["default", "loader", "action"] }
50///   ]
51/// }
52/// ```
53#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
54#[serde(rename_all = "camelCase")]
55pub struct ExternalPluginDef {
56    /// JSON Schema reference (ignored during deserialization).
57    #[serde(rename = "$schema", default, skip_serializing)]
58    #[schemars(skip)]
59    pub schema: Option<String>,
60
61    /// Unique name for this plugin.
62    pub name: String,
63
64    /// Rich detection logic (dependency checks, file existence, boolean combinators).
65    /// Takes priority over `enablers` when set.
66    #[serde(default)]
67    pub detection: Option<PluginDetection>,
68
69    /// Package names that activate this plugin when found in package.json.
70    /// Supports exact matches and prefix patterns (ending with `/`).
71    /// Only used when `detection` is not set.
72    #[serde(default)]
73    pub enablers: Vec<String>,
74
75    /// Glob patterns for entry point files.
76    #[serde(default)]
77    pub entry_points: Vec<String>,
78
79    /// Glob patterns for config files (marked as always-used when active).
80    #[serde(default)]
81    pub config_patterns: Vec<String>,
82
83    /// Files that are always considered "used" when this plugin is active.
84    #[serde(default)]
85    pub always_used: Vec<String>,
86
87    /// Dependencies that are tooling (used via CLI/config, not source imports).
88    /// These should not be flagged as unused devDependencies.
89    #[serde(default)]
90    pub tooling_dependencies: Vec<String>,
91
92    /// Exports that are always considered used for matching file patterns.
93    #[serde(default)]
94    pub used_exports: Vec<ExternalUsedExport>,
95}
96
97/// Exports considered used for files matching a pattern.
98#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
99pub struct ExternalUsedExport {
100    /// Glob pattern for files.
101    pub pattern: String,
102    /// Export names always considered used.
103    pub exports: Vec<String>,
104}
105
106impl ExternalPluginDef {
107    /// Generate JSON Schema for the external plugin format.
108    pub fn json_schema() -> serde_json::Value {
109        serde_json::to_value(schemars::schema_for!(ExternalPluginDef)).unwrap_or_default()
110    }
111}
112
113/// Detect plugin format from file extension.
114enum PluginFormat {
115    Toml,
116    Json,
117    Jsonc,
118}
119
120impl PluginFormat {
121    fn from_path(path: &Path) -> Option<Self> {
122        match path.extension().and_then(|e| e.to_str()) {
123            Some("toml") => Some(Self::Toml),
124            Some("json") => Some(Self::Json),
125            Some("jsonc") => Some(Self::Jsonc),
126            _ => None,
127        }
128    }
129}
130
131/// Check if a file has a supported plugin extension.
132fn is_plugin_file(path: &Path) -> bool {
133    path.extension()
134        .and_then(|e| e.to_str())
135        .is_some_and(|ext| PLUGIN_EXTENSIONS.contains(&ext))
136}
137
138/// Parse a plugin definition from file content based on format.
139fn parse_plugin(content: &str, format: &PluginFormat, path: &Path) -> Option<ExternalPluginDef> {
140    match format {
141        PluginFormat::Toml => match toml::from_str::<ExternalPluginDef>(content) {
142            Ok(plugin) => Some(plugin),
143            Err(e) => {
144                tracing::warn!("failed to parse external plugin {}: {e}", path.display());
145                None
146            }
147        },
148        PluginFormat::Json => match serde_json::from_str::<ExternalPluginDef>(content) {
149            Ok(plugin) => Some(plugin),
150            Err(e) => {
151                tracing::warn!("failed to parse external plugin {}: {e}", path.display());
152                None
153            }
154        },
155        PluginFormat::Jsonc => {
156            let mut stripped = String::new();
157            match json_comments::StripComments::new(content.as_bytes())
158                .read_to_string(&mut stripped)
159            {
160                Ok(_) => match serde_json::from_str::<ExternalPluginDef>(&stripped) {
161                    Ok(plugin) => Some(plugin),
162                    Err(e) => {
163                        tracing::warn!("failed to parse external plugin {}: {e}", path.display());
164                        None
165                    }
166                },
167                Err(e) => {
168                    tracing::warn!("failed to strip comments from {}: {e}", path.display());
169                    None
170                }
171            }
172        }
173    }
174}
175
176/// Discover and load external plugin definitions for a project.
177///
178/// Discovery order (first occurrence of a plugin name wins):
179/// 1. Paths from the `plugins` config field (files or directories)
180/// 2. `.fallow/plugins/` directory (auto-discover `*.toml`, `*.json`, `*.jsonc` files)
181/// 3. Project root `fallow-plugin-*` files (`.toml`, `.json`, `.jsonc`)
182pub fn discover_external_plugins(
183    root: &Path,
184    config_plugin_paths: &[String],
185) -> Vec<ExternalPluginDef> {
186    let mut plugins = Vec::new();
187    let mut seen_names = rustc_hash::FxHashSet::default();
188
189    // All paths are checked against the canonical root to prevent symlink escapes
190    let canonical_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
191
192    // 1. Explicit paths from config
193    for path_str in config_plugin_paths {
194        let path = root.join(path_str);
195        if !is_within_root(&path, &canonical_root) {
196            tracing::warn!("plugin path '{path_str}' resolves outside project root, skipping");
197            continue;
198        }
199        if path.is_dir() {
200            load_plugins_from_dir(&path, &canonical_root, &mut plugins, &mut seen_names);
201        } else if path.is_file() {
202            load_plugin_file(&path, &canonical_root, &mut plugins, &mut seen_names);
203        }
204    }
205
206    // 2. .fallow/plugins/ directory
207    let plugins_dir = root.join(".fallow").join("plugins");
208    if plugins_dir.is_dir() && is_within_root(&plugins_dir, &canonical_root) {
209        load_plugins_from_dir(&plugins_dir, &canonical_root, &mut plugins, &mut seen_names);
210    }
211
212    // 3. Project root fallow-plugin-* files (.toml, .json, .jsonc)
213    if let Ok(entries) = std::fs::read_dir(root) {
214        let mut plugin_files: Vec<PathBuf> = entries
215            .filter_map(|e| e.ok())
216            .map(|e| e.path())
217            .filter(|p| {
218                p.is_file()
219                    && p.file_name().and_then(|n| n.to_str()).is_some_and(|n| {
220                        n.starts_with("fallow-plugin-") && is_plugin_file(Path::new(n))
221                    })
222            })
223            .collect();
224        plugin_files.sort();
225        for path in plugin_files {
226            load_plugin_file(&path, &canonical_root, &mut plugins, &mut seen_names);
227        }
228    }
229
230    plugins
231}
232
233/// Check if a path resolves within the canonical root (follows symlinks).
234fn is_within_root(path: &Path, canonical_root: &Path) -> bool {
235    let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
236    canonical.starts_with(canonical_root)
237}
238
239fn load_plugins_from_dir(
240    dir: &Path,
241    canonical_root: &Path,
242    plugins: &mut Vec<ExternalPluginDef>,
243    seen: &mut rustc_hash::FxHashSet<String>,
244) {
245    if let Ok(entries) = std::fs::read_dir(dir) {
246        let mut plugin_files: Vec<PathBuf> = entries
247            .filter_map(|e| e.ok())
248            .map(|e| e.path())
249            .filter(|p| p.is_file() && is_plugin_file(p))
250            .collect();
251        plugin_files.sort();
252        for path in plugin_files {
253            load_plugin_file(&path, canonical_root, plugins, seen);
254        }
255    }
256}
257
258fn load_plugin_file(
259    path: &Path,
260    canonical_root: &Path,
261    plugins: &mut Vec<ExternalPluginDef>,
262    seen: &mut rustc_hash::FxHashSet<String>,
263) {
264    // Verify symlinks don't escape the project root
265    if !is_within_root(path, canonical_root) {
266        tracing::warn!(
267            "plugin file '{}' resolves outside project root (symlink?), skipping",
268            path.display()
269        );
270        return;
271    }
272
273    let Some(format) = PluginFormat::from_path(path) else {
274        tracing::warn!(
275            "unsupported plugin file extension for {}, expected .toml, .json, or .jsonc",
276            path.display()
277        );
278        return;
279    };
280
281    match std::fs::read_to_string(path) {
282        Ok(content) => {
283            if let Some(plugin) = parse_plugin(&content, &format, path) {
284                if plugin.name.is_empty() {
285                    tracing::warn!(
286                        "external plugin in {} has an empty name, skipping",
287                        path.display()
288                    );
289                    return;
290                }
291                if seen.insert(plugin.name.clone()) {
292                    plugins.push(plugin);
293                } else {
294                    tracing::warn!(
295                        "duplicate external plugin '{}' in {}, skipping",
296                        plugin.name,
297                        path.display()
298                    );
299                }
300            }
301        }
302        Err(e) => {
303            tracing::warn!(
304                "failed to read external plugin file {}: {e}",
305                path.display()
306            );
307        }
308    }
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    #[test]
316    fn deserialize_minimal_plugin() {
317        let toml_str = r#"
318name = "my-plugin"
319enablers = ["my-pkg"]
320"#;
321        let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
322        assert_eq!(plugin.name, "my-plugin");
323        assert_eq!(plugin.enablers, vec!["my-pkg"]);
324        assert!(plugin.entry_points.is_empty());
325        assert!(plugin.always_used.is_empty());
326        assert!(plugin.config_patterns.is_empty());
327        assert!(plugin.tooling_dependencies.is_empty());
328        assert!(plugin.used_exports.is_empty());
329    }
330
331    #[test]
332    fn deserialize_full_plugin() {
333        let toml_str = r#"
334name = "my-framework"
335enablers = ["my-framework", "@my-framework/core"]
336entryPoints = ["src/routes/**/*.{ts,tsx}", "src/middleware.ts"]
337configPatterns = ["my-framework.config.{ts,js,mjs}"]
338alwaysUsed = ["src/setup.ts", "public/**/*"]
339toolingDependencies = ["my-framework-cli"]
340
341[[usedExports]]
342pattern = "src/routes/**/*.{ts,tsx}"
343exports = ["default", "loader", "action"]
344
345[[usedExports]]
346pattern = "src/middleware.ts"
347exports = ["default"]
348"#;
349        let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
350        assert_eq!(plugin.name, "my-framework");
351        assert_eq!(plugin.enablers.len(), 2);
352        assert_eq!(plugin.entry_points.len(), 2);
353        assert_eq!(
354            plugin.config_patterns,
355            vec!["my-framework.config.{ts,js,mjs}"]
356        );
357        assert_eq!(plugin.always_used.len(), 2);
358        assert_eq!(plugin.tooling_dependencies, vec!["my-framework-cli"]);
359        assert_eq!(plugin.used_exports.len(), 2);
360        assert_eq!(plugin.used_exports[0].pattern, "src/routes/**/*.{ts,tsx}");
361        assert_eq!(
362            plugin.used_exports[0].exports,
363            vec!["default", "loader", "action"]
364        );
365    }
366
367    #[test]
368    fn deserialize_json_plugin() {
369        let json_str = r#"{
370            "name": "my-json-plugin",
371            "enablers": ["my-pkg"],
372            "entryPoints": ["src/**/*.ts"],
373            "configPatterns": ["my-plugin.config.js"],
374            "alwaysUsed": ["src/setup.ts"],
375            "toolingDependencies": ["my-cli"],
376            "usedExports": [
377                { "pattern": "src/**/*.ts", "exports": ["default"] }
378            ]
379        }"#;
380        let plugin: ExternalPluginDef = serde_json::from_str(json_str).unwrap();
381        assert_eq!(plugin.name, "my-json-plugin");
382        assert_eq!(plugin.enablers, vec!["my-pkg"]);
383        assert_eq!(plugin.entry_points, vec!["src/**/*.ts"]);
384        assert_eq!(plugin.config_patterns, vec!["my-plugin.config.js"]);
385        assert_eq!(plugin.always_used, vec!["src/setup.ts"]);
386        assert_eq!(plugin.tooling_dependencies, vec!["my-cli"]);
387        assert_eq!(plugin.used_exports.len(), 1);
388        assert_eq!(plugin.used_exports[0].exports, vec!["default"]);
389    }
390
391    #[test]
392    fn deserialize_jsonc_plugin() {
393        let jsonc_str = r#"{
394            // This is a JSONC plugin
395            "name": "my-jsonc-plugin",
396            "enablers": ["my-pkg"],
397            /* Block comment */
398            "entryPoints": ["src/**/*.ts"]
399        }"#;
400        let mut stripped = String::new();
401        json_comments::StripComments::new(jsonc_str.as_bytes())
402            .read_to_string(&mut stripped)
403            .unwrap();
404        let plugin: ExternalPluginDef = serde_json::from_str(&stripped).unwrap();
405        assert_eq!(plugin.name, "my-jsonc-plugin");
406        assert_eq!(plugin.enablers, vec!["my-pkg"]);
407        assert_eq!(plugin.entry_points, vec!["src/**/*.ts"]);
408    }
409
410    #[test]
411    fn deserialize_json_with_schema_field() {
412        let json_str = r#"{
413            "$schema": "https://fallow.dev/plugin-schema.json",
414            "name": "schema-plugin",
415            "enablers": ["my-pkg"]
416        }"#;
417        let plugin: ExternalPluginDef = serde_json::from_str(json_str).unwrap();
418        assert_eq!(plugin.name, "schema-plugin");
419        assert_eq!(plugin.enablers, vec!["my-pkg"]);
420    }
421
422    #[test]
423    fn plugin_json_schema_generation() {
424        let schema = ExternalPluginDef::json_schema();
425        assert!(schema.is_object());
426        let obj = schema.as_object().unwrap();
427        assert!(obj.contains_key("properties"));
428    }
429
430    #[test]
431    fn discover_plugins_from_fallow_plugins_dir() {
432        let dir =
433            std::env::temp_dir().join(format!("fallow-test-ext-plugins-{}", std::process::id()));
434        let plugins_dir = dir.join(".fallow").join("plugins");
435        let _ = std::fs::create_dir_all(&plugins_dir);
436
437        std::fs::write(
438            plugins_dir.join("my-plugin.toml"),
439            r#"
440name = "my-plugin"
441enablers = ["my-pkg"]
442entryPoints = ["src/**/*.ts"]
443"#,
444        )
445        .unwrap();
446
447        let plugins = discover_external_plugins(&dir, &[]);
448        assert_eq!(plugins.len(), 1);
449        assert_eq!(plugins[0].name, "my-plugin");
450
451        let _ = std::fs::remove_dir_all(&dir);
452    }
453
454    #[test]
455    fn discover_json_plugins_from_fallow_plugins_dir() {
456        let dir = std::env::temp_dir().join(format!(
457            "fallow-test-ext-json-plugins-{}",
458            std::process::id()
459        ));
460        let plugins_dir = dir.join(".fallow").join("plugins");
461        let _ = std::fs::create_dir_all(&plugins_dir);
462
463        std::fs::write(
464            plugins_dir.join("my-plugin.json"),
465            r#"{"name": "json-plugin", "enablers": ["json-pkg"]}"#,
466        )
467        .unwrap();
468
469        std::fs::write(
470            plugins_dir.join("my-plugin.jsonc"),
471            r#"{
472                // JSONC plugin
473                "name": "jsonc-plugin",
474                "enablers": ["jsonc-pkg"]
475            }"#,
476        )
477        .unwrap();
478
479        let plugins = discover_external_plugins(&dir, &[]);
480        assert_eq!(plugins.len(), 2);
481        // Sorted: json before jsonc
482        assert_eq!(plugins[0].name, "json-plugin");
483        assert_eq!(plugins[1].name, "jsonc-plugin");
484
485        let _ = std::fs::remove_dir_all(&dir);
486    }
487
488    #[test]
489    fn discover_fallow_plugin_files_in_root() {
490        let dir =
491            std::env::temp_dir().join(format!("fallow-test-root-plugins-{}", std::process::id()));
492        let _ = std::fs::create_dir_all(&dir);
493
494        std::fs::write(
495            dir.join("fallow-plugin-custom.toml"),
496            r#"
497name = "custom"
498enablers = ["custom-pkg"]
499"#,
500        )
501        .unwrap();
502
503        // Non-matching file should be ignored
504        std::fs::write(dir.join("some-other-file.toml"), r#"name = "ignored""#).unwrap();
505
506        let plugins = discover_external_plugins(&dir, &[]);
507        assert_eq!(plugins.len(), 1);
508        assert_eq!(plugins[0].name, "custom");
509
510        let _ = std::fs::remove_dir_all(&dir);
511    }
512
513    #[test]
514    fn discover_fallow_plugin_json_files_in_root() {
515        let dir = std::env::temp_dir().join(format!(
516            "fallow-test-root-json-plugins-{}",
517            std::process::id()
518        ));
519        let _ = std::fs::create_dir_all(&dir);
520
521        std::fs::write(
522            dir.join("fallow-plugin-custom.json"),
523            r#"{"name": "json-root", "enablers": ["json-pkg"]}"#,
524        )
525        .unwrap();
526
527        std::fs::write(
528            dir.join("fallow-plugin-custom2.jsonc"),
529            r#"{
530                // JSONC root plugin
531                "name": "jsonc-root",
532                "enablers": ["jsonc-pkg"]
533            }"#,
534        )
535        .unwrap();
536
537        // Non-matching extension should be ignored
538        std::fs::write(
539            dir.join("fallow-plugin-bad.yaml"),
540            "name: ignored\nenablers:\n  - pkg\n",
541        )
542        .unwrap();
543
544        let plugins = discover_external_plugins(&dir, &[]);
545        assert_eq!(plugins.len(), 2);
546
547        let _ = std::fs::remove_dir_all(&dir);
548    }
549
550    #[test]
551    fn discover_mixed_formats_in_dir() {
552        let dir =
553            std::env::temp_dir().join(format!("fallow-test-mixed-plugins-{}", std::process::id()));
554        let plugins_dir = dir.join(".fallow").join("plugins");
555        let _ = std::fs::create_dir_all(&plugins_dir);
556
557        std::fs::write(
558            plugins_dir.join("a-plugin.toml"),
559            r#"
560name = "toml-plugin"
561enablers = ["toml-pkg"]
562"#,
563        )
564        .unwrap();
565
566        std::fs::write(
567            plugins_dir.join("b-plugin.json"),
568            r#"{"name": "json-plugin", "enablers": ["json-pkg"]}"#,
569        )
570        .unwrap();
571
572        std::fs::write(
573            plugins_dir.join("c-plugin.jsonc"),
574            r#"{
575                // JSONC plugin
576                "name": "jsonc-plugin",
577                "enablers": ["jsonc-pkg"]
578            }"#,
579        )
580        .unwrap();
581
582        let plugins = discover_external_plugins(&dir, &[]);
583        assert_eq!(plugins.len(), 3);
584        assert_eq!(plugins[0].name, "toml-plugin");
585        assert_eq!(plugins[1].name, "json-plugin");
586        assert_eq!(plugins[2].name, "jsonc-plugin");
587
588        let _ = std::fs::remove_dir_all(&dir);
589    }
590
591    #[test]
592    fn deduplicates_by_name() {
593        let dir =
594            std::env::temp_dir().join(format!("fallow-test-dedup-plugins-{}", std::process::id()));
595        let plugins_dir = dir.join(".fallow").join("plugins");
596        let _ = std::fs::create_dir_all(&plugins_dir);
597
598        // Same name in .fallow/plugins/ and root
599        std::fs::write(
600            plugins_dir.join("my-plugin.toml"),
601            r#"
602name = "my-plugin"
603enablers = ["pkg-a"]
604"#,
605        )
606        .unwrap();
607
608        std::fs::write(
609            dir.join("fallow-plugin-my-plugin.toml"),
610            r#"
611name = "my-plugin"
612enablers = ["pkg-b"]
613"#,
614        )
615        .unwrap();
616
617        let plugins = discover_external_plugins(&dir, &[]);
618        assert_eq!(plugins.len(), 1);
619        // First one wins (.fallow/plugins/ before root)
620        assert_eq!(plugins[0].enablers, vec!["pkg-a"]);
621
622        let _ = std::fs::remove_dir_all(&dir);
623    }
624
625    #[test]
626    fn config_plugin_paths_take_priority() {
627        let dir =
628            std::env::temp_dir().join(format!("fallow-test-config-paths-{}", std::process::id()));
629        let custom_dir = dir.join("custom-plugins");
630        let _ = std::fs::create_dir_all(&custom_dir);
631
632        std::fs::write(
633            custom_dir.join("explicit.toml"),
634            r#"
635name = "explicit"
636enablers = ["explicit-pkg"]
637"#,
638        )
639        .unwrap();
640
641        let plugins = discover_external_plugins(&dir, &["custom-plugins".to_string()]);
642        assert_eq!(plugins.len(), 1);
643        assert_eq!(plugins[0].name, "explicit");
644
645        let _ = std::fs::remove_dir_all(&dir);
646    }
647
648    #[test]
649    fn config_plugin_path_to_single_file() {
650        let dir =
651            std::env::temp_dir().join(format!("fallow-test-single-file-{}", std::process::id()));
652        let _ = std::fs::create_dir_all(&dir);
653
654        std::fs::write(
655            dir.join("my-plugin.toml"),
656            r#"
657name = "single-file"
658enablers = ["single-pkg"]
659"#,
660        )
661        .unwrap();
662
663        let plugins = discover_external_plugins(&dir, &["my-plugin.toml".to_string()]);
664        assert_eq!(plugins.len(), 1);
665        assert_eq!(plugins[0].name, "single-file");
666
667        let _ = std::fs::remove_dir_all(&dir);
668    }
669
670    #[test]
671    fn config_plugin_path_to_single_json_file() {
672        let dir = std::env::temp_dir().join(format!(
673            "fallow-test-single-json-file-{}",
674            std::process::id()
675        ));
676        let _ = std::fs::create_dir_all(&dir);
677
678        std::fs::write(
679            dir.join("my-plugin.json"),
680            r#"{"name": "json-single", "enablers": ["json-pkg"]}"#,
681        )
682        .unwrap();
683
684        let plugins = discover_external_plugins(&dir, &["my-plugin.json".to_string()]);
685        assert_eq!(plugins.len(), 1);
686        assert_eq!(plugins[0].name, "json-single");
687
688        let _ = std::fs::remove_dir_all(&dir);
689    }
690
691    #[test]
692    fn skips_invalid_toml() {
693        let dir =
694            std::env::temp_dir().join(format!("fallow-test-invalid-plugin-{}", std::process::id()));
695        let plugins_dir = dir.join(".fallow").join("plugins");
696        let _ = std::fs::create_dir_all(&plugins_dir);
697
698        // Invalid: missing required `name` field
699        std::fs::write(plugins_dir.join("bad.toml"), r#"enablers = ["pkg"]"#).unwrap();
700
701        // Valid
702        std::fs::write(
703            plugins_dir.join("good.toml"),
704            r#"
705name = "good"
706enablers = ["good-pkg"]
707"#,
708        )
709        .unwrap();
710
711        let plugins = discover_external_plugins(&dir, &[]);
712        assert_eq!(plugins.len(), 1);
713        assert_eq!(plugins[0].name, "good");
714
715        let _ = std::fs::remove_dir_all(&dir);
716    }
717
718    #[test]
719    fn skips_invalid_json() {
720        let dir = std::env::temp_dir().join(format!(
721            "fallow-test-invalid-json-plugin-{}",
722            std::process::id()
723        ));
724        let plugins_dir = dir.join(".fallow").join("plugins");
725        let _ = std::fs::create_dir_all(&plugins_dir);
726
727        // Invalid JSON: missing name
728        std::fs::write(plugins_dir.join("bad.json"), r#"{"enablers": ["pkg"]}"#).unwrap();
729
730        // Valid JSON
731        std::fs::write(
732            plugins_dir.join("good.json"),
733            r#"{"name": "good-json", "enablers": ["good-pkg"]}"#,
734        )
735        .unwrap();
736
737        let plugins = discover_external_plugins(&dir, &[]);
738        assert_eq!(plugins.len(), 1);
739        assert_eq!(plugins[0].name, "good-json");
740
741        let _ = std::fs::remove_dir_all(&dir);
742    }
743
744    #[test]
745    fn prefix_enablers() {
746        let toml_str = r#"
747name = "scoped"
748enablers = ["@myorg/"]
749"#;
750        let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
751        assert_eq!(plugin.enablers, vec!["@myorg/"]);
752    }
753
754    #[test]
755    fn skips_empty_name() {
756        let dir =
757            std::env::temp_dir().join(format!("fallow-test-empty-name-{}", std::process::id()));
758        let plugins_dir = dir.join(".fallow").join("plugins");
759        let _ = std::fs::create_dir_all(&plugins_dir);
760
761        std::fs::write(
762            plugins_dir.join("empty.toml"),
763            r#"
764name = ""
765enablers = ["pkg"]
766"#,
767        )
768        .unwrap();
769
770        let plugins = discover_external_plugins(&dir, &[]);
771        assert!(plugins.is_empty(), "empty-name plugin should be skipped");
772
773        let _ = std::fs::remove_dir_all(&dir);
774    }
775
776    #[test]
777    fn rejects_paths_outside_root() {
778        let dir =
779            std::env::temp_dir().join(format!("fallow-test-path-escape-{}", std::process::id()));
780        let _ = std::fs::create_dir_all(&dir);
781
782        // Attempt to load a plugin from outside the project root
783        let plugins = discover_external_plugins(&dir, &["../../../etc".to_string()]);
784        assert!(plugins.is_empty(), "paths outside root should be rejected");
785
786        let _ = std::fs::remove_dir_all(&dir);
787    }
788
789    #[test]
790    fn plugin_format_detection() {
791        assert!(matches!(
792            PluginFormat::from_path(Path::new("plugin.toml")),
793            Some(PluginFormat::Toml)
794        ));
795        assert!(matches!(
796            PluginFormat::from_path(Path::new("plugin.json")),
797            Some(PluginFormat::Json)
798        ));
799        assert!(matches!(
800            PluginFormat::from_path(Path::new("plugin.jsonc")),
801            Some(PluginFormat::Jsonc)
802        ));
803        assert!(PluginFormat::from_path(Path::new("plugin.yaml")).is_none());
804        assert!(PluginFormat::from_path(Path::new("plugin")).is_none());
805    }
806
807    #[test]
808    fn is_plugin_file_checks_extensions() {
809        assert!(is_plugin_file(Path::new("plugin.toml")));
810        assert!(is_plugin_file(Path::new("plugin.json")));
811        assert!(is_plugin_file(Path::new("plugin.jsonc")));
812        assert!(!is_plugin_file(Path::new("plugin.yaml")));
813        assert!(!is_plugin_file(Path::new("plugin.txt")));
814        assert!(!is_plugin_file(Path::new("plugin")));
815    }
816
817    // ── PluginDetection tests ────────────────────────────────────
818
819    #[test]
820    fn detection_deserialize_dependency() {
821        let json = r#"{"type": "dependency", "package": "next"}"#;
822        let detection: PluginDetection = serde_json::from_str(json).unwrap();
823        assert!(matches!(detection, PluginDetection::Dependency { package } if package == "next"));
824    }
825
826    #[test]
827    fn detection_deserialize_file_exists() {
828        let json = r#"{"type": "fileExists", "pattern": "tsconfig.json"}"#;
829        let detection: PluginDetection = serde_json::from_str(json).unwrap();
830        assert!(
831            matches!(detection, PluginDetection::FileExists { pattern } if pattern == "tsconfig.json")
832        );
833    }
834
835    #[test]
836    fn detection_deserialize_all() {
837        let json = r#"{"type": "all", "conditions": [{"type": "dependency", "package": "a"}, {"type": "dependency", "package": "b"}]}"#;
838        let detection: PluginDetection = serde_json::from_str(json).unwrap();
839        assert!(matches!(detection, PluginDetection::All { conditions } if conditions.len() == 2));
840    }
841
842    #[test]
843    fn detection_deserialize_any() {
844        let json = r#"{"type": "any", "conditions": [{"type": "dependency", "package": "a"}]}"#;
845        let detection: PluginDetection = serde_json::from_str(json).unwrap();
846        assert!(matches!(detection, PluginDetection::Any { conditions } if conditions.len() == 1));
847    }
848
849    #[test]
850    fn plugin_with_detection_field() {
851        let json = r#"{
852            "name": "my-plugin",
853            "detection": {"type": "dependency", "package": "my-pkg"},
854            "entryPoints": ["src/**/*.ts"]
855        }"#;
856        let plugin: ExternalPluginDef = serde_json::from_str(json).unwrap();
857        assert_eq!(plugin.name, "my-plugin");
858        assert!(plugin.detection.is_some());
859        assert!(plugin.enablers.is_empty());
860        assert_eq!(plugin.entry_points, vec!["src/**/*.ts"]);
861    }
862
863    #[test]
864    fn plugin_without_detection_uses_enablers() {
865        let json = r#"{
866            "name": "my-plugin",
867            "enablers": ["my-pkg"]
868        }"#;
869        let plugin: ExternalPluginDef = serde_json::from_str(json).unwrap();
870        assert!(plugin.detection.is_none());
871        assert_eq!(plugin.enablers, vec!["my-pkg"]);
872    }
873}