1use std::io::Read as _;
2use std::path::{Path, PathBuf};
3
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6
7const PLUGIN_EXTENSIONS: &[&str] = &["toml", "json", "jsonc"];
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema, Default)]
12#[serde(rename_all = "camelCase")]
13pub enum EntryPointRole {
14 Runtime,
16 Test,
18 #[default]
20 Support,
21}
22
23#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
28#[serde(tag = "type", rename_all = "camelCase")]
29pub enum PluginDetection {
30 Dependency { package: String },
32 FileExists { pattern: String },
34 All { conditions: Vec<Self> },
36 Any { conditions: Vec<Self> },
38}
39
40#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
67#[serde(rename_all = "camelCase")]
68pub struct ExternalPluginDef {
69 #[serde(rename = "$schema", default, skip_serializing)]
71 #[schemars(skip)]
72 pub schema: Option<String>,
73
74 pub name: String,
76
77 #[serde(default)]
80 pub detection: Option<PluginDetection>,
81
82 #[serde(default)]
86 pub enablers: Vec<String>,
87
88 #[serde(default)]
90 pub entry_points: Vec<String>,
91
92 #[serde(default = "default_external_entry_point_role")]
97 pub entry_point_role: EntryPointRole,
98
99 #[serde(default)]
101 pub config_patterns: Vec<String>,
102
103 #[serde(default)]
105 pub always_used: Vec<String>,
106
107 #[serde(default)]
110 pub tooling_dependencies: Vec<String>,
111
112 #[serde(default)]
114 pub used_exports: Vec<ExternalUsedExport>,
115
116 #[serde(default)]
123 pub used_class_members: Vec<String>,
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
349 #[test]
350 fn deserialize_minimal_plugin() {
351 let toml_str = r#"
352name = "my-plugin"
353enablers = ["my-pkg"]
354"#;
355 let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
356 assert_eq!(plugin.name, "my-plugin");
357 assert_eq!(plugin.enablers, vec!["my-pkg"]);
358 assert!(plugin.entry_points.is_empty());
359 assert!(plugin.always_used.is_empty());
360 assert!(plugin.config_patterns.is_empty());
361 assert!(plugin.tooling_dependencies.is_empty());
362 assert!(plugin.used_exports.is_empty());
363 assert!(plugin.used_class_members.is_empty());
364 }
365
366 #[test]
367 fn deserialize_plugin_with_used_class_members_json() {
368 let json_str = r#"{
369 "name": "ag-grid",
370 "enablers": ["ag-grid-angular"],
371 "usedClassMembers": ["agInit", "refresh"]
372 }"#;
373 let plugin: ExternalPluginDef = serde_json::from_str(json_str).unwrap();
374 assert_eq!(plugin.name, "ag-grid");
375 assert_eq!(
376 plugin.used_class_members,
377 vec!["agInit".to_string(), "refresh".to_string()]
378 );
379 }
380
381 #[test]
382 fn deserialize_plugin_with_used_class_members_toml() {
383 let toml_str = r#"
384name = "ag-grid"
385enablers = ["ag-grid-angular"]
386usedClassMembers = ["agInit", "refresh"]
387"#;
388 let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
389 assert_eq!(
390 plugin.used_class_members,
391 vec!["agInit".to_string(), "refresh".to_string()]
392 );
393 }
394
395 #[test]
396 fn deserialize_full_plugin() {
397 let toml_str = r#"
398name = "my-framework"
399enablers = ["my-framework", "@my-framework/core"]
400entryPoints = ["src/routes/**/*.{ts,tsx}", "src/middleware.ts"]
401configPatterns = ["my-framework.config.{ts,js,mjs}"]
402alwaysUsed = ["src/setup.ts", "public/**/*"]
403toolingDependencies = ["my-framework-cli"]
404
405[[usedExports]]
406pattern = "src/routes/**/*.{ts,tsx}"
407exports = ["default", "loader", "action"]
408
409[[usedExports]]
410pattern = "src/middleware.ts"
411exports = ["default"]
412"#;
413 let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
414 assert_eq!(plugin.name, "my-framework");
415 assert_eq!(plugin.enablers.len(), 2);
416 assert_eq!(plugin.entry_points.len(), 2);
417 assert_eq!(
418 plugin.config_patterns,
419 vec!["my-framework.config.{ts,js,mjs}"]
420 );
421 assert_eq!(plugin.always_used.len(), 2);
422 assert_eq!(plugin.tooling_dependencies, vec!["my-framework-cli"]);
423 assert_eq!(plugin.used_exports.len(), 2);
424 assert_eq!(plugin.used_exports[0].pattern, "src/routes/**/*.{ts,tsx}");
425 assert_eq!(
426 plugin.used_exports[0].exports,
427 vec!["default", "loader", "action"]
428 );
429 }
430
431 #[test]
432 fn deserialize_json_plugin() {
433 let json_str = r#"{
434 "name": "my-json-plugin",
435 "enablers": ["my-pkg"],
436 "entryPoints": ["src/**/*.ts"],
437 "configPatterns": ["my-plugin.config.js"],
438 "alwaysUsed": ["src/setup.ts"],
439 "toolingDependencies": ["my-cli"],
440 "usedExports": [
441 { "pattern": "src/**/*.ts", "exports": ["default"] }
442 ]
443 }"#;
444 let plugin: ExternalPluginDef = serde_json::from_str(json_str).unwrap();
445 assert_eq!(plugin.name, "my-json-plugin");
446 assert_eq!(plugin.enablers, vec!["my-pkg"]);
447 assert_eq!(plugin.entry_points, vec!["src/**/*.ts"]);
448 assert_eq!(plugin.config_patterns, vec!["my-plugin.config.js"]);
449 assert_eq!(plugin.always_used, vec!["src/setup.ts"]);
450 assert_eq!(plugin.tooling_dependencies, vec!["my-cli"]);
451 assert_eq!(plugin.used_exports.len(), 1);
452 assert_eq!(plugin.used_exports[0].exports, vec!["default"]);
453 }
454
455 #[test]
456 fn deserialize_jsonc_plugin() {
457 let jsonc_str = r#"{
458 // This is a JSONC plugin
459 "name": "my-jsonc-plugin",
460 "enablers": ["my-pkg"],
461 /* Block comment */
462 "entryPoints": ["src/**/*.ts"]
463 }"#;
464 let mut stripped = String::new();
465 json_comments::StripComments::new(jsonc_str.as_bytes())
466 .read_to_string(&mut stripped)
467 .unwrap();
468 let plugin: ExternalPluginDef = serde_json::from_str(&stripped).unwrap();
469 assert_eq!(plugin.name, "my-jsonc-plugin");
470 assert_eq!(plugin.enablers, vec!["my-pkg"]);
471 assert_eq!(plugin.entry_points, vec!["src/**/*.ts"]);
472 }
473
474 #[test]
475 fn deserialize_json_with_schema_field() {
476 let json_str = r#"{
477 "$schema": "https://fallow.dev/plugin-schema.json",
478 "name": "schema-plugin",
479 "enablers": ["my-pkg"]
480 }"#;
481 let plugin: ExternalPluginDef = serde_json::from_str(json_str).unwrap();
482 assert_eq!(plugin.name, "schema-plugin");
483 assert_eq!(plugin.enablers, vec!["my-pkg"]);
484 }
485
486 #[test]
487 fn plugin_json_schema_generation() {
488 let schema = ExternalPluginDef::json_schema();
489 assert!(schema.is_object());
490 let obj = schema.as_object().unwrap();
491 assert!(obj.contains_key("properties"));
492 }
493
494 #[test]
495 fn discover_plugins_from_fallow_plugins_dir() {
496 let dir =
497 std::env::temp_dir().join(format!("fallow-test-ext-plugins-{}", std::process::id()));
498 let plugins_dir = dir.join(".fallow").join("plugins");
499 let _ = std::fs::create_dir_all(&plugins_dir);
500
501 std::fs::write(
502 plugins_dir.join("my-plugin.toml"),
503 r#"
504name = "my-plugin"
505enablers = ["my-pkg"]
506entryPoints = ["src/**/*.ts"]
507"#,
508 )
509 .unwrap();
510
511 let plugins = discover_external_plugins(&dir, &[]);
512 assert_eq!(plugins.len(), 1);
513 assert_eq!(plugins[0].name, "my-plugin");
514
515 let _ = std::fs::remove_dir_all(&dir);
516 }
517
518 #[test]
519 fn discover_json_plugins_from_fallow_plugins_dir() {
520 let dir = std::env::temp_dir().join(format!(
521 "fallow-test-ext-json-plugins-{}",
522 std::process::id()
523 ));
524 let plugins_dir = dir.join(".fallow").join("plugins");
525 let _ = std::fs::create_dir_all(&plugins_dir);
526
527 std::fs::write(
528 plugins_dir.join("my-plugin.json"),
529 r#"{"name": "json-plugin", "enablers": ["json-pkg"]}"#,
530 )
531 .unwrap();
532
533 std::fs::write(
534 plugins_dir.join("my-plugin.jsonc"),
535 r#"{
536 // JSONC plugin
537 "name": "jsonc-plugin",
538 "enablers": ["jsonc-pkg"]
539 }"#,
540 )
541 .unwrap();
542
543 let plugins = discover_external_plugins(&dir, &[]);
544 assert_eq!(plugins.len(), 2);
545 assert_eq!(plugins[0].name, "json-plugin");
547 assert_eq!(plugins[1].name, "jsonc-plugin");
548
549 let _ = std::fs::remove_dir_all(&dir);
550 }
551
552 #[test]
553 fn discover_fallow_plugin_files_in_root() {
554 let dir =
555 std::env::temp_dir().join(format!("fallow-test-root-plugins-{}", std::process::id()));
556 let _ = std::fs::create_dir_all(&dir);
557
558 std::fs::write(
559 dir.join("fallow-plugin-custom.toml"),
560 r#"
561name = "custom"
562enablers = ["custom-pkg"]
563"#,
564 )
565 .unwrap();
566
567 std::fs::write(dir.join("some-other-file.toml"), r#"name = "ignored""#).unwrap();
569
570 let plugins = discover_external_plugins(&dir, &[]);
571 assert_eq!(plugins.len(), 1);
572 assert_eq!(plugins[0].name, "custom");
573
574 let _ = std::fs::remove_dir_all(&dir);
575 }
576
577 #[test]
578 fn discover_fallow_plugin_json_files_in_root() {
579 let dir = std::env::temp_dir().join(format!(
580 "fallow-test-root-json-plugins-{}",
581 std::process::id()
582 ));
583 let _ = std::fs::create_dir_all(&dir);
584
585 std::fs::write(
586 dir.join("fallow-plugin-custom.json"),
587 r#"{"name": "json-root", "enablers": ["json-pkg"]}"#,
588 )
589 .unwrap();
590
591 std::fs::write(
592 dir.join("fallow-plugin-custom2.jsonc"),
593 r#"{
594 // JSONC root plugin
595 "name": "jsonc-root",
596 "enablers": ["jsonc-pkg"]
597 }"#,
598 )
599 .unwrap();
600
601 std::fs::write(
603 dir.join("fallow-plugin-bad.yaml"),
604 "name: ignored\nenablers:\n - pkg\n",
605 )
606 .unwrap();
607
608 let plugins = discover_external_plugins(&dir, &[]);
609 assert_eq!(plugins.len(), 2);
610
611 let _ = std::fs::remove_dir_all(&dir);
612 }
613
614 #[test]
615 fn discover_mixed_formats_in_dir() {
616 let dir =
617 std::env::temp_dir().join(format!("fallow-test-mixed-plugins-{}", std::process::id()));
618 let plugins_dir = dir.join(".fallow").join("plugins");
619 let _ = std::fs::create_dir_all(&plugins_dir);
620
621 std::fs::write(
622 plugins_dir.join("a-plugin.toml"),
623 r#"
624name = "toml-plugin"
625enablers = ["toml-pkg"]
626"#,
627 )
628 .unwrap();
629
630 std::fs::write(
631 plugins_dir.join("b-plugin.json"),
632 r#"{"name": "json-plugin", "enablers": ["json-pkg"]}"#,
633 )
634 .unwrap();
635
636 std::fs::write(
637 plugins_dir.join("c-plugin.jsonc"),
638 r#"{
639 // JSONC plugin
640 "name": "jsonc-plugin",
641 "enablers": ["jsonc-pkg"]
642 }"#,
643 )
644 .unwrap();
645
646 let plugins = discover_external_plugins(&dir, &[]);
647 assert_eq!(plugins.len(), 3);
648 assert_eq!(plugins[0].name, "toml-plugin");
649 assert_eq!(plugins[1].name, "json-plugin");
650 assert_eq!(plugins[2].name, "jsonc-plugin");
651
652 let _ = std::fs::remove_dir_all(&dir);
653 }
654
655 #[test]
656 fn deduplicates_by_name() {
657 let dir =
658 std::env::temp_dir().join(format!("fallow-test-dedup-plugins-{}", std::process::id()));
659 let plugins_dir = dir.join(".fallow").join("plugins");
660 let _ = std::fs::create_dir_all(&plugins_dir);
661
662 std::fs::write(
664 plugins_dir.join("my-plugin.toml"),
665 r#"
666name = "my-plugin"
667enablers = ["pkg-a"]
668"#,
669 )
670 .unwrap();
671
672 std::fs::write(
673 dir.join("fallow-plugin-my-plugin.toml"),
674 r#"
675name = "my-plugin"
676enablers = ["pkg-b"]
677"#,
678 )
679 .unwrap();
680
681 let plugins = discover_external_plugins(&dir, &[]);
682 assert_eq!(plugins.len(), 1);
683 assert_eq!(plugins[0].enablers, vec!["pkg-a"]);
685
686 let _ = std::fs::remove_dir_all(&dir);
687 }
688
689 #[test]
690 fn config_plugin_paths_take_priority() {
691 let dir =
692 std::env::temp_dir().join(format!("fallow-test-config-paths-{}", std::process::id()));
693 let custom_dir = dir.join("custom-plugins");
694 let _ = std::fs::create_dir_all(&custom_dir);
695
696 std::fs::write(
697 custom_dir.join("explicit.toml"),
698 r#"
699name = "explicit"
700enablers = ["explicit-pkg"]
701"#,
702 )
703 .unwrap();
704
705 let plugins = discover_external_plugins(&dir, &["custom-plugins".to_string()]);
706 assert_eq!(plugins.len(), 1);
707 assert_eq!(plugins[0].name, "explicit");
708
709 let _ = std::fs::remove_dir_all(&dir);
710 }
711
712 #[test]
713 fn config_plugin_path_to_single_file() {
714 let dir =
715 std::env::temp_dir().join(format!("fallow-test-single-file-{}", std::process::id()));
716 let _ = std::fs::create_dir_all(&dir);
717
718 std::fs::write(
719 dir.join("my-plugin.toml"),
720 r#"
721name = "single-file"
722enablers = ["single-pkg"]
723"#,
724 )
725 .unwrap();
726
727 let plugins = discover_external_plugins(&dir, &["my-plugin.toml".to_string()]);
728 assert_eq!(plugins.len(), 1);
729 assert_eq!(plugins[0].name, "single-file");
730
731 let _ = std::fs::remove_dir_all(&dir);
732 }
733
734 #[test]
735 fn config_plugin_path_to_single_json_file() {
736 let dir = std::env::temp_dir().join(format!(
737 "fallow-test-single-json-file-{}",
738 std::process::id()
739 ));
740 let _ = std::fs::create_dir_all(&dir);
741
742 std::fs::write(
743 dir.join("my-plugin.json"),
744 r#"{"name": "json-single", "enablers": ["json-pkg"]}"#,
745 )
746 .unwrap();
747
748 let plugins = discover_external_plugins(&dir, &["my-plugin.json".to_string()]);
749 assert_eq!(plugins.len(), 1);
750 assert_eq!(plugins[0].name, "json-single");
751
752 let _ = std::fs::remove_dir_all(&dir);
753 }
754
755 #[test]
756 fn skips_invalid_toml() {
757 let dir =
758 std::env::temp_dir().join(format!("fallow-test-invalid-plugin-{}", std::process::id()));
759 let plugins_dir = dir.join(".fallow").join("plugins");
760 let _ = std::fs::create_dir_all(&plugins_dir);
761
762 std::fs::write(plugins_dir.join("bad.toml"), r#"enablers = ["pkg"]"#).unwrap();
764
765 std::fs::write(
767 plugins_dir.join("good.toml"),
768 r#"
769name = "good"
770enablers = ["good-pkg"]
771"#,
772 )
773 .unwrap();
774
775 let plugins = discover_external_plugins(&dir, &[]);
776 assert_eq!(plugins.len(), 1);
777 assert_eq!(plugins[0].name, "good");
778
779 let _ = std::fs::remove_dir_all(&dir);
780 }
781
782 #[test]
783 fn skips_invalid_json() {
784 let dir = std::env::temp_dir().join(format!(
785 "fallow-test-invalid-json-plugin-{}",
786 std::process::id()
787 ));
788 let plugins_dir = dir.join(".fallow").join("plugins");
789 let _ = std::fs::create_dir_all(&plugins_dir);
790
791 std::fs::write(plugins_dir.join("bad.json"), r#"{"enablers": ["pkg"]}"#).unwrap();
793
794 std::fs::write(
796 plugins_dir.join("good.json"),
797 r#"{"name": "good-json", "enablers": ["good-pkg"]}"#,
798 )
799 .unwrap();
800
801 let plugins = discover_external_plugins(&dir, &[]);
802 assert_eq!(plugins.len(), 1);
803 assert_eq!(plugins[0].name, "good-json");
804
805 let _ = std::fs::remove_dir_all(&dir);
806 }
807
808 #[test]
809 fn prefix_enablers() {
810 let toml_str = r#"
811name = "scoped"
812enablers = ["@myorg/"]
813"#;
814 let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
815 assert_eq!(plugin.enablers, vec!["@myorg/"]);
816 }
817
818 #[test]
819 fn skips_empty_name() {
820 let dir =
821 std::env::temp_dir().join(format!("fallow-test-empty-name-{}", std::process::id()));
822 let plugins_dir = dir.join(".fallow").join("plugins");
823 let _ = std::fs::create_dir_all(&plugins_dir);
824
825 std::fs::write(
826 plugins_dir.join("empty.toml"),
827 r#"
828name = ""
829enablers = ["pkg"]
830"#,
831 )
832 .unwrap();
833
834 let plugins = discover_external_plugins(&dir, &[]);
835 assert!(plugins.is_empty(), "empty-name plugin should be skipped");
836
837 let _ = std::fs::remove_dir_all(&dir);
838 }
839
840 #[test]
841 fn rejects_paths_outside_root() {
842 let dir =
843 std::env::temp_dir().join(format!("fallow-test-path-escape-{}", std::process::id()));
844 let _ = std::fs::create_dir_all(&dir);
845
846 let plugins = discover_external_plugins(&dir, &["../../../etc".to_string()]);
848 assert!(plugins.is_empty(), "paths outside root should be rejected");
849
850 let _ = std::fs::remove_dir_all(&dir);
851 }
852
853 #[test]
854 fn plugin_format_detection() {
855 assert!(matches!(
856 PluginFormat::from_path(Path::new("plugin.toml")),
857 Some(PluginFormat::Toml)
858 ));
859 assert!(matches!(
860 PluginFormat::from_path(Path::new("plugin.json")),
861 Some(PluginFormat::Json)
862 ));
863 assert!(matches!(
864 PluginFormat::from_path(Path::new("plugin.jsonc")),
865 Some(PluginFormat::Jsonc)
866 ));
867 assert!(PluginFormat::from_path(Path::new("plugin.yaml")).is_none());
868 assert!(PluginFormat::from_path(Path::new("plugin")).is_none());
869 }
870
871 #[test]
872 fn is_plugin_file_checks_extensions() {
873 assert!(is_plugin_file(Path::new("plugin.toml")));
874 assert!(is_plugin_file(Path::new("plugin.json")));
875 assert!(is_plugin_file(Path::new("plugin.jsonc")));
876 assert!(!is_plugin_file(Path::new("plugin.yaml")));
877 assert!(!is_plugin_file(Path::new("plugin.txt")));
878 assert!(!is_plugin_file(Path::new("plugin")));
879 }
880
881 #[test]
884 fn detection_deserialize_dependency() {
885 let json = r#"{"type": "dependency", "package": "next"}"#;
886 let detection: PluginDetection = serde_json::from_str(json).unwrap();
887 assert!(matches!(detection, PluginDetection::Dependency { package } if package == "next"));
888 }
889
890 #[test]
891 fn detection_deserialize_file_exists() {
892 let json = r#"{"type": "fileExists", "pattern": "tsconfig.json"}"#;
893 let detection: PluginDetection = serde_json::from_str(json).unwrap();
894 assert!(
895 matches!(detection, PluginDetection::FileExists { pattern } if pattern == "tsconfig.json")
896 );
897 }
898
899 #[test]
900 fn detection_deserialize_all() {
901 let json = r#"{"type": "all", "conditions": [{"type": "dependency", "package": "a"}, {"type": "dependency", "package": "b"}]}"#;
902 let detection: PluginDetection = serde_json::from_str(json).unwrap();
903 assert!(matches!(detection, PluginDetection::All { conditions } if conditions.len() == 2));
904 }
905
906 #[test]
907 fn detection_deserialize_any() {
908 let json = r#"{"type": "any", "conditions": [{"type": "dependency", "package": "a"}]}"#;
909 let detection: PluginDetection = serde_json::from_str(json).unwrap();
910 assert!(matches!(detection, PluginDetection::Any { conditions } if conditions.len() == 1));
911 }
912
913 #[test]
914 fn plugin_with_detection_field() {
915 let json = r#"{
916 "name": "my-plugin",
917 "detection": {"type": "dependency", "package": "my-pkg"},
918 "entryPoints": ["src/**/*.ts"]
919 }"#;
920 let plugin: ExternalPluginDef = serde_json::from_str(json).unwrap();
921 assert_eq!(plugin.name, "my-plugin");
922 assert!(plugin.detection.is_some());
923 assert!(plugin.enablers.is_empty());
924 assert_eq!(plugin.entry_points, vec!["src/**/*.ts"]);
925 }
926
927 #[test]
928 fn plugin_without_detection_uses_enablers() {
929 let json = r#"{
930 "name": "my-plugin",
931 "enablers": ["my-pkg"]
932 }"#;
933 let plugin: ExternalPluginDef = serde_json::from_str(json).unwrap();
934 assert!(plugin.detection.is_none());
935 assert_eq!(plugin.enablers, vec!["my-pkg"]);
936 }
937
938 #[test]
941 fn detection_nested_all_with_any() {
942 let json = r#"{
943 "type": "all",
944 "conditions": [
945 {"type": "dependency", "package": "react"},
946 {"type": "any", "conditions": [
947 {"type": "fileExists", "pattern": "next.config.js"},
948 {"type": "fileExists", "pattern": "next.config.mjs"}
949 ]}
950 ]
951 }"#;
952 let detection: PluginDetection = serde_json::from_str(json).unwrap();
953 match detection {
954 PluginDetection::All { conditions } => {
955 assert_eq!(conditions.len(), 2);
956 assert!(matches!(
957 &conditions[0],
958 PluginDetection::Dependency { package } if package == "react"
959 ));
960 match &conditions[1] {
961 PluginDetection::Any { conditions: inner } => {
962 assert_eq!(inner.len(), 2);
963 }
964 other => panic!("expected Any, got: {other:?}"),
965 }
966 }
967 other => panic!("expected All, got: {other:?}"),
968 }
969 }
970
971 #[test]
972 fn detection_empty_all_conditions() {
973 let json = r#"{"type": "all", "conditions": []}"#;
974 let detection: PluginDetection = serde_json::from_str(json).unwrap();
975 assert!(matches!(
976 detection,
977 PluginDetection::All { conditions } if conditions.is_empty()
978 ));
979 }
980
981 #[test]
982 fn detection_empty_any_conditions() {
983 let json = r#"{"type": "any", "conditions": []}"#;
984 let detection: PluginDetection = serde_json::from_str(json).unwrap();
985 assert!(matches!(
986 detection,
987 PluginDetection::Any { conditions } if conditions.is_empty()
988 ));
989 }
990
991 #[test]
994 fn detection_toml_dependency() {
995 let toml_str = r#"
996name = "my-plugin"
997
998[detection]
999type = "dependency"
1000package = "next"
1001"#;
1002 let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
1003 assert!(plugin.detection.is_some());
1004 assert!(matches!(
1005 plugin.detection.unwrap(),
1006 PluginDetection::Dependency { package } if package == "next"
1007 ));
1008 }
1009
1010 #[test]
1011 fn detection_toml_file_exists() {
1012 let toml_str = r#"
1013name = "my-plugin"
1014
1015[detection]
1016type = "fileExists"
1017pattern = "next.config.js"
1018"#;
1019 let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
1020 assert!(matches!(
1021 plugin.detection.unwrap(),
1022 PluginDetection::FileExists { pattern } if pattern == "next.config.js"
1023 ));
1024 }
1025
1026 #[test]
1029 fn plugin_all_fields_json() {
1030 let json = r#"{
1031 "$schema": "https://fallow.dev/plugin-schema.json",
1032 "name": "full-plugin",
1033 "detection": {"type": "dependency", "package": "my-pkg"},
1034 "enablers": ["fallback-enabler"],
1035 "entryPoints": ["src/entry.ts"],
1036 "configPatterns": ["config.js"],
1037 "alwaysUsed": ["src/polyfills.ts"],
1038 "toolingDependencies": ["my-cli"],
1039 "usedExports": [{"pattern": "src/**", "exports": ["default", "setup"]}]
1040 }"#;
1041 let plugin: ExternalPluginDef = serde_json::from_str(json).unwrap();
1042 assert_eq!(plugin.name, "full-plugin");
1043 assert!(plugin.detection.is_some());
1044 assert_eq!(plugin.enablers, vec!["fallback-enabler"]);
1045 assert_eq!(plugin.entry_points, vec!["src/entry.ts"]);
1046 assert_eq!(plugin.config_patterns, vec!["config.js"]);
1047 assert_eq!(plugin.always_used, vec!["src/polyfills.ts"]);
1048 assert_eq!(plugin.tooling_dependencies, vec!["my-cli"]);
1049 assert_eq!(plugin.used_exports.len(), 1);
1050 assert_eq!(plugin.used_exports[0].pattern, "src/**");
1051 assert_eq!(plugin.used_exports[0].exports, vec!["default", "setup"]);
1052 }
1053
1054 #[test]
1057 fn plugin_with_special_chars_in_name() {
1058 let json = r#"{"name": "@scope/my-plugin-v2.0", "enablers": ["pkg"]}"#;
1059 let plugin: ExternalPluginDef = serde_json::from_str(json).unwrap();
1060 assert_eq!(plugin.name, "@scope/my-plugin-v2.0");
1061 }
1062
1063 #[test]
1066 fn parse_plugin_toml_format() {
1067 let content = r#"
1068name = "test-plugin"
1069enablers = ["test-pkg"]
1070entryPoints = ["src/**/*.ts"]
1071"#;
1072 let result = parse_plugin(content, &PluginFormat::Toml, Path::new("test.toml"));
1073 assert!(result.is_some());
1074 let plugin = result.unwrap();
1075 assert_eq!(plugin.name, "test-plugin");
1076 }
1077
1078 #[test]
1079 fn parse_plugin_json_format() {
1080 let content = r#"{"name": "json-test", "enablers": ["pkg"]}"#;
1081 let result = parse_plugin(content, &PluginFormat::Json, Path::new("test.json"));
1082 assert!(result.is_some());
1083 assert_eq!(result.unwrap().name, "json-test");
1084 }
1085
1086 #[test]
1087 fn parse_plugin_jsonc_format() {
1088 let content = r#"{
1089 // A comment
1090 "name": "jsonc-test",
1091 "enablers": ["pkg"]
1092 }"#;
1093 let result = parse_plugin(content, &PluginFormat::Jsonc, Path::new("test.jsonc"));
1094 assert!(result.is_some());
1095 assert_eq!(result.unwrap().name, "jsonc-test");
1096 }
1097
1098 #[test]
1099 fn parse_plugin_invalid_toml_returns_none() {
1100 let content = "not valid toml [[[";
1101 let result = parse_plugin(content, &PluginFormat::Toml, Path::new("bad.toml"));
1102 assert!(result.is_none());
1103 }
1104
1105 #[test]
1106 fn parse_plugin_invalid_json_returns_none() {
1107 let content = "{ not valid json }";
1108 let result = parse_plugin(content, &PluginFormat::Json, Path::new("bad.json"));
1109 assert!(result.is_none());
1110 }
1111
1112 #[test]
1113 fn parse_plugin_invalid_jsonc_returns_none() {
1114 let content = r#"{"enablers": ["pkg"]}"#;
1116 let result = parse_plugin(content, &PluginFormat::Jsonc, Path::new("bad.jsonc"));
1117 assert!(result.is_none());
1118 }
1119}