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