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