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