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