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, Deserialize, Serialize, JsonSchema)]
15#[serde(tag = "type", rename_all = "camelCase")]
16pub enum PluginDetection {
17 Dependency { package: String },
19 FileExists { pattern: String },
21 All { conditions: Vec<Self> },
23 Any { conditions: Vec<Self> },
25}
26
27#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
54#[serde(rename_all = "camelCase")]
55pub struct ExternalPluginDef {
56 #[serde(rename = "$schema", default, skip_serializing)]
58 #[schemars(skip)]
59 pub schema: Option<String>,
60
61 pub name: String,
63
64 #[serde(default)]
67 pub detection: Option<PluginDetection>,
68
69 #[serde(default)]
73 pub enablers: Vec<String>,
74
75 #[serde(default)]
77 pub entry_points: Vec<String>,
78
79 #[serde(default)]
81 pub config_patterns: Vec<String>,
82
83 #[serde(default)]
85 pub always_used: Vec<String>,
86
87 #[serde(default)]
90 pub tooling_dependencies: Vec<String>,
91
92 #[serde(default)]
94 pub used_exports: Vec<ExternalUsedExport>,
95}
96
97#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
99pub struct ExternalUsedExport {
100 pub pattern: String,
102 pub exports: Vec<String>,
104}
105
106impl ExternalPluginDef {
107 pub fn json_schema() -> serde_json::Value {
109 serde_json::to_value(schemars::schema_for!(ExternalPluginDef)).unwrap_or_default()
110 }
111}
112
113enum PluginFormat {
115 Toml,
116 Json,
117 Jsonc,
118}
119
120impl PluginFormat {
121 fn from_path(path: &Path) -> Option<Self> {
122 match path.extension().and_then(|e| e.to_str()) {
123 Some("toml") => Some(Self::Toml),
124 Some("json") => Some(Self::Json),
125 Some("jsonc") => Some(Self::Jsonc),
126 _ => None,
127 }
128 }
129}
130
131fn is_plugin_file(path: &Path) -> bool {
133 path.extension()
134 .and_then(|e| e.to_str())
135 .is_some_and(|ext| PLUGIN_EXTENSIONS.contains(&ext))
136}
137
138#[expect(clippy::print_stderr)]
140fn parse_plugin(content: &str, format: &PluginFormat, path: &Path) -> Option<ExternalPluginDef> {
141 match format {
142 PluginFormat::Toml => match toml::from_str::<ExternalPluginDef>(content) {
143 Ok(plugin) => Some(plugin),
144 Err(e) => {
145 eprintln!(
146 "Warning: failed to parse external plugin {}: {e}",
147 path.display()
148 );
149 None
150 }
151 },
152 PluginFormat::Json => match serde_json::from_str::<ExternalPluginDef>(content) {
153 Ok(plugin) => Some(plugin),
154 Err(e) => {
155 eprintln!(
156 "Warning: failed to parse external plugin {}: {e}",
157 path.display()
158 );
159 None
160 }
161 },
162 PluginFormat::Jsonc => {
163 let mut stripped = String::new();
164 match json_comments::StripComments::new(content.as_bytes())
165 .read_to_string(&mut stripped)
166 {
167 Ok(_) => match serde_json::from_str::<ExternalPluginDef>(&stripped) {
168 Ok(plugin) => Some(plugin),
169 Err(e) => {
170 eprintln!(
171 "Warning: failed to parse external plugin {}: {e}",
172 path.display()
173 );
174 None
175 }
176 },
177 Err(e) => {
178 eprintln!(
179 "Warning: failed to strip comments from {}: {e}",
180 path.display()
181 );
182 None
183 }
184 }
185 }
186 }
187}
188
189#[expect(clippy::print_stderr)]
196pub fn discover_external_plugins(
197 root: &Path,
198 config_plugin_paths: &[String],
199) -> Vec<ExternalPluginDef> {
200 let mut plugins = Vec::new();
201 let mut seen_names = rustc_hash::FxHashSet::default();
202
203 let canonical_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
205
206 for path_str in config_plugin_paths {
208 let path = root.join(path_str);
209 if !is_within_root(&path, &canonical_root) {
210 eprintln!("Warning: plugin path '{path_str}' resolves outside project root, skipping");
211 continue;
212 }
213 if path.is_dir() {
214 load_plugins_from_dir(&path, &canonical_root, &mut plugins, &mut seen_names);
215 } else if path.is_file() {
216 load_plugin_file(&path, &canonical_root, &mut plugins, &mut seen_names);
217 }
218 }
219
220 let plugins_dir = root.join(".fallow").join("plugins");
222 if plugins_dir.is_dir() && is_within_root(&plugins_dir, &canonical_root) {
223 load_plugins_from_dir(&plugins_dir, &canonical_root, &mut plugins, &mut seen_names);
224 }
225
226 if let Ok(entries) = std::fs::read_dir(root) {
228 let mut plugin_files: Vec<PathBuf> = entries
229 .filter_map(|e| e.ok())
230 .map(|e| e.path())
231 .filter(|p| {
232 p.is_file()
233 && p.file_name().and_then(|n| n.to_str()).is_some_and(|n| {
234 n.starts_with("fallow-plugin-") && is_plugin_file(Path::new(n))
235 })
236 })
237 .collect();
238 plugin_files.sort();
239 for path in plugin_files {
240 load_plugin_file(&path, &canonical_root, &mut plugins, &mut seen_names);
241 }
242 }
243
244 plugins
245}
246
247fn is_within_root(path: &Path, canonical_root: &Path) -> bool {
249 let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
250 canonical.starts_with(canonical_root)
251}
252
253fn load_plugins_from_dir(
254 dir: &Path,
255 canonical_root: &Path,
256 plugins: &mut Vec<ExternalPluginDef>,
257 seen: &mut rustc_hash::FxHashSet<String>,
258) {
259 if let Ok(entries) = std::fs::read_dir(dir) {
260 let mut plugin_files: Vec<PathBuf> = entries
261 .filter_map(|e| e.ok())
262 .map(|e| e.path())
263 .filter(|p| p.is_file() && is_plugin_file(p))
264 .collect();
265 plugin_files.sort();
266 for path in plugin_files {
267 load_plugin_file(&path, canonical_root, plugins, seen);
268 }
269 }
270}
271
272#[expect(clippy::print_stderr)]
273fn load_plugin_file(
274 path: &Path,
275 canonical_root: &Path,
276 plugins: &mut Vec<ExternalPluginDef>,
277 seen: &mut rustc_hash::FxHashSet<String>,
278) {
279 if !is_within_root(path, canonical_root) {
281 eprintln!(
282 "Warning: plugin file '{}' resolves outside project root (symlink?), skipping",
283 path.display()
284 );
285 return;
286 }
287
288 let Some(format) = PluginFormat::from_path(path) else {
289 eprintln!(
290 "Warning: unsupported plugin file extension for {}, expected .toml, .json, or .jsonc",
291 path.display()
292 );
293 return;
294 };
295
296 match std::fs::read_to_string(path) {
297 Ok(content) => {
298 if let Some(plugin) = parse_plugin(&content, &format, path) {
299 if plugin.name.is_empty() {
300 eprintln!(
301 "Warning: external plugin in {} has an empty name, skipping",
302 path.display()
303 );
304 return;
305 }
306 if seen.insert(plugin.name.clone()) {
307 plugins.push(plugin);
308 } else {
309 eprintln!(
310 "Warning: duplicate external plugin '{}' in {}, skipping",
311 plugin.name,
312 path.display()
313 );
314 }
315 }
316 }
317 Err(e) => {
318 eprintln!(
319 "Warning: failed to read external plugin file {}: {e}",
320 path.display()
321 );
322 }
323 }
324}
325
326#[cfg(test)]
327mod tests {
328 use super::*;
329
330 #[test]
331 fn deserialize_minimal_plugin() {
332 let toml_str = r#"
333name = "my-plugin"
334enablers = ["my-pkg"]
335"#;
336 let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
337 assert_eq!(plugin.name, "my-plugin");
338 assert_eq!(plugin.enablers, vec!["my-pkg"]);
339 assert!(plugin.entry_points.is_empty());
340 assert!(plugin.always_used.is_empty());
341 assert!(plugin.config_patterns.is_empty());
342 assert!(plugin.tooling_dependencies.is_empty());
343 assert!(plugin.used_exports.is_empty());
344 }
345
346 #[test]
347 fn deserialize_full_plugin() {
348 let toml_str = r#"
349name = "my-framework"
350enablers = ["my-framework", "@my-framework/core"]
351entryPoints = ["src/routes/**/*.{ts,tsx}", "src/middleware.ts"]
352configPatterns = ["my-framework.config.{ts,js,mjs}"]
353alwaysUsed = ["src/setup.ts", "public/**/*"]
354toolingDependencies = ["my-framework-cli"]
355
356[[usedExports]]
357pattern = "src/routes/**/*.{ts,tsx}"
358exports = ["default", "loader", "action"]
359
360[[usedExports]]
361pattern = "src/middleware.ts"
362exports = ["default"]
363"#;
364 let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
365 assert_eq!(plugin.name, "my-framework");
366 assert_eq!(plugin.enablers.len(), 2);
367 assert_eq!(plugin.entry_points.len(), 2);
368 assert_eq!(
369 plugin.config_patterns,
370 vec!["my-framework.config.{ts,js,mjs}"]
371 );
372 assert_eq!(plugin.always_used.len(), 2);
373 assert_eq!(plugin.tooling_dependencies, vec!["my-framework-cli"]);
374 assert_eq!(plugin.used_exports.len(), 2);
375 assert_eq!(plugin.used_exports[0].pattern, "src/routes/**/*.{ts,tsx}");
376 assert_eq!(
377 plugin.used_exports[0].exports,
378 vec!["default", "loader", "action"]
379 );
380 }
381
382 #[test]
383 fn deserialize_json_plugin() {
384 let json_str = r#"{
385 "name": "my-json-plugin",
386 "enablers": ["my-pkg"],
387 "entryPoints": ["src/**/*.ts"],
388 "configPatterns": ["my-plugin.config.js"],
389 "alwaysUsed": ["src/setup.ts"],
390 "toolingDependencies": ["my-cli"],
391 "usedExports": [
392 { "pattern": "src/**/*.ts", "exports": ["default"] }
393 ]
394 }"#;
395 let plugin: ExternalPluginDef = serde_json::from_str(json_str).unwrap();
396 assert_eq!(plugin.name, "my-json-plugin");
397 assert_eq!(plugin.enablers, vec!["my-pkg"]);
398 assert_eq!(plugin.entry_points, vec!["src/**/*.ts"]);
399 assert_eq!(plugin.config_patterns, vec!["my-plugin.config.js"]);
400 assert_eq!(plugin.always_used, vec!["src/setup.ts"]);
401 assert_eq!(plugin.tooling_dependencies, vec!["my-cli"]);
402 assert_eq!(plugin.used_exports.len(), 1);
403 assert_eq!(plugin.used_exports[0].exports, vec!["default"]);
404 }
405
406 #[test]
407 fn deserialize_jsonc_plugin() {
408 let jsonc_str = r#"{
409 // This is a JSONC plugin
410 "name": "my-jsonc-plugin",
411 "enablers": ["my-pkg"],
412 /* Block comment */
413 "entryPoints": ["src/**/*.ts"]
414 }"#;
415 let mut stripped = String::new();
416 json_comments::StripComments::new(jsonc_str.as_bytes())
417 .read_to_string(&mut stripped)
418 .unwrap();
419 let plugin: ExternalPluginDef = serde_json::from_str(&stripped).unwrap();
420 assert_eq!(plugin.name, "my-jsonc-plugin");
421 assert_eq!(plugin.enablers, vec!["my-pkg"]);
422 assert_eq!(plugin.entry_points, vec!["src/**/*.ts"]);
423 }
424
425 #[test]
426 fn deserialize_json_with_schema_field() {
427 let json_str = r#"{
428 "$schema": "https://fallow.dev/plugin-schema.json",
429 "name": "schema-plugin",
430 "enablers": ["my-pkg"]
431 }"#;
432 let plugin: ExternalPluginDef = serde_json::from_str(json_str).unwrap();
433 assert_eq!(plugin.name, "schema-plugin");
434 assert_eq!(plugin.enablers, vec!["my-pkg"]);
435 }
436
437 #[test]
438 fn plugin_json_schema_generation() {
439 let schema = ExternalPluginDef::json_schema();
440 assert!(schema.is_object());
441 let obj = schema.as_object().unwrap();
442 assert!(obj.contains_key("properties"));
443 }
444
445 #[test]
446 fn discover_plugins_from_fallow_plugins_dir() {
447 let dir =
448 std::env::temp_dir().join(format!("fallow-test-ext-plugins-{}", std::process::id()));
449 let plugins_dir = dir.join(".fallow").join("plugins");
450 let _ = std::fs::create_dir_all(&plugins_dir);
451
452 std::fs::write(
453 plugins_dir.join("my-plugin.toml"),
454 r#"
455name = "my-plugin"
456enablers = ["my-pkg"]
457entryPoints = ["src/**/*.ts"]
458"#,
459 )
460 .unwrap();
461
462 let plugins = discover_external_plugins(&dir, &[]);
463 assert_eq!(plugins.len(), 1);
464 assert_eq!(plugins[0].name, "my-plugin");
465
466 let _ = std::fs::remove_dir_all(&dir);
467 }
468
469 #[test]
470 fn discover_json_plugins_from_fallow_plugins_dir() {
471 let dir = std::env::temp_dir().join(format!(
472 "fallow-test-ext-json-plugins-{}",
473 std::process::id()
474 ));
475 let plugins_dir = dir.join(".fallow").join("plugins");
476 let _ = std::fs::create_dir_all(&plugins_dir);
477
478 std::fs::write(
479 plugins_dir.join("my-plugin.json"),
480 r#"{"name": "json-plugin", "enablers": ["json-pkg"]}"#,
481 )
482 .unwrap();
483
484 std::fs::write(
485 plugins_dir.join("my-plugin.jsonc"),
486 r#"{
487 // JSONC plugin
488 "name": "jsonc-plugin",
489 "enablers": ["jsonc-pkg"]
490 }"#,
491 )
492 .unwrap();
493
494 let plugins = discover_external_plugins(&dir, &[]);
495 assert_eq!(plugins.len(), 2);
496 assert_eq!(plugins[0].name, "json-plugin");
498 assert_eq!(plugins[1].name, "jsonc-plugin");
499
500 let _ = std::fs::remove_dir_all(&dir);
501 }
502
503 #[test]
504 fn discover_fallow_plugin_files_in_root() {
505 let dir =
506 std::env::temp_dir().join(format!("fallow-test-root-plugins-{}", std::process::id()));
507 let _ = std::fs::create_dir_all(&dir);
508
509 std::fs::write(
510 dir.join("fallow-plugin-custom.toml"),
511 r#"
512name = "custom"
513enablers = ["custom-pkg"]
514"#,
515 )
516 .unwrap();
517
518 std::fs::write(dir.join("some-other-file.toml"), r#"name = "ignored""#).unwrap();
520
521 let plugins = discover_external_plugins(&dir, &[]);
522 assert_eq!(plugins.len(), 1);
523 assert_eq!(plugins[0].name, "custom");
524
525 let _ = std::fs::remove_dir_all(&dir);
526 }
527
528 #[test]
529 fn discover_fallow_plugin_json_files_in_root() {
530 let dir = std::env::temp_dir().join(format!(
531 "fallow-test-root-json-plugins-{}",
532 std::process::id()
533 ));
534 let _ = std::fs::create_dir_all(&dir);
535
536 std::fs::write(
537 dir.join("fallow-plugin-custom.json"),
538 r#"{"name": "json-root", "enablers": ["json-pkg"]}"#,
539 )
540 .unwrap();
541
542 std::fs::write(
543 dir.join("fallow-plugin-custom2.jsonc"),
544 r#"{
545 // JSONC root plugin
546 "name": "jsonc-root",
547 "enablers": ["jsonc-pkg"]
548 }"#,
549 )
550 .unwrap();
551
552 std::fs::write(
554 dir.join("fallow-plugin-bad.yaml"),
555 "name: ignored\nenablers:\n - pkg\n",
556 )
557 .unwrap();
558
559 let plugins = discover_external_plugins(&dir, &[]);
560 assert_eq!(plugins.len(), 2);
561
562 let _ = std::fs::remove_dir_all(&dir);
563 }
564
565 #[test]
566 fn discover_mixed_formats_in_dir() {
567 let dir =
568 std::env::temp_dir().join(format!("fallow-test-mixed-plugins-{}", std::process::id()));
569 let plugins_dir = dir.join(".fallow").join("plugins");
570 let _ = std::fs::create_dir_all(&plugins_dir);
571
572 std::fs::write(
573 plugins_dir.join("a-plugin.toml"),
574 r#"
575name = "toml-plugin"
576enablers = ["toml-pkg"]
577"#,
578 )
579 .unwrap();
580
581 std::fs::write(
582 plugins_dir.join("b-plugin.json"),
583 r#"{"name": "json-plugin", "enablers": ["json-pkg"]}"#,
584 )
585 .unwrap();
586
587 std::fs::write(
588 plugins_dir.join("c-plugin.jsonc"),
589 r#"{
590 // JSONC plugin
591 "name": "jsonc-plugin",
592 "enablers": ["jsonc-pkg"]
593 }"#,
594 )
595 .unwrap();
596
597 let plugins = discover_external_plugins(&dir, &[]);
598 assert_eq!(plugins.len(), 3);
599 assert_eq!(plugins[0].name, "toml-plugin");
600 assert_eq!(plugins[1].name, "json-plugin");
601 assert_eq!(plugins[2].name, "jsonc-plugin");
602
603 let _ = std::fs::remove_dir_all(&dir);
604 }
605
606 #[test]
607 fn deduplicates_by_name() {
608 let dir =
609 std::env::temp_dir().join(format!("fallow-test-dedup-plugins-{}", std::process::id()));
610 let plugins_dir = dir.join(".fallow").join("plugins");
611 let _ = std::fs::create_dir_all(&plugins_dir);
612
613 std::fs::write(
615 plugins_dir.join("my-plugin.toml"),
616 r#"
617name = "my-plugin"
618enablers = ["pkg-a"]
619"#,
620 )
621 .unwrap();
622
623 std::fs::write(
624 dir.join("fallow-plugin-my-plugin.toml"),
625 r#"
626name = "my-plugin"
627enablers = ["pkg-b"]
628"#,
629 )
630 .unwrap();
631
632 let plugins = discover_external_plugins(&dir, &[]);
633 assert_eq!(plugins.len(), 1);
634 assert_eq!(plugins[0].enablers, vec!["pkg-a"]);
636
637 let _ = std::fs::remove_dir_all(&dir);
638 }
639
640 #[test]
641 fn config_plugin_paths_take_priority() {
642 let dir =
643 std::env::temp_dir().join(format!("fallow-test-config-paths-{}", std::process::id()));
644 let custom_dir = dir.join("custom-plugins");
645 let _ = std::fs::create_dir_all(&custom_dir);
646
647 std::fs::write(
648 custom_dir.join("explicit.toml"),
649 r#"
650name = "explicit"
651enablers = ["explicit-pkg"]
652"#,
653 )
654 .unwrap();
655
656 let plugins = discover_external_plugins(&dir, &["custom-plugins".to_string()]);
657 assert_eq!(plugins.len(), 1);
658 assert_eq!(plugins[0].name, "explicit");
659
660 let _ = std::fs::remove_dir_all(&dir);
661 }
662
663 #[test]
664 fn config_plugin_path_to_single_file() {
665 let dir =
666 std::env::temp_dir().join(format!("fallow-test-single-file-{}", std::process::id()));
667 let _ = std::fs::create_dir_all(&dir);
668
669 std::fs::write(
670 dir.join("my-plugin.toml"),
671 r#"
672name = "single-file"
673enablers = ["single-pkg"]
674"#,
675 )
676 .unwrap();
677
678 let plugins = discover_external_plugins(&dir, &["my-plugin.toml".to_string()]);
679 assert_eq!(plugins.len(), 1);
680 assert_eq!(plugins[0].name, "single-file");
681
682 let _ = std::fs::remove_dir_all(&dir);
683 }
684
685 #[test]
686 fn config_plugin_path_to_single_json_file() {
687 let dir = std::env::temp_dir().join(format!(
688 "fallow-test-single-json-file-{}",
689 std::process::id()
690 ));
691 let _ = std::fs::create_dir_all(&dir);
692
693 std::fs::write(
694 dir.join("my-plugin.json"),
695 r#"{"name": "json-single", "enablers": ["json-pkg"]}"#,
696 )
697 .unwrap();
698
699 let plugins = discover_external_plugins(&dir, &["my-plugin.json".to_string()]);
700 assert_eq!(plugins.len(), 1);
701 assert_eq!(plugins[0].name, "json-single");
702
703 let _ = std::fs::remove_dir_all(&dir);
704 }
705
706 #[test]
707 fn skips_invalid_toml() {
708 let dir =
709 std::env::temp_dir().join(format!("fallow-test-invalid-plugin-{}", std::process::id()));
710 let plugins_dir = dir.join(".fallow").join("plugins");
711 let _ = std::fs::create_dir_all(&plugins_dir);
712
713 std::fs::write(plugins_dir.join("bad.toml"), r#"enablers = ["pkg"]"#).unwrap();
715
716 std::fs::write(
718 plugins_dir.join("good.toml"),
719 r#"
720name = "good"
721enablers = ["good-pkg"]
722"#,
723 )
724 .unwrap();
725
726 let plugins = discover_external_plugins(&dir, &[]);
727 assert_eq!(plugins.len(), 1);
728 assert_eq!(plugins[0].name, "good");
729
730 let _ = std::fs::remove_dir_all(&dir);
731 }
732
733 #[test]
734 fn skips_invalid_json() {
735 let dir = std::env::temp_dir().join(format!(
736 "fallow-test-invalid-json-plugin-{}",
737 std::process::id()
738 ));
739 let plugins_dir = dir.join(".fallow").join("plugins");
740 let _ = std::fs::create_dir_all(&plugins_dir);
741
742 std::fs::write(plugins_dir.join("bad.json"), r#"{"enablers": ["pkg"]}"#).unwrap();
744
745 std::fs::write(
747 plugins_dir.join("good.json"),
748 r#"{"name": "good-json", "enablers": ["good-pkg"]}"#,
749 )
750 .unwrap();
751
752 let plugins = discover_external_plugins(&dir, &[]);
753 assert_eq!(plugins.len(), 1);
754 assert_eq!(plugins[0].name, "good-json");
755
756 let _ = std::fs::remove_dir_all(&dir);
757 }
758
759 #[test]
760 fn prefix_enablers() {
761 let toml_str = r#"
762name = "scoped"
763enablers = ["@myorg/"]
764"#;
765 let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
766 assert_eq!(plugin.enablers, vec!["@myorg/"]);
767 }
768
769 #[test]
770 fn skips_empty_name() {
771 let dir =
772 std::env::temp_dir().join(format!("fallow-test-empty-name-{}", std::process::id()));
773 let plugins_dir = dir.join(".fallow").join("plugins");
774 let _ = std::fs::create_dir_all(&plugins_dir);
775
776 std::fs::write(
777 plugins_dir.join("empty.toml"),
778 r#"
779name = ""
780enablers = ["pkg"]
781"#,
782 )
783 .unwrap();
784
785 let plugins = discover_external_plugins(&dir, &[]);
786 assert!(plugins.is_empty(), "empty-name plugin should be skipped");
787
788 let _ = std::fs::remove_dir_all(&dir);
789 }
790
791 #[test]
792 fn rejects_paths_outside_root() {
793 let dir =
794 std::env::temp_dir().join(format!("fallow-test-path-escape-{}", std::process::id()));
795 let _ = std::fs::create_dir_all(&dir);
796
797 let plugins = discover_external_plugins(&dir, &["../../../etc".to_string()]);
799 assert!(plugins.is_empty(), "paths outside root should be rejected");
800
801 let _ = std::fs::remove_dir_all(&dir);
802 }
803
804 #[test]
805 fn plugin_format_detection() {
806 assert!(matches!(
807 PluginFormat::from_path(Path::new("plugin.toml")),
808 Some(PluginFormat::Toml)
809 ));
810 assert!(matches!(
811 PluginFormat::from_path(Path::new("plugin.json")),
812 Some(PluginFormat::Json)
813 ));
814 assert!(matches!(
815 PluginFormat::from_path(Path::new("plugin.jsonc")),
816 Some(PluginFormat::Jsonc)
817 ));
818 assert!(PluginFormat::from_path(Path::new("plugin.yaml")).is_none());
819 assert!(PluginFormat::from_path(Path::new("plugin")).is_none());
820 }
821
822 #[test]
823 fn is_plugin_file_checks_extensions() {
824 assert!(is_plugin_file(Path::new("plugin.toml")));
825 assert!(is_plugin_file(Path::new("plugin.json")));
826 assert!(is_plugin_file(Path::new("plugin.jsonc")));
827 assert!(!is_plugin_file(Path::new("plugin.yaml")));
828 assert!(!is_plugin_file(Path::new("plugin.txt")));
829 assert!(!is_plugin_file(Path::new("plugin")));
830 }
831
832 #[test]
835 fn detection_deserialize_dependency() {
836 let json = r#"{"type": "dependency", "package": "next"}"#;
837 let detection: PluginDetection = serde_json::from_str(json).unwrap();
838 assert!(matches!(detection, PluginDetection::Dependency { package } if package == "next"));
839 }
840
841 #[test]
842 fn detection_deserialize_file_exists() {
843 let json = r#"{"type": "fileExists", "pattern": "tsconfig.json"}"#;
844 let detection: PluginDetection = serde_json::from_str(json).unwrap();
845 assert!(
846 matches!(detection, PluginDetection::FileExists { pattern } if pattern == "tsconfig.json")
847 );
848 }
849
850 #[test]
851 fn detection_deserialize_all() {
852 let json = r#"{"type": "all", "conditions": [{"type": "dependency", "package": "a"}, {"type": "dependency", "package": "b"}]}"#;
853 let detection: PluginDetection = serde_json::from_str(json).unwrap();
854 assert!(matches!(detection, PluginDetection::All { conditions } if conditions.len() == 2));
855 }
856
857 #[test]
858 fn detection_deserialize_any() {
859 let json = r#"{"type": "any", "conditions": [{"type": "dependency", "package": "a"}]}"#;
860 let detection: PluginDetection = serde_json::from_str(json).unwrap();
861 assert!(matches!(detection, PluginDetection::Any { conditions } if conditions.len() == 1));
862 }
863
864 #[test]
865 fn plugin_with_detection_field() {
866 let json = r#"{
867 "name": "my-plugin",
868 "detection": {"type": "dependency", "package": "my-pkg"},
869 "entryPoints": ["src/**/*.ts"]
870 }"#;
871 let plugin: ExternalPluginDef = serde_json::from_str(json).unwrap();
872 assert_eq!(plugin.name, "my-plugin");
873 assert!(plugin.detection.is_some());
874 assert!(plugin.enablers.is_empty());
875 assert_eq!(plugin.entry_points, vec!["src/**/*.ts"]);
876 }
877
878 #[test]
879 fn plugin_without_detection_uses_enablers() {
880 let json = r#"{
881 "name": "my-plugin",
882 "enablers": ["my-pkg"]
883 }"#;
884 let plugin: ExternalPluginDef = serde_json::from_str(json).unwrap();
885 assert!(plugin.detection.is_none());
886 assert_eq!(plugin.enablers, vec!["my-pkg"]);
887 }
888}