1use std::path::{Path, PathBuf};
2
3use super::parse_scripts::extract_script_file_refs;
4use super::walk::SOURCE_EXTENSIONS;
5use fallow_config::{EntryPointRole, PackageJson, ResolvedConfig};
6use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource};
7
8const OUTPUT_DIRS: &[&str] = &["dist", "build", "out", "esm", "cjs"];
12
13#[derive(Debug, Clone, Default)]
15pub struct CategorizedEntryPoints {
16 pub all: Vec<EntryPoint>,
17 pub runtime: Vec<EntryPoint>,
18 pub test: Vec<EntryPoint>,
19}
20
21impl CategorizedEntryPoints {
22 pub fn push_runtime(&mut self, entry: EntryPoint) {
23 self.runtime.push(entry.clone());
24 self.all.push(entry);
25 }
26
27 pub fn push_test(&mut self, entry: EntryPoint) {
28 self.test.push(entry.clone());
29 self.all.push(entry);
30 }
31
32 pub fn push_support(&mut self, entry: EntryPoint) {
33 self.all.push(entry);
34 }
35
36 pub fn extend_runtime<I>(&mut self, entries: I)
37 where
38 I: IntoIterator<Item = EntryPoint>,
39 {
40 for entry in entries {
41 self.push_runtime(entry);
42 }
43 }
44
45 pub fn extend_test<I>(&mut self, entries: I)
46 where
47 I: IntoIterator<Item = EntryPoint>,
48 {
49 for entry in entries {
50 self.push_test(entry);
51 }
52 }
53
54 pub fn extend_support<I>(&mut self, entries: I)
55 where
56 I: IntoIterator<Item = EntryPoint>,
57 {
58 for entry in entries {
59 self.push_support(entry);
60 }
61 }
62
63 pub fn extend(&mut self, other: Self) {
64 self.all.extend(other.all);
65 self.runtime.extend(other.runtime);
66 self.test.extend(other.test);
67 }
68
69 #[must_use]
70 pub fn dedup(mut self) -> Self {
71 dedup_entry_paths(&mut self.all);
72 dedup_entry_paths(&mut self.runtime);
73 dedup_entry_paths(&mut self.test);
74 self
75 }
76}
77
78fn dedup_entry_paths(entries: &mut Vec<EntryPoint>) {
79 entries.sort_by(|a, b| a.path.cmp(&b.path));
80 entries.dedup_by(|a, b| a.path == b.path);
81}
82
83pub fn resolve_entry_path(
90 base: &Path,
91 entry: &str,
92 canonical_root: &Path,
93 source: EntryPointSource,
94) -> Option<EntryPoint> {
95 if entry.contains('*') {
98 return None;
99 }
100
101 let resolved = base.join(entry);
102 let canonical_resolved = dunce::canonicalize(&resolved).unwrap_or_else(|_| resolved.clone());
104 if !canonical_resolved.starts_with(canonical_root) {
105 tracing::warn!(path = %entry, "Skipping entry point outside project root");
106 return None;
107 }
108
109 if let Some(source_path) = try_output_to_source_path(base, entry) {
114 if let Ok(canonical_source) = dunce::canonicalize(&source_path)
116 && canonical_source.starts_with(canonical_root)
117 {
118 return Some(EntryPoint {
119 path: source_path,
120 source,
121 });
122 }
123 }
124
125 if is_entry_in_output_dir(entry)
133 && let Some(source_path) = try_source_index_fallback(base)
134 && let Ok(canonical_source) = dunce::canonicalize(&source_path)
135 && canonical_source.starts_with(canonical_root)
136 {
137 tracing::info!(
138 entry = %entry,
139 fallback = %source_path.display(),
140 "package.json entry resolves to an ignored output directory; falling back to source index"
141 );
142 return Some(EntryPoint {
143 path: source_path,
144 source,
145 });
146 }
147
148 if resolved.exists() {
149 return Some(EntryPoint {
150 path: resolved,
151 source,
152 });
153 }
154 for ext in SOURCE_EXTENSIONS {
156 let with_ext = resolved.with_extension(ext);
157 if with_ext.exists() {
158 return Some(EntryPoint {
159 path: with_ext,
160 source,
161 });
162 }
163 }
164 None
165}
166
167fn try_output_to_source_path(base: &Path, entry: &str) -> Option<PathBuf> {
179 let entry_path = Path::new(entry);
180 let components: Vec<_> = entry_path.components().collect();
181
182 let output_pos = components.iter().rposition(|c| {
184 if let std::path::Component::Normal(s) = c
185 && let Some(name) = s.to_str()
186 {
187 return OUTPUT_DIRS.contains(&name);
188 }
189 false
190 })?;
191
192 let prefix: PathBuf = components[..output_pos]
194 .iter()
195 .filter(|c| !matches!(c, std::path::Component::CurDir))
196 .collect();
197
198 let suffix: PathBuf = components[output_pos + 1..].iter().collect();
200
201 for ext in SOURCE_EXTENSIONS {
203 let source_candidate = base
204 .join(&prefix)
205 .join("src")
206 .join(suffix.with_extension(ext));
207 if source_candidate.exists() {
208 return Some(source_candidate);
209 }
210 }
211
212 None
213}
214
215const SOURCE_INDEX_FALLBACK_STEMS: &[&str] = &["src/index", "src/main", "index", "main"];
218
219fn is_entry_in_output_dir(entry: &str) -> bool {
224 Path::new(entry).components().any(|c| {
225 matches!(
226 c,
227 std::path::Component::Normal(s)
228 if s.to_str().is_some_and(|name| OUTPUT_DIRS.contains(&name))
229 )
230 })
231}
232
233fn try_source_index_fallback(base: &Path) -> Option<PathBuf> {
240 for stem in SOURCE_INDEX_FALLBACK_STEMS {
241 for ext in SOURCE_EXTENSIONS {
242 let candidate = base.join(format!("{stem}.{ext}"));
243 if candidate.is_file() {
244 return Some(candidate);
245 }
246 }
247 }
248 None
249}
250
251const DEFAULT_INDEX_PATTERNS: &[&str] = &[
253 "src/index.{ts,tsx,js,jsx}",
254 "src/main.{ts,tsx,js,jsx}",
255 "index.{ts,tsx,js,jsx}",
256 "main.{ts,tsx,js,jsx}",
257];
258
259fn apply_default_fallback(
264 files: &[DiscoveredFile],
265 root: &Path,
266 ws_filter: Option<&Path>,
267) -> Vec<EntryPoint> {
268 let default_matchers: Vec<globset::GlobMatcher> = DEFAULT_INDEX_PATTERNS
269 .iter()
270 .filter_map(|p| globset::Glob::new(p).ok().map(|g| g.compile_matcher()))
271 .collect();
272
273 let mut entries = Vec::new();
274 for file in files {
275 if let Some(ws_root) = ws_filter
277 && file.path.strip_prefix(ws_root).is_err()
278 {
279 continue;
280 }
281 let relative = file.path.strip_prefix(root).unwrap_or(&file.path);
282 let relative_str = relative.to_string_lossy();
283 if default_matchers
284 .iter()
285 .any(|m| m.is_match(relative_str.as_ref()))
286 {
287 entries.push(EntryPoint {
288 path: file.path.clone(),
289 source: EntryPointSource::DefaultIndex,
290 });
291 }
292 }
293 entries
294}
295
296pub fn discover_entry_points(config: &ResolvedConfig, files: &[DiscoveredFile]) -> Vec<EntryPoint> {
298 let _span = tracing::info_span!("discover_entry_points").entered();
299 let mut entries = Vec::new();
300
301 let relative_paths: Vec<String> = files
303 .iter()
304 .map(|f| {
305 f.path
306 .strip_prefix(&config.root)
307 .unwrap_or(&f.path)
308 .to_string_lossy()
309 .into_owned()
310 })
311 .collect();
312
313 {
316 let mut builder = globset::GlobSetBuilder::new();
317 for pattern in &config.entry_patterns {
318 if let Ok(glob) = globset::Glob::new(pattern) {
319 builder.add(glob);
320 }
321 }
322 if let Ok(glob_set) = builder.build()
323 && !glob_set.is_empty()
324 {
325 for (idx, rel) in relative_paths.iter().enumerate() {
326 if glob_set.is_match(rel) {
327 entries.push(EntryPoint {
328 path: files[idx].path.clone(),
329 source: EntryPointSource::ManualEntry,
330 });
331 }
332 }
333 }
334 }
335
336 let canonical_root = dunce::canonicalize(&config.root).unwrap_or_else(|_| config.root.clone());
339 let pkg_path = config.root.join("package.json");
340 let root_pkg = PackageJson::load(&pkg_path).ok();
341 if let Some(pkg) = &root_pkg {
342 for entry_path in pkg.entry_points() {
343 if let Some(ep) = resolve_entry_path(
344 &config.root,
345 &entry_path,
346 &canonical_root,
347 EntryPointSource::PackageJsonMain,
348 ) {
349 entries.push(ep);
350 }
351 }
352
353 if let Some(scripts) = &pkg.scripts {
355 for script_value in scripts.values() {
356 for file_ref in extract_script_file_refs(script_value) {
357 if let Some(ep) = resolve_entry_path(
358 &config.root,
359 &file_ref,
360 &canonical_root,
361 EntryPointSource::PackageJsonScript,
362 ) {
363 entries.push(ep);
364 }
365 }
366 }
367 }
368
369 }
371
372 let exports_dirs = root_pkg
376 .map(|pkg| pkg.exports_subdirectories())
377 .unwrap_or_default();
378 discover_nested_package_entries(
379 &config.root,
380 files,
381 &mut entries,
382 &canonical_root,
383 &exports_dirs,
384 );
385
386 if entries.is_empty() {
388 entries = apply_default_fallback(files, &config.root, None);
389 }
390
391 entries.sort_by(|a, b| a.path.cmp(&b.path));
393 entries.dedup_by(|a, b| a.path == b.path);
394
395 entries
396}
397
398fn discover_nested_package_entries(
408 root: &Path,
409 _files: &[DiscoveredFile],
410 entries: &mut Vec<EntryPoint>,
411 canonical_root: &Path,
412 exports_subdirectories: &[String],
413) {
414 let mut visited = rustc_hash::FxHashSet::default();
415
416 let search_dirs = [
418 "packages", "apps", "libs", "modules", "plugins", "services", "tools", "utils",
419 ];
420 for dir_name in &search_dirs {
421 let search_dir = root.join(dir_name);
422 if !search_dir.is_dir() {
423 continue;
424 }
425 let Ok(read_dir) = std::fs::read_dir(&search_dir) else {
426 continue;
427 };
428 for entry in read_dir.flatten() {
429 let pkg_dir = entry.path();
430 if visited.insert(pkg_dir.clone()) {
431 collect_nested_package_entries(&pkg_dir, entries, canonical_root);
432 }
433 }
434 }
435
436 for dir_name in exports_subdirectories {
438 let pkg_dir = root.join(dir_name);
439 if pkg_dir.is_dir() && visited.insert(pkg_dir.clone()) {
440 collect_nested_package_entries(&pkg_dir, entries, canonical_root);
441 }
442 }
443}
444
445fn collect_nested_package_entries(
447 pkg_dir: &Path,
448 entries: &mut Vec<EntryPoint>,
449 canonical_root: &Path,
450) {
451 let pkg_path = pkg_dir.join("package.json");
452 if !pkg_path.exists() {
453 return;
454 }
455 let Ok(pkg) = PackageJson::load(&pkg_path) else {
456 return;
457 };
458 for entry_path in pkg.entry_points() {
459 if entry_path.contains('*') {
460 expand_wildcard_entries(pkg_dir, &entry_path, canonical_root, entries);
461 } else if let Some(ep) = resolve_entry_path(
462 pkg_dir,
463 &entry_path,
464 canonical_root,
465 EntryPointSource::PackageJsonExports,
466 ) {
467 entries.push(ep);
468 }
469 }
470 if let Some(scripts) = &pkg.scripts {
471 for script_value in scripts.values() {
472 for file_ref in extract_script_file_refs(script_value) {
473 if let Some(ep) = resolve_entry_path(
474 pkg_dir,
475 &file_ref,
476 canonical_root,
477 EntryPointSource::PackageJsonScript,
478 ) {
479 entries.push(ep);
480 }
481 }
482 }
483 }
484}
485
486fn expand_wildcard_entries(
492 base: &Path,
493 pattern: &str,
494 canonical_root: &Path,
495 entries: &mut Vec<EntryPoint>,
496) {
497 let full_pattern = base.join(pattern).to_string_lossy().to_string();
498 let Ok(matches) = glob::glob(&full_pattern) else {
499 return;
500 };
501 for path_result in matches {
502 let Ok(path) = path_result else {
503 continue;
504 };
505 if let Ok(canonical) = dunce::canonicalize(&path)
506 && canonical.starts_with(canonical_root)
507 {
508 entries.push(EntryPoint {
509 path,
510 source: EntryPointSource::PackageJsonExports,
511 });
512 }
513 }
514}
515
516#[must_use]
518pub fn discover_workspace_entry_points(
519 ws_root: &Path,
520 _config: &ResolvedConfig,
521 all_files: &[DiscoveredFile],
522) -> Vec<EntryPoint> {
523 let mut entries = Vec::new();
524
525 let pkg_path = ws_root.join("package.json");
526 if let Ok(pkg) = PackageJson::load(&pkg_path) {
527 let canonical_ws_root =
528 dunce::canonicalize(ws_root).unwrap_or_else(|_| ws_root.to_path_buf());
529 for entry_path in pkg.entry_points() {
530 if entry_path.contains('*') {
531 expand_wildcard_entries(ws_root, &entry_path, &canonical_ws_root, &mut entries);
532 } else if let Some(ep) = resolve_entry_path(
533 ws_root,
534 &entry_path,
535 &canonical_ws_root,
536 EntryPointSource::PackageJsonMain,
537 ) {
538 entries.push(ep);
539 }
540 }
541
542 if let Some(scripts) = &pkg.scripts {
544 for script_value in scripts.values() {
545 for file_ref in extract_script_file_refs(script_value) {
546 if let Some(ep) = resolve_entry_path(
547 ws_root,
548 &file_ref,
549 &canonical_ws_root,
550 EntryPointSource::PackageJsonScript,
551 ) {
552 entries.push(ep);
553 }
554 }
555 }
556 }
557
558 }
560
561 if entries.is_empty() {
563 entries = apply_default_fallback(all_files, ws_root, Some(ws_root));
564 }
565
566 entries.sort_by(|a, b| a.path.cmp(&b.path));
567 entries.dedup_by(|a, b| a.path == b.path);
568 entries
569}
570
571#[must_use]
576pub fn discover_plugin_entry_points(
577 plugin_result: &crate::plugins::AggregatedPluginResult,
578 config: &ResolvedConfig,
579 files: &[DiscoveredFile],
580) -> Vec<EntryPoint> {
581 discover_plugin_entry_point_sets(plugin_result, config, files).all
582}
583
584#[must_use]
586pub fn discover_plugin_entry_point_sets(
587 plugin_result: &crate::plugins::AggregatedPluginResult,
588 config: &ResolvedConfig,
589 files: &[DiscoveredFile],
590) -> CategorizedEntryPoints {
591 let mut entries = CategorizedEntryPoints::default();
592
593 let relative_paths: Vec<String> = files
595 .iter()
596 .map(|f| {
597 f.path
598 .strip_prefix(&config.root)
599 .unwrap_or(&f.path)
600 .to_string_lossy()
601 .into_owned()
602 })
603 .collect();
604
605 let mut builder = globset::GlobSetBuilder::new();
608 let mut glob_meta: Vec<CompiledEntryRule<'_>> = Vec::new();
609 for (rule, pname) in &plugin_result.entry_patterns {
610 if let Some((include, compiled)) = compile_entry_rule(rule, pname, plugin_result) {
611 builder.add(include);
612 glob_meta.push(compiled);
613 }
614 }
615 for (pattern, pname) in plugin_result
616 .discovered_always_used
617 .iter()
618 .chain(plugin_result.always_used.iter())
619 .chain(plugin_result.fixture_patterns.iter())
620 {
621 if let Ok(glob) = globset::GlobBuilder::new(pattern)
622 .literal_separator(true)
623 .build()
624 {
625 builder.add(glob);
626 if let Some(path) = crate::plugins::CompiledPathRule::for_entry_rule(
627 &crate::plugins::PathRule::new(pattern.clone()),
628 "support entry pattern",
629 ) {
630 glob_meta.push(CompiledEntryRule {
631 path,
632 plugin_name: pname,
633 role: EntryPointRole::Support,
634 });
635 }
636 }
637 }
638 if let Ok(glob_set) = builder.build()
639 && !glob_set.is_empty()
640 {
641 for (idx, rel) in relative_paths.iter().enumerate() {
642 let matches: Vec<usize> = glob_set
643 .matches(rel)
644 .into_iter()
645 .filter(|match_idx| glob_meta[*match_idx].matches(rel))
646 .collect();
647 if !matches.is_empty() {
648 let name = glob_meta[matches[0]].plugin_name;
649 let entry = EntryPoint {
650 path: files[idx].path.clone(),
651 source: EntryPointSource::Plugin {
652 name: name.to_string(),
653 },
654 };
655
656 let mut has_runtime = false;
657 let mut has_test = false;
658 let mut has_support = false;
659 for match_idx in matches {
660 match glob_meta[match_idx].role {
661 EntryPointRole::Runtime => has_runtime = true,
662 EntryPointRole::Test => has_test = true,
663 EntryPointRole::Support => has_support = true,
664 }
665 }
666
667 if has_runtime {
668 entries.push_runtime(entry.clone());
669 }
670 if has_test {
671 entries.push_test(entry.clone());
672 }
673 if has_support || (!has_runtime && !has_test) {
674 entries.push_support(entry);
675 }
676 }
677 }
678 }
679
680 for (setup_file, pname) in &plugin_result.setup_files {
682 let resolved = if setup_file.is_absolute() {
683 setup_file.clone()
684 } else {
685 config.root.join(setup_file)
686 };
687 if resolved.exists() {
688 entries.push_support(EntryPoint {
689 path: resolved,
690 source: EntryPointSource::Plugin {
691 name: pname.clone(),
692 },
693 });
694 } else {
695 for ext in SOURCE_EXTENSIONS {
697 let with_ext = resolved.with_extension(ext);
698 if with_ext.exists() {
699 entries.push_support(EntryPoint {
700 path: with_ext,
701 source: EntryPointSource::Plugin {
702 name: pname.clone(),
703 },
704 });
705 break;
706 }
707 }
708 }
709 }
710
711 entries.dedup()
712}
713
714#[must_use]
719pub fn discover_dynamically_loaded_entry_points(
720 config: &ResolvedConfig,
721 files: &[DiscoveredFile],
722) -> Vec<EntryPoint> {
723 if config.dynamically_loaded.is_empty() {
724 return Vec::new();
725 }
726
727 let mut builder = globset::GlobSetBuilder::new();
728 for pattern in &config.dynamically_loaded {
729 if let Ok(glob) = globset::Glob::new(pattern) {
730 builder.add(glob);
731 }
732 }
733 let Ok(glob_set) = builder.build() else {
734 return Vec::new();
735 };
736 if glob_set.is_empty() {
737 return Vec::new();
738 }
739
740 let mut entries = Vec::new();
741 for file in files {
742 let rel = file
743 .path
744 .strip_prefix(&config.root)
745 .unwrap_or(&file.path)
746 .to_string_lossy();
747 if glob_set.is_match(rel.as_ref()) {
748 entries.push(EntryPoint {
749 path: file.path.clone(),
750 source: EntryPointSource::DynamicallyLoaded,
751 });
752 }
753 }
754 entries
755}
756
757struct CompiledEntryRule<'a> {
758 path: crate::plugins::CompiledPathRule,
759 plugin_name: &'a str,
760 role: EntryPointRole,
761}
762
763impl CompiledEntryRule<'_> {
764 fn matches(&self, path: &str) -> bool {
765 self.path.matches(path)
766 }
767}
768
769fn compile_entry_rule<'a>(
770 rule: &'a crate::plugins::PathRule,
771 plugin_name: &'a str,
772 plugin_result: &'a crate::plugins::AggregatedPluginResult,
773) -> Option<(globset::Glob, CompiledEntryRule<'a>)> {
774 let include = match globset::GlobBuilder::new(&rule.pattern)
775 .literal_separator(true)
776 .build()
777 {
778 Ok(glob) => glob,
779 Err(err) => {
780 tracing::warn!("invalid entry pattern '{}': {err}", rule.pattern);
781 return None;
782 }
783 };
784 let role = plugin_result
785 .entry_point_roles
786 .get(plugin_name)
787 .copied()
788 .unwrap_or(EntryPointRole::Support);
789 Some((
790 include,
791 CompiledEntryRule {
792 path: crate::plugins::CompiledPathRule::for_entry_rule(rule, "entry pattern")?,
793 plugin_name,
794 role,
795 },
796 ))
797}
798
799#[must_use]
801pub fn compile_glob_set(patterns: &[String]) -> Option<globset::GlobSet> {
802 if patterns.is_empty() {
803 return None;
804 }
805 let mut builder = globset::GlobSetBuilder::new();
806 for pattern in patterns {
807 if let Ok(glob) = globset::GlobBuilder::new(pattern)
808 .literal_separator(true)
809 .build()
810 {
811 builder.add(glob);
812 }
813 }
814 builder.build().ok()
815}
816
817#[cfg(test)]
818mod tests {
819 use super::*;
820 use fallow_config::{FallowConfig, OutputFormat, RulesConfig};
821 use fallow_types::discover::FileId;
822 use proptest::prelude::*;
823
824 proptest! {
825 #[test]
827 fn glob_patterns_never_panic_on_compile(
828 prefix in "[a-zA-Z0-9_]{1,20}",
829 ext in prop::sample::select(vec!["ts", "tsx", "js", "jsx", "vue", "svelte", "astro", "mdx"]),
830 ) {
831 let pattern = format!("**/{prefix}*.{ext}");
832 let result = globset::Glob::new(&pattern);
834 prop_assert!(result.is_ok(), "Glob::new should not fail for well-formed patterns");
835 }
836
837 #[test]
839 fn non_source_extensions_not_in_list(
840 ext in prop::sample::select(vec!["py", "rb", "rs", "go", "java", "xml", "yaml", "toml", "md", "txt", "png", "jpg", "wasm", "lock"]),
841 ) {
842 prop_assert!(
843 !SOURCE_EXTENSIONS.contains(&ext),
844 "Extension '{ext}' should NOT be in SOURCE_EXTENSIONS"
845 );
846 }
847
848 #[test]
850 fn compile_glob_set_no_panic(
851 patterns in prop::collection::vec("[a-zA-Z0-9_*/.]{1,30}", 0..10),
852 ) {
853 let _ = compile_glob_set(&patterns);
855 }
856 }
857
858 #[test]
860 fn compile_glob_set_empty_input() {
861 assert!(
862 compile_glob_set(&[]).is_none(),
863 "empty patterns should return None"
864 );
865 }
866
867 #[test]
868 fn compile_glob_set_valid_patterns() {
869 let patterns = vec!["**/*.ts".to_string(), "src/**/*.js".to_string()];
870 let set = compile_glob_set(&patterns);
871 assert!(set.is_some(), "valid patterns should compile");
872 let set = set.unwrap();
873 assert!(set.is_match("src/foo.ts"));
874 assert!(set.is_match("src/bar.js"));
875 assert!(!set.is_match("src/bar.py"));
876 }
877
878 #[test]
879 fn compile_glob_set_keeps_star_within_a_single_path_segment() {
880 let patterns = vec!["composables/*.{ts,js}".to_string()];
881 let set = compile_glob_set(&patterns).expect("pattern should compile");
882
883 assert!(set.is_match("composables/useFoo.ts"));
884 assert!(!set.is_match("composables/nested/useFoo.ts"));
885 }
886
887 #[test]
888 fn plugin_entry_point_sets_preserve_runtime_test_and_support_roles() {
889 let dir = tempfile::tempdir().expect("create temp dir");
890 let root = dir.path();
891 std::fs::create_dir_all(root.join("src")).unwrap();
892 std::fs::create_dir_all(root.join("tests")).unwrap();
893 std::fs::write(root.join("src/runtime.ts"), "export const runtime = 1;").unwrap();
894 std::fs::write(root.join("src/setup.ts"), "export const setup = 1;").unwrap();
895 std::fs::write(root.join("tests/app.test.ts"), "export const test = 1;").unwrap();
896
897 let config = FallowConfig {
898 schema: None,
899 extends: vec![],
900 entry: vec![],
901 ignore_patterns: vec![],
902 framework: vec![],
903 workspaces: None,
904 ignore_dependencies: vec![],
905 ignore_exports: vec![],
906 used_class_members: vec![],
907 duplicates: fallow_config::DuplicatesConfig::default(),
908 health: fallow_config::HealthConfig::default(),
909 rules: RulesConfig::default(),
910 boundaries: fallow_config::BoundaryConfig::default(),
911 production: false,
912 plugins: vec![],
913 dynamically_loaded: vec![],
914 overrides: vec![],
915 regression: None,
916 audit: fallow_config::AuditConfig::default(),
917 codeowners: None,
918 public_packages: vec![],
919 flags: fallow_config::FlagsConfig::default(),
920 resolve: fallow_config::ResolveConfig::default(),
921 sealed: false,
922 }
923 .resolve(root.to_path_buf(), OutputFormat::Human, 4, true, true);
924
925 let files = vec![
926 DiscoveredFile {
927 id: FileId(0),
928 path: root.join("src/runtime.ts"),
929 size_bytes: 1,
930 },
931 DiscoveredFile {
932 id: FileId(1),
933 path: root.join("src/setup.ts"),
934 size_bytes: 1,
935 },
936 DiscoveredFile {
937 id: FileId(2),
938 path: root.join("tests/app.test.ts"),
939 size_bytes: 1,
940 },
941 ];
942
943 let mut plugin_result = crate::plugins::AggregatedPluginResult::default();
944 plugin_result.entry_patterns.push((
945 crate::plugins::PathRule::new("src/runtime.ts"),
946 "runtime-plugin".to_string(),
947 ));
948 plugin_result.entry_patterns.push((
949 crate::plugins::PathRule::new("tests/app.test.ts"),
950 "test-plugin".to_string(),
951 ));
952 plugin_result
953 .always_used
954 .push(("src/setup.ts".to_string(), "support-plugin".to_string()));
955 plugin_result
956 .entry_point_roles
957 .insert("runtime-plugin".to_string(), EntryPointRole::Runtime);
958 plugin_result
959 .entry_point_roles
960 .insert("test-plugin".to_string(), EntryPointRole::Test);
961 plugin_result
962 .entry_point_roles
963 .insert("support-plugin".to_string(), EntryPointRole::Support);
964
965 let entries = discover_plugin_entry_point_sets(&plugin_result, &config, &files);
966
967 assert_eq!(entries.runtime.len(), 1, "expected one runtime entry");
968 assert!(
969 entries.runtime[0].path.ends_with("src/runtime.ts"),
970 "runtime entry should stay runtime-only"
971 );
972 assert_eq!(entries.test.len(), 1, "expected one test entry");
973 assert!(
974 entries.test[0].path.ends_with("tests/app.test.ts"),
975 "test entry should stay test-only"
976 );
977 assert_eq!(
978 entries.all.len(),
979 3,
980 "support entries should stay in all entries"
981 );
982 assert!(
983 entries
984 .all
985 .iter()
986 .any(|entry| entry.path.ends_with("src/setup.ts")),
987 "support entries should remain in the overall entry-point set"
988 );
989 assert!(
990 !entries
991 .runtime
992 .iter()
993 .any(|entry| entry.path.ends_with("src/setup.ts")),
994 "support entries should not bleed into runtime reachability"
995 );
996 assert!(
997 !entries
998 .test
999 .iter()
1000 .any(|entry| entry.path.ends_with("src/setup.ts")),
1001 "support entries should not bleed into test reachability"
1002 );
1003 }
1004
1005 #[test]
1006 fn plugin_entry_point_rules_respect_exclusions() {
1007 let dir = tempfile::tempdir().expect("create temp dir");
1008 let root = dir.path();
1009 std::fs::create_dir_all(root.join("app/pages")).unwrap();
1010 std::fs::write(
1011 root.join("app/pages/index.tsx"),
1012 "export default function Page() { return null; }",
1013 )
1014 .unwrap();
1015 std::fs::write(
1016 root.join("app/pages/-helper.ts"),
1017 "export const helper = 1;",
1018 )
1019 .unwrap();
1020
1021 let config = FallowConfig {
1022 schema: None,
1023 extends: vec![],
1024 entry: vec![],
1025 ignore_patterns: vec![],
1026 framework: vec![],
1027 workspaces: None,
1028 ignore_dependencies: vec![],
1029 ignore_exports: vec![],
1030 used_class_members: vec![],
1031 duplicates: fallow_config::DuplicatesConfig::default(),
1032 health: fallow_config::HealthConfig::default(),
1033 rules: RulesConfig::default(),
1034 boundaries: fallow_config::BoundaryConfig::default(),
1035 production: false,
1036 plugins: vec![],
1037 dynamically_loaded: vec![],
1038 overrides: vec![],
1039 regression: None,
1040 audit: fallow_config::AuditConfig::default(),
1041 codeowners: None,
1042 public_packages: vec![],
1043 flags: fallow_config::FlagsConfig::default(),
1044 resolve: fallow_config::ResolveConfig::default(),
1045 sealed: false,
1046 }
1047 .resolve(root.to_path_buf(), OutputFormat::Human, 4, true, true);
1048
1049 let files = vec![
1050 DiscoveredFile {
1051 id: FileId(0),
1052 path: root.join("app/pages/index.tsx"),
1053 size_bytes: 1,
1054 },
1055 DiscoveredFile {
1056 id: FileId(1),
1057 path: root.join("app/pages/-helper.ts"),
1058 size_bytes: 1,
1059 },
1060 ];
1061
1062 let mut plugin_result = crate::plugins::AggregatedPluginResult::default();
1063 plugin_result.entry_patterns.push((
1064 crate::plugins::PathRule::new("app/pages/**/*.{ts,tsx,js,jsx}")
1065 .with_excluded_globs(["app/pages/**/-*", "app/pages/**/-*/**/*"]),
1066 "tanstack-router".to_string(),
1067 ));
1068 plugin_result
1069 .entry_point_roles
1070 .insert("tanstack-router".to_string(), EntryPointRole::Runtime);
1071
1072 let entries = discover_plugin_entry_point_sets(&plugin_result, &config, &files);
1073 let entry_paths: Vec<_> = entries
1074 .all
1075 .iter()
1076 .map(|entry| {
1077 entry
1078 .path
1079 .strip_prefix(root)
1080 .unwrap()
1081 .to_string_lossy()
1082 .into_owned()
1083 })
1084 .collect();
1085
1086 assert!(entry_paths.contains(&"app/pages/index.tsx".to_string()));
1087 assert!(!entry_paths.contains(&"app/pages/-helper.ts".to_string()));
1088 }
1089
1090 mod resolve_entry_path_tests {
1092 use super::*;
1093
1094 #[test]
1095 fn resolves_existing_file() {
1096 let dir = tempfile::tempdir().expect("create temp dir");
1097 let src = dir.path().join("src");
1098 std::fs::create_dir_all(&src).unwrap();
1099 std::fs::write(src.join("index.ts"), "export const a = 1;").unwrap();
1100
1101 let canonical = dunce::canonicalize(dir.path()).unwrap();
1102 let result = resolve_entry_path(
1103 dir.path(),
1104 "src/index.ts",
1105 &canonical,
1106 EntryPointSource::PackageJsonMain,
1107 );
1108 assert!(result.is_some(), "should resolve an existing file");
1109 assert!(result.unwrap().path.ends_with("src/index.ts"));
1110 }
1111
1112 #[test]
1113 fn resolves_with_extension_fallback() {
1114 let dir = tempfile::tempdir().expect("create temp dir");
1115 let canonical = dunce::canonicalize(dir.path()).unwrap();
1117 let src = canonical.join("src");
1118 std::fs::create_dir_all(&src).unwrap();
1119 std::fs::write(src.join("index.ts"), "export const a = 1;").unwrap();
1120
1121 let result = resolve_entry_path(
1123 &canonical,
1124 "src/index",
1125 &canonical,
1126 EntryPointSource::PackageJsonMain,
1127 );
1128 assert!(
1129 result.is_some(),
1130 "should resolve via extension fallback when exact path doesn't exist"
1131 );
1132 let ep = result.unwrap();
1133 assert!(
1134 ep.path.to_string_lossy().contains("index.ts"),
1135 "should find index.ts via extension fallback"
1136 );
1137 }
1138
1139 #[test]
1140 fn returns_none_for_nonexistent_file() {
1141 let dir = tempfile::tempdir().expect("create temp dir");
1142 let canonical = dunce::canonicalize(dir.path()).unwrap();
1143 let result = resolve_entry_path(
1144 dir.path(),
1145 "does/not/exist.ts",
1146 &canonical,
1147 EntryPointSource::PackageJsonMain,
1148 );
1149 assert!(result.is_none(), "should return None for nonexistent files");
1150 }
1151
1152 #[test]
1153 fn maps_dist_output_to_src() {
1154 let dir = tempfile::tempdir().expect("create temp dir");
1155 let src = dir.path().join("src");
1156 std::fs::create_dir_all(&src).unwrap();
1157 std::fs::write(src.join("utils.ts"), "export const u = 1;").unwrap();
1158
1159 let dist = dir.path().join("dist");
1161 std::fs::create_dir_all(&dist).unwrap();
1162 std::fs::write(dist.join("utils.js"), "// compiled").unwrap();
1163
1164 let canonical = dunce::canonicalize(dir.path()).unwrap();
1165 let result = resolve_entry_path(
1166 dir.path(),
1167 "./dist/utils.js",
1168 &canonical,
1169 EntryPointSource::PackageJsonExports,
1170 );
1171 assert!(result.is_some(), "should resolve dist/ path to src/");
1172 let ep = result.unwrap();
1173 assert!(
1174 ep.path
1175 .to_string_lossy()
1176 .replace('\\', "/")
1177 .contains("src/utils.ts"),
1178 "should map ./dist/utils.js to src/utils.ts"
1179 );
1180 }
1181
1182 #[test]
1183 fn maps_build_output_to_src() {
1184 let dir = tempfile::tempdir().expect("create temp dir");
1185 let canonical = dunce::canonicalize(dir.path()).unwrap();
1187 let src = canonical.join("src");
1188 std::fs::create_dir_all(&src).unwrap();
1189 std::fs::write(src.join("index.tsx"), "export default () => {};").unwrap();
1190
1191 let result = resolve_entry_path(
1192 &canonical,
1193 "./build/index.js",
1194 &canonical,
1195 EntryPointSource::PackageJsonExports,
1196 );
1197 assert!(result.is_some(), "should map build/ output to src/");
1198 let ep = result.unwrap();
1199 assert!(
1200 ep.path
1201 .to_string_lossy()
1202 .replace('\\', "/")
1203 .contains("src/index.tsx"),
1204 "should map ./build/index.js to src/index.tsx"
1205 );
1206 }
1207
1208 #[test]
1209 fn preserves_entry_point_source() {
1210 let dir = tempfile::tempdir().expect("create temp dir");
1211 std::fs::write(dir.path().join("index.ts"), "export const a = 1;").unwrap();
1212
1213 let canonical = dunce::canonicalize(dir.path()).unwrap();
1214 let result = resolve_entry_path(
1215 dir.path(),
1216 "index.ts",
1217 &canonical,
1218 EntryPointSource::PackageJsonScript,
1219 );
1220 assert!(result.is_some());
1221 assert!(
1222 matches!(result.unwrap().source, EntryPointSource::PackageJsonScript),
1223 "should preserve the source kind"
1224 );
1225 }
1226 }
1227
1228 mod output_to_source_tests {
1230 use super::*;
1231
1232 #[test]
1233 fn maps_dist_to_src_with_ts_extension() {
1234 let dir = tempfile::tempdir().expect("create temp dir");
1235 let src = dir.path().join("src");
1236 std::fs::create_dir_all(&src).unwrap();
1237 std::fs::write(src.join("utils.ts"), "export const u = 1;").unwrap();
1238
1239 let result = try_output_to_source_path(dir.path(), "./dist/utils.js");
1240 assert!(result.is_some());
1241 assert!(
1242 result
1243 .unwrap()
1244 .to_string_lossy()
1245 .replace('\\', "/")
1246 .contains("src/utils.ts")
1247 );
1248 }
1249
1250 #[test]
1251 fn returns_none_when_no_source_file_exists() {
1252 let dir = tempfile::tempdir().expect("create temp dir");
1253 let result = try_output_to_source_path(dir.path(), "./dist/missing.js");
1255 assert!(result.is_none());
1256 }
1257
1258 #[test]
1259 fn ignores_non_output_directories() {
1260 let dir = tempfile::tempdir().expect("create temp dir");
1261 let src = dir.path().join("src");
1262 std::fs::create_dir_all(&src).unwrap();
1263 std::fs::write(src.join("foo.ts"), "export const f = 1;").unwrap();
1264
1265 let result = try_output_to_source_path(dir.path(), "./lib/foo.js");
1267 assert!(result.is_none());
1268 }
1269
1270 #[test]
1271 fn maps_nested_output_path_preserving_prefix() {
1272 let dir = tempfile::tempdir().expect("create temp dir");
1273 let modules_src = dir.path().join("modules").join("src");
1274 std::fs::create_dir_all(&modules_src).unwrap();
1275 std::fs::write(modules_src.join("helper.ts"), "export const h = 1;").unwrap();
1276
1277 let result = try_output_to_source_path(dir.path(), "./modules/dist/helper.js");
1278 assert!(result.is_some());
1279 assert!(
1280 result
1281 .unwrap()
1282 .to_string_lossy()
1283 .replace('\\', "/")
1284 .contains("modules/src/helper.ts")
1285 );
1286 }
1287 }
1288
1289 mod source_index_fallback_tests {
1291 use super::*;
1292
1293 #[test]
1294 fn detects_dist_entry_in_output_dir() {
1295 assert!(is_entry_in_output_dir("./dist/esm2022/index.js"));
1296 assert!(is_entry_in_output_dir("dist/index.js"));
1297 assert!(is_entry_in_output_dir("./build/index.js"));
1298 assert!(is_entry_in_output_dir("./out/main.js"));
1299 assert!(is_entry_in_output_dir("./esm/index.js"));
1300 assert!(is_entry_in_output_dir("./cjs/index.js"));
1301 }
1302
1303 #[test]
1304 fn rejects_non_output_entry_paths() {
1305 assert!(!is_entry_in_output_dir("./src/index.ts"));
1306 assert!(!is_entry_in_output_dir("src/main.ts"));
1307 assert!(!is_entry_in_output_dir("./index.js"));
1308 assert!(!is_entry_in_output_dir(""));
1309 }
1310
1311 #[test]
1312 fn rejects_substring_match_for_output_dir() {
1313 assert!(!is_entry_in_output_dir("./distro/index.js"));
1315 assert!(!is_entry_in_output_dir("./build-scripts/run.js"));
1316 }
1317
1318 #[test]
1319 fn finds_src_index_ts() {
1320 let dir = tempfile::tempdir().expect("create temp dir");
1321 let src = dir.path().join("src");
1322 std::fs::create_dir_all(&src).unwrap();
1323 let index_path = src.join("index.ts");
1324 std::fs::write(&index_path, "export const a = 1;").unwrap();
1325
1326 let result = try_source_index_fallback(dir.path());
1327 assert_eq!(result.as_deref(), Some(index_path.as_path()));
1328 }
1329
1330 #[test]
1331 fn finds_src_index_tsx_when_ts_missing() {
1332 let dir = tempfile::tempdir().expect("create temp dir");
1333 let src = dir.path().join("src");
1334 std::fs::create_dir_all(&src).unwrap();
1335 let index_path = src.join("index.tsx");
1336 std::fs::write(&index_path, "export default 1;").unwrap();
1337
1338 let result = try_source_index_fallback(dir.path());
1339 assert_eq!(result.as_deref(), Some(index_path.as_path()));
1340 }
1341
1342 #[test]
1343 fn prefers_src_index_over_root_index() {
1344 let dir = tempfile::tempdir().expect("create temp dir");
1347 let src = dir.path().join("src");
1348 std::fs::create_dir_all(&src).unwrap();
1349 let src_index = src.join("index.ts");
1350 std::fs::write(&src_index, "export const a = 1;").unwrap();
1351 let root_index = dir.path().join("index.ts");
1352 std::fs::write(&root_index, "export const b = 2;").unwrap();
1353
1354 let result = try_source_index_fallback(dir.path());
1355 assert_eq!(result.as_deref(), Some(src_index.as_path()));
1356 }
1357
1358 #[test]
1359 fn falls_back_to_src_main() {
1360 let dir = tempfile::tempdir().expect("create temp dir");
1361 let src = dir.path().join("src");
1362 std::fs::create_dir_all(&src).unwrap();
1363 let main_path = src.join("main.ts");
1364 std::fs::write(&main_path, "export const a = 1;").unwrap();
1365
1366 let result = try_source_index_fallback(dir.path());
1367 assert_eq!(result.as_deref(), Some(main_path.as_path()));
1368 }
1369
1370 #[test]
1371 fn falls_back_to_root_index_when_no_src() {
1372 let dir = tempfile::tempdir().expect("create temp dir");
1373 let index_path = dir.path().join("index.js");
1374 std::fs::write(&index_path, "module.exports = {};").unwrap();
1375
1376 let result = try_source_index_fallback(dir.path());
1377 assert_eq!(result.as_deref(), Some(index_path.as_path()));
1378 }
1379
1380 #[test]
1381 fn returns_none_when_nothing_matches() {
1382 let dir = tempfile::tempdir().expect("create temp dir");
1383 let result = try_source_index_fallback(dir.path());
1384 assert!(result.is_none());
1385 }
1386
1387 #[test]
1388 fn resolve_entry_path_falls_back_to_src_index_for_dist_entry() {
1389 let dir = tempfile::tempdir().expect("create temp dir");
1390 let canonical = dunce::canonicalize(dir.path()).unwrap();
1391
1392 let dist_dir = canonical.join("dist").join("esm2022");
1397 std::fs::create_dir_all(&dist_dir).unwrap();
1398 std::fs::write(dist_dir.join("index.js"), "export const x = 1;").unwrap();
1399
1400 let src = canonical.join("src");
1401 std::fs::create_dir_all(&src).unwrap();
1402 let src_index = src.join("index.ts");
1403 std::fs::write(&src_index, "export const x = 1;").unwrap();
1404
1405 let result = resolve_entry_path(
1406 &canonical,
1407 "./dist/esm2022/index.js",
1408 &canonical,
1409 EntryPointSource::PackageJsonMain,
1410 );
1411 assert!(result.is_some());
1412 let entry = result.unwrap();
1413 assert_eq!(entry.path, src_index);
1414 }
1415
1416 #[test]
1417 fn resolve_entry_path_uses_direct_src_mirror_when_available() {
1418 let dir = tempfile::tempdir().expect("create temp dir");
1421 let canonical = dunce::canonicalize(dir.path()).unwrap();
1422
1423 let src_mirror = canonical.join("src").join("esm2022");
1424 std::fs::create_dir_all(&src_mirror).unwrap();
1425 let mirror_index = src_mirror.join("index.ts");
1426 std::fs::write(&mirror_index, "export const x = 1;").unwrap();
1427
1428 let src_index = canonical.join("src").join("index.ts");
1430 std::fs::write(&src_index, "export const y = 2;").unwrap();
1431
1432 let result = resolve_entry_path(
1433 &canonical,
1434 "./dist/esm2022/index.js",
1435 &canonical,
1436 EntryPointSource::PackageJsonMain,
1437 );
1438 assert_eq!(result.map(|e| e.path), Some(mirror_index));
1439 }
1440 }
1441
1442 mod default_fallback_tests {
1444 use super::*;
1445
1446 #[test]
1447 fn finds_src_index_ts_as_fallback() {
1448 let dir = tempfile::tempdir().expect("create temp dir");
1449 let src = dir.path().join("src");
1450 std::fs::create_dir_all(&src).unwrap();
1451 let index_path = src.join("index.ts");
1452 std::fs::write(&index_path, "export const a = 1;").unwrap();
1453
1454 let files = vec![DiscoveredFile {
1455 id: FileId(0),
1456 path: index_path.clone(),
1457 size_bytes: 20,
1458 }];
1459
1460 let entries = apply_default_fallback(&files, dir.path(), None);
1461 assert_eq!(entries.len(), 1);
1462 assert_eq!(entries[0].path, index_path);
1463 assert!(matches!(entries[0].source, EntryPointSource::DefaultIndex));
1464 }
1465
1466 #[test]
1467 fn finds_root_index_js_as_fallback() {
1468 let dir = tempfile::tempdir().expect("create temp dir");
1469 let index_path = dir.path().join("index.js");
1470 std::fs::write(&index_path, "module.exports = {};").unwrap();
1471
1472 let files = vec![DiscoveredFile {
1473 id: FileId(0),
1474 path: index_path.clone(),
1475 size_bytes: 21,
1476 }];
1477
1478 let entries = apply_default_fallback(&files, dir.path(), None);
1479 assert_eq!(entries.len(), 1);
1480 assert_eq!(entries[0].path, index_path);
1481 }
1482
1483 #[test]
1484 fn returns_empty_when_no_index_file() {
1485 let dir = tempfile::tempdir().expect("create temp dir");
1486 let other_path = dir.path().join("src").join("utils.ts");
1487
1488 let files = vec![DiscoveredFile {
1489 id: FileId(0),
1490 path: other_path,
1491 size_bytes: 10,
1492 }];
1493
1494 let entries = apply_default_fallback(&files, dir.path(), None);
1495 assert!(
1496 entries.is_empty(),
1497 "non-index files should not match default fallback"
1498 );
1499 }
1500
1501 #[test]
1502 fn workspace_filter_restricts_scope() {
1503 let dir = tempfile::tempdir().expect("create temp dir");
1504 let ws_a = dir.path().join("packages").join("a").join("src");
1505 std::fs::create_dir_all(&ws_a).unwrap();
1506 let ws_b = dir.path().join("packages").join("b").join("src");
1507 std::fs::create_dir_all(&ws_b).unwrap();
1508
1509 let index_a = ws_a.join("index.ts");
1510 let index_b = ws_b.join("index.ts");
1511
1512 let files = vec![
1513 DiscoveredFile {
1514 id: FileId(0),
1515 path: index_a.clone(),
1516 size_bytes: 10,
1517 },
1518 DiscoveredFile {
1519 id: FileId(1),
1520 path: index_b,
1521 size_bytes: 10,
1522 },
1523 ];
1524
1525 let ws_root = dir.path().join("packages").join("a");
1527 let entries = apply_default_fallback(&files, &ws_root, Some(&ws_root));
1528 assert_eq!(entries.len(), 1);
1529 assert_eq!(entries[0].path, index_a);
1530 }
1531 }
1532
1533 mod wildcard_entry_tests {
1535 use super::*;
1536
1537 #[test]
1538 fn expands_wildcard_css_entries() {
1539 let dir = tempfile::tempdir().expect("create temp dir");
1542 let themes = dir.path().join("src").join("themes");
1543 std::fs::create_dir_all(&themes).unwrap();
1544 std::fs::write(themes.join("dark.css"), ":root { --bg: #000; }").unwrap();
1545 std::fs::write(themes.join("light.css"), ":root { --bg: #fff; }").unwrap();
1546
1547 let canonical = dunce::canonicalize(dir.path()).unwrap();
1548 let mut entries = Vec::new();
1549 expand_wildcard_entries(dir.path(), "./src/themes/*.css", &canonical, &mut entries);
1550
1551 assert_eq!(entries.len(), 2, "should expand wildcard to 2 CSS files");
1552 let paths: Vec<String> = entries
1553 .iter()
1554 .map(|ep| ep.path.file_name().unwrap().to_string_lossy().to_string())
1555 .collect();
1556 assert!(paths.contains(&"dark.css".to_string()));
1557 assert!(paths.contains(&"light.css".to_string()));
1558 assert!(
1559 entries
1560 .iter()
1561 .all(|ep| matches!(ep.source, EntryPointSource::PackageJsonExports))
1562 );
1563 }
1564
1565 #[test]
1566 fn wildcard_does_not_match_nonexistent_files() {
1567 let dir = tempfile::tempdir().expect("create temp dir");
1568 std::fs::create_dir_all(dir.path().join("src/themes")).unwrap();
1570
1571 let canonical = dunce::canonicalize(dir.path()).unwrap();
1572 let mut entries = Vec::new();
1573 expand_wildcard_entries(dir.path(), "./src/themes/*.css", &canonical, &mut entries);
1574
1575 assert!(
1576 entries.is_empty(),
1577 "should return empty when no files match the wildcard"
1578 );
1579 }
1580
1581 #[test]
1582 fn wildcard_only_matches_specified_extension() {
1583 let dir = tempfile::tempdir().expect("create temp dir");
1585 let themes = dir.path().join("src").join("themes");
1586 std::fs::create_dir_all(&themes).unwrap();
1587 std::fs::write(themes.join("dark.css"), ":root {}").unwrap();
1588 std::fs::write(themes.join("index.ts"), "export {};").unwrap();
1589
1590 let canonical = dunce::canonicalize(dir.path()).unwrap();
1591 let mut entries = Vec::new();
1592 expand_wildcard_entries(dir.path(), "./src/themes/*.css", &canonical, &mut entries);
1593
1594 assert_eq!(entries.len(), 1, "should only match CSS files");
1595 assert!(
1596 entries[0]
1597 .path
1598 .file_name()
1599 .unwrap()
1600 .to_string_lossy()
1601 .ends_with(".css")
1602 );
1603 }
1604 }
1605}