Skip to main content

fallow_config/
external_plugin.rs

1use std::path::{Path, PathBuf};
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5
6use crate::config::UsedClassMemberRule;
7
8/// Supported plugin file extensions.
9const PLUGIN_EXTENSIONS: &[&str] = &["toml", "json", "jsonc"];
10
11/// How a plugin's discovered entry points contribute to coverage reachability.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema, Default)]
13#[serde(rename_all = "camelCase")]
14pub enum EntryPointRole {
15    /// Runtime/application roots that should count toward runtime reachability.
16    Runtime,
17    /// Test roots that should count toward test reachability.
18    Test,
19    /// Support/setup/config roots that should keep files alive but not count as runtime/test.
20    #[default]
21    Support,
22}
23
24/// Which export shape a convention auto-import credits when its name is
25/// referenced without an explicit `import` statement.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum AutoImportKind {
28    /// `import { name } from source` (named export, e.g. a Nuxt composable/util).
29    Named,
30    /// `import name from source` (default export).
31    Default,
32    /// SFC default export consumed by a template tag (Nuxt `components/`).
33    DefaultComponent,
34}
35
36/// A single convention-based auto-import: a bare identifier name that resolves
37/// to an export in `source` by framework convention, with no explicit `import`
38/// statement in the consuming file.
39///
40/// Built by `Plugin::auto_imports` from a filesystem scan (e.g. Nuxt scanning
41/// `components/`), and consumed at graph-build time: the resolver matches a
42/// file's captured `auto_import_candidates` against these rules and synthesizes
43/// an edge to `source`. The table is a function of which files exist on disk,
44/// not of any single file's bytes, so it is computed fresh per run and never
45/// cached as part of per-file extraction.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct AutoImportRule {
48    /// Bare identifier name (e.g. `useCounter`, `Card001`, `BaseButton`,
49    /// `LazyCard001`). Component names are canonical PascalCase; the consuming
50    /// scanner normalizes kebab-case tags before matching.
51    pub name: String,
52    /// Absolute path to the source file providing the export.
53    pub source: PathBuf,
54    /// Which export to credit when the name is referenced.
55    pub kind: AutoImportKind,
56}
57
58/// How to detect if a plugin should be activated.
59///
60/// When set on an `ExternalPluginDef`, this takes priority over `enablers`.
61/// Supports dependency checks, file existence checks, and boolean combinators.
62#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
63#[serde(tag = "type", rename_all = "camelCase")]
64pub enum PluginDetection {
65    /// Plugin detected if this package is in dependencies.
66    Dependency { package: String },
67    /// Plugin detected if this file pattern matches.
68    FileExists { pattern: String },
69    /// All conditions must be true.
70    All { conditions: Vec<Self> },
71    /// Any condition must be true.
72    Any { conditions: Vec<Self> },
73}
74
75/// A declarative plugin definition loaded from a standalone file or inline config.
76///
77/// External plugins provide the same static pattern capabilities as built-in
78/// plugins (entry points, always-used files, used exports, tooling dependencies),
79/// but are defined in standalone files or inline in the fallow config rather than
80/// compiled Rust code.
81///
82/// They cannot do AST-based config parsing (`resolve_config()`), but cover the
83/// vast majority of framework integration use cases.
84///
85/// Supports JSONC, JSON, and TOML formats. All use camelCase field names.
86///
87/// ```json
88/// {
89///   "$schema": "https://raw.githubusercontent.com/fallow-rs/fallow/main/plugin-schema.json",
90///   "name": "my-framework",
91///   "enablers": ["my-framework", "@my-framework/core"],
92///   "entryPoints": ["src/routes/**/*.{ts,tsx}"],
93///   "configPatterns": ["my-framework.config.{ts,js}"],
94///   "alwaysUsed": ["src/setup.ts"],
95///   "toolingDependencies": ["my-framework-cli"],
96///   "usedExports": [
97///     { "pattern": "src/routes/**/*.{ts,tsx}", "exports": ["default", "loader", "action"] }
98///   ]
99/// }
100/// ```
101#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
102#[serde(rename_all = "camelCase")]
103pub struct ExternalPluginDef {
104    /// JSON Schema reference (ignored during deserialization).
105    #[serde(rename = "$schema", default, skip_serializing)]
106    #[schemars(skip)]
107    pub schema: Option<String>,
108
109    /// Unique name for this plugin.
110    pub name: String,
111
112    /// Rich detection logic (dependency checks, file existence, boolean combinators).
113    /// Takes priority over `enablers` when set.
114    #[serde(default)]
115    pub detection: Option<PluginDetection>,
116
117    /// Package names that activate this plugin when found in package.json.
118    /// Supports exact matches and prefix patterns (ending with `/`).
119    /// Only used when `detection` is not set.
120    #[serde(default)]
121    pub enablers: Vec<String>,
122
123    /// Glob patterns for entry point files.
124    #[serde(default)]
125    pub entry_points: Vec<String>,
126
127    /// Coverage role for `entryPoints`.
128    ///
129    /// Defaults to `support`. Set to `runtime` for application entry points
130    /// or `test` for test framework entry points.
131    #[serde(default = "default_external_entry_point_role")]
132    pub entry_point_role: EntryPointRole,
133
134    /// Glob patterns for config files (marked as always-used when active).
135    #[serde(default)]
136    pub config_patterns: Vec<String>,
137
138    /// Files that are always considered "used" when this plugin is active.
139    #[serde(default)]
140    pub always_used: Vec<String>,
141
142    /// Dependencies that are tooling (used via CLI/config, not source imports).
143    /// These should not be flagged as unused devDependencies.
144    #[serde(default)]
145    pub tooling_dependencies: Vec<String>,
146
147    /// Exports that are always considered used for matching file patterns.
148    #[serde(default)]
149    pub used_exports: Vec<ExternalUsedExport>,
150
151    /// Class member method/property rules the framework invokes at runtime.
152    /// Supports plain member names for global suppression and scoped objects
153    /// with `extends` / `implements` constraints when the method name is too
154    /// common to suppress across the whole workspace.
155    #[serde(default)]
156    pub used_class_members: Vec<UsedClassMemberRule>,
157}
158
159/// Exports considered used for files matching a pattern.
160#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
161pub struct ExternalUsedExport {
162    /// Glob pattern for files.
163    pub pattern: String,
164    /// Export names always considered used.
165    pub exports: Vec<String>,
166}
167
168fn default_external_entry_point_role() -> EntryPointRole {
169    EntryPointRole::Support
170}
171
172impl ExternalPluginDef {
173    /// Generate JSON Schema for the external plugin format.
174    #[must_use]
175    pub fn json_schema() -> serde_json::Value {
176        serde_json::to_value(schemars::schema_for!(ExternalPluginDef)).unwrap_or_default()
177    }
178
179    /// Validate all user-supplied glob patterns on this plugin definition,
180    /// including patterns nested inside `detection` combinators (`all` / `any`).
181    ///
182    /// Pattern names use the same `framework[].<field>` notation used by
183    /// inline plugin definitions in `FallowConfig::validate_user_globs` so the
184    /// user sees consistent field paths whether the plugin is inline or
185    /// loaded from `.fallow/plugins/` / `fallow-plugin-*.{toml,json,jsonc}`.
186    ///
187    /// # Errors
188    ///
189    /// Returns a non-empty `Vec` of
190    /// [`GlobValidationError`](crate::config::glob_validation::GlobValidationError)
191    /// when any pattern is rejected.
192    pub fn validate_user_globs(
193        &self,
194    ) -> Result<(), Vec<crate::config::glob_validation::GlobValidationError>> {
195        use crate::config::glob_validation::{compile_user_glob, validate_user_globs};
196
197        let mut errors = Vec::new();
198        validate_user_globs(&self.entry_points, "framework[].entryPoints", &mut errors);
199        validate_user_globs(&self.always_used, "framework[].alwaysUsed", &mut errors);
200        validate_user_globs(
201            &self.config_patterns,
202            "framework[].configPatterns",
203            &mut errors,
204        );
205        for used in &self.used_exports {
206            if let Err(e) = compile_user_glob(&used.pattern, "framework[].usedExports[].pattern") {
207                errors.push(e);
208            }
209        }
210        if let Some(detection) = &self.detection {
211            validate_detection_user_globs(detection, "framework[].detection", &mut errors);
212        }
213        if errors.is_empty() {
214            Ok(())
215        } else {
216            Err(errors)
217        }
218    }
219}
220
221/// Recursively validate `FileExists.pattern` fields inside a `PluginDetection`
222/// tree. `All` and `Any` combinators recurse into their nested conditions.
223fn validate_detection_user_globs(
224    detection: &PluginDetection,
225    field: &'static str,
226    errors: &mut Vec<crate::config::glob_validation::GlobValidationError>,
227) {
228    match detection {
229        PluginDetection::Dependency { .. } => {}
230        PluginDetection::FileExists { pattern } => {
231            if let Err(e) = crate::config::glob_validation::compile_user_glob(pattern, field) {
232                errors.push(e);
233            }
234        }
235        PluginDetection::All { conditions } | PluginDetection::Any { conditions } => {
236            for condition in conditions {
237                validate_detection_user_globs(condition, field, errors);
238            }
239        }
240    }
241}
242
243/// Discover external plugin definitions AND validate their user-supplied glob
244/// patterns. Accumulates all errors across all loaded plugins so the user sees
245/// every problem in one run.
246///
247/// Discovery is identical to [`discover_external_plugins`]; this wrapper adds
248/// the per-plugin glob validation step required for security
249/// (see issue #463: `framework[].detection.fileExists.pattern` reaches
250/// `glob::glob` on disk via `root.join(pattern)`, so a `..` segment loaded
251/// from `.fallow/plugins/` would be a real path traversal).
252///
253/// # Errors
254///
255/// Returns the list of validation errors when any discovered plugin contains
256/// a rejected pattern. The CLI surfaces these with exit code 2.
257pub fn discover_and_validate_external_plugins(
258    root: &Path,
259    config_plugin_paths: &[String],
260) -> Result<Vec<ExternalPluginDef>, Vec<crate::config::glob_validation::GlobValidationError>> {
261    let plugins = discover_external_plugins(root, config_plugin_paths);
262    let mut errors = Vec::new();
263    for plugin in &plugins {
264        if let Err(mut plugin_errors) = plugin.validate_user_globs() {
265            errors.append(&mut plugin_errors);
266        }
267    }
268    if errors.is_empty() {
269        Ok(plugins)
270    } else {
271        Err(errors)
272    }
273}
274
275/// Detect plugin format from file extension.
276enum PluginFormat {
277    Toml,
278    Json,
279    Jsonc,
280}
281
282impl PluginFormat {
283    fn from_path(path: &Path) -> Option<Self> {
284        match path.extension().and_then(|e| e.to_str()) {
285            Some("toml") => Some(Self::Toml),
286            Some("json") => Some(Self::Json),
287            Some("jsonc") => Some(Self::Jsonc),
288            _ => None,
289        }
290    }
291}
292
293/// Check if a file has a supported plugin extension.
294fn is_plugin_file(path: &Path) -> bool {
295    path.extension()
296        .and_then(|e| e.to_str())
297        .is_some_and(|ext| PLUGIN_EXTENSIONS.contains(&ext))
298}
299
300/// Parse a plugin definition from file content based on format.
301fn parse_plugin(content: &str, format: &PluginFormat, path: &Path) -> Option<ExternalPluginDef> {
302    match format {
303        PluginFormat::Toml => match toml::from_str::<ExternalPluginDef>(content) {
304            Ok(plugin) => Some(plugin),
305            Err(e) => {
306                tracing::warn!("failed to parse external plugin {}: {e}", path.display());
307                None
308            }
309        },
310        PluginFormat::Json => match serde_json::from_str::<ExternalPluginDef>(content) {
311            Ok(plugin) => Some(plugin),
312            Err(e) => {
313                tracing::warn!("failed to parse external plugin {}: {e}", path.display());
314                None
315            }
316        },
317        PluginFormat::Jsonc => match crate::jsonc::parse_to_value::<ExternalPluginDef>(content) {
318            Ok(plugin) => Some(plugin),
319            Err(e) => {
320                tracing::warn!("failed to parse external plugin {}: {e}", path.display());
321                None
322            }
323        },
324    }
325}
326
327/// Discover and load external plugin definitions for a project.
328///
329/// Discovery order (first occurrence of a plugin name wins):
330/// 1. Paths from the `plugins` config field (files or directories)
331/// 2. `.fallow/plugins/` directory (auto-discover `*.toml`, `*.json`, `*.jsonc` files)
332/// 3. Project root `fallow-plugin-*` files (`.toml`, `.json`, `.jsonc`)
333pub fn discover_external_plugins(
334    root: &Path,
335    config_plugin_paths: &[String],
336) -> Vec<ExternalPluginDef> {
337    let mut plugins = Vec::new();
338    let mut seen_names = rustc_hash::FxHashSet::default();
339
340    let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
341
342    load_configured_plugin_paths(
343        root,
344        config_plugin_paths,
345        &canonical_root,
346        &mut plugins,
347        &mut seen_names,
348    );
349    load_default_plugins_dir(root, &canonical_root, &mut plugins, &mut seen_names);
350    load_root_plugin_files(root, &canonical_root, &mut plugins, &mut seen_names);
351
352    plugins
353}
354
355fn load_configured_plugin_paths(
356    root: &Path,
357    config_plugin_paths: &[String],
358    canonical_root: &Path,
359    plugins: &mut Vec<ExternalPluginDef>,
360    seen_names: &mut rustc_hash::FxHashSet<String>,
361) {
362    for path_str in config_plugin_paths {
363        let path = root.join(path_str);
364        if !is_within_root(&path, canonical_root) {
365            tracing::warn!("plugin path '{path_str}' resolves outside project root, skipping");
366            continue;
367        }
368        if path.is_dir() {
369            load_plugins_from_dir(&path, canonical_root, plugins, seen_names);
370        } else if path.is_file() {
371            load_plugin_file(&path, canonical_root, plugins, seen_names);
372        }
373    }
374}
375
376fn load_default_plugins_dir(
377    root: &Path,
378    canonical_root: &Path,
379    plugins: &mut Vec<ExternalPluginDef>,
380    seen_names: &mut rustc_hash::FxHashSet<String>,
381) {
382    let plugins_dir = root.join(".fallow").join("plugins");
383    if plugins_dir.is_dir() && is_within_root(&plugins_dir, canonical_root) {
384        load_plugins_from_dir(&plugins_dir, canonical_root, plugins, seen_names);
385    }
386}
387
388fn load_root_plugin_files(
389    root: &Path,
390    canonical_root: &Path,
391    plugins: &mut Vec<ExternalPluginDef>,
392    seen_names: &mut rustc_hash::FxHashSet<String>,
393) {
394    if let Ok(entries) = std::fs::read_dir(root) {
395        let mut plugin_files: Vec<PathBuf> = entries
396            .filter_map(Result::ok)
397            .map(|e| e.path())
398            .filter(|p| {
399                p.is_file()
400                    && p.file_name().and_then(|n| n.to_str()).is_some_and(|n| {
401                        n.starts_with("fallow-plugin-") && is_plugin_file(Path::new(n))
402                    })
403            })
404            .collect();
405        plugin_files.sort();
406        for path in plugin_files {
407            load_plugin_file(&path, canonical_root, plugins, seen_names);
408        }
409    }
410}
411
412/// Check if a path resolves within the canonical root (follows symlinks).
413#[expect(
414    clippy::redundant_pub_crate,
415    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"
416)]
417pub(crate) fn is_within_root(path: &Path, canonical_root: &Path) -> bool {
418    let canonical = dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
419    canonical.starts_with(canonical_root)
420}
421
422fn load_plugins_from_dir(
423    dir: &Path,
424    canonical_root: &Path,
425    plugins: &mut Vec<ExternalPluginDef>,
426    seen: &mut rustc_hash::FxHashSet<String>,
427) {
428    if let Ok(entries) = std::fs::read_dir(dir) {
429        let mut plugin_files: Vec<PathBuf> = entries
430            .filter_map(Result::ok)
431            .map(|e| e.path())
432            .filter(|p| p.is_file() && is_plugin_file(p))
433            .collect();
434        plugin_files.sort();
435        for path in plugin_files {
436            load_plugin_file(&path, canonical_root, plugins, seen);
437        }
438    }
439}
440
441fn load_plugin_file(
442    path: &Path,
443    canonical_root: &Path,
444    plugins: &mut Vec<ExternalPluginDef>,
445    seen: &mut rustc_hash::FxHashSet<String>,
446) {
447    if !is_within_root(path, canonical_root) {
448        tracing::warn!(
449            "plugin file '{}' resolves outside project root (symlink?), skipping",
450            path.display()
451        );
452        return;
453    }
454
455    let Some(format) = PluginFormat::from_path(path) else {
456        tracing::warn!(
457            "unsupported plugin file extension for {}, expected .toml, .json, or .jsonc",
458            path.display()
459        );
460        return;
461    };
462
463    let Some(content) = read_plugin_file(path) else {
464        return;
465    };
466
467    if let Some(plugin) = parse_plugin(&content, &format, path) {
468        push_plugin_if_unique(plugin, path, plugins, seen);
469    }
470}
471
472fn read_plugin_file(path: &Path) -> Option<String> {
473    match std::fs::read_to_string(path) {
474        Ok(content) => Some(content),
475        Err(e) => {
476            tracing::warn!(
477                "failed to read external plugin file {}: {e}",
478                path.display()
479            );
480            None
481        }
482    }
483}
484
485fn push_plugin_if_unique(
486    plugin: ExternalPluginDef,
487    path: &Path,
488    plugins: &mut Vec<ExternalPluginDef>,
489    seen: &mut rustc_hash::FxHashSet<String>,
490) {
491    if plugin.name.is_empty() {
492        tracing::warn!(
493            "external plugin in {} has an empty name, skipping",
494            path.display()
495        );
496        return;
497    }
498
499    if seen.insert(plugin.name.clone()) {
500        plugins.push(plugin);
501    } else {
502        tracing::warn!(
503            "duplicate external plugin '{}' in {}, skipping",
504            plugin.name,
505            path.display()
506        );
507    }
508}
509
510#[cfg(test)]
511mod tests {
512    use super::*;
513    use crate::ScopedUsedClassMemberRule;
514
515    #[test]
516    fn deserialize_minimal_plugin() {
517        let toml_str = r#"
518name = "my-plugin"
519enablers = ["my-pkg"]
520"#;
521        let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
522        assert_eq!(plugin.name, "my-plugin");
523        assert_eq!(plugin.enablers, vec!["my-pkg"]);
524        assert!(plugin.entry_points.is_empty());
525        assert!(plugin.always_used.is_empty());
526        assert!(plugin.config_patterns.is_empty());
527        assert!(plugin.tooling_dependencies.is_empty());
528        assert!(plugin.used_exports.is_empty());
529        assert!(plugin.used_class_members.is_empty());
530    }
531
532    #[test]
533    fn deserialize_plugin_with_used_class_members_json() {
534        let json_str = r#"{
535            "name": "ag-grid",
536            "enablers": ["ag-grid-angular"],
537            "usedClassMembers": ["agInit", "refresh"]
538        }"#;
539        let plugin: ExternalPluginDef = serde_json::from_str(json_str).unwrap();
540        assert_eq!(plugin.name, "ag-grid");
541        assert_eq!(
542            plugin.used_class_members,
543            vec![
544                UsedClassMemberRule::from("agInit"),
545                UsedClassMemberRule::from("refresh"),
546            ]
547        );
548    }
549
550    #[test]
551    fn deserialize_plugin_with_scoped_used_class_members_json() {
552        let json_str = r#"{
553            "name": "ag-grid",
554            "enablers": ["ag-grid-angular"],
555            "usedClassMembers": [
556                "agInit",
557                { "implements": "ICellRendererAngularComp", "members": ["refresh"] },
558                { "extends": "BaseCommand", "members": ["execute"] }
559            ]
560        }"#;
561        let plugin: ExternalPluginDef = serde_json::from_str(json_str).unwrap();
562        assert_eq!(
563            plugin.used_class_members,
564            vec![
565                UsedClassMemberRule::from("agInit"),
566                UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
567                    extends: None,
568                    implements: Some("ICellRendererAngularComp".to_string()),
569                    members: vec!["refresh".to_string()],
570                }),
571                UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
572                    extends: Some("BaseCommand".to_string()),
573                    implements: None,
574                    members: vec!["execute".to_string()],
575                }),
576            ]
577        );
578    }
579
580    #[test]
581    fn deserialize_plugin_with_used_class_members_toml() {
582        let toml_str = r#"
583name = "ag-grid"
584enablers = ["ag-grid-angular"]
585usedClassMembers = ["agInit", "refresh"]
586"#;
587        let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
588        assert_eq!(
589            plugin.used_class_members,
590            vec![
591                UsedClassMemberRule::from("agInit"),
592                UsedClassMemberRule::from("refresh"),
593            ]
594        );
595    }
596
597    #[test]
598    fn deserialize_plugin_with_scoped_used_class_members_toml() {
599        let toml_str = r#"
600name = "ag-grid"
601enablers = ["ag-grid-angular"]
602usedClassMembers = [
603  { implements = "ICellRendererAngularComp", members = ["refresh"] },
604  { extends = "BaseCommand", members = ["execute"] }
605]
606"#;
607        let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
608        assert_eq!(
609            plugin.used_class_members,
610            vec![
611                UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
612                    extends: None,
613                    implements: Some("ICellRendererAngularComp".to_string()),
614                    members: vec!["refresh".to_string()],
615                }),
616                UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
617                    extends: Some("BaseCommand".to_string()),
618                    implements: None,
619                    members: vec!["execute".to_string()],
620                }),
621            ]
622        );
623    }
624
625    #[test]
626    fn deserialize_plugin_rejects_unconstrained_scoped_used_class_members() {
627        let result = serde_json::from_str::<ExternalPluginDef>(
628            r#"{
629                "name": "ag-grid",
630                "enablers": ["ag-grid-angular"],
631                "usedClassMembers": [{ "members": ["refresh"] }]
632            }"#,
633        );
634        assert!(
635            result.is_err(),
636            "unconstrained scoped rule should be rejected"
637        );
638    }
639
640    #[test]
641    fn deserialize_full_plugin() {
642        let toml_str = r#"
643name = "my-framework"
644enablers = ["my-framework", "@my-framework/core"]
645entryPoints = ["src/routes/**/*.{ts,tsx}", "src/middleware.ts"]
646configPatterns = ["my-framework.config.{ts,js,mjs}"]
647alwaysUsed = ["src/setup.ts", "public/**/*"]
648toolingDependencies = ["my-framework-cli"]
649
650[[usedExports]]
651pattern = "src/routes/**/*.{ts,tsx}"
652exports = ["default", "loader", "action"]
653
654[[usedExports]]
655pattern = "src/middleware.ts"
656exports = ["default"]
657"#;
658        let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
659        assert_eq!(plugin.name, "my-framework");
660        assert_eq!(plugin.enablers.len(), 2);
661        assert_eq!(plugin.entry_points.len(), 2);
662        assert_eq!(
663            plugin.config_patterns,
664            vec!["my-framework.config.{ts,js,mjs}"]
665        );
666        assert_eq!(plugin.always_used.len(), 2);
667        assert_eq!(plugin.tooling_dependencies, vec!["my-framework-cli"]);
668        assert_eq!(plugin.used_exports.len(), 2);
669        assert_eq!(plugin.used_exports[0].pattern, "src/routes/**/*.{ts,tsx}");
670        assert_eq!(
671            plugin.used_exports[0].exports,
672            vec!["default", "loader", "action"]
673        );
674    }
675
676    #[test]
677    fn deserialize_json_plugin() {
678        let json_str = r#"{
679            "name": "my-json-plugin",
680            "enablers": ["my-pkg"],
681            "entryPoints": ["src/**/*.ts"],
682            "configPatterns": ["my-plugin.config.js"],
683            "alwaysUsed": ["src/setup.ts"],
684            "toolingDependencies": ["my-cli"],
685            "usedExports": [
686                { "pattern": "src/**/*.ts", "exports": ["default"] }
687            ]
688        }"#;
689        let plugin: ExternalPluginDef = serde_json::from_str(json_str).unwrap();
690        assert_eq!(plugin.name, "my-json-plugin");
691        assert_eq!(plugin.enablers, vec!["my-pkg"]);
692        assert_eq!(plugin.entry_points, vec!["src/**/*.ts"]);
693        assert_eq!(plugin.config_patterns, vec!["my-plugin.config.js"]);
694        assert_eq!(plugin.always_used, vec!["src/setup.ts"]);
695        assert_eq!(plugin.tooling_dependencies, vec!["my-cli"]);
696        assert_eq!(plugin.used_exports.len(), 1);
697        assert_eq!(plugin.used_exports[0].exports, vec!["default"]);
698    }
699
700    #[test]
701    fn deserialize_jsonc_plugin() {
702        let jsonc_str = r#"{
703            "name": "my-jsonc-plugin",
704            "enablers": ["my-pkg"],
705            /* Block comment */
706            "entryPoints": ["src/**/*.ts"]
707        }"#;
708        let plugin: ExternalPluginDef = crate::jsonc::parse_to_value(jsonc_str).unwrap();
709        assert_eq!(plugin.name, "my-jsonc-plugin");
710        assert_eq!(plugin.enablers, vec!["my-pkg"]);
711        assert_eq!(plugin.entry_points, vec!["src/**/*.ts"]);
712    }
713
714    #[test]
715    fn deserialize_json_with_schema_field() {
716        let json_str = r#"{
717            "$schema": "https://fallow.dev/plugin-schema.json",
718            "name": "schema-plugin",
719            "enablers": ["my-pkg"]
720        }"#;
721        let plugin: ExternalPluginDef = serde_json::from_str(json_str).unwrap();
722        assert_eq!(plugin.name, "schema-plugin");
723        assert_eq!(plugin.enablers, vec!["my-pkg"]);
724    }
725
726    #[test]
727    fn plugin_json_schema_generation() {
728        let schema = ExternalPluginDef::json_schema();
729        assert!(schema.is_object());
730        let obj = schema.as_object().unwrap();
731        assert!(obj.contains_key("properties"));
732    }
733
734    #[test]
735    fn discover_plugins_from_fallow_plugins_dir() {
736        let dir =
737            std::env::temp_dir().join(format!("fallow-test-ext-plugins-{}", std::process::id()));
738        let plugins_dir = dir.join(".fallow").join("plugins");
739        let _ = std::fs::create_dir_all(&plugins_dir);
740
741        std::fs::write(
742            plugins_dir.join("my-plugin.toml"),
743            r#"
744name = "my-plugin"
745enablers = ["my-pkg"]
746entryPoints = ["src/**/*.ts"]
747"#,
748        )
749        .unwrap();
750
751        let plugins = discover_external_plugins(&dir, &[]);
752        assert_eq!(plugins.len(), 1);
753        assert_eq!(plugins[0].name, "my-plugin");
754
755        let _ = std::fs::remove_dir_all(&dir);
756    }
757
758    #[test]
759    fn discover_json_plugins_from_fallow_plugins_dir() {
760        let dir = std::env::temp_dir().join(format!(
761            "fallow-test-ext-json-plugins-{}",
762            std::process::id()
763        ));
764        let plugins_dir = dir.join(".fallow").join("plugins");
765        let _ = std::fs::create_dir_all(&plugins_dir);
766
767        std::fs::write(
768            plugins_dir.join("my-plugin.json"),
769            r#"{"name": "json-plugin", "enablers": ["json-pkg"]}"#,
770        )
771        .unwrap();
772
773        std::fs::write(
774            plugins_dir.join("my-plugin.jsonc"),
775            r#"{
776                "name": "jsonc-plugin",
777                "enablers": ["jsonc-pkg"]
778            }"#,
779        )
780        .unwrap();
781
782        let plugins = discover_external_plugins(&dir, &[]);
783        assert_eq!(plugins.len(), 2);
784        assert_eq!(plugins[0].name, "json-plugin");
785        assert_eq!(plugins[1].name, "jsonc-plugin");
786
787        let _ = std::fs::remove_dir_all(&dir);
788    }
789
790    #[test]
791    fn discover_fallow_plugin_files_in_root() {
792        let dir =
793            std::env::temp_dir().join(format!("fallow-test-root-plugins-{}", std::process::id()));
794        let _ = std::fs::create_dir_all(&dir);
795
796        std::fs::write(
797            dir.join("fallow-plugin-custom.toml"),
798            r#"
799name = "custom"
800enablers = ["custom-pkg"]
801"#,
802        )
803        .unwrap();
804
805        std::fs::write(dir.join("some-other-file.toml"), r#"name = "ignored""#).unwrap();
806
807        let plugins = discover_external_plugins(&dir, &[]);
808        assert_eq!(plugins.len(), 1);
809        assert_eq!(plugins[0].name, "custom");
810
811        let _ = std::fs::remove_dir_all(&dir);
812    }
813
814    #[test]
815    fn discover_fallow_plugin_json_files_in_root() {
816        let dir = std::env::temp_dir().join(format!(
817            "fallow-test-root-json-plugins-{}",
818            std::process::id()
819        ));
820        let _ = std::fs::create_dir_all(&dir);
821
822        std::fs::write(
823            dir.join("fallow-plugin-custom.json"),
824            r#"{"name": "json-root", "enablers": ["json-pkg"]}"#,
825        )
826        .unwrap();
827
828        std::fs::write(
829            dir.join("fallow-plugin-custom2.jsonc"),
830            r#"{
831                "name": "jsonc-root",
832                "enablers": ["jsonc-pkg"]
833            }"#,
834        )
835        .unwrap();
836
837        std::fs::write(
838            dir.join("fallow-plugin-bad.yaml"),
839            "name: ignored\nenablers:\n  - pkg\n",
840        )
841        .unwrap();
842
843        let plugins = discover_external_plugins(&dir, &[]);
844        assert_eq!(plugins.len(), 2);
845
846        let _ = std::fs::remove_dir_all(&dir);
847    }
848
849    #[test]
850    fn discover_mixed_formats_in_dir() {
851        let dir =
852            std::env::temp_dir().join(format!("fallow-test-mixed-plugins-{}", std::process::id()));
853        let plugins_dir = dir.join(".fallow").join("plugins");
854        let _ = std::fs::create_dir_all(&plugins_dir);
855
856        std::fs::write(
857            plugins_dir.join("a-plugin.toml"),
858            r#"
859name = "toml-plugin"
860enablers = ["toml-pkg"]
861"#,
862        )
863        .unwrap();
864
865        std::fs::write(
866            plugins_dir.join("b-plugin.json"),
867            r#"{"name": "json-plugin", "enablers": ["json-pkg"]}"#,
868        )
869        .unwrap();
870
871        std::fs::write(
872            plugins_dir.join("c-plugin.jsonc"),
873            r#"{
874                "name": "jsonc-plugin",
875                "enablers": ["jsonc-pkg"]
876            }"#,
877        )
878        .unwrap();
879
880        let plugins = discover_external_plugins(&dir, &[]);
881        assert_eq!(plugins.len(), 3);
882        assert_eq!(plugins[0].name, "toml-plugin");
883        assert_eq!(plugins[1].name, "json-plugin");
884        assert_eq!(plugins[2].name, "jsonc-plugin");
885
886        let _ = std::fs::remove_dir_all(&dir);
887    }
888
889    #[test]
890    fn deduplicates_by_name() {
891        let dir =
892            std::env::temp_dir().join(format!("fallow-test-dedup-plugins-{}", std::process::id()));
893        let plugins_dir = dir.join(".fallow").join("plugins");
894        let _ = std::fs::create_dir_all(&plugins_dir);
895
896        std::fs::write(
897            plugins_dir.join("my-plugin.toml"),
898            r#"
899name = "my-plugin"
900enablers = ["pkg-a"]
901"#,
902        )
903        .unwrap();
904
905        std::fs::write(
906            dir.join("fallow-plugin-my-plugin.toml"),
907            r#"
908name = "my-plugin"
909enablers = ["pkg-b"]
910"#,
911        )
912        .unwrap();
913
914        let plugins = discover_external_plugins(&dir, &[]);
915        assert_eq!(plugins.len(), 1);
916        assert_eq!(plugins[0].enablers, vec!["pkg-a"]);
917
918        let _ = std::fs::remove_dir_all(&dir);
919    }
920
921    #[test]
922    fn config_plugin_paths_take_priority() {
923        let dir =
924            std::env::temp_dir().join(format!("fallow-test-config-paths-{}", std::process::id()));
925        let custom_dir = dir.join("custom-plugins");
926        let _ = std::fs::create_dir_all(&custom_dir);
927
928        std::fs::write(
929            custom_dir.join("explicit.toml"),
930            r#"
931name = "explicit"
932enablers = ["explicit-pkg"]
933"#,
934        )
935        .unwrap();
936
937        let plugins = discover_external_plugins(&dir, &["custom-plugins".to_string()]);
938        assert_eq!(plugins.len(), 1);
939        assert_eq!(plugins[0].name, "explicit");
940
941        let _ = std::fs::remove_dir_all(&dir);
942    }
943
944    #[test]
945    fn config_plugin_path_to_single_file() {
946        let dir =
947            std::env::temp_dir().join(format!("fallow-test-single-file-{}", std::process::id()));
948        let _ = std::fs::create_dir_all(&dir);
949
950        std::fs::write(
951            dir.join("my-plugin.toml"),
952            r#"
953name = "single-file"
954enablers = ["single-pkg"]
955"#,
956        )
957        .unwrap();
958
959        let plugins = discover_external_plugins(&dir, &["my-plugin.toml".to_string()]);
960        assert_eq!(plugins.len(), 1);
961        assert_eq!(plugins[0].name, "single-file");
962
963        let _ = std::fs::remove_dir_all(&dir);
964    }
965
966    #[test]
967    fn config_plugin_path_to_single_json_file() {
968        let dir = std::env::temp_dir().join(format!(
969            "fallow-test-single-json-file-{}",
970            std::process::id()
971        ));
972        let _ = std::fs::create_dir_all(&dir);
973
974        std::fs::write(
975            dir.join("my-plugin.json"),
976            r#"{"name": "json-single", "enablers": ["json-pkg"]}"#,
977        )
978        .unwrap();
979
980        let plugins = discover_external_plugins(&dir, &["my-plugin.json".to_string()]);
981        assert_eq!(plugins.len(), 1);
982        assert_eq!(plugins[0].name, "json-single");
983
984        let _ = std::fs::remove_dir_all(&dir);
985    }
986
987    #[test]
988    fn skips_invalid_toml() {
989        let dir =
990            std::env::temp_dir().join(format!("fallow-test-invalid-plugin-{}", std::process::id()));
991        let plugins_dir = dir.join(".fallow").join("plugins");
992        let _ = std::fs::create_dir_all(&plugins_dir);
993
994        std::fs::write(plugins_dir.join("bad.toml"), r#"enablers = ["pkg"]"#).unwrap();
995
996        std::fs::write(
997            plugins_dir.join("good.toml"),
998            r#"
999name = "good"
1000enablers = ["good-pkg"]
1001"#,
1002        )
1003        .unwrap();
1004
1005        let plugins = discover_external_plugins(&dir, &[]);
1006        assert_eq!(plugins.len(), 1);
1007        assert_eq!(plugins[0].name, "good");
1008
1009        let _ = std::fs::remove_dir_all(&dir);
1010    }
1011
1012    #[test]
1013    fn skips_invalid_json() {
1014        let dir = std::env::temp_dir().join(format!(
1015            "fallow-test-invalid-json-plugin-{}",
1016            std::process::id()
1017        ));
1018        let plugins_dir = dir.join(".fallow").join("plugins");
1019        let _ = std::fs::create_dir_all(&plugins_dir);
1020
1021        std::fs::write(plugins_dir.join("bad.json"), r#"{"enablers": ["pkg"]}"#).unwrap();
1022
1023        std::fs::write(
1024            plugins_dir.join("good.json"),
1025            r#"{"name": "good-json", "enablers": ["good-pkg"]}"#,
1026        )
1027        .unwrap();
1028
1029        let plugins = discover_external_plugins(&dir, &[]);
1030        assert_eq!(plugins.len(), 1);
1031        assert_eq!(plugins[0].name, "good-json");
1032
1033        let _ = std::fs::remove_dir_all(&dir);
1034    }
1035
1036    #[test]
1037    fn prefix_enablers() {
1038        let toml_str = r#"
1039name = "scoped"
1040enablers = ["@myorg/"]
1041"#;
1042        let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
1043        assert_eq!(plugin.enablers, vec!["@myorg/"]);
1044    }
1045
1046    #[test]
1047    fn skips_empty_name() {
1048        let dir =
1049            std::env::temp_dir().join(format!("fallow-test-empty-name-{}", std::process::id()));
1050        let plugins_dir = dir.join(".fallow").join("plugins");
1051        let _ = std::fs::create_dir_all(&plugins_dir);
1052
1053        std::fs::write(
1054            plugins_dir.join("empty.toml"),
1055            r#"
1056name = ""
1057enablers = ["pkg"]
1058"#,
1059        )
1060        .unwrap();
1061
1062        let plugins = discover_external_plugins(&dir, &[]);
1063        assert!(plugins.is_empty(), "empty-name plugin should be skipped");
1064
1065        let _ = std::fs::remove_dir_all(&dir);
1066    }
1067
1068    #[test]
1069    fn rejects_paths_outside_root() {
1070        let dir =
1071            std::env::temp_dir().join(format!("fallow-test-path-escape-{}", std::process::id()));
1072        let _ = std::fs::create_dir_all(&dir);
1073
1074        let plugins = discover_external_plugins(&dir, &["../../../etc".to_string()]);
1075        assert!(plugins.is_empty(), "paths outside root should be rejected");
1076
1077        let _ = std::fs::remove_dir_all(&dir);
1078    }
1079
1080    #[test]
1081    fn plugin_format_detection() {
1082        assert!(matches!(
1083            PluginFormat::from_path(Path::new("plugin.toml")),
1084            Some(PluginFormat::Toml)
1085        ));
1086        assert!(matches!(
1087            PluginFormat::from_path(Path::new("plugin.json")),
1088            Some(PluginFormat::Json)
1089        ));
1090        assert!(matches!(
1091            PluginFormat::from_path(Path::new("plugin.jsonc")),
1092            Some(PluginFormat::Jsonc)
1093        ));
1094        assert!(PluginFormat::from_path(Path::new("plugin.yaml")).is_none());
1095        assert!(PluginFormat::from_path(Path::new("plugin")).is_none());
1096    }
1097
1098    #[test]
1099    fn is_plugin_file_checks_extensions() {
1100        assert!(is_plugin_file(Path::new("plugin.toml")));
1101        assert!(is_plugin_file(Path::new("plugin.json")));
1102        assert!(is_plugin_file(Path::new("plugin.jsonc")));
1103        assert!(!is_plugin_file(Path::new("plugin.yaml")));
1104        assert!(!is_plugin_file(Path::new("plugin.txt")));
1105        assert!(!is_plugin_file(Path::new("plugin")));
1106    }
1107
1108    #[test]
1109    fn detection_deserialize_dependency() {
1110        let json = r#"{"type": "dependency", "package": "next"}"#;
1111        let detection: PluginDetection = serde_json::from_str(json).unwrap();
1112        assert!(matches!(detection, PluginDetection::Dependency { package } if package == "next"));
1113    }
1114
1115    #[test]
1116    fn detection_deserialize_file_exists() {
1117        let json = r#"{"type": "fileExists", "pattern": "tsconfig.json"}"#;
1118        let detection: PluginDetection = serde_json::from_str(json).unwrap();
1119        assert!(
1120            matches!(detection, PluginDetection::FileExists { pattern } if pattern == "tsconfig.json")
1121        );
1122    }
1123
1124    #[test]
1125    fn detection_deserialize_all() {
1126        let json = r#"{"type": "all", "conditions": [{"type": "dependency", "package": "a"}, {"type": "dependency", "package": "b"}]}"#;
1127        let detection: PluginDetection = serde_json::from_str(json).unwrap();
1128        assert!(matches!(detection, PluginDetection::All { conditions } if conditions.len() == 2));
1129    }
1130
1131    #[test]
1132    fn detection_deserialize_any() {
1133        let json = r#"{"type": "any", "conditions": [{"type": "dependency", "package": "a"}]}"#;
1134        let detection: PluginDetection = serde_json::from_str(json).unwrap();
1135        assert!(matches!(detection, PluginDetection::Any { conditions } if conditions.len() == 1));
1136    }
1137
1138    #[test]
1139    fn plugin_with_detection_field() {
1140        let json = r#"{
1141            "name": "my-plugin",
1142            "detection": {"type": "dependency", "package": "my-pkg"},
1143            "entryPoints": ["src/**/*.ts"]
1144        }"#;
1145        let plugin: ExternalPluginDef = serde_json::from_str(json).unwrap();
1146        assert_eq!(plugin.name, "my-plugin");
1147        assert!(plugin.detection.is_some());
1148        assert!(plugin.enablers.is_empty());
1149        assert_eq!(plugin.entry_points, vec!["src/**/*.ts"]);
1150    }
1151
1152    #[test]
1153    fn plugin_without_detection_uses_enablers() {
1154        let json = r#"{
1155            "name": "my-plugin",
1156            "enablers": ["my-pkg"]
1157        }"#;
1158        let plugin: ExternalPluginDef = serde_json::from_str(json).unwrap();
1159        assert!(plugin.detection.is_none());
1160        assert_eq!(plugin.enablers, vec!["my-pkg"]);
1161    }
1162
1163    #[test]
1164    fn detection_nested_all_with_any() {
1165        let json = r#"{
1166            "type": "all",
1167            "conditions": [
1168                {"type": "dependency", "package": "react"},
1169                {"type": "any", "conditions": [
1170                    {"type": "fileExists", "pattern": "next.config.js"},
1171                    {"type": "fileExists", "pattern": "next.config.mjs"}
1172                ]}
1173            ]
1174        }"#;
1175        let detection: PluginDetection = serde_json::from_str(json).unwrap();
1176        match detection {
1177            PluginDetection::All { conditions } => {
1178                assert_eq!(conditions.len(), 2);
1179                assert!(matches!(
1180                    &conditions[0],
1181                    PluginDetection::Dependency { package } if package == "react"
1182                ));
1183                match &conditions[1] {
1184                    PluginDetection::Any { conditions: inner } => {
1185                        assert_eq!(inner.len(), 2);
1186                    }
1187                    other => panic!("expected Any, got: {other:?}"),
1188                }
1189            }
1190            other => panic!("expected All, got: {other:?}"),
1191        }
1192    }
1193
1194    #[test]
1195    fn detection_empty_all_conditions() {
1196        let json = r#"{"type": "all", "conditions": []}"#;
1197        let detection: PluginDetection = serde_json::from_str(json).unwrap();
1198        assert!(matches!(
1199            detection,
1200            PluginDetection::All { conditions } if conditions.is_empty()
1201        ));
1202    }
1203
1204    #[test]
1205    fn detection_empty_any_conditions() {
1206        let json = r#"{"type": "any", "conditions": []}"#;
1207        let detection: PluginDetection = serde_json::from_str(json).unwrap();
1208        assert!(matches!(
1209            detection,
1210            PluginDetection::Any { conditions } if conditions.is_empty()
1211        ));
1212    }
1213
1214    #[test]
1215    fn detection_toml_dependency() {
1216        let toml_str = r#"
1217name = "my-plugin"
1218
1219[detection]
1220type = "dependency"
1221package = "next"
1222"#;
1223        let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
1224        assert!(plugin.detection.is_some());
1225        assert!(matches!(
1226            plugin.detection.unwrap(),
1227            PluginDetection::Dependency { package } if package == "next"
1228        ));
1229    }
1230
1231    #[test]
1232    fn detection_toml_file_exists() {
1233        let toml_str = r#"
1234name = "my-plugin"
1235
1236[detection]
1237type = "fileExists"
1238pattern = "next.config.js"
1239"#;
1240        let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
1241        assert!(matches!(
1242            plugin.detection.unwrap(),
1243            PluginDetection::FileExists { pattern } if pattern == "next.config.js"
1244        ));
1245    }
1246
1247    #[test]
1248    fn plugin_all_fields_json() {
1249        let json = r#"{
1250            "$schema": "https://fallow.dev/plugin-schema.json",
1251            "name": "full-plugin",
1252            "detection": {"type": "dependency", "package": "my-pkg"},
1253            "enablers": ["fallback-enabler"],
1254            "entryPoints": ["src/entry.ts"],
1255            "configPatterns": ["config.js"],
1256            "alwaysUsed": ["src/polyfills.ts"],
1257            "toolingDependencies": ["my-cli"],
1258            "usedExports": [{"pattern": "src/**", "exports": ["default", "setup"]}]
1259        }"#;
1260        let plugin: ExternalPluginDef = serde_json::from_str(json).unwrap();
1261        assert_eq!(plugin.name, "full-plugin");
1262        assert!(plugin.detection.is_some());
1263        assert_eq!(plugin.enablers, vec!["fallback-enabler"]);
1264        assert_eq!(plugin.entry_points, vec!["src/entry.ts"]);
1265        assert_eq!(plugin.config_patterns, vec!["config.js"]);
1266        assert_eq!(plugin.always_used, vec!["src/polyfills.ts"]);
1267        assert_eq!(plugin.tooling_dependencies, vec!["my-cli"]);
1268        assert_eq!(plugin.used_exports.len(), 1);
1269        assert_eq!(plugin.used_exports[0].pattern, "src/**");
1270        assert_eq!(plugin.used_exports[0].exports, vec!["default", "setup"]);
1271    }
1272
1273    #[test]
1274    fn plugin_with_special_chars_in_name() {
1275        let json = r#"{"name": "@scope/my-plugin-v2.0", "enablers": ["pkg"]}"#;
1276        let plugin: ExternalPluginDef = serde_json::from_str(json).unwrap();
1277        assert_eq!(plugin.name, "@scope/my-plugin-v2.0");
1278    }
1279
1280    #[test]
1281    fn parse_plugin_toml_format() {
1282        let content = r#"
1283name = "test-plugin"
1284enablers = ["test-pkg"]
1285entryPoints = ["src/**/*.ts"]
1286"#;
1287        let result = parse_plugin(content, &PluginFormat::Toml, Path::new("test.toml"));
1288        assert!(result.is_some());
1289        let plugin = result.unwrap();
1290        assert_eq!(plugin.name, "test-plugin");
1291    }
1292
1293    #[test]
1294    fn parse_plugin_json_format() {
1295        let content = r#"{"name": "json-test", "enablers": ["pkg"]}"#;
1296        let result = parse_plugin(content, &PluginFormat::Json, Path::new("test.json"));
1297        assert!(result.is_some());
1298        assert_eq!(result.unwrap().name, "json-test");
1299    }
1300
1301    #[test]
1302    fn parse_plugin_jsonc_format() {
1303        let content = r#"{
1304            "name": "jsonc-test",
1305            "enablers": ["pkg"]
1306        }"#;
1307        let result = parse_plugin(content, &PluginFormat::Jsonc, Path::new("test.jsonc"));
1308        assert!(result.is_some());
1309        assert_eq!(result.unwrap().name, "jsonc-test");
1310    }
1311
1312    #[test]
1313    fn parse_plugin_invalid_toml_returns_none() {
1314        let content = "not valid toml [[[";
1315        let result = parse_plugin(content, &PluginFormat::Toml, Path::new("bad.toml"));
1316        assert!(result.is_none());
1317    }
1318
1319    #[test]
1320    fn parse_plugin_invalid_json_returns_none() {
1321        let content = "{ not valid json }";
1322        let result = parse_plugin(content, &PluginFormat::Json, Path::new("bad.json"));
1323        assert!(result.is_none());
1324    }
1325
1326    #[test]
1327    fn parse_plugin_invalid_jsonc_returns_none() {
1328        let content = r#"{"enablers": ["pkg"]}"#;
1329        let result = parse_plugin(content, &PluginFormat::Jsonc, Path::new("bad.jsonc"));
1330        assert!(result.is_none());
1331    }
1332}