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