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