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