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