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