Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions e2e/cli/test_error_display
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ local_assert_fail "mise install core:node@invalid-version" \
# Test 2: Backend error (cargo)
echo "Test 2: Cargo backend error"
local_assert_fail "mise install cargo:nonexistent-crate-12345@1.0.0" \
"mise ERROR Failed to install cargo:nonexistent-crate-12345@1.0.0: HTTP status client error (404 Not Found) for url (https://crates.io/api/v1/crates/nonexistent-crate-12345/versions)"
"mise ERROR Failed to install cargo:nonexistent-crate-12345@1.0.0: HTTP status client error (404 Not Found) for url (https://index.crates.io/no/ne/nonexistent-crate-12345)"

# Test 3: GitHub repository not found
echo "Test 3: GitHub repository not found"
Expand Down Expand Up @@ -89,7 +89,7 @@ local_assert_fail "mise install core:node@invalid-version" \
# Test 2: Backend error (cargo) - check for error format with full chain
echo "Test 2: Cargo backend error"
local_assert_fail "mise install cargo:nonexistent-crate-12345@1.0.0" \
"0: Failed to install cargo:nonexistent-crate-12345@1.0.0: HTTP status client error (404 Not Found) for url (https://crates.io/api/v1/crates/nonexistent-crate-12345/versions)"
"0: Failed to install cargo:nonexistent-crate-12345@1.0.0: HTTP status client error (404 Not Found) for url (https://index.crates.io/no/ne/nonexistent-crate-12345)"

# Test 3: GitHub repository not found - check for error format with full chain
echo "Test 3: GitHub repository not found"
Expand Down
110 changes: 85 additions & 25 deletions src/backend/cargo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use std::{fmt::Debug, sync::Arc};
use async_trait::async_trait;
use color_eyre::Section;
use eyre::{bail, eyre};
use serde_json::Deserializer;
use url::Url;

mod native_binstall;
Expand Down Expand Up @@ -157,26 +158,11 @@ impl Backend for CargoBackend {
}]);
}

// Use crates.io API which includes created_at timestamps
let url = format!(
"https://crates.io/api/v1/crates/{}/versions",
self.tool_name()
);
let response: CratesIoVersionsResponse = HTTP_FETCH.json(&url).await?;

let versions = response
.versions
.into_iter()
.filter(|v| !v.yanked)
.map(|v| VersionInfo {
version: v.num,
created_at: Some(v.created_at),
..Default::default()
})
.rev() // API returns newest first, we want oldest first
.collect();
let response = HTTP_FETCH
.get_text(get_crate_url(&self.tool_name())?)
.await?;

Ok(versions)
parse_crate_versions(&response)
}

async fn install_version_(&self, ctx: &InstallContext, tv: ToolVersion) -> Result<ToolVersion> {
Expand Down Expand Up @@ -428,16 +414,44 @@ fn format_tool_options(options: &[&'static str]) -> String {
.join(", ")
}

#[derive(Debug, serde::Deserialize)]
struct CratesIoVersionsResponse {
versions: Vec<CratesIoVersion>,
fn get_crate_url(name: &str) -> eyre::Result<Url> {
if name.is_empty() || !name.is_ascii() {
bail!("invalid Cargo crate name: {name:?}");
}
let name = name.to_lowercase();
let url = match name.len() {
1 => format!("https://index.crates.io/1/{name}"),
2 => format!("https://index.crates.io/2/{name}"),
3 => format!("https://index.crates.io/3/{}/{name}", &name[..1]),
_ => format!(
"https://index.crates.io/{}/{}/{name}",
&name[..2],
&name[2..4]
),
};
Ok(url.parse()?)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}

fn parse_crate_versions(response: &str) -> eyre::Result<Vec<VersionInfo>> {
let mut versions = vec![];
for version in Deserializer::from_str(response).into_iter::<CrateVersion>() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The sparse index format guarantees that version entries are stored in publication order (oldest first), which is why .rev() from the old API path was dropped. A short comment here would make that implicit assumption explicit and prevent a future reader from "restoring" the reversal by mistake.

Suggested change
for version in Deserializer::from_str(response).into_iter::<CrateVersion>() {
// Sparse index records are stored oldest-first (unlike the crates.io REST API).
for version in Deserializer::from_str(response).into_iter::<CrateVersion>() {

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

let version = version?;
if !version.yanked {
versions.push(VersionInfo {
version: version.vers,
created_at: version.pubtime,
..Default::default()
});
}
}
Ok(versions)
}

#[derive(Debug, serde::Deserialize)]
struct CratesIoVersion {
num: String,
struct CrateVersion {
vers: String,
yanked: bool,
created_at: String,
pubtime: Option<String>,
}

#[cfg(test)]
Expand Down Expand Up @@ -560,4 +574,50 @@ mod tests {
vec!["default-features"]
);
}

#[test]
fn crate_index_url_uses_sparse_index_layout() {
let cases = [
("a", "https://index.crates.io/1/a"),
("Ab", "https://index.crates.io/2/ab"),
("Abc", "https://index.crates.io/3/a/abc"),
("Cargo-Deny", "https://index.crates.io/ca/rg/cargo-deny"),
];

for (name, expected) in cases {
assert_eq!(get_crate_url(name).unwrap().as_str(), expected);
}
}

#[test]
fn crate_index_url_rejects_empty_name() {
let error = get_crate_url("").unwrap_err();

assert_eq!(error.to_string(), "invalid Cargo crate name: \"\"");
}

#[test]
fn crate_index_url_rejects_non_ascii_name() {
let error = get_crate_url("aébc").unwrap_err();

assert_eq!(error.to_string(), "invalid Cargo crate name: \"aébc\"");
}

#[test]
fn parse_crate_versions_uses_pubtime_and_skips_yanked_versions() {
let response = r#"{"vers":"1.0.0","yanked":false,"pubtime":"2025-01-01T00:00:00Z"}
{"vers":"1.1.0","yanked":true,"pubtime":"2025-02-01T00:00:00Z"}
{"vers":"1.2.0","yanked":false}"#;

let versions = parse_crate_versions(response).unwrap();
let versions = versions
.iter()
.map(|version| (version.version.as_str(), version.created_at.as_deref()))
.collect::<Vec<_>>();

assert_eq!(
versions,
vec![("1.0.0", Some("2025-01-01T00:00:00Z")), ("1.2.0", None),]
);
}
}