Skip to main content

fallow_config/
external_plugin.rs

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