1use std::path::{Path, PathBuf};
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5
6use crate::config::UsedClassMemberRule;
7
8const PLUGIN_EXTENSIONS: &[&str] = &["toml", "json", "jsonc"];
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema, Default)]
13#[serde(rename_all = "camelCase")]
14pub enum EntryPointRole {
15 Runtime,
17 Test,
19 #[default]
21 Support,
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum AutoImportKind {
28 Named,
30 Default,
32 DefaultComponent,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct AutoImportRule {
48 pub name: String,
52 pub source: PathBuf,
54 pub kind: AutoImportKind,
56}
57
58#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
63#[serde(tag = "type", rename_all = "camelCase")]
64pub enum PluginDetection {
65 Dependency { package: String },
67 FileExists { pattern: String },
69 All { conditions: Vec<Self> },
71 Any { conditions: Vec<Self> },
73}
74
75#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
102#[serde(rename_all = "camelCase")]
103pub struct ExternalPluginDef {
104 #[serde(rename = "$schema", default, skip_serializing)]
106 #[schemars(skip)]
107 pub schema: Option<String>,
108
109 pub name: String,
111
112 #[serde(default)]
115 pub detection: Option<PluginDetection>,
116
117 #[serde(default)]
121 pub enablers: Vec<String>,
122
123 #[serde(default)]
125 pub entry_points: Vec<String>,
126
127 #[serde(default = "default_external_entry_point_role")]
132 pub entry_point_role: EntryPointRole,
133
134 #[serde(default)]
136 pub config_patterns: Vec<String>,
137
138 #[serde(default)]
140 pub always_used: Vec<String>,
141
142 #[serde(default)]
145 pub tooling_dependencies: Vec<String>,
146
147 #[serde(default)]
149 pub used_exports: Vec<ExternalUsedExport>,
150
151 #[serde(default)]
156 pub used_class_members: Vec<UsedClassMemberRule>,
157}
158
159#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
161pub struct ExternalUsedExport {
162 pub pattern: String,
164 pub exports: Vec<String>,
166}
167
168fn default_external_entry_point_role() -> EntryPointRole {
169 EntryPointRole::Support
170}
171
172impl ExternalPluginDef {
173 #[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 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
221fn 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
243pub 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
275enum 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
293fn 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
300fn 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
327pub 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 for path_str in config_plugin_paths {
343 let path = root.join(path_str);
344 if !is_within_root(&path, &canonical_root) {
345 tracing::warn!("plugin path '{path_str}' resolves outside project root, skipping");
346 continue;
347 }
348 if path.is_dir() {
349 load_plugins_from_dir(&path, &canonical_root, &mut plugins, &mut seen_names);
350 } else if path.is_file() {
351 load_plugin_file(&path, &canonical_root, &mut plugins, &mut seen_names);
352 }
353 }
354
355 let plugins_dir = root.join(".fallow").join("plugins");
356 if plugins_dir.is_dir() && is_within_root(&plugins_dir, &canonical_root) {
357 load_plugins_from_dir(&plugins_dir, &canonical_root, &mut plugins, &mut seen_names);
358 }
359
360 if let Ok(entries) = std::fs::read_dir(root) {
361 let mut plugin_files: Vec<PathBuf> = entries
362 .filter_map(Result::ok)
363 .map(|e| e.path())
364 .filter(|p| {
365 p.is_file()
366 && p.file_name().and_then(|n| n.to_str()).is_some_and(|n| {
367 n.starts_with("fallow-plugin-") && is_plugin_file(Path::new(n))
368 })
369 })
370 .collect();
371 plugin_files.sort();
372 for path in plugin_files {
373 load_plugin_file(&path, &canonical_root, &mut plugins, &mut seen_names);
374 }
375 }
376
377 plugins
378}
379
380#[expect(
382 clippy::redundant_pub_crate,
383 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"
384)]
385pub(crate) fn is_within_root(path: &Path, canonical_root: &Path) -> bool {
386 let canonical = dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
387 canonical.starts_with(canonical_root)
388}
389
390fn load_plugins_from_dir(
391 dir: &Path,
392 canonical_root: &Path,
393 plugins: &mut Vec<ExternalPluginDef>,
394 seen: &mut rustc_hash::FxHashSet<String>,
395) {
396 if let Ok(entries) = std::fs::read_dir(dir) {
397 let mut plugin_files: Vec<PathBuf> = entries
398 .filter_map(Result::ok)
399 .map(|e| e.path())
400 .filter(|p| p.is_file() && is_plugin_file(p))
401 .collect();
402 plugin_files.sort();
403 for path in plugin_files {
404 load_plugin_file(&path, canonical_root, plugins, seen);
405 }
406 }
407}
408
409fn load_plugin_file(
410 path: &Path,
411 canonical_root: &Path,
412 plugins: &mut Vec<ExternalPluginDef>,
413 seen: &mut rustc_hash::FxHashSet<String>,
414) {
415 if !is_within_root(path, canonical_root) {
416 tracing::warn!(
417 "plugin file '{}' resolves outside project root (symlink?), skipping",
418 path.display()
419 );
420 return;
421 }
422
423 let Some(format) = PluginFormat::from_path(path) else {
424 tracing::warn!(
425 "unsupported plugin file extension for {}, expected .toml, .json, or .jsonc",
426 path.display()
427 );
428 return;
429 };
430
431 match std::fs::read_to_string(path) {
432 Ok(content) => {
433 if let Some(plugin) = parse_plugin(&content, &format, path) {
434 if plugin.name.is_empty() {
435 tracing::warn!(
436 "external plugin in {} has an empty name, skipping",
437 path.display()
438 );
439 return;
440 }
441 if seen.insert(plugin.name.clone()) {
442 plugins.push(plugin);
443 } else {
444 tracing::warn!(
445 "duplicate external plugin '{}' in {}, skipping",
446 plugin.name,
447 path.display()
448 );
449 }
450 }
451 }
452 Err(e) => {
453 tracing::warn!(
454 "failed to read external plugin file {}: {e}",
455 path.display()
456 );
457 }
458 }
459}
460
461#[cfg(test)]
462mod tests {
463 use super::*;
464 use crate::ScopedUsedClassMemberRule;
465
466 #[test]
467 fn deserialize_minimal_plugin() {
468 let toml_str = r#"
469name = "my-plugin"
470enablers = ["my-pkg"]
471"#;
472 let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
473 assert_eq!(plugin.name, "my-plugin");
474 assert_eq!(plugin.enablers, vec!["my-pkg"]);
475 assert!(plugin.entry_points.is_empty());
476 assert!(plugin.always_used.is_empty());
477 assert!(plugin.config_patterns.is_empty());
478 assert!(plugin.tooling_dependencies.is_empty());
479 assert!(plugin.used_exports.is_empty());
480 assert!(plugin.used_class_members.is_empty());
481 }
482
483 #[test]
484 fn deserialize_plugin_with_used_class_members_json() {
485 let json_str = r#"{
486 "name": "ag-grid",
487 "enablers": ["ag-grid-angular"],
488 "usedClassMembers": ["agInit", "refresh"]
489 }"#;
490 let plugin: ExternalPluginDef = serde_json::from_str(json_str).unwrap();
491 assert_eq!(plugin.name, "ag-grid");
492 assert_eq!(
493 plugin.used_class_members,
494 vec![
495 UsedClassMemberRule::from("agInit"),
496 UsedClassMemberRule::from("refresh"),
497 ]
498 );
499 }
500
501 #[test]
502 fn deserialize_plugin_with_scoped_used_class_members_json() {
503 let json_str = r#"{
504 "name": "ag-grid",
505 "enablers": ["ag-grid-angular"],
506 "usedClassMembers": [
507 "agInit",
508 { "implements": "ICellRendererAngularComp", "members": ["refresh"] },
509 { "extends": "BaseCommand", "members": ["execute"] }
510 ]
511 }"#;
512 let plugin: ExternalPluginDef = serde_json::from_str(json_str).unwrap();
513 assert_eq!(
514 plugin.used_class_members,
515 vec![
516 UsedClassMemberRule::from("agInit"),
517 UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
518 extends: None,
519 implements: Some("ICellRendererAngularComp".to_string()),
520 members: vec!["refresh".to_string()],
521 }),
522 UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
523 extends: Some("BaseCommand".to_string()),
524 implements: None,
525 members: vec!["execute".to_string()],
526 }),
527 ]
528 );
529 }
530
531 #[test]
532 fn deserialize_plugin_with_used_class_members_toml() {
533 let toml_str = r#"
534name = "ag-grid"
535enablers = ["ag-grid-angular"]
536usedClassMembers = ["agInit", "refresh"]
537"#;
538 let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
539 assert_eq!(
540 plugin.used_class_members,
541 vec![
542 UsedClassMemberRule::from("agInit"),
543 UsedClassMemberRule::from("refresh"),
544 ]
545 );
546 }
547
548 #[test]
549 fn deserialize_plugin_with_scoped_used_class_members_toml() {
550 let toml_str = r#"
551name = "ag-grid"
552enablers = ["ag-grid-angular"]
553usedClassMembers = [
554 { implements = "ICellRendererAngularComp", members = ["refresh"] },
555 { extends = "BaseCommand", members = ["execute"] }
556]
557"#;
558 let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
559 assert_eq!(
560 plugin.used_class_members,
561 vec![
562 UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
563 extends: None,
564 implements: Some("ICellRendererAngularComp".to_string()),
565 members: vec!["refresh".to_string()],
566 }),
567 UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
568 extends: Some("BaseCommand".to_string()),
569 implements: None,
570 members: vec!["execute".to_string()],
571 }),
572 ]
573 );
574 }
575
576 #[test]
577 fn deserialize_plugin_rejects_unconstrained_scoped_used_class_members() {
578 let result = serde_json::from_str::<ExternalPluginDef>(
579 r#"{
580 "name": "ag-grid",
581 "enablers": ["ag-grid-angular"],
582 "usedClassMembers": [{ "members": ["refresh"] }]
583 }"#,
584 );
585 assert!(
586 result.is_err(),
587 "unconstrained scoped rule should be rejected"
588 );
589 }
590
591 #[test]
592 fn deserialize_full_plugin() {
593 let toml_str = r#"
594name = "my-framework"
595enablers = ["my-framework", "@my-framework/core"]
596entryPoints = ["src/routes/**/*.{ts,tsx}", "src/middleware.ts"]
597configPatterns = ["my-framework.config.{ts,js,mjs}"]
598alwaysUsed = ["src/setup.ts", "public/**/*"]
599toolingDependencies = ["my-framework-cli"]
600
601[[usedExports]]
602pattern = "src/routes/**/*.{ts,tsx}"
603exports = ["default", "loader", "action"]
604
605[[usedExports]]
606pattern = "src/middleware.ts"
607exports = ["default"]
608"#;
609 let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
610 assert_eq!(plugin.name, "my-framework");
611 assert_eq!(plugin.enablers.len(), 2);
612 assert_eq!(plugin.entry_points.len(), 2);
613 assert_eq!(
614 plugin.config_patterns,
615 vec!["my-framework.config.{ts,js,mjs}"]
616 );
617 assert_eq!(plugin.always_used.len(), 2);
618 assert_eq!(plugin.tooling_dependencies, vec!["my-framework-cli"]);
619 assert_eq!(plugin.used_exports.len(), 2);
620 assert_eq!(plugin.used_exports[0].pattern, "src/routes/**/*.{ts,tsx}");
621 assert_eq!(
622 plugin.used_exports[0].exports,
623 vec!["default", "loader", "action"]
624 );
625 }
626
627 #[test]
628 fn deserialize_json_plugin() {
629 let json_str = r#"{
630 "name": "my-json-plugin",
631 "enablers": ["my-pkg"],
632 "entryPoints": ["src/**/*.ts"],
633 "configPatterns": ["my-plugin.config.js"],
634 "alwaysUsed": ["src/setup.ts"],
635 "toolingDependencies": ["my-cli"],
636 "usedExports": [
637 { "pattern": "src/**/*.ts", "exports": ["default"] }
638 ]
639 }"#;
640 let plugin: ExternalPluginDef = serde_json::from_str(json_str).unwrap();
641 assert_eq!(plugin.name, "my-json-plugin");
642 assert_eq!(plugin.enablers, vec!["my-pkg"]);
643 assert_eq!(plugin.entry_points, vec!["src/**/*.ts"]);
644 assert_eq!(plugin.config_patterns, vec!["my-plugin.config.js"]);
645 assert_eq!(plugin.always_used, vec!["src/setup.ts"]);
646 assert_eq!(plugin.tooling_dependencies, vec!["my-cli"]);
647 assert_eq!(plugin.used_exports.len(), 1);
648 assert_eq!(plugin.used_exports[0].exports, vec!["default"]);
649 }
650
651 #[test]
652 fn deserialize_jsonc_plugin() {
653 let jsonc_str = r#"{
654 "name": "my-jsonc-plugin",
655 "enablers": ["my-pkg"],
656 /* Block comment */
657 "entryPoints": ["src/**/*.ts"]
658 }"#;
659 let plugin: ExternalPluginDef = crate::jsonc::parse_to_value(jsonc_str).unwrap();
660 assert_eq!(plugin.name, "my-jsonc-plugin");
661 assert_eq!(plugin.enablers, vec!["my-pkg"]);
662 assert_eq!(plugin.entry_points, vec!["src/**/*.ts"]);
663 }
664
665 #[test]
666 fn deserialize_json_with_schema_field() {
667 let json_str = r#"{
668 "$schema": "https://fallow.dev/plugin-schema.json",
669 "name": "schema-plugin",
670 "enablers": ["my-pkg"]
671 }"#;
672 let plugin: ExternalPluginDef = serde_json::from_str(json_str).unwrap();
673 assert_eq!(plugin.name, "schema-plugin");
674 assert_eq!(plugin.enablers, vec!["my-pkg"]);
675 }
676
677 #[test]
678 fn plugin_json_schema_generation() {
679 let schema = ExternalPluginDef::json_schema();
680 assert!(schema.is_object());
681 let obj = schema.as_object().unwrap();
682 assert!(obj.contains_key("properties"));
683 }
684
685 #[test]
686 fn discover_plugins_from_fallow_plugins_dir() {
687 let dir =
688 std::env::temp_dir().join(format!("fallow-test-ext-plugins-{}", std::process::id()));
689 let plugins_dir = dir.join(".fallow").join("plugins");
690 let _ = std::fs::create_dir_all(&plugins_dir);
691
692 std::fs::write(
693 plugins_dir.join("my-plugin.toml"),
694 r#"
695name = "my-plugin"
696enablers = ["my-pkg"]
697entryPoints = ["src/**/*.ts"]
698"#,
699 )
700 .unwrap();
701
702 let plugins = discover_external_plugins(&dir, &[]);
703 assert_eq!(plugins.len(), 1);
704 assert_eq!(plugins[0].name, "my-plugin");
705
706 let _ = std::fs::remove_dir_all(&dir);
707 }
708
709 #[test]
710 fn discover_json_plugins_from_fallow_plugins_dir() {
711 let dir = std::env::temp_dir().join(format!(
712 "fallow-test-ext-json-plugins-{}",
713 std::process::id()
714 ));
715 let plugins_dir = dir.join(".fallow").join("plugins");
716 let _ = std::fs::create_dir_all(&plugins_dir);
717
718 std::fs::write(
719 plugins_dir.join("my-plugin.json"),
720 r#"{"name": "json-plugin", "enablers": ["json-pkg"]}"#,
721 )
722 .unwrap();
723
724 std::fs::write(
725 plugins_dir.join("my-plugin.jsonc"),
726 r#"{
727 "name": "jsonc-plugin",
728 "enablers": ["jsonc-pkg"]
729 }"#,
730 )
731 .unwrap();
732
733 let plugins = discover_external_plugins(&dir, &[]);
734 assert_eq!(plugins.len(), 2);
735 assert_eq!(plugins[0].name, "json-plugin");
736 assert_eq!(plugins[1].name, "jsonc-plugin");
737
738 let _ = std::fs::remove_dir_all(&dir);
739 }
740
741 #[test]
742 fn discover_fallow_plugin_files_in_root() {
743 let dir =
744 std::env::temp_dir().join(format!("fallow-test-root-plugins-{}", std::process::id()));
745 let _ = std::fs::create_dir_all(&dir);
746
747 std::fs::write(
748 dir.join("fallow-plugin-custom.toml"),
749 r#"
750name = "custom"
751enablers = ["custom-pkg"]
752"#,
753 )
754 .unwrap();
755
756 std::fs::write(dir.join("some-other-file.toml"), r#"name = "ignored""#).unwrap();
757
758 let plugins = discover_external_plugins(&dir, &[]);
759 assert_eq!(plugins.len(), 1);
760 assert_eq!(plugins[0].name, "custom");
761
762 let _ = std::fs::remove_dir_all(&dir);
763 }
764
765 #[test]
766 fn discover_fallow_plugin_json_files_in_root() {
767 let dir = std::env::temp_dir().join(format!(
768 "fallow-test-root-json-plugins-{}",
769 std::process::id()
770 ));
771 let _ = std::fs::create_dir_all(&dir);
772
773 std::fs::write(
774 dir.join("fallow-plugin-custom.json"),
775 r#"{"name": "json-root", "enablers": ["json-pkg"]}"#,
776 )
777 .unwrap();
778
779 std::fs::write(
780 dir.join("fallow-plugin-custom2.jsonc"),
781 r#"{
782 "name": "jsonc-root",
783 "enablers": ["jsonc-pkg"]
784 }"#,
785 )
786 .unwrap();
787
788 std::fs::write(
789 dir.join("fallow-plugin-bad.yaml"),
790 "name: ignored\nenablers:\n - pkg\n",
791 )
792 .unwrap();
793
794 let plugins = discover_external_plugins(&dir, &[]);
795 assert_eq!(plugins.len(), 2);
796
797 let _ = std::fs::remove_dir_all(&dir);
798 }
799
800 #[test]
801 fn discover_mixed_formats_in_dir() {
802 let dir =
803 std::env::temp_dir().join(format!("fallow-test-mixed-plugins-{}", std::process::id()));
804 let plugins_dir = dir.join(".fallow").join("plugins");
805 let _ = std::fs::create_dir_all(&plugins_dir);
806
807 std::fs::write(
808 plugins_dir.join("a-plugin.toml"),
809 r#"
810name = "toml-plugin"
811enablers = ["toml-pkg"]
812"#,
813 )
814 .unwrap();
815
816 std::fs::write(
817 plugins_dir.join("b-plugin.json"),
818 r#"{"name": "json-plugin", "enablers": ["json-pkg"]}"#,
819 )
820 .unwrap();
821
822 std::fs::write(
823 plugins_dir.join("c-plugin.jsonc"),
824 r#"{
825 "name": "jsonc-plugin",
826 "enablers": ["jsonc-pkg"]
827 }"#,
828 )
829 .unwrap();
830
831 let plugins = discover_external_plugins(&dir, &[]);
832 assert_eq!(plugins.len(), 3);
833 assert_eq!(plugins[0].name, "toml-plugin");
834 assert_eq!(plugins[1].name, "json-plugin");
835 assert_eq!(plugins[2].name, "jsonc-plugin");
836
837 let _ = std::fs::remove_dir_all(&dir);
838 }
839
840 #[test]
841 fn deduplicates_by_name() {
842 let dir =
843 std::env::temp_dir().join(format!("fallow-test-dedup-plugins-{}", std::process::id()));
844 let plugins_dir = dir.join(".fallow").join("plugins");
845 let _ = std::fs::create_dir_all(&plugins_dir);
846
847 std::fs::write(
848 plugins_dir.join("my-plugin.toml"),
849 r#"
850name = "my-plugin"
851enablers = ["pkg-a"]
852"#,
853 )
854 .unwrap();
855
856 std::fs::write(
857 dir.join("fallow-plugin-my-plugin.toml"),
858 r#"
859name = "my-plugin"
860enablers = ["pkg-b"]
861"#,
862 )
863 .unwrap();
864
865 let plugins = discover_external_plugins(&dir, &[]);
866 assert_eq!(plugins.len(), 1);
867 assert_eq!(plugins[0].enablers, vec!["pkg-a"]);
868
869 let _ = std::fs::remove_dir_all(&dir);
870 }
871
872 #[test]
873 fn config_plugin_paths_take_priority() {
874 let dir =
875 std::env::temp_dir().join(format!("fallow-test-config-paths-{}", std::process::id()));
876 let custom_dir = dir.join("custom-plugins");
877 let _ = std::fs::create_dir_all(&custom_dir);
878
879 std::fs::write(
880 custom_dir.join("explicit.toml"),
881 r#"
882name = "explicit"
883enablers = ["explicit-pkg"]
884"#,
885 )
886 .unwrap();
887
888 let plugins = discover_external_plugins(&dir, &["custom-plugins".to_string()]);
889 assert_eq!(plugins.len(), 1);
890 assert_eq!(plugins[0].name, "explicit");
891
892 let _ = std::fs::remove_dir_all(&dir);
893 }
894
895 #[test]
896 fn config_plugin_path_to_single_file() {
897 let dir =
898 std::env::temp_dir().join(format!("fallow-test-single-file-{}", std::process::id()));
899 let _ = std::fs::create_dir_all(&dir);
900
901 std::fs::write(
902 dir.join("my-plugin.toml"),
903 r#"
904name = "single-file"
905enablers = ["single-pkg"]
906"#,
907 )
908 .unwrap();
909
910 let plugins = discover_external_plugins(&dir, &["my-plugin.toml".to_string()]);
911 assert_eq!(plugins.len(), 1);
912 assert_eq!(plugins[0].name, "single-file");
913
914 let _ = std::fs::remove_dir_all(&dir);
915 }
916
917 #[test]
918 fn config_plugin_path_to_single_json_file() {
919 let dir = std::env::temp_dir().join(format!(
920 "fallow-test-single-json-file-{}",
921 std::process::id()
922 ));
923 let _ = std::fs::create_dir_all(&dir);
924
925 std::fs::write(
926 dir.join("my-plugin.json"),
927 r#"{"name": "json-single", "enablers": ["json-pkg"]}"#,
928 )
929 .unwrap();
930
931 let plugins = discover_external_plugins(&dir, &["my-plugin.json".to_string()]);
932 assert_eq!(plugins.len(), 1);
933 assert_eq!(plugins[0].name, "json-single");
934
935 let _ = std::fs::remove_dir_all(&dir);
936 }
937
938 #[test]
939 fn skips_invalid_toml() {
940 let dir =
941 std::env::temp_dir().join(format!("fallow-test-invalid-plugin-{}", std::process::id()));
942 let plugins_dir = dir.join(".fallow").join("plugins");
943 let _ = std::fs::create_dir_all(&plugins_dir);
944
945 std::fs::write(plugins_dir.join("bad.toml"), r#"enablers = ["pkg"]"#).unwrap();
946
947 std::fs::write(
948 plugins_dir.join("good.toml"),
949 r#"
950name = "good"
951enablers = ["good-pkg"]
952"#,
953 )
954 .unwrap();
955
956 let plugins = discover_external_plugins(&dir, &[]);
957 assert_eq!(plugins.len(), 1);
958 assert_eq!(plugins[0].name, "good");
959
960 let _ = std::fs::remove_dir_all(&dir);
961 }
962
963 #[test]
964 fn skips_invalid_json() {
965 let dir = std::env::temp_dir().join(format!(
966 "fallow-test-invalid-json-plugin-{}",
967 std::process::id()
968 ));
969 let plugins_dir = dir.join(".fallow").join("plugins");
970 let _ = std::fs::create_dir_all(&plugins_dir);
971
972 std::fs::write(plugins_dir.join("bad.json"), r#"{"enablers": ["pkg"]}"#).unwrap();
973
974 std::fs::write(
975 plugins_dir.join("good.json"),
976 r#"{"name": "good-json", "enablers": ["good-pkg"]}"#,
977 )
978 .unwrap();
979
980 let plugins = discover_external_plugins(&dir, &[]);
981 assert_eq!(plugins.len(), 1);
982 assert_eq!(plugins[0].name, "good-json");
983
984 let _ = std::fs::remove_dir_all(&dir);
985 }
986
987 #[test]
988 fn prefix_enablers() {
989 let toml_str = r#"
990name = "scoped"
991enablers = ["@myorg/"]
992"#;
993 let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
994 assert_eq!(plugin.enablers, vec!["@myorg/"]);
995 }
996
997 #[test]
998 fn skips_empty_name() {
999 let dir =
1000 std::env::temp_dir().join(format!("fallow-test-empty-name-{}", std::process::id()));
1001 let plugins_dir = dir.join(".fallow").join("plugins");
1002 let _ = std::fs::create_dir_all(&plugins_dir);
1003
1004 std::fs::write(
1005 plugins_dir.join("empty.toml"),
1006 r#"
1007name = ""
1008enablers = ["pkg"]
1009"#,
1010 )
1011 .unwrap();
1012
1013 let plugins = discover_external_plugins(&dir, &[]);
1014 assert!(plugins.is_empty(), "empty-name plugin should be skipped");
1015
1016 let _ = std::fs::remove_dir_all(&dir);
1017 }
1018
1019 #[test]
1020 fn rejects_paths_outside_root() {
1021 let dir =
1022 std::env::temp_dir().join(format!("fallow-test-path-escape-{}", std::process::id()));
1023 let _ = std::fs::create_dir_all(&dir);
1024
1025 let plugins = discover_external_plugins(&dir, &["../../../etc".to_string()]);
1026 assert!(plugins.is_empty(), "paths outside root should be rejected");
1027
1028 let _ = std::fs::remove_dir_all(&dir);
1029 }
1030
1031 #[test]
1032 fn plugin_format_detection() {
1033 assert!(matches!(
1034 PluginFormat::from_path(Path::new("plugin.toml")),
1035 Some(PluginFormat::Toml)
1036 ));
1037 assert!(matches!(
1038 PluginFormat::from_path(Path::new("plugin.json")),
1039 Some(PluginFormat::Json)
1040 ));
1041 assert!(matches!(
1042 PluginFormat::from_path(Path::new("plugin.jsonc")),
1043 Some(PluginFormat::Jsonc)
1044 ));
1045 assert!(PluginFormat::from_path(Path::new("plugin.yaml")).is_none());
1046 assert!(PluginFormat::from_path(Path::new("plugin")).is_none());
1047 }
1048
1049 #[test]
1050 fn is_plugin_file_checks_extensions() {
1051 assert!(is_plugin_file(Path::new("plugin.toml")));
1052 assert!(is_plugin_file(Path::new("plugin.json")));
1053 assert!(is_plugin_file(Path::new("plugin.jsonc")));
1054 assert!(!is_plugin_file(Path::new("plugin.yaml")));
1055 assert!(!is_plugin_file(Path::new("plugin.txt")));
1056 assert!(!is_plugin_file(Path::new("plugin")));
1057 }
1058
1059 #[test]
1060 fn detection_deserialize_dependency() {
1061 let json = r#"{"type": "dependency", "package": "next"}"#;
1062 let detection: PluginDetection = serde_json::from_str(json).unwrap();
1063 assert!(matches!(detection, PluginDetection::Dependency { package } if package == "next"));
1064 }
1065
1066 #[test]
1067 fn detection_deserialize_file_exists() {
1068 let json = r#"{"type": "fileExists", "pattern": "tsconfig.json"}"#;
1069 let detection: PluginDetection = serde_json::from_str(json).unwrap();
1070 assert!(
1071 matches!(detection, PluginDetection::FileExists { pattern } if pattern == "tsconfig.json")
1072 );
1073 }
1074
1075 #[test]
1076 fn detection_deserialize_all() {
1077 let json = r#"{"type": "all", "conditions": [{"type": "dependency", "package": "a"}, {"type": "dependency", "package": "b"}]}"#;
1078 let detection: PluginDetection = serde_json::from_str(json).unwrap();
1079 assert!(matches!(detection, PluginDetection::All { conditions } if conditions.len() == 2));
1080 }
1081
1082 #[test]
1083 fn detection_deserialize_any() {
1084 let json = r#"{"type": "any", "conditions": [{"type": "dependency", "package": "a"}]}"#;
1085 let detection: PluginDetection = serde_json::from_str(json).unwrap();
1086 assert!(matches!(detection, PluginDetection::Any { conditions } if conditions.len() == 1));
1087 }
1088
1089 #[test]
1090 fn plugin_with_detection_field() {
1091 let json = r#"{
1092 "name": "my-plugin",
1093 "detection": {"type": "dependency", "package": "my-pkg"},
1094 "entryPoints": ["src/**/*.ts"]
1095 }"#;
1096 let plugin: ExternalPluginDef = serde_json::from_str(json).unwrap();
1097 assert_eq!(plugin.name, "my-plugin");
1098 assert!(plugin.detection.is_some());
1099 assert!(plugin.enablers.is_empty());
1100 assert_eq!(plugin.entry_points, vec!["src/**/*.ts"]);
1101 }
1102
1103 #[test]
1104 fn plugin_without_detection_uses_enablers() {
1105 let json = r#"{
1106 "name": "my-plugin",
1107 "enablers": ["my-pkg"]
1108 }"#;
1109 let plugin: ExternalPluginDef = serde_json::from_str(json).unwrap();
1110 assert!(plugin.detection.is_none());
1111 assert_eq!(plugin.enablers, vec!["my-pkg"]);
1112 }
1113
1114 #[test]
1115 fn detection_nested_all_with_any() {
1116 let json = r#"{
1117 "type": "all",
1118 "conditions": [
1119 {"type": "dependency", "package": "react"},
1120 {"type": "any", "conditions": [
1121 {"type": "fileExists", "pattern": "next.config.js"},
1122 {"type": "fileExists", "pattern": "next.config.mjs"}
1123 ]}
1124 ]
1125 }"#;
1126 let detection: PluginDetection = serde_json::from_str(json).unwrap();
1127 match detection {
1128 PluginDetection::All { conditions } => {
1129 assert_eq!(conditions.len(), 2);
1130 assert!(matches!(
1131 &conditions[0],
1132 PluginDetection::Dependency { package } if package == "react"
1133 ));
1134 match &conditions[1] {
1135 PluginDetection::Any { conditions: inner } => {
1136 assert_eq!(inner.len(), 2);
1137 }
1138 other => panic!("expected Any, got: {other:?}"),
1139 }
1140 }
1141 other => panic!("expected All, got: {other:?}"),
1142 }
1143 }
1144
1145 #[test]
1146 fn detection_empty_all_conditions() {
1147 let json = r#"{"type": "all", "conditions": []}"#;
1148 let detection: PluginDetection = serde_json::from_str(json).unwrap();
1149 assert!(matches!(
1150 detection,
1151 PluginDetection::All { conditions } if conditions.is_empty()
1152 ));
1153 }
1154
1155 #[test]
1156 fn detection_empty_any_conditions() {
1157 let json = r#"{"type": "any", "conditions": []}"#;
1158 let detection: PluginDetection = serde_json::from_str(json).unwrap();
1159 assert!(matches!(
1160 detection,
1161 PluginDetection::Any { conditions } if conditions.is_empty()
1162 ));
1163 }
1164
1165 #[test]
1166 fn detection_toml_dependency() {
1167 let toml_str = r#"
1168name = "my-plugin"
1169
1170[detection]
1171type = "dependency"
1172package = "next"
1173"#;
1174 let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
1175 assert!(plugin.detection.is_some());
1176 assert!(matches!(
1177 plugin.detection.unwrap(),
1178 PluginDetection::Dependency { package } if package == "next"
1179 ));
1180 }
1181
1182 #[test]
1183 fn detection_toml_file_exists() {
1184 let toml_str = r#"
1185name = "my-plugin"
1186
1187[detection]
1188type = "fileExists"
1189pattern = "next.config.js"
1190"#;
1191 let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
1192 assert!(matches!(
1193 plugin.detection.unwrap(),
1194 PluginDetection::FileExists { pattern } if pattern == "next.config.js"
1195 ));
1196 }
1197
1198 #[test]
1199 fn plugin_all_fields_json() {
1200 let json = r#"{
1201 "$schema": "https://fallow.dev/plugin-schema.json",
1202 "name": "full-plugin",
1203 "detection": {"type": "dependency", "package": "my-pkg"},
1204 "enablers": ["fallback-enabler"],
1205 "entryPoints": ["src/entry.ts"],
1206 "configPatterns": ["config.js"],
1207 "alwaysUsed": ["src/polyfills.ts"],
1208 "toolingDependencies": ["my-cli"],
1209 "usedExports": [{"pattern": "src/**", "exports": ["default", "setup"]}]
1210 }"#;
1211 let plugin: ExternalPluginDef = serde_json::from_str(json).unwrap();
1212 assert_eq!(plugin.name, "full-plugin");
1213 assert!(plugin.detection.is_some());
1214 assert_eq!(plugin.enablers, vec!["fallback-enabler"]);
1215 assert_eq!(plugin.entry_points, vec!["src/entry.ts"]);
1216 assert_eq!(plugin.config_patterns, vec!["config.js"]);
1217 assert_eq!(plugin.always_used, vec!["src/polyfills.ts"]);
1218 assert_eq!(plugin.tooling_dependencies, vec!["my-cli"]);
1219 assert_eq!(plugin.used_exports.len(), 1);
1220 assert_eq!(plugin.used_exports[0].pattern, "src/**");
1221 assert_eq!(plugin.used_exports[0].exports, vec!["default", "setup"]);
1222 }
1223
1224 #[test]
1225 fn plugin_with_special_chars_in_name() {
1226 let json = r#"{"name": "@scope/my-plugin-v2.0", "enablers": ["pkg"]}"#;
1227 let plugin: ExternalPluginDef = serde_json::from_str(json).unwrap();
1228 assert_eq!(plugin.name, "@scope/my-plugin-v2.0");
1229 }
1230
1231 #[test]
1232 fn parse_plugin_toml_format() {
1233 let content = r#"
1234name = "test-plugin"
1235enablers = ["test-pkg"]
1236entryPoints = ["src/**/*.ts"]
1237"#;
1238 let result = parse_plugin(content, &PluginFormat::Toml, Path::new("test.toml"));
1239 assert!(result.is_some());
1240 let plugin = result.unwrap();
1241 assert_eq!(plugin.name, "test-plugin");
1242 }
1243
1244 #[test]
1245 fn parse_plugin_json_format() {
1246 let content = r#"{"name": "json-test", "enablers": ["pkg"]}"#;
1247 let result = parse_plugin(content, &PluginFormat::Json, Path::new("test.json"));
1248 assert!(result.is_some());
1249 assert_eq!(result.unwrap().name, "json-test");
1250 }
1251
1252 #[test]
1253 fn parse_plugin_jsonc_format() {
1254 let content = r#"{
1255 "name": "jsonc-test",
1256 "enablers": ["pkg"]
1257 }"#;
1258 let result = parse_plugin(content, &PluginFormat::Jsonc, Path::new("test.jsonc"));
1259 assert!(result.is_some());
1260 assert_eq!(result.unwrap().name, "jsonc-test");
1261 }
1262
1263 #[test]
1264 fn parse_plugin_invalid_toml_returns_none() {
1265 let content = "not valid toml [[[";
1266 let result = parse_plugin(content, &PluginFormat::Toml, Path::new("bad.toml"));
1267 assert!(result.is_none());
1268 }
1269
1270 #[test]
1271 fn parse_plugin_invalid_json_returns_none() {
1272 let content = "{ not valid json }";
1273 let result = parse_plugin(content, &PluginFormat::Json, Path::new("bad.json"));
1274 assert!(result.is_none());
1275 }
1276
1277 #[test]
1278 fn parse_plugin_invalid_jsonc_returns_none() {
1279 let content = r#"{"enablers": ["pkg"]}"#;
1280 let result = parse_plugin(content, &PluginFormat::Jsonc, Path::new("bad.jsonc"));
1281 assert!(result.is_none());
1282 }
1283}