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