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