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