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