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