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#[derive(Clone, Debug, PartialEq, Eq)]
17pub struct CratesIoSpec {
18 pub package: String,
20 pub requirement: VersionReq,
22}
23
24impl CratesIoSpec {
25 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#[derive(Clone, Debug, PartialEq, Eq)]
69pub struct VersionReq {
70 raw: String,
71 kind: VersionReqKind,
72}
73
74impl VersionReq {
75 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 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#[derive(Clone, Debug, PartialEq, Eq)]
147pub struct ResolvedCratesIoSource {
148 pub requested: CratesIoSpec,
150 pub package: String,
152 pub version: String,
154 pub artifact: PathBuf,
156}
157
158#[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#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
169pub(crate) enum CratesIoListingSource {
170 Cache,
171 Registry,
172}
173
174#[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 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 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 pub fn cache_dir(&self) -> &Path {
219 &self.cache_dir
220 }
221
222 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 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 #[cfg(feature = "registry")]
250 pub fn add_git_registry_resolver(&mut self, resolver: GitRegistryResolver) {
251 self.git_registry = Some(resolver);
252 }
253
254 #[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 #[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 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}