Skip to main content

deps_core/
ecosystem_registry.rs

1use dashmap::DashMap;
2use std::sync::Arc;
3use tower_lsp_server::ls_types::Uri;
4
5use crate::Ecosystem;
6
7/// Registry for all available ecosystems.
8///
9/// This registry manages ecosystem implementations and provides fast lookup
10/// by ecosystem ID or manifest filename. It's designed for thread-safe
11/// concurrent access using DashMap.
12///
13/// # Examples
14///
15/// ```no_run
16/// use deps_core::EcosystemRegistry;
17/// use std::sync::Arc;
18///
19/// let registry = EcosystemRegistry::new();
20///
21/// // Register ecosystems (would be actual implementations)
22/// // registry.register(Arc::new(CargoEcosystem::new(cache.clone())));
23/// // registry.register(Arc::new(NpmEcosystem::new(cache.clone())));
24///
25/// // Look up by filename
26/// if let Some(ecosystem) = registry.get_for_filename("Cargo.toml") {
27///     println!("Found ecosystem: {}", ecosystem.display_name());
28/// }
29///
30/// // List all registered ecosystems
31/// for id in registry.ecosystem_ids() {
32///     println!("Registered: {}", id);
33/// }
34/// ```
35pub struct EcosystemRegistry {
36    /// Map from ecosystem ID to implementation
37    ecosystems: DashMap<&'static str, Arc<dyn Ecosystem>>,
38    /// Map from filename to ecosystem ID (for fast lookup)
39    filename_map: DashMap<&'static str, &'static str>,
40    /// Map from lowercased file extension (e.g. ".csproj") to ecosystem ID.
41    ///
42    /// Consulted only when an exact `filename_map` lookup misses. Kept as a
43    /// separate `DashMap` rather than folded into `filename_map` so extension
44    /// routing cannot silently shadow an exact filename. Two ecosystems
45    /// claiming the same extension is a configuration error caught by a
46    /// `debug_assert!` in [`register`](EcosystemRegistry::register) — in a
47    /// release build (or if the assertion is otherwise skipped) `DashMap::insert`
48    /// silently last-write-wins, so the outcome is registration-order-dependent,
49    /// not deterministic.
50    extension_map: DashMap<&'static str, &'static str>,
51    /// Map from a basename pattern (e.g. `"requirements*.txt"`) to
52    /// `(prefix, suffix, ecosystem_id)`, split on the pattern's single `*` at
53    /// [`register`](EcosystemRegistry::register) time. Consulted between the
54    /// exact-filename and extension stages in
55    /// [`get_for_filename`](EcosystemRegistry::get_for_filename), using
56    /// most-specific-wins selection over all matches for determinism
57    /// regardless of `DashMap` iteration order.
58    patterns: DashMap<&'static str, (&'static str, &'static str, &'static str)>,
59}
60
61impl EcosystemRegistry {
62    /// Create a new empty registry
63    ///
64    /// # Examples
65    ///
66    /// ```
67    /// use deps_core::EcosystemRegistry;
68    ///
69    /// let registry = EcosystemRegistry::new();
70    /// assert_eq!(registry.ecosystem_ids().len(), 0);
71    /// ```
72    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    /// Register an ecosystem implementation
82    ///
83    /// This method registers the ecosystem and creates filename mappings
84    /// for all manifest filenames declared by the ecosystem.
85    ///
86    /// # Arguments
87    ///
88    /// * `ecosystem` - Arc-wrapped ecosystem implementation
89    ///
90    /// # Examples
91    ///
92    /// ```no_run
93    /// use deps_core::EcosystemRegistry;
94    /// use std::sync::Arc;
95    ///
96    /// let registry = EcosystemRegistry::new();
97    /// // registry.register(Arc::new(CargoEcosystem::new(cache)));
98    /// ```
99    pub fn register(&self, ecosystem: Arc<dyn Ecosystem>) {
100        let id = ecosystem.id();
101
102        // Register filename mappings
103        for filename in ecosystem.manifest_filenames() {
104            self.filename_map.insert(*filename, id);
105        }
106
107        // Register extension mappings (fallback for unbounded basenames, e.g. *.csproj)
108        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        // Register pattern mappings (e.g. `requirements*.txt`)
120        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        // Register ecosystem
148        self.ecosystems.insert(id, ecosystem);
149    }
150
151    /// Get ecosystem by ID
152    ///
153    /// # Arguments
154    ///
155    /// * `id` - Ecosystem identifier (e.g., "cargo", "npm", "pypi")
156    ///
157    /// # Returns
158    ///
159    /// * `Some(Arc<dyn Ecosystem>)` - Registered ecosystem
160    /// * `None` - No ecosystem registered with this ID
161    ///
162    /// # Examples
163    ///
164    /// ```no_run
165    /// use deps_core::EcosystemRegistry;
166    ///
167    /// let registry = EcosystemRegistry::new();
168    /// if let Some(ecosystem) = registry.get("cargo") {
169    ///     println!("Found: {}", ecosystem.display_name());
170    /// }
171    /// ```
172    pub fn get(&self, id: &str) -> Option<Arc<dyn Ecosystem>> {
173        self.ecosystems.get(id).map(|e| Arc::clone(&e))
174    }
175
176    /// Get ecosystem for a filename
177    ///
178    /// Lookup is two-stage: an exact, case-sensitive match against
179    /// registered manifest filenames (e.g. `"Cargo.toml"`) is tried first;
180    /// if that misses, the filename's extension is matched case-insensitively
181    /// against registered [`Ecosystem::manifest_extensions`]. This asymmetry
182    /// is deliberate — exact filenames are canonical and case-sensitive on
183    /// Unix filesystems, while extensions on unbounded basenames (e.g.
184    /// `*.csproj`) come from MSBuild/Windows projects that commonly vary
185    /// case (`MyApp.CSPROJ`). So `MyApp.CSPROJ` routes via the extension
186    /// fallback, but `packages.Config` does **not** match the exact-name
187    /// entry for `packages.config`.
188    ///
189    /// # Arguments
190    ///
191    /// * `filename` - Manifest filename (e.g., "Cargo.toml", "package.json")
192    ///
193    /// # Returns
194    ///
195    /// * `Some(Arc<dyn Ecosystem>)` - Ecosystem handling this filename
196    /// * `None` - No ecosystem handles this filename
197    ///
198    /// # Examples
199    ///
200    /// ```no_run
201    /// use deps_core::EcosystemRegistry;
202    ///
203    /// let registry = EcosystemRegistry::new();
204    /// if let Some(ecosystem) = registry.get_for_filename("Cargo.toml") {
205    ///     println!("Cargo.toml handled by: {}", ecosystem.display_name());
206    /// }
207    /// ```
208    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        // Avoid the rsplit_once/format! allocation below when no ecosystem has registered
218        // an extension (extension routing is only used by nuget today) — this runs on
219        // every exact-match miss, including every did_open/did_change via get_for_uri.
220        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    /// Matches `filename` against registered [`Ecosystem::manifest_patterns`],
231    /// case-sensitively, returning the ecosystem id of the most specific
232    /// match (greatest `prefix.len() + suffix.len()`, ties broken by pattern
233    /// string ascending) rather than the first `DashMap` hit — so the result
234    /// is deterministic regardless of map iteration order.
235    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; // (specificity, pattern, id)
241        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    /// Get ecosystem from URI
256    ///
257    /// Extracts the filename from the URI path and looks up the ecosystem.
258    ///
259    /// # Arguments
260    ///
261    /// * `uri` - Document URI (file:///path/to/Cargo.toml)
262    ///
263    /// # Returns
264    ///
265    /// * `Some(Arc<dyn Ecosystem>)` - Ecosystem handling this file
266    /// * `None` - No ecosystem handles this file type or URI parsing failed
267    ///
268    /// # Examples
269    ///
270    /// ```no_run
271    /// use deps_core::EcosystemRegistry;
272    /// use tower_lsp_server::ls_types::Uri;
273    ///
274    /// let registry = EcosystemRegistry::new();
275    /// let uri = Uri::from_file_path("/home/user/project/Cargo.toml").unwrap();
276    ///
277    /// if let Some(ecosystem) = registry.get_for_uri(&uri) {
278    ///     println!("File handled by: {}", ecosystem.display_name());
279    /// }
280    /// ```
281    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    /// Matches a file whose containing directory path (tail) and file suffix are
291    /// declared by an ecosystem's
292    /// [`manifest_directory_patterns`](Ecosystem::manifest_directory_patterns) — e.g.
293    /// Python's single-segment `requirements/base.txt` layout, or GitHub Actions'
294    /// multi-segment `.github/workflows/ci.yml` layout, where the basename alone
295    /// carries no ecosystem signal. This needs the full path, not just the basename,
296    /// so unlike [`manifest_patterns`](Ecosystem::manifest_patterns) it is consulted
297    /// only from [`get_for_uri`](Self::get_for_uri), never from
298    /// [`get_for_filename`](Self::get_for_filename). Mirrors
299    /// [`get_for_lockfile`](Self::get_for_lockfile)'s linear scan rather than
300    /// building a dedicated map — the pattern count per ecosystem is tiny.
301    ///
302    /// The scan is `DashMap`-iteration-order-dependent when two ecosystems' patterns
303    /// could both match the same path; today's registered patterns (PyPI's
304    /// `requirements`, GitHub Actions' `.github/workflows`) are disjoint, so this is
305    /// deterministic in practice, but a future third owner introducing an overlapping
306    /// pattern would need its own disambiguation, not silent first-match-wins.
307    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    /// Get all registered ecosystem IDs
325    ///
326    /// Returns a vector of all ecosystem IDs currently registered.
327    /// This is useful for debugging and listing available ecosystems.
328    ///
329    /// # Returns
330    ///
331    /// Vector of ecosystem ID strings
332    ///
333    /// # Examples
334    ///
335    /// ```no_run
336    /// use deps_core::EcosystemRegistry;
337    ///
338    /// let registry = EcosystemRegistry::new();
339    /// // registry.register(cargo_ecosystem);
340    /// // registry.register(npm_ecosystem);
341    ///
342    /// for id in registry.ecosystem_ids() {
343    ///     println!("Registered ecosystem: {}", id);
344    /// }
345    /// ```
346    pub fn ecosystem_ids(&self) -> Vec<&'static str> {
347        self.ecosystems.iter().map(|e| *e.key()).collect()
348    }
349
350    /// Get ecosystem for a lock file name
351    ///
352    /// An entry in [`Ecosystem::lockfile_filenames`] is either an exact name
353    /// (`"Cargo.lock"`) or a single-`*`-wildcard pattern
354    /// (`"packages.*.lock.json"`, NuGet's per-project lock files) — the same
355    /// prefix/suffix scheme [`Ecosystem::manifest_patterns`] uses, applied
356    /// here via a linear scan (mirroring `get_for_directory_pattern`'s own linear scan)
357    /// rather than a dedicated map, since the pattern count per ecosystem is tiny.
358    ///
359    /// # Arguments
360    ///
361    /// * `filename` - Lock file name (e.g., "Cargo.lock", "package-lock.json")
362    ///
363    /// # Returns
364    ///
365    /// * `Some(Arc<dyn Ecosystem>)` - Ecosystem using this lock file
366    /// * `None` - No ecosystem uses this lock file
367    ///
368    /// # Examples
369    ///
370    /// ```no_run
371    /// use deps_core::EcosystemRegistry;
372    ///
373    /// let registry = EcosystemRegistry::new();
374    /// // registry.register(cargo_ecosystem);
375    ///
376    /// if let Some(ecosystem) = registry.get_for_lockfile("Cargo.lock") {
377    ///     println!("Cargo.lock handled by: {}", ecosystem.display_name());
378    /// }
379    /// ```
380    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    /// Get all lock file patterns for file watching
395    ///
396    /// Returns glob patterns (e.g., "**/Cargo.lock") for all registered ecosystems.
397    ///
398    /// # Examples
399    ///
400    /// ```no_run
401    /// use deps_core::EcosystemRegistry;
402    ///
403    /// let registry = EcosystemRegistry::new();
404    /// // registry.register(cargo_ecosystem);
405    /// // registry.register(npm_ecosystem);
406    ///
407    /// let patterns = registry.all_lockfile_patterns();
408    /// for pattern in patterns {
409    ///     println!("Watching pattern: {}", pattern);
410    /// }
411    /// ```
412    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    /// Get ecosystem for a [`Ecosystem::watched_config_filenames`] entry — mirrors
424    /// [`Self::get_for_lockfile`] exactly, reusing the same exact/single-`*`-wildcard
425    /// matching, but scanning the *config* list instead of the lockfile one.
426    ///
427    /// # Examples
428    ///
429    /// ```no_run
430    /// use deps_core::EcosystemRegistry;
431    ///
432    /// let registry = EcosystemRegistry::new();
433    /// // registry.register(npm_ecosystem);
434    ///
435    /// if let Some(ecosystem) = registry.get_for_watched_config("pnpm-workspace.yaml") {
436    ///     println!("pnpm-workspace.yaml handled by: {}", ecosystem.display_name());
437    /// }
438    /// ```
439    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    /// Get all [`Ecosystem::watched_config_filenames`] glob patterns for file watching —
454    /// mirrors [`Self::all_lockfile_patterns`], scanning the *config* list instead.
455    ///
456    /// # Examples
457    ///
458    /// ```no_run
459    /// use deps_core::EcosystemRegistry;
460    ///
461    /// let registry = EcosystemRegistry::new();
462    /// // registry.register(npm_ecosystem);
463    ///
464    /// let patterns = registry.all_watched_config_patterns();
465    /// for pattern in patterns {
466    ///     println!("Watching pattern: {}", pattern);
467    /// }
468    /// ```
469    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
487/// Matches a lock file `filename` against one [`Ecosystem::lockfile_filenames`] entry: an
488/// exact name, or — for a NuGet-style `packages.*.lock.json` entry — a single-`*`
489/// prefix/suffix pattern, the same scheme [`EcosystemRegistry::register`] uses to split
490/// `manifest_patterns` (S2, #451 follow-up).
491fn 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
498/// Core single-`*`-wildcard glob check shared by every basename-pattern matcher in this
499/// module: `filename` matches when it is at least as long as `prefix` and `suffix`
500/// combined, starts with `prefix`, and ends with `suffix`.
501fn 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
507/// Whether `filename`'s containing directory path, relative to any ancestor, ends
508/// exactly at `dir_pattern` on a `/`-segment boundary, and `filename` itself ends
509/// with `suffix`.
510///
511/// `dir_pattern` may be a single segment (PyPI's `"requirements"`, matching any
512/// `.../requirements/base.txt`) or multiple segments joined by `/` (GitHub Actions'
513/// `".github/workflows"`, matching any `.../.github/workflows/ci.yml`) — the
514/// single-segment case is just the multi-segment rule applied to a one-element path,
515/// so both share this one matcher rather than PyPI keeping a separate
516/// immediate-parent-only check.
517///
518/// `path` is the full URI path (e.g. `/home/user/project/.github/workflows/ci.yml`);
519/// `filename` is its basename, passed separately so the caller (which already split
520/// it off) does not force a second basename computation here.
521fn 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/// Whether `filename` matches a raw [`Ecosystem::manifest_patterns`] entry (e.g.
537/// `"requirements*.txt"`).
538///
539/// Applies the same single-`*`-wildcard semantics
540/// [`EcosystemRegistry::register`]/[`EcosystemRegistry::get_for_filename`] use
541/// internally — but as a stateless, registry-free check.
542///
543/// Exposed so an ecosystem can ask "would my own `manifest_patterns` have matched this
544/// basename?" independently of an `EcosystemRegistry` instance — e.g. to gate a
545/// [`Ecosystem::manifest_directory_patterns`]-only match more strictly than a primary
546/// basename match (#452 S6): a file routed to an ecosystem purely by directory-name
547/// convention carries far weaker "this really is a manifest" evidence than one that
548/// also matches a basename pattern.
549///
550/// Returns `false` for a malformed `pattern` (no `*`, or more than one) rather than
551/// panicking — callers pass a `&'static str` they authored themselves, so this is a
552/// defensive fallback, not an expected runtime path.
553///
554/// # Examples
555///
556/// ```
557/// use deps_core::ecosystem_registry::manifest_pattern_matches;
558///
559/// assert!(manifest_pattern_matches("requirements-dev.txt", "requirements*.txt"));
560/// assert!(!manifest_pattern_matches("base.txt", "requirements*.txt"));
561/// ```
562#[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    // Mock ecosystem for testing
611    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    // Mock ecosystem with unbounded-basename extension routing (mirrors NuGet's *.csproj)
674    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    // Mock ecosystem with basename patterns (mirrors PyPI's `requirements*.txt`)
731    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    /// D1 regression fixture: a multi-segment `manifest_directory_patterns` entry
807    /// (`.github/workflows`), the shape a single-segment directory pattern like
808    /// PyPI's `requirements` could never express.
809    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            // Missing the `.github` segment entirely.
854            "/repo/workflows/x.yml",
855            // Missing the `workflows` segment.
856            "/repo/.github/x.yml",
857            // A directory that merely ends with the pattern as a substring, not on a
858            // segment boundary — mirrors PyPI's `myrequirements` regression guard.
859            "/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        // Matches both `requirements*.txt` (score 16) and `*.requirements.txt`
954        // (score 17) — the longer, more specific pattern must win.
955        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            // Wrong directory name.
1070            "/home/user/project/reqs/base.txt",
1071            // Right directory, wrong suffix.
1072            "/home/user/project/requirements/base.cfg",
1073            // Bare `requirements.txt` at the top level is not a directory match
1074            // (it's already handled by the basename pattern stage).
1075            "/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        // Only a path segment that is *exactly* "requirements" counts — a
1092        // directory that merely contains that string as a substring must not
1093        // match (#452 S6 follow-up, confirmed by impl-critic).
1094        for path in [
1095            // The parent "directory" here is itself a file named
1096            // `requirements.txt`, not a directory literally named `requirements`.
1097            "/home/user/project/requirements.txt/base.txt",
1098            // "myrequirements" contains "requirements" as a substring but is not
1099            // an exact match.
1100            "/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        // `requirements/dev-requirements.txt` matches the basename pattern stage
1113        // (`*-requirements.txt`) — the directory-pattern fallback must never be
1114        // reached, let alone override it.
1115        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        // Unknown lockfile should return None
1174        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    /// S2 regression (#451 follow-up): a single-`*`-wildcard `lockfile_filenames()` entry
1198    /// (NuGet's `"packages.*.lock.json"`, registered only so `all_lockfile_patterns()` sets
1199    /// up a file watcher) must actually route a real multi-project lock filename through
1200    /// `get_for_lockfile` — this is what `did_change_watched_files` calls to find the owning
1201    /// ecosystem for a changed lock file.
1202    #[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        // Too short to contain both the prefix and the suffix.
1229        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        // A lockfile is not a watched config, and vice versa.
1334        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        // Lockfile patterns are a disjoint set, unaffected by watched-config registration.
1361        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        // Exact filenames stay case-sensitive: differently-cased basename does not match.
1421        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}