1use dashmap::DashMap;
2use std::sync::Arc;
3use tower_lsp_server::ls_types::Uri;
4
5use crate::Ecosystem;
6
7pub struct EcosystemRegistry {
36 ecosystems: DashMap<&'static str, Arc<dyn Ecosystem>>,
38 filename_map: DashMap<&'static str, &'static str>,
40 extension_map: DashMap<&'static str, &'static str>,
51 patterns: DashMap<&'static str, (&'static str, &'static str, &'static str)>,
59}
60
61impl EcosystemRegistry {
62 pub fn new() -> Self {
73 Self {
74 ecosystems: DashMap::new(),
75 filename_map: DashMap::new(),
76 extension_map: DashMap::new(),
77 patterns: DashMap::new(),
78 }
79 }
80
81 pub fn register(&self, ecosystem: Arc<dyn Ecosystem>) {
100 let id = ecosystem.id();
101
102 for filename in ecosystem.manifest_filenames() {
104 self.filename_map.insert(*filename, id);
105 }
106
107 for extension in ecosystem.manifest_extensions() {
109 debug_assert!(
110 self.extension_map
111 .get(extension)
112 .is_none_or(|owner| *owner == id),
113 "extension {extension:?} already claimed by ecosystem {:?}, cannot also assign it to {id:?}",
114 self.extension_map.get(extension).map(|o| *o),
115 );
116 self.extension_map.insert(*extension, id);
117 }
118
119 for &pattern in ecosystem.manifest_patterns() {
121 let Some((prefix, suffix)) = pattern.split_once('*') else {
122 debug_assert!(
123 false,
124 "manifest pattern {pattern:?} must contain exactly one '*'"
125 );
126 continue;
127 };
128 debug_assert!(
129 !prefix.is_empty() || !suffix.is_empty(),
130 "manifest pattern {pattern:?} must not be a bare '*', which would claim every file"
131 );
132 debug_assert!(
133 !suffix.contains('*'),
134 "manifest pattern {pattern:?} must contain exactly one '*'"
135 );
136 debug_assert!(
137 self.patterns
138 .iter()
139 .all(|e| e.value().2 == id || *e.key() == pattern),
140 "pattern {pattern:?} would introduce a second ecosystem ({id:?}) into the \
141 single-owner pattern set; the most-specific-wins selection in \
142 get_for_filename assumes all patterns belong to one ecosystem"
143 );
144 self.patterns.insert(pattern, (prefix, suffix, id));
145 }
146
147 self.ecosystems.insert(id, ecosystem);
149 }
150
151 pub fn get(&self, id: &str) -> Option<Arc<dyn Ecosystem>> {
173 self.ecosystems.get(id).map(|e| Arc::clone(&e))
174 }
175
176 pub fn get_for_filename(&self, filename: &str) -> Option<Arc<dyn Ecosystem>> {
209 if let Some(id) = self.filename_map.get(filename) {
210 return self.get(*id);
211 }
212
213 if let Some(id) = self.match_pattern(filename) {
214 return self.get(id);
215 }
216
217 if self.extension_map.is_empty() {
221 return None;
222 }
223
224 let (_, extension) = filename.rsplit_once('.')?;
225 let lowercased = format!(".{}", extension.to_lowercase());
226 let id = self.extension_map.get(lowercased.as_str())?;
227 self.get(*id)
228 }
229
230 fn match_pattern(&self, filename: &str) -> Option<&'static str> {
236 if self.patterns.is_empty() {
237 return None;
238 }
239
240 let mut best: Option<(usize, &'static str, &'static str)> = None; for e in self.patterns.iter() {
242 let (prefix, suffix, id) = *e.value();
243 if prefix_suffix_matches(filename, prefix, suffix) {
244 let score = prefix.len() + suffix.len();
245 let pattern = *e.key();
246 if best.is_none_or(|(s, p, _)| (score, pattern) > (s, p)) {
247 best = Some((score, pattern, id));
248 }
249 }
250 }
251
252 best.map(|(_, _, id)| id)
253 }
254
255 pub fn get_for_uri(&self, uri: &Uri) -> Option<Arc<dyn Ecosystem>> {
282 let path = uri.path().as_str();
283 let filename = path.rsplit('/').next()?;
284 if let Some(ecosystem) = self.get_for_filename(filename) {
285 return Some(ecosystem);
286 }
287 self.get_for_directory_pattern(path, filename)
288 }
289
290 fn get_for_directory_pattern(&self, path: &str, filename: &str) -> Option<Arc<dyn Ecosystem>> {
308 for entry in self.ecosystems.iter() {
309 let ecosystem = entry.value();
310 let matches =
311 ecosystem
312 .manifest_directory_patterns()
313 .iter()
314 .any(|(dir_pattern, suffix)| {
315 directory_pattern_matches(path, filename, dir_pattern, suffix)
316 });
317 if matches {
318 return Some(Arc::clone(ecosystem));
319 }
320 }
321 None
322 }
323
324 pub fn ecosystem_ids(&self) -> Vec<&'static str> {
347 self.ecosystems.iter().map(|e| *e.key()).collect()
348 }
349
350 pub fn get_for_lockfile(&self, filename: &str) -> Option<Arc<dyn Ecosystem>> {
381 for entry in self.ecosystems.iter() {
382 let ecosystem = entry.value();
383 let matches = ecosystem
384 .lockfile_filenames()
385 .iter()
386 .any(|pattern| lockfile_pattern_matches(pattern, filename));
387 if matches {
388 return Some(Arc::clone(ecosystem));
389 }
390 }
391 None
392 }
393
394 pub fn all_lockfile_patterns(&self) -> Vec<String> {
413 let mut patterns = Vec::new();
414 for entry in self.ecosystems.iter() {
415 let ecosystem = entry.value();
416 for filename in ecosystem.lockfile_filenames() {
417 patterns.push(format!("**/{}", filename));
418 }
419 }
420 patterns
421 }
422
423 pub fn get_for_watched_config(&self, filename: &str) -> Option<Arc<dyn Ecosystem>> {
440 for entry in self.ecosystems.iter() {
441 let ecosystem = entry.value();
442 let matches = ecosystem
443 .watched_config_filenames()
444 .iter()
445 .any(|pattern| lockfile_pattern_matches(pattern, filename));
446 if matches {
447 return Some(Arc::clone(ecosystem));
448 }
449 }
450 None
451 }
452
453 pub fn all_watched_config_patterns(&self) -> Vec<String> {
470 let mut patterns = Vec::new();
471 for entry in self.ecosystems.iter() {
472 let ecosystem = entry.value();
473 for filename in ecosystem.watched_config_filenames() {
474 patterns.push(format!("**/{}", filename));
475 }
476 }
477 patterns
478 }
479}
480
481impl Default for EcosystemRegistry {
482 fn default() -> Self {
483 Self::new()
484 }
485}
486
487fn lockfile_pattern_matches(pattern: &str, filename: &str) -> bool {
492 match pattern.split_once('*') {
493 Some((prefix, suffix)) => prefix_suffix_matches(filename, prefix, suffix),
494 None => pattern == filename,
495 }
496}
497
498fn prefix_suffix_matches(filename: &str, prefix: &str, suffix: &str) -> bool {
502 filename.len() >= prefix.len() + suffix.len()
503 && filename.starts_with(prefix)
504 && filename.ends_with(suffix)
505}
506
507fn directory_pattern_matches(path: &str, filename: &str, dir_pattern: &str, suffix: &str) -> bool {
522 if !filename.ends_with(suffix) {
523 return false;
524 }
525 let Some(dir_path) = path.strip_suffix(filename) else {
526 return false;
527 };
528 let dir_path = dir_path.trim_end_matches('/');
529 match dir_path.len().checked_sub(dir_pattern.len()) {
530 Some(0) => dir_path == dir_pattern,
531 Some(n) => dir_path.ends_with(dir_pattern) && dir_path.as_bytes()[n - 1] == b'/',
532 None => false,
533 }
534}
535
536#[must_use]
563pub fn manifest_pattern_matches(filename: &str, pattern: &str) -> bool {
564 match pattern.split_once('*') {
565 Some((prefix, suffix)) if !suffix.contains('*') => {
566 prefix_suffix_matches(filename, prefix, suffix)
567 }
568 _ => false,
569 }
570}
571
572#[cfg(test)]
573mod tests {
574 use super::*;
575 use std::any::Any;
576 use tower_lsp_server::ls_types::Position;
577
578 use crate::{
579 ConcreteVersion, PackageName, ParseResult, Registry,
580 completion::Completions,
581 lsp_helpers::{
582 DiagnosticMessages, DiagnosticPolicy, EcosystemFormatter, OsvNaming, PackageNaming,
583 PackageRendering, RequirementResolution, SourcePolicy,
584 },
585 };
586
587 struct MockFormatter;
588 impl PackageNaming for MockFormatter {}
589
590 impl PackageRendering for MockFormatter {
591 fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
592 version.to_string()
593 }
594
595 fn package_url(&self, name: &PackageName) -> String {
596 format!("https://example.com/{name}")
597 }
598 }
599
600 impl RequirementResolution for MockFormatter {}
601
602 impl DiagnosticMessages for MockFormatter {}
603
604 impl DiagnosticPolicy for MockFormatter {}
605
606 impl SourcePolicy for MockFormatter {}
607
608 impl OsvNaming for MockFormatter {}
609
610 struct MockEcosystem {
612 id: &'static str,
613 display_name: &'static str,
614 filenames: &'static [&'static str],
615 lockfiles: &'static [&'static str],
616 watched_configs: &'static [&'static str],
617 }
618
619 impl crate::ecosystem::private::Sealed for MockEcosystem {}
620
621 impl Ecosystem for MockEcosystem {
622 fn id(&self) -> &'static str {
623 self.id
624 }
625
626 fn display_name(&self) -> &'static str {
627 self.display_name
628 }
629
630 fn manifest_filenames(&self) -> &[&'static str] {
631 self.filenames
632 }
633
634 fn lockfile_filenames(&self) -> &[&'static str] {
635 self.lockfiles
636 }
637
638 fn watched_config_filenames(&self) -> &[&'static str] {
639 self.watched_configs
640 }
641
642 fn parse_manifest<'a>(
643 &'a self,
644 _content: &'a str,
645 _uri: &'a Uri,
646 ) -> crate::ecosystem::BoxFuture<'a, crate::error::Result<Box<dyn ParseResult>>> {
647 Box::pin(async move { unimplemented!() })
648 }
649
650 fn registry(&self) -> Arc<dyn Registry> {
651 unimplemented!()
652 }
653
654 fn formatter(&self) -> &dyn EcosystemFormatter {
655 &MockFormatter
656 }
657
658 fn generate_completions<'a>(
659 &'a self,
660 _parse_result: &'a dyn ParseResult,
661 _position: Position,
662 _content: &'a str,
663 _freshness: crate::FreshnessSettings,
664 ) -> crate::ecosystem::BoxFuture<'a, Completions> {
665 Box::pin(async move { Completions::default() })
666 }
667
668 fn as_any(&self) -> &dyn Any {
669 self
670 }
671 }
672
673 struct MockExtEcosystem {
675 id: &'static str,
676 filenames: &'static [&'static str],
677 extensions: &'static [&'static str],
678 }
679
680 impl crate::ecosystem::private::Sealed for MockExtEcosystem {}
681
682 impl Ecosystem for MockExtEcosystem {
683 fn id(&self) -> &'static str {
684 self.id
685 }
686
687 fn display_name(&self) -> &'static str {
688 self.id
689 }
690
691 fn manifest_filenames(&self) -> &[&'static str] {
692 self.filenames
693 }
694
695 fn manifest_extensions(&self) -> &[&'static str] {
696 self.extensions
697 }
698
699 fn parse_manifest<'a>(
700 &'a self,
701 _content: &'a str,
702 _uri: &'a Uri,
703 ) -> crate::ecosystem::BoxFuture<'a, crate::error::Result<Box<dyn ParseResult>>> {
704 Box::pin(async move { unimplemented!() })
705 }
706
707 fn registry(&self) -> Arc<dyn Registry> {
708 unimplemented!()
709 }
710
711 fn formatter(&self) -> &dyn EcosystemFormatter {
712 &MockFormatter
713 }
714
715 fn generate_completions<'a>(
716 &'a self,
717 _parse_result: &'a dyn ParseResult,
718 _position: Position,
719 _content: &'a str,
720 _freshness: crate::FreshnessSettings,
721 ) -> crate::ecosystem::BoxFuture<'a, Completions> {
722 Box::pin(async move { Completions::default() })
723 }
724
725 fn as_any(&self) -> &dyn Any {
726 self
727 }
728 }
729
730 struct MockPatternEcosystem {
732 id: &'static str,
733 patterns: &'static [&'static str],
734 dir_patterns: &'static [(&'static str, &'static str)],
735 }
736
737 impl crate::ecosystem::private::Sealed for MockPatternEcosystem {}
738
739 impl Ecosystem for MockPatternEcosystem {
740 fn id(&self) -> &'static str {
741 self.id
742 }
743
744 fn display_name(&self) -> &'static str {
745 self.id
746 }
747
748 fn manifest_filenames(&self) -> &[&'static str] {
749 &[]
750 }
751
752 fn manifest_patterns(&self) -> &[&'static str] {
753 self.patterns
754 }
755
756 fn manifest_directory_patterns(&self) -> &[(&'static str, &'static str)] {
757 self.dir_patterns
758 }
759
760 fn parse_manifest<'a>(
761 &'a self,
762 _content: &'a str,
763 _uri: &'a Uri,
764 ) -> crate::ecosystem::BoxFuture<'a, crate::error::Result<Box<dyn ParseResult>>> {
765 Box::pin(async move { unimplemented!() })
766 }
767
768 fn registry(&self) -> Arc<dyn Registry> {
769 unimplemented!()
770 }
771
772 fn formatter(&self) -> &dyn EcosystemFormatter {
773 &MockFormatter
774 }
775
776 fn generate_completions<'a>(
777 &'a self,
778 _parse_result: &'a dyn ParseResult,
779 _position: Position,
780 _content: &'a str,
781 _freshness: crate::FreshnessSettings,
782 ) -> crate::ecosystem::BoxFuture<'a, Completions> {
783 Box::pin(async move { Completions::default() })
784 }
785
786 fn as_any(&self) -> &dyn Any {
787 self
788 }
789 }
790
791 fn pypi_pattern_registry() -> EcosystemRegistry {
792 let registry = EcosystemRegistry::new();
793 registry.register(Arc::new(MockPatternEcosystem {
794 id: "pypi",
795 patterns: &[
796 "requirements*.txt",
797 "*-requirements.txt",
798 "*.requirements.txt",
799 "constraints*.txt",
800 ],
801 dir_patterns: &[("requirements", ".txt")],
802 }));
803 registry
804 }
805
806 fn gha_pattern_registry() -> EcosystemRegistry {
810 let registry = EcosystemRegistry::new();
811 registry.register(Arc::new(MockPatternEcosystem {
812 id: "github-actions",
813 patterns: &[],
814 dir_patterns: &[
815 (".github/workflows", ".yml"),
816 (".github/workflows", ".yaml"),
817 ],
818 }));
819 registry
820 }
821
822 #[test]
823 fn test_get_for_uri_multi_segment_directory_pattern_matches_yml_and_yaml() {
824 let registry = gha_pattern_registry();
825 for path in [
826 "/repo/.github/workflows/ci.yml",
827 "/repo/.github/workflows/release.yaml",
828 ] {
829 let uri = crate::test_util::test_uri(path);
830 assert_eq!(
831 registry.get_for_uri(&uri).map(|e| e.id()),
832 Some("github-actions"),
833 "{path} should match the .github/workflows/*.y[a]ml directory pattern"
834 );
835 }
836 }
837
838 #[test]
839 fn test_get_for_uri_multi_segment_directory_pattern_matches_nested_repo() {
840 let registry = gha_pattern_registry();
841 let uri = crate::test_util::test_uri("/home/user/a/b/.github/workflows/x.yml");
842 assert_eq!(
843 registry.get_for_uri(&uri).map(|e| e.id()),
844 Some("github-actions"),
845 "a repo nested under arbitrary ancestor directories should still match"
846 );
847 }
848
849 #[test]
850 fn test_get_for_uri_multi_segment_directory_pattern_rejects_partial_paths() {
851 let registry = gha_pattern_registry();
852 for path in [
853 "/repo/workflows/x.yml",
855 "/repo/.github/x.yml",
857 "/repo/my.github/workflows/x.yml",
860 ] {
861 let uri = crate::test_util::test_uri(path);
862 assert!(
863 registry.get_for_uri(&uri).is_none(),
864 "{path} should not match the .github/workflows/*.y[a]ml directory pattern"
865 );
866 }
867 }
868
869 #[test]
870 fn test_get_for_filename_pattern_matches_requirements_variants() {
871 let registry = pypi_pattern_registry();
872 for name in [
873 "requirements.txt",
874 "requirements-dev.txt",
875 "requirements.dev.txt",
876 "requirements_test.txt",
877 "dev-requirements.txt",
878 "test.requirements.txt",
879 "constraints.txt",
880 "constraints-prod.txt",
881 ] {
882 assert_eq!(
883 registry.get_for_filename(name).map(|e| e.id()),
884 Some("pypi"),
885 "{name} should match a PyPI pattern"
886 );
887 }
888 }
889
890 #[test]
891 fn test_get_for_filename_pattern_does_not_match_unrelated_files() {
892 let registry = pypi_pattern_registry();
893 for name in [
894 "notes.txt",
895 "LICENSE.txt",
896 "myrequirements.txt",
897 "requirements.txt.bak",
898 "Requirements.txt",
899 "requirements",
900 ] {
901 assert!(
902 registry.get_for_filename(name).is_none(),
903 "{name} should not match any PyPI pattern"
904 );
905 }
906 }
907
908 #[test]
909 fn test_get_for_filename_exact_name_wins_over_pattern() {
910 let registry = EcosystemRegistry::new();
911 registry.register(Arc::new(MockEcosystem {
912 id: "exact",
913 display_name: "Exact",
914 filenames: &["requirements.txt"],
915 lockfiles: &[],
916 watched_configs: &[],
917 }));
918 registry.register(Arc::new(MockPatternEcosystem {
919 id: "pattern",
920 patterns: &["requirements*.txt"],
921 dir_patterns: &[],
922 }));
923
924 assert_eq!(
925 registry.get_for_filename("requirements.txt").unwrap().id(),
926 "exact"
927 );
928 }
929
930 #[test]
931 fn test_get_for_filename_pattern_wins_over_extension() {
932 let registry = EcosystemRegistry::new();
933 registry.register(Arc::new(MockExtEcosystem {
934 id: "ext",
935 filenames: &[],
936 extensions: &[".txt"],
937 }));
938 registry.register(Arc::new(MockPatternEcosystem {
939 id: "pattern",
940 patterns: &["requirements*.txt"],
941 dir_patterns: &[],
942 }));
943
944 assert_eq!(
945 registry.get_for_filename("requirements.txt").unwrap().id(),
946 "pattern"
947 );
948 }
949
950 #[test]
951 fn test_get_for_filename_pattern_most_specific_wins_deterministically() {
952 let registry = pypi_pattern_registry();
953 assert_eq!(
956 registry
957 .get_for_filename("requirements.requirements.txt")
958 .unwrap()
959 .id(),
960 "pypi"
961 );
962 }
963
964 #[test]
965 fn test_new_registry_is_empty() {
966 let registry = EcosystemRegistry::new();
967 assert_eq!(registry.ecosystem_ids().len(), 0);
968 }
969
970 #[test]
971 fn test_register_ecosystem() {
972 let registry = EcosystemRegistry::new();
973 let ecosystem = Arc::new(MockEcosystem {
974 id: "test",
975 display_name: "Test Ecosystem",
976 filenames: &["test.toml"],
977 lockfiles: &[],
978 watched_configs: &[],
979 });
980
981 registry.register(ecosystem);
982
983 assert_eq!(registry.ecosystem_ids().len(), 1);
984 assert!(registry.get("test").is_some());
985 }
986
987 #[test]
988 fn test_get_by_id() {
989 let registry = EcosystemRegistry::new();
990 let ecosystem = Arc::new(MockEcosystem {
991 id: "test",
992 display_name: "Test Ecosystem",
993 filenames: &["test.toml"],
994 lockfiles: &[],
995 watched_configs: &[],
996 });
997
998 registry.register(ecosystem);
999
1000 let retrieved = registry.get("test").unwrap();
1001 assert_eq!(retrieved.id(), "test");
1002 assert_eq!(retrieved.display_name(), "Test Ecosystem");
1003 }
1004
1005 #[test]
1006 fn test_get_by_filename() {
1007 let registry = EcosystemRegistry::new();
1008 let ecosystem = Arc::new(MockEcosystem {
1009 id: "test",
1010 display_name: "Test Ecosystem",
1011 filenames: &["test.toml", "test.json"],
1012 lockfiles: &[],
1013 watched_configs: &[],
1014 });
1015
1016 registry.register(ecosystem);
1017
1018 let retrieved1 = registry.get_for_filename("test.toml").unwrap();
1019 assert_eq!(retrieved1.id(), "test");
1020
1021 let retrieved2 = registry.get_for_filename("test.json").unwrap();
1022 assert_eq!(retrieved2.id(), "test");
1023
1024 assert!(registry.get_for_filename("unknown.toml").is_none());
1025 }
1026
1027 #[test]
1028 fn test_get_by_uri() {
1029 let registry = EcosystemRegistry::new();
1030 let ecosystem = Arc::new(MockEcosystem {
1031 id: "test",
1032 display_name: "Test Ecosystem",
1033 filenames: &["test.toml"],
1034 lockfiles: &[],
1035 watched_configs: &[],
1036 });
1037
1038 registry.register(ecosystem);
1039
1040 let uri = crate::test_util::test_uri("/home/user/project/test.toml");
1041 let retrieved = registry.get_for_uri(&uri).unwrap();
1042 assert_eq!(retrieved.id(), "test");
1043
1044 let unknown_uri = crate::test_util::test_uri("/home/user/project/unknown.toml");
1045 assert!(registry.get_for_uri(&unknown_uri).is_none());
1046 }
1047
1048 #[test]
1049 fn test_get_for_uri_directory_pattern_matches_split_requirements_layout() {
1050 let registry = pypi_pattern_registry();
1051 for path in [
1052 "/home/user/project/requirements/base.txt",
1053 "/home/user/project/requirements/dev.txt",
1054 "/home/user/project/sub/requirements/prod.txt",
1055 ] {
1056 let uri = crate::test_util::test_uri(path);
1057 assert_eq!(
1058 registry.get_for_uri(&uri).map(|e| e.id()),
1059 Some("pypi"),
1060 "{path} should match the requirements/*.txt directory pattern"
1061 );
1062 }
1063 }
1064
1065 #[test]
1066 fn test_get_for_uri_directory_pattern_requires_matching_directory_and_suffix() {
1067 let registry = pypi_pattern_registry();
1068 for path in [
1069 "/home/user/project/reqs/base.txt",
1071 "/home/user/project/requirements/base.cfg",
1073 "/home/user/project/requirements.txt",
1076 ] {
1077 let uri = crate::test_util::test_uri(path);
1078 if path.ends_with("requirements.txt") {
1079 assert_eq!(registry.get_for_uri(&uri).map(|e| e.id()), Some("pypi"));
1080 } else {
1081 assert!(
1082 registry.get_for_uri(&uri).is_none(),
1083 "{path} should not match the requirements/*.txt directory pattern"
1084 );
1085 }
1086 }
1087 }
1088
1089 #[test]
1090 fn test_get_for_uri_directory_pattern_requires_exact_directory_segment() {
1091 for path in [
1095 "/home/user/project/requirements.txt/base.txt",
1098 "/home/user/project/myrequirements/base.txt",
1101 ] {
1102 let uri = crate::test_util::test_uri(path);
1103 assert!(
1104 pypi_pattern_registry().get_for_uri(&uri).is_none(),
1105 "{path} should not match the requirements/*.txt directory pattern"
1106 );
1107 }
1108 }
1109
1110 #[test]
1111 fn test_get_for_uri_basename_pattern_wins_over_directory_pattern() {
1112 let registry = pypi_pattern_registry();
1116 let uri =
1117 crate::test_util::test_uri("/home/user/project/requirements/dev-requirements.txt");
1118 assert_eq!(registry.get_for_uri(&uri).map(|e| e.id()), Some("pypi"));
1119 }
1120
1121 #[test]
1122 fn test_multiple_ecosystems() {
1123 let registry = EcosystemRegistry::new();
1124
1125 let eco1 = Arc::new(MockEcosystem {
1126 id: "cargo",
1127 display_name: "Cargo",
1128 filenames: &["Cargo.toml"],
1129 lockfiles: &["Cargo.lock"],
1130 watched_configs: &[],
1131 });
1132
1133 let eco2 = Arc::new(MockEcosystem {
1134 id: "npm",
1135 display_name: "npm",
1136 filenames: &["package.json"],
1137 lockfiles: &["package-lock.json"],
1138 watched_configs: &[],
1139 });
1140
1141 registry.register(eco1);
1142 registry.register(eco2);
1143
1144 assert_eq!(registry.ecosystem_ids().len(), 2);
1145
1146 assert_eq!(
1147 registry.get_for_filename("Cargo.toml").unwrap().id(),
1148 "cargo"
1149 );
1150 assert_eq!(
1151 registry.get_for_filename("package.json").unwrap().id(),
1152 "npm"
1153 );
1154 }
1155
1156 #[test]
1157 fn test_get_for_lockfile() {
1158 let registry = EcosystemRegistry::new();
1159 let ecosystem = Arc::new(MockEcosystem {
1160 id: "cargo",
1161 display_name: "Cargo",
1162 filenames: &["Cargo.toml"],
1163 lockfiles: &["Cargo.lock"],
1164 watched_configs: &[],
1165 });
1166
1167 registry.register(ecosystem);
1168
1169 let retrieved = registry.get_for_lockfile("Cargo.lock").unwrap();
1170 assert_eq!(retrieved.id(), "cargo");
1171 assert_eq!(retrieved.display_name(), "Cargo");
1172
1173 assert!(registry.get_for_lockfile("unknown.lock").is_none());
1175 }
1176
1177 #[test]
1178 fn test_get_for_lockfile_multiple_lockfiles() {
1179 let registry = EcosystemRegistry::new();
1180 let ecosystem = Arc::new(MockEcosystem {
1181 id: "pypi",
1182 display_name: "PyPI",
1183 filenames: &["pyproject.toml"],
1184 lockfiles: &["poetry.lock", "uv.lock"],
1185 watched_configs: &[],
1186 });
1187
1188 registry.register(ecosystem);
1189
1190 let retrieved1 = registry.get_for_lockfile("poetry.lock").unwrap();
1191 assert_eq!(retrieved1.id(), "pypi");
1192
1193 let retrieved2 = registry.get_for_lockfile("uv.lock").unwrap();
1194 assert_eq!(retrieved2.id(), "pypi");
1195 }
1196
1197 #[test]
1203 fn test_get_for_lockfile_matches_wildcard_pattern() {
1204 let registry = EcosystemRegistry::new();
1205 let ecosystem = Arc::new(MockEcosystem {
1206 id: "nuget",
1207 display_name: "NuGet",
1208 filenames: &["Directory.Packages.props"],
1209 lockfiles: &["packages.lock.json", "packages.*.lock.json"],
1210 watched_configs: &[],
1211 });
1212
1213 registry.register(ecosystem);
1214
1215 assert_eq!(
1216 registry
1217 .get_for_lockfile("packages.App1.lock.json")
1218 .map(|e| e.id()),
1219 Some("nuget")
1220 );
1221 assert_eq!(
1222 registry
1223 .get_for_lockfile("packages.lock.json")
1224 .map(|e| e.id()),
1225 Some("nuget")
1226 );
1227 assert!(registry.get_for_lockfile("other.lock.json").is_none());
1228 assert!(registry.get_for_lockfile("packages.lock").is_none());
1230 }
1231
1232 #[test]
1233 fn test_all_lockfile_patterns_empty() {
1234 let registry = EcosystemRegistry::new();
1235 assert!(registry.all_lockfile_patterns().is_empty());
1236 }
1237
1238 #[test]
1239 fn test_all_lockfile_patterns_single_ecosystem() {
1240 let registry = EcosystemRegistry::new();
1241 let ecosystem = Arc::new(MockEcosystem {
1242 id: "cargo",
1243 display_name: "Cargo",
1244 filenames: &["Cargo.toml"],
1245 lockfiles: &["Cargo.lock"],
1246 watched_configs: &[],
1247 });
1248
1249 registry.register(ecosystem);
1250
1251 let patterns = registry.all_lockfile_patterns();
1252 assert_eq!(patterns.len(), 1);
1253 assert_eq!(patterns[0], "**/Cargo.lock");
1254 }
1255
1256 #[test]
1257 fn test_all_lockfile_patterns_multiple_ecosystems() {
1258 let registry = EcosystemRegistry::new();
1259
1260 let eco1 = Arc::new(MockEcosystem {
1261 id: "cargo",
1262 display_name: "Cargo",
1263 filenames: &["Cargo.toml"],
1264 lockfiles: &["Cargo.lock"],
1265 watched_configs: &[],
1266 });
1267
1268 let eco2 = Arc::new(MockEcosystem {
1269 id: "npm",
1270 display_name: "npm",
1271 filenames: &["package.json"],
1272 lockfiles: &["package-lock.json"],
1273 watched_configs: &[],
1274 });
1275
1276 let eco3 = Arc::new(MockEcosystem {
1277 id: "pypi",
1278 display_name: "PyPI",
1279 filenames: &["pyproject.toml"],
1280 lockfiles: &["poetry.lock", "uv.lock"],
1281 watched_configs: &[],
1282 });
1283
1284 registry.register(eco1);
1285 registry.register(eco2);
1286 registry.register(eco3);
1287
1288 let patterns = registry.all_lockfile_patterns();
1289 assert_eq!(patterns.len(), 4);
1290 assert!(patterns.contains(&"**/Cargo.lock".to_string()));
1291 assert!(patterns.contains(&"**/package-lock.json".to_string()));
1292 assert!(patterns.contains(&"**/poetry.lock".to_string()));
1293 assert!(patterns.contains(&"**/uv.lock".to_string()));
1294 }
1295
1296 #[test]
1297 fn test_all_lockfile_patterns_no_lockfiles() {
1298 let registry = EcosystemRegistry::new();
1299 let ecosystem = Arc::new(MockEcosystem {
1300 id: "test",
1301 display_name: "Test",
1302 filenames: &["test.toml"],
1303 lockfiles: &[],
1304 watched_configs: &[],
1305 });
1306
1307 registry.register(ecosystem);
1308
1309 let patterns = registry.all_lockfile_patterns();
1310 assert!(patterns.is_empty());
1311 }
1312
1313 #[test]
1314 fn test_get_for_watched_config() {
1315 let registry = EcosystemRegistry::new();
1316 let ecosystem = Arc::new(MockEcosystem {
1317 id: "npm",
1318 display_name: "npm",
1319 filenames: &["package.json"],
1320 lockfiles: &["package-lock.json"],
1321 watched_configs: &["pnpm-workspace.yaml", ".npmrc"],
1322 });
1323
1324 registry.register(ecosystem);
1325
1326 let retrieved = registry
1327 .get_for_watched_config("pnpm-workspace.yaml")
1328 .unwrap();
1329 assert_eq!(retrieved.id(), "npm");
1330 let retrieved = registry.get_for_watched_config(".npmrc").unwrap();
1331 assert_eq!(retrieved.id(), "npm");
1332
1333 assert!(
1335 registry
1336 .get_for_watched_config("package-lock.json")
1337 .is_none()
1338 );
1339 assert!(registry.get_for_watched_config("unknown.yaml").is_none());
1340 }
1341
1342 #[test]
1343 fn test_all_watched_config_patterns() {
1344 let registry = EcosystemRegistry::new();
1345 let ecosystem = Arc::new(MockEcosystem {
1346 id: "npm",
1347 display_name: "npm",
1348 filenames: &["package.json"],
1349 lockfiles: &["package-lock.json"],
1350 watched_configs: &["pnpm-workspace.yaml", ".npmrc"],
1351 });
1352
1353 registry.register(ecosystem);
1354
1355 let patterns = registry.all_watched_config_patterns();
1356 assert_eq!(patterns.len(), 2);
1357 assert!(patterns.contains(&"**/pnpm-workspace.yaml".to_string()));
1358 assert!(patterns.contains(&"**/.npmrc".to_string()));
1359
1360 assert_eq!(
1362 registry.all_lockfile_patterns(),
1363 vec!["**/package-lock.json"]
1364 );
1365 }
1366
1367 #[test]
1368 fn test_all_watched_config_patterns_empty() {
1369 let registry = EcosystemRegistry::new();
1370 assert!(registry.all_watched_config_patterns().is_empty());
1371 }
1372
1373 #[test]
1374 fn test_get_for_filename_extension_fallback() {
1375 let registry = EcosystemRegistry::new();
1376 registry.register(Arc::new(MockExtEcosystem {
1377 id: "nuget",
1378 filenames: &["Directory.Packages.props"],
1379 extensions: &[".csproj", ".fsproj"],
1380 }));
1381
1382 assert_eq!(
1383 registry.get_for_filename("MyApp.csproj").unwrap().id(),
1384 "nuget"
1385 );
1386 assert_eq!(
1387 registry
1388 .get_for_filename("Directory.Packages.props")
1389 .unwrap()
1390 .id(),
1391 "nuget"
1392 );
1393 assert!(registry.get_for_filename("unrelated.txt").is_none());
1394 }
1395
1396 #[test]
1397 fn test_get_for_filename_extension_fallback_case_insensitive() {
1398 let registry = EcosystemRegistry::new();
1399 registry.register(Arc::new(MockExtEcosystem {
1400 id: "nuget",
1401 filenames: &["Directory.Packages.props"],
1402 extensions: &[".csproj"],
1403 }));
1404
1405 assert_eq!(
1406 registry.get_for_filename("MyApp.CSPROJ").unwrap().id(),
1407 "nuget"
1408 );
1409 }
1410
1411 #[test]
1412 fn test_get_for_filename_exact_match_case_sensitive_not_shadowed_by_extension() {
1413 let registry = EcosystemRegistry::new();
1414 registry.register(Arc::new(MockExtEcosystem {
1415 id: "nuget",
1416 filenames: &["packages.config"],
1417 extensions: &[],
1418 }));
1419
1420 assert!(registry.get_for_filename("packages.Config").is_none());
1422 }
1423
1424 #[test]
1425 fn test_get_for_filename_no_extension_returns_none() {
1426 let registry = EcosystemRegistry::new();
1427 registry.register(Arc::new(MockExtEcosystem {
1428 id: "nuget",
1429 filenames: &[],
1430 extensions: &[".csproj"],
1431 }));
1432
1433 assert!(registry.get_for_filename("README").is_none());
1434 }
1435}