Skip to main content

cargo_gears_core/source/
mod.rs

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/// Resolve Rust source code from a crate
19#[derive(Clone, Debug, Eq, PartialEq)]
20pub struct SourceParams {
21    /// Path to the Cargo workspace or crate to inspect
22    pub path: PathBuf,
23    /// Registry to query when the crate is not present in local metadata
24    pub registry: Registry,
25    /// Print query/package/version/source metadata before the resolved Rust source
26    pub verbose: bool,
27    /// List `library_name` -> `package_name` mappings for a package query
28    pub libs: bool,
29    /// Resolve a specific crate version after metadata/cache lookup misses
30    pub version: Option<Version>,
31    /// Remove the source cache for the selected registry before resolving
32    pub clean: bool,
33    /// Rust path to resolve(start always by `package_name`), for example `cf-modkit` it will resolve the lib.rs
34    /// You can resolve modules `tokio::sync` to resolve the source code from the sync module from tokio crate
35    /// You can also resolve by function name, for example `cf-modkit::gts::plugin::BaseModkitPluginV1`
36    /// Also resolve by function name, for instance `cf-modkit::gts::schemas::get_core_gts_schemas`
37    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
594/// Parses a dependency entry from a `Cargo.toml` `[dependencies]` table.
595///
596/// Only exact three-component semver versions (e.g. `"1.0.210"`) are captured
597/// in `DependencySpec::version`. Version *requirements* such as `"^1.0"`,
598/// `">=1.0"`, or two-component shorthand like `"1.0"` will not parse as
599/// `semver::Version` and are silently treated as `None`. This is intentional:
600/// the version field is only used for exact cache lookups and crate downloads,
601/// and non-exact requirements cause a fallback to the registry's latest version
602/// which is the correct behaviour for doc resolution.
603fn 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    // NOTE: the symlink update below is not protected against concurrent processes.
832    // If multiple `gears src` invocations race here, the symlink may be
833    // updated more than once, but the result is still correct (points to the
834    // highest cached version). Add file-based locking on `package_root` if this
835    // ever becomes a problem in practice.
836    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}