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