Skip to main content

cgx_core/
crate_resolver.rs

1use std::{
2    path::{Path, PathBuf},
3    sync::Arc,
4};
5
6use semver::{Version, VersionReq};
7use serde::{Deserialize, Serialize};
8use sha2::{Digest, Sha256};
9use snafu::OptionExt;
10
11use crate::{
12    Result,
13    cache::Cache,
14    cargo::{CargoMetadataOptions, CargoRunner},
15    config::Config,
16    cratespec::{CrateSpec, Forge, RegistrySource},
17    error,
18    git::{GitClient, GitSelector},
19    http::HttpClient,
20    registry::RegistryClient,
21};
22
23/// A resolved crate represents a concrete, validated reference to a specific crate version.
24///
25/// Unlike [`CrateSpec`], which may contain ambiguous information
26/// (like version requirements or missing crate names), a [`ResolvedCrate`] always contains:
27/// - An exact crate name
28/// - An exact version (not a version requirement)
29/// - A validated source location that is known to exist at the time of resolution
30///
31/// This type is the result of resolving a [`CrateSpec`].
32#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
33pub struct ResolvedCrate {
34    /// The exact name of the crate
35    pub name: String,
36
37    /// The exact version of the crate
38    pub version: Version,
39
40    /// The source location where this crate was found
41    pub source: ResolvedSource,
42}
43
44/// Abstract interface for resolving a (potentially ambiguous, potentially invalid) [`CrateSpec`]
45/// to a concrete, validated [`ResolvedCrate`].
46///
47/// The trait abstraction is important to allow thorough testing of the many edge cases and failure
48/// modes involved.
49pub trait CrateResolver: std::fmt::Debug + Send + Sync + 'static {
50    /// Resolve a (potentially ambiguous, potentially invalid) [`CrateSpec`] to a concrete,
51    /// validated [`ResolvedCrate`].
52    ///
53    /// This involves:
54    /// - Validating the crate specification
55    /// - Querying remote registries or repositories as needed
56    /// - Ensuring that the specified version (if any) is compatible with the found version
57    ///
58    /// # Errors
59    ///
60    /// Returns an error if the crate specification is invalid, if the crate cannot be found,
61    /// or if the specified version is not compatible with the found version.
62    fn resolve(&self, spec: &CrateSpec) -> Result<ResolvedCrate>;
63}
64
65/// The source location of a resolved crate.
66///
67/// Unlike [`CrateSpec`] variants, which may contain ambiguous
68/// selectors (like branch names or tags), [`ResolvedSource`] variants contain only concrete,
69/// immutable references (like commit hashes).
70#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
71#[serde(rename_all = "snake_case")]
72pub enum ResolvedSource {
73    /// A crate from Crates.io
74    CratesIo,
75
76    /// A crate from another registry
77    Registry {
78        /// The registry source (named registry or index URL)
79        source: RegistrySource,
80    },
81
82    /// A crate from a git repository
83    Git {
84        /// The repository URL
85        repo: String,
86
87        /// The exact commit hash (not a branch, tag, or other selector)
88        commit: String,
89    },
90
91    /// A crate from a software forge (GitHub, GitLab, etc.)
92    Forge {
93        /// The forge where the crate is hosted
94        forge: Forge,
95
96        /// The exact commit hash (not a branch, tag, or other selector)
97        commit: String,
98    },
99
100    /// A crate from a local directory
101    LocalDir {
102        /// The path to the directory containing the crate
103        path: PathBuf,
104    },
105}
106
107impl ResolvedSource {
108    /// Compute the stable hash used to distinguish resolved crate sources in cache paths.
109    pub(crate) fn source_hash(&self) -> String {
110        let json = serde_json::to_string(self).expect("BUG: serializing a ResolvedSource cannot fail");
111        let mut hasher = Sha256::new();
112        hasher.update(json.as_bytes());
113        crate::helpers::format_hex_lower(hasher.finalize())
114    }
115}
116
117/// Create the default [`CrateResolver`] implementation, respecting the given config and using the
118/// provided cache.
119pub(crate) fn create_resolver(
120    config: Config,
121    cache: Cache,
122    git_client: GitClient,
123    cargo: Arc<dyn CargoRunner>,
124    http_client: HttpClient,
125) -> impl CrateResolver {
126    let inner = DefaultCrateResolver::new(config, git_client, cargo, http_client);
127    CachingResolver::new(inner, cache)
128}
129
130/// Default implementation of [`CrateResolver`] that performs actual network requests
131/// and file system operations to resolve crate specifications.
132#[derive(Debug, Clone)]
133struct DefaultCrateResolver {
134    config: Config,
135    git_client: GitClient,
136    cargo: Arc<dyn CargoRunner>,
137    http_client: HttpClient,
138}
139
140impl DefaultCrateResolver {
141    /// Create a new [`DefaultCrateResolver`] with the given configuration and git client.
142    pub(crate) fn new(
143        config: Config,
144        git_client: GitClient,
145        cargo: Arc<dyn CargoRunner>,
146        http_client: HttpClient,
147    ) -> Self {
148        Self {
149            config,
150            git_client,
151            cargo,
152            http_client,
153        }
154    }
155
156    /// Resolve a local directory crate specification.
157    fn resolve_local_dir(
158        &self,
159        path: &Path,
160        name: &Option<String>,
161        version: &Option<VersionReq>,
162    ) -> Result<ResolvedCrate> {
163        let metadata = self.cargo.metadata(
164            path,
165            &CargoMetadataOptions {
166                no_deps: true,
167                ..Default::default()
168            },
169        )?;
170
171        let package = if let Some(n) = name {
172            metadata
173                .packages
174                .iter()
175                .find(|p| p.name.as_str() == n)
176                .with_context(|| error::PackageNotFoundInWorkspaceSnafu {
177                    name: n.clone(),
178                    available: metadata
179                        .packages
180                        .iter()
181                        .map(|p| p.name.to_string())
182                        .collect::<Vec<_>>(),
183                })?
184        } else {
185            if metadata.packages.len() != 1 {
186                return error::AmbiguousPackageNameSnafu {
187                    count: metadata.packages.len(),
188                }
189                .fail();
190            }
191            &metadata.packages[0]
192        };
193
194        if let Some(req) = version {
195            if !req.matches(&package.version) {
196                return error::VersionMismatchSnafu {
197                    requirement: req.to_string(),
198                    found: package.version.clone(),
199                }
200                .fail();
201            }
202        }
203
204        Ok(ResolvedCrate {
205            name: package.name.to_string(),
206            version: package.version.clone(),
207            source: ResolvedSource::LocalDir {
208                path: path.to_path_buf(),
209            },
210        })
211    }
212
213    /// Resolve a registry crate specification.
214    ///
215    /// `source` is `None` to indicate the default (crates.io) registry.
216    fn resolve_registry(
217        &self,
218        name: &str,
219        version: Option<&VersionReq>,
220        source: Option<&RegistrySource>,
221    ) -> Result<ResolvedCrate> {
222        // There is always some VersionReq; if not specified explicitly then "*" is implied
223        let version = version.cloned().unwrap_or(VersionReq::STAR);
224        let registry = RegistryClient::new(source, &self.http_client, &self.config.http)?;
225        let versions = match registry.crate_versions(name, self.config.offline)? {
226            Some(versions) => versions,
227            None if self.config.offline => {
228                return error::OfflineModeSnafu {
229                    name: name.to_string(),
230                    version: version.to_string(),
231                }
232                .fail();
233            }
234            None => {
235                return error::CrateNotFoundInRegistrySnafu {
236                    name: name.to_string(),
237                }
238                .fail();
239            }
240        };
241
242        // Filter non-yanked versions matching the requirement and select the best, by which
243        // we mean the highest version number.
244        let best_version = versions
245            .iter()
246            .filter(|v| !v.yanked)
247            .filter_map(|v| {
248                Version::parse(&v.version)
249                    .ok()
250                    .filter(|ver| version.matches(ver))
251                    .map(|ver| (v.version.clone(), ver))
252            })
253            .max_by(|(_, a), (_, b)| a.cmp(b))
254            .map(|(_, best)| best)
255            .with_context(|| error::NoMatchingVersionSnafu {
256                name: name.to_string(),
257                requirement: version.to_string(),
258            })?;
259
260        // Record the resolved source which we store alongside the crate, as we will still need
261        // to retrieve the crate contents at some point later.
262        let resolved_source = match source {
263            None => ResolvedSource::CratesIo,
264            Some(custom_registry) => ResolvedSource::Registry {
265                source: custom_registry.clone(),
266            },
267        };
268
269        Ok(ResolvedCrate {
270            name: name.to_string(),
271            version: best_version,
272            source: resolved_source,
273        })
274    }
275
276    /// Resolve a git repository crate specification.
277    fn resolve_git(
278        &self,
279        repo: &str,
280        selector: &GitSelector,
281        name: &Option<String>,
282        version: &Option<VersionReq>,
283    ) -> Result<ResolvedCrate> {
284        // Checkout using git client (returns cached checkout path and commit hash)
285        let (checkout_path, commit_hash) = self.git_client.checkout_ref(repo, selector.clone())?;
286
287        // Use cargo_metadata to read the crate info
288        let metadata = self.cargo.metadata(
289            &checkout_path,
290            &CargoMetadataOptions {
291                no_deps: true,
292                ..Default::default()
293            },
294        )?;
295
296        let package = if let Some(n) = name {
297            metadata
298                .packages
299                .iter()
300                .find(|p| p.name.as_str() == n)
301                .with_context(|| error::PackageNotFoundInWorkspaceSnafu {
302                    name: n.clone(),
303                    available: metadata
304                        .packages
305                        .iter()
306                        .map(|p| p.name.to_string())
307                        .collect::<Vec<_>>(),
308                })?
309        } else {
310            if metadata.packages.len() != 1 {
311                return error::AmbiguousPackageNameSnafu {
312                    count: metadata.packages.len(),
313                }
314                .fail();
315            }
316            &metadata.packages[0]
317        };
318
319        if let Some(req) = version {
320            if !req.matches(&package.version) {
321                return error::VersionMismatchSnafu {
322                    requirement: req.to_string(),
323                    found: package.version.clone(),
324                }
325                .fail();
326            }
327        }
328
329        Ok(ResolvedCrate {
330            name: package.name.to_string(),
331            version: package.version.clone(),
332            source: ResolvedSource::Git {
333                repo: repo.to_string(),
334                commit: commit_hash,
335            },
336        })
337    }
338
339    /// Resolve a forge (GitHub, GitLab, etc.) crate specification.
340    fn resolve_forge(
341        &self,
342        forge: &Forge,
343        selector: &GitSelector,
344        name: &Option<String>,
345        version: &Option<VersionReq>,
346    ) -> Result<ResolvedCrate> {
347        // Convert Forge to git URL
348        let git_url = forge.git_url();
349
350        // Resolve using git resolution logic
351        let mut resolved = self.resolve_git(&git_url, selector, name, version)?;
352
353        // Replace the source with Forge instead of Git
354        if let ResolvedSource::Git { commit, .. } = resolved.source {
355            resolved.source = ResolvedSource::Forge {
356                forge: forge.clone(),
357                commit,
358            };
359        } else {
360            unreachable!("BUG: Expected ResolvedSource::Git from resolve_git");
361        }
362
363        Ok(resolved)
364    }
365}
366
367impl CrateResolver for DefaultCrateResolver {
368    fn resolve(&self, spec: &CrateSpec) -> Result<ResolvedCrate> {
369        match spec {
370            CrateSpec::CratesIo { name, version } => self.resolve_registry(name, version.as_ref(), None),
371            CrateSpec::Registry {
372                source,
373                name,
374                version,
375            } => self.resolve_registry(name, version.as_ref(), Some(source)),
376            CrateSpec::Git {
377                repo,
378                selector,
379                name,
380                version,
381            } => self.resolve_git(repo, selector, name, version),
382            CrateSpec::Forge {
383                forge,
384                selector,
385                name,
386                version,
387            } => self.resolve_forge(forge, selector, name, version),
388            CrateSpec::LocalDir { path, name, version } => self.resolve_local_dir(path, name, version),
389        }
390    }
391}
392
393/// A caching wrapper around any [`CrateResolver`] implementation.
394///
395/// This resolver adds a caching layer on top of an inner resolver, storing resolutions
396/// in a cache and using them to avoid unnecessary network requests. It also implements
397/// resilient behavior like falling back to stale cache entries when network errors occur.
398#[derive(Debug)]
399pub(crate) struct CachingResolver<R: CrateResolver> {
400    inner: R,
401    cache: Cache,
402}
403
404impl<R: CrateResolver> CachingResolver<R> {
405    /// Create a new [`CachingResolver`] that wraps the given inner resolver.
406    pub(crate) fn new(inner: R, cache: Cache) -> Self {
407        Self { inner, cache }
408    }
409}
410
411impl<R: CrateResolver> CrateResolver for CachingResolver<R> {
412    fn resolve(&self, spec: &CrateSpec) -> Result<ResolvedCrate> {
413        if matches!(spec, CrateSpec::LocalDir { .. }) {
414            return self.inner.resolve(spec);
415        }
416
417        self.cache.get_or_resolve_crate(spec, || self.inner.resolve(spec))
418    }
419}
420
421#[cfg(test)]
422mod tests {
423    use std::time::Duration;
424
425    use assert_matches::assert_matches;
426
427    use super::*;
428    use crate::testdata::CrateTestCase;
429
430    /// Create a test resolver with online config and an isolated temp directory.
431    ///
432    /// Returns the resolver and the `TempDir` which must be kept alive for the test duration.
433    fn test_resolver() -> (CachingResolver<DefaultCrateResolver>, tempfile::TempDir) {
434        crate::logging::init_test_logging();
435
436        let (temp_dir, config) = crate::config::create_test_env();
437        let reporter = crate::messages::MessageReporter::null();
438        let cache = Cache::new(config.clone(), reporter.clone());
439        let git_client = GitClient::new(cache.clone(), reporter.clone(), config.http.clone());
440        let http_client = HttpClient::new(&config.http).unwrap();
441        let resolver = DefaultCrateResolver::new(
442            config.clone(),
443            git_client,
444            Arc::new(crate::cargo::create_cargo_runner(config.clone(), reporter).unwrap()),
445            http_client,
446        );
447        (CachingResolver::new(resolver, cache), temp_dir)
448    }
449
450    /// Create a test resolver with offline config and an isolated temp directory.
451    fn test_resolver_offline() -> (CachingResolver<DefaultCrateResolver>, tempfile::TempDir) {
452        let (resolver, temp_dir) = test_resolver();
453        let mut config = resolver.inner.config;
454        config.offline = true;
455        let reporter = crate::messages::MessageReporter::null();
456        let cache = Cache::new(config.clone(), reporter.clone());
457        let git_client = GitClient::new(cache.clone(), reporter, config.http.clone());
458        let http_client = HttpClient::new(&config.http).unwrap();
459        let resolver = DefaultCrateResolver::new(config, git_client, resolver.inner.cargo, http_client);
460        (CachingResolver::new(resolver, cache), temp_dir)
461    }
462
463    /// Exercise resolving `LocalDir` crate specs using test cases from testdata/.
464    mod local_dir {
465        use super::*;
466        use crate::error::Error;
467
468        #[test]
469        fn single_package_auto_name() {
470            let (resolver, _temp_dir) = test_resolver();
471            let testcase = CrateTestCase::simple_bin_no_deps();
472
473            let spec = CrateSpec::LocalDir {
474                path: testcase.path().to_path_buf(),
475                name: None,
476                version: None,
477            };
478
479            let resolved = resolver.resolve(&spec).unwrap();
480            assert_eq!(resolved.name, "simple-bin-no-deps");
481            assert_matches!(resolved.source, ResolvedSource::LocalDir { .. });
482        }
483
484        #[test]
485        fn single_package_explicit_name() {
486            let (resolver, _temp_dir) = test_resolver();
487            let testcase = CrateTestCase::simple_bin_no_deps();
488
489            let spec = CrateSpec::LocalDir {
490                path: testcase.path().to_path_buf(),
491                name: Some("simple-bin-no-deps".to_string()),
492                version: None,
493            };
494
495            let resolved = resolver.resolve(&spec).unwrap();
496            assert_eq!(resolved.name, "simple-bin-no-deps");
497        }
498
499        #[test]
500        fn single_package_wrong_name() {
501            let (resolver, _temp_dir) = test_resolver();
502            let testcase = CrateTestCase::simple_bin_no_deps();
503
504            let spec = CrateSpec::LocalDir {
505                path: testcase.path().to_path_buf(),
506                name: Some("wrong-name".to_string()),
507                version: None,
508            };
509
510            let result = resolver.resolve(&spec);
511            assert_matches!(result.unwrap_err(), Error::PackageNotFoundInWorkspace { .. });
512        }
513
514        #[test]
515        fn single_package_version_req_match() {
516            let (resolver, _temp_dir) = test_resolver();
517            let testcase = CrateTestCase::simple_bin_no_deps();
518
519            let spec = CrateSpec::LocalDir {
520                path: testcase.path().to_path_buf(),
521                name: None,
522                version: Some(VersionReq::parse(">=0.1.0").unwrap()),
523            };
524
525            let resolved = resolver.resolve(&spec).unwrap();
526            assert_eq!(resolved.version, Version::parse("0.1.0").unwrap());
527        }
528
529        #[test]
530        fn single_package_version_req_mismatch() {
531            let (resolver, _temp_dir) = test_resolver();
532            let testcase = CrateTestCase::simple_bin_no_deps();
533
534            let spec = CrateSpec::LocalDir {
535                path: testcase.path().to_path_buf(),
536                name: None,
537                version: Some(VersionReq::parse(">=999.0.0").unwrap()),
538            };
539
540            let result = resolver.resolve(&spec);
541            assert_matches!(result.unwrap_err(), Error::VersionMismatch { .. });
542        }
543
544        #[test]
545        fn invalid_path() {
546            let (resolver, _temp_dir) = test_resolver();
547            let invalid_path = PathBuf::from("/nonexistent/path/to/nowhere");
548
549            let spec = CrateSpec::LocalDir {
550                path: invalid_path,
551                name: None,
552                version: None,
553            };
554
555            let result = resolver.resolve(&spec);
556            assert_matches!(result.unwrap_err(), Error::CargoMetadata { .. });
557        }
558
559        #[test]
560        fn workspace_ambiguous_without_name() {
561            let (resolver, _temp_dir) = test_resolver();
562            let testcase = CrateTestCase::workspace_all_libs();
563
564            let spec = CrateSpec::LocalDir {
565                path: testcase.path().to_path_buf(),
566                name: None,
567                version: None,
568            };
569
570            let result = resolver.resolve(&spec);
571            assert_matches!(result.unwrap_err(), Error::AmbiguousPackageName { .. });
572        }
573
574        #[test]
575        fn workspace_with_valid_package_name() {
576            let (resolver, _temp_dir) = test_resolver();
577            let testcase = CrateTestCase::workspace_all_libs();
578
579            let spec = CrateSpec::LocalDir {
580                path: testcase.path().to_path_buf(),
581                name: Some("lib1".to_string()),
582                version: None,
583            };
584
585            let resolved = resolver.resolve(&spec).unwrap();
586            assert_eq!(resolved.name, "lib1");
587            assert_eq!(resolved.version, Version::parse("0.1.0").unwrap());
588        }
589
590        #[test]
591        fn workspace_with_nonexistent_package() {
592            let (resolver, _temp_dir) = test_resolver();
593            let testcase = CrateTestCase::workspace_all_libs();
594
595            let spec = CrateSpec::LocalDir {
596                path: testcase.path().to_path_buf(),
597                name: Some("nonexistent".to_string()),
598                version: None,
599            };
600
601            let result = resolver.resolve(&spec);
602            assert_matches!(result.unwrap_err(), Error::PackageNotFoundInWorkspace { .. });
603        }
604
605        #[test]
606        fn workspace_package_with_version() {
607            let (resolver, _temp_dir) = test_resolver();
608            let testcase = CrateTestCase::workspace_all_libs();
609
610            let spec = CrateSpec::LocalDir {
611                path: testcase.path().to_path_buf(),
612                name: Some("lib2".to_string()),
613                version: Some(VersionReq::parse("=0.1.0").unwrap()),
614            };
615
616            let resolved = resolver.resolve(&spec).unwrap();
617            assert_eq!(resolved.name, "lib2");
618            assert_eq!(resolved.version, Version::parse("0.1.0").unwrap());
619        }
620
621        #[test]
622        fn library_package_auto_name() {
623            let (resolver, _temp_dir) = test_resolver();
624            let testcase = CrateTestCase::simple_lib_no_deps();
625
626            let spec = CrateSpec::LocalDir {
627                path: testcase.path().to_path_buf(),
628                name: None,
629                version: None,
630            };
631
632            let resolved = resolver.resolve(&spec).unwrap();
633            assert_eq!(resolved.name, "simple-lib-no-deps");
634            assert_matches!(resolved.source, ResolvedSource::LocalDir { .. });
635        }
636    }
637
638    /// Tests exercising crate specs using a registry (mostly crates.io).
639    ///
640    /// These tests will actually hit the registry over the network.  Hopefully they don't get
641    /// throttled.
642    mod registry {
643        use std::thread;
644
645        use rand::seq::SliceRandom;
646
647        use super::*;
648        use crate::error::Error;
649
650        #[test]
651        fn serde_latest() {
652            let (resolver, _temp_dir) = test_resolver();
653
654            let spec = CrateSpec::CratesIo {
655                name: "serde".to_string(),
656                version: None,
657            };
658
659            let resolved = resolver.resolve(&spec).unwrap();
660            assert_eq!(resolved.name, "serde");
661            assert_matches!(resolved.source, ResolvedSource::CratesIo);
662        }
663
664        #[test]
665        fn with_version() {
666            let (resolver, _temp_dir) = test_resolver();
667            let version_req = VersionReq::parse("^1.0").unwrap();
668
669            let spec = CrateSpec::CratesIo {
670                name: "serde".to_string(),
671                version: Some(version_req.clone()),
672            };
673
674            let resolved = resolver.resolve(&spec).unwrap();
675            assert_eq!(resolved.name, "serde");
676            assert!(version_req.matches(&resolved.version));
677        }
678
679        #[test]
680        fn star_version() {
681            let (resolver, _temp_dir) = test_resolver();
682
683            let spec = CrateSpec::CratesIo {
684                name: "tokio".to_string(),
685                version: Some(VersionReq::STAR),
686            };
687
688            let resolved = resolver.resolve(&spec).unwrap();
689            assert_eq!(resolved.name, "tokio");
690        }
691
692        #[test]
693        fn nonexistent() {
694            let (resolver, _temp_dir) = test_resolver();
695
696            let spec = CrateSpec::CratesIo {
697                name: "definitely-not-a-real-crate-xyzzy-12345".to_string(),
698                version: None,
699            };
700
701            let result = resolver.resolve(&spec);
702
703            assert_matches!(result.unwrap_err(), Error::CrateNotFoundInRegistry { .. });
704        }
705
706        /// It's safe to assume serde will never release version 999.0.0, so this tests the proper
707        /// behavior when the crate exists on the registry but no compatible version is present
708        #[test]
709        fn non_existent_version() {
710            let (resolver, _temp_dir) = test_resolver();
711            let version_req = VersionReq::parse(">=999.0.0").unwrap();
712
713            let spec = CrateSpec::CratesIo {
714                name: "serde".to_string(),
715                version: Some(version_req),
716            };
717
718            let result = resolver.resolve(&spec);
719
720            assert_matches!(result.unwrap_err(), Error::NoMatchingVersion { .. });
721        }
722
723        #[test]
724        fn selects_highest_version() {
725            let (resolver, _temp_dir) = test_resolver();
726            let version_req = VersionReq::parse(">=1.0.0").unwrap();
727
728            let spec = CrateSpec::CratesIo {
729                name: "serde".to_string(),
730                version: Some(version_req.clone()),
731            };
732
733            let resolved = resolver.resolve(&spec).unwrap();
734            assert!(resolved.version.major >= 1);
735            assert!(version_req.matches(&resolved.version));
736        }
737
738        /// Test that resolving an uncached crate in offline mode fails.
739        ///
740        /// This test attempts to resolve a definitely-nonexistent crate name in offline mode
741        /// without any prior caching. Because the crate is not in `tame_index`'s cache and we're
742        /// in offline mode (which only uses `cached_krate`), the resolve fails with
743        /// [`OfflineMode`].
744        #[test]
745        fn offline_without_cached_fails() {
746            let (resolver, _temp_dir) = test_resolver_offline();
747
748            let spec = CrateSpec::CratesIo {
749                name: "definitely-not-real-crate-xyzzy-offline-99999".to_string(),
750                version: None,
751            };
752
753            let result = resolver.resolve(&spec);
754
755            assert_matches!(result.unwrap_err(), Error::OfflineMode { .. });
756        }
757
758        /// Test that resolving a cached crate in offline mode succeeds.
759        ///
760        /// This test first queries serde online to populate `tame_index`'s cache, then
761        /// queries the same crate in offline mode. The second query should succeed
762        /// by using our cached resolution result. While we can't prove the network wasn't
763        /// used, this exercises the offline code path that calls `cached_krate` instead of krate.
764        #[test]
765        fn offline_with_cached_works() {
766            let (online_resolver, _temp_dir) = test_resolver();
767
768            // Query serde online first to populate tame_index cache
769            let version_req = VersionReq::parse("^1.0").unwrap();
770            let spec = CrateSpec::CratesIo {
771                name: "serde".to_string(),
772                version: Some(version_req),
773            };
774
775            let online_resolved = online_resolver.resolve(&spec).unwrap();
776
777            // Now try offline mode - should work because the crate resolution is cached
778            let offline_config = Config {
779                offline: true,
780                ..online_resolver.inner.config.clone()
781            };
782            let git_client = GitClient::new(
783                online_resolver.cache.clone(),
784                crate::messages::MessageReporter::null(),
785                offline_config.http.clone(),
786            );
787            let http_client = HttpClient::new(&offline_config.http).unwrap();
788            let offline_resolver = CachingResolver::new(
789                DefaultCrateResolver::new(
790                    offline_config,
791                    git_client,
792                    online_resolver.inner.cargo.clone(),
793                    http_client,
794                ),
795                online_resolver.cache.clone(),
796            );
797
798            let offline_resolved = offline_resolver.resolve(&spec).unwrap();
799
800            assert_eq!(online_resolved.name, offline_resolved.name);
801            assert_eq!(online_resolved.version, offline_resolved.version);
802        }
803
804        /// Test that stale cgx cache entries are returned in offline mode for invalid crate names.
805        ///
806        /// This test inserts a fake cache entry for an invalid crate name (+invalid-crate-name,
807        /// which contains characters not allowed in crate names) with a stale timestamp. When
808        /// resolving in offline mode, the resolver returns the stale entry. We know for certain
809        /// the network wasn't hit because querying crates.io for an invalid crate name would
810        /// cause an error.
811        #[test]
812        fn stale_invalid_crate_returned_in_offline_mode() {
813            let (resolver, _temp_dir) = test_resolver();
814            let cache_timeout = resolver.inner.config.resolve_cache_timeout;
815
816            // Create a spec for an invalid crate name (+ is not valid in crate names)
817            let invalid_spec = CrateSpec::CratesIo {
818                name: "+invalid-crate-name".to_string(),
819                version: None,
820            };
821
822            // Create a fake resolved crate
823            let fake_resolved = ResolvedCrate {
824                name: "+invalid-crate-name".to_string(),
825                version: Version::parse("1.0.0").unwrap(),
826                source: ResolvedSource::CratesIo,
827            };
828
829            // Insert a stale cache entry (older than timeout)
830            resolver
831                .cache
832                .insert_stale_resolve_entry(
833                    &invalid_spec,
834                    &fake_resolved,
835                    cache_timeout + Duration::from_secs(1),
836                )
837                .unwrap();
838
839            // Create offline config and resolver, that shares the same cache as the one we just
840            // inserted the stale cache entry into
841            let offline_config = Config {
842                offline: true,
843                ..resolver.inner.config.clone()
844            };
845            let git_client = GitClient::new(
846                resolver.cache.clone(),
847                crate::messages::MessageReporter::null(),
848                offline_config.http.clone(),
849            );
850            let http_client = HttpClient::new(&offline_config.http).unwrap();
851            let offline_resolver = CachingResolver::new(
852                DefaultCrateResolver::new(
853                    offline_config,
854                    git_client,
855                    resolver.inner.cargo.clone(),
856                    http_client,
857                ),
858                resolver.cache,
859            );
860
861            // Query in offline mode - should return stale entry without hitting network
862            let resolved = offline_resolver.resolve(&invalid_spec).unwrap();
863
864            assert_eq!(resolved.name, fake_resolved.name);
865            assert_eq!(resolved.version, fake_resolved.version);
866        }
867
868        /// Test that a non-stale cache entry is served without querying the registry.
869        ///
870        /// This test inserts a fake serde@999.99.99 entry (which doesn't exist on crates.io)
871        /// into the cgx cache with a fresh timestamp. When resolving in online mode, if the cache
872        /// entry is returned, we know for certain that the registry was not queried (because
873        /// the registry would fail to find version 999.99.99, which doesn't exist).
874        #[test]
875        fn cache_serves_non_stale_entry_without_registry_lookup() {
876            let (resolver, _temp_dir) = test_resolver();
877
878            // Create a spec for serde with a nonexistent version
879            let spec = CrateSpec::CratesIo {
880                name: "serde".to_string(),
881                version: None,
882            };
883
884            // Create a fake resolved crate with a version that doesn't exist
885            let fake_resolved = ResolvedCrate {
886                name: "serde".to_string(),
887                version: Version::parse("999.99.99").unwrap(),
888                source: ResolvedSource::CratesIo,
889            };
890
891            // Insert a NON-stale cache entry (fresh, within timeout)
892            resolver
893                .cache
894                .insert_stale_resolve_entry(&spec, &fake_resolved, Duration::from_secs(1))
895                .unwrap();
896
897            // Query in online mode - should return the cached fake entry without hitting registry
898            let resolved = resolver.resolve(&spec).unwrap();
899
900            assert_eq!(resolved.name, "serde");
901            assert_eq!(resolved.version, Version::parse("999.99.99").unwrap());
902        }
903
904        /// Test that stale cache entries are not used as fallback for permanent errors.
905        ///
906        /// This test inserts a fake serde@999.99.99 entry into the cache with a stale timestamp.
907        /// When resolving in online mode, the resolver queries the registry, which returns
908        /// `NoMatchingVersion` (since 999.99.99 doesn't exist). Because `NoMatchingVersion` is not
909        /// a transient error (not in `should_use_stale_cache` list), the stale cache should NOT
910        /// be used as a fallback, and the error should propagate.
911        #[test]
912        fn stale_cache_not_used_for_permanent_errors() {
913            let (resolver, _temp_dir) = test_resolver();
914            let cache_timeout = resolver.inner.config.resolve_cache_timeout;
915
916            // Create a spec for serde with a specific nonexistent version
917            let spec = CrateSpec::CratesIo {
918                name: "serde".to_string(),
919                version: Some(VersionReq::parse("=999.99.99").unwrap()),
920            };
921
922            // Create a fake resolved crate with the nonexistent version
923            let fake_resolved = ResolvedCrate {
924                name: "serde".to_string(),
925                version: Version::parse("999.99.99").unwrap(),
926                source: ResolvedSource::CratesIo,
927            };
928
929            // Insert a STALE cache entry (older than timeout)
930            resolver
931                .cache
932                .insert_stale_resolve_entry(&spec, &fake_resolved, cache_timeout + Duration::from_secs(1))
933                .unwrap();
934
935            // Query in online mode - should fail because registry returns NoMatchingVersion
936            // and stale cache is not used for this error type
937            let result = resolver.resolve(&spec);
938
939            assert_matches!(result.unwrap_err(), Error::NoMatchingVersion { .. });
940        }
941
942        /// Stress test to reproduce Windows file lock bug through heavy lock contention.
943        ///
944        /// This test spawns 20 threads that all simultaneously query different crates from
945        /// crates.io in random order. Each thread queries 10 crates, creating heavy contention
946        /// for the cargo package cache lock.
947        ///
948        /// On Windows with the tame-index bug where `None` timeout maps to 0 instead of INFINITE,
949        /// threads will fail with `TimedOut` errors. With the fix, all threads properly wait
950        /// for the lock and succeed.
951        ///
952        /// This reproduces: <https://github.com/EmbarkStudios/tame-index/issues/94>
953        #[test]
954        fn lock_contention_stress_test() {
955            let crates = vec![
956                "serde",
957                "tokio",
958                "reqwest",
959                "clap",
960                "anyhow",
961                "thiserror",
962                "tracing",
963                "syn",
964                "quote",
965                "proc-macro2",
966                "serde_json",
967                "regex",
968                "rayon",
969                "bytes",
970                "http",
971                "futures",
972                "async-trait",
973                "rand",
974                "chrono",
975                "log",
976            ];
977
978            let (resolver, _temp_dir) = test_resolver();
979            let resolver = Arc::new(resolver);
980
981            let mut handles = vec![];
982
983            for thread_id in 0..20 {
984                let resolver = Arc::clone(&resolver);
985                let mut thread_crates = crates.clone();
986
987                let handle = thread::spawn(move || {
988                    let mut rng = rand::rng();
989                    thread_crates.shuffle(&mut rng);
990
991                    for krate_name in thread_crates.iter().take(10) {
992                        let spec = CrateSpec::CratesIo {
993                            name: (*krate_name).to_string(),
994                            version: None,
995                        };
996
997                        match resolver.resolve(&spec) {
998                            Ok(resolved) => {
999                                assert_eq!(resolved.name, *krate_name);
1000                            }
1001                            Err(e) => {
1002                                panic!("[Thread {}] Failed to resolve {}: {:?}", thread_id, krate_name, e);
1003                            }
1004                        }
1005                    }
1006                });
1007
1008                handles.push(handle);
1009            }
1010
1011            for (i, handle) in handles.into_iter().enumerate() {
1012                handle
1013                    .join()
1014                    .unwrap_or_else(|e| panic!("Thread {} panicked: {:?}", i, e));
1015            }
1016        }
1017    }
1018
1019    /// Tests exercising crate specs pointing to git repositories.
1020    mod git {
1021        use super::*;
1022        use crate::error::Error;
1023
1024        /// Absent any kind of selector, defaults to the most recent commit on the default branch.
1025        #[test]
1026        fn default_branch() {
1027            let (resolver, _temp_dir) = test_resolver();
1028            let repo = "https://github.com/rust-lang/rustlings.git";
1029
1030            let spec = CrateSpec::Git {
1031                repo: repo.to_string(),
1032                selector: GitSelector::DefaultBranch,
1033                name: Some("rustlings".to_string()),
1034                version: None,
1035            };
1036
1037            let resolved = resolver.resolve(&spec).unwrap();
1038            assert_eq!(resolved.name, "rustlings");
1039            if let ResolvedSource::Git { repo: r, commit } = &resolved.source {
1040                assert_eq!(r, repo);
1041                assert!(!commit.is_empty());
1042            } else {
1043                panic!("Expected Git source, got {:?}", resolved.source);
1044            }
1045        }
1046
1047        #[test]
1048        fn with_branch() {
1049            let (resolver, _temp_dir) = test_resolver();
1050
1051            let spec = CrateSpec::Git {
1052                repo: "https://github.com/rust-lang/rustlings.git".to_string(),
1053                selector: GitSelector::Branch("main".to_string()),
1054                name: Some("rustlings".to_string()),
1055                version: None,
1056            };
1057
1058            let resolved = resolver.resolve(&spec).unwrap();
1059            if let ResolvedSource::Git { commit, .. } = &resolved.source {
1060                assert!(!commit.is_empty());
1061            } else {
1062                panic!("Expected Git source, got {:?}", resolved.source);
1063            }
1064        }
1065
1066        #[test]
1067        fn with_tag() {
1068            let (resolver, _temp_dir) = test_resolver();
1069
1070            let spec = CrateSpec::Git {
1071                repo: "https://github.com/rust-lang/rustlings.git".to_string(),
1072                selector: GitSelector::Tag("v6.0.0".to_string()),
1073                name: Some("rustlings".to_string()),
1074                version: None,
1075            };
1076
1077            let resolved = resolver.resolve(&spec).unwrap();
1078            if let ResolvedSource::Git { commit, .. } = &resolved.source {
1079                assert!(!commit.is_empty());
1080            } else {
1081                panic!("Expected Git source, got {:?}", resolved.source);
1082            }
1083        }
1084
1085        #[test]
1086        fn with_commit() {
1087            let (resolver, _temp_dir) = test_resolver();
1088
1089            // Use actual commit hash (not tag object hash)
1090            // This is the commit that v6.0.0 tag points to
1091            let spec = CrateSpec::Git {
1092                repo: "https://github.com/rust-lang/rustlings.git".to_string(),
1093                selector: GitSelector::Commit("28d2bb04326d7036514245d73f10fb72b9ed108c".to_string()),
1094                name: Some("rustlings".to_string()),
1095                version: None,
1096            };
1097
1098            let resolved = resolver.resolve(&spec).unwrap();
1099            assert_eq!(resolved.name, "rustlings");
1100
1101            if let ResolvedSource::Git { repo: r, commit } = &resolved.source {
1102                assert_eq!(r, "https://github.com/rust-lang/rustlings.git");
1103                assert_eq!(commit, "28d2bb04326d7036514245d73f10fb72b9ed108c");
1104            } else {
1105                panic!("Expected Git source, got {:?}", resolved.source);
1106            }
1107        }
1108
1109        #[test]
1110        fn nonexistent_branch() {
1111            let (resolver, _temp_dir) = test_resolver();
1112
1113            let spec = CrateSpec::Git {
1114                repo: "https://github.com/rust-lang/rustlings.git".to_string(),
1115                selector: GitSelector::Branch("nonexistent-branch-xyzzy-99999".to_string()),
1116                name: Some("rustlings".to_string()),
1117                version: None,
1118            };
1119
1120            let result = resolver.resolve(&spec);
1121
1122            assert_matches!(result.unwrap_err(), Error::Git { .. });
1123        }
1124
1125        #[test]
1126        fn nonexistent_tag() {
1127            let (resolver, _temp_dir) = test_resolver();
1128
1129            let spec = CrateSpec::Git {
1130                repo: "https://github.com/rust-lang/rustlings.git".to_string(),
1131                selector: GitSelector::Tag("999.999.999".to_string()),
1132                name: Some("rustlings".to_string()),
1133                version: None,
1134            };
1135
1136            let result = resolver.resolve(&spec);
1137
1138            assert_matches!(result.unwrap_err(), Error::Git { .. });
1139        }
1140
1141        #[test]
1142        fn invalid_url() {
1143            let (resolver, _temp_dir) = test_resolver();
1144
1145            let spec = CrateSpec::Git {
1146                repo: "https://[invalid-url".to_string(),
1147                selector: GitSelector::DefaultBranch,
1148                name: None,
1149                version: None,
1150            };
1151
1152            let result = resolver.resolve(&spec);
1153
1154            assert_matches!(result.unwrap_err(), Error::Git { .. });
1155        }
1156
1157        /// As with local paths, versions don't have to be specified when pointing to a git repo
1158        /// but if specified the version must be compatible with whatever is at that repo
1159        #[test]
1160        fn version_mismatch() {
1161            let (resolver, _temp_dir) = test_resolver();
1162            // As `rustlings` evolves this version must remain compatible with it; presumably it's
1163            // a long way off from version 999...
1164            let version_req = VersionReq::parse(">=999.0.0").unwrap();
1165
1166            let spec = CrateSpec::Git {
1167                repo: "https://github.com/rust-lang/rustlings.git".to_string(),
1168                selector: GitSelector::DefaultBranch,
1169                name: Some("rustlings".to_string()),
1170                version: Some(version_req),
1171            };
1172
1173            let result = resolver.resolve(&spec);
1174
1175            assert_matches!(result.unwrap_err(), Error::VersionMismatch { .. });
1176        }
1177    }
1178
1179    /// Tests exercising crate specs pointing to forges (GitHub, GitLab, etc.)
1180    ///
1181    /// Mostly this is just a thin wrapper around git resolution, so these tests are lighter.
1182    /// We don't care about the forge vs other git distinction until we start looking for
1183    /// pre-built binaries to download, which is outside of the scope of this module.
1184    mod forge {
1185        use super::*;
1186
1187        #[test]
1188        fn github() {
1189            let (resolver, _temp_dir) = test_resolver();
1190            let spec = CrateSpec::Forge {
1191                forge: Forge::GitHub {
1192                    custom_url: None,
1193                    owner: "rust-lang".to_string(),
1194                    repo: "rustlings".to_string(),
1195                },
1196                selector: GitSelector::DefaultBranch,
1197                name: Some("rustlings".to_string()),
1198                version: None,
1199            };
1200
1201            let resolved = resolver.resolve(&spec).unwrap();
1202            assert_eq!(resolved.name, "rustlings");
1203            if let ResolvedSource::Forge { forge: f, commit } = &resolved.source {
1204                assert_matches!(f, Forge::GitHub { .. });
1205                assert!(!commit.is_empty());
1206            } else {
1207                panic!("Expected Forge source, got {:?}", resolved.source);
1208            }
1209        }
1210
1211        #[test]
1212        fn github_with_branch() {
1213            let (resolver, _temp_dir) = test_resolver();
1214
1215            let spec = CrateSpec::Forge {
1216                forge: Forge::GitHub {
1217                    custom_url: None,
1218                    owner: "rust-lang".to_string(),
1219                    repo: "rustlings".to_string(),
1220                },
1221                selector: GitSelector::Branch("main".to_string()),
1222                name: Some("rustlings".to_string()),
1223                version: None,
1224            };
1225
1226            let resolved = resolver.resolve(&spec).unwrap();
1227            if let ResolvedSource::Forge { forge: f, commit, .. } = &resolved.source {
1228                assert_matches!(f, Forge::GitHub { .. });
1229                assert!(!commit.is_empty());
1230            } else {
1231                panic!("Expected Forge source, got {:?}", resolved.source);
1232            }
1233        }
1234
1235        #[test]
1236        fn github_with_tag() {
1237            let (resolver, _temp_dir) = test_resolver();
1238
1239            let spec = CrateSpec::Forge {
1240                forge: Forge::GitHub {
1241                    custom_url: None,
1242                    owner: "rust-lang".to_string(),
1243                    repo: "rustlings".to_string(),
1244                },
1245                selector: GitSelector::Tag("v6.0.0".to_string()),
1246                name: Some("rustlings".to_string()),
1247                version: None,
1248            };
1249
1250            let resolved = resolver.resolve(&spec).unwrap();
1251            if let ResolvedSource::Forge { forge: f, commit, .. } = &resolved.source {
1252                assert_matches!(f, Forge::GitHub { .. });
1253                assert!(!commit.is_empty());
1254            } else {
1255                panic!("Expected Forge source, got {:?}", resolved.source);
1256            }
1257        }
1258    }
1259}