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