Skip to main content

fallow_config/external_plugin/
mod.rs

1use std::collections::BTreeMap;
2use std::fmt;
3use std::path::{Path, PathBuf};
4use std::str::FromStr;
5
6use schemars::JsonSchema;
7use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _};
8
9use crate::config::UsedClassMemberRule;
10
11/// Supported plugin file extensions.
12/// The file extensions of external plugin files.
13pub const PLUGIN_EXTENSIONS: &[&str] = &["toml", "json", "jsonc"];
14
15/// How a plugin's discovered entry points contribute to coverage reachability.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema, Default)]
17#[serde(rename_all = "camelCase")]
18pub enum EntryPointRole {
19    /// Runtime/application roots that should count toward runtime reachability.
20    Runtime,
21    /// Test roots that should count toward test reachability.
22    Test,
23    /// Support/setup/config roots that should keep files alive but not count as runtime/test.
24    #[default]
25    Support,
26}
27
28/// Which export shape a convention auto-import credits when its name is
29/// referenced without an explicit `import` statement.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum AutoImportKind {
32    /// `import { name } from source` (named export, e.g. a Nuxt composable/util).
33    Named,
34    /// `import name from source` (default export).
35    Default,
36    /// SFC default export consumed by a template tag (Nuxt `components/`).
37    DefaultComponent,
38}
39
40/// A single convention-based auto-import: a bare identifier name that resolves
41/// to an export in `source` by framework convention, with no explicit `import`
42/// statement in the consuming file.
43///
44/// Built by `Plugin::auto_imports` from a filesystem scan (e.g. Nuxt scanning
45/// `components/`), and consumed at graph-build time: the resolver matches a
46/// file's captured `auto_import_candidates` against these rules and synthesizes
47/// an edge to `source`. The table is a function of which files exist on disk,
48/// not of any single file's bytes, so it is computed fresh per run and never
49/// cached as part of per-file extraction.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct AutoImportRule {
52    /// Bare identifier name (e.g. `useCounter`, `Card001`, `BaseButton`,
53    /// `LazyCard001`). Component names are canonical PascalCase; the consuming
54    /// scanner normalizes kebab-case tags before matching.
55    pub name: String,
56    /// Absolute path to the source file providing the export.
57    pub source: PathBuf,
58    /// Which export to credit when the name is referenced.
59    pub kind: AutoImportKind,
60    /// Absolute roots whose files can reference the name: the root that
61    /// declared the rule, plus the roots that share its names, such as a Nuxt
62    /// app and the layers it extends. A file outside every root gets no edge.
63    /// Empty means every file, and the plugin run fills it with its own root.
64    pub scope: Vec<PathBuf>,
65}
66
67impl AutoImportRule {
68    /// A rule with no scope yet. The plugin run scopes it to its own root.
69    #[must_use]
70    pub const fn new(name: String, source: PathBuf, kind: AutoImportKind) -> Self {
71        Self {
72            name,
73            source,
74            kind,
75            scope: Vec::new(),
76        }
77    }
78}
79
80/// A JSX import source that a bundler or test config sets for a group of files.
81///
82/// With the automatic JSX runtime, the transform adds an import of
83/// `<source>/jsx-dev-runtime` to each file with JSX, and no import statement
84/// shows that edge. A per-file `@jsxImportSource` pragma overrides the config,
85/// so the resolver applies a rule only to a module with
86/// `jsx_runtime_from_config` set. Built from the config each run and never
87/// cached with per-file extraction.
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct JsxImportSourceRule {
90    /// The `importSource` value as written, for example `./src/jsx` or
91    /// `preact`.
92    pub source: String,
93    /// Absolute directory of the config file. The `include` globs match paths
94    /// relative to it, and a relative `source` that does not resolve from the
95    /// file resolves from it.
96    pub config_dir: PathBuf,
97    /// Glob patterns, relative to `config_dir`, of the files that the config
98    /// transforms.
99    pub include: Vec<String>,
100    /// Glob patterns, relative to `config_dir`, of the files that the config
101    /// does not transform, also when `include` matches them.
102    pub exclude: Vec<String>,
103}
104
105/// How to detect if a plugin should be activated.
106///
107/// When set on an `ExternalPluginDef`, this takes priority over `enablers`.
108/// Supports dependency checks, file existence checks, and boolean combinators.
109#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
110#[serde(tag = "type", rename_all = "camelCase")]
111pub enum PluginDetection {
112    /// Plugin detected if this package is in dependencies.
113    Dependency {
114        /// Exact package name looked up in the project's declared dependencies.
115        package: String,
116    },
117    /// Plugin detected if this file pattern matches.
118    FileExists {
119        /// Project-root-relative glob; the plugin activates when any
120        /// discovered file matches it. Validated at load like other user
121        /// globs (no absolute paths or `..` segments).
122        /// A single `*` stays within one path segment; use `**` to match across directories.
123        pattern: String,
124    },
125    /// All conditions must be true.
126    All {
127        /// Sub-conditions combined with logical AND.
128        conditions: Vec<Self>,
129    },
130    /// Any condition must be true.
131    Any {
132        /// Sub-conditions combined with logical OR.
133        conditions: Vec<Self>,
134    },
135}
136
137/// A declarative plugin definition loaded from a standalone file or inline config.
138///
139/// External plugins provide the same static pattern capabilities as built-in
140/// plugins (entry points, always-used files, used exports, tooling dependencies),
141/// but are defined in standalone files or inline in the fallow config rather than
142/// compiled Rust code.
143///
144/// They cannot do AST-based config parsing (`resolve_config()`), but cover the
145/// vast majority of framework integration use cases.
146///
147/// Supports JSONC, JSON, and TOML formats. All use camelCase field names.
148///
149/// ```json
150/// {
151///   "$schema": "https://raw.githubusercontent.com/fallow-rs/fallow/main/plugin-schema.json",
152///   "name": "my-framework",
153///   "enablers": ["my-framework", "@my-framework/core"],
154///   "entryPoints": ["src/routes/**/*.{ts,tsx}"],
155///   "configPatterns": ["my-framework.config.{ts,js}"],
156///   "alwaysUsed": ["src/setup.ts"],
157///   "toolingDependencies": ["my-framework-cli"],
158///   "usedExports": [
159///     { "pattern": "src/routes/**/*.{ts,tsx}", "exports": ["default", "loader", "action"] }
160///   ]
161/// }
162/// ```
163#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
164#[serde(rename_all = "camelCase")]
165pub struct ExternalPluginDef {
166    /// JSON Schema reference (ignored during deserialization).
167    #[serde(rename = "$schema", default, skip_serializing)]
168    #[schemars(skip)]
169    pub schema: Option<String>,
170
171    /// Unique name for this plugin.
172    pub name: String,
173
174    /// Rich detection logic (dependency checks, file existence, boolean combinators).
175    /// Takes priority over `enablers` when set.
176    #[serde(default)]
177    pub detection: Option<PluginDetection>,
178
179    /// Package names that activate this plugin when found in package.json.
180    /// Supports exact matches and prefix patterns (ending with `/`).
181    /// Only used when `detection` is not set.
182    #[serde(default)]
183    pub enablers: Vec<String>,
184
185    /// Glob patterns for entry point files.
186    #[serde(default)]
187    pub entry_points: Vec<String>,
188
189    /// Coverage role for `entryPoints`.
190    ///
191    /// Defaults to `support`. Set to `runtime` for application entry points
192    /// or `test` for test framework entry points.
193    #[serde(default = "default_external_entry_point_role")]
194    pub entry_point_role: EntryPointRole,
195
196    /// Entry points DERIVED from framework manifest files.
197    ///
198    /// Unlike `entryPoints` (static globs), each rule finds manifest files by a
199    /// recursive glob, parses them, and seeds sibling entries resolved relative
200    /// to each manifest's directory, gated on the manifest's own fields. Seeded
201    /// entries use this plugin's `entryPointRole`.
202    #[serde(default)]
203    pub manifest_entries: Vec<ManifestEntryRule>,
204
205    /// Glob patterns for config files (marked as always-used when active).
206    #[serde(default)]
207    pub config_patterns: Vec<String>,
208
209    /// Entries of `configPatterns` whose files do not get the `default`
210    /// export credit under `includeEntryExports`. Use it when the tool reads
211    /// named exports, or when the pattern also matches ordinary source files.
212    /// Write each entry exactly as in `configPatterns`. Other entries have
213    /// no effect.
214    #[serde(default)]
215    pub config_default_export_opt_out: Vec<String>,
216
217    /// Files that are always considered "used" when this plugin is active.
218    #[serde(default)]
219    pub always_used: Vec<String>,
220
221    /// Dependencies that are tooling (used via CLI/config, not source imports).
222    /// These should not be flagged as unused devDependencies.
223    #[serde(default)]
224    pub tooling_dependencies: Vec<String>,
225
226    /// Exports that are always considered used for matching file patterns.
227    #[serde(default)]
228    pub used_exports: Vec<ExternalUsedExport>,
229
230    /// Class member method/property rules the framework invokes at runtime.
231    /// Supports plain member names for global suppression and scoped objects
232    /// with `extends` / `implements` constraints when the method name is too
233    /// common to suppress across the whole workspace.
234    #[serde(default)]
235    pub used_class_members: Vec<UsedClassMemberRule>,
236}
237
238/// Exports considered used for files matching a pattern.
239#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
240pub struct ExternalUsedExport {
241    /// Glob pattern for files.
242    /// A single `*` stays within one path segment; use `**` to match across directories.
243    pub pattern: String,
244    /// Export names always considered used.
245    pub exports: Vec<String>,
246}
247
248/// Format of the manifest files a [`ManifestEntryRule`] reads.
249///
250/// `jsonc` (the default) also parses plain JSON, so it is the tolerant choice.
251#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema, Default)]
252#[serde(rename_all = "lowercase")]
253pub enum ManifestFormat {
254    /// JSONC (comments + trailing commas). Also accepts plain JSON.
255    #[default]
256    Jsonc,
257    /// Strict JSON.
258    Json,
259}
260
261/// A validated field path into a manifest document.
262///
263/// Field paths use dotted object keys and an exact `[*]` segment for array
264/// traversal. The parsed segments are retained so evaluators do not repeatedly
265/// interpret user-authored path strings.
266#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
267pub struct ManifestFieldPath {
268    raw: String,
269    segments: Vec<ManifestFieldSegment>,
270}
271
272/// One validated traversal step in a [`ManifestFieldPath`].
273#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
274pub enum ManifestFieldSegment {
275    /// Read one named key from each current JSON object.
276    Key(String),
277    /// Visit every item in each current JSON array.
278    Each,
279}
280
281impl ManifestFieldPath {
282    /// The original validated path, used in diagnostics and serialization.
283    #[must_use]
284    pub fn as_str(&self) -> &str {
285        &self.raw
286    }
287
288    /// Parsed object-key and array traversal segments in source order.
289    #[must_use]
290    pub fn segments(&self) -> &[ManifestFieldSegment] {
291        &self.segments
292    }
293
294    fn parse(raw: String) -> Result<Self, String> {
295        if raw.is_empty() {
296            return Err("manifest field path must not be empty".to_string());
297        }
298
299        let mut segments = Vec::new();
300        for component in raw.split('.') {
301            if component.is_empty() {
302                return Err(format!(
303                    "manifest field path '{raw}' contains an empty segment"
304                ));
305            }
306
307            let key_end = component.find('[').unwrap_or(component.len());
308            let key = &component[..key_end];
309            if key.is_empty() || key.contains(['{', '}', ']']) {
310                return Err(format!(
311                    "manifest field path '{raw}' contains unsupported bracket syntax"
312                ));
313            }
314            segments.push(ManifestFieldSegment::Key(key.to_string()));
315
316            let mut suffix = &component[key_end..];
317            while !suffix.is_empty() {
318                let Some(rest) = suffix.strip_prefix("[*]") else {
319                    return Err(format!(
320                        "manifest field path '{raw}' only supports exact '[*]' array traversal"
321                    ));
322                };
323                segments.push(ManifestFieldSegment::Each);
324                suffix = rest;
325            }
326        }
327
328        Ok(Self { raw, segments })
329    }
330}
331
332impl FromStr for ManifestFieldPath {
333    type Err = String;
334
335    fn from_str(raw: &str) -> Result<Self, Self::Err> {
336        Self::parse(raw.to_string())
337    }
338}
339
340impl fmt::Display for ManifestFieldPath {
341    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
342        f.write_str(&self.raw)
343    }
344}
345
346impl Serialize for ManifestFieldPath {
347    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
348    where
349        S: Serializer,
350    {
351        serializer.serialize_str(&self.raw)
352    }
353}
354
355impl<'de> Deserialize<'de> for ManifestFieldPath {
356    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
357    where
358        D: Deserializer<'de>,
359    {
360        let raw = String::deserialize(deserializer)?;
361        Self::parse(raw).map_err(D::Error::custom)
362    }
363}
364
365/// One parsed part of a manifest entry path template.
366#[derive(Debug, Clone, PartialEq, Eq)]
367pub enum ManifestPathPart {
368    /// Literal path text copied without interpretation.
369    Literal(String),
370    /// A manifest field whose scalar values are interpolated.
371    Field(ManifestFieldPath),
372}
373
374/// A validated manifest-relative entry path with parsed `${field.path}` parts.
375#[derive(Debug, Clone, PartialEq, Eq)]
376pub struct ManifestPathTemplate {
377    raw: String,
378    parts: Vec<ManifestPathPart>,
379}
380
381impl ManifestPathTemplate {
382    /// The original template used for serialization and diagnostics.
383    #[must_use]
384    pub fn as_str(&self) -> &str {
385        &self.raw
386    }
387
388    /// Parsed literal and field parts in source order.
389    #[must_use]
390    pub fn parts(&self) -> &[ManifestPathPart] {
391        &self.parts
392    }
393
394    /// Replace field interpolations with a safe segment for glob validation.
395    #[must_use]
396    pub fn validation_pattern(&self) -> String {
397        let mut pattern = String::with_capacity(self.raw.len());
398        for part in &self.parts {
399            match part {
400                ManifestPathPart::Literal(literal) => pattern.push_str(literal),
401                ManifestPathPart::Field(_) => pattern.push_str("fallowinterp"),
402            }
403        }
404        pattern
405    }
406
407    fn parse(raw: String) -> Result<Self, String> {
408        let mut parts = Vec::new();
409        let mut rest = raw.as_str();
410
411        while let Some(start) = rest.find("${") {
412            if start > 0 {
413                parts.push(ManifestPathPart::Literal(rest[..start].to_string()));
414            }
415
416            let after = &rest[start + 2..];
417            let Some(end) = after.find('}') else {
418                return Err(format!(
419                    "manifest entry path '{raw}' contains an unterminated interpolation"
420                ));
421            };
422            let field = after[..end].parse::<ManifestFieldPath>()?;
423            parts.push(ManifestPathPart::Field(field));
424            rest = &after[end + 1..];
425        }
426
427        if !rest.is_empty() {
428            parts.push(ManifestPathPart::Literal(rest.to_string()));
429        }
430        if parts.is_empty() {
431            parts.push(ManifestPathPart::Literal(String::new()));
432        }
433
434        Ok(Self { raw, parts })
435    }
436}
437
438impl FromStr for ManifestPathTemplate {
439    type Err = String;
440
441    fn from_str(raw: &str) -> Result<Self, Self::Err> {
442        Self::parse(raw.to_string())
443    }
444}
445
446impl fmt::Display for ManifestPathTemplate {
447    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
448        f.write_str(&self.raw)
449    }
450}
451
452impl Serialize for ManifestPathTemplate {
453    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
454    where
455        S: Serializer,
456    {
457        serializer.serialize_str(&self.raw)
458    }
459}
460
461impl<'de> Deserialize<'de> for ManifestPathTemplate {
462    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
463    where
464        D: Deserializer<'de>,
465    {
466        let raw = String::deserialize(deserializer)?;
467        Self::parse(raw).map_err(D::Error::custom)
468    }
469}
470
471/// A typed condition used by a manifest entry gate.
472///
473/// Plain JSON values retain strict equality semantics. The reserved
474/// `{ "exists": bool }` object tests field presence without truthiness.
475#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
476#[serde(untagged)]
477pub enum ManifestCondition {
478    /// Require the field path to resolve (`true`) or not resolve (`false`).
479    Exists(ManifestExistsPredicate),
480    /// Require at least one yielded value to equal this JSON value exactly.
481    Equals(serde_json::Value),
482}
483
484/// The explicit field-presence predicate accepted by [`ManifestCondition`].
485#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
486#[serde(deny_unknown_fields)]
487pub struct ManifestExistsPredicate {
488    /// Whether the field path must resolve to at least one value.
489    pub exists: bool,
490}
491
492/// A rule that seeds entry points DERIVED from framework manifest files.
493///
494/// For every file matching `manifests` (a recursive glob) that passes the
495/// manifest-level `when` gate, each rule in `entries` is resolved relative to
496/// the manifest's directory (with `${dotted.field}` interpolation) into an entry
497/// point. Seeded entries use the owning plugin's `entryPointRole`.
498///
499/// ```jsonc
500/// {
501///   "manifests": "**/kibana.jsonc",
502///   "when": { "type": "plugin" },
503///   "entries": [
504///     { "path": "public/index.{ts,tsx}", "when": { "plugin.browser": true } },
505///     { "path": "server/index.{ts,tsx}", "when": { "plugin.server": true } },
506///     { "path": "${plugin.extraPublicDirs}/index.{ts,tsx}" }
507///   ]
508/// }
509/// ```
510#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
511#[serde(rename_all = "camelCase")]
512pub struct ManifestEntryRule {
513    /// Recursive glob selecting the manifest files to read (e.g. `**/kibana.jsonc`).
514    /// A single `*` stays within one path segment; use `**` to match across directories.
515    pub manifests: String,
516
517    /// Manifest format. Defaults to `jsonc` (which also parses plain JSON).
518    #[serde(default)]
519    pub format: ManifestFormat,
520
521    /// Manifest-level gate: a map of field path to an equality expectation or
522    /// an explicit `exists` predicate. Paths use dotted object keys and may
523    /// traverse arrays with `[*]`. ALL entries must match; a wildcard equality
524    /// matches when any yielded value equals the expectation. An empty map
525    /// matches every manifest.
526    #[serde(default)]
527    #[schemars(with = "BTreeMap<String, ManifestCondition>")]
528    pub when: BTreeMap<ManifestFieldPath, ManifestCondition>,
529
530    /// Entry rules seeded per matching manifest.
531    pub entries: Vec<ManifestSeedRule>,
532}
533
534/// A single entry seeded by a [`ManifestEntryRule`], resolved relative to the
535/// manifest's directory.
536#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
537#[serde(rename_all = "camelCase")]
538pub struct ManifestSeedRule {
539    /// Entry glob relative to the manifest directory. May contain
540    /// `${dotted.field}` interpolation that fans out over string / array
541    /// manifest field values; `[*]` traverses object arrays. A missing or empty
542    /// field seeds nothing. The glob must encode its own extension, such as
543    /// `public/index.{ts,tsx}`. Glob entry patterns are matched literally
544    /// against discovered files without
545    /// source-extension probing.
546    #[schemars(with = "String")]
547    pub path: ManifestPathTemplate,
548
549    /// Per-entry gate, using the same strict equality and `[*]` traversal as the
550    /// manifest-level gate. An empty map always passes.
551    #[serde(default)]
552    #[schemars(with = "BTreeMap<String, ManifestCondition>")]
553    pub when: BTreeMap<ManifestFieldPath, ManifestCondition>,
554}
555
556fn default_external_entry_point_role() -> EntryPointRole {
557    EntryPointRole::Support
558}
559
560impl ExternalPluginDef {
561    /// Generate JSON Schema for the external plugin format.
562    #[must_use]
563    pub fn json_schema() -> serde_json::Value {
564        serde_json::to_value(schemars::schema_for!(ExternalPluginDef)).unwrap_or_default()
565    }
566
567    /// Validate all user-supplied glob patterns on this plugin definition,
568    /// including patterns nested inside `detection` combinators (`all` / `any`).
569    ///
570    /// Pattern names use the same `framework[].<field>` notation used by
571    /// inline plugin definitions in `FallowConfig::validate_user_globs` so the
572    /// user sees consistent field paths whether the plugin is inline or
573    /// loaded from `.fallow/plugins/` / `fallow-plugin-*.{toml,json,jsonc}`.
574    ///
575    /// # Errors
576    ///
577    /// Returns a non-empty `Vec` of
578    /// [`GlobValidationError`](crate::config::glob_validation::GlobValidationError)
579    /// when any pattern is rejected.
580    pub fn validate_user_globs(
581        &self,
582    ) -> Result<(), Vec<crate::config::glob_validation::GlobValidationError>> {
583        use crate::config::glob_validation::{compile_user_glob, validate_user_globs};
584
585        let mut errors = Vec::new();
586        validate_user_globs(&self.entry_points, "framework[].entryPoints", &mut errors);
587        validate_user_globs(&self.always_used, "framework[].alwaysUsed", &mut errors);
588        validate_user_globs(
589            &self.config_patterns,
590            "framework[].configPatterns",
591            &mut errors,
592        );
593        for used in &self.used_exports {
594            if let Err(e) = compile_user_glob(&used.pattern, "framework[].usedExports[].pattern") {
595                errors.push(e);
596            }
597        }
598        for rule in &self.manifest_entries {
599            if let Err(e) =
600                compile_user_glob(&rule.manifests, "framework[].manifestEntries[].manifests")
601            {
602                errors.push(e);
603            }
604            for seed in &rule.entries {
605                // Substitute `${...}` interpolation with a placeholder segment so
606                // the surrounding glob (e.g. `${x}/index.{ts,tsx}`) validates.
607                let probe = seed.path.validation_pattern();
608                if let Err(e) =
609                    compile_user_glob(&probe, "framework[].manifestEntries[].entries[].path")
610                {
611                    errors.push(e);
612                }
613            }
614        }
615        if let Some(detection) = &self.detection {
616            validate_detection_user_globs(detection, "framework[].detection", &mut errors);
617        }
618        if errors.is_empty() {
619            Ok(())
620        } else {
621            Err(errors)
622        }
623    }
624}
625
626/// Recursively validate `FileExists.pattern` fields inside a `PluginDetection`
627/// tree. `All` and `Any` combinators recurse into their nested conditions.
628fn validate_detection_user_globs(
629    detection: &PluginDetection,
630    field: &'static str,
631    errors: &mut Vec<crate::config::glob_validation::GlobValidationError>,
632) {
633    match detection {
634        PluginDetection::Dependency { .. } => {}
635        PluginDetection::FileExists { pattern } => {
636            if let Err(e) = crate::config::glob_validation::compile_user_glob(pattern, field) {
637                errors.push(e);
638            }
639        }
640        PluginDetection::All { conditions } | PluginDetection::Any { conditions } => {
641            for condition in conditions {
642                validate_detection_user_globs(condition, field, errors);
643            }
644        }
645    }
646}
647
648/// Discover external plugin definitions AND validate their user-supplied glob
649/// patterns. Accumulates all errors across all loaded plugins so the user sees
650/// every problem in one run.
651///
652/// Discovery is identical to [`discover_external_plugins`]; this wrapper adds
653/// the per-plugin glob validation step required for security
654/// (see issue #463: `framework[].detection.fileExists.pattern` reaches
655/// `glob::glob` on disk via `root.join(pattern)`, so a `..` segment loaded
656/// from `.fallow/plugins/` would be a real path traversal).
657///
658/// # Errors
659///
660/// Returns the list of validation errors when any discovered plugin contains
661/// a rejected pattern. The CLI surfaces these with exit code 2.
662pub fn discover_and_validate_external_plugins(
663    root: &Path,
664    config_plugin_paths: &[String],
665) -> Result<Vec<ExternalPluginDef>, Vec<crate::config::glob_validation::GlobValidationError>> {
666    let plugins = discover_external_plugins(root, config_plugin_paths);
667    let mut errors = Vec::new();
668    for plugin in &plugins {
669        if let Err(mut plugin_errors) = plugin.validate_user_globs() {
670            errors.append(&mut plugin_errors);
671        }
672    }
673    if errors.is_empty() {
674        Ok(plugins)
675    } else {
676        Err(errors)
677    }
678}
679
680/// Detect plugin format from file extension.
681enum PluginFormat {
682    Toml,
683    Json,
684    Jsonc,
685}
686
687impl PluginFormat {
688    fn from_path(path: &Path) -> Option<Self> {
689        match path.extension().and_then(|e| e.to_str()) {
690            Some("toml") => Some(Self::Toml),
691            Some("json") => Some(Self::Json),
692            Some("jsonc") => Some(Self::Jsonc),
693            _ => None,
694        }
695    }
696}
697
698/// Check if a file has a supported plugin extension.
699/// The name of the plugin directory under `.fallow/` that the loader reads.
700const DEFAULT_PLUGINS_DIR_NAME: &str = "plugins";
701
702/// The name prefix of the plugin files in the project root that the loader
703/// reads.
704pub const ROOT_PLUGIN_FILE_PREFIX: &str = "fallow-plugin-";
705
706fn is_plugin_file(path: &Path) -> bool {
707    path.extension()
708        .and_then(|e| e.to_str())
709        .is_some_and(|ext| PLUGIN_EXTENSIONS.contains(&ext))
710}
711
712/// Parse a plugin definition from file content based on format.
713fn parse_plugin(content: &str, format: &PluginFormat, path: &Path) -> Option<ExternalPluginDef> {
714    match format {
715        PluginFormat::Toml => match toml::from_str::<ExternalPluginDef>(content) {
716            Ok(plugin) => Some(plugin),
717            Err(e) => {
718                tracing::warn!("failed to parse external plugin {}: {e}", path.display());
719                None
720            }
721        },
722        PluginFormat::Json => match serde_json::from_str::<ExternalPluginDef>(content) {
723            Ok(plugin) => Some(plugin),
724            Err(e) => {
725                tracing::warn!("failed to parse external plugin {}: {e}", path.display());
726                None
727            }
728        },
729        PluginFormat::Jsonc => match crate::jsonc::parse_to_value::<ExternalPluginDef>(content) {
730            Ok(plugin) => Some(plugin),
731            Err(e) => {
732                tracing::warn!("failed to parse external plugin {}: {e}", path.display());
733                None
734            }
735        },
736    }
737}
738
739/// Why an explicitly configured external-plugin resource could not be loaded.
740#[derive(Debug, Clone, Copy, PartialEq, Eq)]
741#[non_exhaustive]
742pub enum ConfiguredPluginDiagnosticKind {
743    /// The configured path does not exist.
744    Missing,
745    /// The configured path resolves outside the project root.
746    OutsideProjectRoot,
747    /// The configured file does not use a supported plugin extension.
748    UnsupportedFormat,
749    /// The configured file or directory could not be read.
750    Unreadable,
751    /// The configured plugin file could not be parsed into a usable definition.
752    InvalidDefinition,
753    /// The configured directory contains no supported plugin definitions.
754    NoPluginFiles,
755}
756
757/// Typed readiness diagnostic for an explicitly configured plugin resource.
758#[derive(Debug, Clone, PartialEq, Eq)]
759#[non_exhaustive]
760pub struct ConfiguredPluginDiagnostic {
761    /// Failure classification suitable for programmatic branching.
762    pub kind: ConfiguredPluginDiagnosticKind,
763    /// Resolved path that was inspected. Callers must apply their own privacy policy.
764    pub path: PathBuf,
765}
766
767/// Inspect only paths named explicitly by the `plugins` config field.
768///
769/// Automatic plugin discovery deliberately retains its existing best-effort
770/// behavior. This API exists for readiness surfaces that must distinguish an
771/// absent plugin configuration from an explicitly configured resource that
772/// could not be loaded.
773#[must_use]
774pub fn diagnose_configured_external_plugins(
775    root: &Path,
776    config_plugin_paths: &[String],
777) -> Vec<ConfiguredPluginDiagnostic> {
778    let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
779    let mut diagnostics = Vec::new();
780
781    for path_str in config_plugin_paths {
782        let path = root.join(path_str);
783        let metadata = match std::fs::metadata(&path) {
784            Ok(metadata) => metadata,
785            Err(error) => {
786                diagnostics.push(ConfiguredPluginDiagnostic {
787                    kind: if error.kind() == std::io::ErrorKind::NotFound {
788                        ConfiguredPluginDiagnosticKind::Missing
789                    } else {
790                        ConfiguredPluginDiagnosticKind::Unreadable
791                    },
792                    path,
793                });
794                continue;
795            }
796        };
797
798        if !is_within_root(&path, &canonical_root) {
799            diagnostics.push(ConfiguredPluginDiagnostic {
800                kind: ConfiguredPluginDiagnosticKind::OutsideProjectRoot,
801                path,
802            });
803            continue;
804        }
805
806        if metadata.is_dir() {
807            diagnose_configured_plugin_dir(&path, &canonical_root, &mut diagnostics);
808        } else if metadata.is_file() {
809            diagnose_configured_plugin_file(&path, &canonical_root, &mut diagnostics);
810        } else {
811            diagnostics.push(ConfiguredPluginDiagnostic {
812                kind: ConfiguredPluginDiagnosticKind::UnsupportedFormat,
813                path,
814            });
815        }
816    }
817
818    diagnostics
819}
820
821fn diagnose_configured_plugin_dir(
822    dir: &Path,
823    canonical_root: &Path,
824    diagnostics: &mut Vec<ConfiguredPluginDiagnostic>,
825) {
826    let Ok(entries) = std::fs::read_dir(dir) else {
827        diagnostics.push(ConfiguredPluginDiagnostic {
828            kind: ConfiguredPluginDiagnosticKind::Unreadable,
829            path: dir.to_path_buf(),
830        });
831        return;
832    };
833    let mut plugin_files = Vec::new();
834    let mut entry_failed = false;
835    for entry in entries {
836        match entry {
837            Ok(entry) => {
838                let path = entry.path();
839                if path.is_file() && is_plugin_file(&path) {
840                    plugin_files.push(path);
841                }
842            }
843            Err(_) => {
844                entry_failed = true;
845                diagnostics.push(ConfiguredPluginDiagnostic {
846                    kind: ConfiguredPluginDiagnosticKind::Unreadable,
847                    path: dir.to_path_buf(),
848                });
849            }
850        }
851    }
852    if plugin_files.is_empty() && !entry_failed {
853        diagnostics.push(ConfiguredPluginDiagnostic {
854            kind: ConfiguredPluginDiagnosticKind::NoPluginFiles,
855            path: dir.to_path_buf(),
856        });
857        return;
858    }
859    plugin_files.sort();
860    for path in plugin_files {
861        diagnose_configured_plugin_file(&path, canonical_root, diagnostics);
862    }
863}
864
865fn diagnose_configured_plugin_file(
866    path: &Path,
867    canonical_root: &Path,
868    diagnostics: &mut Vec<ConfiguredPluginDiagnostic>,
869) {
870    let kind = if !is_within_root(path, canonical_root) {
871        Some(ConfiguredPluginDiagnosticKind::OutsideProjectRoot)
872    } else if let Some(format) = PluginFormat::from_path(path) {
873        match std::fs::read_to_string(path) {
874            Ok(content) => match parse_plugin(&content, &format, path) {
875                Some(plugin) if !plugin.name.is_empty() => None,
876                Some(_) | None => Some(ConfiguredPluginDiagnosticKind::InvalidDefinition),
877            },
878            Err(_) => Some(ConfiguredPluginDiagnosticKind::Unreadable),
879        }
880    } else {
881        Some(ConfiguredPluginDiagnosticKind::UnsupportedFormat)
882    };
883    if let Some(kind) = kind {
884        diagnostics.push(ConfiguredPluginDiagnostic {
885            kind,
886            path: path.to_path_buf(),
887        });
888    }
889}
890
891/// Discover and load external plugin definitions for a project.
892///
893/// Discovery order (first occurrence of a plugin name wins):
894/// 1. Paths from the `plugins` config field (files or directories)
895/// 2. `.fallow/plugins/` directory (auto-discover `*.toml`, `*.json`, `*.jsonc` files)
896/// 3. Project root `fallow-plugin-*` files (`.toml`, `.json`, `.jsonc`)
897pub fn discover_external_plugins(
898    root: &Path,
899    config_plugin_paths: &[String],
900) -> Vec<ExternalPluginDef> {
901    let mut plugins = Vec::new();
902    let mut seen_names = rustc_hash::FxHashSet::default();
903
904    let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
905
906    load_configured_plugin_paths(
907        root,
908        config_plugin_paths,
909        &canonical_root,
910        &mut plugins,
911        &mut seen_names,
912    );
913    load_default_plugins_dir(root, &canonical_root, &mut plugins, &mut seen_names);
914    load_root_plugin_files(root, &canonical_root, &mut plugins, &mut seen_names);
915
916    plugins
917}
918
919fn load_configured_plugin_paths(
920    root: &Path,
921    config_plugin_paths: &[String],
922    canonical_root: &Path,
923    plugins: &mut Vec<ExternalPluginDef>,
924    seen_names: &mut rustc_hash::FxHashSet<String>,
925) {
926    for path_str in config_plugin_paths {
927        let path = root.join(path_str);
928        if !is_within_root(&path, canonical_root) {
929            tracing::warn!("plugin path '{path_str}' resolves outside project root, skipping");
930            continue;
931        }
932        if path.is_dir() {
933            load_plugins_from_dir(&path, canonical_root, plugins, seen_names);
934        } else if path.is_file() {
935            load_plugin_file(&path, canonical_root, plugins, seen_names);
936        }
937    }
938}
939
940fn load_default_plugins_dir(
941    root: &Path,
942    canonical_root: &Path,
943    plugins: &mut Vec<ExternalPluginDef>,
944    seen_names: &mut rustc_hash::FxHashSet<String>,
945) {
946    let plugins_dir = root.join(".fallow").join(DEFAULT_PLUGINS_DIR_NAME);
947    if plugins_dir.is_dir() && is_within_root(&plugins_dir, canonical_root) {
948        load_plugins_from_dir(&plugins_dir, canonical_root, plugins, seen_names);
949    }
950}
951
952fn load_root_plugin_files(
953    root: &Path,
954    canonical_root: &Path,
955    plugins: &mut Vec<ExternalPluginDef>,
956    seen_names: &mut rustc_hash::FxHashSet<String>,
957) {
958    for path in root_plugin_files(root) {
959        load_plugin_file(&path, canonical_root, plugins, seen_names);
960    }
961}
962
963/// The `fallow-plugin-*` files in the project root, sorted.
964fn root_plugin_files(root: &Path) -> Vec<PathBuf> {
965    let Ok(entries) = std::fs::read_dir(root) else {
966        return Vec::new();
967    };
968    let mut plugin_files: Vec<PathBuf> = entries
969        .filter_map(Result::ok)
970        .map(|e| e.path())
971        .filter(|p| {
972            p.is_file()
973                && p.file_name().and_then(|n| n.to_str()).is_some_and(|n| {
974                    n.starts_with(ROOT_PLUGIN_FILE_PREFIX) && is_plugin_file(Path::new(n))
975                })
976        })
977        .collect();
978    plugin_files.sort();
979    plugin_files
980}
981
982/// The plugin files in a plugin directory, sorted.
983fn plugin_files_in_dir(dir: &Path) -> Vec<PathBuf> {
984    let Ok(entries) = std::fs::read_dir(dir) else {
985        return Vec::new();
986    };
987    let mut plugin_files: Vec<PathBuf> = entries
988        .filter_map(Result::ok)
989        .map(|e| e.path())
990        .filter(|p| p.is_file() && is_plugin_file(p))
991        .collect();
992    plugin_files.sort();
993    plugin_files
994}
995
996/// Whether the loader reads `path` from one of its default plugin
997/// locations: a `fallow-plugin-*` file, or a file in a `.fallow/plugins/`
998/// directory. A file that the `plugins` config key names can have any name.
999#[must_use]
1000pub fn is_default_external_plugin_file(path: &Path) -> bool {
1001    if !is_plugin_file(path) {
1002        return false;
1003    }
1004    let name_matches = path
1005        .file_name()
1006        .and_then(|name| name.to_str())
1007        .is_some_and(|name| name.starts_with(ROOT_PLUGIN_FILE_PREFIX));
1008    let parent = path.parent();
1009    let in_plugins_dir = parent
1010        .and_then(Path::file_name)
1011        .is_some_and(|name| name == DEFAULT_PLUGINS_DIR_NAME)
1012        && parent
1013            .and_then(Path::parent)
1014            .and_then(Path::file_name)
1015            .is_some_and(|name| name == ".fallow");
1016    name_matches || in_plugins_dir
1017}
1018
1019/// The files that [`discover_external_plugins`] can read for a project, in
1020/// the order it reads them. A `plugins` entry that is not a directory is
1021/// listed also when it does not exist, so that a caller can see it appear.
1022/// The list does not apply the project-root check of the loader, so it can
1023/// hold a file that the loader skips.
1024#[must_use]
1025pub fn external_plugin_source_files(root: &Path, config_plugin_paths: &[String]) -> Vec<PathBuf> {
1026    let mut files = Vec::new();
1027    for path_str in config_plugin_paths {
1028        let path = root.join(path_str);
1029        if path.is_dir() {
1030            files.extend(plugin_files_in_dir(&path));
1031        } else {
1032            files.push(path);
1033        }
1034    }
1035    files.extend(plugin_files_in_dir(
1036        &root.join(".fallow").join(DEFAULT_PLUGINS_DIR_NAME),
1037    ));
1038    files.extend(root_plugin_files(root));
1039    files
1040}
1041
1042/// Check if a path resolves within the canonical root (follows symlinks).
1043#[expect(
1044    clippy::redundant_pub_crate,
1045    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"
1046)]
1047pub(crate) fn is_within_root(path: &Path, canonical_root: &Path) -> bool {
1048    let canonical = dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
1049    canonical.starts_with(canonical_root)
1050}
1051
1052fn load_plugins_from_dir(
1053    dir: &Path,
1054    canonical_root: &Path,
1055    plugins: &mut Vec<ExternalPluginDef>,
1056    seen: &mut rustc_hash::FxHashSet<String>,
1057) {
1058    for path in plugin_files_in_dir(dir) {
1059        load_plugin_file(&path, canonical_root, plugins, seen);
1060    }
1061}
1062
1063fn load_plugin_file(
1064    path: &Path,
1065    canonical_root: &Path,
1066    plugins: &mut Vec<ExternalPluginDef>,
1067    seen: &mut rustc_hash::FxHashSet<String>,
1068) {
1069    if !is_within_root(path, canonical_root) {
1070        tracing::warn!(
1071            "plugin file '{}' resolves outside project root (symlink?), skipping",
1072            path.display()
1073        );
1074        return;
1075    }
1076
1077    let Some(format) = PluginFormat::from_path(path) else {
1078        tracing::warn!(
1079            "unsupported plugin file extension for {}, expected .toml, .json, or .jsonc",
1080            path.display()
1081        );
1082        return;
1083    };
1084
1085    let Some(content) = read_plugin_file(path) else {
1086        return;
1087    };
1088
1089    if let Some(plugin) = parse_plugin(&content, &format, path) {
1090        push_plugin_if_unique(plugin, path, plugins, seen);
1091    }
1092}
1093
1094fn read_plugin_file(path: &Path) -> Option<String> {
1095    match std::fs::read_to_string(path) {
1096        Ok(content) => Some(content),
1097        Err(e) => {
1098            tracing::warn!(
1099                "failed to read external plugin file {}: {e}",
1100                path.display()
1101            );
1102            None
1103        }
1104    }
1105}
1106
1107fn push_plugin_if_unique(
1108    plugin: ExternalPluginDef,
1109    path: &Path,
1110    plugins: &mut Vec<ExternalPluginDef>,
1111    seen: &mut rustc_hash::FxHashSet<String>,
1112) {
1113    if plugin.name.is_empty() {
1114        tracing::warn!(
1115            "external plugin in {} has an empty name, skipping",
1116            path.display()
1117        );
1118        return;
1119    }
1120
1121    if seen.insert(plugin.name.clone()) {
1122        plugins.push(plugin);
1123    } else {
1124        tracing::warn!(
1125            "duplicate external plugin '{}' in {}, skipping",
1126            plugin.name,
1127            path.display()
1128        );
1129    }
1130}
1131
1132#[cfg(test)]
1133mod tests;