1use std::collections::BTreeMap;
2use std::path::{Path, PathBuf};
3
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6
7use crate::config::UsedClassMemberRule;
8
9const PLUGIN_EXTENSIONS: &[&str] = &["toml", "json", "jsonc"];
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema, Default)]
14#[serde(rename_all = "camelCase")]
15pub enum EntryPointRole {
16 Runtime,
18 Test,
20 #[default]
22 Support,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum AutoImportKind {
29 Named,
31 Default,
33 DefaultComponent,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct AutoImportRule {
49 pub name: String,
53 pub source: PathBuf,
55 pub kind: AutoImportKind,
57}
58
59#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
64#[serde(tag = "type", rename_all = "camelCase")]
65pub enum PluginDetection {
66 Dependency {
68 package: String,
70 },
71 FileExists {
73 pattern: String,
77 },
78 All {
80 conditions: Vec<Self>,
82 },
83 Any {
85 conditions: Vec<Self>,
87 },
88}
89
90#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
117#[serde(rename_all = "camelCase")]
118pub struct ExternalPluginDef {
119 #[serde(rename = "$schema", default, skip_serializing)]
121 #[schemars(skip)]
122 pub schema: Option<String>,
123
124 pub name: String,
126
127 #[serde(default)]
130 pub detection: Option<PluginDetection>,
131
132 #[serde(default)]
136 pub enablers: Vec<String>,
137
138 #[serde(default)]
140 pub entry_points: Vec<String>,
141
142 #[serde(default = "default_external_entry_point_role")]
147 pub entry_point_role: EntryPointRole,
148
149 #[serde(default)]
156 pub manifest_entries: Vec<ManifestEntryRule>,
157
158 #[serde(default)]
160 pub config_patterns: Vec<String>,
161
162 #[serde(default)]
164 pub always_used: Vec<String>,
165
166 #[serde(default)]
169 pub tooling_dependencies: Vec<String>,
170
171 #[serde(default)]
173 pub used_exports: Vec<ExternalUsedExport>,
174
175 #[serde(default)]
180 pub used_class_members: Vec<UsedClassMemberRule>,
181}
182
183#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
185pub struct ExternalUsedExport {
186 pub pattern: String,
188 pub exports: Vec<String>,
190}
191
192#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema, Default)]
196#[serde(rename_all = "lowercase")]
197pub enum ManifestFormat {
198 #[default]
200 Jsonc,
201 Json,
203}
204
205#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
224#[serde(rename_all = "camelCase")]
225pub struct ManifestEntryRule {
226 pub manifests: String,
228
229 #[serde(default)]
231 pub format: ManifestFormat,
232
233 #[serde(default)]
237 pub when: BTreeMap<String, serde_json::Value>,
238
239 pub entries: Vec<ManifestSeedRule>,
241}
242
243#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
246#[serde(rename_all = "camelCase")]
247pub struct ManifestSeedRule {
248 pub path: String,
255
256 #[serde(default)]
259 pub when: BTreeMap<String, serde_json::Value>,
260}
261
262fn default_external_entry_point_role() -> EntryPointRole {
263 EntryPointRole::Support
264}
265
266impl ExternalPluginDef {
267 #[must_use]
269 pub fn json_schema() -> serde_json::Value {
270 serde_json::to_value(schemars::schema_for!(ExternalPluginDef)).unwrap_or_default()
271 }
272
273 pub fn validate_user_globs(
287 &self,
288 ) -> Result<(), Vec<crate::config::glob_validation::GlobValidationError>> {
289 use crate::config::glob_validation::{compile_user_glob, validate_user_globs};
290
291 let mut errors = Vec::new();
292 validate_user_globs(&self.entry_points, "framework[].entryPoints", &mut errors);
293 validate_user_globs(&self.always_used, "framework[].alwaysUsed", &mut errors);
294 validate_user_globs(
295 &self.config_patterns,
296 "framework[].configPatterns",
297 &mut errors,
298 );
299 for used in &self.used_exports {
300 if let Err(e) = compile_user_glob(&used.pattern, "framework[].usedExports[].pattern") {
301 errors.push(e);
302 }
303 }
304 for rule in &self.manifest_entries {
305 if let Err(e) =
306 compile_user_glob(&rule.manifests, "framework[].manifestEntries[].manifests")
307 {
308 errors.push(e);
309 }
310 for seed in &rule.entries {
311 let probe = substitute_interpolation_placeholder(&seed.path);
314 if let Err(e) =
315 compile_user_glob(&probe, "framework[].manifestEntries[].entries[].path")
316 {
317 errors.push(e);
318 }
319 }
320 }
321 if let Some(detection) = &self.detection {
322 validate_detection_user_globs(detection, "framework[].detection", &mut errors);
323 }
324 if errors.is_empty() {
325 Ok(())
326 } else {
327 Err(errors)
328 }
329 }
330}
331
332fn substitute_interpolation_placeholder(path: &str) -> String {
337 let mut out = String::with_capacity(path.len());
338 let mut rest = path;
339 while let Some(start) = rest.find("${") {
340 out.push_str(&rest[..start]);
341 if let Some(end_rel) = rest[start + 2..].find('}') {
342 out.push_str("fallowinterp");
343 rest = &rest[start + 2 + end_rel + 1..];
344 } else {
345 out.push_str(&rest[start..]);
346 return out;
347 }
348 }
349 out.push_str(rest);
350 out
351}
352
353fn validate_detection_user_globs(
356 detection: &PluginDetection,
357 field: &'static str,
358 errors: &mut Vec<crate::config::glob_validation::GlobValidationError>,
359) {
360 match detection {
361 PluginDetection::Dependency { .. } => {}
362 PluginDetection::FileExists { pattern } => {
363 if let Err(e) = crate::config::glob_validation::compile_user_glob(pattern, field) {
364 errors.push(e);
365 }
366 }
367 PluginDetection::All { conditions } | PluginDetection::Any { conditions } => {
368 for condition in conditions {
369 validate_detection_user_globs(condition, field, errors);
370 }
371 }
372 }
373}
374
375pub fn discover_and_validate_external_plugins(
390 root: &Path,
391 config_plugin_paths: &[String],
392) -> Result<Vec<ExternalPluginDef>, Vec<crate::config::glob_validation::GlobValidationError>> {
393 let plugins = discover_external_plugins(root, config_plugin_paths);
394 let mut errors = Vec::new();
395 for plugin in &plugins {
396 if let Err(mut plugin_errors) = plugin.validate_user_globs() {
397 errors.append(&mut plugin_errors);
398 }
399 }
400 if errors.is_empty() {
401 Ok(plugins)
402 } else {
403 Err(errors)
404 }
405}
406
407enum PluginFormat {
409 Toml,
410 Json,
411 Jsonc,
412}
413
414impl PluginFormat {
415 fn from_path(path: &Path) -> Option<Self> {
416 match path.extension().and_then(|e| e.to_str()) {
417 Some("toml") => Some(Self::Toml),
418 Some("json") => Some(Self::Json),
419 Some("jsonc") => Some(Self::Jsonc),
420 _ => None,
421 }
422 }
423}
424
425fn is_plugin_file(path: &Path) -> bool {
427 path.extension()
428 .and_then(|e| e.to_str())
429 .is_some_and(|ext| PLUGIN_EXTENSIONS.contains(&ext))
430}
431
432fn parse_plugin(content: &str, format: &PluginFormat, path: &Path) -> Option<ExternalPluginDef> {
434 match format {
435 PluginFormat::Toml => match toml::from_str::<ExternalPluginDef>(content) {
436 Ok(plugin) => Some(plugin),
437 Err(e) => {
438 tracing::warn!("failed to parse external plugin {}: {e}", path.display());
439 None
440 }
441 },
442 PluginFormat::Json => match serde_json::from_str::<ExternalPluginDef>(content) {
443 Ok(plugin) => Some(plugin),
444 Err(e) => {
445 tracing::warn!("failed to parse external plugin {}: {e}", path.display());
446 None
447 }
448 },
449 PluginFormat::Jsonc => match crate::jsonc::parse_to_value::<ExternalPluginDef>(content) {
450 Ok(plugin) => Some(plugin),
451 Err(e) => {
452 tracing::warn!("failed to parse external plugin {}: {e}", path.display());
453 None
454 }
455 },
456 }
457}
458
459pub fn discover_external_plugins(
466 root: &Path,
467 config_plugin_paths: &[String],
468) -> Vec<ExternalPluginDef> {
469 let mut plugins = Vec::new();
470 let mut seen_names = rustc_hash::FxHashSet::default();
471
472 let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
473
474 load_configured_plugin_paths(
475 root,
476 config_plugin_paths,
477 &canonical_root,
478 &mut plugins,
479 &mut seen_names,
480 );
481 load_default_plugins_dir(root, &canonical_root, &mut plugins, &mut seen_names);
482 load_root_plugin_files(root, &canonical_root, &mut plugins, &mut seen_names);
483
484 plugins
485}
486
487fn load_configured_plugin_paths(
488 root: &Path,
489 config_plugin_paths: &[String],
490 canonical_root: &Path,
491 plugins: &mut Vec<ExternalPluginDef>,
492 seen_names: &mut rustc_hash::FxHashSet<String>,
493) {
494 for path_str in config_plugin_paths {
495 let path = root.join(path_str);
496 if !is_within_root(&path, canonical_root) {
497 tracing::warn!("plugin path '{path_str}' resolves outside project root, skipping");
498 continue;
499 }
500 if path.is_dir() {
501 load_plugins_from_dir(&path, canonical_root, plugins, seen_names);
502 } else if path.is_file() {
503 load_plugin_file(&path, canonical_root, plugins, seen_names);
504 }
505 }
506}
507
508fn load_default_plugins_dir(
509 root: &Path,
510 canonical_root: &Path,
511 plugins: &mut Vec<ExternalPluginDef>,
512 seen_names: &mut rustc_hash::FxHashSet<String>,
513) {
514 let plugins_dir = root.join(".fallow").join("plugins");
515 if plugins_dir.is_dir() && is_within_root(&plugins_dir, canonical_root) {
516 load_plugins_from_dir(&plugins_dir, canonical_root, plugins, seen_names);
517 }
518}
519
520fn load_root_plugin_files(
521 root: &Path,
522 canonical_root: &Path,
523 plugins: &mut Vec<ExternalPluginDef>,
524 seen_names: &mut rustc_hash::FxHashSet<String>,
525) {
526 if let Ok(entries) = std::fs::read_dir(root) {
527 let mut plugin_files: Vec<PathBuf> = entries
528 .filter_map(Result::ok)
529 .map(|e| e.path())
530 .filter(|p| {
531 p.is_file()
532 && p.file_name().and_then(|n| n.to_str()).is_some_and(|n| {
533 n.starts_with("fallow-plugin-") && is_plugin_file(Path::new(n))
534 })
535 })
536 .collect();
537 plugin_files.sort();
538 for path in plugin_files {
539 load_plugin_file(&path, canonical_root, plugins, seen_names);
540 }
541 }
542}
543
544#[expect(
546 clippy::redundant_pub_crate,
547 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"
548)]
549pub(crate) fn is_within_root(path: &Path, canonical_root: &Path) -> bool {
550 let canonical = dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
551 canonical.starts_with(canonical_root)
552}
553
554fn load_plugins_from_dir(
555 dir: &Path,
556 canonical_root: &Path,
557 plugins: &mut Vec<ExternalPluginDef>,
558 seen: &mut rustc_hash::FxHashSet<String>,
559) {
560 if let Ok(entries) = std::fs::read_dir(dir) {
561 let mut plugin_files: Vec<PathBuf> = entries
562 .filter_map(Result::ok)
563 .map(|e| e.path())
564 .filter(|p| p.is_file() && is_plugin_file(p))
565 .collect();
566 plugin_files.sort();
567 for path in plugin_files {
568 load_plugin_file(&path, canonical_root, plugins, seen);
569 }
570 }
571}
572
573fn load_plugin_file(
574 path: &Path,
575 canonical_root: &Path,
576 plugins: &mut Vec<ExternalPluginDef>,
577 seen: &mut rustc_hash::FxHashSet<String>,
578) {
579 if !is_within_root(path, canonical_root) {
580 tracing::warn!(
581 "plugin file '{}' resolves outside project root (symlink?), skipping",
582 path.display()
583 );
584 return;
585 }
586
587 let Some(format) = PluginFormat::from_path(path) else {
588 tracing::warn!(
589 "unsupported plugin file extension for {}, expected .toml, .json, or .jsonc",
590 path.display()
591 );
592 return;
593 };
594
595 let Some(content) = read_plugin_file(path) else {
596 return;
597 };
598
599 if let Some(plugin) = parse_plugin(&content, &format, path) {
600 push_plugin_if_unique(plugin, path, plugins, seen);
601 }
602}
603
604fn read_plugin_file(path: &Path) -> Option<String> {
605 match std::fs::read_to_string(path) {
606 Ok(content) => Some(content),
607 Err(e) => {
608 tracing::warn!(
609 "failed to read external plugin file {}: {e}",
610 path.display()
611 );
612 None
613 }
614 }
615}
616
617fn push_plugin_if_unique(
618 plugin: ExternalPluginDef,
619 path: &Path,
620 plugins: &mut Vec<ExternalPluginDef>,
621 seen: &mut rustc_hash::FxHashSet<String>,
622) {
623 if plugin.name.is_empty() {
624 tracing::warn!(
625 "external plugin in {} has an empty name, skipping",
626 path.display()
627 );
628 return;
629 }
630
631 if seen.insert(plugin.name.clone()) {
632 plugins.push(plugin);
633 } else {
634 tracing::warn!(
635 "duplicate external plugin '{}' in {}, skipping",
636 plugin.name,
637 path.display()
638 );
639 }
640}
641
642#[cfg(test)]
643mod tests {
644 use super::*;
645 use crate::ScopedUsedClassMemberRule;
646
647 #[test]
648 fn deserialize_minimal_plugin() {
649 let toml_str = r#"
650name = "my-plugin"
651enablers = ["my-pkg"]
652"#;
653 let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
654 assert_eq!(plugin.name, "my-plugin");
655 assert_eq!(plugin.enablers, vec!["my-pkg"]);
656 assert!(plugin.entry_points.is_empty());
657 assert!(plugin.always_used.is_empty());
658 assert!(plugin.config_patterns.is_empty());
659 assert!(plugin.tooling_dependencies.is_empty());
660 assert!(plugin.used_exports.is_empty());
661 assert!(plugin.used_class_members.is_empty());
662 }
663
664 #[test]
665 fn deserialize_plugin_with_used_class_members_json() {
666 let json_str = r#"{
667 "name": "ag-grid",
668 "enablers": ["ag-grid-angular"],
669 "usedClassMembers": ["agInit", "refresh"]
670 }"#;
671 let plugin: ExternalPluginDef = serde_json::from_str(json_str).unwrap();
672 assert_eq!(plugin.name, "ag-grid");
673 assert_eq!(
674 plugin.used_class_members,
675 vec![
676 UsedClassMemberRule::from("agInit"),
677 UsedClassMemberRule::from("refresh"),
678 ]
679 );
680 }
681
682 #[test]
683 fn deserialize_plugin_with_scoped_used_class_members_json() {
684 let json_str = r#"{
685 "name": "ag-grid",
686 "enablers": ["ag-grid-angular"],
687 "usedClassMembers": [
688 "agInit",
689 { "implements": "ICellRendererAngularComp", "members": ["refresh"] },
690 { "extends": "BaseCommand", "members": ["execute"] }
691 ]
692 }"#;
693 let plugin: ExternalPluginDef = serde_json::from_str(json_str).unwrap();
694 assert_eq!(
695 plugin.used_class_members,
696 vec![
697 UsedClassMemberRule::from("agInit"),
698 UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
699 extends: None,
700 implements: Some("ICellRendererAngularComp".to_string()),
701 members: vec!["refresh".to_string()],
702 }),
703 UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
704 extends: Some("BaseCommand".to_string()),
705 implements: None,
706 members: vec!["execute".to_string()],
707 }),
708 ]
709 );
710 }
711
712 #[test]
713 fn deserialize_plugin_with_used_class_members_toml() {
714 let toml_str = r#"
715name = "ag-grid"
716enablers = ["ag-grid-angular"]
717usedClassMembers = ["agInit", "refresh"]
718"#;
719 let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
720 assert_eq!(
721 plugin.used_class_members,
722 vec![
723 UsedClassMemberRule::from("agInit"),
724 UsedClassMemberRule::from("refresh"),
725 ]
726 );
727 }
728
729 #[test]
730 fn deserialize_plugin_with_scoped_used_class_members_toml() {
731 let toml_str = r#"
732name = "ag-grid"
733enablers = ["ag-grid-angular"]
734usedClassMembers = [
735 { implements = "ICellRendererAngularComp", members = ["refresh"] },
736 { extends = "BaseCommand", members = ["execute"] }
737]
738"#;
739 let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
740 assert_eq!(
741 plugin.used_class_members,
742 vec![
743 UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
744 extends: None,
745 implements: Some("ICellRendererAngularComp".to_string()),
746 members: vec!["refresh".to_string()],
747 }),
748 UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
749 extends: Some("BaseCommand".to_string()),
750 implements: None,
751 members: vec!["execute".to_string()],
752 }),
753 ]
754 );
755 }
756
757 #[test]
758 fn deserialize_plugin_rejects_unconstrained_scoped_used_class_members() {
759 let result = serde_json::from_str::<ExternalPluginDef>(
760 r#"{
761 "name": "ag-grid",
762 "enablers": ["ag-grid-angular"],
763 "usedClassMembers": [{ "members": ["refresh"] }]
764 }"#,
765 );
766 assert!(
767 result.is_err(),
768 "unconstrained scoped rule should be rejected"
769 );
770 }
771
772 #[test]
773 fn deserialize_full_plugin() {
774 let toml_str = r#"
775name = "my-framework"
776enablers = ["my-framework", "@my-framework/core"]
777entryPoints = ["src/routes/**/*.{ts,tsx}", "src/middleware.ts"]
778configPatterns = ["my-framework.config.{ts,js,mjs}"]
779alwaysUsed = ["src/setup.ts", "public/**/*"]
780toolingDependencies = ["my-framework-cli"]
781
782[[usedExports]]
783pattern = "src/routes/**/*.{ts,tsx}"
784exports = ["default", "loader", "action"]
785
786[[usedExports]]
787pattern = "src/middleware.ts"
788exports = ["default"]
789"#;
790 let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
791 assert_eq!(plugin.name, "my-framework");
792 assert_eq!(plugin.enablers.len(), 2);
793 assert_eq!(plugin.entry_points.len(), 2);
794 assert_eq!(
795 plugin.config_patterns,
796 vec!["my-framework.config.{ts,js,mjs}"]
797 );
798 assert_eq!(plugin.always_used.len(), 2);
799 assert_eq!(plugin.tooling_dependencies, vec!["my-framework-cli"]);
800 assert_eq!(plugin.used_exports.len(), 2);
801 assert_eq!(plugin.used_exports[0].pattern, "src/routes/**/*.{ts,tsx}");
802 assert_eq!(
803 plugin.used_exports[0].exports,
804 vec!["default", "loader", "action"]
805 );
806 }
807
808 #[test]
809 fn deserialize_json_plugin() {
810 let json_str = r#"{
811 "name": "my-json-plugin",
812 "enablers": ["my-pkg"],
813 "entryPoints": ["src/**/*.ts"],
814 "configPatterns": ["my-plugin.config.js"],
815 "alwaysUsed": ["src/setup.ts"],
816 "toolingDependencies": ["my-cli"],
817 "usedExports": [
818 { "pattern": "src/**/*.ts", "exports": ["default"] }
819 ]
820 }"#;
821 let plugin: ExternalPluginDef = serde_json::from_str(json_str).unwrap();
822 assert_eq!(plugin.name, "my-json-plugin");
823 assert_eq!(plugin.enablers, vec!["my-pkg"]);
824 assert_eq!(plugin.entry_points, vec!["src/**/*.ts"]);
825 assert_eq!(plugin.config_patterns, vec!["my-plugin.config.js"]);
826 assert_eq!(plugin.always_used, vec!["src/setup.ts"]);
827 assert_eq!(plugin.tooling_dependencies, vec!["my-cli"]);
828 assert_eq!(plugin.used_exports.len(), 1);
829 assert_eq!(plugin.used_exports[0].exports, vec!["default"]);
830 }
831
832 #[test]
833 fn deserialize_jsonc_plugin() {
834 let jsonc_str = r#"{
835 "name": "my-jsonc-plugin",
836 "enablers": ["my-pkg"],
837 /* Block comment */
838 "entryPoints": ["src/**/*.ts"]
839 }"#;
840 let plugin: ExternalPluginDef = crate::jsonc::parse_to_value(jsonc_str).unwrap();
841 assert_eq!(plugin.name, "my-jsonc-plugin");
842 assert_eq!(plugin.enablers, vec!["my-pkg"]);
843 assert_eq!(plugin.entry_points, vec!["src/**/*.ts"]);
844 }
845
846 #[test]
847 fn deserialize_json_with_schema_field() {
848 let json_str = r#"{
849 "$schema": "https://raw.githubusercontent.com/fallow-rs/fallow/main/plugin-schema.json",
850 "name": "schema-plugin",
851 "enablers": ["my-pkg"]
852 }"#;
853 let plugin: ExternalPluginDef = serde_json::from_str(json_str).unwrap();
854 assert_eq!(plugin.name, "schema-plugin");
855 assert_eq!(plugin.enablers, vec!["my-pkg"]);
856 }
857
858 #[test]
859 fn plugin_json_schema_generation() {
860 let schema = ExternalPluginDef::json_schema();
861 assert!(schema.is_object());
862 let obj = schema.as_object().unwrap();
863 assert!(obj.contains_key("properties"));
864 }
865
866 #[test]
867 fn discover_plugins_from_fallow_plugins_dir() {
868 let dir =
869 std::env::temp_dir().join(format!("fallow-test-ext-plugins-{}", std::process::id()));
870 let plugins_dir = dir.join(".fallow").join("plugins");
871 let _ = std::fs::create_dir_all(&plugins_dir);
872
873 std::fs::write(
874 plugins_dir.join("my-plugin.toml"),
875 r#"
876name = "my-plugin"
877enablers = ["my-pkg"]
878entryPoints = ["src/**/*.ts"]
879"#,
880 )
881 .unwrap();
882
883 let plugins = discover_external_plugins(&dir, &[]);
884 assert_eq!(plugins.len(), 1);
885 assert_eq!(plugins[0].name, "my-plugin");
886
887 let _ = std::fs::remove_dir_all(&dir);
888 }
889
890 #[test]
891 fn discover_json_plugins_from_fallow_plugins_dir() {
892 let dir = std::env::temp_dir().join(format!(
893 "fallow-test-ext-json-plugins-{}",
894 std::process::id()
895 ));
896 let plugins_dir = dir.join(".fallow").join("plugins");
897 let _ = std::fs::create_dir_all(&plugins_dir);
898
899 std::fs::write(
900 plugins_dir.join("my-plugin.json"),
901 r#"{"name": "json-plugin", "enablers": ["json-pkg"]}"#,
902 )
903 .unwrap();
904
905 std::fs::write(
906 plugins_dir.join("my-plugin.jsonc"),
907 r#"{
908 "name": "jsonc-plugin",
909 "enablers": ["jsonc-pkg"]
910 }"#,
911 )
912 .unwrap();
913
914 let plugins = discover_external_plugins(&dir, &[]);
915 assert_eq!(plugins.len(), 2);
916 assert_eq!(plugins[0].name, "json-plugin");
917 assert_eq!(plugins[1].name, "jsonc-plugin");
918
919 let _ = std::fs::remove_dir_all(&dir);
920 }
921
922 #[test]
923 fn discover_fallow_plugin_files_in_root() {
924 let dir =
925 std::env::temp_dir().join(format!("fallow-test-root-plugins-{}", std::process::id()));
926 let _ = std::fs::create_dir_all(&dir);
927
928 std::fs::write(
929 dir.join("fallow-plugin-custom.toml"),
930 r#"
931name = "custom"
932enablers = ["custom-pkg"]
933"#,
934 )
935 .unwrap();
936
937 std::fs::write(dir.join("some-other-file.toml"), r#"name = "ignored""#).unwrap();
938
939 let plugins = discover_external_plugins(&dir, &[]);
940 assert_eq!(plugins.len(), 1);
941 assert_eq!(plugins[0].name, "custom");
942
943 let _ = std::fs::remove_dir_all(&dir);
944 }
945
946 #[test]
947 fn discover_fallow_plugin_json_files_in_root() {
948 let dir = std::env::temp_dir().join(format!(
949 "fallow-test-root-json-plugins-{}",
950 std::process::id()
951 ));
952 let _ = std::fs::create_dir_all(&dir);
953
954 std::fs::write(
955 dir.join("fallow-plugin-custom.json"),
956 r#"{"name": "json-root", "enablers": ["json-pkg"]}"#,
957 )
958 .unwrap();
959
960 std::fs::write(
961 dir.join("fallow-plugin-custom2.jsonc"),
962 r#"{
963 "name": "jsonc-root",
964 "enablers": ["jsonc-pkg"]
965 }"#,
966 )
967 .unwrap();
968
969 std::fs::write(
970 dir.join("fallow-plugin-bad.yaml"),
971 "name: ignored\nenablers:\n - pkg\n",
972 )
973 .unwrap();
974
975 let plugins = discover_external_plugins(&dir, &[]);
976 assert_eq!(plugins.len(), 2);
977
978 let _ = std::fs::remove_dir_all(&dir);
979 }
980
981 #[test]
982 fn discover_mixed_formats_in_dir() {
983 let dir =
984 std::env::temp_dir().join(format!("fallow-test-mixed-plugins-{}", std::process::id()));
985 let plugins_dir = dir.join(".fallow").join("plugins");
986 let _ = std::fs::create_dir_all(&plugins_dir);
987
988 std::fs::write(
989 plugins_dir.join("a-plugin.toml"),
990 r#"
991name = "toml-plugin"
992enablers = ["toml-pkg"]
993"#,
994 )
995 .unwrap();
996
997 std::fs::write(
998 plugins_dir.join("b-plugin.json"),
999 r#"{"name": "json-plugin", "enablers": ["json-pkg"]}"#,
1000 )
1001 .unwrap();
1002
1003 std::fs::write(
1004 plugins_dir.join("c-plugin.jsonc"),
1005 r#"{
1006 "name": "jsonc-plugin",
1007 "enablers": ["jsonc-pkg"]
1008 }"#,
1009 )
1010 .unwrap();
1011
1012 let plugins = discover_external_plugins(&dir, &[]);
1013 assert_eq!(plugins.len(), 3);
1014 assert_eq!(plugins[0].name, "toml-plugin");
1015 assert_eq!(plugins[1].name, "json-plugin");
1016 assert_eq!(plugins[2].name, "jsonc-plugin");
1017
1018 let _ = std::fs::remove_dir_all(&dir);
1019 }
1020
1021 #[test]
1022 fn deduplicates_by_name() {
1023 let dir =
1024 std::env::temp_dir().join(format!("fallow-test-dedup-plugins-{}", std::process::id()));
1025 let plugins_dir = dir.join(".fallow").join("plugins");
1026 let _ = std::fs::create_dir_all(&plugins_dir);
1027
1028 std::fs::write(
1029 plugins_dir.join("my-plugin.toml"),
1030 r#"
1031name = "my-plugin"
1032enablers = ["pkg-a"]
1033"#,
1034 )
1035 .unwrap();
1036
1037 std::fs::write(
1038 dir.join("fallow-plugin-my-plugin.toml"),
1039 r#"
1040name = "my-plugin"
1041enablers = ["pkg-b"]
1042"#,
1043 )
1044 .unwrap();
1045
1046 let plugins = discover_external_plugins(&dir, &[]);
1047 assert_eq!(plugins.len(), 1);
1048 assert_eq!(plugins[0].enablers, vec!["pkg-a"]);
1049
1050 let _ = std::fs::remove_dir_all(&dir);
1051 }
1052
1053 #[test]
1054 fn config_plugin_paths_take_priority() {
1055 let dir =
1056 std::env::temp_dir().join(format!("fallow-test-config-paths-{}", std::process::id()));
1057 let custom_dir = dir.join("custom-plugins");
1058 let _ = std::fs::create_dir_all(&custom_dir);
1059
1060 std::fs::write(
1061 custom_dir.join("explicit.toml"),
1062 r#"
1063name = "explicit"
1064enablers = ["explicit-pkg"]
1065"#,
1066 )
1067 .unwrap();
1068
1069 let plugins = discover_external_plugins(&dir, &["custom-plugins".to_string()]);
1070 assert_eq!(plugins.len(), 1);
1071 assert_eq!(plugins[0].name, "explicit");
1072
1073 let _ = std::fs::remove_dir_all(&dir);
1074 }
1075
1076 #[test]
1077 fn config_plugin_path_to_single_file() {
1078 let dir =
1079 std::env::temp_dir().join(format!("fallow-test-single-file-{}", std::process::id()));
1080 let _ = std::fs::create_dir_all(&dir);
1081
1082 std::fs::write(
1083 dir.join("my-plugin.toml"),
1084 r#"
1085name = "single-file"
1086enablers = ["single-pkg"]
1087"#,
1088 )
1089 .unwrap();
1090
1091 let plugins = discover_external_plugins(&dir, &["my-plugin.toml".to_string()]);
1092 assert_eq!(plugins.len(), 1);
1093 assert_eq!(plugins[0].name, "single-file");
1094
1095 let _ = std::fs::remove_dir_all(&dir);
1096 }
1097
1098 #[test]
1099 fn config_plugin_path_to_single_json_file() {
1100 let dir = std::env::temp_dir().join(format!(
1101 "fallow-test-single-json-file-{}",
1102 std::process::id()
1103 ));
1104 let _ = std::fs::create_dir_all(&dir);
1105
1106 std::fs::write(
1107 dir.join("my-plugin.json"),
1108 r#"{"name": "json-single", "enablers": ["json-pkg"]}"#,
1109 )
1110 .unwrap();
1111
1112 let plugins = discover_external_plugins(&dir, &["my-plugin.json".to_string()]);
1113 assert_eq!(plugins.len(), 1);
1114 assert_eq!(plugins[0].name, "json-single");
1115
1116 let _ = std::fs::remove_dir_all(&dir);
1117 }
1118
1119 #[test]
1120 fn skips_invalid_toml() {
1121 let dir =
1122 std::env::temp_dir().join(format!("fallow-test-invalid-plugin-{}", std::process::id()));
1123 let plugins_dir = dir.join(".fallow").join("plugins");
1124 let _ = std::fs::create_dir_all(&plugins_dir);
1125
1126 std::fs::write(plugins_dir.join("bad.toml"), r#"enablers = ["pkg"]"#).unwrap();
1127
1128 std::fs::write(
1129 plugins_dir.join("good.toml"),
1130 r#"
1131name = "good"
1132enablers = ["good-pkg"]
1133"#,
1134 )
1135 .unwrap();
1136
1137 let plugins = discover_external_plugins(&dir, &[]);
1138 assert_eq!(plugins.len(), 1);
1139 assert_eq!(plugins[0].name, "good");
1140
1141 let _ = std::fs::remove_dir_all(&dir);
1142 }
1143
1144 #[test]
1145 fn skips_invalid_json() {
1146 let dir = std::env::temp_dir().join(format!(
1147 "fallow-test-invalid-json-plugin-{}",
1148 std::process::id()
1149 ));
1150 let plugins_dir = dir.join(".fallow").join("plugins");
1151 let _ = std::fs::create_dir_all(&plugins_dir);
1152
1153 std::fs::write(plugins_dir.join("bad.json"), r#"{"enablers": ["pkg"]}"#).unwrap();
1154
1155 std::fs::write(
1156 plugins_dir.join("good.json"),
1157 r#"{"name": "good-json", "enablers": ["good-pkg"]}"#,
1158 )
1159 .unwrap();
1160
1161 let plugins = discover_external_plugins(&dir, &[]);
1162 assert_eq!(plugins.len(), 1);
1163 assert_eq!(plugins[0].name, "good-json");
1164
1165 let _ = std::fs::remove_dir_all(&dir);
1166 }
1167
1168 #[test]
1169 fn prefix_enablers() {
1170 let toml_str = r#"
1171name = "scoped"
1172enablers = ["@myorg/"]
1173"#;
1174 let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
1175 assert_eq!(plugin.enablers, vec!["@myorg/"]);
1176 }
1177
1178 #[test]
1179 fn skips_empty_name() {
1180 let dir =
1181 std::env::temp_dir().join(format!("fallow-test-empty-name-{}", std::process::id()));
1182 let plugins_dir = dir.join(".fallow").join("plugins");
1183 let _ = std::fs::create_dir_all(&plugins_dir);
1184
1185 std::fs::write(
1186 plugins_dir.join("empty.toml"),
1187 r#"
1188name = ""
1189enablers = ["pkg"]
1190"#,
1191 )
1192 .unwrap();
1193
1194 let plugins = discover_external_plugins(&dir, &[]);
1195 assert!(plugins.is_empty(), "empty-name plugin should be skipped");
1196
1197 let _ = std::fs::remove_dir_all(&dir);
1198 }
1199
1200 #[test]
1201 fn rejects_paths_outside_root() {
1202 let dir =
1203 std::env::temp_dir().join(format!("fallow-test-path-escape-{}", std::process::id()));
1204 let _ = std::fs::create_dir_all(&dir);
1205
1206 let plugins = discover_external_plugins(&dir, &["../../../etc".to_string()]);
1207 assert!(plugins.is_empty(), "paths outside root should be rejected");
1208
1209 let _ = std::fs::remove_dir_all(&dir);
1210 }
1211
1212 #[test]
1213 fn plugin_format_detection() {
1214 assert!(matches!(
1215 PluginFormat::from_path(Path::new("plugin.toml")),
1216 Some(PluginFormat::Toml)
1217 ));
1218 assert!(matches!(
1219 PluginFormat::from_path(Path::new("plugin.json")),
1220 Some(PluginFormat::Json)
1221 ));
1222 assert!(matches!(
1223 PluginFormat::from_path(Path::new("plugin.jsonc")),
1224 Some(PluginFormat::Jsonc)
1225 ));
1226 assert!(PluginFormat::from_path(Path::new("plugin.yaml")).is_none());
1227 assert!(PluginFormat::from_path(Path::new("plugin")).is_none());
1228 }
1229
1230 #[test]
1231 fn is_plugin_file_checks_extensions() {
1232 assert!(is_plugin_file(Path::new("plugin.toml")));
1233 assert!(is_plugin_file(Path::new("plugin.json")));
1234 assert!(is_plugin_file(Path::new("plugin.jsonc")));
1235 assert!(!is_plugin_file(Path::new("plugin.yaml")));
1236 assert!(!is_plugin_file(Path::new("plugin.txt")));
1237 assert!(!is_plugin_file(Path::new("plugin")));
1238 }
1239
1240 #[test]
1241 fn detection_deserialize_dependency() {
1242 let json = r#"{"type": "dependency", "package": "next"}"#;
1243 let detection: PluginDetection = serde_json::from_str(json).unwrap();
1244 assert!(matches!(detection, PluginDetection::Dependency { package } if package == "next"));
1245 }
1246
1247 #[test]
1248 fn detection_deserialize_file_exists() {
1249 let json = r#"{"type": "fileExists", "pattern": "tsconfig.json"}"#;
1250 let detection: PluginDetection = serde_json::from_str(json).unwrap();
1251 assert!(
1252 matches!(detection, PluginDetection::FileExists { pattern } if pattern == "tsconfig.json")
1253 );
1254 }
1255
1256 #[test]
1257 fn detection_deserialize_all() {
1258 let json = r#"{"type": "all", "conditions": [{"type": "dependency", "package": "a"}, {"type": "dependency", "package": "b"}]}"#;
1259 let detection: PluginDetection = serde_json::from_str(json).unwrap();
1260 assert!(matches!(detection, PluginDetection::All { conditions } if conditions.len() == 2));
1261 }
1262
1263 #[test]
1264 fn detection_deserialize_any() {
1265 let json = r#"{"type": "any", "conditions": [{"type": "dependency", "package": "a"}]}"#;
1266 let detection: PluginDetection = serde_json::from_str(json).unwrap();
1267 assert!(matches!(detection, PluginDetection::Any { conditions } if conditions.len() == 1));
1268 }
1269
1270 #[test]
1271 fn plugin_with_detection_field() {
1272 let json = r#"{
1273 "name": "my-plugin",
1274 "detection": {"type": "dependency", "package": "my-pkg"},
1275 "entryPoints": ["src/**/*.ts"]
1276 }"#;
1277 let plugin: ExternalPluginDef = serde_json::from_str(json).unwrap();
1278 assert_eq!(plugin.name, "my-plugin");
1279 assert!(plugin.detection.is_some());
1280 assert!(plugin.enablers.is_empty());
1281 assert_eq!(plugin.entry_points, vec!["src/**/*.ts"]);
1282 }
1283
1284 #[test]
1285 fn plugin_without_detection_uses_enablers() {
1286 let json = r#"{
1287 "name": "my-plugin",
1288 "enablers": ["my-pkg"]
1289 }"#;
1290 let plugin: ExternalPluginDef = serde_json::from_str(json).unwrap();
1291 assert!(plugin.detection.is_none());
1292 assert_eq!(plugin.enablers, vec!["my-pkg"]);
1293 }
1294
1295 #[test]
1296 fn detection_nested_all_with_any() {
1297 let json = r#"{
1298 "type": "all",
1299 "conditions": [
1300 {"type": "dependency", "package": "react"},
1301 {"type": "any", "conditions": [
1302 {"type": "fileExists", "pattern": "next.config.js"},
1303 {"type": "fileExists", "pattern": "next.config.mjs"}
1304 ]}
1305 ]
1306 }"#;
1307 let detection: PluginDetection = serde_json::from_str(json).unwrap();
1308 match detection {
1309 PluginDetection::All { conditions } => {
1310 assert_eq!(conditions.len(), 2);
1311 assert!(matches!(
1312 &conditions[0],
1313 PluginDetection::Dependency { package } if package == "react"
1314 ));
1315 match &conditions[1] {
1316 PluginDetection::Any { conditions: inner } => {
1317 assert_eq!(inner.len(), 2);
1318 }
1319 other => panic!("expected Any, got: {other:?}"),
1320 }
1321 }
1322 other => panic!("expected All, got: {other:?}"),
1323 }
1324 }
1325
1326 #[test]
1327 fn detection_empty_all_conditions() {
1328 let json = r#"{"type": "all", "conditions": []}"#;
1329 let detection: PluginDetection = serde_json::from_str(json).unwrap();
1330 assert!(matches!(
1331 detection,
1332 PluginDetection::All { conditions } if conditions.is_empty()
1333 ));
1334 }
1335
1336 #[test]
1337 fn detection_empty_any_conditions() {
1338 let json = r#"{"type": "any", "conditions": []}"#;
1339 let detection: PluginDetection = serde_json::from_str(json).unwrap();
1340 assert!(matches!(
1341 detection,
1342 PluginDetection::Any { conditions } if conditions.is_empty()
1343 ));
1344 }
1345
1346 #[test]
1347 fn detection_toml_dependency() {
1348 let toml_str = r#"
1349name = "my-plugin"
1350
1351[detection]
1352type = "dependency"
1353package = "next"
1354"#;
1355 let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
1356 assert!(plugin.detection.is_some());
1357 assert!(matches!(
1358 plugin.detection.unwrap(),
1359 PluginDetection::Dependency { package } if package == "next"
1360 ));
1361 }
1362
1363 #[test]
1364 fn detection_toml_file_exists() {
1365 let toml_str = r#"
1366name = "my-plugin"
1367
1368[detection]
1369type = "fileExists"
1370pattern = "next.config.js"
1371"#;
1372 let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
1373 assert!(matches!(
1374 plugin.detection.unwrap(),
1375 PluginDetection::FileExists { pattern } if pattern == "next.config.js"
1376 ));
1377 }
1378
1379 #[test]
1380 fn plugin_all_fields_json() {
1381 let json = r#"{
1382 "$schema": "https://raw.githubusercontent.com/fallow-rs/fallow/main/plugin-schema.json",
1383 "name": "full-plugin",
1384 "detection": {"type": "dependency", "package": "my-pkg"},
1385 "enablers": ["fallback-enabler"],
1386 "entryPoints": ["src/entry.ts"],
1387 "configPatterns": ["config.js"],
1388 "alwaysUsed": ["src/polyfills.ts"],
1389 "toolingDependencies": ["my-cli"],
1390 "usedExports": [{"pattern": "src/**", "exports": ["default", "setup"]}]
1391 }"#;
1392 let plugin: ExternalPluginDef = serde_json::from_str(json).unwrap();
1393 assert_eq!(plugin.name, "full-plugin");
1394 assert!(plugin.detection.is_some());
1395 assert_eq!(plugin.enablers, vec!["fallback-enabler"]);
1396 assert_eq!(plugin.entry_points, vec!["src/entry.ts"]);
1397 assert_eq!(plugin.config_patterns, vec!["config.js"]);
1398 assert_eq!(plugin.always_used, vec!["src/polyfills.ts"]);
1399 assert_eq!(plugin.tooling_dependencies, vec!["my-cli"]);
1400 assert_eq!(plugin.used_exports.len(), 1);
1401 assert_eq!(plugin.used_exports[0].pattern, "src/**");
1402 assert_eq!(plugin.used_exports[0].exports, vec!["default", "setup"]);
1403 }
1404
1405 #[test]
1406 fn plugin_with_special_chars_in_name() {
1407 let json = r#"{"name": "@scope/my-plugin-v2.0", "enablers": ["pkg"]}"#;
1408 let plugin: ExternalPluginDef = serde_json::from_str(json).unwrap();
1409 assert_eq!(plugin.name, "@scope/my-plugin-v2.0");
1410 }
1411
1412 #[test]
1413 fn parse_plugin_toml_format() {
1414 let content = r#"
1415name = "test-plugin"
1416enablers = ["test-pkg"]
1417entryPoints = ["src/**/*.ts"]
1418"#;
1419 let result = parse_plugin(content, &PluginFormat::Toml, Path::new("test.toml"));
1420 assert!(result.is_some());
1421 let plugin = result.unwrap();
1422 assert_eq!(plugin.name, "test-plugin");
1423 }
1424
1425 #[test]
1426 fn parse_plugin_json_format() {
1427 let content = r#"{"name": "json-test", "enablers": ["pkg"]}"#;
1428 let result = parse_plugin(content, &PluginFormat::Json, Path::new("test.json"));
1429 assert!(result.is_some());
1430 assert_eq!(result.unwrap().name, "json-test");
1431 }
1432
1433 #[test]
1434 fn parse_plugin_jsonc_format() {
1435 let content = r#"{
1436 "name": "jsonc-test",
1437 "enablers": ["pkg"]
1438 }"#;
1439 let result = parse_plugin(content, &PluginFormat::Jsonc, Path::new("test.jsonc"));
1440 assert!(result.is_some());
1441 assert_eq!(result.unwrap().name, "jsonc-test");
1442 }
1443
1444 #[test]
1445 fn parse_plugin_invalid_toml_returns_none() {
1446 let content = "not valid toml [[[";
1447 let result = parse_plugin(content, &PluginFormat::Toml, Path::new("bad.toml"));
1448 assert!(result.is_none());
1449 }
1450
1451 #[test]
1452 fn parse_plugin_invalid_json_returns_none() {
1453 let content = "{ not valid json }";
1454 let result = parse_plugin(content, &PluginFormat::Json, Path::new("bad.json"));
1455 assert!(result.is_none());
1456 }
1457
1458 #[test]
1459 fn parse_plugin_invalid_jsonc_returns_none() {
1460 let content = r#"{"enablers": ["pkg"]}"#;
1461 let result = parse_plugin(content, &PluginFormat::Jsonc, Path::new("bad.jsonc"));
1462 assert!(result.is_none());
1463 }
1464}