rustdoc_markdown/
cratesio.rs

1use anyhow::{Context, Result, anyhow, bail};
2use flate2::read::GzDecoder;
3
4use semver::{Version, VersionReq};
5use serde::Deserialize;
6use std::io::Cursor; // Use IoWrite alias and IMPORT Cursor
7use std::path::{Path as FilePath, PathBuf}; // Corrected use statement
8use tar::Archive;
9use tracing::{debug, info, warn};
10
11#[derive(Deserialize, Debug)]
12struct CratesApiResponse {
13    versions: Vec<CrateVersion>,
14}
15
16/// Represents a specific version of a crate from crates.io.
17#[derive(Deserialize, Debug, Clone)]
18pub struct CrateVersion {
19    /// The name of the crate.
20    #[serde(rename = "crate")]
21    pub crate_name: String,
22    /// The version number string (e.g., "1.2.3").
23    pub num: String, // Version number string
24    /// Whether this version has been yanked from crates.io.
25    pub yanked: bool,
26    /// The parsed SemVer version, populated after fetching from the API.
27    #[serde(skip)]
28    pub semver: Option<Version>, // Parsed version, populated later
29}
30
31/// Finds the best matching version of a crate on crates.io based on a version requirement.
32///
33/// It fetches version information from the crates.io API, filters out yanked versions,
34/// optionally filters by pre-release status, and then selects the highest version
35/// that satisfies the given requirement.
36///
37/// # Arguments
38///
39/// * `client`: A `reqwest::Client` for making HTTP requests.
40/// * `crate_name`: The name of the crate to search for.
41/// * `version_req_str`: A SemVer version requirement string (e.g., "1.0", "~1.2.3", "*").
42///   If "*", the latest suitable version is selected.
43/// * `include_prerelease`: If `true`, pre-release versions (e.g., "1.0.0-alpha") are considered.
44///   Otherwise, they are ignored unless explicitly matched by `version_req_str`.
45///
46/// # Returns
47///
48/// A `Result` containing the [`CrateVersion`] of the best matching version, or an error
49/// if no suitable version is found or if API interaction fails.
50pub async fn find_best_version(
51    client: &reqwest::Client,
52    crate_name: &str,
53    version_req_str: &str,
54    include_prerelease: bool,
55) -> Result<CrateVersion> {
56    info!(
57        "Fetching versions for crate '{}' from crates.io...",
58        crate_name
59    );
60    let url = format!("https://crates.io/api/v1/crates/{}", crate_name);
61    let response = client.get(&url).send().await?.error_for_status()?;
62    let mut api_data: CratesApiResponse = response
63        .json()
64        .await
65        .context("Failed to parse JSON response from crates.io API")?;
66
67    if api_data.versions.is_empty() {
68        bail!("No versions found for crate '{}'", crate_name);
69    }
70
71    // Parse semver and filter out yanked versions
72    api_data.versions.retain_mut(|v| {
73        if v.yanked {
74            debug!("Ignoring yanked version: {}", v.num);
75            return false;
76        }
77        match Version::parse(&v.num) {
78            Ok(sv) => {
79                v.semver = Some(sv);
80                true
81            }
82            Err(e) => {
83                warn!("Failed to parse version '{}': {}", v.num, e);
84                false // Ignore versions we can't parse
85            }
86        }
87    });
88
89    // Filter based on prerelease flag
90    if !include_prerelease {
91        api_data
92            .versions
93            .retain(|v| v.semver.as_ref().is_some_and(|sv| sv.pre.is_empty()));
94    }
95
96    // Sort remaining versions (highest first)
97    api_data
98        .versions
99        .sort_unstable_by(|a, b| b.semver.cmp(&a.semver)); // descending
100
101    if api_data.versions.is_empty() {
102        bail!(
103            "No suitable non-yanked{} versions found for crate '{}'",
104            if include_prerelease { "" } else { " stable" },
105            crate_name
106        );
107    }
108
109    match version_req_str {
110        "*" => {
111            // Find the latest non-prerelease (unless include_prerelease is true)
112            info!("No version specified, selecting latest suitable version...");
113            api_data.versions.into_iter().next().ok_or_else(|| {
114                anyhow!(
115                    "Could not determine the latest{} version for crate '{}'",
116                    if include_prerelease { "" } else { " stable" },
117                    crate_name
118                )
119            })
120        }
121        req_str => {
122            info!(
123                "Finding best match for version requirement '{}'...",
124                req_str
125            );
126            let req = VersionReq::parse(req_str)
127                .with_context(|| format!("Invalid version requirement string: '{}'", req_str))?;
128
129            api_data
130                .versions
131                .into_iter()
132                .find(|v| v.semver.as_ref().is_some_and(|sv| req.matches(sv)))
133                .ok_or_else(|| {
134                    anyhow!(
135                        "No version found matching requirement '{}' for crate '{}'",
136                        req_str,
137                        crate_name
138                    )
139                })
140        }
141    }
142}
143
144/// Downloads a crate from crates.io and unpacks it into the specified build directory.
145///
146/// If the crate has already been downloaded and unpacked to the target location,
147/// this function will skip the download and unpacking steps.
148///
149/// # Arguments
150///
151/// * `client`: A `reqwest::Client` for making HTTP requests.
152/// * `krate`: The [`CrateVersion`] specifying the crate and version to download.
153/// * `build_path`: The base directory where the crate source should be unpacked.
154///   The crate will be unpacked into a subdirectory like `{build_path}/{crate_name}-{version}`.
155///
156/// # Returns
157///
158/// A `Result` containing the [`PathBuf`] to the root directory of the unpacked crate source,
159/// or an error if downloading or unpacking fails.
160pub async fn download_and_unpack_crate(
161    client: &reqwest::Client,
162    krate: &CrateVersion,
163    build_path: &FilePath, // Renamed from output_path
164) -> Result<PathBuf> {
165    let crate_dir_name = format!("{}-{}", krate.crate_name, krate.num);
166    let target_dir = build_path.join(crate_dir_name); // Use build_path
167
168    if target_dir.exists() {
169        info!(
170            "Crate already downloaded and unpacked at: {}",
171            target_dir.display()
172        );
173        return Ok(target_dir);
174    }
175
176    info!("Downloading {} version {}...", krate.crate_name, krate.num);
177    let url = format!(
178        "https://crates.io/api/v1/crates/{}/{}/download",
179        krate.crate_name, krate.num
180    );
181    let response = client.get(&url).send().await?.error_for_status()?;
182
183    let content = response.bytes().await?;
184    let reader = Cursor::new(content); // Cursor is now in scope
185
186    info!("Unpacking crate to: {}", target_dir.display());
187    std::fs::create_dir_all(&target_dir)
188        .with_context(|| format!("Failed to create directory: {}", target_dir.display()))?;
189
190    let tar = GzDecoder::new(reader);
191    let mut archive = Archive::new(tar);
192
193    // Crate files are usually inside a directory like "crate_name-version/"
194    let crate_dir_prefix = format!("{}-{}/", krate.crate_name, krate.num);
195
196    for entry_result in archive.entries()? {
197        let mut entry = entry_result?;
198        let path = entry.path()?;
199
200        // Ensure we extract only files within the expected subdirectory
201        if path.starts_with(&crate_dir_prefix) {
202            let relative_path = path.strip_prefix(&crate_dir_prefix)?;
203            let dest_path = target_dir.join(relative_path);
204
205            if entry.header().entry_type().is_dir() {
206                std::fs::create_dir_all(&dest_path)?;
207            } else {
208                if let Some(parent) = dest_path.parent() {
209                    std::fs::create_dir_all(parent)?;
210                }
211                entry.unpack(&dest_path)?;
212            }
213        } else {
214            debug!("Skipping entry outside expected crate dir: {:?}", path);
215        }
216    }
217
218    info!("Unpacked to: {}", target_dir.display());
219    Ok(target_dir)
220}