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