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
7use crate::config::UsedClassMemberRule;
8
9/// Supported plugin file extensions.
10const PLUGIN_EXTENSIONS: &[&str] = &["toml", "json", "jsonc"];
11
12/// How a plugin's discovered entry points contribute to coverage reachability.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema, Default)]
14#[serde(rename_all = "camelCase")]
15pub enum EntryPointRole {
16    /// Runtime/application roots that should count toward runtime reachability.
17    Runtime,
18    /// Test roots that should count toward test reachability.
19    Test,
20    /// Support/setup/config roots that should keep files alive but not count as runtime/test.
21    #[default]
22    Support,
23}
24
25/// How to detect if a plugin should be activated.
26///
27/// When set on an `ExternalPluginDef`, this takes priority over `enablers`.
28/// Supports dependency checks, file existence checks, and boolean combinators.
29#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
30#[serde(tag = "type", rename_all = "camelCase")]
31pub enum PluginDetection {
32    /// Plugin detected if this package is in dependencies.
33    Dependency { package: String },
34    /// Plugin detected if this file pattern matches.
35    FileExists { pattern: String },
36    /// All conditions must be true.
37    All { conditions: Vec<Self> },
38    /// Any condition must be true.
39    Any { conditions: Vec<Self> },
40}
41
42/// A declarative plugin definition loaded from a standalone file or inline config.
43///
44/// External plugins provide the same static pattern capabilities as built-in
45/// plugins (entry points, always-used files, used exports, tooling dependencies),
46/// but are defined in standalone files or inline in the fallow config rather than
47/// compiled Rust code.
48///
49/// They cannot do AST-based config parsing (`resolve_config()`), but cover the
50/// vast majority of framework integration use cases.
51///
52/// Supports JSONC, JSON, and TOML formats. All use camelCase field names.
53///
54/// ```json
55/// {
56///   "$schema": "https://raw.githubusercontent.com/fallow-rs/fallow/main/plugin-schema.json",
57///   "name": "my-framework",
58///   "enablers": ["my-framework", "@my-framework/core"],
59///   "entryPoints": ["src/routes/**/*.{ts,tsx}"],
60///   "configPatterns": ["my-framework.config.{ts,js}"],
61///   "alwaysUsed": ["src/setup.ts"],
62///   "toolingDependencies": ["my-framework-cli"],
63///   "usedExports": [
64///     { "pattern": "src/routes/**/*.{ts,tsx}", "exports": ["default", "loader", "action"] }
65///   ]
66/// }
67/// ```
68#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
69#[serde(rename_all = "camelCase")]
70pub struct ExternalPluginDef {
71    /// JSON Schema reference (ignored during deserialization).
72    #[serde(rename = "$schema", default, skip_serializing)]
73    #[schemars(skip)]
74    pub schema: Option<String>,
75
76    /// Unique name for this plugin.
77    pub name: String,
78
79    /// Rich detection logic (dependency checks, file existence, boolean combinators).
80    /// Takes priority over `enablers` when set.
81    #[serde(default)]
82    pub detection: Option<PluginDetection>,
83
84    /// Package names that activate this plugin when found in package.json.
85    /// Supports exact matches and prefix patterns (ending with `/`).
86    /// Only used when `detection` is not set.
87    #[serde(default)]
88    pub enablers: Vec<String>,
89
90    /// Glob patterns for entry point files.
91    #[serde(default)]
92    pub entry_points: Vec<String>,
93
94    /// Coverage role for `entryPoints`.
95    ///
96    /// Defaults to `support`. Set to `runtime` for application entry points
97    /// or `test` for test framework entry points.
98    #[serde(default = "default_external_entry_point_role")]
99    pub entry_point_role: EntryPointRole,
100
101    /// Glob patterns for config files (marked as always-used when active).
102    #[serde(default)]
103    pub config_patterns: Vec<String>,
104
105    /// Files that are always considered "used" when this plugin is active.
106    #[serde(default)]
107    pub always_used: Vec<String>,
108
109    /// Dependencies that are tooling (used via CLI/config, not source imports).
110    /// These should not be flagged as unused devDependencies.
111    #[serde(default)]
112    pub tooling_dependencies: Vec<String>,
113
114    /// Exports that are always considered used for matching file patterns.
115    #[serde(default)]
116    pub used_exports: Vec<ExternalUsedExport>,
117
118    /// Class member method/property rules the framework invokes at runtime.
119    /// Supports plain member names for global suppression and scoped objects
120    /// with `extends` / `implements` constraints when the method name is too
121    /// common to suppress across the whole workspace.
122    #[serde(default)]
123    pub used_class_members: Vec<UsedClassMemberRule>,
124}
125
126/// Exports considered used for files matching a pattern.
127#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
128pub struct ExternalUsedExport {
129    /// Glob pattern for files.
130    pub pattern: String,
131    /// Export names always considered used.
132    pub exports: Vec<String>,
133}
134
135fn default_external_entry_point_role() -> EntryPointRole {
136    EntryPointRole::Support
137}
138
139impl ExternalPluginDef {
140    /// Generate JSON Schema for the external plugin format.
141    #[must_use]
142    pub fn json_schema() -> serde_json::Value {
143        serde_json::to_value(schemars::schema_for!(ExternalPluginDef)).unwrap_or_default()
144    }
145}
146
147/// Detect plugin format from file extension.
148enum PluginFormat {
149    Toml,
150    Json,
151    Jsonc,
152}
153
154impl PluginFormat {
155    fn from_path(path: &Path) -> Option<Self> {
156        match path.extension().and_then(|e| e.to_str()) {
157            Some("toml") => Some(Self::Toml),
158            Some("json") => Some(Self::Json),
159            Some("jsonc") => Some(Self::Jsonc),
160            _ => None,
161        }
162    }
163}
164
165/// Check if a file has a supported plugin extension.
166fn is_plugin_file(path: &Path) -> bool {
167    path.extension()
168        .and_then(|e| e.to_str())
169        .is_some_and(|ext| PLUGIN_EXTENSIONS.contains(&ext))
170}
171
172/// Parse a plugin definition from file content based on format.
173fn parse_plugin(content: &str, format: &PluginFormat, path: &Path) -> Option<ExternalPluginDef> {
174    match format {
175        PluginFormat::Toml => match toml::from_str::<ExternalPluginDef>(content) {
176            Ok(plugin) => Some(plugin),
177            Err(e) => {
178                tracing::warn!("failed to parse external plugin {}: {e}", path.display());
179                None
180            }
181        },
182        PluginFormat::Json => match serde_json::from_str::<ExternalPluginDef>(content) {
183            Ok(plugin) => Some(plugin),
184            Err(e) => {
185                tracing::warn!("failed to parse external plugin {}: {e}", path.display());
186                None
187            }
188        },
189        PluginFormat::Jsonc => {
190            let mut stripped = String::new();
191            match json_comments::StripComments::new(content.as_bytes())
192                .read_to_string(&mut stripped)
193            {
194                Ok(_) => match serde_json::from_str::<ExternalPluginDef>(&stripped) {
195                    Ok(plugin) => Some(plugin),
196                    Err(e) => {
197                        tracing::warn!("failed to parse external plugin {}: {e}", path.display());
198                        None
199                    }
200                },
201                Err(e) => {
202                    tracing::warn!("failed to strip comments from {}: {e}", path.display());
203                    None
204                }
205            }
206        }
207    }
208}
209
210/// Discover and load external plugin definitions for a project.
211///
212/// Discovery order (first occurrence of a plugin name wins):
213/// 1. Paths from the `plugins` config field (files or directories)
214/// 2. `.fallow/plugins/` directory (auto-discover `*.toml`, `*.json`, `*.jsonc` files)
215/// 3. Project root `fallow-plugin-*` files (`.toml`, `.json`, `.jsonc`)
216pub fn discover_external_plugins(
217    root: &Path,
218    config_plugin_paths: &[String],
219) -> Vec<ExternalPluginDef> {
220    let mut plugins = Vec::new();
221    let mut seen_names = rustc_hash::FxHashSet::default();
222
223    // All paths are checked against the canonical root to prevent symlink escapes
224    let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
225
226    // 1. Explicit paths from config
227    for path_str in config_plugin_paths {
228        let path = root.join(path_str);
229        if !is_within_root(&path, &canonical_root) {
230            tracing::warn!("plugin path '{path_str}' resolves outside project root, skipping");
231            continue;
232        }
233        if path.is_dir() {
234            load_plugins_from_dir(&path, &canonical_root, &mut plugins, &mut seen_names);
235        } else if path.is_file() {
236            load_plugin_file(&path, &canonical_root, &mut plugins, &mut seen_names);
237        }
238    }
239
240    // 2. .fallow/plugins/ directory
241    let plugins_dir = root.join(".fallow").join("plugins");
242    if plugins_dir.is_dir() && is_within_root(&plugins_dir, &canonical_root) {
243        load_plugins_from_dir(&plugins_dir, &canonical_root, &mut plugins, &mut seen_names);
244    }
245
246    // 3. Project root fallow-plugin-* files (.toml, .json, .jsonc)
247    if let Ok(entries) = std::fs::read_dir(root) {
248        let mut plugin_files: Vec<PathBuf> = entries
249            .filter_map(Result::ok)
250            .map(|e| e.path())
251            .filter(|p| {
252                p.is_file()
253                    && p.file_name().and_then(|n| n.to_str()).is_some_and(|n| {
254                        n.starts_with("fallow-plugin-") && is_plugin_file(Path::new(n))
255                    })
256            })
257            .collect();
258        plugin_files.sort();
259        for path in plugin_files {
260            load_plugin_file(&path, &canonical_root, &mut plugins, &mut seen_names);
261        }
262    }
263
264    plugins
265}
266
267/// Check if a path resolves within the canonical root (follows symlinks).
268fn is_within_root(path: &Path, canonical_root: &Path) -> bool {
269    let canonical = dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
270    canonical.starts_with(canonical_root)
271}
272
273fn load_plugins_from_dir(
274    dir: &Path,
275    canonical_root: &Path,
276    plugins: &mut Vec<ExternalPluginDef>,
277    seen: &mut rustc_hash::FxHashSet<String>,
278) {
279    if let Ok(entries) = std::fs::read_dir(dir) {
280        let mut plugin_files: Vec<PathBuf> = entries
281            .filter_map(Result::ok)
282            .map(|e| e.path())
283            .filter(|p| p.is_file() && is_plugin_file(p))
284            .collect();
285        plugin_files.sort();
286        for path in plugin_files {
287            load_plugin_file(&path, canonical_root, plugins, seen);
288        }
289    }
290}
291
292fn load_plugin_file(
293    path: &Path,
294    canonical_root: &Path,
295    plugins: &mut Vec<ExternalPluginDef>,
296    seen: &mut rustc_hash::FxHashSet<String>,
297) {
298    // Verify symlinks don't escape the project root
299    if !is_within_root(path, canonical_root) {
300        tracing::warn!(
301            "plugin file '{}' resolves outside project root (symlink?), skipping",
302            path.display()
303        );
304        return;
305    }
306
307    let Some(format) = PluginFormat::from_path(path) else {
308        tracing::warn!(
309            "unsupported plugin file extension for {}, expected .toml, .json, or .jsonc",
310            path.display()
311        );
312        return;
313    };
314
315    match std::fs::read_to_string(path) {
316        Ok(content) => {
317            if let Some(plugin) = parse_plugin(&content, &format, path) {
318                if plugin.name.is_empty() {
319                    tracing::warn!(
320                        "external plugin in {} has an empty name, skipping",
321                        path.display()
322                    );
323                    return;
324                }
325                if seen.insert(plugin.name.clone()) {
326                    plugins.push(plugin);
327                } else {
328                    tracing::warn!(
329                        "duplicate external plugin '{}' in {}, skipping",
330                        plugin.name,
331                        path.display()
332                    );
333                }
334            }
335        }
336        Err(e) => {
337            tracing::warn!(
338                "failed to read external plugin file {}: {e}",
339                path.display()
340            );
341        }
342    }
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348    use crate::ScopedUsedClassMemberRule;
349
350    #[test]
351    fn deserialize_minimal_plugin() {
352        let toml_str = r#"
353name = "my-plugin"
354enablers = ["my-pkg"]
355"#;
356        let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
357        assert_eq!(plugin.name, "my-plugin");
358        assert_eq!(plugin.enablers, vec!["my-pkg"]);
359        assert!(plugin.entry_points.is_empty());
360        assert!(plugin.always_used.is_empty());
361        assert!(plugin.config_patterns.is_empty());
362        assert!(plugin.tooling_dependencies.is_empty());
363        assert!(plugin.used_exports.is_empty());
364        assert!(plugin.used_class_members.is_empty());
365    }
366
367    #[test]
368    fn deserialize_plugin_with_used_class_members_json() {
369        let json_str = r#"{
370            "name": "ag-grid",
371            "enablers": ["ag-grid-angular"],
372            "usedClassMembers": ["agInit", "refresh"]
373        }"#;
374        let plugin: ExternalPluginDef = serde_json::from_str(json_str).unwrap();
375        assert_eq!(plugin.name, "ag-grid");
376        assert_eq!(
377            plugin.used_class_members,
378            vec![
379                UsedClassMemberRule::from("agInit"),
380                UsedClassMemberRule::from("refresh"),
381            ]
382        );
383    }
384
385    #[test]
386    fn deserialize_plugin_with_scoped_used_class_members_json() {
387        let json_str = r#"{
388            "name": "ag-grid",
389            "enablers": ["ag-grid-angular"],
390            "usedClassMembers": [
391                "agInit",
392                { "implements": "ICellRendererAngularComp", "members": ["refresh"] },
393                { "extends": "BaseCommand", "members": ["execute"] }
394            ]
395        }"#;
396        let plugin: ExternalPluginDef = serde_json::from_str(json_str).unwrap();
397        assert_eq!(
398            plugin.used_class_members,
399            vec![
400                UsedClassMemberRule::from("agInit"),
401                UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
402                    extends: None,
403                    implements: Some("ICellRendererAngularComp".to_string()),
404                    members: vec!["refresh".to_string()],
405                }),
406                UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
407                    extends: Some("BaseCommand".to_string()),
408                    implements: None,
409                    members: vec!["execute".to_string()],
410                }),
411            ]
412        );
413    }
414
415    #[test]
416    fn deserialize_plugin_with_used_class_members_toml() {
417        let toml_str = r#"
418name = "ag-grid"
419enablers = ["ag-grid-angular"]
420usedClassMembers = ["agInit", "refresh"]
421"#;
422        let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
423        assert_eq!(
424            plugin.used_class_members,
425            vec![
426                UsedClassMemberRule::from("agInit"),
427                UsedClassMemberRule::from("refresh"),
428            ]
429        );
430    }
431
432    #[test]
433    fn deserialize_plugin_with_scoped_used_class_members_toml() {
434        let toml_str = r#"
435name = "ag-grid"
436enablers = ["ag-grid-angular"]
437usedClassMembers = [
438  { implements = "ICellRendererAngularComp", members = ["refresh"] },
439  { extends = "BaseCommand", members = ["execute"] }
440]
441"#;
442        let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
443        assert_eq!(
444            plugin.used_class_members,
445            vec![
446                UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
447                    extends: None,
448                    implements: Some("ICellRendererAngularComp".to_string()),
449                    members: vec!["refresh".to_string()],
450                }),
451                UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
452                    extends: Some("BaseCommand".to_string()),
453                    implements: None,
454                    members: vec!["execute".to_string()],
455                }),
456            ]
457        );
458    }
459
460    #[test]
461    fn deserialize_plugin_rejects_unconstrained_scoped_used_class_members() {
462        let result = serde_json::from_str::<ExternalPluginDef>(
463            r#"{
464                "name": "ag-grid",
465                "enablers": ["ag-grid-angular"],
466                "usedClassMembers": [{ "members": ["refresh"] }]
467            }"#,
468        );
469        assert!(
470            result.is_err(),
471            "unconstrained scoped rule should be rejected"
472        );
473    }
474
475    #[test]
476    fn deserialize_full_plugin() {
477        let toml_str = r#"
478name = "my-framework"
479enablers = ["my-framework", "@my-framework/core"]
480entryPoints = ["src/routes/**/*.{ts,tsx}", "src/middleware.ts"]
481configPatterns = ["my-framework.config.{ts,js,mjs}"]
482alwaysUsed = ["src/setup.ts", "public/**/*"]
483toolingDependencies = ["my-framework-cli"]
484
485[[usedExports]]
486pattern = "src/routes/**/*.{ts,tsx}"
487exports = ["default", "loader", "action"]
488
489[[usedExports]]
490pattern = "src/middleware.ts"
491exports = ["default"]
492"#;
493        let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
494        assert_eq!(plugin.name, "my-framework");
495        assert_eq!(plugin.enablers.len(), 2);
496        assert_eq!(plugin.entry_points.len(), 2);
497        assert_eq!(
498            plugin.config_patterns,
499            vec!["my-framework.config.{ts,js,mjs}"]
500        );
501        assert_eq!(plugin.always_used.len(), 2);
502        assert_eq!(plugin.tooling_dependencies, vec!["my-framework-cli"]);
503        assert_eq!(plugin.used_exports.len(), 2);
504        assert_eq!(plugin.used_exports[0].pattern, "src/routes/**/*.{ts,tsx}");
505        assert_eq!(
506            plugin.used_exports[0].exports,
507            vec!["default", "loader", "action"]
508        );
509    }
510
511    #[test]
512    fn deserialize_json_plugin() {
513        let json_str = r#"{
514            "name": "my-json-plugin",
515            "enablers": ["my-pkg"],
516            "entryPoints": ["src/**/*.ts"],
517            "configPatterns": ["my-plugin.config.js"],
518            "alwaysUsed": ["src/setup.ts"],
519            "toolingDependencies": ["my-cli"],
520            "usedExports": [
521                { "pattern": "src/**/*.ts", "exports": ["default"] }
522            ]
523        }"#;
524        let plugin: ExternalPluginDef = serde_json::from_str(json_str).unwrap();
525        assert_eq!(plugin.name, "my-json-plugin");
526        assert_eq!(plugin.enablers, vec!["my-pkg"]);
527        assert_eq!(plugin.entry_points, vec!["src/**/*.ts"]);
528        assert_eq!(plugin.config_patterns, vec!["my-plugin.config.js"]);
529        assert_eq!(plugin.always_used, vec!["src/setup.ts"]);
530        assert_eq!(plugin.tooling_dependencies, vec!["my-cli"]);
531        assert_eq!(plugin.used_exports.len(), 1);
532        assert_eq!(plugin.used_exports[0].exports, vec!["default"]);
533    }
534
535    #[test]
536    fn deserialize_jsonc_plugin() {
537        let jsonc_str = r#"{
538            // This is a JSONC plugin
539            "name": "my-jsonc-plugin",
540            "enablers": ["my-pkg"],
541            /* Block comment */
542            "entryPoints": ["src/**/*.ts"]
543        }"#;
544        let mut stripped = String::new();
545        json_comments::StripComments::new(jsonc_str.as_bytes())
546            .read_to_string(&mut stripped)
547            .unwrap();
548        let plugin: ExternalPluginDef = serde_json::from_str(&stripped).unwrap();
549        assert_eq!(plugin.name, "my-jsonc-plugin");
550        assert_eq!(plugin.enablers, vec!["my-pkg"]);
551        assert_eq!(plugin.entry_points, vec!["src/**/*.ts"]);
552    }
553
554    #[test]
555    fn deserialize_json_with_schema_field() {
556        let json_str = r#"{
557            "$schema": "https://fallow.dev/plugin-schema.json",
558            "name": "schema-plugin",
559            "enablers": ["my-pkg"]
560        }"#;
561        let plugin: ExternalPluginDef = serde_json::from_str(json_str).unwrap();
562        assert_eq!(plugin.name, "schema-plugin");
563        assert_eq!(plugin.enablers, vec!["my-pkg"]);
564    }
565
566    #[test]
567    fn plugin_json_schema_generation() {
568        let schema = ExternalPluginDef::json_schema();
569        assert!(schema.is_object());
570        let obj = schema.as_object().unwrap();
571        assert!(obj.contains_key("properties"));
572    }
573
574    #[test]
575    fn discover_plugins_from_fallow_plugins_dir() {
576        let dir =
577            std::env::temp_dir().join(format!("fallow-test-ext-plugins-{}", std::process::id()));
578        let plugins_dir = dir.join(".fallow").join("plugins");
579        let _ = std::fs::create_dir_all(&plugins_dir);
580
581        std::fs::write(
582            plugins_dir.join("my-plugin.toml"),
583            r#"
584name = "my-plugin"
585enablers = ["my-pkg"]
586entryPoints = ["src/**/*.ts"]
587"#,
588        )
589        .unwrap();
590
591        let plugins = discover_external_plugins(&dir, &[]);
592        assert_eq!(plugins.len(), 1);
593        assert_eq!(plugins[0].name, "my-plugin");
594
595        let _ = std::fs::remove_dir_all(&dir);
596    }
597
598    #[test]
599    fn discover_json_plugins_from_fallow_plugins_dir() {
600        let dir = std::env::temp_dir().join(format!(
601            "fallow-test-ext-json-plugins-{}",
602            std::process::id()
603        ));
604        let plugins_dir = dir.join(".fallow").join("plugins");
605        let _ = std::fs::create_dir_all(&plugins_dir);
606
607        std::fs::write(
608            plugins_dir.join("my-plugin.json"),
609            r#"{"name": "json-plugin", "enablers": ["json-pkg"]}"#,
610        )
611        .unwrap();
612
613        std::fs::write(
614            plugins_dir.join("my-plugin.jsonc"),
615            r#"{
616                // JSONC plugin
617                "name": "jsonc-plugin",
618                "enablers": ["jsonc-pkg"]
619            }"#,
620        )
621        .unwrap();
622
623        let plugins = discover_external_plugins(&dir, &[]);
624        assert_eq!(plugins.len(), 2);
625        // Sorted: json before jsonc
626        assert_eq!(plugins[0].name, "json-plugin");
627        assert_eq!(plugins[1].name, "jsonc-plugin");
628
629        let _ = std::fs::remove_dir_all(&dir);
630    }
631
632    #[test]
633    fn discover_fallow_plugin_files_in_root() {
634        let dir =
635            std::env::temp_dir().join(format!("fallow-test-root-plugins-{}", std::process::id()));
636        let _ = std::fs::create_dir_all(&dir);
637
638        std::fs::write(
639            dir.join("fallow-plugin-custom.toml"),
640            r#"
641name = "custom"
642enablers = ["custom-pkg"]
643"#,
644        )
645        .unwrap();
646
647        // Non-matching file should be ignored
648        std::fs::write(dir.join("some-other-file.toml"), r#"name = "ignored""#).unwrap();
649
650        let plugins = discover_external_plugins(&dir, &[]);
651        assert_eq!(plugins.len(), 1);
652        assert_eq!(plugins[0].name, "custom");
653
654        let _ = std::fs::remove_dir_all(&dir);
655    }
656
657    #[test]
658    fn discover_fallow_plugin_json_files_in_root() {
659        let dir = std::env::temp_dir().join(format!(
660            "fallow-test-root-json-plugins-{}",
661            std::process::id()
662        ));
663        let _ = std::fs::create_dir_all(&dir);
664
665        std::fs::write(
666            dir.join("fallow-plugin-custom.json"),
667            r#"{"name": "json-root", "enablers": ["json-pkg"]}"#,
668        )
669        .unwrap();
670
671        std::fs::write(
672            dir.join("fallow-plugin-custom2.jsonc"),
673            r#"{
674                // JSONC root plugin
675                "name": "jsonc-root",
676                "enablers": ["jsonc-pkg"]
677            }"#,
678        )
679        .unwrap();
680
681        // Non-matching extension should be ignored
682        std::fs::write(
683            dir.join("fallow-plugin-bad.yaml"),
684            "name: ignored\nenablers:\n  - pkg\n",
685        )
686        .unwrap();
687
688        let plugins = discover_external_plugins(&dir, &[]);
689        assert_eq!(plugins.len(), 2);
690
691        let _ = std::fs::remove_dir_all(&dir);
692    }
693
694    #[test]
695    fn discover_mixed_formats_in_dir() {
696        let dir =
697            std::env::temp_dir().join(format!("fallow-test-mixed-plugins-{}", std::process::id()));
698        let plugins_dir = dir.join(".fallow").join("plugins");
699        let _ = std::fs::create_dir_all(&plugins_dir);
700
701        std::fs::write(
702            plugins_dir.join("a-plugin.toml"),
703            r#"
704name = "toml-plugin"
705enablers = ["toml-pkg"]
706"#,
707        )
708        .unwrap();
709
710        std::fs::write(
711            plugins_dir.join("b-plugin.json"),
712            r#"{"name": "json-plugin", "enablers": ["json-pkg"]}"#,
713        )
714        .unwrap();
715
716        std::fs::write(
717            plugins_dir.join("c-plugin.jsonc"),
718            r#"{
719                // JSONC plugin
720                "name": "jsonc-plugin",
721                "enablers": ["jsonc-pkg"]
722            }"#,
723        )
724        .unwrap();
725
726        let plugins = discover_external_plugins(&dir, &[]);
727        assert_eq!(plugins.len(), 3);
728        assert_eq!(plugins[0].name, "toml-plugin");
729        assert_eq!(plugins[1].name, "json-plugin");
730        assert_eq!(plugins[2].name, "jsonc-plugin");
731
732        let _ = std::fs::remove_dir_all(&dir);
733    }
734
735    #[test]
736    fn deduplicates_by_name() {
737        let dir =
738            std::env::temp_dir().join(format!("fallow-test-dedup-plugins-{}", std::process::id()));
739        let plugins_dir = dir.join(".fallow").join("plugins");
740        let _ = std::fs::create_dir_all(&plugins_dir);
741
742        // Same name in .fallow/plugins/ and root
743        std::fs::write(
744            plugins_dir.join("my-plugin.toml"),
745            r#"
746name = "my-plugin"
747enablers = ["pkg-a"]
748"#,
749        )
750        .unwrap();
751
752        std::fs::write(
753            dir.join("fallow-plugin-my-plugin.toml"),
754            r#"
755name = "my-plugin"
756enablers = ["pkg-b"]
757"#,
758        )
759        .unwrap();
760
761        let plugins = discover_external_plugins(&dir, &[]);
762        assert_eq!(plugins.len(), 1);
763        // First one wins (.fallow/plugins/ before root)
764        assert_eq!(plugins[0].enablers, vec!["pkg-a"]);
765
766        let _ = std::fs::remove_dir_all(&dir);
767    }
768
769    #[test]
770    fn config_plugin_paths_take_priority() {
771        let dir =
772            std::env::temp_dir().join(format!("fallow-test-config-paths-{}", std::process::id()));
773        let custom_dir = dir.join("custom-plugins");
774        let _ = std::fs::create_dir_all(&custom_dir);
775
776        std::fs::write(
777            custom_dir.join("explicit.toml"),
778            r#"
779name = "explicit"
780enablers = ["explicit-pkg"]
781"#,
782        )
783        .unwrap();
784
785        let plugins = discover_external_plugins(&dir, &["custom-plugins".to_string()]);
786        assert_eq!(plugins.len(), 1);
787        assert_eq!(plugins[0].name, "explicit");
788
789        let _ = std::fs::remove_dir_all(&dir);
790    }
791
792    #[test]
793    fn config_plugin_path_to_single_file() {
794        let dir =
795            std::env::temp_dir().join(format!("fallow-test-single-file-{}", std::process::id()));
796        let _ = std::fs::create_dir_all(&dir);
797
798        std::fs::write(
799            dir.join("my-plugin.toml"),
800            r#"
801name = "single-file"
802enablers = ["single-pkg"]
803"#,
804        )
805        .unwrap();
806
807        let plugins = discover_external_plugins(&dir, &["my-plugin.toml".to_string()]);
808        assert_eq!(plugins.len(), 1);
809        assert_eq!(plugins[0].name, "single-file");
810
811        let _ = std::fs::remove_dir_all(&dir);
812    }
813
814    #[test]
815    fn config_plugin_path_to_single_json_file() {
816        let dir = std::env::temp_dir().join(format!(
817            "fallow-test-single-json-file-{}",
818            std::process::id()
819        ));
820        let _ = std::fs::create_dir_all(&dir);
821
822        std::fs::write(
823            dir.join("my-plugin.json"),
824            r#"{"name": "json-single", "enablers": ["json-pkg"]}"#,
825        )
826        .unwrap();
827
828        let plugins = discover_external_plugins(&dir, &["my-plugin.json".to_string()]);
829        assert_eq!(plugins.len(), 1);
830        assert_eq!(plugins[0].name, "json-single");
831
832        let _ = std::fs::remove_dir_all(&dir);
833    }
834
835    #[test]
836    fn skips_invalid_toml() {
837        let dir =
838            std::env::temp_dir().join(format!("fallow-test-invalid-plugin-{}", std::process::id()));
839        let plugins_dir = dir.join(".fallow").join("plugins");
840        let _ = std::fs::create_dir_all(&plugins_dir);
841
842        // Invalid: missing required `name` field
843        std::fs::write(plugins_dir.join("bad.toml"), r#"enablers = ["pkg"]"#).unwrap();
844
845        // Valid
846        std::fs::write(
847            plugins_dir.join("good.toml"),
848            r#"
849name = "good"
850enablers = ["good-pkg"]
851"#,
852        )
853        .unwrap();
854
855        let plugins = discover_external_plugins(&dir, &[]);
856        assert_eq!(plugins.len(), 1);
857        assert_eq!(plugins[0].name, "good");
858
859        let _ = std::fs::remove_dir_all(&dir);
860    }
861
862    #[test]
863    fn skips_invalid_json() {
864        let dir = std::env::temp_dir().join(format!(
865            "fallow-test-invalid-json-plugin-{}",
866            std::process::id()
867        ));
868        let plugins_dir = dir.join(".fallow").join("plugins");
869        let _ = std::fs::create_dir_all(&plugins_dir);
870
871        // Invalid JSON: missing name
872        std::fs::write(plugins_dir.join("bad.json"), r#"{"enablers": ["pkg"]}"#).unwrap();
873
874        // Valid JSON
875        std::fs::write(
876            plugins_dir.join("good.json"),
877            r#"{"name": "good-json", "enablers": ["good-pkg"]}"#,
878        )
879        .unwrap();
880
881        let plugins = discover_external_plugins(&dir, &[]);
882        assert_eq!(plugins.len(), 1);
883        assert_eq!(plugins[0].name, "good-json");
884
885        let _ = std::fs::remove_dir_all(&dir);
886    }
887
888    #[test]
889    fn prefix_enablers() {
890        let toml_str = r#"
891name = "scoped"
892enablers = ["@myorg/"]
893"#;
894        let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
895        assert_eq!(plugin.enablers, vec!["@myorg/"]);
896    }
897
898    #[test]
899    fn skips_empty_name() {
900        let dir =
901            std::env::temp_dir().join(format!("fallow-test-empty-name-{}", std::process::id()));
902        let plugins_dir = dir.join(".fallow").join("plugins");
903        let _ = std::fs::create_dir_all(&plugins_dir);
904
905        std::fs::write(
906            plugins_dir.join("empty.toml"),
907            r#"
908name = ""
909enablers = ["pkg"]
910"#,
911        )
912        .unwrap();
913
914        let plugins = discover_external_plugins(&dir, &[]);
915        assert!(plugins.is_empty(), "empty-name plugin should be skipped");
916
917        let _ = std::fs::remove_dir_all(&dir);
918    }
919
920    #[test]
921    fn rejects_paths_outside_root() {
922        let dir =
923            std::env::temp_dir().join(format!("fallow-test-path-escape-{}", std::process::id()));
924        let _ = std::fs::create_dir_all(&dir);
925
926        // Attempt to load a plugin from outside the project root
927        let plugins = discover_external_plugins(&dir, &["../../../etc".to_string()]);
928        assert!(plugins.is_empty(), "paths outside root should be rejected");
929
930        let _ = std::fs::remove_dir_all(&dir);
931    }
932
933    #[test]
934    fn plugin_format_detection() {
935        assert!(matches!(
936            PluginFormat::from_path(Path::new("plugin.toml")),
937            Some(PluginFormat::Toml)
938        ));
939        assert!(matches!(
940            PluginFormat::from_path(Path::new("plugin.json")),
941            Some(PluginFormat::Json)
942        ));
943        assert!(matches!(
944            PluginFormat::from_path(Path::new("plugin.jsonc")),
945            Some(PluginFormat::Jsonc)
946        ));
947        assert!(PluginFormat::from_path(Path::new("plugin.yaml")).is_none());
948        assert!(PluginFormat::from_path(Path::new("plugin")).is_none());
949    }
950
951    #[test]
952    fn is_plugin_file_checks_extensions() {
953        assert!(is_plugin_file(Path::new("plugin.toml")));
954        assert!(is_plugin_file(Path::new("plugin.json")));
955        assert!(is_plugin_file(Path::new("plugin.jsonc")));
956        assert!(!is_plugin_file(Path::new("plugin.yaml")));
957        assert!(!is_plugin_file(Path::new("plugin.txt")));
958        assert!(!is_plugin_file(Path::new("plugin")));
959    }
960
961    // ── PluginDetection tests ────────────────────────────────────
962
963    #[test]
964    fn detection_deserialize_dependency() {
965        let json = r#"{"type": "dependency", "package": "next"}"#;
966        let detection: PluginDetection = serde_json::from_str(json).unwrap();
967        assert!(matches!(detection, PluginDetection::Dependency { package } if package == "next"));
968    }
969
970    #[test]
971    fn detection_deserialize_file_exists() {
972        let json = r#"{"type": "fileExists", "pattern": "tsconfig.json"}"#;
973        let detection: PluginDetection = serde_json::from_str(json).unwrap();
974        assert!(
975            matches!(detection, PluginDetection::FileExists { pattern } if pattern == "tsconfig.json")
976        );
977    }
978
979    #[test]
980    fn detection_deserialize_all() {
981        let json = r#"{"type": "all", "conditions": [{"type": "dependency", "package": "a"}, {"type": "dependency", "package": "b"}]}"#;
982        let detection: PluginDetection = serde_json::from_str(json).unwrap();
983        assert!(matches!(detection, PluginDetection::All { conditions } if conditions.len() == 2));
984    }
985
986    #[test]
987    fn detection_deserialize_any() {
988        let json = r#"{"type": "any", "conditions": [{"type": "dependency", "package": "a"}]}"#;
989        let detection: PluginDetection = serde_json::from_str(json).unwrap();
990        assert!(matches!(detection, PluginDetection::Any { conditions } if conditions.len() == 1));
991    }
992
993    #[test]
994    fn plugin_with_detection_field() {
995        let json = r#"{
996            "name": "my-plugin",
997            "detection": {"type": "dependency", "package": "my-pkg"},
998            "entryPoints": ["src/**/*.ts"]
999        }"#;
1000        let plugin: ExternalPluginDef = serde_json::from_str(json).unwrap();
1001        assert_eq!(plugin.name, "my-plugin");
1002        assert!(plugin.detection.is_some());
1003        assert!(plugin.enablers.is_empty());
1004        assert_eq!(plugin.entry_points, vec!["src/**/*.ts"]);
1005    }
1006
1007    #[test]
1008    fn plugin_without_detection_uses_enablers() {
1009        let json = r#"{
1010            "name": "my-plugin",
1011            "enablers": ["my-pkg"]
1012        }"#;
1013        let plugin: ExternalPluginDef = serde_json::from_str(json).unwrap();
1014        assert!(plugin.detection.is_none());
1015        assert_eq!(plugin.enablers, vec!["my-pkg"]);
1016    }
1017
1018    // ── Nested detection combinators ────────────────────────────────
1019
1020    #[test]
1021    fn detection_nested_all_with_any() {
1022        let json = r#"{
1023            "type": "all",
1024            "conditions": [
1025                {"type": "dependency", "package": "react"},
1026                {"type": "any", "conditions": [
1027                    {"type": "fileExists", "pattern": "next.config.js"},
1028                    {"type": "fileExists", "pattern": "next.config.mjs"}
1029                ]}
1030            ]
1031        }"#;
1032        let detection: PluginDetection = serde_json::from_str(json).unwrap();
1033        match detection {
1034            PluginDetection::All { conditions } => {
1035                assert_eq!(conditions.len(), 2);
1036                assert!(matches!(
1037                    &conditions[0],
1038                    PluginDetection::Dependency { package } if package == "react"
1039                ));
1040                match &conditions[1] {
1041                    PluginDetection::Any { conditions: inner } => {
1042                        assert_eq!(inner.len(), 2);
1043                    }
1044                    other => panic!("expected Any, got: {other:?}"),
1045                }
1046            }
1047            other => panic!("expected All, got: {other:?}"),
1048        }
1049    }
1050
1051    #[test]
1052    fn detection_empty_all_conditions() {
1053        let json = r#"{"type": "all", "conditions": []}"#;
1054        let detection: PluginDetection = serde_json::from_str(json).unwrap();
1055        assert!(matches!(
1056            detection,
1057            PluginDetection::All { conditions } if conditions.is_empty()
1058        ));
1059    }
1060
1061    #[test]
1062    fn detection_empty_any_conditions() {
1063        let json = r#"{"type": "any", "conditions": []}"#;
1064        let detection: PluginDetection = serde_json::from_str(json).unwrap();
1065        assert!(matches!(
1066            detection,
1067            PluginDetection::Any { conditions } if conditions.is_empty()
1068        ));
1069    }
1070
1071    // ── TOML with detection field ───────────────────────────────────
1072
1073    #[test]
1074    fn detection_toml_dependency() {
1075        let toml_str = r#"
1076name = "my-plugin"
1077
1078[detection]
1079type = "dependency"
1080package = "next"
1081"#;
1082        let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
1083        assert!(plugin.detection.is_some());
1084        assert!(matches!(
1085            plugin.detection.unwrap(),
1086            PluginDetection::Dependency { package } if package == "next"
1087        ));
1088    }
1089
1090    #[test]
1091    fn detection_toml_file_exists() {
1092        let toml_str = r#"
1093name = "my-plugin"
1094
1095[detection]
1096type = "fileExists"
1097pattern = "next.config.js"
1098"#;
1099        let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
1100        assert!(matches!(
1101            plugin.detection.unwrap(),
1102            PluginDetection::FileExists { pattern } if pattern == "next.config.js"
1103        ));
1104    }
1105
1106    // ── Plugin with all fields set ──────────────────────────────────
1107
1108    #[test]
1109    fn plugin_all_fields_json() {
1110        let json = r#"{
1111            "$schema": "https://fallow.dev/plugin-schema.json",
1112            "name": "full-plugin",
1113            "detection": {"type": "dependency", "package": "my-pkg"},
1114            "enablers": ["fallback-enabler"],
1115            "entryPoints": ["src/entry.ts"],
1116            "configPatterns": ["config.js"],
1117            "alwaysUsed": ["src/polyfills.ts"],
1118            "toolingDependencies": ["my-cli"],
1119            "usedExports": [{"pattern": "src/**", "exports": ["default", "setup"]}]
1120        }"#;
1121        let plugin: ExternalPluginDef = serde_json::from_str(json).unwrap();
1122        assert_eq!(plugin.name, "full-plugin");
1123        assert!(plugin.detection.is_some());
1124        assert_eq!(plugin.enablers, vec!["fallback-enabler"]);
1125        assert_eq!(plugin.entry_points, vec!["src/entry.ts"]);
1126        assert_eq!(plugin.config_patterns, vec!["config.js"]);
1127        assert_eq!(plugin.always_used, vec!["src/polyfills.ts"]);
1128        assert_eq!(plugin.tooling_dependencies, vec!["my-cli"]);
1129        assert_eq!(plugin.used_exports.len(), 1);
1130        assert_eq!(plugin.used_exports[0].pattern, "src/**");
1131        assert_eq!(plugin.used_exports[0].exports, vec!["default", "setup"]);
1132    }
1133
1134    // ── Plugin name validation edge case ────────────────────────────
1135
1136    #[test]
1137    fn plugin_with_special_chars_in_name() {
1138        let json = r#"{"name": "@scope/my-plugin-v2.0", "enablers": ["pkg"]}"#;
1139        let plugin: ExternalPluginDef = serde_json::from_str(json).unwrap();
1140        assert_eq!(plugin.name, "@scope/my-plugin-v2.0");
1141    }
1142
1143    // ── parse_plugin with various formats ───────────────────────────
1144
1145    #[test]
1146    fn parse_plugin_toml_format() {
1147        let content = r#"
1148name = "test-plugin"
1149enablers = ["test-pkg"]
1150entryPoints = ["src/**/*.ts"]
1151"#;
1152        let result = parse_plugin(content, &PluginFormat::Toml, Path::new("test.toml"));
1153        assert!(result.is_some());
1154        let plugin = result.unwrap();
1155        assert_eq!(plugin.name, "test-plugin");
1156    }
1157
1158    #[test]
1159    fn parse_plugin_json_format() {
1160        let content = r#"{"name": "json-test", "enablers": ["pkg"]}"#;
1161        let result = parse_plugin(content, &PluginFormat::Json, Path::new("test.json"));
1162        assert!(result.is_some());
1163        assert_eq!(result.unwrap().name, "json-test");
1164    }
1165
1166    #[test]
1167    fn parse_plugin_jsonc_format() {
1168        let content = r#"{
1169            // A comment
1170            "name": "jsonc-test",
1171            "enablers": ["pkg"]
1172        }"#;
1173        let result = parse_plugin(content, &PluginFormat::Jsonc, Path::new("test.jsonc"));
1174        assert!(result.is_some());
1175        assert_eq!(result.unwrap().name, "jsonc-test");
1176    }
1177
1178    #[test]
1179    fn parse_plugin_invalid_toml_returns_none() {
1180        let content = "not valid toml [[[";
1181        let result = parse_plugin(content, &PluginFormat::Toml, Path::new("bad.toml"));
1182        assert!(result.is_none());
1183    }
1184
1185    #[test]
1186    fn parse_plugin_invalid_json_returns_none() {
1187        let content = "{ not valid json }";
1188        let result = parse_plugin(content, &PluginFormat::Json, Path::new("bad.json"));
1189        assert!(result.is_none());
1190    }
1191
1192    #[test]
1193    fn parse_plugin_invalid_jsonc_returns_none() {
1194        // Missing required `name` field
1195        let content = r#"{"enablers": ["pkg"]}"#;
1196        let result = parse_plugin(content, &PluginFormat::Jsonc, Path::new("bad.jsonc"));
1197        assert!(result.is_none());
1198    }
1199}