Skip to main content

fallow_config/
external_plugin.rs

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