1use std::collections::BTreeMap;
2use std::fmt;
3use std::path::{Path, PathBuf};
4use std::str::FromStr;
5
6use schemars::JsonSchema;
7use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _};
8
9use crate::config::UsedClassMemberRule;
10
11const PLUGIN_EXTENSIONS: &[&str] = &["toml", "json", "jsonc"];
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema, Default)]
16#[serde(rename_all = "camelCase")]
17pub enum EntryPointRole {
18 Runtime,
20 Test,
22 #[default]
24 Support,
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum AutoImportKind {
31 Named,
33 Default,
35 DefaultComponent,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct AutoImportRule {
51 pub name: String,
55 pub source: PathBuf,
57 pub kind: AutoImportKind,
59}
60
61#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
66#[serde(tag = "type", rename_all = "camelCase")]
67pub enum PluginDetection {
68 Dependency {
70 package: String,
72 },
73 FileExists {
75 pattern: String,
79 },
80 All {
82 conditions: Vec<Self>,
84 },
85 Any {
87 conditions: Vec<Self>,
89 },
90}
91
92#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
119#[serde(rename_all = "camelCase")]
120pub struct ExternalPluginDef {
121 #[serde(rename = "$schema", default, skip_serializing)]
123 #[schemars(skip)]
124 pub schema: Option<String>,
125
126 pub name: String,
128
129 #[serde(default)]
132 pub detection: Option<PluginDetection>,
133
134 #[serde(default)]
138 pub enablers: Vec<String>,
139
140 #[serde(default)]
142 pub entry_points: Vec<String>,
143
144 #[serde(default = "default_external_entry_point_role")]
149 pub entry_point_role: EntryPointRole,
150
151 #[serde(default)]
158 pub manifest_entries: Vec<ManifestEntryRule>,
159
160 #[serde(default)]
162 pub config_patterns: Vec<String>,
163
164 #[serde(default)]
166 pub always_used: Vec<String>,
167
168 #[serde(default)]
171 pub tooling_dependencies: Vec<String>,
172
173 #[serde(default)]
175 pub used_exports: Vec<ExternalUsedExport>,
176
177 #[serde(default)]
182 pub used_class_members: Vec<UsedClassMemberRule>,
183}
184
185#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
187pub struct ExternalUsedExport {
188 pub pattern: String,
190 pub exports: Vec<String>,
192}
193
194#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema, Default)]
198#[serde(rename_all = "lowercase")]
199pub enum ManifestFormat {
200 #[default]
202 Jsonc,
203 Json,
205}
206
207#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
213pub struct ManifestFieldPath {
214 raw: String,
215 segments: Vec<ManifestFieldSegment>,
216}
217
218#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
220pub enum ManifestFieldSegment {
221 Key(String),
223 Each,
225}
226
227impl ManifestFieldPath {
228 #[must_use]
230 pub fn as_str(&self) -> &str {
231 &self.raw
232 }
233
234 #[must_use]
236 pub fn segments(&self) -> &[ManifestFieldSegment] {
237 &self.segments
238 }
239
240 fn parse(raw: String) -> Result<Self, String> {
241 if raw.is_empty() {
242 return Err("manifest field path must not be empty".to_string());
243 }
244
245 let mut segments = Vec::new();
246 for component in raw.split('.') {
247 if component.is_empty() {
248 return Err(format!(
249 "manifest field path '{raw}' contains an empty segment"
250 ));
251 }
252
253 let key_end = component.find('[').unwrap_or(component.len());
254 let key = &component[..key_end];
255 if key.is_empty() || key.contains(['{', '}', ']']) {
256 return Err(format!(
257 "manifest field path '{raw}' contains unsupported bracket syntax"
258 ));
259 }
260 segments.push(ManifestFieldSegment::Key(key.to_string()));
261
262 let mut suffix = &component[key_end..];
263 while !suffix.is_empty() {
264 let Some(rest) = suffix.strip_prefix("[*]") else {
265 return Err(format!(
266 "manifest field path '{raw}' only supports exact '[*]' array traversal"
267 ));
268 };
269 segments.push(ManifestFieldSegment::Each);
270 suffix = rest;
271 }
272 }
273
274 Ok(Self { raw, segments })
275 }
276}
277
278impl FromStr for ManifestFieldPath {
279 type Err = String;
280
281 fn from_str(raw: &str) -> Result<Self, Self::Err> {
282 Self::parse(raw.to_string())
283 }
284}
285
286impl fmt::Display for ManifestFieldPath {
287 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
288 f.write_str(&self.raw)
289 }
290}
291
292impl Serialize for ManifestFieldPath {
293 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
294 where
295 S: Serializer,
296 {
297 serializer.serialize_str(&self.raw)
298 }
299}
300
301impl<'de> Deserialize<'de> for ManifestFieldPath {
302 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
303 where
304 D: Deserializer<'de>,
305 {
306 let raw = String::deserialize(deserializer)?;
307 Self::parse(raw).map_err(D::Error::custom)
308 }
309}
310
311#[derive(Debug, Clone, PartialEq, Eq)]
313pub enum ManifestPathPart {
314 Literal(String),
316 Field(ManifestFieldPath),
318}
319
320#[derive(Debug, Clone, PartialEq, Eq)]
322pub struct ManifestPathTemplate {
323 raw: String,
324 parts: Vec<ManifestPathPart>,
325}
326
327impl ManifestPathTemplate {
328 #[must_use]
330 pub fn as_str(&self) -> &str {
331 &self.raw
332 }
333
334 #[must_use]
336 pub fn parts(&self) -> &[ManifestPathPart] {
337 &self.parts
338 }
339
340 #[must_use]
342 pub fn validation_pattern(&self) -> String {
343 let mut pattern = String::with_capacity(self.raw.len());
344 for part in &self.parts {
345 match part {
346 ManifestPathPart::Literal(literal) => pattern.push_str(literal),
347 ManifestPathPart::Field(_) => pattern.push_str("fallowinterp"),
348 }
349 }
350 pattern
351 }
352
353 fn parse(raw: String) -> Result<Self, String> {
354 let mut parts = Vec::new();
355 let mut rest = raw.as_str();
356
357 while let Some(start) = rest.find("${") {
358 if start > 0 {
359 parts.push(ManifestPathPart::Literal(rest[..start].to_string()));
360 }
361
362 let after = &rest[start + 2..];
363 let Some(end) = after.find('}') else {
364 return Err(format!(
365 "manifest entry path '{raw}' contains an unterminated interpolation"
366 ));
367 };
368 let field = after[..end].parse::<ManifestFieldPath>()?;
369 parts.push(ManifestPathPart::Field(field));
370 rest = &after[end + 1..];
371 }
372
373 if !rest.is_empty() {
374 parts.push(ManifestPathPart::Literal(rest.to_string()));
375 }
376 if parts.is_empty() {
377 parts.push(ManifestPathPart::Literal(String::new()));
378 }
379
380 Ok(Self { raw, parts })
381 }
382}
383
384impl FromStr for ManifestPathTemplate {
385 type Err = String;
386
387 fn from_str(raw: &str) -> Result<Self, Self::Err> {
388 Self::parse(raw.to_string())
389 }
390}
391
392impl fmt::Display for ManifestPathTemplate {
393 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
394 f.write_str(&self.raw)
395 }
396}
397
398impl Serialize for ManifestPathTemplate {
399 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
400 where
401 S: Serializer,
402 {
403 serializer.serialize_str(&self.raw)
404 }
405}
406
407impl<'de> Deserialize<'de> for ManifestPathTemplate {
408 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
409 where
410 D: Deserializer<'de>,
411 {
412 let raw = String::deserialize(deserializer)?;
413 Self::parse(raw).map_err(D::Error::custom)
414 }
415}
416
417#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
422#[serde(untagged)]
423pub enum ManifestCondition {
424 Exists(ManifestExistsPredicate),
426 Equals(serde_json::Value),
428}
429
430#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
432#[serde(deny_unknown_fields)]
433pub struct ManifestExistsPredicate {
434 pub exists: bool,
436}
437
438#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
457#[serde(rename_all = "camelCase")]
458pub struct ManifestEntryRule {
459 pub manifests: String,
461
462 #[serde(default)]
464 pub format: ManifestFormat,
465
466 #[serde(default)]
472 #[schemars(with = "BTreeMap<String, ManifestCondition>")]
473 pub when: BTreeMap<ManifestFieldPath, ManifestCondition>,
474
475 pub entries: Vec<ManifestSeedRule>,
477}
478
479#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
482#[serde(rename_all = "camelCase")]
483pub struct ManifestSeedRule {
484 #[schemars(with = "String")]
492 pub path: ManifestPathTemplate,
493
494 #[serde(default)]
497 #[schemars(with = "BTreeMap<String, ManifestCondition>")]
498 pub when: BTreeMap<ManifestFieldPath, ManifestCondition>,
499}
500
501fn default_external_entry_point_role() -> EntryPointRole {
502 EntryPointRole::Support
503}
504
505impl ExternalPluginDef {
506 #[must_use]
508 pub fn json_schema() -> serde_json::Value {
509 serde_json::to_value(schemars::schema_for!(ExternalPluginDef)).unwrap_or_default()
510 }
511
512 pub fn validate_user_globs(
526 &self,
527 ) -> Result<(), Vec<crate::config::glob_validation::GlobValidationError>> {
528 use crate::config::glob_validation::{compile_user_glob, validate_user_globs};
529
530 let mut errors = Vec::new();
531 validate_user_globs(&self.entry_points, "framework[].entryPoints", &mut errors);
532 validate_user_globs(&self.always_used, "framework[].alwaysUsed", &mut errors);
533 validate_user_globs(
534 &self.config_patterns,
535 "framework[].configPatterns",
536 &mut errors,
537 );
538 for used in &self.used_exports {
539 if let Err(e) = compile_user_glob(&used.pattern, "framework[].usedExports[].pattern") {
540 errors.push(e);
541 }
542 }
543 for rule in &self.manifest_entries {
544 if let Err(e) =
545 compile_user_glob(&rule.manifests, "framework[].manifestEntries[].manifests")
546 {
547 errors.push(e);
548 }
549 for seed in &rule.entries {
550 let probe = seed.path.validation_pattern();
553 if let Err(e) =
554 compile_user_glob(&probe, "framework[].manifestEntries[].entries[].path")
555 {
556 errors.push(e);
557 }
558 }
559 }
560 if let Some(detection) = &self.detection {
561 validate_detection_user_globs(detection, "framework[].detection", &mut errors);
562 }
563 if errors.is_empty() {
564 Ok(())
565 } else {
566 Err(errors)
567 }
568 }
569}
570
571fn validate_detection_user_globs(
574 detection: &PluginDetection,
575 field: &'static str,
576 errors: &mut Vec<crate::config::glob_validation::GlobValidationError>,
577) {
578 match detection {
579 PluginDetection::Dependency { .. } => {}
580 PluginDetection::FileExists { pattern } => {
581 if let Err(e) = crate::config::glob_validation::compile_user_glob(pattern, field) {
582 errors.push(e);
583 }
584 }
585 PluginDetection::All { conditions } | PluginDetection::Any { conditions } => {
586 for condition in conditions {
587 validate_detection_user_globs(condition, field, errors);
588 }
589 }
590 }
591}
592
593pub fn discover_and_validate_external_plugins(
608 root: &Path,
609 config_plugin_paths: &[String],
610) -> Result<Vec<ExternalPluginDef>, Vec<crate::config::glob_validation::GlobValidationError>> {
611 let plugins = discover_external_plugins(root, config_plugin_paths);
612 let mut errors = Vec::new();
613 for plugin in &plugins {
614 if let Err(mut plugin_errors) = plugin.validate_user_globs() {
615 errors.append(&mut plugin_errors);
616 }
617 }
618 if errors.is_empty() {
619 Ok(plugins)
620 } else {
621 Err(errors)
622 }
623}
624
625enum PluginFormat {
627 Toml,
628 Json,
629 Jsonc,
630}
631
632impl PluginFormat {
633 fn from_path(path: &Path) -> Option<Self> {
634 match path.extension().and_then(|e| e.to_str()) {
635 Some("toml") => Some(Self::Toml),
636 Some("json") => Some(Self::Json),
637 Some("jsonc") => Some(Self::Jsonc),
638 _ => None,
639 }
640 }
641}
642
643fn is_plugin_file(path: &Path) -> bool {
645 path.extension()
646 .and_then(|e| e.to_str())
647 .is_some_and(|ext| PLUGIN_EXTENSIONS.contains(&ext))
648}
649
650fn parse_plugin(content: &str, format: &PluginFormat, path: &Path) -> Option<ExternalPluginDef> {
652 match format {
653 PluginFormat::Toml => match toml::from_str::<ExternalPluginDef>(content) {
654 Ok(plugin) => Some(plugin),
655 Err(e) => {
656 tracing::warn!("failed to parse external plugin {}: {e}", path.display());
657 None
658 }
659 },
660 PluginFormat::Json => match serde_json::from_str::<ExternalPluginDef>(content) {
661 Ok(plugin) => Some(plugin),
662 Err(e) => {
663 tracing::warn!("failed to parse external plugin {}: {e}", path.display());
664 None
665 }
666 },
667 PluginFormat::Jsonc => match crate::jsonc::parse_to_value::<ExternalPluginDef>(content) {
668 Ok(plugin) => Some(plugin),
669 Err(e) => {
670 tracing::warn!("failed to parse external plugin {}: {e}", path.display());
671 None
672 }
673 },
674 }
675}
676
677pub fn discover_external_plugins(
684 root: &Path,
685 config_plugin_paths: &[String],
686) -> Vec<ExternalPluginDef> {
687 let mut plugins = Vec::new();
688 let mut seen_names = rustc_hash::FxHashSet::default();
689
690 let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
691
692 load_configured_plugin_paths(
693 root,
694 config_plugin_paths,
695 &canonical_root,
696 &mut plugins,
697 &mut seen_names,
698 );
699 load_default_plugins_dir(root, &canonical_root, &mut plugins, &mut seen_names);
700 load_root_plugin_files(root, &canonical_root, &mut plugins, &mut seen_names);
701
702 plugins
703}
704
705fn load_configured_plugin_paths(
706 root: &Path,
707 config_plugin_paths: &[String],
708 canonical_root: &Path,
709 plugins: &mut Vec<ExternalPluginDef>,
710 seen_names: &mut rustc_hash::FxHashSet<String>,
711) {
712 for path_str in config_plugin_paths {
713 let path = root.join(path_str);
714 if !is_within_root(&path, canonical_root) {
715 tracing::warn!("plugin path '{path_str}' resolves outside project root, skipping");
716 continue;
717 }
718 if path.is_dir() {
719 load_plugins_from_dir(&path, canonical_root, plugins, seen_names);
720 } else if path.is_file() {
721 load_plugin_file(&path, canonical_root, plugins, seen_names);
722 }
723 }
724}
725
726fn load_default_plugins_dir(
727 root: &Path,
728 canonical_root: &Path,
729 plugins: &mut Vec<ExternalPluginDef>,
730 seen_names: &mut rustc_hash::FxHashSet<String>,
731) {
732 let plugins_dir = root.join(".fallow").join("plugins");
733 if plugins_dir.is_dir() && is_within_root(&plugins_dir, canonical_root) {
734 load_plugins_from_dir(&plugins_dir, canonical_root, plugins, seen_names);
735 }
736}
737
738fn load_root_plugin_files(
739 root: &Path,
740 canonical_root: &Path,
741 plugins: &mut Vec<ExternalPluginDef>,
742 seen_names: &mut rustc_hash::FxHashSet<String>,
743) {
744 if let Ok(entries) = std::fs::read_dir(root) {
745 let mut plugin_files: Vec<PathBuf> = entries
746 .filter_map(Result::ok)
747 .map(|e| e.path())
748 .filter(|p| {
749 p.is_file()
750 && p.file_name().and_then(|n| n.to_str()).is_some_and(|n| {
751 n.starts_with("fallow-plugin-") && is_plugin_file(Path::new(n))
752 })
753 })
754 .collect();
755 plugin_files.sort();
756 for path in plugin_files {
757 load_plugin_file(&path, canonical_root, plugins, seen_names);
758 }
759 }
760}
761
762#[expect(
764 clippy::redundant_pub_crate,
765 reason = "this module is glob re-exported from lib.rs, so `pub` would leak this helper into the public API; pub(crate) is the minimal widening for the rule-pack loader"
766)]
767pub(crate) fn is_within_root(path: &Path, canonical_root: &Path) -> bool {
768 let canonical = dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
769 canonical.starts_with(canonical_root)
770}
771
772fn load_plugins_from_dir(
773 dir: &Path,
774 canonical_root: &Path,
775 plugins: &mut Vec<ExternalPluginDef>,
776 seen: &mut rustc_hash::FxHashSet<String>,
777) {
778 if let Ok(entries) = std::fs::read_dir(dir) {
779 let mut plugin_files: Vec<PathBuf> = entries
780 .filter_map(Result::ok)
781 .map(|e| e.path())
782 .filter(|p| p.is_file() && is_plugin_file(p))
783 .collect();
784 plugin_files.sort();
785 for path in plugin_files {
786 load_plugin_file(&path, canonical_root, plugins, seen);
787 }
788 }
789}
790
791fn load_plugin_file(
792 path: &Path,
793 canonical_root: &Path,
794 plugins: &mut Vec<ExternalPluginDef>,
795 seen: &mut rustc_hash::FxHashSet<String>,
796) {
797 if !is_within_root(path, canonical_root) {
798 tracing::warn!(
799 "plugin file '{}' resolves outside project root (symlink?), skipping",
800 path.display()
801 );
802 return;
803 }
804
805 let Some(format) = PluginFormat::from_path(path) else {
806 tracing::warn!(
807 "unsupported plugin file extension for {}, expected .toml, .json, or .jsonc",
808 path.display()
809 );
810 return;
811 };
812
813 let Some(content) = read_plugin_file(path) else {
814 return;
815 };
816
817 if let Some(plugin) = parse_plugin(&content, &format, path) {
818 push_plugin_if_unique(plugin, path, plugins, seen);
819 }
820}
821
822fn read_plugin_file(path: &Path) -> Option<String> {
823 match std::fs::read_to_string(path) {
824 Ok(content) => Some(content),
825 Err(e) => {
826 tracing::warn!(
827 "failed to read external plugin file {}: {e}",
828 path.display()
829 );
830 None
831 }
832 }
833}
834
835fn push_plugin_if_unique(
836 plugin: ExternalPluginDef,
837 path: &Path,
838 plugins: &mut Vec<ExternalPluginDef>,
839 seen: &mut rustc_hash::FxHashSet<String>,
840) {
841 if plugin.name.is_empty() {
842 tracing::warn!(
843 "external plugin in {} has an empty name, skipping",
844 path.display()
845 );
846 return;
847 }
848
849 if seen.insert(plugin.name.clone()) {
850 plugins.push(plugin);
851 } else {
852 tracing::warn!(
853 "duplicate external plugin '{}' in {}, skipping",
854 plugin.name,
855 path.display()
856 );
857 }
858}
859
860#[cfg(test)]
861mod tests {
862 use super::*;
863 use crate::ScopedUsedClassMemberRule;
864
865 #[test]
866 fn deserialize_minimal_plugin() {
867 let toml_str = r#"
868name = "my-plugin"
869enablers = ["my-pkg"]
870"#;
871 let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
872 assert_eq!(plugin.name, "my-plugin");
873 assert_eq!(plugin.enablers, vec!["my-pkg"]);
874 assert!(plugin.entry_points.is_empty());
875 assert!(plugin.always_used.is_empty());
876 assert!(plugin.config_patterns.is_empty());
877 assert!(plugin.tooling_dependencies.is_empty());
878 assert!(plugin.used_exports.is_empty());
879 assert!(plugin.used_class_members.is_empty());
880 }
881
882 #[test]
883 fn deserialize_plugin_with_used_class_members_json() {
884 let json_str = r#"{
885 "name": "ag-grid",
886 "enablers": ["ag-grid-angular"],
887 "usedClassMembers": ["agInit", "refresh"]
888 }"#;
889 let plugin: ExternalPluginDef = serde_json::from_str(json_str).unwrap();
890 assert_eq!(plugin.name, "ag-grid");
891 assert_eq!(
892 plugin.used_class_members,
893 vec![
894 UsedClassMemberRule::from("agInit"),
895 UsedClassMemberRule::from("refresh"),
896 ]
897 );
898 }
899
900 #[test]
901 fn deserialize_plugin_with_scoped_used_class_members_json() {
902 let json_str = r#"{
903 "name": "ag-grid",
904 "enablers": ["ag-grid-angular"],
905 "usedClassMembers": [
906 "agInit",
907 { "implements": "ICellRendererAngularComp", "members": ["refresh"] },
908 { "extends": "BaseCommand", "members": ["execute"] }
909 ]
910 }"#;
911 let plugin: ExternalPluginDef = serde_json::from_str(json_str).unwrap();
912 assert_eq!(
913 plugin.used_class_members,
914 vec![
915 UsedClassMemberRule::from("agInit"),
916 UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
917 extends: None,
918 implements: Some("ICellRendererAngularComp".to_string()),
919 members: vec!["refresh".to_string()],
920 }),
921 UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
922 extends: Some("BaseCommand".to_string()),
923 implements: None,
924 members: vec!["execute".to_string()],
925 }),
926 ]
927 );
928 }
929
930 #[test]
931 fn deserialize_plugin_with_used_class_members_toml() {
932 let toml_str = r#"
933name = "ag-grid"
934enablers = ["ag-grid-angular"]
935usedClassMembers = ["agInit", "refresh"]
936"#;
937 let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
938 assert_eq!(
939 plugin.used_class_members,
940 vec![
941 UsedClassMemberRule::from("agInit"),
942 UsedClassMemberRule::from("refresh"),
943 ]
944 );
945 }
946
947 #[test]
948 fn deserialize_plugin_with_scoped_used_class_members_toml() {
949 let toml_str = r#"
950name = "ag-grid"
951enablers = ["ag-grid-angular"]
952usedClassMembers = [
953 { implements = "ICellRendererAngularComp", members = ["refresh"] },
954 { extends = "BaseCommand", members = ["execute"] }
955]
956"#;
957 let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
958 assert_eq!(
959 plugin.used_class_members,
960 vec![
961 UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
962 extends: None,
963 implements: Some("ICellRendererAngularComp".to_string()),
964 members: vec!["refresh".to_string()],
965 }),
966 UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
967 extends: Some("BaseCommand".to_string()),
968 implements: None,
969 members: vec!["execute".to_string()],
970 }),
971 ]
972 );
973 }
974
975 #[test]
976 fn deserialize_plugin_rejects_unconstrained_scoped_used_class_members() {
977 let result = serde_json::from_str::<ExternalPluginDef>(
978 r#"{
979 "name": "ag-grid",
980 "enablers": ["ag-grid-angular"],
981 "usedClassMembers": [{ "members": ["refresh"] }]
982 }"#,
983 );
984 assert!(
985 result.is_err(),
986 "unconstrained scoped rule should be rejected"
987 );
988 }
989
990 #[test]
991 fn deserialize_full_plugin() {
992 let toml_str = r#"
993name = "my-framework"
994enablers = ["my-framework", "@my-framework/core"]
995entryPoints = ["src/routes/**/*.{ts,tsx}", "src/middleware.ts"]
996configPatterns = ["my-framework.config.{ts,js,mjs}"]
997alwaysUsed = ["src/setup.ts", "public/**/*"]
998toolingDependencies = ["my-framework-cli"]
999
1000[[usedExports]]
1001pattern = "src/routes/**/*.{ts,tsx}"
1002exports = ["default", "loader", "action"]
1003
1004[[usedExports]]
1005pattern = "src/middleware.ts"
1006exports = ["default"]
1007"#;
1008 let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
1009 assert_eq!(plugin.name, "my-framework");
1010 assert_eq!(plugin.enablers.len(), 2);
1011 assert_eq!(plugin.entry_points.len(), 2);
1012 assert_eq!(
1013 plugin.config_patterns,
1014 vec!["my-framework.config.{ts,js,mjs}"]
1015 );
1016 assert_eq!(plugin.always_used.len(), 2);
1017 assert_eq!(plugin.tooling_dependencies, vec!["my-framework-cli"]);
1018 assert_eq!(plugin.used_exports.len(), 2);
1019 assert_eq!(plugin.used_exports[0].pattern, "src/routes/**/*.{ts,tsx}");
1020 assert_eq!(
1021 plugin.used_exports[0].exports,
1022 vec!["default", "loader", "action"]
1023 );
1024 }
1025
1026 #[test]
1027 fn deserialize_json_plugin() {
1028 let json_str = r#"{
1029 "name": "my-json-plugin",
1030 "enablers": ["my-pkg"],
1031 "entryPoints": ["src/**/*.ts"],
1032 "configPatterns": ["my-plugin.config.js"],
1033 "alwaysUsed": ["src/setup.ts"],
1034 "toolingDependencies": ["my-cli"],
1035 "usedExports": [
1036 { "pattern": "src/**/*.ts", "exports": ["default"] }
1037 ]
1038 }"#;
1039 let plugin: ExternalPluginDef = serde_json::from_str(json_str).unwrap();
1040 assert_eq!(plugin.name, "my-json-plugin");
1041 assert_eq!(plugin.enablers, vec!["my-pkg"]);
1042 assert_eq!(plugin.entry_points, vec!["src/**/*.ts"]);
1043 assert_eq!(plugin.config_patterns, vec!["my-plugin.config.js"]);
1044 assert_eq!(plugin.always_used, vec!["src/setup.ts"]);
1045 assert_eq!(plugin.tooling_dependencies, vec!["my-cli"]);
1046 assert_eq!(plugin.used_exports.len(), 1);
1047 assert_eq!(plugin.used_exports[0].exports, vec!["default"]);
1048 }
1049
1050 #[test]
1051 fn manifest_field_paths_and_templates_are_parsed_once_and_round_trip() {
1052 let source = r#"{
1053 "name": "manifest-plugin",
1054 "manifestEntries": [{
1055 "manifests": "**/manifest.json",
1056 "when": { "plugin.browser": true },
1057 "entries": [{
1058 "path": "${plugin.entry}/index.{ts,tsx}",
1059 "when": { "type": "plugin" }
1060 }]
1061 }]
1062 }"#;
1063
1064 let plugin: ExternalPluginDef = serde_json::from_str(source).unwrap();
1065 let rule = &plugin.manifest_entries[0];
1066 let condition_path = rule.when.keys().next().unwrap();
1067 assert_eq!(
1068 condition_path.segments(),
1069 &[
1070 ManifestFieldSegment::Key("plugin".to_string()),
1071 ManifestFieldSegment::Key("browser".to_string()),
1072 ]
1073 );
1074 assert_eq!(
1075 rule.entries[0].path.as_str(),
1076 "${plugin.entry}/index.{ts,tsx}"
1077 );
1078 assert_eq!(
1079 rule.entries[0].path.parts(),
1080 &[
1081 ManifestPathPart::Field("plugin.entry".parse().unwrap()),
1082 ManifestPathPart::Literal("/index.{ts,tsx}".to_string()),
1083 ]
1084 );
1085
1086 let serialized = serde_json::to_value(&plugin).unwrap();
1087 assert_eq!(
1088 serialized["manifestEntries"][0]["when"]["plugin.browser"],
1089 true
1090 );
1091 assert_eq!(
1092 serialized["manifestEntries"][0]["entries"][0]["path"],
1093 "${plugin.entry}/index.{ts,tsx}"
1094 );
1095 }
1096
1097 #[test]
1098 fn invalid_manifest_field_paths_and_templates_are_rejected() {
1099 for source in [
1100 r#"{
1101 "name": "bad-condition",
1102 "manifestEntries": [{
1103 "manifests": "**/manifest.json",
1104 "when": { "plugin..browser": true },
1105 "entries": [{ "path": "index.ts" }]
1106 }]
1107 }"#,
1108 r#"{
1109 "name": "bad-template-field",
1110 "manifestEntries": [{
1111 "manifests": "**/manifest.json",
1112 "entries": [{ "path": "${plugin..entry}/index.ts" }]
1113 }]
1114 }"#,
1115 r#"{
1116 "name": "bad-entry-condition",
1117 "manifestEntries": [{
1118 "manifests": "**/manifest.json",
1119 "entries": [{
1120 "path": "index.ts",
1121 "when": { "plugin..browser": true }
1122 }]
1123 }]
1124 }"#,
1125 r#"{
1126 "name": "unterminated-template",
1127 "manifestEntries": [{
1128 "manifests": "**/manifest.json",
1129 "entries": [{ "path": "${plugin.entry/index.ts" }]
1130 }]
1131 }"#,
1132 ] {
1133 assert!(serde_json::from_str::<ExternalPluginDef>(source).is_err());
1134 }
1135 }
1136
1137 #[test]
1138 fn manifest_field_paths_support_only_explicit_array_traversal() {
1139 let path: ManifestFieldPath = "content_scripts[*].js[*]".parse().unwrap();
1140 assert_eq!(
1141 path.segments(),
1142 &[
1143 ManifestFieldSegment::Key("content_scripts".to_string()),
1144 ManifestFieldSegment::Each,
1145 ManifestFieldSegment::Key("js".to_string()),
1146 ManifestFieldSegment::Each,
1147 ]
1148 );
1149
1150 for invalid in [
1151 "content_scripts[0].js",
1152 "content_scripts[].js",
1153 "content_scripts[foo].js",
1154 "content_scripts[*x].js",
1155 "[*].js",
1156 ] {
1157 assert!(invalid.parse::<ManifestFieldPath>().is_err(), "{invalid}");
1158 }
1159 }
1160
1161 #[test]
1162 fn manifest_conditions_distinguish_scalar_equality_from_presence() {
1163 let source = r#"{
1164 "name": "condition-plugin",
1165 "manifestEntries": [{
1166 "manifests": "**/manifest.json",
1167 "when": {
1168 "main": { "exists": true },
1169 "enabled": false,
1170 "mode": "worker",
1171 "version": 3,
1172 "metadata": null
1173 },
1174 "entries": [{ "path": "index.ts" }]
1175 }]
1176 }"#;
1177 let plugin: ExternalPluginDef = serde_json::from_str(source).unwrap();
1178 let when = &plugin.manifest_entries[0].when;
1179
1180 assert_eq!(
1181 when.get(&"main".parse().unwrap()),
1182 Some(&ManifestCondition::Exists(ManifestExistsPredicate {
1183 exists: true,
1184 }))
1185 );
1186 assert_eq!(
1187 when.get(&"enabled".parse().unwrap()),
1188 Some(&ManifestCondition::Equals(serde_json::Value::Bool(false)))
1189 );
1190 assert_eq!(
1191 when.get(&"metadata".parse().unwrap()),
1192 Some(&ManifestCondition::Equals(serde_json::Value::Null))
1193 );
1194
1195 let serialized = serde_json::to_value(plugin).unwrap();
1196 assert_eq!(
1197 serialized["manifestEntries"][0]["when"]["main"],
1198 serde_json::json!({ "exists": true })
1199 );
1200 }
1201
1202 #[test]
1203 fn manifest_conditions_preserve_non_operator_json_equality_values() {
1204 let expected = [
1205 serde_json::json!({ "exists": "not-an-operator" }),
1206 serde_json::json!({ "exists": true, "extra": true }),
1207 serde_json::json!({ "other": true }),
1208 serde_json::json!(["worker"]),
1209 ];
1210 for condition in expected {
1211 let source = serde_json::json!({
1212 "name": "equality-condition",
1213 "manifestEntries": [{
1214 "manifests": "**/manifest.json",
1215 "when": { "field": condition.clone() },
1216 "entries": [{ "path": "index.ts" }]
1217 }]
1218 });
1219 let plugin: ExternalPluginDef = serde_json::from_value(source).unwrap();
1220 assert_eq!(
1221 plugin.manifest_entries[0]
1222 .when
1223 .get(&"field".parse().unwrap()),
1224 Some(&ManifestCondition::Equals(condition))
1225 );
1226 }
1227 }
1228
1229 #[test]
1230 fn manifest_exists_conditions_deserialize_from_toml() {
1231 let source = r#"
1232name = "condition-plugin"
1233
1234[[manifestEntries]]
1235manifests = "**/manifest.json"
1236
1237[manifestEntries.when.main]
1238exists = true
1239
1240[[manifestEntries.entries]]
1241path = "index.ts"
1242"#;
1243 let plugin: ExternalPluginDef = toml::from_str(source).unwrap();
1244 assert_eq!(
1245 plugin.manifest_entries[0]
1246 .when
1247 .get(&"main".parse().unwrap()),
1248 Some(&ManifestCondition::Exists(ManifestExistsPredicate {
1249 exists: true,
1250 }))
1251 );
1252 }
1253
1254 #[test]
1255 fn deserialize_jsonc_plugin() {
1256 let jsonc_str = r#"{
1257 "name": "my-jsonc-plugin",
1258 "enablers": ["my-pkg"],
1259 /* Block comment */
1260 "entryPoints": ["src/**/*.ts"]
1261 }"#;
1262 let plugin: ExternalPluginDef = crate::jsonc::parse_to_value(jsonc_str).unwrap();
1263 assert_eq!(plugin.name, "my-jsonc-plugin");
1264 assert_eq!(plugin.enablers, vec!["my-pkg"]);
1265 assert_eq!(plugin.entry_points, vec!["src/**/*.ts"]);
1266 }
1267
1268 #[test]
1269 fn deserialize_json_with_schema_field() {
1270 let json_str = r#"{
1271 "$schema": "https://raw.githubusercontent.com/fallow-rs/fallow/main/plugin-schema.json",
1272 "name": "schema-plugin",
1273 "enablers": ["my-pkg"]
1274 }"#;
1275 let plugin: ExternalPluginDef = serde_json::from_str(json_str).unwrap();
1276 assert_eq!(plugin.name, "schema-plugin");
1277 assert_eq!(plugin.enablers, vec!["my-pkg"]);
1278 }
1279
1280 #[test]
1281 fn plugin_json_schema_generation() {
1282 let schema = ExternalPluginDef::json_schema();
1283 assert!(schema.is_object());
1284 let obj = schema.as_object().unwrap();
1285 assert!(obj.contains_key("properties"));
1286 }
1287
1288 #[test]
1289 fn discover_plugins_from_fallow_plugins_dir() {
1290 let dir =
1291 std::env::temp_dir().join(format!("fallow-test-ext-plugins-{}", std::process::id()));
1292 let plugins_dir = dir.join(".fallow").join("plugins");
1293 let _ = std::fs::create_dir_all(&plugins_dir);
1294
1295 std::fs::write(
1296 plugins_dir.join("my-plugin.toml"),
1297 r#"
1298name = "my-plugin"
1299enablers = ["my-pkg"]
1300entryPoints = ["src/**/*.ts"]
1301"#,
1302 )
1303 .unwrap();
1304
1305 let plugins = discover_external_plugins(&dir, &[]);
1306 assert_eq!(plugins.len(), 1);
1307 assert_eq!(plugins[0].name, "my-plugin");
1308
1309 let _ = std::fs::remove_dir_all(&dir);
1310 }
1311
1312 #[test]
1313 fn discover_json_plugins_from_fallow_plugins_dir() {
1314 let dir = std::env::temp_dir().join(format!(
1315 "fallow-test-ext-json-plugins-{}",
1316 std::process::id()
1317 ));
1318 let plugins_dir = dir.join(".fallow").join("plugins");
1319 let _ = std::fs::create_dir_all(&plugins_dir);
1320
1321 std::fs::write(
1322 plugins_dir.join("my-plugin.json"),
1323 r#"{"name": "json-plugin", "enablers": ["json-pkg"]}"#,
1324 )
1325 .unwrap();
1326
1327 std::fs::write(
1328 plugins_dir.join("my-plugin.jsonc"),
1329 r#"{
1330 "name": "jsonc-plugin",
1331 "enablers": ["jsonc-pkg"]
1332 }"#,
1333 )
1334 .unwrap();
1335
1336 let plugins = discover_external_plugins(&dir, &[]);
1337 assert_eq!(plugins.len(), 2);
1338 assert_eq!(plugins[0].name, "json-plugin");
1339 assert_eq!(plugins[1].name, "jsonc-plugin");
1340
1341 let _ = std::fs::remove_dir_all(&dir);
1342 }
1343
1344 #[test]
1345 fn discover_fallow_plugin_files_in_root() {
1346 let dir =
1347 std::env::temp_dir().join(format!("fallow-test-root-plugins-{}", std::process::id()));
1348 let _ = std::fs::create_dir_all(&dir);
1349
1350 std::fs::write(
1351 dir.join("fallow-plugin-custom.toml"),
1352 r#"
1353name = "custom"
1354enablers = ["custom-pkg"]
1355"#,
1356 )
1357 .unwrap();
1358
1359 std::fs::write(dir.join("some-other-file.toml"), r#"name = "ignored""#).unwrap();
1360
1361 let plugins = discover_external_plugins(&dir, &[]);
1362 assert_eq!(plugins.len(), 1);
1363 assert_eq!(plugins[0].name, "custom");
1364
1365 let _ = std::fs::remove_dir_all(&dir);
1366 }
1367
1368 #[test]
1369 fn discover_fallow_plugin_json_files_in_root() {
1370 let dir = std::env::temp_dir().join(format!(
1371 "fallow-test-root-json-plugins-{}",
1372 std::process::id()
1373 ));
1374 let _ = std::fs::create_dir_all(&dir);
1375
1376 std::fs::write(
1377 dir.join("fallow-plugin-custom.json"),
1378 r#"{"name": "json-root", "enablers": ["json-pkg"]}"#,
1379 )
1380 .unwrap();
1381
1382 std::fs::write(
1383 dir.join("fallow-plugin-custom2.jsonc"),
1384 r#"{
1385 "name": "jsonc-root",
1386 "enablers": ["jsonc-pkg"]
1387 }"#,
1388 )
1389 .unwrap();
1390
1391 std::fs::write(
1392 dir.join("fallow-plugin-bad.yaml"),
1393 "name: ignored\nenablers:\n - pkg\n",
1394 )
1395 .unwrap();
1396
1397 let plugins = discover_external_plugins(&dir, &[]);
1398 assert_eq!(plugins.len(), 2);
1399
1400 let _ = std::fs::remove_dir_all(&dir);
1401 }
1402
1403 #[test]
1404 fn discover_mixed_formats_in_dir() {
1405 let dir =
1406 std::env::temp_dir().join(format!("fallow-test-mixed-plugins-{}", std::process::id()));
1407 let plugins_dir = dir.join(".fallow").join("plugins");
1408 let _ = std::fs::create_dir_all(&plugins_dir);
1409
1410 std::fs::write(
1411 plugins_dir.join("a-plugin.toml"),
1412 r#"
1413name = "toml-plugin"
1414enablers = ["toml-pkg"]
1415"#,
1416 )
1417 .unwrap();
1418
1419 std::fs::write(
1420 plugins_dir.join("b-plugin.json"),
1421 r#"{"name": "json-plugin", "enablers": ["json-pkg"]}"#,
1422 )
1423 .unwrap();
1424
1425 std::fs::write(
1426 plugins_dir.join("c-plugin.jsonc"),
1427 r#"{
1428 "name": "jsonc-plugin",
1429 "enablers": ["jsonc-pkg"]
1430 }"#,
1431 )
1432 .unwrap();
1433
1434 let plugins = discover_external_plugins(&dir, &[]);
1435 assert_eq!(plugins.len(), 3);
1436 assert_eq!(plugins[0].name, "toml-plugin");
1437 assert_eq!(plugins[1].name, "json-plugin");
1438 assert_eq!(plugins[2].name, "jsonc-plugin");
1439
1440 let _ = std::fs::remove_dir_all(&dir);
1441 }
1442
1443 #[test]
1444 fn deduplicates_by_name() {
1445 let dir =
1446 std::env::temp_dir().join(format!("fallow-test-dedup-plugins-{}", std::process::id()));
1447 let plugins_dir = dir.join(".fallow").join("plugins");
1448 let _ = std::fs::create_dir_all(&plugins_dir);
1449
1450 std::fs::write(
1451 plugins_dir.join("my-plugin.toml"),
1452 r#"
1453name = "my-plugin"
1454enablers = ["pkg-a"]
1455"#,
1456 )
1457 .unwrap();
1458
1459 std::fs::write(
1460 dir.join("fallow-plugin-my-plugin.toml"),
1461 r#"
1462name = "my-plugin"
1463enablers = ["pkg-b"]
1464"#,
1465 )
1466 .unwrap();
1467
1468 let plugins = discover_external_plugins(&dir, &[]);
1469 assert_eq!(plugins.len(), 1);
1470 assert_eq!(plugins[0].enablers, vec!["pkg-a"]);
1471
1472 let _ = std::fs::remove_dir_all(&dir);
1473 }
1474
1475 #[test]
1476 fn config_plugin_paths_take_priority() {
1477 let dir =
1478 std::env::temp_dir().join(format!("fallow-test-config-paths-{}", std::process::id()));
1479 let custom_dir = dir.join("custom-plugins");
1480 let _ = std::fs::create_dir_all(&custom_dir);
1481
1482 std::fs::write(
1483 custom_dir.join("explicit.toml"),
1484 r#"
1485name = "explicit"
1486enablers = ["explicit-pkg"]
1487"#,
1488 )
1489 .unwrap();
1490
1491 let plugins = discover_external_plugins(&dir, &["custom-plugins".to_string()]);
1492 assert_eq!(plugins.len(), 1);
1493 assert_eq!(plugins[0].name, "explicit");
1494
1495 let _ = std::fs::remove_dir_all(&dir);
1496 }
1497
1498 #[test]
1499 fn config_plugin_path_to_single_file() {
1500 let dir =
1501 std::env::temp_dir().join(format!("fallow-test-single-file-{}", std::process::id()));
1502 let _ = std::fs::create_dir_all(&dir);
1503
1504 std::fs::write(
1505 dir.join("my-plugin.toml"),
1506 r#"
1507name = "single-file"
1508enablers = ["single-pkg"]
1509"#,
1510 )
1511 .unwrap();
1512
1513 let plugins = discover_external_plugins(&dir, &["my-plugin.toml".to_string()]);
1514 assert_eq!(plugins.len(), 1);
1515 assert_eq!(plugins[0].name, "single-file");
1516
1517 let _ = std::fs::remove_dir_all(&dir);
1518 }
1519
1520 #[test]
1521 fn config_plugin_path_to_single_json_file() {
1522 let dir = std::env::temp_dir().join(format!(
1523 "fallow-test-single-json-file-{}",
1524 std::process::id()
1525 ));
1526 let _ = std::fs::create_dir_all(&dir);
1527
1528 std::fs::write(
1529 dir.join("my-plugin.json"),
1530 r#"{"name": "json-single", "enablers": ["json-pkg"]}"#,
1531 )
1532 .unwrap();
1533
1534 let plugins = discover_external_plugins(&dir, &["my-plugin.json".to_string()]);
1535 assert_eq!(plugins.len(), 1);
1536 assert_eq!(plugins[0].name, "json-single");
1537
1538 let _ = std::fs::remove_dir_all(&dir);
1539 }
1540
1541 #[test]
1542 fn skips_invalid_toml() {
1543 let dir =
1544 std::env::temp_dir().join(format!("fallow-test-invalid-plugin-{}", std::process::id()));
1545 let plugins_dir = dir.join(".fallow").join("plugins");
1546 let _ = std::fs::create_dir_all(&plugins_dir);
1547
1548 std::fs::write(plugins_dir.join("bad.toml"), r#"enablers = ["pkg"]"#).unwrap();
1549
1550 std::fs::write(
1551 plugins_dir.join("good.toml"),
1552 r#"
1553name = "good"
1554enablers = ["good-pkg"]
1555"#,
1556 )
1557 .unwrap();
1558
1559 let plugins = discover_external_plugins(&dir, &[]);
1560 assert_eq!(plugins.len(), 1);
1561 assert_eq!(plugins[0].name, "good");
1562
1563 let _ = std::fs::remove_dir_all(&dir);
1564 }
1565
1566 #[test]
1567 fn skips_invalid_json() {
1568 let dir = std::env::temp_dir().join(format!(
1569 "fallow-test-invalid-json-plugin-{}",
1570 std::process::id()
1571 ));
1572 let plugins_dir = dir.join(".fallow").join("plugins");
1573 let _ = std::fs::create_dir_all(&plugins_dir);
1574
1575 std::fs::write(plugins_dir.join("bad.json"), r#"{"enablers": ["pkg"]}"#).unwrap();
1576
1577 std::fs::write(
1578 plugins_dir.join("good.json"),
1579 r#"{"name": "good-json", "enablers": ["good-pkg"]}"#,
1580 )
1581 .unwrap();
1582
1583 let plugins = discover_external_plugins(&dir, &[]);
1584 assert_eq!(plugins.len(), 1);
1585 assert_eq!(plugins[0].name, "good-json");
1586
1587 let _ = std::fs::remove_dir_all(&dir);
1588 }
1589
1590 #[test]
1591 fn prefix_enablers() {
1592 let toml_str = r#"
1593name = "scoped"
1594enablers = ["@myorg/"]
1595"#;
1596 let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
1597 assert_eq!(plugin.enablers, vec!["@myorg/"]);
1598 }
1599
1600 #[test]
1601 fn skips_empty_name() {
1602 let dir =
1603 std::env::temp_dir().join(format!("fallow-test-empty-name-{}", std::process::id()));
1604 let plugins_dir = dir.join(".fallow").join("plugins");
1605 let _ = std::fs::create_dir_all(&plugins_dir);
1606
1607 std::fs::write(
1608 plugins_dir.join("empty.toml"),
1609 r#"
1610name = ""
1611enablers = ["pkg"]
1612"#,
1613 )
1614 .unwrap();
1615
1616 let plugins = discover_external_plugins(&dir, &[]);
1617 assert!(plugins.is_empty(), "empty-name plugin should be skipped");
1618
1619 let _ = std::fs::remove_dir_all(&dir);
1620 }
1621
1622 #[test]
1623 fn rejects_paths_outside_root() {
1624 let dir =
1625 std::env::temp_dir().join(format!("fallow-test-path-escape-{}", std::process::id()));
1626 let _ = std::fs::create_dir_all(&dir);
1627
1628 let plugins = discover_external_plugins(&dir, &["../../../etc".to_string()]);
1629 assert!(plugins.is_empty(), "paths outside root should be rejected");
1630
1631 let _ = std::fs::remove_dir_all(&dir);
1632 }
1633
1634 #[test]
1635 fn plugin_format_detection() {
1636 assert!(matches!(
1637 PluginFormat::from_path(Path::new("plugin.toml")),
1638 Some(PluginFormat::Toml)
1639 ));
1640 assert!(matches!(
1641 PluginFormat::from_path(Path::new("plugin.json")),
1642 Some(PluginFormat::Json)
1643 ));
1644 assert!(matches!(
1645 PluginFormat::from_path(Path::new("plugin.jsonc")),
1646 Some(PluginFormat::Jsonc)
1647 ));
1648 assert!(PluginFormat::from_path(Path::new("plugin.yaml")).is_none());
1649 assert!(PluginFormat::from_path(Path::new("plugin")).is_none());
1650 }
1651
1652 #[test]
1653 fn is_plugin_file_checks_extensions() {
1654 assert!(is_plugin_file(Path::new("plugin.toml")));
1655 assert!(is_plugin_file(Path::new("plugin.json")));
1656 assert!(is_plugin_file(Path::new("plugin.jsonc")));
1657 assert!(!is_plugin_file(Path::new("plugin.yaml")));
1658 assert!(!is_plugin_file(Path::new("plugin.txt")));
1659 assert!(!is_plugin_file(Path::new("plugin")));
1660 }
1661
1662 #[test]
1663 fn detection_deserialize_dependency() {
1664 let json = r#"{"type": "dependency", "package": "next"}"#;
1665 let detection: PluginDetection = serde_json::from_str(json).unwrap();
1666 assert!(matches!(detection, PluginDetection::Dependency { package } if package == "next"));
1667 }
1668
1669 #[test]
1670 fn detection_deserialize_file_exists() {
1671 let json = r#"{"type": "fileExists", "pattern": "tsconfig.json"}"#;
1672 let detection: PluginDetection = serde_json::from_str(json).unwrap();
1673 assert!(
1674 matches!(detection, PluginDetection::FileExists { pattern } if pattern == "tsconfig.json")
1675 );
1676 }
1677
1678 #[test]
1679 fn detection_deserialize_all() {
1680 let json = r#"{"type": "all", "conditions": [{"type": "dependency", "package": "a"}, {"type": "dependency", "package": "b"}]}"#;
1681 let detection: PluginDetection = serde_json::from_str(json).unwrap();
1682 assert!(matches!(detection, PluginDetection::All { conditions } if conditions.len() == 2));
1683 }
1684
1685 #[test]
1686 fn detection_deserialize_any() {
1687 let json = r#"{"type": "any", "conditions": [{"type": "dependency", "package": "a"}]}"#;
1688 let detection: PluginDetection = serde_json::from_str(json).unwrap();
1689 assert!(matches!(detection, PluginDetection::Any { conditions } if conditions.len() == 1));
1690 }
1691
1692 #[test]
1693 fn plugin_with_detection_field() {
1694 let json = r#"{
1695 "name": "my-plugin",
1696 "detection": {"type": "dependency", "package": "my-pkg"},
1697 "entryPoints": ["src/**/*.ts"]
1698 }"#;
1699 let plugin: ExternalPluginDef = serde_json::from_str(json).unwrap();
1700 assert_eq!(plugin.name, "my-plugin");
1701 assert!(plugin.detection.is_some());
1702 assert!(plugin.enablers.is_empty());
1703 assert_eq!(plugin.entry_points, vec!["src/**/*.ts"]);
1704 }
1705
1706 #[test]
1707 fn plugin_without_detection_uses_enablers() {
1708 let json = r#"{
1709 "name": "my-plugin",
1710 "enablers": ["my-pkg"]
1711 }"#;
1712 let plugin: ExternalPluginDef = serde_json::from_str(json).unwrap();
1713 assert!(plugin.detection.is_none());
1714 assert_eq!(plugin.enablers, vec!["my-pkg"]);
1715 }
1716
1717 #[test]
1718 fn detection_nested_all_with_any() {
1719 let json = r#"{
1720 "type": "all",
1721 "conditions": [
1722 {"type": "dependency", "package": "react"},
1723 {"type": "any", "conditions": [
1724 {"type": "fileExists", "pattern": "next.config.js"},
1725 {"type": "fileExists", "pattern": "next.config.mjs"}
1726 ]}
1727 ]
1728 }"#;
1729 let detection: PluginDetection = serde_json::from_str(json).unwrap();
1730 match detection {
1731 PluginDetection::All { conditions } => {
1732 assert_eq!(conditions.len(), 2);
1733 assert!(matches!(
1734 &conditions[0],
1735 PluginDetection::Dependency { package } if package == "react"
1736 ));
1737 match &conditions[1] {
1738 PluginDetection::Any { conditions: inner } => {
1739 assert_eq!(inner.len(), 2);
1740 }
1741 other => panic!("expected Any, got: {other:?}"),
1742 }
1743 }
1744 other => panic!("expected All, got: {other:?}"),
1745 }
1746 }
1747
1748 #[test]
1749 fn detection_empty_all_conditions() {
1750 let json = r#"{"type": "all", "conditions": []}"#;
1751 let detection: PluginDetection = serde_json::from_str(json).unwrap();
1752 assert!(matches!(
1753 detection,
1754 PluginDetection::All { conditions } if conditions.is_empty()
1755 ));
1756 }
1757
1758 #[test]
1759 fn detection_empty_any_conditions() {
1760 let json = r#"{"type": "any", "conditions": []}"#;
1761 let detection: PluginDetection = serde_json::from_str(json).unwrap();
1762 assert!(matches!(
1763 detection,
1764 PluginDetection::Any { conditions } if conditions.is_empty()
1765 ));
1766 }
1767
1768 #[test]
1769 fn detection_toml_dependency() {
1770 let toml_str = r#"
1771name = "my-plugin"
1772
1773[detection]
1774type = "dependency"
1775package = "next"
1776"#;
1777 let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
1778 assert!(plugin.detection.is_some());
1779 assert!(matches!(
1780 plugin.detection.unwrap(),
1781 PluginDetection::Dependency { package } if package == "next"
1782 ));
1783 }
1784
1785 #[test]
1786 fn detection_toml_file_exists() {
1787 let toml_str = r#"
1788name = "my-plugin"
1789
1790[detection]
1791type = "fileExists"
1792pattern = "next.config.js"
1793"#;
1794 let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
1795 assert!(matches!(
1796 plugin.detection.unwrap(),
1797 PluginDetection::FileExists { pattern } if pattern == "next.config.js"
1798 ));
1799 }
1800
1801 #[test]
1802 fn plugin_all_fields_json() {
1803 let json = r#"{
1804 "$schema": "https://raw.githubusercontent.com/fallow-rs/fallow/main/plugin-schema.json",
1805 "name": "full-plugin",
1806 "detection": {"type": "dependency", "package": "my-pkg"},
1807 "enablers": ["fallback-enabler"],
1808 "entryPoints": ["src/entry.ts"],
1809 "configPatterns": ["config.js"],
1810 "alwaysUsed": ["src/polyfills.ts"],
1811 "toolingDependencies": ["my-cli"],
1812 "usedExports": [{"pattern": "src/**", "exports": ["default", "setup"]}]
1813 }"#;
1814 let plugin: ExternalPluginDef = serde_json::from_str(json).unwrap();
1815 assert_eq!(plugin.name, "full-plugin");
1816 assert!(plugin.detection.is_some());
1817 assert_eq!(plugin.enablers, vec!["fallback-enabler"]);
1818 assert_eq!(plugin.entry_points, vec!["src/entry.ts"]);
1819 assert_eq!(plugin.config_patterns, vec!["config.js"]);
1820 assert_eq!(plugin.always_used, vec!["src/polyfills.ts"]);
1821 assert_eq!(plugin.tooling_dependencies, vec!["my-cli"]);
1822 assert_eq!(plugin.used_exports.len(), 1);
1823 assert_eq!(plugin.used_exports[0].pattern, "src/**");
1824 assert_eq!(plugin.used_exports[0].exports, vec!["default", "setup"]);
1825 }
1826
1827 #[test]
1828 fn plugin_with_special_chars_in_name() {
1829 let json = r#"{"name": "@scope/my-plugin-v2.0", "enablers": ["pkg"]}"#;
1830 let plugin: ExternalPluginDef = serde_json::from_str(json).unwrap();
1831 assert_eq!(plugin.name, "@scope/my-plugin-v2.0");
1832 }
1833
1834 #[test]
1835 fn parse_plugin_toml_format() {
1836 let content = r#"
1837name = "test-plugin"
1838enablers = ["test-pkg"]
1839entryPoints = ["src/**/*.ts"]
1840"#;
1841 let result = parse_plugin(content, &PluginFormat::Toml, Path::new("test.toml"));
1842 assert!(result.is_some());
1843 let plugin = result.unwrap();
1844 assert_eq!(plugin.name, "test-plugin");
1845 }
1846
1847 #[test]
1848 fn parse_plugin_json_format() {
1849 let content = r#"{"name": "json-test", "enablers": ["pkg"]}"#;
1850 let result = parse_plugin(content, &PluginFormat::Json, Path::new("test.json"));
1851 assert!(result.is_some());
1852 assert_eq!(result.unwrap().name, "json-test");
1853 }
1854
1855 #[test]
1856 fn parse_plugin_jsonc_format() {
1857 let content = r#"{
1858 "name": "jsonc-test",
1859 "enablers": ["pkg"]
1860 }"#;
1861 let result = parse_plugin(content, &PluginFormat::Jsonc, Path::new("test.jsonc"));
1862 assert!(result.is_some());
1863 assert_eq!(result.unwrap().name, "jsonc-test");
1864 }
1865
1866 #[test]
1867 fn parse_plugin_invalid_toml_returns_none() {
1868 let content = "not valid toml [[[";
1869 let result = parse_plugin(content, &PluginFormat::Toml, Path::new("bad.toml"));
1870 assert!(result.is_none());
1871 }
1872
1873 #[test]
1874 fn parse_plugin_invalid_json_returns_none() {
1875 let content = "{ not valid json }";
1876 let result = parse_plugin(content, &PluginFormat::Json, Path::new("bad.json"));
1877 assert!(result.is_none());
1878 }
1879
1880 #[test]
1881 fn parse_plugin_invalid_jsonc_returns_none() {
1882 let content = r#"{"enablers": ["pkg"]}"#;
1883 let result = parse_plugin(content, &PluginFormat::Jsonc, Path::new("bad.jsonc"));
1884 assert!(result.is_none());
1885 }
1886}