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