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