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