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