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 pub scope: Vec<PathBuf>,
64}
65
66impl AutoImportRule {
67 #[must_use]
69 pub const fn new(name: String, source: PathBuf, kind: AutoImportKind) -> Self {
70 Self {
71 name,
72 source,
73 kind,
74 scope: Vec::new(),
75 }
76 }
77}
78
79#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
84#[serde(tag = "type", rename_all = "camelCase")]
85pub enum PluginDetection {
86 Dependency {
88 package: String,
90 },
91 FileExists {
93 pattern: String,
97 },
98 All {
100 conditions: Vec<Self>,
102 },
103 Any {
105 conditions: Vec<Self>,
107 },
108}
109
110#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
137#[serde(rename_all = "camelCase")]
138pub struct ExternalPluginDef {
139 #[serde(rename = "$schema", default, skip_serializing)]
141 #[schemars(skip)]
142 pub schema: Option<String>,
143
144 pub name: String,
146
147 #[serde(default)]
150 pub detection: Option<PluginDetection>,
151
152 #[serde(default)]
156 pub enablers: Vec<String>,
157
158 #[serde(default)]
160 pub entry_points: Vec<String>,
161
162 #[serde(default = "default_external_entry_point_role")]
167 pub entry_point_role: EntryPointRole,
168
169 #[serde(default)]
176 pub manifest_entries: Vec<ManifestEntryRule>,
177
178 #[serde(default)]
180 pub config_patterns: Vec<String>,
181
182 #[serde(default)]
184 pub always_used: Vec<String>,
185
186 #[serde(default)]
189 pub tooling_dependencies: Vec<String>,
190
191 #[serde(default)]
193 pub used_exports: Vec<ExternalUsedExport>,
194
195 #[serde(default)]
200 pub used_class_members: Vec<UsedClassMemberRule>,
201}
202
203#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
205pub struct ExternalUsedExport {
206 pub pattern: String,
208 pub exports: Vec<String>,
210}
211
212#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema, Default)]
216#[serde(rename_all = "lowercase")]
217pub enum ManifestFormat {
218 #[default]
220 Jsonc,
221 Json,
223}
224
225#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
231pub struct ManifestFieldPath {
232 raw: String,
233 segments: Vec<ManifestFieldSegment>,
234}
235
236#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
238pub enum ManifestFieldSegment {
239 Key(String),
241 Each,
243}
244
245impl ManifestFieldPath {
246 #[must_use]
248 pub fn as_str(&self) -> &str {
249 &self.raw
250 }
251
252 #[must_use]
254 pub fn segments(&self) -> &[ManifestFieldSegment] {
255 &self.segments
256 }
257
258 fn parse(raw: String) -> Result<Self, String> {
259 if raw.is_empty() {
260 return Err("manifest field path must not be empty".to_string());
261 }
262
263 let mut segments = Vec::new();
264 for component in raw.split('.') {
265 if component.is_empty() {
266 return Err(format!(
267 "manifest field path '{raw}' contains an empty segment"
268 ));
269 }
270
271 let key_end = component.find('[').unwrap_or(component.len());
272 let key = &component[..key_end];
273 if key.is_empty() || key.contains(['{', '}', ']']) {
274 return Err(format!(
275 "manifest field path '{raw}' contains unsupported bracket syntax"
276 ));
277 }
278 segments.push(ManifestFieldSegment::Key(key.to_string()));
279
280 let mut suffix = &component[key_end..];
281 while !suffix.is_empty() {
282 let Some(rest) = suffix.strip_prefix("[*]") else {
283 return Err(format!(
284 "manifest field path '{raw}' only supports exact '[*]' array traversal"
285 ));
286 };
287 segments.push(ManifestFieldSegment::Each);
288 suffix = rest;
289 }
290 }
291
292 Ok(Self { raw, segments })
293 }
294}
295
296impl FromStr for ManifestFieldPath {
297 type Err = String;
298
299 fn from_str(raw: &str) -> Result<Self, Self::Err> {
300 Self::parse(raw.to_string())
301 }
302}
303
304impl fmt::Display for ManifestFieldPath {
305 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
306 f.write_str(&self.raw)
307 }
308}
309
310impl Serialize for ManifestFieldPath {
311 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
312 where
313 S: Serializer,
314 {
315 serializer.serialize_str(&self.raw)
316 }
317}
318
319impl<'de> Deserialize<'de> for ManifestFieldPath {
320 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
321 where
322 D: Deserializer<'de>,
323 {
324 let raw = String::deserialize(deserializer)?;
325 Self::parse(raw).map_err(D::Error::custom)
326 }
327}
328
329#[derive(Debug, Clone, PartialEq, Eq)]
331pub enum ManifestPathPart {
332 Literal(String),
334 Field(ManifestFieldPath),
336}
337
338#[derive(Debug, Clone, PartialEq, Eq)]
340pub struct ManifestPathTemplate {
341 raw: String,
342 parts: Vec<ManifestPathPart>,
343}
344
345impl ManifestPathTemplate {
346 #[must_use]
348 pub fn as_str(&self) -> &str {
349 &self.raw
350 }
351
352 #[must_use]
354 pub fn parts(&self) -> &[ManifestPathPart] {
355 &self.parts
356 }
357
358 #[must_use]
360 pub fn validation_pattern(&self) -> String {
361 let mut pattern = String::with_capacity(self.raw.len());
362 for part in &self.parts {
363 match part {
364 ManifestPathPart::Literal(literal) => pattern.push_str(literal),
365 ManifestPathPart::Field(_) => pattern.push_str("fallowinterp"),
366 }
367 }
368 pattern
369 }
370
371 fn parse(raw: String) -> Result<Self, String> {
372 let mut parts = Vec::new();
373 let mut rest = raw.as_str();
374
375 while let Some(start) = rest.find("${") {
376 if start > 0 {
377 parts.push(ManifestPathPart::Literal(rest[..start].to_string()));
378 }
379
380 let after = &rest[start + 2..];
381 let Some(end) = after.find('}') else {
382 return Err(format!(
383 "manifest entry path '{raw}' contains an unterminated interpolation"
384 ));
385 };
386 let field = after[..end].parse::<ManifestFieldPath>()?;
387 parts.push(ManifestPathPart::Field(field));
388 rest = &after[end + 1..];
389 }
390
391 if !rest.is_empty() {
392 parts.push(ManifestPathPart::Literal(rest.to_string()));
393 }
394 if parts.is_empty() {
395 parts.push(ManifestPathPart::Literal(String::new()));
396 }
397
398 Ok(Self { raw, parts })
399 }
400}
401
402impl FromStr for ManifestPathTemplate {
403 type Err = String;
404
405 fn from_str(raw: &str) -> Result<Self, Self::Err> {
406 Self::parse(raw.to_string())
407 }
408}
409
410impl fmt::Display for ManifestPathTemplate {
411 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
412 f.write_str(&self.raw)
413 }
414}
415
416impl Serialize for ManifestPathTemplate {
417 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
418 where
419 S: Serializer,
420 {
421 serializer.serialize_str(&self.raw)
422 }
423}
424
425impl<'de> Deserialize<'de> for ManifestPathTemplate {
426 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
427 where
428 D: Deserializer<'de>,
429 {
430 let raw = String::deserialize(deserializer)?;
431 Self::parse(raw).map_err(D::Error::custom)
432 }
433}
434
435#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
440#[serde(untagged)]
441pub enum ManifestCondition {
442 Exists(ManifestExistsPredicate),
444 Equals(serde_json::Value),
446}
447
448#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
450#[serde(deny_unknown_fields)]
451pub struct ManifestExistsPredicate {
452 pub exists: bool,
454}
455
456#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
475#[serde(rename_all = "camelCase")]
476pub struct ManifestEntryRule {
477 pub manifests: String,
479
480 #[serde(default)]
482 pub format: ManifestFormat,
483
484 #[serde(default)]
490 #[schemars(with = "BTreeMap<String, ManifestCondition>")]
491 pub when: BTreeMap<ManifestFieldPath, ManifestCondition>,
492
493 pub entries: Vec<ManifestSeedRule>,
495}
496
497#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
500#[serde(rename_all = "camelCase")]
501pub struct ManifestSeedRule {
502 #[schemars(with = "String")]
510 pub path: ManifestPathTemplate,
511
512 #[serde(default)]
515 #[schemars(with = "BTreeMap<String, ManifestCondition>")]
516 pub when: BTreeMap<ManifestFieldPath, ManifestCondition>,
517}
518
519fn default_external_entry_point_role() -> EntryPointRole {
520 EntryPointRole::Support
521}
522
523impl ExternalPluginDef {
524 #[must_use]
526 pub fn json_schema() -> serde_json::Value {
527 serde_json::to_value(schemars::schema_for!(ExternalPluginDef)).unwrap_or_default()
528 }
529
530 pub fn validate_user_globs(
544 &self,
545 ) -> Result<(), Vec<crate::config::glob_validation::GlobValidationError>> {
546 use crate::config::glob_validation::{compile_user_glob, validate_user_globs};
547
548 let mut errors = Vec::new();
549 validate_user_globs(&self.entry_points, "framework[].entryPoints", &mut errors);
550 validate_user_globs(&self.always_used, "framework[].alwaysUsed", &mut errors);
551 validate_user_globs(
552 &self.config_patterns,
553 "framework[].configPatterns",
554 &mut errors,
555 );
556 for used in &self.used_exports {
557 if let Err(e) = compile_user_glob(&used.pattern, "framework[].usedExports[].pattern") {
558 errors.push(e);
559 }
560 }
561 for rule in &self.manifest_entries {
562 if let Err(e) =
563 compile_user_glob(&rule.manifests, "framework[].manifestEntries[].manifests")
564 {
565 errors.push(e);
566 }
567 for seed in &rule.entries {
568 let probe = seed.path.validation_pattern();
571 if let Err(e) =
572 compile_user_glob(&probe, "framework[].manifestEntries[].entries[].path")
573 {
574 errors.push(e);
575 }
576 }
577 }
578 if let Some(detection) = &self.detection {
579 validate_detection_user_globs(detection, "framework[].detection", &mut errors);
580 }
581 if errors.is_empty() {
582 Ok(())
583 } else {
584 Err(errors)
585 }
586 }
587}
588
589fn validate_detection_user_globs(
592 detection: &PluginDetection,
593 field: &'static str,
594 errors: &mut Vec<crate::config::glob_validation::GlobValidationError>,
595) {
596 match detection {
597 PluginDetection::Dependency { .. } => {}
598 PluginDetection::FileExists { pattern } => {
599 if let Err(e) = crate::config::glob_validation::compile_user_glob(pattern, field) {
600 errors.push(e);
601 }
602 }
603 PluginDetection::All { conditions } | PluginDetection::Any { conditions } => {
604 for condition in conditions {
605 validate_detection_user_globs(condition, field, errors);
606 }
607 }
608 }
609}
610
611pub fn discover_and_validate_external_plugins(
626 root: &Path,
627 config_plugin_paths: &[String],
628) -> Result<Vec<ExternalPluginDef>, Vec<crate::config::glob_validation::GlobValidationError>> {
629 let plugins = discover_external_plugins(root, config_plugin_paths);
630 let mut errors = Vec::new();
631 for plugin in &plugins {
632 if let Err(mut plugin_errors) = plugin.validate_user_globs() {
633 errors.append(&mut plugin_errors);
634 }
635 }
636 if errors.is_empty() {
637 Ok(plugins)
638 } else {
639 Err(errors)
640 }
641}
642
643enum PluginFormat {
645 Toml,
646 Json,
647 Jsonc,
648}
649
650impl PluginFormat {
651 fn from_path(path: &Path) -> Option<Self> {
652 match path.extension().and_then(|e| e.to_str()) {
653 Some("toml") => Some(Self::Toml),
654 Some("json") => Some(Self::Json),
655 Some("jsonc") => Some(Self::Jsonc),
656 _ => None,
657 }
658 }
659}
660
661fn is_plugin_file(path: &Path) -> bool {
663 path.extension()
664 .and_then(|e| e.to_str())
665 .is_some_and(|ext| PLUGIN_EXTENSIONS.contains(&ext))
666}
667
668fn parse_plugin(content: &str, format: &PluginFormat, path: &Path) -> Option<ExternalPluginDef> {
670 match format {
671 PluginFormat::Toml => match toml::from_str::<ExternalPluginDef>(content) {
672 Ok(plugin) => Some(plugin),
673 Err(e) => {
674 tracing::warn!("failed to parse external plugin {}: {e}", path.display());
675 None
676 }
677 },
678 PluginFormat::Json => match serde_json::from_str::<ExternalPluginDef>(content) {
679 Ok(plugin) => Some(plugin),
680 Err(e) => {
681 tracing::warn!("failed to parse external plugin {}: {e}", path.display());
682 None
683 }
684 },
685 PluginFormat::Jsonc => match crate::jsonc::parse_to_value::<ExternalPluginDef>(content) {
686 Ok(plugin) => Some(plugin),
687 Err(e) => {
688 tracing::warn!("failed to parse external plugin {}: {e}", path.display());
689 None
690 }
691 },
692 }
693}
694
695#[derive(Debug, Clone, Copy, PartialEq, Eq)]
697#[non_exhaustive]
698pub enum ConfiguredPluginDiagnosticKind {
699 Missing,
701 OutsideProjectRoot,
703 UnsupportedFormat,
705 Unreadable,
707 InvalidDefinition,
709 NoPluginFiles,
711}
712
713#[derive(Debug, Clone, PartialEq, Eq)]
715#[non_exhaustive]
716pub struct ConfiguredPluginDiagnostic {
717 pub kind: ConfiguredPluginDiagnosticKind,
719 pub path: PathBuf,
721}
722
723#[must_use]
730pub fn diagnose_configured_external_plugins(
731 root: &Path,
732 config_plugin_paths: &[String],
733) -> Vec<ConfiguredPluginDiagnostic> {
734 let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
735 let mut diagnostics = Vec::new();
736
737 for path_str in config_plugin_paths {
738 let path = root.join(path_str);
739 let metadata = match std::fs::metadata(&path) {
740 Ok(metadata) => metadata,
741 Err(error) => {
742 diagnostics.push(ConfiguredPluginDiagnostic {
743 kind: if error.kind() == std::io::ErrorKind::NotFound {
744 ConfiguredPluginDiagnosticKind::Missing
745 } else {
746 ConfiguredPluginDiagnosticKind::Unreadable
747 },
748 path,
749 });
750 continue;
751 }
752 };
753
754 if !is_within_root(&path, &canonical_root) {
755 diagnostics.push(ConfiguredPluginDiagnostic {
756 kind: ConfiguredPluginDiagnosticKind::OutsideProjectRoot,
757 path,
758 });
759 continue;
760 }
761
762 if metadata.is_dir() {
763 diagnose_configured_plugin_dir(&path, &canonical_root, &mut diagnostics);
764 } else if metadata.is_file() {
765 diagnose_configured_plugin_file(&path, &canonical_root, &mut diagnostics);
766 } else {
767 diagnostics.push(ConfiguredPluginDiagnostic {
768 kind: ConfiguredPluginDiagnosticKind::UnsupportedFormat,
769 path,
770 });
771 }
772 }
773
774 diagnostics
775}
776
777fn diagnose_configured_plugin_dir(
778 dir: &Path,
779 canonical_root: &Path,
780 diagnostics: &mut Vec<ConfiguredPluginDiagnostic>,
781) {
782 let Ok(entries) = std::fs::read_dir(dir) else {
783 diagnostics.push(ConfiguredPluginDiagnostic {
784 kind: ConfiguredPluginDiagnosticKind::Unreadable,
785 path: dir.to_path_buf(),
786 });
787 return;
788 };
789 let mut plugin_files = Vec::new();
790 let mut entry_failed = false;
791 for entry in entries {
792 match entry {
793 Ok(entry) => {
794 let path = entry.path();
795 if path.is_file() && is_plugin_file(&path) {
796 plugin_files.push(path);
797 }
798 }
799 Err(_) => {
800 entry_failed = true;
801 diagnostics.push(ConfiguredPluginDiagnostic {
802 kind: ConfiguredPluginDiagnosticKind::Unreadable,
803 path: dir.to_path_buf(),
804 });
805 }
806 }
807 }
808 if plugin_files.is_empty() && !entry_failed {
809 diagnostics.push(ConfiguredPluginDiagnostic {
810 kind: ConfiguredPluginDiagnosticKind::NoPluginFiles,
811 path: dir.to_path_buf(),
812 });
813 return;
814 }
815 plugin_files.sort();
816 for path in plugin_files {
817 diagnose_configured_plugin_file(&path, canonical_root, diagnostics);
818 }
819}
820
821fn diagnose_configured_plugin_file(
822 path: &Path,
823 canonical_root: &Path,
824 diagnostics: &mut Vec<ConfiguredPluginDiagnostic>,
825) {
826 let kind = if !is_within_root(path, canonical_root) {
827 Some(ConfiguredPluginDiagnosticKind::OutsideProjectRoot)
828 } else if let Some(format) = PluginFormat::from_path(path) {
829 match std::fs::read_to_string(path) {
830 Ok(content) => match parse_plugin(&content, &format, path) {
831 Some(plugin) if !plugin.name.is_empty() => None,
832 Some(_) | None => Some(ConfiguredPluginDiagnosticKind::InvalidDefinition),
833 },
834 Err(_) => Some(ConfiguredPluginDiagnosticKind::Unreadable),
835 }
836 } else {
837 Some(ConfiguredPluginDiagnosticKind::UnsupportedFormat)
838 };
839 if let Some(kind) = kind {
840 diagnostics.push(ConfiguredPluginDiagnostic {
841 kind,
842 path: path.to_path_buf(),
843 });
844 }
845}
846
847pub fn discover_external_plugins(
854 root: &Path,
855 config_plugin_paths: &[String],
856) -> Vec<ExternalPluginDef> {
857 let mut plugins = Vec::new();
858 let mut seen_names = rustc_hash::FxHashSet::default();
859
860 let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
861
862 load_configured_plugin_paths(
863 root,
864 config_plugin_paths,
865 &canonical_root,
866 &mut plugins,
867 &mut seen_names,
868 );
869 load_default_plugins_dir(root, &canonical_root, &mut plugins, &mut seen_names);
870 load_root_plugin_files(root, &canonical_root, &mut plugins, &mut seen_names);
871
872 plugins
873}
874
875fn load_configured_plugin_paths(
876 root: &Path,
877 config_plugin_paths: &[String],
878 canonical_root: &Path,
879 plugins: &mut Vec<ExternalPluginDef>,
880 seen_names: &mut rustc_hash::FxHashSet<String>,
881) {
882 for path_str in config_plugin_paths {
883 let path = root.join(path_str);
884 if !is_within_root(&path, canonical_root) {
885 tracing::warn!("plugin path '{path_str}' resolves outside project root, skipping");
886 continue;
887 }
888 if path.is_dir() {
889 load_plugins_from_dir(&path, canonical_root, plugins, seen_names);
890 } else if path.is_file() {
891 load_plugin_file(&path, canonical_root, plugins, seen_names);
892 }
893 }
894}
895
896fn load_default_plugins_dir(
897 root: &Path,
898 canonical_root: &Path,
899 plugins: &mut Vec<ExternalPluginDef>,
900 seen_names: &mut rustc_hash::FxHashSet<String>,
901) {
902 let plugins_dir = root.join(".fallow").join("plugins");
903 if plugins_dir.is_dir() && is_within_root(&plugins_dir, canonical_root) {
904 load_plugins_from_dir(&plugins_dir, canonical_root, plugins, seen_names);
905 }
906}
907
908fn load_root_plugin_files(
909 root: &Path,
910 canonical_root: &Path,
911 plugins: &mut Vec<ExternalPluginDef>,
912 seen_names: &mut rustc_hash::FxHashSet<String>,
913) {
914 if let Ok(entries) = std::fs::read_dir(root) {
915 let mut plugin_files: Vec<PathBuf> = entries
916 .filter_map(Result::ok)
917 .map(|e| e.path())
918 .filter(|p| {
919 p.is_file()
920 && p.file_name().and_then(|n| n.to_str()).is_some_and(|n| {
921 n.starts_with("fallow-plugin-") && is_plugin_file(Path::new(n))
922 })
923 })
924 .collect();
925 plugin_files.sort();
926 for path in plugin_files {
927 load_plugin_file(&path, canonical_root, plugins, seen_names);
928 }
929 }
930}
931
932#[expect(
934 clippy::redundant_pub_crate,
935 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"
936)]
937pub(crate) fn is_within_root(path: &Path, canonical_root: &Path) -> bool {
938 let canonical = dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
939 canonical.starts_with(canonical_root)
940}
941
942fn load_plugins_from_dir(
943 dir: &Path,
944 canonical_root: &Path,
945 plugins: &mut Vec<ExternalPluginDef>,
946 seen: &mut rustc_hash::FxHashSet<String>,
947) {
948 if let Ok(entries) = std::fs::read_dir(dir) {
949 let mut plugin_files: Vec<PathBuf> = entries
950 .filter_map(Result::ok)
951 .map(|e| e.path())
952 .filter(|p| p.is_file() && is_plugin_file(p))
953 .collect();
954 plugin_files.sort();
955 for path in plugin_files {
956 load_plugin_file(&path, canonical_root, plugins, seen);
957 }
958 }
959}
960
961fn load_plugin_file(
962 path: &Path,
963 canonical_root: &Path,
964 plugins: &mut Vec<ExternalPluginDef>,
965 seen: &mut rustc_hash::FxHashSet<String>,
966) {
967 if !is_within_root(path, canonical_root) {
968 tracing::warn!(
969 "plugin file '{}' resolves outside project root (symlink?), skipping",
970 path.display()
971 );
972 return;
973 }
974
975 let Some(format) = PluginFormat::from_path(path) else {
976 tracing::warn!(
977 "unsupported plugin file extension for {}, expected .toml, .json, or .jsonc",
978 path.display()
979 );
980 return;
981 };
982
983 let Some(content) = read_plugin_file(path) else {
984 return;
985 };
986
987 if let Some(plugin) = parse_plugin(&content, &format, path) {
988 push_plugin_if_unique(plugin, path, plugins, seen);
989 }
990}
991
992fn read_plugin_file(path: &Path) -> Option<String> {
993 match std::fs::read_to_string(path) {
994 Ok(content) => Some(content),
995 Err(e) => {
996 tracing::warn!(
997 "failed to read external plugin file {}: {e}",
998 path.display()
999 );
1000 None
1001 }
1002 }
1003}
1004
1005fn push_plugin_if_unique(
1006 plugin: ExternalPluginDef,
1007 path: &Path,
1008 plugins: &mut Vec<ExternalPluginDef>,
1009 seen: &mut rustc_hash::FxHashSet<String>,
1010) {
1011 if plugin.name.is_empty() {
1012 tracing::warn!(
1013 "external plugin in {} has an empty name, skipping",
1014 path.display()
1015 );
1016 return;
1017 }
1018
1019 if seen.insert(plugin.name.clone()) {
1020 plugins.push(plugin);
1021 } else {
1022 tracing::warn!(
1023 "duplicate external plugin '{}' in {}, skipping",
1024 plugin.name,
1025 path.display()
1026 );
1027 }
1028}
1029
1030#[cfg(test)]
1031mod tests {
1032 use super::*;
1033 use crate::ScopedUsedClassMemberRule;
1034
1035 #[test]
1036 fn deserialize_minimal_plugin() {
1037 let toml_str = r#"
1038name = "my-plugin"
1039enablers = ["my-pkg"]
1040"#;
1041 let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
1042 assert_eq!(plugin.name, "my-plugin");
1043 assert_eq!(plugin.enablers, vec!["my-pkg"]);
1044 assert!(plugin.entry_points.is_empty());
1045 assert!(plugin.always_used.is_empty());
1046 assert!(plugin.config_patterns.is_empty());
1047 assert!(plugin.tooling_dependencies.is_empty());
1048 assert!(plugin.used_exports.is_empty());
1049 assert!(plugin.used_class_members.is_empty());
1050 }
1051
1052 #[test]
1053 fn deserialize_plugin_with_used_class_members_json() {
1054 let json_str = r#"{
1055 "name": "ag-grid",
1056 "enablers": ["ag-grid-angular"],
1057 "usedClassMembers": ["agInit", "refresh"]
1058 }"#;
1059 let plugin: ExternalPluginDef = serde_json::from_str(json_str).unwrap();
1060 assert_eq!(plugin.name, "ag-grid");
1061 assert_eq!(
1062 plugin.used_class_members,
1063 vec![
1064 UsedClassMemberRule::from("agInit"),
1065 UsedClassMemberRule::from("refresh"),
1066 ]
1067 );
1068 }
1069
1070 #[test]
1071 fn deserialize_plugin_with_scoped_used_class_members_json() {
1072 let json_str = r#"{
1073 "name": "ag-grid",
1074 "enablers": ["ag-grid-angular"],
1075 "usedClassMembers": [
1076 "agInit",
1077 { "implements": "ICellRendererAngularComp", "members": ["refresh"] },
1078 { "extends": "BaseCommand", "members": ["execute"] }
1079 ]
1080 }"#;
1081 let plugin: ExternalPluginDef = serde_json::from_str(json_str).unwrap();
1082 assert_eq!(
1083 plugin.used_class_members,
1084 vec![
1085 UsedClassMemberRule::from("agInit"),
1086 UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
1087 extends: None,
1088 implements: Some("ICellRendererAngularComp".to_string()),
1089 members: vec!["refresh".to_string()],
1090 }),
1091 UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
1092 extends: Some("BaseCommand".to_string()),
1093 implements: None,
1094 members: vec!["execute".to_string()],
1095 }),
1096 ]
1097 );
1098 }
1099
1100 #[test]
1101 fn deserialize_plugin_with_used_class_members_toml() {
1102 let toml_str = r#"
1103name = "ag-grid"
1104enablers = ["ag-grid-angular"]
1105usedClassMembers = ["agInit", "refresh"]
1106"#;
1107 let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
1108 assert_eq!(
1109 plugin.used_class_members,
1110 vec![
1111 UsedClassMemberRule::from("agInit"),
1112 UsedClassMemberRule::from("refresh"),
1113 ]
1114 );
1115 }
1116
1117 #[test]
1118 fn deserialize_plugin_with_scoped_used_class_members_toml() {
1119 let toml_str = r#"
1120name = "ag-grid"
1121enablers = ["ag-grid-angular"]
1122usedClassMembers = [
1123 { implements = "ICellRendererAngularComp", members = ["refresh"] },
1124 { extends = "BaseCommand", members = ["execute"] }
1125]
1126"#;
1127 let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
1128 assert_eq!(
1129 plugin.used_class_members,
1130 vec![
1131 UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
1132 extends: None,
1133 implements: Some("ICellRendererAngularComp".to_string()),
1134 members: vec!["refresh".to_string()],
1135 }),
1136 UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
1137 extends: Some("BaseCommand".to_string()),
1138 implements: None,
1139 members: vec!["execute".to_string()],
1140 }),
1141 ]
1142 );
1143 }
1144
1145 #[test]
1146 fn deserialize_plugin_rejects_unconstrained_scoped_used_class_members() {
1147 let result = serde_json::from_str::<ExternalPluginDef>(
1148 r#"{
1149 "name": "ag-grid",
1150 "enablers": ["ag-grid-angular"],
1151 "usedClassMembers": [{ "members": ["refresh"] }]
1152 }"#,
1153 );
1154 assert!(
1155 result.is_err(),
1156 "unconstrained scoped rule should be rejected"
1157 );
1158 }
1159
1160 #[test]
1161 fn deserialize_full_plugin() {
1162 let toml_str = r#"
1163name = "my-framework"
1164enablers = ["my-framework", "@my-framework/core"]
1165entryPoints = ["src/routes/**/*.{ts,tsx}", "src/middleware.ts"]
1166configPatterns = ["my-framework.config.{ts,js,mjs}"]
1167alwaysUsed = ["src/setup.ts", "public/**/*"]
1168toolingDependencies = ["my-framework-cli"]
1169
1170[[usedExports]]
1171pattern = "src/routes/**/*.{ts,tsx}"
1172exports = ["default", "loader", "action"]
1173
1174[[usedExports]]
1175pattern = "src/middleware.ts"
1176exports = ["default"]
1177"#;
1178 let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
1179 assert_eq!(plugin.name, "my-framework");
1180 assert_eq!(plugin.enablers.len(), 2);
1181 assert_eq!(plugin.entry_points.len(), 2);
1182 assert_eq!(
1183 plugin.config_patterns,
1184 vec!["my-framework.config.{ts,js,mjs}"]
1185 );
1186 assert_eq!(plugin.always_used.len(), 2);
1187 assert_eq!(plugin.tooling_dependencies, vec!["my-framework-cli"]);
1188 assert_eq!(plugin.used_exports.len(), 2);
1189 assert_eq!(plugin.used_exports[0].pattern, "src/routes/**/*.{ts,tsx}");
1190 assert_eq!(
1191 plugin.used_exports[0].exports,
1192 vec!["default", "loader", "action"]
1193 );
1194 }
1195
1196 #[test]
1197 fn deserialize_json_plugin() {
1198 let json_str = r#"{
1199 "name": "my-json-plugin",
1200 "enablers": ["my-pkg"],
1201 "entryPoints": ["src/**/*.ts"],
1202 "configPatterns": ["my-plugin.config.js"],
1203 "alwaysUsed": ["src/setup.ts"],
1204 "toolingDependencies": ["my-cli"],
1205 "usedExports": [
1206 { "pattern": "src/**/*.ts", "exports": ["default"] }
1207 ]
1208 }"#;
1209 let plugin: ExternalPluginDef = serde_json::from_str(json_str).unwrap();
1210 assert_eq!(plugin.name, "my-json-plugin");
1211 assert_eq!(plugin.enablers, vec!["my-pkg"]);
1212 assert_eq!(plugin.entry_points, vec!["src/**/*.ts"]);
1213 assert_eq!(plugin.config_patterns, vec!["my-plugin.config.js"]);
1214 assert_eq!(plugin.always_used, vec!["src/setup.ts"]);
1215 assert_eq!(plugin.tooling_dependencies, vec!["my-cli"]);
1216 assert_eq!(plugin.used_exports.len(), 1);
1217 assert_eq!(plugin.used_exports[0].exports, vec!["default"]);
1218 }
1219
1220 #[test]
1221 fn manifest_field_paths_and_templates_are_parsed_once_and_round_trip() {
1222 let source = r#"{
1223 "name": "manifest-plugin",
1224 "manifestEntries": [{
1225 "manifests": "**/manifest.json",
1226 "when": { "plugin.browser": true },
1227 "entries": [{
1228 "path": "${plugin.entry}/index.{ts,tsx}",
1229 "when": { "type": "plugin" }
1230 }]
1231 }]
1232 }"#;
1233
1234 let plugin: ExternalPluginDef = serde_json::from_str(source).unwrap();
1235 let rule = &plugin.manifest_entries[0];
1236 let condition_path = rule.when.keys().next().unwrap();
1237 assert_eq!(
1238 condition_path.segments(),
1239 &[
1240 ManifestFieldSegment::Key("plugin".to_string()),
1241 ManifestFieldSegment::Key("browser".to_string()),
1242 ]
1243 );
1244 assert_eq!(
1245 rule.entries[0].path.as_str(),
1246 "${plugin.entry}/index.{ts,tsx}"
1247 );
1248 assert_eq!(
1249 rule.entries[0].path.parts(),
1250 &[
1251 ManifestPathPart::Field("plugin.entry".parse().unwrap()),
1252 ManifestPathPart::Literal("/index.{ts,tsx}".to_string()),
1253 ]
1254 );
1255
1256 let serialized = serde_json::to_value(&plugin).unwrap();
1257 assert_eq!(
1258 serialized["manifestEntries"][0]["when"]["plugin.browser"],
1259 true
1260 );
1261 assert_eq!(
1262 serialized["manifestEntries"][0]["entries"][0]["path"],
1263 "${plugin.entry}/index.{ts,tsx}"
1264 );
1265 }
1266
1267 #[test]
1268 fn invalid_manifest_field_paths_and_templates_are_rejected() {
1269 for source in [
1270 r#"{
1271 "name": "bad-condition",
1272 "manifestEntries": [{
1273 "manifests": "**/manifest.json",
1274 "when": { "plugin..browser": true },
1275 "entries": [{ "path": "index.ts" }]
1276 }]
1277 }"#,
1278 r#"{
1279 "name": "bad-template-field",
1280 "manifestEntries": [{
1281 "manifests": "**/manifest.json",
1282 "entries": [{ "path": "${plugin..entry}/index.ts" }]
1283 }]
1284 }"#,
1285 r#"{
1286 "name": "bad-entry-condition",
1287 "manifestEntries": [{
1288 "manifests": "**/manifest.json",
1289 "entries": [{
1290 "path": "index.ts",
1291 "when": { "plugin..browser": true }
1292 }]
1293 }]
1294 }"#,
1295 r#"{
1296 "name": "unterminated-template",
1297 "manifestEntries": [{
1298 "manifests": "**/manifest.json",
1299 "entries": [{ "path": "${plugin.entry/index.ts" }]
1300 }]
1301 }"#,
1302 ] {
1303 assert!(serde_json::from_str::<ExternalPluginDef>(source).is_err());
1304 }
1305 }
1306
1307 #[test]
1308 fn manifest_field_paths_support_only_explicit_array_traversal() {
1309 let path: ManifestFieldPath = "content_scripts[*].js[*]".parse().unwrap();
1310 assert_eq!(
1311 path.segments(),
1312 &[
1313 ManifestFieldSegment::Key("content_scripts".to_string()),
1314 ManifestFieldSegment::Each,
1315 ManifestFieldSegment::Key("js".to_string()),
1316 ManifestFieldSegment::Each,
1317 ]
1318 );
1319
1320 for invalid in [
1321 "content_scripts[0].js",
1322 "content_scripts[].js",
1323 "content_scripts[foo].js",
1324 "content_scripts[*x].js",
1325 "[*].js",
1326 ] {
1327 assert!(invalid.parse::<ManifestFieldPath>().is_err(), "{invalid}");
1328 }
1329 }
1330
1331 #[test]
1332 fn manifest_conditions_distinguish_scalar_equality_from_presence() {
1333 let source = r#"{
1334 "name": "condition-plugin",
1335 "manifestEntries": [{
1336 "manifests": "**/manifest.json",
1337 "when": {
1338 "main": { "exists": true },
1339 "enabled": false,
1340 "mode": "worker",
1341 "version": 3,
1342 "metadata": null
1343 },
1344 "entries": [{ "path": "index.ts" }]
1345 }]
1346 }"#;
1347 let plugin: ExternalPluginDef = serde_json::from_str(source).unwrap();
1348 let when = &plugin.manifest_entries[0].when;
1349
1350 assert_eq!(
1351 when.get(&"main".parse().unwrap()),
1352 Some(&ManifestCondition::Exists(ManifestExistsPredicate {
1353 exists: true,
1354 }))
1355 );
1356 assert_eq!(
1357 when.get(&"enabled".parse().unwrap()),
1358 Some(&ManifestCondition::Equals(serde_json::Value::Bool(false)))
1359 );
1360 assert_eq!(
1361 when.get(&"metadata".parse().unwrap()),
1362 Some(&ManifestCondition::Equals(serde_json::Value::Null))
1363 );
1364
1365 let serialized = serde_json::to_value(plugin).unwrap();
1366 assert_eq!(
1367 serialized["manifestEntries"][0]["when"]["main"],
1368 serde_json::json!({ "exists": true })
1369 );
1370 }
1371
1372 #[test]
1373 fn manifest_conditions_preserve_non_operator_json_equality_values() {
1374 let expected = [
1375 serde_json::json!({ "exists": "not-an-operator" }),
1376 serde_json::json!({ "exists": true, "extra": true }),
1377 serde_json::json!({ "other": true }),
1378 serde_json::json!(["worker"]),
1379 ];
1380 for condition in expected {
1381 let source = serde_json::json!({
1382 "name": "equality-condition",
1383 "manifestEntries": [{
1384 "manifests": "**/manifest.json",
1385 "when": { "field": condition.clone() },
1386 "entries": [{ "path": "index.ts" }]
1387 }]
1388 });
1389 let plugin: ExternalPluginDef = serde_json::from_value(source).unwrap();
1390 assert_eq!(
1391 plugin.manifest_entries[0]
1392 .when
1393 .get(&"field".parse().unwrap()),
1394 Some(&ManifestCondition::Equals(condition))
1395 );
1396 }
1397 }
1398
1399 #[test]
1400 fn manifest_exists_conditions_deserialize_from_toml() {
1401 let source = r#"
1402name = "condition-plugin"
1403
1404[[manifestEntries]]
1405manifests = "**/manifest.json"
1406
1407[manifestEntries.when.main]
1408exists = true
1409
1410[[manifestEntries.entries]]
1411path = "index.ts"
1412"#;
1413 let plugin: ExternalPluginDef = toml::from_str(source).unwrap();
1414 assert_eq!(
1415 plugin.manifest_entries[0]
1416 .when
1417 .get(&"main".parse().unwrap()),
1418 Some(&ManifestCondition::Exists(ManifestExistsPredicate {
1419 exists: true,
1420 }))
1421 );
1422 }
1423
1424 #[test]
1425 fn deserialize_jsonc_plugin() {
1426 let jsonc_str = r#"{
1427 "name": "my-jsonc-plugin",
1428 "enablers": ["my-pkg"],
1429 /* Block comment */
1430 "entryPoints": ["src/**/*.ts"]
1431 }"#;
1432 let plugin: ExternalPluginDef = crate::jsonc::parse_to_value(jsonc_str).unwrap();
1433 assert_eq!(plugin.name, "my-jsonc-plugin");
1434 assert_eq!(plugin.enablers, vec!["my-pkg"]);
1435 assert_eq!(plugin.entry_points, vec!["src/**/*.ts"]);
1436 }
1437
1438 #[test]
1439 fn deserialize_json_with_schema_field() {
1440 let json_str = r#"{
1441 "$schema": "https://raw.githubusercontent.com/fallow-rs/fallow/main/plugin-schema.json",
1442 "name": "schema-plugin",
1443 "enablers": ["my-pkg"]
1444 }"#;
1445 let plugin: ExternalPluginDef = serde_json::from_str(json_str).unwrap();
1446 assert_eq!(plugin.name, "schema-plugin");
1447 assert_eq!(plugin.enablers, vec!["my-pkg"]);
1448 }
1449
1450 #[test]
1451 fn plugin_json_schema_generation() {
1452 let schema = ExternalPluginDef::json_schema();
1453 assert!(schema.is_object());
1454 let obj = schema.as_object().unwrap();
1455 assert!(obj.contains_key("properties"));
1456 }
1457
1458 #[test]
1459 fn discover_plugins_from_fallow_plugins_dir() {
1460 let dir =
1461 std::env::temp_dir().join(format!("fallow-test-ext-plugins-{}", std::process::id()));
1462 let plugins_dir = dir.join(".fallow").join("plugins");
1463 let _ = std::fs::create_dir_all(&plugins_dir);
1464
1465 std::fs::write(
1466 plugins_dir.join("my-plugin.toml"),
1467 r#"
1468name = "my-plugin"
1469enablers = ["my-pkg"]
1470entryPoints = ["src/**/*.ts"]
1471"#,
1472 )
1473 .unwrap();
1474
1475 let plugins = discover_external_plugins(&dir, &[]);
1476 assert_eq!(plugins.len(), 1);
1477 assert_eq!(plugins[0].name, "my-plugin");
1478
1479 let _ = std::fs::remove_dir_all(&dir);
1480 }
1481
1482 #[test]
1483 fn discover_json_plugins_from_fallow_plugins_dir() {
1484 let dir = std::env::temp_dir().join(format!(
1485 "fallow-test-ext-json-plugins-{}",
1486 std::process::id()
1487 ));
1488 let plugins_dir = dir.join(".fallow").join("plugins");
1489 let _ = std::fs::create_dir_all(&plugins_dir);
1490
1491 std::fs::write(
1492 plugins_dir.join("my-plugin.json"),
1493 r#"{"name": "json-plugin", "enablers": ["json-pkg"]}"#,
1494 )
1495 .unwrap();
1496
1497 std::fs::write(
1498 plugins_dir.join("my-plugin.jsonc"),
1499 r#"{
1500 "name": "jsonc-plugin",
1501 "enablers": ["jsonc-pkg"]
1502 }"#,
1503 )
1504 .unwrap();
1505
1506 let plugins = discover_external_plugins(&dir, &[]);
1507 assert_eq!(plugins.len(), 2);
1508 assert_eq!(plugins[0].name, "json-plugin");
1509 assert_eq!(plugins[1].name, "jsonc-plugin");
1510
1511 let _ = std::fs::remove_dir_all(&dir);
1512 }
1513
1514 #[test]
1515 fn discover_fallow_plugin_files_in_root() {
1516 let dir =
1517 std::env::temp_dir().join(format!("fallow-test-root-plugins-{}", std::process::id()));
1518 let _ = std::fs::create_dir_all(&dir);
1519
1520 std::fs::write(
1521 dir.join("fallow-plugin-custom.toml"),
1522 r#"
1523name = "custom"
1524enablers = ["custom-pkg"]
1525"#,
1526 )
1527 .unwrap();
1528
1529 std::fs::write(dir.join("some-other-file.toml"), r#"name = "ignored""#).unwrap();
1530
1531 let plugins = discover_external_plugins(&dir, &[]);
1532 assert_eq!(plugins.len(), 1);
1533 assert_eq!(plugins[0].name, "custom");
1534
1535 let _ = std::fs::remove_dir_all(&dir);
1536 }
1537
1538 #[test]
1539 fn discover_fallow_plugin_json_files_in_root() {
1540 let dir = std::env::temp_dir().join(format!(
1541 "fallow-test-root-json-plugins-{}",
1542 std::process::id()
1543 ));
1544 let _ = std::fs::create_dir_all(&dir);
1545
1546 std::fs::write(
1547 dir.join("fallow-plugin-custom.json"),
1548 r#"{"name": "json-root", "enablers": ["json-pkg"]}"#,
1549 )
1550 .unwrap();
1551
1552 std::fs::write(
1553 dir.join("fallow-plugin-custom2.jsonc"),
1554 r#"{
1555 "name": "jsonc-root",
1556 "enablers": ["jsonc-pkg"]
1557 }"#,
1558 )
1559 .unwrap();
1560
1561 std::fs::write(
1562 dir.join("fallow-plugin-bad.yaml"),
1563 "name: ignored\nenablers:\n - pkg\n",
1564 )
1565 .unwrap();
1566
1567 let plugins = discover_external_plugins(&dir, &[]);
1568 assert_eq!(plugins.len(), 2);
1569
1570 let _ = std::fs::remove_dir_all(&dir);
1571 }
1572
1573 #[test]
1574 fn discover_mixed_formats_in_dir() {
1575 let dir =
1576 std::env::temp_dir().join(format!("fallow-test-mixed-plugins-{}", std::process::id()));
1577 let plugins_dir = dir.join(".fallow").join("plugins");
1578 let _ = std::fs::create_dir_all(&plugins_dir);
1579
1580 std::fs::write(
1581 plugins_dir.join("a-plugin.toml"),
1582 r#"
1583name = "toml-plugin"
1584enablers = ["toml-pkg"]
1585"#,
1586 )
1587 .unwrap();
1588
1589 std::fs::write(
1590 plugins_dir.join("b-plugin.json"),
1591 r#"{"name": "json-plugin", "enablers": ["json-pkg"]}"#,
1592 )
1593 .unwrap();
1594
1595 std::fs::write(
1596 plugins_dir.join("c-plugin.jsonc"),
1597 r#"{
1598 "name": "jsonc-plugin",
1599 "enablers": ["jsonc-pkg"]
1600 }"#,
1601 )
1602 .unwrap();
1603
1604 let plugins = discover_external_plugins(&dir, &[]);
1605 assert_eq!(plugins.len(), 3);
1606 assert_eq!(plugins[0].name, "toml-plugin");
1607 assert_eq!(plugins[1].name, "json-plugin");
1608 assert_eq!(plugins[2].name, "jsonc-plugin");
1609
1610 let _ = std::fs::remove_dir_all(&dir);
1611 }
1612
1613 #[test]
1614 fn deduplicates_by_name() {
1615 let dir =
1616 std::env::temp_dir().join(format!("fallow-test-dedup-plugins-{}", std::process::id()));
1617 let plugins_dir = dir.join(".fallow").join("plugins");
1618 let _ = std::fs::create_dir_all(&plugins_dir);
1619
1620 std::fs::write(
1621 plugins_dir.join("my-plugin.toml"),
1622 r#"
1623name = "my-plugin"
1624enablers = ["pkg-a"]
1625"#,
1626 )
1627 .unwrap();
1628
1629 std::fs::write(
1630 dir.join("fallow-plugin-my-plugin.toml"),
1631 r#"
1632name = "my-plugin"
1633enablers = ["pkg-b"]
1634"#,
1635 )
1636 .unwrap();
1637
1638 let plugins = discover_external_plugins(&dir, &[]);
1639 assert_eq!(plugins.len(), 1);
1640 assert_eq!(plugins[0].enablers, vec!["pkg-a"]);
1641
1642 let _ = std::fs::remove_dir_all(&dir);
1643 }
1644
1645 #[test]
1646 fn config_plugin_paths_take_priority() {
1647 let dir =
1648 std::env::temp_dir().join(format!("fallow-test-config-paths-{}", std::process::id()));
1649 let custom_dir = dir.join("custom-plugins");
1650 let _ = std::fs::create_dir_all(&custom_dir);
1651
1652 std::fs::write(
1653 custom_dir.join("explicit.toml"),
1654 r#"
1655name = "explicit"
1656enablers = ["explicit-pkg"]
1657"#,
1658 )
1659 .unwrap();
1660
1661 let plugins = discover_external_plugins(&dir, &["custom-plugins".to_string()]);
1662 assert_eq!(plugins.len(), 1);
1663 assert_eq!(plugins[0].name, "explicit");
1664
1665 let _ = std::fs::remove_dir_all(&dir);
1666 }
1667
1668 #[test]
1669 fn config_plugin_path_to_single_file() {
1670 let dir =
1671 std::env::temp_dir().join(format!("fallow-test-single-file-{}", std::process::id()));
1672 let _ = std::fs::create_dir_all(&dir);
1673
1674 std::fs::write(
1675 dir.join("my-plugin.toml"),
1676 r#"
1677name = "single-file"
1678enablers = ["single-pkg"]
1679"#,
1680 )
1681 .unwrap();
1682
1683 let plugins = discover_external_plugins(&dir, &["my-plugin.toml".to_string()]);
1684 assert_eq!(plugins.len(), 1);
1685 assert_eq!(plugins[0].name, "single-file");
1686
1687 let _ = std::fs::remove_dir_all(&dir);
1688 }
1689
1690 #[test]
1691 fn config_plugin_path_to_single_json_file() {
1692 let dir = std::env::temp_dir().join(format!(
1693 "fallow-test-single-json-file-{}",
1694 std::process::id()
1695 ));
1696 let _ = std::fs::create_dir_all(&dir);
1697
1698 std::fs::write(
1699 dir.join("my-plugin.json"),
1700 r#"{"name": "json-single", "enablers": ["json-pkg"]}"#,
1701 )
1702 .unwrap();
1703
1704 let plugins = discover_external_plugins(&dir, &["my-plugin.json".to_string()]);
1705 assert_eq!(plugins.len(), 1);
1706 assert_eq!(plugins[0].name, "json-single");
1707
1708 let _ = std::fs::remove_dir_all(&dir);
1709 }
1710
1711 #[test]
1712 fn configured_plugin_diagnostics_report_missing_file() {
1713 let dir = tempfile::tempdir().expect("temp root");
1714
1715 let diagnostics =
1716 diagnose_configured_external_plugins(dir.path(), &["missing-plugin.json".to_string()]);
1717
1718 assert_eq!(diagnostics.len(), 1);
1719 assert_eq!(diagnostics[0].kind, ConfiguredPluginDiagnosticKind::Missing);
1720 assert_eq!(diagnostics[0].path, dir.path().join("missing-plugin.json"));
1721 }
1722
1723 #[test]
1724 fn configured_plugin_diagnostics_report_malformed_file() {
1725 let dir = tempfile::tempdir().expect("temp root");
1726 std::fs::write(dir.path().join("broken.json"), "{").expect("write plugin");
1727
1728 let diagnostics =
1729 diagnose_configured_external_plugins(dir.path(), &["broken.json".to_string()]);
1730
1731 assert_eq!(diagnostics.len(), 1);
1732 assert_eq!(
1733 diagnostics[0].kind,
1734 ConfiguredPluginDiagnosticKind::InvalidDefinition
1735 );
1736 }
1737
1738 #[test]
1739 fn configured_plugin_diagnostics_report_directory_without_plugin_files() {
1740 let dir = tempfile::tempdir().expect("temp root");
1741 let plugins_dir = dir.path().join("plugins");
1742 std::fs::create_dir(&plugins_dir).expect("create plugin dir");
1743 std::fs::write(plugins_dir.join("plugin.yaml"), "name: ignored")
1744 .expect("write unsupported file");
1745
1746 let diagnostics =
1747 diagnose_configured_external_plugins(dir.path(), &["plugins".to_string()]);
1748
1749 assert_eq!(diagnostics.len(), 1);
1750 assert_eq!(
1751 diagnostics[0].kind,
1752 ConfiguredPluginDiagnosticKind::NoPluginFiles
1753 );
1754 }
1755
1756 #[test]
1757 fn skips_invalid_toml() {
1758 let dir =
1759 std::env::temp_dir().join(format!("fallow-test-invalid-plugin-{}", std::process::id()));
1760 let plugins_dir = dir.join(".fallow").join("plugins");
1761 let _ = std::fs::create_dir_all(&plugins_dir);
1762
1763 std::fs::write(plugins_dir.join("bad.toml"), r#"enablers = ["pkg"]"#).unwrap();
1764
1765 std::fs::write(
1766 plugins_dir.join("good.toml"),
1767 r#"
1768name = "good"
1769enablers = ["good-pkg"]
1770"#,
1771 )
1772 .unwrap();
1773
1774 let plugins = discover_external_plugins(&dir, &[]);
1775 assert_eq!(plugins.len(), 1);
1776 assert_eq!(plugins[0].name, "good");
1777
1778 let _ = std::fs::remove_dir_all(&dir);
1779 }
1780
1781 #[test]
1782 fn skips_invalid_json() {
1783 let dir = std::env::temp_dir().join(format!(
1784 "fallow-test-invalid-json-plugin-{}",
1785 std::process::id()
1786 ));
1787 let plugins_dir = dir.join(".fallow").join("plugins");
1788 let _ = std::fs::create_dir_all(&plugins_dir);
1789
1790 std::fs::write(plugins_dir.join("bad.json"), r#"{"enablers": ["pkg"]}"#).unwrap();
1791
1792 std::fs::write(
1793 plugins_dir.join("good.json"),
1794 r#"{"name": "good-json", "enablers": ["good-pkg"]}"#,
1795 )
1796 .unwrap();
1797
1798 let plugins = discover_external_plugins(&dir, &[]);
1799 assert_eq!(plugins.len(), 1);
1800 assert_eq!(plugins[0].name, "good-json");
1801
1802 let _ = std::fs::remove_dir_all(&dir);
1803 }
1804
1805 #[test]
1806 fn prefix_enablers() {
1807 let toml_str = r#"
1808name = "scoped"
1809enablers = ["@myorg/"]
1810"#;
1811 let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
1812 assert_eq!(plugin.enablers, vec!["@myorg/"]);
1813 }
1814
1815 #[test]
1816 fn skips_empty_name() {
1817 let dir =
1818 std::env::temp_dir().join(format!("fallow-test-empty-name-{}", std::process::id()));
1819 let plugins_dir = dir.join(".fallow").join("plugins");
1820 let _ = std::fs::create_dir_all(&plugins_dir);
1821
1822 std::fs::write(
1823 plugins_dir.join("empty.toml"),
1824 r#"
1825name = ""
1826enablers = ["pkg"]
1827"#,
1828 )
1829 .unwrap();
1830
1831 let plugins = discover_external_plugins(&dir, &[]);
1832 assert!(plugins.is_empty(), "empty-name plugin should be skipped");
1833
1834 let _ = std::fs::remove_dir_all(&dir);
1835 }
1836
1837 #[test]
1838 fn rejects_paths_outside_root() {
1839 let dir =
1840 std::env::temp_dir().join(format!("fallow-test-path-escape-{}", std::process::id()));
1841 let _ = std::fs::create_dir_all(&dir);
1842
1843 let plugins = discover_external_plugins(&dir, &["../../../etc".to_string()]);
1844 assert!(plugins.is_empty(), "paths outside root should be rejected");
1845
1846 let _ = std::fs::remove_dir_all(&dir);
1847 }
1848
1849 #[test]
1850 fn plugin_format_detection() {
1851 assert!(matches!(
1852 PluginFormat::from_path(Path::new("plugin.toml")),
1853 Some(PluginFormat::Toml)
1854 ));
1855 assert!(matches!(
1856 PluginFormat::from_path(Path::new("plugin.json")),
1857 Some(PluginFormat::Json)
1858 ));
1859 assert!(matches!(
1860 PluginFormat::from_path(Path::new("plugin.jsonc")),
1861 Some(PluginFormat::Jsonc)
1862 ));
1863 assert!(PluginFormat::from_path(Path::new("plugin.yaml")).is_none());
1864 assert!(PluginFormat::from_path(Path::new("plugin")).is_none());
1865 }
1866
1867 #[test]
1868 fn is_plugin_file_checks_extensions() {
1869 assert!(is_plugin_file(Path::new("plugin.toml")));
1870 assert!(is_plugin_file(Path::new("plugin.json")));
1871 assert!(is_plugin_file(Path::new("plugin.jsonc")));
1872 assert!(!is_plugin_file(Path::new("plugin.yaml")));
1873 assert!(!is_plugin_file(Path::new("plugin.txt")));
1874 assert!(!is_plugin_file(Path::new("plugin")));
1875 }
1876
1877 #[test]
1878 fn detection_deserialize_dependency() {
1879 let json = r#"{"type": "dependency", "package": "next"}"#;
1880 let detection: PluginDetection = serde_json::from_str(json).unwrap();
1881 assert!(matches!(detection, PluginDetection::Dependency { package } if package == "next"));
1882 }
1883
1884 #[test]
1885 fn detection_deserialize_file_exists() {
1886 let json = r#"{"type": "fileExists", "pattern": "tsconfig.json"}"#;
1887 let detection: PluginDetection = serde_json::from_str(json).unwrap();
1888 assert!(
1889 matches!(detection, PluginDetection::FileExists { pattern } if pattern == "tsconfig.json")
1890 );
1891 }
1892
1893 #[test]
1894 fn detection_deserialize_all() {
1895 let json = r#"{"type": "all", "conditions": [{"type": "dependency", "package": "a"}, {"type": "dependency", "package": "b"}]}"#;
1896 let detection: PluginDetection = serde_json::from_str(json).unwrap();
1897 assert!(matches!(detection, PluginDetection::All { conditions } if conditions.len() == 2));
1898 }
1899
1900 #[test]
1901 fn detection_deserialize_any() {
1902 let json = r#"{"type": "any", "conditions": [{"type": "dependency", "package": "a"}]}"#;
1903 let detection: PluginDetection = serde_json::from_str(json).unwrap();
1904 assert!(matches!(detection, PluginDetection::Any { conditions } if conditions.len() == 1));
1905 }
1906
1907 #[test]
1908 fn plugin_with_detection_field() {
1909 let json = r#"{
1910 "name": "my-plugin",
1911 "detection": {"type": "dependency", "package": "my-pkg"},
1912 "entryPoints": ["src/**/*.ts"]
1913 }"#;
1914 let plugin: ExternalPluginDef = serde_json::from_str(json).unwrap();
1915 assert_eq!(plugin.name, "my-plugin");
1916 assert!(plugin.detection.is_some());
1917 assert!(plugin.enablers.is_empty());
1918 assert_eq!(plugin.entry_points, vec!["src/**/*.ts"]);
1919 }
1920
1921 #[test]
1922 fn plugin_without_detection_uses_enablers() {
1923 let json = r#"{
1924 "name": "my-plugin",
1925 "enablers": ["my-pkg"]
1926 }"#;
1927 let plugin: ExternalPluginDef = serde_json::from_str(json).unwrap();
1928 assert!(plugin.detection.is_none());
1929 assert_eq!(plugin.enablers, vec!["my-pkg"]);
1930 }
1931
1932 #[test]
1933 fn detection_nested_all_with_any() {
1934 let json = r#"{
1935 "type": "all",
1936 "conditions": [
1937 {"type": "dependency", "package": "react"},
1938 {"type": "any", "conditions": [
1939 {"type": "fileExists", "pattern": "next.config.js"},
1940 {"type": "fileExists", "pattern": "next.config.mjs"}
1941 ]}
1942 ]
1943 }"#;
1944 let detection: PluginDetection = serde_json::from_str(json).unwrap();
1945 match detection {
1946 PluginDetection::All { conditions } => {
1947 assert_eq!(conditions.len(), 2);
1948 assert!(matches!(
1949 &conditions[0],
1950 PluginDetection::Dependency { package } if package == "react"
1951 ));
1952 match &conditions[1] {
1953 PluginDetection::Any { conditions: inner } => {
1954 assert_eq!(inner.len(), 2);
1955 }
1956 other => panic!("expected Any, got: {other:?}"),
1957 }
1958 }
1959 other => panic!("expected All, got: {other:?}"),
1960 }
1961 }
1962
1963 #[test]
1964 fn detection_empty_all_conditions() {
1965 let json = r#"{"type": "all", "conditions": []}"#;
1966 let detection: PluginDetection = serde_json::from_str(json).unwrap();
1967 assert!(matches!(
1968 detection,
1969 PluginDetection::All { conditions } if conditions.is_empty()
1970 ));
1971 }
1972
1973 #[test]
1974 fn detection_empty_any_conditions() {
1975 let json = r#"{"type": "any", "conditions": []}"#;
1976 let detection: PluginDetection = serde_json::from_str(json).unwrap();
1977 assert!(matches!(
1978 detection,
1979 PluginDetection::Any { conditions } if conditions.is_empty()
1980 ));
1981 }
1982
1983 #[test]
1984 fn detection_toml_dependency() {
1985 let toml_str = r#"
1986name = "my-plugin"
1987
1988[detection]
1989type = "dependency"
1990package = "next"
1991"#;
1992 let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
1993 assert!(plugin.detection.is_some());
1994 assert!(matches!(
1995 plugin.detection.unwrap(),
1996 PluginDetection::Dependency { package } if package == "next"
1997 ));
1998 }
1999
2000 #[test]
2001 fn detection_toml_file_exists() {
2002 let toml_str = r#"
2003name = "my-plugin"
2004
2005[detection]
2006type = "fileExists"
2007pattern = "next.config.js"
2008"#;
2009 let plugin: ExternalPluginDef = toml::from_str(toml_str).unwrap();
2010 assert!(matches!(
2011 plugin.detection.unwrap(),
2012 PluginDetection::FileExists { pattern } if pattern == "next.config.js"
2013 ));
2014 }
2015
2016 #[test]
2017 fn plugin_all_fields_json() {
2018 let json = r#"{
2019 "$schema": "https://raw.githubusercontent.com/fallow-rs/fallow/main/plugin-schema.json",
2020 "name": "full-plugin",
2021 "detection": {"type": "dependency", "package": "my-pkg"},
2022 "enablers": ["fallback-enabler"],
2023 "entryPoints": ["src/entry.ts"],
2024 "configPatterns": ["config.js"],
2025 "alwaysUsed": ["src/polyfills.ts"],
2026 "toolingDependencies": ["my-cli"],
2027 "usedExports": [{"pattern": "src/**", "exports": ["default", "setup"]}]
2028 }"#;
2029 let plugin: ExternalPluginDef = serde_json::from_str(json).unwrap();
2030 assert_eq!(plugin.name, "full-plugin");
2031 assert!(plugin.detection.is_some());
2032 assert_eq!(plugin.enablers, vec!["fallback-enabler"]);
2033 assert_eq!(plugin.entry_points, vec!["src/entry.ts"]);
2034 assert_eq!(plugin.config_patterns, vec!["config.js"]);
2035 assert_eq!(plugin.always_used, vec!["src/polyfills.ts"]);
2036 assert_eq!(plugin.tooling_dependencies, vec!["my-cli"]);
2037 assert_eq!(plugin.used_exports.len(), 1);
2038 assert_eq!(plugin.used_exports[0].pattern, "src/**");
2039 assert_eq!(plugin.used_exports[0].exports, vec!["default", "setup"]);
2040 }
2041
2042 #[test]
2043 fn plugin_with_special_chars_in_name() {
2044 let json = r#"{"name": "@scope/my-plugin-v2.0", "enablers": ["pkg"]}"#;
2045 let plugin: ExternalPluginDef = serde_json::from_str(json).unwrap();
2046 assert_eq!(plugin.name, "@scope/my-plugin-v2.0");
2047 }
2048
2049 #[test]
2050 fn parse_plugin_toml_format() {
2051 let content = r#"
2052name = "test-plugin"
2053enablers = ["test-pkg"]
2054entryPoints = ["src/**/*.ts"]
2055"#;
2056 let result = parse_plugin(content, &PluginFormat::Toml, Path::new("test.toml"));
2057 assert!(result.is_some());
2058 let plugin = result.unwrap();
2059 assert_eq!(plugin.name, "test-plugin");
2060 }
2061
2062 #[test]
2063 fn parse_plugin_json_format() {
2064 let content = r#"{"name": "json-test", "enablers": ["pkg"]}"#;
2065 let result = parse_plugin(content, &PluginFormat::Json, Path::new("test.json"));
2066 assert!(result.is_some());
2067 assert_eq!(result.unwrap().name, "json-test");
2068 }
2069
2070 #[test]
2071 fn parse_plugin_jsonc_format() {
2072 let content = r#"{
2073 "name": "jsonc-test",
2074 "enablers": ["pkg"]
2075 }"#;
2076 let result = parse_plugin(content, &PluginFormat::Jsonc, Path::new("test.jsonc"));
2077 assert!(result.is_some());
2078 assert_eq!(result.unwrap().name, "jsonc-test");
2079 }
2080
2081 #[test]
2082 fn parse_plugin_invalid_toml_returns_none() {
2083 let content = "not valid toml [[[";
2084 let result = parse_plugin(content, &PluginFormat::Toml, Path::new("bad.toml"));
2085 assert!(result.is_none());
2086 }
2087
2088 #[test]
2089 fn parse_plugin_invalid_json_returns_none() {
2090 let content = "{ not valid json }";
2091 let result = parse_plugin(content, &PluginFormat::Json, Path::new("bad.json"));
2092 assert!(result.is_none());
2093 }
2094
2095 #[test]
2096 fn parse_plugin_invalid_jsonc_returns_none() {
2097 let content = r#"{"enablers": ["pkg"]}"#;
2098 let result = parse_plugin(content, &PluginFormat::Jsonc, Path::new("bad.jsonc"));
2099 assert!(result.is_none());
2100 }
2101}