Skip to main content

fallow_config/
external_plugin.rs

1use std::collections::BTreeMap;
2use std::fmt;
3use std::path::{Path, PathBuf};
4use std::str::FromStr;
5
6use schemars::JsonSchema;
7use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _};
8
9use crate::config::UsedClassMemberRule;
10
11/// Supported plugin file extensions.
12const PLUGIN_EXTENSIONS: &[&str] = &["toml", "json", "jsonc"];
13
14/// How a plugin's discovered entry points contribute to coverage reachability.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema, Default)]
16#[serde(rename_all = "camelCase")]
17pub enum EntryPointRole {
18    /// Runtime/application roots that should count toward runtime reachability.
19    Runtime,
20    /// Test roots that should count toward test reachability.
21    Test,
22    /// Support/setup/config roots that should keep files alive but not count as runtime/test.
23    #[default]
24    Support,
25}
26
27/// Which export shape a convention auto-import credits when its name is
28/// referenced without an explicit `import` statement.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum AutoImportKind {
31    /// `import { name } from source` (named export, e.g. a Nuxt composable/util).
32    Named,
33    /// `import name from source` (default export).
34    Default,
35    /// SFC default export consumed by a template tag (Nuxt `components/`).
36    DefaultComponent,
37}
38
39/// A single convention-based auto-import: a bare identifier name that resolves
40/// to an export in `source` by framework convention, with no explicit `import`
41/// statement in the consuming file.
42///
43/// Built by `Plugin::auto_imports` from a filesystem scan (e.g. Nuxt scanning
44/// `components/`), and consumed at graph-build time: the resolver matches a
45/// file's captured `auto_import_candidates` against these rules and synthesizes
46/// an edge to `source`. The table is a function of which files exist on disk,
47/// not of any single file's bytes, so it is computed fresh per run and never
48/// cached as part of per-file extraction.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct AutoImportRule {
51    /// Bare identifier name (e.g. `useCounter`, `Card001`, `BaseButton`,
52    /// `LazyCard001`). Component names are canonical PascalCase; the consuming
53    /// scanner normalizes kebab-case tags before matching.
54    pub name: String,
55    /// Absolute path to the source file providing the export.
56    pub source: PathBuf,
57    /// Which export to credit when the name is referenced.
58    pub kind: AutoImportKind,
59}
60
61/// How to detect if a plugin should be activated.
62///
63/// When set on an `ExternalPluginDef`, this takes priority over `enablers`.
64/// Supports dependency checks, file existence checks, and boolean combinators.
65#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
66#[serde(tag = "type", rename_all = "camelCase")]
67pub enum PluginDetection {
68    /// Plugin detected if this package is in dependencies.
69    Dependency {
70        /// Exact package name looked up in the project's declared dependencies.
71        package: String,
72    },
73    /// Plugin detected if this file pattern matches.
74    FileExists {
75        /// Project-root-relative glob; the plugin activates when any
76        /// discovered file matches it. Validated at load like other user
77        /// globs (no absolute paths or `..` segments).
78        pattern: String,
79    },
80    /// All conditions must be true.
81    All {
82        /// Sub-conditions combined with logical AND.
83        conditions: Vec<Self>,
84    },
85    /// Any condition must be true.
86    Any {
87        /// Sub-conditions combined with logical OR.
88        conditions: Vec<Self>,
89    },
90}
91
92/// A declarative plugin definition loaded from a standalone file or inline config.
93///
94/// External plugins provide the same static pattern capabilities as built-in
95/// plugins (entry points, always-used files, used exports, tooling dependencies),
96/// but are defined in standalone files or inline in the fallow config rather than
97/// compiled Rust code.
98///
99/// They cannot do AST-based config parsing (`resolve_config()`), but cover the
100/// vast majority of framework integration use cases.
101///
102/// Supports JSONC, JSON, and TOML formats. All use camelCase field names.
103///
104/// ```json
105/// {
106///   "$schema": "https://raw.githubusercontent.com/fallow-rs/fallow/main/plugin-schema.json",
107///   "name": "my-framework",
108///   "enablers": ["my-framework", "@my-framework/core"],
109///   "entryPoints": ["src/routes/**/*.{ts,tsx}"],
110///   "configPatterns": ["my-framework.config.{ts,js}"],
111///   "alwaysUsed": ["src/setup.ts"],
112///   "toolingDependencies": ["my-framework-cli"],
113///   "usedExports": [
114///     { "pattern": "src/routes/**/*.{ts,tsx}", "exports": ["default", "loader", "action"] }
115///   ]
116/// }
117/// ```
118#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
119#[serde(rename_all = "camelCase")]
120pub struct ExternalPluginDef {
121    /// JSON Schema reference (ignored during deserialization).
122    #[serde(rename = "$schema", default, skip_serializing)]
123    #[schemars(skip)]
124    pub schema: Option<String>,
125
126    /// Unique name for this plugin.
127    pub name: String,
128
129    /// Rich detection logic (dependency checks, file existence, boolean combinators).
130    /// Takes priority over `enablers` when set.
131    #[serde(default)]
132    pub detection: Option<PluginDetection>,
133
134    /// Package names that activate this plugin when found in package.json.
135    /// Supports exact matches and prefix patterns (ending with `/`).
136    /// Only used when `detection` is not set.
137    #[serde(default)]
138    pub enablers: Vec<String>,
139
140    /// Glob patterns for entry point files.
141    #[serde(default)]
142    pub entry_points: Vec<String>,
143
144    /// Coverage role for `entryPoints`.
145    ///
146    /// Defaults to `support`. Set to `runtime` for application entry points
147    /// or `test` for test framework entry points.
148    #[serde(default = "default_external_entry_point_role")]
149    pub entry_point_role: EntryPointRole,
150
151    /// Entry points DERIVED from framework manifest files.
152    ///
153    /// Unlike `entryPoints` (static globs), each rule finds manifest files by a
154    /// recursive glob, parses them, and seeds sibling entries resolved relative
155    /// to each manifest's directory, gated on the manifest's own fields. Seeded
156    /// entries use this plugin's `entryPointRole`.
157    #[serde(default)]
158    pub manifest_entries: Vec<ManifestEntryRule>,
159
160    /// Glob patterns for config files (marked as always-used when active).
161    #[serde(default)]
162    pub config_patterns: Vec<String>,
163
164    /// Files that are always considered "used" when this plugin is active.
165    #[serde(default)]
166    pub always_used: Vec<String>,
167
168    /// Dependencies that are tooling (used via CLI/config, not source imports).
169    /// These should not be flagged as unused devDependencies.
170    #[serde(default)]
171    pub tooling_dependencies: Vec<String>,
172
173    /// Exports that are always considered used for matching file patterns.
174    #[serde(default)]
175    pub used_exports: Vec<ExternalUsedExport>,
176
177    /// Class member method/property rules the framework invokes at runtime.
178    /// Supports plain member names for global suppression and scoped objects
179    /// with `extends` / `implements` constraints when the method name is too
180    /// common to suppress across the whole workspace.
181    #[serde(default)]
182    pub used_class_members: Vec<UsedClassMemberRule>,
183}
184
185/// Exports considered used for files matching a pattern.
186#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
187pub struct ExternalUsedExport {
188    /// Glob pattern for files.
189    pub pattern: String,
190    /// Export names always considered used.
191    pub exports: Vec<String>,
192}
193
194/// Format of the manifest files a [`ManifestEntryRule`] reads.
195///
196/// `jsonc` (the default) also parses plain JSON, so it is the tolerant choice.
197#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema, Default)]
198#[serde(rename_all = "lowercase")]
199pub enum ManifestFormat {
200    /// JSONC (comments + trailing commas). Also accepts plain JSON.
201    #[default]
202    Jsonc,
203    /// Strict JSON.
204    Json,
205}
206
207/// A validated field path into a manifest document.
208///
209/// Field paths use dotted object keys and an exact `[*]` segment for array
210/// traversal. The parsed segments are retained so evaluators do not repeatedly
211/// interpret user-authored path strings.
212#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
213pub struct ManifestFieldPath {
214    raw: String,
215    segments: Vec<ManifestFieldSegment>,
216}
217
218/// One validated traversal step in a [`ManifestFieldPath`].
219#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
220pub enum ManifestFieldSegment {
221    /// Read one named key from each current JSON object.
222    Key(String),
223    /// Visit every item in each current JSON array.
224    Each,
225}
226
227impl ManifestFieldPath {
228    /// The original validated path, used in diagnostics and serialization.
229    #[must_use]
230    pub fn as_str(&self) -> &str {
231        &self.raw
232    }
233
234    /// Parsed object-key and array traversal segments in source order.
235    #[must_use]
236    pub fn segments(&self) -> &[ManifestFieldSegment] {
237        &self.segments
238    }
239
240    fn parse(raw: String) -> Result<Self, String> {
241        if raw.is_empty() {
242            return Err("manifest field path must not be empty".to_string());
243        }
244
245        let mut segments = Vec::new();
246        for component in raw.split('.') {
247            if component.is_empty() {
248                return Err(format!(
249                    "manifest field path '{raw}' contains an empty segment"
250                ));
251            }
252
253            let key_end = component.find('[').unwrap_or(component.len());
254            let key = &component[..key_end];
255            if key.is_empty() || key.contains(['{', '}', ']']) {
256                return Err(format!(
257                    "manifest field path '{raw}' contains unsupported bracket syntax"
258                ));
259            }
260            segments.push(ManifestFieldSegment::Key(key.to_string()));
261
262            let mut suffix = &component[key_end..];
263            while !suffix.is_empty() {
264                let Some(rest) = suffix.strip_prefix("[*]") else {
265                    return Err(format!(
266                        "manifest field path '{raw}' only supports exact '[*]' array traversal"
267                    ));
268                };
269                segments.push(ManifestFieldSegment::Each);
270                suffix = rest;
271            }
272        }
273
274        Ok(Self { raw, segments })
275    }
276}
277
278impl FromStr for ManifestFieldPath {
279    type Err = String;
280
281    fn from_str(raw: &str) -> Result<Self, Self::Err> {
282        Self::parse(raw.to_string())
283    }
284}
285
286impl fmt::Display for ManifestFieldPath {
287    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
288        f.write_str(&self.raw)
289    }
290}
291
292impl Serialize for ManifestFieldPath {
293    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
294    where
295        S: Serializer,
296    {
297        serializer.serialize_str(&self.raw)
298    }
299}
300
301impl<'de> Deserialize<'de> for ManifestFieldPath {
302    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
303    where
304        D: Deserializer<'de>,
305    {
306        let raw = String::deserialize(deserializer)?;
307        Self::parse(raw).map_err(D::Error::custom)
308    }
309}
310
311/// One parsed part of a manifest entry path template.
312#[derive(Debug, Clone, PartialEq, Eq)]
313pub enum ManifestPathPart {
314    /// Literal path text copied without interpretation.
315    Literal(String),
316    /// A manifest field whose scalar values are interpolated.
317    Field(ManifestFieldPath),
318}
319
320/// A validated manifest-relative entry path with parsed `${field.path}` parts.
321#[derive(Debug, Clone, PartialEq, Eq)]
322pub struct ManifestPathTemplate {
323    raw: String,
324    parts: Vec<ManifestPathPart>,
325}
326
327impl ManifestPathTemplate {
328    /// The original template used for serialization and diagnostics.
329    #[must_use]
330    pub fn as_str(&self) -> &str {
331        &self.raw
332    }
333
334    /// Parsed literal and field parts in source order.
335    #[must_use]
336    pub fn parts(&self) -> &[ManifestPathPart] {
337        &self.parts
338    }
339
340    /// Replace field interpolations with a safe segment for glob validation.
341    #[must_use]
342    pub fn validation_pattern(&self) -> String {
343        let mut pattern = String::with_capacity(self.raw.len());
344        for part in &self.parts {
345            match part {
346                ManifestPathPart::Literal(literal) => pattern.push_str(literal),
347                ManifestPathPart::Field(_) => pattern.push_str("fallowinterp"),
348            }
349        }
350        pattern
351    }
352
353    fn parse(raw: String) -> Result<Self, String> {
354        let mut parts = Vec::new();
355        let mut rest = raw.as_str();
356
357        while let Some(start) = rest.find("${") {
358            if start > 0 {
359                parts.push(ManifestPathPart::Literal(rest[..start].to_string()));
360            }
361
362            let after = &rest[start + 2..];
363            let Some(end) = after.find('}') else {
364                return Err(format!(
365                    "manifest entry path '{raw}' contains an unterminated interpolation"
366                ));
367            };
368            let field = after[..end].parse::<ManifestFieldPath>()?;
369            parts.push(ManifestPathPart::Field(field));
370            rest = &after[end + 1..];
371        }
372
373        if !rest.is_empty() {
374            parts.push(ManifestPathPart::Literal(rest.to_string()));
375        }
376        if parts.is_empty() {
377            parts.push(ManifestPathPart::Literal(String::new()));
378        }
379
380        Ok(Self { raw, parts })
381    }
382}
383
384impl FromStr for ManifestPathTemplate {
385    type Err = String;
386
387    fn from_str(raw: &str) -> Result<Self, Self::Err> {
388        Self::parse(raw.to_string())
389    }
390}
391
392impl fmt::Display for ManifestPathTemplate {
393    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
394        f.write_str(&self.raw)
395    }
396}
397
398impl Serialize for ManifestPathTemplate {
399    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
400    where
401        S: Serializer,
402    {
403        serializer.serialize_str(&self.raw)
404    }
405}
406
407impl<'de> Deserialize<'de> for ManifestPathTemplate {
408    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
409    where
410        D: Deserializer<'de>,
411    {
412        let raw = String::deserialize(deserializer)?;
413        Self::parse(raw).map_err(D::Error::custom)
414    }
415}
416
417/// A typed condition used by a manifest entry gate.
418///
419/// Plain JSON values retain strict equality semantics. The reserved
420/// `{ "exists": bool }` object tests field presence without truthiness.
421#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
422#[serde(untagged)]
423pub enum ManifestCondition {
424    /// Require the field path to resolve (`true`) or not resolve (`false`).
425    Exists(ManifestExistsPredicate),
426    /// Require at least one yielded value to equal this JSON value exactly.
427    Equals(serde_json::Value),
428}
429
430/// The explicit field-presence predicate accepted by [`ManifestCondition`].
431#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
432#[serde(deny_unknown_fields)]
433pub struct ManifestExistsPredicate {
434    /// Whether the field path must resolve to at least one value.
435    pub exists: bool,
436}
437
438/// A rule that seeds entry points DERIVED from framework manifest files.
439///
440/// For every file matching `manifests` (a recursive glob) that passes the
441/// manifest-level `when` gate, each rule in `entries` is resolved relative to
442/// the manifest's directory (with `${dotted.field}` interpolation) into an entry
443/// point. Seeded entries use the owning plugin's `entryPointRole`.
444///
445/// ```jsonc
446/// {
447///   "manifests": "**/kibana.jsonc",
448///   "when": { "type": "plugin" },
449///   "entries": [
450///     { "path": "public/index.{ts,tsx}", "when": { "plugin.browser": true } },
451///     { "path": "server/index.{ts,tsx}", "when": { "plugin.server": true } },
452///     { "path": "${plugin.extraPublicDirs}/index.{ts,tsx}" }
453///   ]
454/// }
455/// ```
456#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
457#[serde(rename_all = "camelCase")]
458pub struct ManifestEntryRule {
459    /// Recursive glob selecting the manifest files to read (e.g. `**/kibana.jsonc`).
460    pub manifests: String,
461
462    /// Manifest format. Defaults to `jsonc` (which also parses plain JSON).
463    #[serde(default)]
464    pub format: ManifestFormat,
465
466    /// Manifest-level gate: a map of field path to an equality expectation or
467    /// an explicit `exists` predicate. Paths use dotted object keys and may
468    /// traverse arrays with `[*]`. ALL entries must match; a wildcard equality
469    /// matches when any yielded value equals the expectation. An empty map
470    /// matches every manifest.
471    #[serde(default)]
472    #[schemars(with = "BTreeMap<String, ManifestCondition>")]
473    pub when: BTreeMap<ManifestFieldPath, ManifestCondition>,
474
475    /// Entry rules seeded per matching manifest.
476    pub entries: Vec<ManifestSeedRule>,
477}
478
479/// A single entry seeded by a [`ManifestEntryRule`], resolved relative to the
480/// manifest's directory.
481#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
482#[serde(rename_all = "camelCase")]
483pub struct ManifestSeedRule {
484    /// Entry glob relative to the manifest directory. May contain
485    /// `${dotted.field}` interpolation that fans out over string / array
486    /// manifest field values; `[*]` traverses object arrays. A missing or empty
487    /// field seeds nothing. The glob must encode its own extension, such as
488    /// `public/index.{ts,tsx}`. Glob entry patterns are matched literally
489    /// against discovered files without
490    /// source-extension probing.
491    #[schemars(with = "String")]
492    pub path: ManifestPathTemplate,
493
494    /// Per-entry gate, using the same strict equality and `[*]` traversal as the
495    /// manifest-level gate. An empty map always passes.
496    #[serde(default)]
497    #[schemars(with = "BTreeMap<String, ManifestCondition>")]
498    pub when: BTreeMap<ManifestFieldPath, ManifestCondition>,
499}
500
501fn default_external_entry_point_role() -> EntryPointRole {
502    EntryPointRole::Support
503}
504
505impl ExternalPluginDef {
506    /// Generate JSON Schema for the external plugin format.
507    #[must_use]
508    pub fn json_schema() -> serde_json::Value {
509        serde_json::to_value(schemars::schema_for!(ExternalPluginDef)).unwrap_or_default()
510    }
511
512    /// Validate all user-supplied glob patterns on this plugin definition,
513    /// including patterns nested inside `detection` combinators (`all` / `any`).
514    ///
515    /// Pattern names use the same `framework[].<field>` notation used by
516    /// inline plugin definitions in `FallowConfig::validate_user_globs` so the
517    /// user sees consistent field paths whether the plugin is inline or
518    /// loaded from `.fallow/plugins/` / `fallow-plugin-*.{toml,json,jsonc}`.
519    ///
520    /// # Errors
521    ///
522    /// Returns a non-empty `Vec` of
523    /// [`GlobValidationError`](crate::config::glob_validation::GlobValidationError)
524    /// when any pattern is rejected.
525    pub fn validate_user_globs(
526        &self,
527    ) -> Result<(), Vec<crate::config::glob_validation::GlobValidationError>> {
528        use crate::config::glob_validation::{compile_user_glob, validate_user_globs};
529
530        let mut errors = Vec::new();
531        validate_user_globs(&self.entry_points, "framework[].entryPoints", &mut errors);
532        validate_user_globs(&self.always_used, "framework[].alwaysUsed", &mut errors);
533        validate_user_globs(
534            &self.config_patterns,
535            "framework[].configPatterns",
536            &mut errors,
537        );
538        for used in &self.used_exports {
539            if let Err(e) = compile_user_glob(&used.pattern, "framework[].usedExports[].pattern") {
540                errors.push(e);
541            }
542        }
543        for rule in &self.manifest_entries {
544            if let Err(e) =
545                compile_user_glob(&rule.manifests, "framework[].manifestEntries[].manifests")
546            {
547                errors.push(e);
548            }
549            for seed in &rule.entries {
550                // Substitute `${...}` interpolation with a placeholder segment so
551                // the surrounding glob (e.g. `${x}/index.{ts,tsx}`) validates.
552                let probe = seed.path.validation_pattern();
553                if let Err(e) =
554                    compile_user_glob(&probe, "framework[].manifestEntries[].entries[].path")
555                {
556                    errors.push(e);
557                }
558            }
559        }
560        if let Some(detection) = &self.detection {
561            validate_detection_user_globs(detection, "framework[].detection", &mut errors);
562        }
563        if errors.is_empty() {
564            Ok(())
565        } else {
566            Err(errors)
567        }
568    }
569}
570
571/// Recursively validate `FileExists.pattern` fields inside a `PluginDetection`
572/// tree. `All` and `Any` combinators recurse into their nested conditions.
573fn validate_detection_user_globs(
574    detection: &PluginDetection,
575    field: &'static str,
576    errors: &mut Vec<crate::config::glob_validation::GlobValidationError>,
577) {
578    match detection {
579        PluginDetection::Dependency { .. } => {}
580        PluginDetection::FileExists { pattern } => {
581            if let Err(e) = crate::config::glob_validation::compile_user_glob(pattern, field) {
582                errors.push(e);
583            }
584        }
585        PluginDetection::All { conditions } | PluginDetection::Any { conditions } => {
586            for condition in conditions {
587                validate_detection_user_globs(condition, field, errors);
588            }
589        }
590    }
591}
592
593/// Discover external plugin definitions AND validate their user-supplied glob
594/// patterns. Accumulates all errors across all loaded plugins so the user sees
595/// every problem in one run.
596///
597/// Discovery is identical to [`discover_external_plugins`]; this wrapper adds
598/// the per-plugin glob validation step required for security
599/// (see issue #463: `framework[].detection.fileExists.pattern` reaches
600/// `glob::glob` on disk via `root.join(pattern)`, so a `..` segment loaded
601/// from `.fallow/plugins/` would be a real path traversal).
602///
603/// # Errors
604///
605/// Returns the list of validation errors when any discovered plugin contains
606/// a rejected pattern. The CLI surfaces these with exit code 2.
607pub fn discover_and_validate_external_plugins(
608    root: &Path,
609    config_plugin_paths: &[String],
610) -> Result<Vec<ExternalPluginDef>, Vec<crate::config::glob_validation::GlobValidationError>> {
611    let plugins = discover_external_plugins(root, config_plugin_paths);
612    let mut errors = Vec::new();
613    for plugin in &plugins {
614        if let Err(mut plugin_errors) = plugin.validate_user_globs() {
615            errors.append(&mut plugin_errors);
616        }
617    }
618    if errors.is_empty() {
619        Ok(plugins)
620    } else {
621        Err(errors)
622    }
623}
624
625/// Detect plugin format from file extension.
626enum PluginFormat {
627    Toml,
628    Json,
629    Jsonc,
630}
631
632impl PluginFormat {
633    fn from_path(path: &Path) -> Option<Self> {
634        match path.extension().and_then(|e| e.to_str()) {
635            Some("toml") => Some(Self::Toml),
636            Some("json") => Some(Self::Json),
637            Some("jsonc") => Some(Self::Jsonc),
638            _ => None,
639        }
640    }
641}
642
643/// Check if a file has a supported plugin extension.
644fn is_plugin_file(path: &Path) -> bool {
645    path.extension()
646        .and_then(|e| e.to_str())
647        .is_some_and(|ext| PLUGIN_EXTENSIONS.contains(&ext))
648}
649
650/// Parse a plugin definition from file content based on format.
651fn parse_plugin(content: &str, format: &PluginFormat, path: &Path) -> Option<ExternalPluginDef> {
652    match format {
653        PluginFormat::Toml => match toml::from_str::<ExternalPluginDef>(content) {
654            Ok(plugin) => Some(plugin),
655            Err(e) => {
656                tracing::warn!("failed to parse external plugin {}: {e}", path.display());
657                None
658            }
659        },
660        PluginFormat::Json => match serde_json::from_str::<ExternalPluginDef>(content) {
661            Ok(plugin) => Some(plugin),
662            Err(e) => {
663                tracing::warn!("failed to parse external plugin {}: {e}", path.display());
664                None
665            }
666        },
667        PluginFormat::Jsonc => match crate::jsonc::parse_to_value::<ExternalPluginDef>(content) {
668            Ok(plugin) => Some(plugin),
669            Err(e) => {
670                tracing::warn!("failed to parse external plugin {}: {e}", path.display());
671                None
672            }
673        },
674    }
675}
676
677/// Discover and load external plugin definitions for a project.
678///
679/// Discovery order (first occurrence of a plugin name wins):
680/// 1. Paths from the `plugins` config field (files or directories)
681/// 2. `.fallow/plugins/` directory (auto-discover `*.toml`, `*.json`, `*.jsonc` files)
682/// 3. Project root `fallow-plugin-*` files (`.toml`, `.json`, `.jsonc`)
683pub fn discover_external_plugins(
684    root: &Path,
685    config_plugin_paths: &[String],
686) -> Vec<ExternalPluginDef> {
687    let mut plugins = Vec::new();
688    let mut seen_names = rustc_hash::FxHashSet::default();
689
690    let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
691
692    load_configured_plugin_paths(
693        root,
694        config_plugin_paths,
695        &canonical_root,
696        &mut plugins,
697        &mut seen_names,
698    );
699    load_default_plugins_dir(root, &canonical_root, &mut plugins, &mut seen_names);
700    load_root_plugin_files(root, &canonical_root, &mut plugins, &mut seen_names);
701
702    plugins
703}
704
705fn load_configured_plugin_paths(
706    root: &Path,
707    config_plugin_paths: &[String],
708    canonical_root: &Path,
709    plugins: &mut Vec<ExternalPluginDef>,
710    seen_names: &mut rustc_hash::FxHashSet<String>,
711) {
712    for path_str in config_plugin_paths {
713        let path = root.join(path_str);
714        if !is_within_root(&path, canonical_root) {
715            tracing::warn!("plugin path '{path_str}' resolves outside project root, skipping");
716            continue;
717        }
718        if path.is_dir() {
719            load_plugins_from_dir(&path, canonical_root, plugins, seen_names);
720        } else if path.is_file() {
721            load_plugin_file(&path, canonical_root, plugins, seen_names);
722        }
723    }
724}
725
726fn load_default_plugins_dir(
727    root: &Path,
728    canonical_root: &Path,
729    plugins: &mut Vec<ExternalPluginDef>,
730    seen_names: &mut rustc_hash::FxHashSet<String>,
731) {
732    let plugins_dir = root.join(".fallow").join("plugins");
733    if plugins_dir.is_dir() && is_within_root(&plugins_dir, canonical_root) {
734        load_plugins_from_dir(&plugins_dir, canonical_root, plugins, seen_names);
735    }
736}
737
738fn load_root_plugin_files(
739    root: &Path,
740    canonical_root: &Path,
741    plugins: &mut Vec<ExternalPluginDef>,
742    seen_names: &mut rustc_hash::FxHashSet<String>,
743) {
744    if let Ok(entries) = std::fs::read_dir(root) {
745        let mut plugin_files: Vec<PathBuf> = entries
746            .filter_map(Result::ok)
747            .map(|e| e.path())
748            .filter(|p| {
749                p.is_file()
750                    && p.file_name().and_then(|n| n.to_str()).is_some_and(|n| {
751                        n.starts_with("fallow-plugin-") && is_plugin_file(Path::new(n))
752                    })
753            })
754            .collect();
755        plugin_files.sort();
756        for path in plugin_files {
757            load_plugin_file(&path, canonical_root, plugins, seen_names);
758        }
759    }
760}
761
762/// Check if a path resolves within the canonical root (follows symlinks).
763#[expect(
764    clippy::redundant_pub_crate,
765    reason = "this module is glob re-exported from lib.rs, so `pub` would leak this helper into the public API; pub(crate) is the minimal widening for the rule-pack loader"
766)]
767pub(crate) fn is_within_root(path: &Path, canonical_root: &Path) -> bool {
768    let canonical = dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
769    canonical.starts_with(canonical_root)
770}
771
772fn load_plugins_from_dir(
773    dir: &Path,
774    canonical_root: &Path,
775    plugins: &mut Vec<ExternalPluginDef>,
776    seen: &mut rustc_hash::FxHashSet<String>,
777) {
778    if let Ok(entries) = std::fs::read_dir(dir) {
779        let mut plugin_files: Vec<PathBuf> = entries
780            .filter_map(Result::ok)
781            .map(|e| e.path())
782            .filter(|p| p.is_file() && is_plugin_file(p))
783            .collect();
784        plugin_files.sort();
785        for path in plugin_files {
786            load_plugin_file(&path, canonical_root, plugins, seen);
787        }
788    }
789}
790
791fn load_plugin_file(
792    path: &Path,
793    canonical_root: &Path,
794    plugins: &mut Vec<ExternalPluginDef>,
795    seen: &mut rustc_hash::FxHashSet<String>,
796) {
797    if !is_within_root(path, canonical_root) {
798        tracing::warn!(
799            "plugin file '{}' resolves outside project root (symlink?), skipping",
800            path.display()
801        );
802        return;
803    }
804
805    let Some(format) = PluginFormat::from_path(path) else {
806        tracing::warn!(
807            "unsupported plugin file extension for {}, expected .toml, .json, or .jsonc",
808            path.display()
809        );
810        return;
811    };
812
813    let Some(content) = read_plugin_file(path) else {
814        return;
815    };
816
817    if let Some(plugin) = parse_plugin(&content, &format, path) {
818        push_plugin_if_unique(plugin, path, plugins, seen);
819    }
820}
821
822fn read_plugin_file(path: &Path) -> Option<String> {
823    match std::fs::read_to_string(path) {
824        Ok(content) => Some(content),
825        Err(e) => {
826            tracing::warn!(
827                "failed to read external plugin file {}: {e}",
828                path.display()
829            );
830            None
831        }
832    }
833}
834
835fn push_plugin_if_unique(
836    plugin: ExternalPluginDef,
837    path: &Path,
838    plugins: &mut Vec<ExternalPluginDef>,
839    seen: &mut rustc_hash::FxHashSet<String>,
840) {
841    if plugin.name.is_empty() {
842        tracing::warn!(
843            "external plugin in {} has an empty name, skipping",
844            path.display()
845        );
846        return;
847    }
848
849    if seen.insert(plugin.name.clone()) {
850        plugins.push(plugin);
851    } else {
852        tracing::warn!(
853            "duplicate external plugin '{}' in {}, skipping",
854            plugin.name,
855            path.display()
856        );
857    }
858}
859
860#[cfg(test)]
861mod tests {
862    use super::*;
863    use crate::ScopedUsedClassMemberRule;
864
865    #[test]
866    fn deserialize_minimal_plugin() {
867        let toml_str = r#"
868name = "my-plugin"
869enablers = ["my-pkg"]
870"#;
871        let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
872        assert_eq!(plugin.name, "my-plugin");
873        assert_eq!(plugin.enablers, vec!["my-pkg"]);
874        assert!(plugin.entry_points.is_empty());
875        assert!(plugin.always_used.is_empty());
876        assert!(plugin.config_patterns.is_empty());
877        assert!(plugin.tooling_dependencies.is_empty());
878        assert!(plugin.used_exports.is_empty());
879        assert!(plugin.used_class_members.is_empty());
880    }
881
882    #[test]
883    fn deserialize_plugin_with_used_class_members_json() {
884        let json_str = r#"{
885            "name": "ag-grid",
886            "enablers": ["ag-grid-angular"],
887            "usedClassMembers": ["agInit", "refresh"]
888        }"#;
889        let plugin: ExternalPluginDef = serde_json::from_str(json_str).unwrap();
890        assert_eq!(plugin.name, "ag-grid");
891        assert_eq!(
892            plugin.used_class_members,
893            vec![
894                UsedClassMemberRule::from("agInit"),
895                UsedClassMemberRule::from("refresh"),
896            ]
897        );
898    }
899
900    #[test]
901    fn deserialize_plugin_with_scoped_used_class_members_json() {
902        let json_str = r#"{
903            "name": "ag-grid",
904            "enablers": ["ag-grid-angular"],
905            "usedClassMembers": [
906                "agInit",
907                { "implements": "ICellRendererAngularComp", "members": ["refresh"] },
908                { "extends": "BaseCommand", "members": ["execute"] }
909            ]
910        }"#;
911        let plugin: ExternalPluginDef = serde_json::from_str(json_str).unwrap();
912        assert_eq!(
913            plugin.used_class_members,
914            vec![
915                UsedClassMemberRule::from("agInit"),
916                UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
917                    extends: None,
918                    implements: Some("ICellRendererAngularComp".to_string()),
919                    members: vec!["refresh".to_string()],
920                }),
921                UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
922                    extends: Some("BaseCommand".to_string()),
923                    implements: None,
924                    members: vec!["execute".to_string()],
925                }),
926            ]
927        );
928    }
929
930    #[test]
931    fn deserialize_plugin_with_used_class_members_toml() {
932        let toml_str = r#"
933name = "ag-grid"
934enablers = ["ag-grid-angular"]
935usedClassMembers = ["agInit", "refresh"]
936"#;
937        let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
938        assert_eq!(
939            plugin.used_class_members,
940            vec![
941                UsedClassMemberRule::from("agInit"),
942                UsedClassMemberRule::from("refresh"),
943            ]
944        );
945    }
946
947    #[test]
948    fn deserialize_plugin_with_scoped_used_class_members_toml() {
949        let toml_str = r#"
950name = "ag-grid"
951enablers = ["ag-grid-angular"]
952usedClassMembers = [
953  { implements = "ICellRendererAngularComp", members = ["refresh"] },
954  { extends = "BaseCommand", members = ["execute"] }
955]
956"#;
957        let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
958        assert_eq!(
959            plugin.used_class_members,
960            vec![
961                UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
962                    extends: None,
963                    implements: Some("ICellRendererAngularComp".to_string()),
964                    members: vec!["refresh".to_string()],
965                }),
966                UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
967                    extends: Some("BaseCommand".to_string()),
968                    implements: None,
969                    members: vec!["execute".to_string()],
970                }),
971            ]
972        );
973    }
974
975    #[test]
976    fn deserialize_plugin_rejects_unconstrained_scoped_used_class_members() {
977        let result = serde_json::from_str::<ExternalPluginDef>(
978            r#"{
979                "name": "ag-grid",
980                "enablers": ["ag-grid-angular"],
981                "usedClassMembers": [{ "members": ["refresh"] }]
982            }"#,
983        );
984        assert!(
985            result.is_err(),
986            "unconstrained scoped rule should be rejected"
987        );
988    }
989
990    #[test]
991    fn deserialize_full_plugin() {
992        let toml_str = r#"
993name = "my-framework"
994enablers = ["my-framework", "@my-framework/core"]
995entryPoints = ["src/routes/**/*.{ts,tsx}", "src/middleware.ts"]
996configPatterns = ["my-framework.config.{ts,js,mjs}"]
997alwaysUsed = ["src/setup.ts", "public/**/*"]
998toolingDependencies = ["my-framework-cli"]
999
1000[[usedExports]]
1001pattern = "src/routes/**/*.{ts,tsx}"
1002exports = ["default", "loader", "action"]
1003
1004[[usedExports]]
1005pattern = "src/middleware.ts"
1006exports = ["default"]
1007"#;
1008        let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
1009        assert_eq!(plugin.name, "my-framework");
1010        assert_eq!(plugin.enablers.len(), 2);
1011        assert_eq!(plugin.entry_points.len(), 2);
1012        assert_eq!(
1013            plugin.config_patterns,
1014            vec!["my-framework.config.{ts,js,mjs}"]
1015        );
1016        assert_eq!(plugin.always_used.len(), 2);
1017        assert_eq!(plugin.tooling_dependencies, vec!["my-framework-cli"]);
1018        assert_eq!(plugin.used_exports.len(), 2);
1019        assert_eq!(plugin.used_exports[0].pattern, "src/routes/**/*.{ts,tsx}");
1020        assert_eq!(
1021            plugin.used_exports[0].exports,
1022            vec!["default", "loader", "action"]
1023        );
1024    }
1025
1026    #[test]
1027    fn deserialize_json_plugin() {
1028        let json_str = r#"{
1029            "name": "my-json-plugin",
1030            "enablers": ["my-pkg"],
1031            "entryPoints": ["src/**/*.ts"],
1032            "configPatterns": ["my-plugin.config.js"],
1033            "alwaysUsed": ["src/setup.ts"],
1034            "toolingDependencies": ["my-cli"],
1035            "usedExports": [
1036                { "pattern": "src/**/*.ts", "exports": ["default"] }
1037            ]
1038        }"#;
1039        let plugin: ExternalPluginDef = serde_json::from_str(json_str).unwrap();
1040        assert_eq!(plugin.name, "my-json-plugin");
1041        assert_eq!(plugin.enablers, vec!["my-pkg"]);
1042        assert_eq!(plugin.entry_points, vec!["src/**/*.ts"]);
1043        assert_eq!(plugin.config_patterns, vec!["my-plugin.config.js"]);
1044        assert_eq!(plugin.always_used, vec!["src/setup.ts"]);
1045        assert_eq!(plugin.tooling_dependencies, vec!["my-cli"]);
1046        assert_eq!(plugin.used_exports.len(), 1);
1047        assert_eq!(plugin.used_exports[0].exports, vec!["default"]);
1048    }
1049
1050    #[test]
1051    fn manifest_field_paths_and_templates_are_parsed_once_and_round_trip() {
1052        let source = r#"{
1053            "name": "manifest-plugin",
1054            "manifestEntries": [{
1055                "manifests": "**/manifest.json",
1056                "when": { "plugin.browser": true },
1057                "entries": [{
1058                    "path": "${plugin.entry}/index.{ts,tsx}",
1059                    "when": { "type": "plugin" }
1060                }]
1061            }]
1062        }"#;
1063
1064        let plugin: ExternalPluginDef = serde_json::from_str(source).unwrap();
1065        let rule = &plugin.manifest_entries[0];
1066        let condition_path = rule.when.keys().next().unwrap();
1067        assert_eq!(
1068            condition_path.segments(),
1069            &[
1070                ManifestFieldSegment::Key("plugin".to_string()),
1071                ManifestFieldSegment::Key("browser".to_string()),
1072            ]
1073        );
1074        assert_eq!(
1075            rule.entries[0].path.as_str(),
1076            "${plugin.entry}/index.{ts,tsx}"
1077        );
1078        assert_eq!(
1079            rule.entries[0].path.parts(),
1080            &[
1081                ManifestPathPart::Field("plugin.entry".parse().unwrap()),
1082                ManifestPathPart::Literal("/index.{ts,tsx}".to_string()),
1083            ]
1084        );
1085
1086        let serialized = serde_json::to_value(&plugin).unwrap();
1087        assert_eq!(
1088            serialized["manifestEntries"][0]["when"]["plugin.browser"],
1089            true
1090        );
1091        assert_eq!(
1092            serialized["manifestEntries"][0]["entries"][0]["path"],
1093            "${plugin.entry}/index.{ts,tsx}"
1094        );
1095    }
1096
1097    #[test]
1098    fn invalid_manifest_field_paths_and_templates_are_rejected() {
1099        for source in [
1100            r#"{
1101                "name": "bad-condition",
1102                "manifestEntries": [{
1103                    "manifests": "**/manifest.json",
1104                    "when": { "plugin..browser": true },
1105                    "entries": [{ "path": "index.ts" }]
1106                }]
1107            }"#,
1108            r#"{
1109                "name": "bad-template-field",
1110                "manifestEntries": [{
1111                    "manifests": "**/manifest.json",
1112                    "entries": [{ "path": "${plugin..entry}/index.ts" }]
1113                }]
1114            }"#,
1115            r#"{
1116                "name": "bad-entry-condition",
1117                "manifestEntries": [{
1118                    "manifests": "**/manifest.json",
1119                    "entries": [{
1120                        "path": "index.ts",
1121                        "when": { "plugin..browser": true }
1122                    }]
1123                }]
1124            }"#,
1125            r#"{
1126                "name": "unterminated-template",
1127                "manifestEntries": [{
1128                    "manifests": "**/manifest.json",
1129                    "entries": [{ "path": "${plugin.entry/index.ts" }]
1130                }]
1131            }"#,
1132        ] {
1133            assert!(serde_json::from_str::<ExternalPluginDef>(source).is_err());
1134        }
1135    }
1136
1137    #[test]
1138    fn manifest_field_paths_support_only_explicit_array_traversal() {
1139        let path: ManifestFieldPath = "content_scripts[*].js[*]".parse().unwrap();
1140        assert_eq!(
1141            path.segments(),
1142            &[
1143                ManifestFieldSegment::Key("content_scripts".to_string()),
1144                ManifestFieldSegment::Each,
1145                ManifestFieldSegment::Key("js".to_string()),
1146                ManifestFieldSegment::Each,
1147            ]
1148        );
1149
1150        for invalid in [
1151            "content_scripts[0].js",
1152            "content_scripts[].js",
1153            "content_scripts[foo].js",
1154            "content_scripts[*x].js",
1155            "[*].js",
1156        ] {
1157            assert!(invalid.parse::<ManifestFieldPath>().is_err(), "{invalid}");
1158        }
1159    }
1160
1161    #[test]
1162    fn manifest_conditions_distinguish_scalar_equality_from_presence() {
1163        let source = r#"{
1164            "name": "condition-plugin",
1165            "manifestEntries": [{
1166                "manifests": "**/manifest.json",
1167                "when": {
1168                    "main": { "exists": true },
1169                    "enabled": false,
1170                    "mode": "worker",
1171                    "version": 3,
1172                    "metadata": null
1173                },
1174                "entries": [{ "path": "index.ts" }]
1175            }]
1176        }"#;
1177        let plugin: ExternalPluginDef = serde_json::from_str(source).unwrap();
1178        let when = &plugin.manifest_entries[0].when;
1179
1180        assert_eq!(
1181            when.get(&"main".parse().unwrap()),
1182            Some(&ManifestCondition::Exists(ManifestExistsPredicate {
1183                exists: true,
1184            }))
1185        );
1186        assert_eq!(
1187            when.get(&"enabled".parse().unwrap()),
1188            Some(&ManifestCondition::Equals(serde_json::Value::Bool(false)))
1189        );
1190        assert_eq!(
1191            when.get(&"metadata".parse().unwrap()),
1192            Some(&ManifestCondition::Equals(serde_json::Value::Null))
1193        );
1194
1195        let serialized = serde_json::to_value(plugin).unwrap();
1196        assert_eq!(
1197            serialized["manifestEntries"][0]["when"]["main"],
1198            serde_json::json!({ "exists": true })
1199        );
1200    }
1201
1202    #[test]
1203    fn manifest_conditions_preserve_non_operator_json_equality_values() {
1204        let expected = [
1205            serde_json::json!({ "exists": "not-an-operator" }),
1206            serde_json::json!({ "exists": true, "extra": true }),
1207            serde_json::json!({ "other": true }),
1208            serde_json::json!(["worker"]),
1209        ];
1210        for condition in expected {
1211            let source = serde_json::json!({
1212                "name": "equality-condition",
1213                "manifestEntries": [{
1214                    "manifests": "**/manifest.json",
1215                    "when": { "field": condition.clone() },
1216                    "entries": [{ "path": "index.ts" }]
1217                }]
1218            });
1219            let plugin: ExternalPluginDef = serde_json::from_value(source).unwrap();
1220            assert_eq!(
1221                plugin.manifest_entries[0]
1222                    .when
1223                    .get(&"field".parse().unwrap()),
1224                Some(&ManifestCondition::Equals(condition))
1225            );
1226        }
1227    }
1228
1229    #[test]
1230    fn manifest_exists_conditions_deserialize_from_toml() {
1231        let source = r#"
1232name = "condition-plugin"
1233
1234[[manifestEntries]]
1235manifests = "**/manifest.json"
1236
1237[manifestEntries.when.main]
1238exists = true
1239
1240[[manifestEntries.entries]]
1241path = "index.ts"
1242"#;
1243        let plugin: ExternalPluginDef = toml::from_str(source).unwrap();
1244        assert_eq!(
1245            plugin.manifest_entries[0]
1246                .when
1247                .get(&"main".parse().unwrap()),
1248            Some(&ManifestCondition::Exists(ManifestExistsPredicate {
1249                exists: true,
1250            }))
1251        );
1252    }
1253
1254    #[test]
1255    fn deserialize_jsonc_plugin() {
1256        let jsonc_str = r#"{
1257            "name": "my-jsonc-plugin",
1258            "enablers": ["my-pkg"],
1259            /* Block comment */
1260            "entryPoints": ["src/**/*.ts"]
1261        }"#;
1262        let plugin: ExternalPluginDef = crate::jsonc::parse_to_value(jsonc_str).unwrap();
1263        assert_eq!(plugin.name, "my-jsonc-plugin");
1264        assert_eq!(plugin.enablers, vec!["my-pkg"]);
1265        assert_eq!(plugin.entry_points, vec!["src/**/*.ts"]);
1266    }
1267
1268    #[test]
1269    fn deserialize_json_with_schema_field() {
1270        let json_str = r#"{
1271            "$schema": "https://raw.githubusercontent.com/fallow-rs/fallow/main/plugin-schema.json",
1272            "name": "schema-plugin",
1273            "enablers": ["my-pkg"]
1274        }"#;
1275        let plugin: ExternalPluginDef = serde_json::from_str(json_str).unwrap();
1276        assert_eq!(plugin.name, "schema-plugin");
1277        assert_eq!(plugin.enablers, vec!["my-pkg"]);
1278    }
1279
1280    #[test]
1281    fn plugin_json_schema_generation() {
1282        let schema = ExternalPluginDef::json_schema();
1283        assert!(schema.is_object());
1284        let obj = schema.as_object().unwrap();
1285        assert!(obj.contains_key("properties"));
1286    }
1287
1288    #[test]
1289    fn discover_plugins_from_fallow_plugins_dir() {
1290        let dir =
1291            std::env::temp_dir().join(format!("fallow-test-ext-plugins-{}", std::process::id()));
1292        let plugins_dir = dir.join(".fallow").join("plugins");
1293        let _ = std::fs::create_dir_all(&plugins_dir);
1294
1295        std::fs::write(
1296            plugins_dir.join("my-plugin.toml"),
1297            r#"
1298name = "my-plugin"
1299enablers = ["my-pkg"]
1300entryPoints = ["src/**/*.ts"]
1301"#,
1302        )
1303        .unwrap();
1304
1305        let plugins = discover_external_plugins(&dir, &[]);
1306        assert_eq!(plugins.len(), 1);
1307        assert_eq!(plugins[0].name, "my-plugin");
1308
1309        let _ = std::fs::remove_dir_all(&dir);
1310    }
1311
1312    #[test]
1313    fn discover_json_plugins_from_fallow_plugins_dir() {
1314        let dir = std::env::temp_dir().join(format!(
1315            "fallow-test-ext-json-plugins-{}",
1316            std::process::id()
1317        ));
1318        let plugins_dir = dir.join(".fallow").join("plugins");
1319        let _ = std::fs::create_dir_all(&plugins_dir);
1320
1321        std::fs::write(
1322            plugins_dir.join("my-plugin.json"),
1323            r#"{"name": "json-plugin", "enablers": ["json-pkg"]}"#,
1324        )
1325        .unwrap();
1326
1327        std::fs::write(
1328            plugins_dir.join("my-plugin.jsonc"),
1329            r#"{
1330                "name": "jsonc-plugin",
1331                "enablers": ["jsonc-pkg"]
1332            }"#,
1333        )
1334        .unwrap();
1335
1336        let plugins = discover_external_plugins(&dir, &[]);
1337        assert_eq!(plugins.len(), 2);
1338        assert_eq!(plugins[0].name, "json-plugin");
1339        assert_eq!(plugins[1].name, "jsonc-plugin");
1340
1341        let _ = std::fs::remove_dir_all(&dir);
1342    }
1343
1344    #[test]
1345    fn discover_fallow_plugin_files_in_root() {
1346        let dir =
1347            std::env::temp_dir().join(format!("fallow-test-root-plugins-{}", std::process::id()));
1348        let _ = std::fs::create_dir_all(&dir);
1349
1350        std::fs::write(
1351            dir.join("fallow-plugin-custom.toml"),
1352            r#"
1353name = "custom"
1354enablers = ["custom-pkg"]
1355"#,
1356        )
1357        .unwrap();
1358
1359        std::fs::write(dir.join("some-other-file.toml"), r#"name = "ignored""#).unwrap();
1360
1361        let plugins = discover_external_plugins(&dir, &[]);
1362        assert_eq!(plugins.len(), 1);
1363        assert_eq!(plugins[0].name, "custom");
1364
1365        let _ = std::fs::remove_dir_all(&dir);
1366    }
1367
1368    #[test]
1369    fn discover_fallow_plugin_json_files_in_root() {
1370        let dir = std::env::temp_dir().join(format!(
1371            "fallow-test-root-json-plugins-{}",
1372            std::process::id()
1373        ));
1374        let _ = std::fs::create_dir_all(&dir);
1375
1376        std::fs::write(
1377            dir.join("fallow-plugin-custom.json"),
1378            r#"{"name": "json-root", "enablers": ["json-pkg"]}"#,
1379        )
1380        .unwrap();
1381
1382        std::fs::write(
1383            dir.join("fallow-plugin-custom2.jsonc"),
1384            r#"{
1385                "name": "jsonc-root",
1386                "enablers": ["jsonc-pkg"]
1387            }"#,
1388        )
1389        .unwrap();
1390
1391        std::fs::write(
1392            dir.join("fallow-plugin-bad.yaml"),
1393            "name: ignored\nenablers:\n  - pkg\n",
1394        )
1395        .unwrap();
1396
1397        let plugins = discover_external_plugins(&dir, &[]);
1398        assert_eq!(plugins.len(), 2);
1399
1400        let _ = std::fs::remove_dir_all(&dir);
1401    }
1402
1403    #[test]
1404    fn discover_mixed_formats_in_dir() {
1405        let dir =
1406            std::env::temp_dir().join(format!("fallow-test-mixed-plugins-{}", std::process::id()));
1407        let plugins_dir = dir.join(".fallow").join("plugins");
1408        let _ = std::fs::create_dir_all(&plugins_dir);
1409
1410        std::fs::write(
1411            plugins_dir.join("a-plugin.toml"),
1412            r#"
1413name = "toml-plugin"
1414enablers = ["toml-pkg"]
1415"#,
1416        )
1417        .unwrap();
1418
1419        std::fs::write(
1420            plugins_dir.join("b-plugin.json"),
1421            r#"{"name": "json-plugin", "enablers": ["json-pkg"]}"#,
1422        )
1423        .unwrap();
1424
1425        std::fs::write(
1426            plugins_dir.join("c-plugin.jsonc"),
1427            r#"{
1428                "name": "jsonc-plugin",
1429                "enablers": ["jsonc-pkg"]
1430            }"#,
1431        )
1432        .unwrap();
1433
1434        let plugins = discover_external_plugins(&dir, &[]);
1435        assert_eq!(plugins.len(), 3);
1436        assert_eq!(plugins[0].name, "toml-plugin");
1437        assert_eq!(plugins[1].name, "json-plugin");
1438        assert_eq!(plugins[2].name, "jsonc-plugin");
1439
1440        let _ = std::fs::remove_dir_all(&dir);
1441    }
1442
1443    #[test]
1444    fn deduplicates_by_name() {
1445        let dir =
1446            std::env::temp_dir().join(format!("fallow-test-dedup-plugins-{}", std::process::id()));
1447        let plugins_dir = dir.join(".fallow").join("plugins");
1448        let _ = std::fs::create_dir_all(&plugins_dir);
1449
1450        std::fs::write(
1451            plugins_dir.join("my-plugin.toml"),
1452            r#"
1453name = "my-plugin"
1454enablers = ["pkg-a"]
1455"#,
1456        )
1457        .unwrap();
1458
1459        std::fs::write(
1460            dir.join("fallow-plugin-my-plugin.toml"),
1461            r#"
1462name = "my-plugin"
1463enablers = ["pkg-b"]
1464"#,
1465        )
1466        .unwrap();
1467
1468        let plugins = discover_external_plugins(&dir, &[]);
1469        assert_eq!(plugins.len(), 1);
1470        assert_eq!(plugins[0].enablers, vec!["pkg-a"]);
1471
1472        let _ = std::fs::remove_dir_all(&dir);
1473    }
1474
1475    #[test]
1476    fn config_plugin_paths_take_priority() {
1477        let dir =
1478            std::env::temp_dir().join(format!("fallow-test-config-paths-{}", std::process::id()));
1479        let custom_dir = dir.join("custom-plugins");
1480        let _ = std::fs::create_dir_all(&custom_dir);
1481
1482        std::fs::write(
1483            custom_dir.join("explicit.toml"),
1484            r#"
1485name = "explicit"
1486enablers = ["explicit-pkg"]
1487"#,
1488        )
1489        .unwrap();
1490
1491        let plugins = discover_external_plugins(&dir, &["custom-plugins".to_string()]);
1492        assert_eq!(plugins.len(), 1);
1493        assert_eq!(plugins[0].name, "explicit");
1494
1495        let _ = std::fs::remove_dir_all(&dir);
1496    }
1497
1498    #[test]
1499    fn config_plugin_path_to_single_file() {
1500        let dir =
1501            std::env::temp_dir().join(format!("fallow-test-single-file-{}", std::process::id()));
1502        let _ = std::fs::create_dir_all(&dir);
1503
1504        std::fs::write(
1505            dir.join("my-plugin.toml"),
1506            r#"
1507name = "single-file"
1508enablers = ["single-pkg"]
1509"#,
1510        )
1511        .unwrap();
1512
1513        let plugins = discover_external_plugins(&dir, &["my-plugin.toml".to_string()]);
1514        assert_eq!(plugins.len(), 1);
1515        assert_eq!(plugins[0].name, "single-file");
1516
1517        let _ = std::fs::remove_dir_all(&dir);
1518    }
1519
1520    #[test]
1521    fn config_plugin_path_to_single_json_file() {
1522        let dir = std::env::temp_dir().join(format!(
1523            "fallow-test-single-json-file-{}",
1524            std::process::id()
1525        ));
1526        let _ = std::fs::create_dir_all(&dir);
1527
1528        std::fs::write(
1529            dir.join("my-plugin.json"),
1530            r#"{"name": "json-single", "enablers": ["json-pkg"]}"#,
1531        )
1532        .unwrap();
1533
1534        let plugins = discover_external_plugins(&dir, &["my-plugin.json".to_string()]);
1535        assert_eq!(plugins.len(), 1);
1536        assert_eq!(plugins[0].name, "json-single");
1537
1538        let _ = std::fs::remove_dir_all(&dir);
1539    }
1540
1541    #[test]
1542    fn skips_invalid_toml() {
1543        let dir =
1544            std::env::temp_dir().join(format!("fallow-test-invalid-plugin-{}", std::process::id()));
1545        let plugins_dir = dir.join(".fallow").join("plugins");
1546        let _ = std::fs::create_dir_all(&plugins_dir);
1547
1548        std::fs::write(plugins_dir.join("bad.toml"), r#"enablers = ["pkg"]"#).unwrap();
1549
1550        std::fs::write(
1551            plugins_dir.join("good.toml"),
1552            r#"
1553name = "good"
1554enablers = ["good-pkg"]
1555"#,
1556        )
1557        .unwrap();
1558
1559        let plugins = discover_external_plugins(&dir, &[]);
1560        assert_eq!(plugins.len(), 1);
1561        assert_eq!(plugins[0].name, "good");
1562
1563        let _ = std::fs::remove_dir_all(&dir);
1564    }
1565
1566    #[test]
1567    fn skips_invalid_json() {
1568        let dir = std::env::temp_dir().join(format!(
1569            "fallow-test-invalid-json-plugin-{}",
1570            std::process::id()
1571        ));
1572        let plugins_dir = dir.join(".fallow").join("plugins");
1573        let _ = std::fs::create_dir_all(&plugins_dir);
1574
1575        std::fs::write(plugins_dir.join("bad.json"), r#"{"enablers": ["pkg"]}"#).unwrap();
1576
1577        std::fs::write(
1578            plugins_dir.join("good.json"),
1579            r#"{"name": "good-json", "enablers": ["good-pkg"]}"#,
1580        )
1581        .unwrap();
1582
1583        let plugins = discover_external_plugins(&dir, &[]);
1584        assert_eq!(plugins.len(), 1);
1585        assert_eq!(plugins[0].name, "good-json");
1586
1587        let _ = std::fs::remove_dir_all(&dir);
1588    }
1589
1590    #[test]
1591    fn prefix_enablers() {
1592        let toml_str = r#"
1593name = "scoped"
1594enablers = ["@myorg/"]
1595"#;
1596        let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
1597        assert_eq!(plugin.enablers, vec!["@myorg/"]);
1598    }
1599
1600    #[test]
1601    fn skips_empty_name() {
1602        let dir =
1603            std::env::temp_dir().join(format!("fallow-test-empty-name-{}", std::process::id()));
1604        let plugins_dir = dir.join(".fallow").join("plugins");
1605        let _ = std::fs::create_dir_all(&plugins_dir);
1606
1607        std::fs::write(
1608            plugins_dir.join("empty.toml"),
1609            r#"
1610name = ""
1611enablers = ["pkg"]
1612"#,
1613        )
1614        .unwrap();
1615
1616        let plugins = discover_external_plugins(&dir, &[]);
1617        assert!(plugins.is_empty(), "empty-name plugin should be skipped");
1618
1619        let _ = std::fs::remove_dir_all(&dir);
1620    }
1621
1622    #[test]
1623    fn rejects_paths_outside_root() {
1624        let dir =
1625            std::env::temp_dir().join(format!("fallow-test-path-escape-{}", std::process::id()));
1626        let _ = std::fs::create_dir_all(&dir);
1627
1628        let plugins = discover_external_plugins(&dir, &["../../../etc".to_string()]);
1629        assert!(plugins.is_empty(), "paths outside root should be rejected");
1630
1631        let _ = std::fs::remove_dir_all(&dir);
1632    }
1633
1634    #[test]
1635    fn plugin_format_detection() {
1636        assert!(matches!(
1637            PluginFormat::from_path(Path::new("plugin.toml")),
1638            Some(PluginFormat::Toml)
1639        ));
1640        assert!(matches!(
1641            PluginFormat::from_path(Path::new("plugin.json")),
1642            Some(PluginFormat::Json)
1643        ));
1644        assert!(matches!(
1645            PluginFormat::from_path(Path::new("plugin.jsonc")),
1646            Some(PluginFormat::Jsonc)
1647        ));
1648        assert!(PluginFormat::from_path(Path::new("plugin.yaml")).is_none());
1649        assert!(PluginFormat::from_path(Path::new("plugin")).is_none());
1650    }
1651
1652    #[test]
1653    fn is_plugin_file_checks_extensions() {
1654        assert!(is_plugin_file(Path::new("plugin.toml")));
1655        assert!(is_plugin_file(Path::new("plugin.json")));
1656        assert!(is_plugin_file(Path::new("plugin.jsonc")));
1657        assert!(!is_plugin_file(Path::new("plugin.yaml")));
1658        assert!(!is_plugin_file(Path::new("plugin.txt")));
1659        assert!(!is_plugin_file(Path::new("plugin")));
1660    }
1661
1662    #[test]
1663    fn detection_deserialize_dependency() {
1664        let json = r#"{"type": "dependency", "package": "next"}"#;
1665        let detection: PluginDetection = serde_json::from_str(json).unwrap();
1666        assert!(matches!(detection, PluginDetection::Dependency { package } if package == "next"));
1667    }
1668
1669    #[test]
1670    fn detection_deserialize_file_exists() {
1671        let json = r#"{"type": "fileExists", "pattern": "tsconfig.json"}"#;
1672        let detection: PluginDetection = serde_json::from_str(json).unwrap();
1673        assert!(
1674            matches!(detection, PluginDetection::FileExists { pattern } if pattern == "tsconfig.json")
1675        );
1676    }
1677
1678    #[test]
1679    fn detection_deserialize_all() {
1680        let json = r#"{"type": "all", "conditions": [{"type": "dependency", "package": "a"}, {"type": "dependency", "package": "b"}]}"#;
1681        let detection: PluginDetection = serde_json::from_str(json).unwrap();
1682        assert!(matches!(detection, PluginDetection::All { conditions } if conditions.len() == 2));
1683    }
1684
1685    #[test]
1686    fn detection_deserialize_any() {
1687        let json = r#"{"type": "any", "conditions": [{"type": "dependency", "package": "a"}]}"#;
1688        let detection: PluginDetection = serde_json::from_str(json).unwrap();
1689        assert!(matches!(detection, PluginDetection::Any { conditions } if conditions.len() == 1));
1690    }
1691
1692    #[test]
1693    fn plugin_with_detection_field() {
1694        let json = r#"{
1695            "name": "my-plugin",
1696            "detection": {"type": "dependency", "package": "my-pkg"},
1697            "entryPoints": ["src/**/*.ts"]
1698        }"#;
1699        let plugin: ExternalPluginDef = serde_json::from_str(json).unwrap();
1700        assert_eq!(plugin.name, "my-plugin");
1701        assert!(plugin.detection.is_some());
1702        assert!(plugin.enablers.is_empty());
1703        assert_eq!(plugin.entry_points, vec!["src/**/*.ts"]);
1704    }
1705
1706    #[test]
1707    fn plugin_without_detection_uses_enablers() {
1708        let json = r#"{
1709            "name": "my-plugin",
1710            "enablers": ["my-pkg"]
1711        }"#;
1712        let plugin: ExternalPluginDef = serde_json::from_str(json).unwrap();
1713        assert!(plugin.detection.is_none());
1714        assert_eq!(plugin.enablers, vec!["my-pkg"]);
1715    }
1716
1717    #[test]
1718    fn detection_nested_all_with_any() {
1719        let json = r#"{
1720            "type": "all",
1721            "conditions": [
1722                {"type": "dependency", "package": "react"},
1723                {"type": "any", "conditions": [
1724                    {"type": "fileExists", "pattern": "next.config.js"},
1725                    {"type": "fileExists", "pattern": "next.config.mjs"}
1726                ]}
1727            ]
1728        }"#;
1729        let detection: PluginDetection = serde_json::from_str(json).unwrap();
1730        match detection {
1731            PluginDetection::All { conditions } => {
1732                assert_eq!(conditions.len(), 2);
1733                assert!(matches!(
1734                    &conditions[0],
1735                    PluginDetection::Dependency { package } if package == "react"
1736                ));
1737                match &conditions[1] {
1738                    PluginDetection::Any { conditions: inner } => {
1739                        assert_eq!(inner.len(), 2);
1740                    }
1741                    other => panic!("expected Any, got: {other:?}"),
1742                }
1743            }
1744            other => panic!("expected All, got: {other:?}"),
1745        }
1746    }
1747
1748    #[test]
1749    fn detection_empty_all_conditions() {
1750        let json = r#"{"type": "all", "conditions": []}"#;
1751        let detection: PluginDetection = serde_json::from_str(json).unwrap();
1752        assert!(matches!(
1753            detection,
1754            PluginDetection::All { conditions } if conditions.is_empty()
1755        ));
1756    }
1757
1758    #[test]
1759    fn detection_empty_any_conditions() {
1760        let json = r#"{"type": "any", "conditions": []}"#;
1761        let detection: PluginDetection = serde_json::from_str(json).unwrap();
1762        assert!(matches!(
1763            detection,
1764            PluginDetection::Any { conditions } if conditions.is_empty()
1765        ));
1766    }
1767
1768    #[test]
1769    fn detection_toml_dependency() {
1770        let toml_str = r#"
1771name = "my-plugin"
1772
1773[detection]
1774type = "dependency"
1775package = "next"
1776"#;
1777        let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
1778        assert!(plugin.detection.is_some());
1779        assert!(matches!(
1780            plugin.detection.unwrap(),
1781            PluginDetection::Dependency { package } if package == "next"
1782        ));
1783    }
1784
1785    #[test]
1786    fn detection_toml_file_exists() {
1787        let toml_str = r#"
1788name = "my-plugin"
1789
1790[detection]
1791type = "fileExists"
1792pattern = "next.config.js"
1793"#;
1794        let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
1795        assert!(matches!(
1796            plugin.detection.unwrap(),
1797            PluginDetection::FileExists { pattern } if pattern == "next.config.js"
1798        ));
1799    }
1800
1801    #[test]
1802    fn plugin_all_fields_json() {
1803        let json = r#"{
1804            "$schema": "https://raw.githubusercontent.com/fallow-rs/fallow/main/plugin-schema.json",
1805            "name": "full-plugin",
1806            "detection": {"type": "dependency", "package": "my-pkg"},
1807            "enablers": ["fallback-enabler"],
1808            "entryPoints": ["src/entry.ts"],
1809            "configPatterns": ["config.js"],
1810            "alwaysUsed": ["src/polyfills.ts"],
1811            "toolingDependencies": ["my-cli"],
1812            "usedExports": [{"pattern": "src/**", "exports": ["default", "setup"]}]
1813        }"#;
1814        let plugin: ExternalPluginDef = serde_json::from_str(json).unwrap();
1815        assert_eq!(plugin.name, "full-plugin");
1816        assert!(plugin.detection.is_some());
1817        assert_eq!(plugin.enablers, vec!["fallback-enabler"]);
1818        assert_eq!(plugin.entry_points, vec!["src/entry.ts"]);
1819        assert_eq!(plugin.config_patterns, vec!["config.js"]);
1820        assert_eq!(plugin.always_used, vec!["src/polyfills.ts"]);
1821        assert_eq!(plugin.tooling_dependencies, vec!["my-cli"]);
1822        assert_eq!(plugin.used_exports.len(), 1);
1823        assert_eq!(plugin.used_exports[0].pattern, "src/**");
1824        assert_eq!(plugin.used_exports[0].exports, vec!["default", "setup"]);
1825    }
1826
1827    #[test]
1828    fn plugin_with_special_chars_in_name() {
1829        let json = r#"{"name": "@scope/my-plugin-v2.0", "enablers": ["pkg"]}"#;
1830        let plugin: ExternalPluginDef = serde_json::from_str(json).unwrap();
1831        assert_eq!(plugin.name, "@scope/my-plugin-v2.0");
1832    }
1833
1834    #[test]
1835    fn parse_plugin_toml_format() {
1836        let content = r#"
1837name = "test-plugin"
1838enablers = ["test-pkg"]
1839entryPoints = ["src/**/*.ts"]
1840"#;
1841        let result = parse_plugin(content, &PluginFormat::Toml, Path::new("test.toml"));
1842        assert!(result.is_some());
1843        let plugin = result.unwrap();
1844        assert_eq!(plugin.name, "test-plugin");
1845    }
1846
1847    #[test]
1848    fn parse_plugin_json_format() {
1849        let content = r#"{"name": "json-test", "enablers": ["pkg"]}"#;
1850        let result = parse_plugin(content, &PluginFormat::Json, Path::new("test.json"));
1851        assert!(result.is_some());
1852        assert_eq!(result.unwrap().name, "json-test");
1853    }
1854
1855    #[test]
1856    fn parse_plugin_jsonc_format() {
1857        let content = r#"{
1858            "name": "jsonc-test",
1859            "enablers": ["pkg"]
1860        }"#;
1861        let result = parse_plugin(content, &PluginFormat::Jsonc, Path::new("test.jsonc"));
1862        assert!(result.is_some());
1863        assert_eq!(result.unwrap().name, "jsonc-test");
1864    }
1865
1866    #[test]
1867    fn parse_plugin_invalid_toml_returns_none() {
1868        let content = "not valid toml [[[";
1869        let result = parse_plugin(content, &PluginFormat::Toml, Path::new("bad.toml"));
1870        assert!(result.is_none());
1871    }
1872
1873    #[test]
1874    fn parse_plugin_invalid_json_returns_none() {
1875        let content = "{ not valid json }";
1876        let result = parse_plugin(content, &PluginFormat::Json, Path::new("bad.json"));
1877        assert!(result.is_none());
1878    }
1879
1880    #[test]
1881    fn parse_plugin_invalid_jsonc_returns_none() {
1882        let content = r#"{"enablers": ["pkg"]}"#;
1883        let result = parse_plugin(content, &PluginFormat::Jsonc, Path::new("bad.jsonc"));
1884        assert!(result.is_none());
1885    }
1886}