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