1use crate::common::Registry;
2use crate::module_parser::{
3 LibraryMapping, ResolvedMetadataPath, extract_reexport_target,
4 list_library_mappings_from_metadata, resolve_source_from_metadata,
5};
6use anyhow::{Context, bail};
7use flate2::read::GzDecoder;
8use reqwest::{Client, Method, StatusCode, retry};
9use semver::Version;
10use serde::Deserialize;
11use std::collections::{HashMap, HashSet};
12use std::ffi::OsStr;
13use std::fs;
14use std::io::Cursor;
15use std::path::{Path, PathBuf};
16use std::time::Duration;
17
18#[derive(Clone, Debug, Eq, PartialEq)]
20pub struct SourceParams {
21 pub path: PathBuf,
23 pub registry: Registry,
25 pub verbose: bool,
27 pub libs: bool,
29 pub version: Option<Version>,
31 pub clean: bool,
33 pub query: Option<String>,
38}
39
40impl SourceParams {
41 pub fn run(&self) -> anyhow::Result<()> {
42 if self.clean {
43 clean_registry_cache(self.registry)?;
44 }
45
46 let Some(query) = self.query.as_deref() else {
47 return if self.clean {
48 Ok(())
49 } else {
50 bail!("source query is required unless --clean is used by itself")
51 };
52 };
53
54 let workspace_path = self
55 .path
56 .canonicalize()
57 .with_context(|| format!("can't canonicalize path {}", self.path.display()))?;
58 let runtime = tokio::runtime::Builder::new_current_thread()
59 .enable_all()
60 .build()
61 .context("failed to build tokio runtime for source queries")?;
62 let client = build_registry_client()?;
63 let resolution_ctx = Resolver {
64 workspace_path: &workspace_path,
65 client: &client,
66 runtime: &runtime,
67 registry: self.registry,
68 };
69 let mut visited = HashSet::new();
70 let final_resolution = resolve_query_recursive(
71 &resolution_ctx,
72 &workspace_path,
73 query,
74 self.version.as_ref(),
75 &mut visited,
76 )?;
77
78 if self.libs {
79 let mappings = list_library_mappings(
80 final_resolution.manifest_path.parent(),
81 &final_resolution.package_name,
82 )?;
83 print_library_mappings(query, &final_resolution, &mappings, self.verbose);
84 return Ok(());
85 }
86
87 print_resolved_path(query, &final_resolution, self.verbose);
88
89 Ok(())
90 }
91}
92
93fn print_resolved_path(query: &str, resolved: &ResolvedMetadataPath, verbose: bool) {
94 if verbose {
95 println!("query: {query}");
96 println!("package: {}", resolved.package_name);
97 println!("library: {}", resolved.library_name);
98 println!("version: {}", resolved.version);
99 println!("manifest: {}", resolved.manifest_path.display());
100 println!("source: {}", resolved.source_path.display());
101 println!();
102 }
103
104 println!("{}", resolved.source);
105}
106
107fn print_library_mappings(
108 query: &str,
109 resolved: &ResolvedMetadataPath,
110 mappings: &[LibraryMapping],
111 verbose: bool,
112) {
113 if verbose {
114 println!("query: {query}");
115 println!("package: {}", resolved.package_name);
116 println!("version: {}", resolved.version);
117 println!("manifest: {}", resolved.manifest_path.display());
118 println!();
119 }
120
121 for mapping in mappings {
122 println!("{} -> {}", mapping.library_name, mapping.package_name);
123 }
124}
125
126fn list_library_mappings(
127 crate_root: Option<&Path>,
128 query: &str,
129) -> anyhow::Result<Vec<LibraryMapping>> {
130 let crate_root = crate_root.context("resolved manifest path has no parent")?;
131 let package_query = package_only_query(query)?;
132 list_library_mappings_from_metadata(crate_root, &package_query)?
133 .with_context(|| format!("could not list libraries for package '{package_query}'"))
134}
135
136fn package_only_query(query: &str) -> anyhow::Result<String> {
137 let segments = split_query_segments(query)?;
138 if segments.len() != 1 {
139 bail!("--libs requires a package-only query such as 'cf-modkit'");
140 }
141
142 Ok(segments[0].clone())
143}
144
145struct RetryAllHosts;
146
147impl PartialEq<&str> for RetryAllHosts {
148 fn eq(&self, _: &&str) -> bool {
149 true
150 }
151}
152
153fn should_retry_registry_request(
154 method: &Method,
155 status: Option<StatusCode>,
156 has_error: bool,
157) -> bool {
158 if method != Method::GET {
159 return false;
160 }
161
162 if has_error {
163 return true;
164 }
165
166 status.is_some_and(|status| {
167 matches!(
168 status,
169 StatusCode::REQUEST_TIMEOUT
170 | StatusCode::TOO_MANY_REQUESTS
171 | StatusCode::INTERNAL_SERVER_ERROR
172 | StatusCode::BAD_GATEWAY
173 | StatusCode::SERVICE_UNAVAILABLE
174 | StatusCode::GATEWAY_TIMEOUT
175 )
176 })
177}
178
179fn build_registry_client() -> anyhow::Result<Client> {
180 Client::builder()
181 .user_agent("cargo-gears")
182 .timeout(Duration::from_secs(20))
183 .retry(retry::for_host(RetryAllHosts).classify_fn(|req_rep| {
184 if should_retry_registry_request(
185 req_rep.method(),
186 req_rep.status(),
187 req_rep.error().is_some(),
188 ) {
189 req_rep.retryable()
190 } else {
191 req_rep.success()
192 }
193 }))
194 .build()
195 .context("failed to create registry HTTP client")
196}
197
198struct Resolver<'a> {
199 workspace_path: &'a Path,
200 client: &'a Client,
201 runtime: &'a tokio::runtime::Runtime,
202 registry: Registry,
203}
204
205fn resolve_query_recursive(
206 context: &Resolver<'_>,
207 preferred_path: &Path,
208 query: &str,
209 requested_version: Option<&Version>,
210 visited: &mut HashSet<String>,
211) -> anyhow::Result<ResolvedMetadataPath> {
212 let visit_key = format!(
213 "{}|{}|{}",
214 preferred_path.display(),
215 query,
216 requested_version.map_or_else(|| "*".to_owned(), ToString::to_string)
217 );
218 if !visited.insert(visit_key) {
219 bail!("detected recursive re-export loop while resolving '{query}'");
220 }
221
222 let Some(resolution) = resolve_from_paths(
223 context.workspace_path,
224 preferred_path,
225 context.client,
226 context.runtime,
227 context.registry,
228 query,
229 requested_version,
230 )?
231 else {
232 bail!("could not resolve '{query}'");
233 };
234
235 if let Some(next_step) = next_reexport_step(preferred_path, &resolution, query)? {
236 return resolve_query_recursive(
237 context,
238 &next_step.preferred_path,
239 &next_step.query,
240 next_step.requested_version.as_ref(),
241 visited,
242 );
243 }
244
245 Ok(resolution)
246}
247
248fn resolve_from_paths(
249 workspace_path: &Path,
250 preferred_path: &Path,
251 client: &Client,
252 runtime: &tokio::runtime::Runtime,
253 registry: Registry,
254 query: &str,
255 requested_version: Option<&Version>,
256) -> anyhow::Result<Option<ResolvedMetadataPath>> {
257 if let Some(resolved) = resolve_source_from_metadata(preferred_path, query)? {
258 return Ok(Some(resolved));
259 }
260
261 if preferred_path != workspace_path
262 && let Some(resolved) = resolve_source_from_metadata(workspace_path, query)?
263 {
264 return Ok(Some(resolved));
265 }
266
267 runtime
268 .block_on(resolve_from_registry(
269 client,
270 registry,
271 query,
272 requested_version,
273 ))
274 .map(Some)
275}
276
277async fn resolve_from_registry(
278 client: &Client,
279 registry: Registry,
280 query: &str,
281 requested_version: Option<&Version>,
282) -> anyhow::Result<ResolvedMetadataPath> {
283 let crate_name = query
284 .split("::")
285 .next()
286 .filter(|segment| !segment.is_empty())
287 .context("query must not be empty")?;
288
289 if let Some(resolved) = resolve_from_cache(registry, crate_name, query, requested_version)? {
290 return Ok(resolved);
291 }
292
293 let resolved_version = if let Some(requested_version) = requested_version {
294 requested_version.to_string()
295 } else {
296 fetch_exact_registry_candidate(client, registry, crate_name)
297 .await?
298 .with_context(|| {
299 format!("could not resolve package '{crate_name}' from the {registry} registry")
300 })?
301 .max_version
302 };
303 let crate_root = cache_crate_source(client, registry, crate_name, &resolved_version).await?;
304
305 resolve_source_from_metadata(&crate_root, query)?
306 .with_context(|| format!("could not resolve '{query}' inside package '{crate_name}'"))
307}
308
309struct NextStep {
310 preferred_path: PathBuf,
311 query: String,
312 requested_version: Option<Version>,
313}
314
315fn next_reexport_step(
316 preferred_path: &Path,
317 resolved: &ResolvedMetadataPath,
318 query: &str,
319) -> anyhow::Result<Option<NextStep>> {
320 let Some(target_segments) = extract_reexport_target(
321 &resolved.source,
322 query
323 .split("::")
324 .last()
325 .context("query must not be empty")?,
326 )?
327 else {
328 return Ok(None);
329 };
330
331 let crate_root = resolved
332 .manifest_path
333 .parent()
334 .context("resolved manifest path has no parent")?;
335 let package_name = &resolved.package_name;
336 let query_segments = split_query_segments(query)?;
337 let package_index = query_segments
338 .iter()
339 .position(|segment| segment == package_name)
340 .context("resolved query does not include package name")?;
341 let containing_module_segments = query_segments[package_index + 1..]
342 .split_last()
343 .map_or_else(Vec::new, |(_, module_segments)| module_segments.to_vec());
344
345 if let Some(relative_segments) =
346 resolve_relative_reexport(&target_segments, &containing_module_segments)?
347 {
348 let next_query = build_query(package_name, &relative_segments);
349 return Ok(Some(NextStep {
350 preferred_path: preferred_path.to_path_buf(),
351 query: next_query,
352 requested_version: None,
353 }));
354 }
355
356 let dependencies = parse_dependencies(crate_root)?;
357
358 let Some((first_segment, remaining_segments)) = target_segments.split_first() else {
359 return Ok(None);
360 };
361
362 let Some(dep) = find_dependency_spec(&dependencies, first_segment) else {
363 if let Some(next_query) = resolve_bare_relative_reexport(
364 crate_root,
365 package_name,
366 &target_segments,
367 &containing_module_segments,
368 )? {
369 return Ok(Some(NextStep {
370 preferred_path: preferred_path.to_path_buf(),
371 query: next_query,
372 requested_version: None,
373 }));
374 }
375
376 let next_query = build_query(package_name, &target_segments);
377 return Ok(Some(NextStep {
378 preferred_path: preferred_path.to_path_buf(),
379 query: next_query,
380 requested_version: None,
381 }));
382 };
383
384 let next_query = build_query(&dep.package_name, remaining_segments);
385 let next_preferred_path = dep.path.as_ref().map_or_else(
386 || preferred_path.to_path_buf(),
387 |path| crate_root.join(path),
388 );
389
390 Ok(Some(NextStep {
391 preferred_path: next_preferred_path,
392 query: next_query,
393 requested_version: dep.version.clone(),
394 }))
395}
396
397fn resolve_bare_relative_reexport(
398 crate_root: &Path,
399 package_name: &str,
400 target_segments: &[String],
401 containing_module_segments: &[String],
402) -> anyhow::Result<Option<String>> {
403 if containing_module_segments.is_empty() {
404 return Ok(None);
405 }
406
407 let relative_segments = containing_module_segments
408 .iter()
409 .cloned()
410 .chain(target_segments.iter().cloned())
411 .collect::<Vec<_>>();
412 let relative_query = build_query(package_name, &relative_segments);
413
414 Ok(resolve_source_from_metadata(crate_root, &relative_query)?.map(|_| relative_query))
415}
416
417fn split_query_segments(query: &str) -> anyhow::Result<Vec<String>> {
418 let segments = query
419 .split("::")
420 .filter(|segment| !segment.is_empty())
421 .map(str::to_owned)
422 .collect::<Vec<_>>();
423 if segments.is_empty() {
424 bail!("query must not be empty");
425 }
426 Ok(segments)
427}
428
429fn build_query(package_name: &str, segments: &[String]) -> String {
430 if segments.is_empty() {
431 package_name.to_owned()
432 } else {
433 format!("{package_name}::{}", segments.join("::"))
434 }
435}
436
437fn resolve_relative_reexport(
438 target_segments: &[String],
439 containing_module_segments: &[String],
440) -> anyhow::Result<Option<Vec<String>>> {
441 let Some(first) = target_segments.first() else {
442 return Ok(None);
443 };
444
445 match first.as_str() {
446 "crate" => Ok(Some(target_segments[1..].to_vec())),
447 "self" => Ok(Some(
448 containing_module_segments
449 .iter()
450 .cloned()
451 .chain(target_segments[1..].iter().cloned())
452 .collect(),
453 )),
454 "super" => {
455 let mut module_segments = containing_module_segments.to_vec();
456 let mut index = 0;
457 while target_segments
458 .get(index)
459 .is_some_and(|segment| segment == "super")
460 {
461 if module_segments.pop().is_none() {
462 bail!("re-export path moves above crate root");
463 }
464 index += 1;
465 }
466 Ok(Some(
467 module_segments
468 .into_iter()
469 .chain(target_segments[index..].iter().cloned())
470 .collect(),
471 ))
472 }
473 _ => Ok(None),
474 }
475}
476
477#[derive(Clone)]
478struct DependencySpec {
479 package_name: String,
480 version: Option<Version>,
481 path: Option<PathBuf>,
482}
483
484fn parse_dependencies(crate_root: &Path) -> anyhow::Result<HashMap<String, DependencySpec>> {
485 let manifest_path = crate_root.join("Cargo.toml");
486 let manifest = read_manifest(&manifest_path)?;
487 let workspace_deps = read_workspace_dependencies(crate_root)?;
488
489 let mut deps = HashMap::new();
490 collect_dependency_table(
491 &mut deps,
492 &manifest,
493 "dependencies",
494 workspace_deps.as_ref(),
495 );
496 collect_target_dependency_tables(&mut deps, &manifest, workspace_deps.as_ref());
497
498 Ok(deps)
499}
500
501fn read_manifest(manifest_path: &Path) -> anyhow::Result<toml_edit::DocumentMut> {
502 let manifest = fs::read_to_string(manifest_path)
503 .with_context(|| format!("failed to read manifest {}", manifest_path.display()))?;
504 manifest
505 .parse::<toml_edit::DocumentMut>()
506 .with_context(|| format!("failed to parse manifest {}", manifest_path.display()))
507}
508
509fn read_workspace_dependencies(
510 crate_root: &Path,
511) -> anyhow::Result<Option<HashMap<String, DependencySpec>>> {
512 let Some(workspace_manifest_path) = find_workspace_manifest(crate_root)? else {
513 return Ok(None);
514 };
515 let workspace_manifest = read_manifest(&workspace_manifest_path)?;
516 let Some(table) = workspace_manifest
517 .get("workspace")
518 .and_then(toml_edit::Item::as_table_like)
519 .and_then(|workspace| workspace.get("dependencies"))
520 .and_then(toml_edit::Item::as_table_like)
521 else {
522 return Ok(None);
523 };
524
525 let mut deps = HashMap::new();
526 for (alias, value) in table.iter() {
527 deps.insert(alias.to_owned(), parse_dependency_spec(alias, value));
528 }
529
530 Ok(Some(deps))
531}
532
533fn find_workspace_manifest(crate_root: &Path) -> anyhow::Result<Option<PathBuf>> {
534 for dir in crate_root.ancestors() {
535 let manifest_path = dir.join("Cargo.toml");
536 if !manifest_path.is_file() {
537 continue;
538 }
539
540 let manifest = read_manifest(&manifest_path)?;
541 if manifest.get("workspace").is_some() {
542 return Ok(Some(manifest_path));
543 }
544 }
545
546 Ok(None)
547}
548
549fn collect_dependency_table(
550 deps: &mut HashMap<String, DependencySpec>,
551 manifest: &toml_edit::DocumentMut,
552 table_name: &str,
553 workspace_deps: Option<&HashMap<String, DependencySpec>>,
554) {
555 if let Some(table) = manifest
556 .get(table_name)
557 .and_then(toml_edit::Item::as_table_like)
558 {
559 for (alias, value) in table.iter() {
560 let spec = parse_dependency_spec_with_workspace(alias, value, workspace_deps);
561 deps.insert(alias.to_owned(), spec);
562 }
563 }
564}
565
566fn collect_target_dependency_tables(
567 deps: &mut HashMap<String, DependencySpec>,
568 manifest: &toml_edit::DocumentMut,
569 workspace_deps: Option<&HashMap<String, DependencySpec>>,
570) {
571 let Some(targets) = manifest
572 .get("target")
573 .and_then(toml_edit::Item::as_table_like)
574 else {
575 return;
576 };
577
578 for (_, target) in targets.iter() {
579 let Some(dependencies) = target
580 .as_table_like()
581 .and_then(|target| target.get("dependencies"))
582 .and_then(toml_edit::Item::as_table_like)
583 else {
584 continue;
585 };
586
587 for (alias, value) in dependencies.iter() {
588 let spec = parse_dependency_spec_with_workspace(alias, value, workspace_deps);
589 deps.insert(alias.to_owned(), spec);
590 }
591 }
592}
593
594fn parse_dependency_spec(alias: &str, value: &toml_edit::Item) -> DependencySpec {
604 if let Some(version) = value.as_str() {
605 return DependencySpec {
606 package_name: alias.to_owned(),
607 version: Version::parse(version).ok(),
608 path: None,
609 };
610 }
611
612 if let Some(table) = value.as_inline_table() {
613 return DependencySpec {
614 package_name: table
615 .get("package")
616 .and_then(toml_edit::Value::as_str)
617 .unwrap_or(alias)
618 .to_owned(),
619 version: table
620 .get("version")
621 .and_then(toml_edit::Value::as_str)
622 .and_then(|version| Version::parse(version).ok()),
623 path: table
624 .get("path")
625 .and_then(toml_edit::Value::as_str)
626 .map(PathBuf::from),
627 };
628 }
629
630 if let Some(table) = value.as_table_like() {
631 return DependencySpec {
632 package_name: table
633 .get("package")
634 .and_then(toml_edit::Item::as_str)
635 .unwrap_or(alias)
636 .to_owned(),
637 version: table
638 .get("version")
639 .and_then(toml_edit::Item::as_str)
640 .and_then(|version| Version::parse(version).ok()),
641 path: table
642 .get("path")
643 .and_then(toml_edit::Item::as_str)
644 .map(PathBuf::from),
645 };
646 }
647
648 DependencySpec {
649 package_name: alias.to_owned(),
650 version: None,
651 path: None,
652 }
653}
654
655fn parse_dependency_spec_with_workspace(
656 alias: &str,
657 value: &toml_edit::Item,
658 workspace_deps: Option<&HashMap<String, DependencySpec>>,
659) -> DependencySpec {
660 let mut spec = parse_dependency_spec(alias, value);
661 if dependency_uses_workspace_inheritance(value)
662 && let Some(workspace_spec) = workspace_deps.and_then(|deps| deps.get(alias))
663 {
664 if spec.package_name == alias {
665 spec.package_name.clone_from(&workspace_spec.package_name);
666 }
667 if spec.version.is_none() {
668 spec.version.clone_from(&workspace_spec.version);
669 }
670 }
671 spec
672}
673
674fn dependency_uses_workspace_inheritance(value: &toml_edit::Item) -> bool {
675 get_dep_bool_field(value, "workspace").unwrap_or(false)
676}
677
678fn get_dep_value<'a>(dep: &'a toml_edit::Item, key: &str) -> Option<&'a toml_edit::Value> {
679 dep.as_table()
680 .and_then(|t| t.get(key))
681 .and_then(toml_edit::Item::as_value)
682 .or_else(|| dep.as_inline_table().and_then(|t| t.get(key)))
683}
684
685fn get_dep_bool_field(dep: &toml_edit::Item, key: &str) -> Option<bool> {
686 get_dep_value(dep, key).and_then(toml_edit::Value::as_bool)
687}
688
689fn find_dependency_spec<'a>(
690 dependencies: &'a HashMap<String, DependencySpec>,
691 rust_crate_name: &str,
692) -> Option<&'a DependencySpec> {
693 dependencies.get(rust_crate_name).or_else(|| {
694 dependencies
695 .iter()
696 .find(|(alias, _)| normalize_dependency_alias(alias) == rust_crate_name)
697 .map(|(_, spec)| spec)
698 })
699}
700
701fn normalize_dependency_alias(alias: &str) -> String {
702 alias.replace('-', "_")
703}
704
705async fn fetch_exact_registry_candidate(
706 client: &Client,
707 registry: Registry,
708 crate_name: &str,
709) -> anyhow::Result<Option<ExactCrate>> {
710 let crate_url = format!("https://{registry}/api/v1/crates/{crate_name}");
711 let response = client
712 .get(&crate_url)
713 .send()
714 .await
715 .with_context(|| format!("request failed for '{crate_name}'"))?;
716
717 if response.status() == StatusCode::NOT_FOUND {
718 return Ok(None);
719 }
720
721 let response = response
722 .error_for_status()
723 .with_context(|| format!("registry returned an error for '{crate_name}'"))?
724 .json::<ExactCrateResponse>()
725 .await
726 .with_context(|| format!("invalid crate metadata for '{crate_name}'"))?;
727
728 Ok(Some(ExactCrate {
729 max_version: response.crate_info.max_version,
730 }))
731}
732
733#[derive(Deserialize)]
734struct ExactCrateResponse {
735 #[serde(rename = "crate")]
736 crate_info: ExactCrateInfo,
737}
738
739#[derive(Deserialize)]
740struct ExactCrateInfo {
741 max_version: String,
742}
743
744struct ExactCrate {
745 max_version: String,
746}
747
748async fn download_crate_archive(
749 client: &Client,
750 registry: Registry,
751 crate_name: &str,
752 version: &str,
753) -> anyhow::Result<Vec<u8>> {
754 let download_url = format!("https://{registry}/api/v1/crates/{crate_name}/{version}/download");
755 let archive = client
756 .get(&download_url)
757 .send()
758 .await
759 .with_context(|| format!("download request failed for {crate_name}"))?
760 .error_for_status()
761 .with_context(|| format!("download endpoint returned an error for {crate_name}"))?
762 .bytes()
763 .await
764 .with_context(|| format!("failed to read downloaded source for {crate_name}"))?;
765
766 Ok(archive.to_vec())
767}
768
769async fn cache_crate_source(
770 client: &Client,
771 registry: Registry,
772 crate_name: &str,
773 version: &str,
774) -> anyhow::Result<PathBuf> {
775 let package_root = package_cache_root(registry, crate_name)?;
776 let crate_root = package_root.join(version);
777
778 if crate_root.join("Cargo.toml").is_file() {
779 return Ok(crate_root);
780 }
781
782 let archive_bytes = download_crate_archive(client, registry, crate_name, version).await?;
783 extract_crate_archive(&archive_bytes, &package_root, crate_name, version)?;
784 update_latest_symlink(&package_root, version)?;
785
786 if crate_root.join("Cargo.toml").is_file() {
787 Ok(crate_root)
788 } else {
789 bail!("cached crate source is missing Cargo.toml for {crate_name} {version}");
790 }
791}
792
793fn registry_cache_root(registry: Registry) -> anyhow::Result<PathBuf> {
794 let cache_root = std::env::temp_dir()
795 .join("gears-docs-cache")
796 .join(sanitize_registry_name(registry));
797 fs::create_dir_all(&cache_root)
798 .with_context(|| format!("failed to create cache dir {}", cache_root.display()))?;
799 Ok(cache_root)
800}
801
802fn package_cache_root(registry: Registry, crate_name: &str) -> anyhow::Result<PathBuf> {
803 let package_root = registry_cache_root(registry)?.join(crate_name);
804 fs::create_dir_all(&package_root).with_context(|| {
805 format!(
806 "failed to create package cache dir {}",
807 package_root.display()
808 )
809 })?;
810 Ok(package_root)
811}
812
813fn resolve_from_cache(
814 registry: Registry,
815 crate_name: &str,
816 query: &str,
817 requested_version: Option<&Version>,
818) -> anyhow::Result<Option<ResolvedMetadataPath>> {
819 let package_root = package_cache_root(registry, crate_name)?;
820
821 if let Some(requested_version) = requested_version {
822 let crate_root = package_root.join(requested_version.to_string());
823 return resolve_from_cached_root(&crate_root, query);
824 }
825
826 let latest_link = package_root.join("latest");
827 if let Some(resolved) = resolve_from_cached_root(&latest_link, query)? {
828 return Ok(Some(resolved));
829 }
830
831 let mut cached_versions = cached_package_versions(&package_root)?;
837 cached_versions
838 .sort_by(|(left_version, _), (right_version, _)| right_version.cmp(left_version));
839
840 if let Some((latest_version, _)) = cached_versions.first() {
841 let needs_update = fs::read_link(&latest_link)
842 .ok()
843 .and_then(|target| target.file_name().map(OsStr::to_os_string))
844 .is_none_or(|current| current != latest_version.to_string().as_str());
845 if needs_update {
846 update_latest_symlink(&package_root, &latest_version.to_string())?;
847 }
848 }
849
850 for (_, crate_root) in cached_versions {
851 if let Some(resolved) = resolve_from_cached_root(&crate_root, query)? {
852 return Ok(Some(resolved));
853 }
854 }
855
856 Ok(None)
857}
858
859fn resolve_from_cached_root(
860 crate_root: &Path,
861 query: &str,
862) -> anyhow::Result<Option<ResolvedMetadataPath>> {
863 if !crate_root.join("Cargo.toml").is_file() {
864 return Ok(None);
865 }
866
867 resolve_source_from_metadata(crate_root, query)
868}
869
870fn cached_package_versions(package_root: &Path) -> anyhow::Result<Vec<(Version, PathBuf)>> {
871 Ok(fs::read_dir(package_root)
872 .with_context(|| format!("failed to read cache dir {}", package_root.display()))?
873 .filter_map(|entry| {
874 let entry = entry.ok()?;
875 let file_name = entry.file_name();
876 let file_name = file_name.to_str()?;
877 if file_name == "latest" {
878 return None;
879 }
880
881 let crate_root = entry.path();
882 if !crate_root.join("Cargo.toml").is_file() {
883 return None;
884 }
885
886 Some((Version::parse(file_name).ok()?, crate_root))
887 })
888 .collect::<Vec<_>>())
889}
890
891fn clean_registry_cache(registry: Registry) -> anyhow::Result<()> {
892 let cache_root = std::env::temp_dir()
893 .join("gears-docs-cache")
894 .join(sanitize_registry_name(registry));
895 if cache_root.exists() {
896 fs::remove_dir_all(&cache_root)
897 .with_context(|| format!("failed to remove cache dir {}", cache_root.display()))?;
898 }
899 Ok(())
900}
901
902fn sanitize_registry_name(registry: Registry) -> String {
903 registry
904 .as_str()
905 .chars()
906 .map(|ch| {
907 if ch.is_ascii_alphanumeric() || ch == '.' || ch == '-' || ch == '_' {
908 ch
909 } else {
910 '_'
911 }
912 })
913 .collect()
914}
915
916fn extract_crate_archive(
917 archive_bytes: &[u8],
918 package_root: &Path,
919 crate_name: &str,
920 version: &str,
921) -> anyhow::Result<()> {
922 let decoder = GzDecoder::new(Cursor::new(archive_bytes));
923 let mut archive = tar::Archive::new(decoder);
924 archive.unpack(package_root).with_context(|| {
925 format!(
926 "failed to unpack crate archive into {}",
927 package_root.display()
928 )
929 })?;
930
931 let extracted_root = package_root.join(format!("{crate_name}-{version}"));
932 let crate_root = package_root.join(version);
933 if extracted_root != crate_root && extracted_root.exists() && !crate_root.exists() {
934 fs::rename(&extracted_root, &crate_root).with_context(|| {
935 format!(
936 "failed to move extracted crate from {} to {}",
937 extracted_root.display(),
938 crate_root.display()
939 )
940 })?;
941 }
942
943 if crate_root.join("Cargo.toml").is_file() {
944 Ok(())
945 } else {
946 bail!("crate archive did not extract expected root for {crate_name} {version}")
947 }
948}
949
950fn update_latest_symlink(package_root: &Path, version: &str) -> anyhow::Result<()> {
951 let latest_link = package_root.join("latest");
952 let target = Path::new(version);
953
954 if let Ok(metadata) = fs::symlink_metadata(&latest_link) {
955 if metadata.file_type().is_symlink() {
956 remove_symlink(&latest_link)?;
957 } else if metadata.is_dir() {
958 fs::remove_dir_all(&latest_link).with_context(|| {
959 format!(
960 "failed to remove existing latest entry {}",
961 latest_link.display()
962 )
963 })?;
964 } else {
965 fs::remove_file(&latest_link).with_context(|| {
966 format!(
967 "failed to remove existing latest entry {}",
968 latest_link.display()
969 )
970 })?;
971 }
972 }
973
974 create_dir_symlink(target, &latest_link)
975}
976
977#[cfg(unix)]
978fn create_dir_symlink(target: &Path, link: &Path) -> anyhow::Result<()> {
979 std::os::unix::fs::symlink(target, link).with_context(|| {
980 format!(
981 "failed to create symlink from {} to {}",
982 link.display(),
983 target.display()
984 )
985 })
986}
987
988#[cfg(windows)]
989fn create_dir_symlink(target: &Path, link: &Path) -> anyhow::Result<()> {
990 std::os::windows::fs::symlink_dir(target, link).with_context(|| {
991 format!(
992 "failed to create symlink from {} to {}",
993 link.display(),
994 target.display()
995 )
996 })
997}
998
999#[cfg(unix)]
1000fn remove_symlink(path: &Path) -> anyhow::Result<()> {
1001 fs::remove_file(path).with_context(|| format!("failed to remove symlink {}", path.display()))
1002}
1003
1004#[cfg(windows)]
1005fn remove_symlink(path: &Path) -> anyhow::Result<()> {
1006 fs::remove_dir(path).with_context(|| format!("failed to remove symlink {}", path.display()))
1007}
1008
1009#[cfg(test)]
1010mod tests {
1011 use super::{
1012 Resolver, build_registry_client, find_dependency_spec, list_library_mappings,
1013 next_reexport_step, parse_dependencies, resolve_query_recursive,
1014 should_retry_registry_request,
1015 };
1016 use crate::common::Registry;
1017 use crate::module_parser::resolve_source_from_metadata;
1018 use crate::module_parser::test_utils::TempDirExt;
1019 use reqwest::{Method, StatusCode};
1020 use std::collections::HashSet;
1021 use std::path::Path;
1022 use tempfile::TempDir;
1023
1024 #[test]
1025 fn next_reexport_step_prefers_current_module_for_bare_reexports() {
1026 let project = TempDir::new().expect("temp dir should be created");
1027 project.write(
1028 "Cargo.toml",
1029 r#"
1030 [package]
1031 name = "cf-modkit"
1032 version = "0.5.4"
1033 edition = "2024"
1034 "#,
1035 );
1036 project.write(
1037 "src/lib.rs",
1038 r"
1039 pub mod gts;
1040 ",
1041 );
1042 project.write(
1043 "src/gts/mod.rs",
1044 r"
1045 pub mod plugin;
1046 pub use plugin::BaseModkitPluginV1;
1047 ",
1048 );
1049 project.write(
1050 "src/gts/plugin.rs",
1051 r"
1052 pub struct BaseModkitPluginV1;
1053 ",
1054 );
1055
1056 let query = "cf-modkit::gts::BaseModkitPluginV1";
1057 let resolved = resolve_source_from_metadata(project.path(), query)
1058 .expect("metadata query should run")
1059 .expect("query should resolve");
1060
1061 let next_step = next_reexport_step(project.path(), &resolved, query)
1062 .expect("re-export step should resolve")
1063 .expect("re-export step should exist");
1064
1065 assert_eq!(
1066 next_step.query,
1067 "cf-modkit::gts::plugin::BaseModkitPluginV1"
1068 );
1069 assert!(next_step.requested_version.is_none());
1070 assert_eq!(next_step.preferred_path, project.path());
1071 }
1072
1073 #[test]
1074 fn resolve_query_recursive_follows_bare_relative_reexports() {
1075 let project = TempDir::new().expect("temp dir should be created");
1076 project.write(
1077 "Cargo.toml",
1078 r#"
1079 [package]
1080 name = "cf-modkit"
1081 version = "0.5.4"
1082 edition = "2024"
1083 "#,
1084 );
1085 project.write(
1086 "src/lib.rs",
1087 r"
1088 pub mod gts;
1089 ",
1090 );
1091 project.write(
1092 "src/gts/mod.rs",
1093 r"
1094 pub mod plugin;
1095 pub use plugin::BaseModkitPluginV1;
1096 ",
1097 );
1098 project.write(
1099 "src/gts/plugin.rs",
1100 r"
1101 pub struct BaseModkitPluginV1;
1102 ",
1103 );
1104
1105 let client = build_registry_client().expect("client should build");
1106 let runtime = tokio::runtime::Builder::new_current_thread()
1107 .enable_all()
1108 .build()
1109 .expect("runtime should build");
1110 let resolver = Resolver {
1111 workspace_path: project.path(),
1112 client: &client,
1113 runtime: &runtime,
1114 registry: Registry::CratesIo,
1115 };
1116 let mut visited = HashSet::new();
1117
1118 let resolved_query = resolve_query_recursive(
1119 &resolver,
1120 project.path(),
1121 "cf-modkit::gts::BaseModkitPluginV1",
1122 None,
1123 &mut visited,
1124 )
1125 .expect("recursive resolution should succeed");
1126
1127 assert!(
1128 resolved_query
1129 .source
1130 .contains("pub struct BaseModkitPluginV1;")
1131 );
1132 assert!(
1133 resolved_query
1134 .source_path
1135 .ends_with(Path::new("src/gts/plugin.rs"))
1136 );
1137 }
1138
1139 #[test]
1140 fn parse_dependencies_inherits_workspace_package_name() {
1141 let project = TempDir::new().expect("temp dir should be created");
1142 project.write(
1143 "Cargo.toml",
1144 r#"
1145 [workspace]
1146 members = ["cf-modkit", "cf-modkit-macros"]
1147 resolver = "3"
1148
1149 [workspace.dependencies]
1150 modkit_macros = { package = "cf-modkit-macros", version = "0.5.4" }
1151 "#,
1152 );
1153 project.write(
1154 "cf-modkit/Cargo.toml",
1155 r#"
1156 [package]
1157 name = "cf-modkit"
1158 version = "0.5.4"
1159 edition = "2024"
1160
1161 [dependencies]
1162 modkit_macros = { workspace = true }
1163 "#,
1164 );
1165
1166 let dependencies =
1167 parse_dependencies(&project.path().join("cf-modkit")).expect("dependencies parse");
1168 let dep = dependencies
1169 .get("modkit_macros")
1170 .expect("workspace dependency should be present");
1171
1172 assert_eq!(dep.package_name, "cf-modkit-macros");
1173 assert_eq!(
1174 dep.version.as_ref().map(ToString::to_string).as_deref(),
1175 Some("0.5.4")
1176 );
1177 }
1178
1179 #[test]
1180 fn find_dependency_spec_matches_hyphenated_manifest_keys() {
1181 let project = TempDir::new().expect("temp dir should be created");
1182 project.write(
1183 "Cargo.toml",
1184 r#"
1185 [package]
1186 name = "cf-modkit"
1187 version = "0.5.4"
1188 edition = "2024"
1189
1190 [dependencies]
1191 cf-modkit-macros = "0.5.4"
1192 "#,
1193 );
1194
1195 let dependencies = parse_dependencies(project.path()).expect("dependencies parse");
1196 let dep = find_dependency_spec(&dependencies, "cf_modkit_macros")
1197 .expect("hyphenated dependency should match underscore crate name");
1198
1199 assert_eq!(dep.package_name, "cf-modkit-macros");
1200 }
1201
1202 #[test]
1203 fn registry_retry_classifier_only_retries_transient_gets() {
1204 assert!(should_retry_registry_request(
1205 &Method::GET,
1206 Some(StatusCode::TOO_MANY_REQUESTS),
1207 false,
1208 ));
1209 assert!(should_retry_registry_request(
1210 &Method::GET,
1211 Some(StatusCode::SERVICE_UNAVAILABLE),
1212 false,
1213 ));
1214 assert!(should_retry_registry_request(&Method::GET, None, true));
1215 assert!(!should_retry_registry_request(
1216 &Method::GET,
1217 Some(StatusCode::NOT_FOUND),
1218 false,
1219 ));
1220 assert!(!should_retry_registry_request(
1221 &Method::POST,
1222 Some(StatusCode::SERVICE_UNAVAILABLE),
1223 false,
1224 ));
1225 }
1226
1227 #[test]
1228 fn resolve_query_recursive_uses_workspace_package_name_for_external_reexports() {
1229 let project = TempDir::new().expect("temp dir should be created");
1230 project.write(
1231 "Cargo.toml",
1232 r#"
1233 [workspace]
1234 members = ["cf-modkit", "cf-modkit-macros"]
1235 resolver = "3"
1236
1237 [workspace.dependencies]
1238 modkit_macros = { package = "cf-modkit-macros", path = "cf-modkit-macros" }
1239 "#,
1240 );
1241 project.write(
1242 "cf-modkit/Cargo.toml",
1243 r#"
1244 [package]
1245 name = "cf-modkit"
1246 version = "0.5.4"
1247 edition = "2024"
1248
1249 [dependencies]
1250 modkit_macros = { workspace = true }
1251 "#,
1252 );
1253 project.write(
1254 "cf-modkit/src/lib.rs",
1255 r"
1256 pub use modkit_macros::module;
1257 ",
1258 );
1259 project.write(
1260 "cf-modkit-macros/Cargo.toml",
1261 r#"
1262 [package]
1263 name = "cf-modkit-macros"
1264 version = "0.5.4"
1265 edition = "2024"
1266 "#,
1267 );
1268 project.write(
1269 "cf-modkit-macros/src/lib.rs",
1270 r"
1271 pub fn module() {}
1272 ",
1273 );
1274
1275 let client = build_registry_client().expect("client should build");
1276 let runtime = tokio::runtime::Builder::new_current_thread()
1277 .enable_all()
1278 .build()
1279 .expect("runtime should build");
1280 let resolver = Resolver {
1281 workspace_path: project.path(),
1282 client: &client,
1283 runtime: &runtime,
1284 registry: Registry::CratesIo,
1285 };
1286 let mut visited = HashSet::new();
1287
1288 let resolved_query = resolve_query_recursive(
1289 &resolver,
1290 &project.path().join("cf-modkit"),
1291 "cf-modkit::module",
1292 None,
1293 &mut visited,
1294 )
1295 .expect("recursive resolution should succeed");
1296
1297 assert_eq!(resolved_query.package_name, "cf-modkit-macros");
1298 assert!(resolved_query.source.contains("pub fn module() {}"));
1299 assert!(
1300 resolved_query
1301 .source_path
1302 .ends_with(Path::new("cf-modkit-macros/src/lib.rs"))
1303 );
1304 }
1305
1306 #[test]
1307 fn list_library_mappings_requires_package_only_query() {
1308 let project = TempDir::new().expect("temp dir should be created");
1309 project.write(
1310 "Cargo.toml",
1311 r#"
1312 [package]
1313 name = "cf-modkit"
1314 version = "0.5.4"
1315 edition = "2024"
1316 "#,
1317 );
1318
1319 let error = list_library_mappings(Some(project.path()), "cf-modkit::module")
1320 .expect_err("non-package query should fail");
1321
1322 assert!(
1323 error
1324 .to_string()
1325 .contains("--libs requires a package-only query")
1326 );
1327 }
1328
1329 #[test]
1330 fn list_library_mappings_returns_source_code_names() {
1331 let project = TempDir::new().expect("temp dir should be created");
1332 project.write(
1333 "Cargo.toml",
1334 r#"
1335 [workspace]
1336 members = ["cf-modkit", "cf-modkit-macros"]
1337 resolver = "3"
1338 "#,
1339 );
1340 project.write(
1341 "cf-modkit/Cargo.toml",
1342 r#"
1343 [package]
1344 name = "cf-modkit"
1345 version = "0.5.4"
1346 edition = "2024"
1347
1348 [lib]
1349 name = "modkit"
1350 path = "src/lib.rs"
1351
1352 [dependencies]
1353 modkit_macros = { package = "cf-modkit-macros", path = "../cf-modkit-macros" }
1354 "#,
1355 );
1356 project.write(
1357 "cf-modkit/src/lib.rs",
1358 r"
1359 pub use modkit_macros::module;
1360 ",
1361 );
1362 project.write(
1363 "cf-modkit-macros/Cargo.toml",
1364 r#"
1365 [package]
1366 name = "cf-modkit-macros"
1367 version = "0.5.4"
1368 edition = "2024"
1369
1370 [lib]
1371 proc-macro = true
1372 "#,
1373 );
1374 project.write(
1375 "cf-modkit-macros/src/lib.rs",
1376 r"
1377 use proc_macro::TokenStream;
1378
1379 #[proc_macro_attribute]
1380 pub fn module(_attr: TokenStream, item: TokenStream) -> TokenStream {
1381 item
1382 }
1383 ",
1384 );
1385
1386 let mappings = list_library_mappings(Some(&project.path().join("cf-modkit")), "cf-modkit")
1387 .expect("mappings should resolve");
1388
1389 assert_eq!(
1390 mappings
1391 .iter()
1392 .map(|mapping| format!("{} -> {}", mapping.library_name, mapping.package_name))
1393 .collect::<Vec<_>>(),
1394 vec![
1395 "modkit -> cf-modkit".to_owned(),
1396 "modkit_macros -> cf-modkit-macros".to_owned(),
1397 ]
1398 );
1399 }
1400}