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