Skip to main content

sim_run_core/
crates_io.rs

1use std::{
2    cmp::Ordering,
3    env, fmt, fs,
4    path::{Path, PathBuf},
5    str::FromStr,
6};
7
8use crate::CliError;
9#[cfg(feature = "registry")]
10use crate::GitRegistryResolver;
11
12const DEFAULT_FALLBACK_REQ: &str = "^0.1";
13pub(crate) const ARTIFACT_FILE: &str = "artifact.simlib";
14
15/// A parsed `crates.io:NAME@REQ` source.
16#[derive(Clone, Debug, PartialEq, Eq)]
17pub struct CratesIoSpec {
18    /// crates.io package name.
19    pub package: String,
20    /// Version requirement applied to that package.
21    pub requirement: VersionReq,
22}
23
24impl CratesIoSpec {
25    /// Builds a spec from a package name and version requirement.
26    ///
27    /// Returns an error when the package name is empty.
28    ///
29    /// # Examples
30    ///
31    /// ```
32    /// use sim_run_core::CratesIoSpec;
33    ///
34    /// let spec = CratesIoSpec::new("sim-codec-lisp", "^0.1".parse().unwrap()).unwrap();
35    /// assert_eq!(spec.package, "sim-codec-lisp");
36    /// assert!(CratesIoSpec::new("", "*".parse().unwrap()).is_err());
37    /// ```
38    pub fn new(package: impl Into<String>, requirement: VersionReq) -> Result<Self, CliError> {
39        let package = package.into();
40        if package.is_empty() {
41            return Err(CliError::new("crates.io package name is empty"));
42        }
43        Ok(Self {
44            package,
45            requirement,
46        })
47    }
48}
49
50impl FromStr for CratesIoSpec {
51    type Err = CliError;
52
53    fn from_str(source: &str) -> Result<Self, Self::Err> {
54        let Some((package, requirement)) = source.split_once('@') else {
55            return Err(CliError::new("crates.io source must use NAME@REQ syntax"));
56        };
57        Self::new(package.to_owned(), requirement.parse::<VersionReq>()?)
58    }
59}
60
61impl fmt::Display for CratesIoSpec {
62    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63        write!(f, "{}@{}", self.package, self.requirement)
64    }
65}
66
67/// Version requirement syntax accepted by the CLI crates.io resolver.
68#[derive(Clone, Debug, PartialEq, Eq)]
69pub struct VersionReq {
70    raw: String,
71    kind: VersionReqKind,
72}
73
74impl VersionReq {
75    /// Reports whether a concrete version satisfies this requirement.
76    ///
77    /// # Examples
78    ///
79    /// ```
80    /// use sim_run_core::VersionReq;
81    ///
82    /// let caret: VersionReq = "^0.1".parse().unwrap();
83    /// assert!(caret.matches("0.1.9"));
84    /// assert!(!caret.matches("0.2.0"));
85    /// ```
86    pub fn matches(&self, version: &str) -> bool {
87        match &self.kind {
88            VersionReqKind::Any => true,
89            VersionReqKind::Exact(exact) => version == exact,
90            VersionReqKind::Caret(minimum) => {
91                compare_versions(version, minimum) != Ordering::Less
92                    && same_caret_range(version, minimum)
93            }
94        }
95    }
96
97    /// Returns the original requirement text.
98    pub fn as_str(&self) -> &str {
99        &self.raw
100    }
101}
102
103impl FromStr for VersionReq {
104    type Err = CliError;
105
106    fn from_str(source: &str) -> Result<Self, Self::Err> {
107        if source.is_empty() {
108            return Err(CliError::new("crates.io version requirement is empty"));
109        }
110        let kind = if source == "*" {
111            VersionReqKind::Any
112        } else if let Some(minimum) = source.strip_prefix('^') {
113            if minimum.is_empty() {
114                return Err(CliError::new("crates.io caret requirement is empty"));
115            }
116            VersionReqKind::Caret(minimum.to_owned())
117        } else if let Some(exact) = source.strip_prefix('=') {
118            if exact.is_empty() {
119                return Err(CliError::new("crates.io exact requirement is empty"));
120            }
121            VersionReqKind::Exact(exact.to_owned())
122        } else {
123            VersionReqKind::Exact(source.to_owned())
124        };
125        Ok(Self {
126            raw: source.to_owned(),
127            kind,
128        })
129    }
130}
131
132impl fmt::Display for VersionReq {
133    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
134        f.write_str(&self.raw)
135    }
136}
137
138#[derive(Clone, Debug, PartialEq, Eq)]
139enum VersionReqKind {
140    Any,
141    Exact(String),
142    Caret(String),
143}
144
145/// A crates.io source resolved to a local artifact path.
146#[derive(Clone, Debug, PartialEq, Eq)]
147pub struct ResolvedCratesIoSource {
148    /// The spec that was resolved.
149    pub requested: CratesIoSpec,
150    /// Resolved package name.
151    pub package: String,
152    /// Concrete version selected for the package.
153    pub version: String,
154    /// Local path to the resolved artifact.
155    pub artifact: PathBuf,
156}
157
158/// A crates.io artifact available without network access.
159#[derive(Clone, Debug, PartialEq, Eq)]
160pub(crate) struct CratesIoListing {
161    pub(crate) package: String,
162    pub(crate) version: String,
163    pub(crate) artifact: PathBuf,
164    pub(crate) source: CratesIoListingSource,
165}
166
167/// Where an offline crates.io artifact listing came from.
168#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
169pub(crate) enum CratesIoListingSource {
170    Cache,
171    Registry,
172}
173
174/// crates.io-style artifact resolver owned by the CLI layer.
175///
176/// The default resolver performs **no network access**. It resolves a spec only
177/// from an already-seeded local cache or an explicitly registered registry
178/// artifact. With the `registry` feature, callers can install `GitRegistryResolver`
179/// so unresolved specs fetch from an explicit git registry artifact endpoint and
180/// then enter the same cache layout.
181#[derive(Clone, Debug)]
182pub struct CratesIoResolver {
183    cache_dir: PathBuf,
184    registry: Vec<RegistryArtifact>,
185    #[cfg(feature = "registry")]
186    git_registry: Option<GitRegistryResolver>,
187}
188
189impl CratesIoResolver {
190    /// Builds a cache-only resolver rooted at a cache directory.
191    pub fn new(cache_dir: PathBuf) -> Self {
192        Self {
193            cache_dir,
194            registry: Vec::new(),
195            #[cfg(feature = "registry")]
196            git_registry: None,
197        }
198    }
199
200    /// Returns the default cache directory.
201    ///
202    /// Honors `SIM_CLI_CACHE_DIR`, then `XDG_CACHE_HOME`, then `HOME`, and
203    /// falls back to the system temporary directory.
204    pub fn default_cache_dir() -> PathBuf {
205        if let Ok(path) = env::var("SIM_CLI_CACHE_DIR") {
206            return PathBuf::from(path);
207        }
208        if let Ok(path) = env::var("XDG_CACHE_HOME") {
209            return PathBuf::from(path).join("sim").join("libs");
210        }
211        if let Ok(path) = env::var("HOME") {
212            return PathBuf::from(path).join(".cache").join("sim").join("libs");
213        }
214        env::temp_dir().join("sim").join("libs")
215    }
216
217    /// Returns the cache directory this resolver reads from.
218    pub fn cache_dir(&self) -> &Path {
219        &self.cache_dir
220    }
221
222    /// Registers an in-memory registry artifact available for resolution.
223    pub fn add_registry_artifact(
224        &mut self,
225        package: impl Into<String>,
226        version: impl Into<String>,
227        artifact: impl Into<PathBuf>,
228    ) {
229        self.registry.push(RegistryArtifact {
230            package: package.into(),
231            version: version.into(),
232            artifact: artifact.into(),
233        });
234    }
235
236    /// Registers an in-memory registry artifact, returning `self`.
237    pub fn with_registry_artifact(
238        mut self,
239        package: impl Into<String>,
240        version: impl Into<String>,
241        artifact: impl Into<PathBuf>,
242    ) -> Self {
243        self.add_registry_artifact(package, version, artifact);
244        self
245    }
246
247    /// Sets the networked git registry artifact resolver used after cache and
248    /// explicit in-memory registry misses.
249    #[cfg(feature = "registry")]
250    pub fn add_git_registry_resolver(&mut self, resolver: GitRegistryResolver) {
251        self.git_registry = Some(resolver);
252    }
253
254    /// Sets the networked git registry artifact resolver, returning `self`.
255    #[cfg(feature = "registry")]
256    pub fn with_git_registry_resolver(mut self, resolver: GitRegistryResolver) -> Self {
257        self.add_git_registry_resolver(resolver);
258        self
259    }
260
261    /// Builds a git registry resolver from an endpoint and this resolver's cache.
262    #[cfg(feature = "registry")]
263    pub fn with_git_registry_endpoint(
264        mut self,
265        endpoint: impl Into<String>,
266    ) -> Result<Self, CliError> {
267        let resolver = GitRegistryResolver::new(endpoint, self.cache_dir.clone())?;
268        self.add_git_registry_resolver(resolver);
269        Ok(self)
270    }
271
272    /// Resolves a spec to a cached artifact, seeding the cache when needed.
273    ///
274    /// Prefers an already-cached artifact, then a registered registry artifact.
275    /// With the `registry` feature and an installed git registry resolver, misses
276    /// can fetch through that resolver. Otherwise misses fail closed with a
277    /// clear cache-only error rather than pretending to fetch one.
278    pub fn resolve(&self, spec: &CratesIoSpec) -> Result<ResolvedCratesIoSource, CliError> {
279        if let Some((version, artifact)) = self.cached_artifact(spec)? {
280            return Ok(ResolvedCratesIoSource {
281                requested: spec.clone(),
282                package: spec.package.clone(),
283                version,
284                artifact,
285            });
286        }
287        if let Some(entry) = self.registry_artifact(spec) {
288            let artifact = self.cache_artifact(&entry)?;
289            return Ok(ResolvedCratesIoSource {
290                requested: spec.clone(),
291                package: entry.package,
292                version: entry.version,
293                artifact,
294            });
295        }
296        #[cfg(feature = "registry")]
297        if let Some(git_registry) = &self.git_registry {
298            return git_registry.resolve(spec);
299        }
300        Err(CliError::new(format!(
301            "crates.io network fetch is not implemented (cache-only resolver); seed the cache for {spec}"
302        )))
303    }
304
305    pub(crate) fn available_artifacts(&self) -> Result<Vec<CratesIoListing>, CliError> {
306        let mut listings = self.registry_artifact_listings();
307        listings.extend(self.cached_artifact_listings()?);
308        listings.sort_by(|left, right| {
309            left.package
310                .cmp(&right.package)
311                .then_with(|| compare_versions(&right.version, &left.version))
312                .then_with(|| left.artifact.cmp(&right.artifact))
313                .then_with(|| left.source.cmp(&right.source))
314        });
315        Ok(listings)
316    }
317
318    fn registry_artifact_listings(&self) -> Vec<CratesIoListing> {
319        self.registry
320            .iter()
321            .map(|entry| CratesIoListing {
322                package: entry.package.clone(),
323                version: entry.version.clone(),
324                artifact: entry.artifact.clone(),
325                source: CratesIoListingSource::Registry,
326            })
327            .collect()
328    }
329
330    fn cached_artifact_listings(&self) -> Result<Vec<CratesIoListing>, CliError> {
331        let root = self.cache_dir.join("crates.io");
332        let Ok(package_entries) = fs::read_dir(&root) else {
333            return Ok(Vec::new());
334        };
335        let mut listings = Vec::new();
336        for package_entry in package_entries {
337            let package_entry = package_entry.map_err(|err| {
338                CliError::new(format!("read crates.io cache {}: {err}", root.display()))
339            })?;
340            if !package_entry
341                .file_type()
342                .map(|ty| ty.is_dir())
343                .unwrap_or(false)
344            {
345                continue;
346            }
347            let package = package_entry.file_name().to_string_lossy().into_owned();
348            let version_root = package_entry.path();
349            for version_entry in fs::read_dir(&version_root).map_err(|err| {
350                CliError::new(format!(
351                    "read crates.io cache {}: {err}",
352                    version_root.display()
353                ))
354            })? {
355                let version_entry = version_entry.map_err(|err| {
356                    CliError::new(format!(
357                        "read crates.io cache {}: {err}",
358                        version_root.display()
359                    ))
360                })?;
361                if !version_entry
362                    .file_type()
363                    .map(|ty| ty.is_dir())
364                    .unwrap_or(false)
365                {
366                    continue;
367                }
368                if let Some(artifact) = cached_artifact_file(&version_entry.path())? {
369                    listings.push(CratesIoListing {
370                        package: package.clone(),
371                        version: version_entry.file_name().to_string_lossy().into_owned(),
372                        artifact,
373                        source: CratesIoListingSource::Cache,
374                    });
375                }
376            }
377        }
378        Ok(listings)
379    }
380
381    fn cached_artifact(&self, spec: &CratesIoSpec) -> Result<Option<(String, PathBuf)>, CliError> {
382        let package_dir = self.cache_dir.join("crates.io").join(&spec.package);
383        let Ok(entries) = fs::read_dir(&package_dir) else {
384            return Ok(None);
385        };
386        let mut matches = Vec::new();
387        for entry in entries {
388            let entry = entry.map_err(|err| {
389                CliError::new(format!(
390                    "read crates.io cache {}: {err}",
391                    package_dir.display()
392                ))
393            })?;
394            if !entry.file_type().map(|ty| ty.is_dir()).unwrap_or(false) {
395                continue;
396            }
397            let version = entry.file_name().to_string_lossy().into_owned();
398            if let Some(artifact) = cached_artifact_file(&entry.path())?
399                && spec.requirement.matches(&version)
400            {
401                matches.push((version, artifact));
402            }
403        }
404        matches.sort_by(|left, right| compare_versions(&right.0, &left.0));
405        Ok(matches.into_iter().next())
406    }
407
408    fn registry_artifact(&self, spec: &CratesIoSpec) -> Option<RegistryArtifact> {
409        let mut matches = self
410            .registry
411            .iter()
412            .filter(|entry| {
413                entry.package == spec.package && spec.requirement.matches(&entry.version)
414            })
415            .cloned()
416            .collect::<Vec<_>>();
417        matches.sort_by(|left, right| compare_versions(&right.version, &left.version));
418        matches.into_iter().next()
419    }
420
421    fn cache_artifact(&self, entry: &RegistryArtifact) -> Result<PathBuf, CliError> {
422        let artifact = cache_artifact_path(&self.cache_dir, &entry.package, &entry.version);
423        let parent = artifact
424            .parent()
425            .ok_or_else(|| CliError::new("crates.io cache artifact has no parent"))?;
426        fs::create_dir_all(parent)
427            .map_err(|err| CliError::new(format!("create crates.io cache: {err}")))?;
428        fs::copy(&entry.artifact, &artifact).map_err(|err| {
429            CliError::new(format!(
430                "cache crates.io artifact {}: {err}",
431                entry.artifact.display()
432            ))
433        })?;
434        Ok(artifact)
435    }
436}
437
438impl Default for CratesIoResolver {
439    fn default() -> Self {
440        Self::new(Self::default_cache_dir())
441    }
442}
443
444#[derive(Clone, Debug)]
445struct RegistryArtifact {
446    package: String,
447    version: String,
448    artifact: PathBuf,
449}
450
451pub(crate) fn fallback_spec_for_symbol(symbol: &str) -> Option<CratesIoSpec> {
452    if let Some(codec) = symbol.strip_prefix("codec/") {
453        return fallback_spec(format!("sim-codec-{codec}"));
454    }
455    if !symbol.is_empty() && !symbol.contains('/') {
456        return fallback_spec(format!("sim-lib-{symbol}"));
457    }
458    None
459}
460
461fn fallback_spec(package: String) -> Option<CratesIoSpec> {
462    CratesIoSpec::new(package, DEFAULT_FALLBACK_REQ.parse().ok()?).ok()
463}
464
465pub(crate) fn cache_artifact_path(cache_dir: &Path, package: &str, version: &str) -> PathBuf {
466    cache_artifact_path_with_file(cache_dir, package, version, ARTIFACT_FILE)
467}
468
469pub(crate) fn cache_artifact_path_with_file(
470    cache_dir: &Path,
471    package: &str,
472    version: &str,
473    file_name: &str,
474) -> PathBuf {
475    cache_dir
476        .join("crates.io")
477        .join(package)
478        .join(version)
479        .join(file_name)
480}
481
482fn cached_artifact_file(version_dir: &Path) -> Result<Option<PathBuf>, CliError> {
483    let preferred = version_dir.join(ARTIFACT_FILE);
484    if preferred.is_file() {
485        return Ok(Some(preferred));
486    }
487    let Ok(entries) = fs::read_dir(version_dir) else {
488        return Ok(None);
489    };
490    let mut files = Vec::new();
491    for entry in entries {
492        let entry = entry.map_err(|err| {
493            CliError::new(format!(
494                "read crates.io cache {}: {err}",
495                version_dir.display()
496            ))
497        })?;
498        if entry.file_type().map(|ty| ty.is_file()).unwrap_or(false) {
499            files.push(entry.path());
500        }
501    }
502    files.sort();
503    Ok(files.into_iter().next())
504}
505
506pub(crate) fn compare_versions(left: &str, right: &str) -> Ordering {
507    let left = version_components(left);
508    let right = version_components(right);
509    let len = left.len().max(right.len());
510    for index in 0..len {
511        let ordering = left
512            .get(index)
513            .copied()
514            .unwrap_or(0)
515            .cmp(&right.get(index).copied().unwrap_or(0));
516        if ordering != Ordering::Equal {
517            return ordering;
518        }
519    }
520    Ordering::Equal
521}
522
523fn same_caret_range(version: &str, minimum: &str) -> bool {
524    let version = version_components(version);
525    let minimum = version_components(minimum);
526    let major = *minimum.first().unwrap_or(&0);
527    let minor = *minimum.get(1).unwrap_or(&0);
528    let patch = *minimum.get(2).unwrap_or(&0);
529    if major > 0 {
530        version.first() == Some(&major)
531    } else if minor > 0 {
532        version.first() == Some(&0) && version.get(1) == Some(&minor)
533    } else {
534        version.first() == Some(&0) && version.get(1) == Some(&0) && version.get(2) == Some(&patch)
535    }
536}
537
538fn version_components(version: &str) -> Vec<u64> {
539    version
540        .split('.')
541        .map(|part| {
542            part.chars()
543                .take_while(|ch| ch.is_ascii_digit())
544                .collect::<String>()
545                .parse::<u64>()
546                .unwrap_or(0)
547        })
548        .collect()
549}
550
551#[cfg(test)]
552mod tests {
553    use super::*;
554
555    #[test]
556    fn parses_crates_io_specs_and_requirements() {
557        let exact = "sim-codec-lisp@0.1.0".parse::<CratesIoSpec>().unwrap();
558        assert_eq!(exact.package, "sim-codec-lisp");
559        assert_eq!(exact.requirement.as_str(), "0.1.0");
560        assert!(exact.requirement.matches("0.1.0"));
561        assert!(!exact.requirement.matches("0.1.1"));
562
563        let caret = "sim-lib-demo@^0.1".parse::<CratesIoSpec>().unwrap();
564        assert!(caret.requirement.matches("0.1.0"));
565        assert!(caret.requirement.matches("0.1.9"));
566        assert!(!caret.requirement.matches("0.2.0"));
567    }
568
569    #[test]
570    fn cache_hit_resolves_without_registry() {
571        let cache = temp_dir("cache-hit");
572        let artifact = cache_artifact_path(&cache, "sim-lib-demo", "0.1.0");
573        fs::create_dir_all(artifact.parent().unwrap()).unwrap();
574        fs::write(&artifact, b"cached").unwrap();
575        let resolver = CratesIoResolver::new(cache.clone());
576
577        let resolved = resolver
578            .resolve(&"sim-lib-demo@0.1.0".parse().unwrap())
579            .unwrap();
580
581        assert_eq!(resolved.version, "0.1.0");
582        assert_eq!(resolved.artifact, artifact);
583        let _ = fs::remove_dir_all(cache);
584    }
585
586    #[test]
587    fn cache_miss_is_a_clear_cache_only_failure() {
588        let cache = temp_dir("cache-miss");
589        let resolver = CratesIoResolver::new(cache.clone());
590
591        let err = resolver
592            .resolve(&"sim-lib-demo@0.1.0".parse().unwrap())
593            .unwrap_err();
594
595        assert_eq!(
596            err.to_string(),
597            "crates.io network fetch is not implemented (cache-only resolver); \
598             seed the cache for sim-lib-demo@0.1.0"
599        );
600        let _ = fs::remove_dir_all(cache);
601    }
602
603    #[test]
604    fn fake_registry_resolve_caches_artifact() {
605        let cache = temp_dir("fake-registry-cache");
606        let source_dir = temp_dir("fake-registry-source");
607        fs::create_dir_all(&source_dir).unwrap();
608        let source_artifact = source_dir.join("artifact.simlib");
609        fs::write(&source_artifact, b"registry").unwrap();
610        let resolver = CratesIoResolver::new(cache.clone()).with_registry_artifact(
611            "sim-lib-demo",
612            "0.1.2",
613            source_artifact,
614        );
615
616        let resolved = resolver
617            .resolve(&"sim-lib-demo@^0.1".parse().unwrap())
618            .unwrap();
619
620        assert_eq!(resolved.version, "0.1.2");
621        assert_eq!(fs::read(resolved.artifact).unwrap(), b"registry");
622        let _ = fs::remove_dir_all(cache);
623        let _ = fs::remove_dir_all(source_dir);
624    }
625
626    #[test]
627    fn fallback_mapping_names_codec_and_lib_packages() {
628        assert_eq!(
629            fallback_spec_for_symbol("codec/lisp").unwrap().to_string(),
630            "sim-codec-lisp@^0.1"
631        );
632        assert_eq!(
633            fallback_spec_for_symbol("demo").unwrap().to_string(),
634            "sim-lib-demo@^0.1"
635        );
636        assert!(fallback_spec_for_symbol("domain/demo").is_none());
637    }
638
639    fn temp_dir(label: &str) -> PathBuf {
640        env::temp_dir().join(format!(
641            "sim-run-core-crates-{}-{label}",
642            std::process::id()
643        ))
644    }
645}