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