Skip to main content

fallow_config/
external_plugin.rs

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