use std::collections::BTreeMap;
use std::path::PathBuf;
use std::{fmt::Debug, sync::Arc};
use async_trait::async_trait;
use color_eyre::Section;
use eyre::{bail, eyre};
use serde::{Deserialize, Serialize};
use serde_json::Deserializer;
use url::Url;
mod native_binstall;
use native_binstall::NativeBinstallAction;
use crate::Result;
use crate::backend::Backend;
use crate::backend::VersionInfo;
use crate::backend::backend_type::BackendType;
use crate::backend::options::BackendOptions;
use crate::backend::platform_target::PlatformTarget;
use crate::cli::args::BackendArg;
use crate::cmd::CmdLineRunner;
use crate::config::{Config, Settings};
use crate::env::GITHUB_TOKEN;
use crate::errors::Error;
use crate::file;
use crate::http::HTTP_FETCH;
use crate::install_context::InstallContext;
use crate::toolset::{ToolRequest, ToolVersion, ToolVersionOptions, Toolset};
#[derive(Debug)]
pub(crate) struct CargoBackend {
ba: Arc<BackendArg>,
}
const CARGO_BINSTALL_NO_FALLBACK_EXIT_CODE: i32 = 94;
const CARGO_BINSTALL_DEFAULT_DISABLED_STRATEGIES: &[&str] = &["compile"];
const CARGO_INSTALL_STATE_FILENAME: &str = ".mise-cargo-install.toml";
#[derive(Debug, Clone, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(default)]
struct CargoInstallState {
features: Vec<String>,
default_features: bool,
bin: Option<String>,
#[serde(rename = "crate")]
crate_name: Option<String>,
locked: bool,
}
#[derive(Debug, Clone, Copy)]
pub(super) struct CargoOptions<'a> {
values: BackendOptions<'a>,
}
impl<'a> CargoOptions<'a> {
pub(super) fn new(raw: &'a ToolVersionOptions) -> Self {
Self {
values: BackendOptions::new(raw),
}
}
pub(super) fn bin(&self) -> Option<String> {
self.values.platform_string("bin")
}
fn bin_for_target(&self, target: &PlatformTarget) -> Option<String> {
self.values.platform_string_for_target("bin", target)
}
pub(super) fn locked(&self) -> bool {
self.values
.raw()
.get_string("locked")
.is_none_or(|v| v.to_lowercase() != "false")
}
fn features(&self) -> Option<String> {
match self.values.raw().opts.get("features") {
Some(toml::Value::Array(features)) => {
let features = features
.iter()
.filter_map(|feature| {
feature.as_str().map(str::to_string).or_else(|| {
warn!(
"invalid value in cargo features array: {feature}; expected string"
);
None
})
})
.collect::<Vec<_>>();
if features.is_empty() {
None
} else {
Some(features.join(" "))
}
}
_ => self.values.raw().get_string("features"),
}
}
fn default_features_disabled(&self) -> bool {
self.values
.raw()
.get_string("default-features")
.is_some_and(|v| v.to_lowercase() == "false")
}
pub(super) fn crate_arg(&self) -> Option<String> {
self.values.raw().get_string("crate")
}
fn install_state(&self) -> CargoInstallState {
let mut features = self
.features()
.unwrap_or_default()
.split([',', ' ', '\t', '\r', '\n'])
.filter(|feature| !feature.is_empty())
.map(str::to_string)
.collect::<Vec<_>>();
features.sort();
features.dedup();
CargoInstallState {
features,
default_features: !self.default_features_disabled(),
bin: self.bin(),
crate_name: self.crate_arg(),
locked: self.locked(),
}
}
fn lockfile_options(&self, target: &PlatformTarget) -> BTreeMap<String, String> {
let mut result = BTreeMap::new();
if let Some(features) = self.features() {
result.insert("features".to_string(), features);
}
for key in ["default-features", "crate", "locked"] {
if let Some(value) = self.values.raw().get_string(key) {
result.insert(key.to_string(), value.to_string());
}
}
if let Some(bin) = self.bin_for_target(target) {
result.insert("bin".to_string(), bin);
}
result
}
}
#[derive(Debug)]
enum BinstallStatus {
Enabled(PathBuf),
Disabled,
Unavailable,
}
#[async_trait]
impl Backend for CargoBackend {
fn get_type(&self) -> BackendType {
BackendType::Cargo
}
fn ba(&self) -> &Arc<BackendArg> {
&self.ba
}
fn get_dependencies(&self) -> eyre::Result<Vec<&str>> {
Ok(vec!["rust"])
}
fn get_optional_dependencies(&self) -> eyre::Result<Vec<&str>> {
Ok(vec!["cargo-binstall", "sccache"])
}
fn supports_lockfile_url(&self) -> bool {
false
}
fn mark_prereleases_from_version_pattern(&self) -> bool {
true
}
async fn is_install_satisfied(
&self,
config: &Arc<Config>,
tv: &ToolVersion,
check_symlink: bool,
) -> Result<bool> {
if !self.is_version_installed(config, tv, check_symlink) {
return Ok(false);
}
Ok(self.install_state_matches(tv))
}
async fn _list_remote_versions(&self, _config: &Arc<Config>) -> eyre::Result<Vec<VersionInfo>> {
if self.git_url().is_some() {
return Ok(vec![VersionInfo {
version: "HEAD".into(),
..Default::default()
}]);
}
let response = HTTP_FETCH
.get_text(get_crate_url(&self.tool_name())?)
.await?;
parse_crate_versions(&response)
}
async fn resolve_exact_version(
&self,
_config: &Arc<Config>,
version: &str,
) -> eyre::Result<Option<String>> {
if self.git_url().is_some() {
return Ok(None);
}
let version = version.strip_prefix('=').unwrap_or(version);
Ok(versions::SemVer::new(version).map(|_| version.to_string()))
}
async fn install_version_(&self, ctx: &InstallContext, tv: ToolVersion) -> Result<ToolVersion> {
self.warn_if_dependency_missing(
&ctx.config,
"cargo",
&["rust", "cargo"],
"To use cargo packages with mise, you need to install Rust first:\n\
mise use rust@latest\n\n\
Or install Rust via https://rustup.rs/",
)
.await;
let config = ctx.config.clone();
let install_arg = format!("{}@{}", self.tool_name(), tv.version);
let cargo_install_required_options =
Self::cargo_install_required_options(&tv.request.options());
if self.git_url().is_none() && cargo_install_required_options.is_empty() {
match self.binstall_status(&config, Some(&ctx.ts)).await {
BinstallStatus::Enabled(cargo_binstall) => {
let mut cmd = CmdLineRunner::new(cargo_binstall).arg("-y");
let disabled_strategies = CARGO_BINSTALL_DEFAULT_DISABLED_STRATEGIES
.iter()
.copied()
.chain(
(!Settings::get().cargo.binstall_quickinstall)
.then_some("quick-install"),
)
.collect::<Vec<_>>()
.join(",");
cmd = cmd.args(["--disable-strategies", &disabled_strategies]);
if let Some(token) = &*GITHUB_TOKEN {
cmd = cmd.env("GITHUB_TOKEN", token)
}
let result = self
.execute_install_command(ctx, &tv, cmd.arg(&install_arg))
.await;
if result.as_ref().is_ok() {
self.write_install_state_best_effort(&tv);
return Ok(tv.clone());
}
if Settings::get().cargo.binstall_only
|| !result.as_ref().is_err_and(|err| {
Error::get_exit_status(err)
== Some(CARGO_BINSTALL_NO_FALLBACK_EXIT_CODE)
})
{
result?;
}
info!("cargo-binstall found no prebuilt binary; falling back to cargo install");
}
BinstallStatus::Disabled if Settings::get().cargo.binstall_only => {
bail!("cargo-binstall is disabled, but cargo.binstall_only is set");
}
_ if Settings::get().cargo.binstall_only => {
bail!("cargo-binstall is not available, but cargo.binstall_only is set");
}
BinstallStatus::Unavailable => match Settings::get().cargo.binstall_native {
Some(true) => {
if self
.native_binstall(ctx, &tv, NativeBinstallAction::Install)
.await?
{
self.write_install_state_best_effort(&tv);
return Ok(tv.clone());
}
}
Some(false) => {}
None if native_binstall::rollout_warning_active() => {
self.native_binstall(ctx, &tv, NativeBinstallAction::WarnOnly)
.await?;
}
None => {}
},
_ => {}
}
} else if Settings::get().cargo.binstall_only
&& self.git_url().is_none()
&& !cargo_install_required_options.is_empty()
{
let options = format_tool_options(&cargo_install_required_options);
bail!(
"cargo-binstall cannot honor cargo install-only tool option(s): {options}\n\
hint: Remove the option(s), or disable cargo.binstall_only to allow cargo install"
);
} else if !cargo_install_required_options.is_empty() {
let options = format_tool_options(&cargo_install_required_options);
info!(
"not using cargo-binstall because cargo install-only tool option(s) are specified: {options}"
);
}
let mut cmd = CmdLineRunner::new(
self.spawn_program(&ctx.config, Some(&ctx.ts), "cargo")
.await,
)
.arg("install");
if let Some(url) = self.git_url() {
cmd = cmd.arg(format!("--git={url}"));
if let Some(rev) = tv.version.strip_prefix("rev:") {
cmd = cmd.arg(format!("--rev={rev}"));
} else if let Some(branch) = tv.version.strip_prefix("branch:") {
cmd = cmd.arg(format!("--branch={branch}"));
} else if let Some(tag) = tv.version.strip_prefix("tag:") {
cmd = cmd.arg(format!("--tag={tag}"));
} else if tv.version != "HEAD" {
Err(eyre!("Invalid cargo git version: {}", tv.version).note(
r#"You can specify "rev:", "branch:", or "tag:", e.g.:
* mise use cargo:eza-community/eza@tag:v0.18.0
* mise use cargo:eza-community/eza@branch:main"#,
))?;
}
} else {
cmd = cmd.arg(&install_arg);
}
self.execute_install_command(ctx, &tv, cmd).await?;
self.write_install_state_best_effort(&tv);
Ok(tv.clone())
}
fn resolve_lockfile_options(
&self,
request: &ToolRequest,
target: &PlatformTarget,
) -> Result<BTreeMap<String, String>> {
let opts = request.options();
Ok(CargoOptions::new(&opts).lockfile_options(target))
}
}
pub(crate) fn install_time_option_keys() -> Vec<String> {
vec![
"features".into(),
"default-features".into(),
"bin".into(),
"crate".into(),
"locked".into(),
]
}
impl CargoBackend {
pub(crate) fn from_arg(ba: BackendArg) -> Self {
Self { ba: Arc::new(ba) }
}
fn requested_install_state(&self, tv: &ToolVersion) -> CargoInstallState {
CargoOptions::new(&tv.request.options()).install_state()
}
fn install_state_matches(&self, tv: &ToolVersion) -> bool {
let expected = self.requested_install_state(tv);
let state_path = tv.install_path().join(CARGO_INSTALL_STATE_FILENAME);
if !state_path.exists() {
return expected
== CargoInstallState {
default_features: true,
locked: true,
..Default::default()
};
}
file::read_to_string(&state_path)
.ok()
.and_then(|body| toml::from_str::<CargoInstallState>(&body).ok())
.is_some_and(|state| state == expected)
}
fn write_install_state(&self, tv: &ToolVersion) -> Result<()> {
let state = self.requested_install_state(tv);
file::write(
tv.install_path().join(CARGO_INSTALL_STATE_FILENAME),
toml::to_string(&state)?,
)
}
fn write_install_state_best_effort(&self, tv: &ToolVersion) {
if let Err(err) = self.write_install_state(tv) {
warn!(
"failed to persist cargo install state for {}: {err:#}",
tv.style()
);
}
}
async fn execute_install_command<'a>(
&'a self,
ctx: &'a InstallContext,
tv: &'a ToolVersion,
mut cmd: CmdLineRunner<'a>,
) -> Result<()> {
let request_options = tv.request.options();
let opts = CargoOptions::new(&request_options);
if let Some(bin) = opts.bin() {
cmd = cmd.arg(format!("--bin={bin}"));
}
if opts.locked() {
cmd = cmd.arg("--locked");
}
if let Some(features) = opts.features() {
cmd = cmd.arg(format!("--features={features}"));
}
if opts.default_features_disabled() {
cmd = cmd.arg("--no-default-features");
}
if let Some(c) = opts.crate_arg() {
cmd = cmd.arg(c);
}
if let Some(registry_name) = &Settings::get().cargo.registry_name {
cmd = cmd.arg(format!("--registry={registry_name}"));
}
cmd.arg("--root")
.arg(tv.install_path())
.with_pr(ctx.pr.as_ref())
.envs(ctx.ts.env_with_path_without_tools(&ctx.config).await?)
.env_values(tv.install_env())
.prepend_path(ctx.ts.list_paths(&ctx.config).await)?
.prepend_path(
self.dependency_toolset(&ctx.config)
.await?
.list_paths(&ctx.config)
.await,
)?
.execute()
}
fn cargo_install_required_options(opts: &ToolVersionOptions) -> Vec<&'static str> {
let mut options = vec![];
if CargoOptions::new(opts)
.features()
.is_some_and(|features| !features.trim().is_empty())
{
options.push("features");
}
if opts
.get_string("default-features")
.is_some_and(|default_features| default_features.to_lowercase() == "false")
{
options.push("default-features");
}
options
}
async fn binstall_status(&self, config: &Arc<Config>, ts: Option<&Toolset>) -> BinstallStatus {
if !Settings::get().cargo.binstall {
return BinstallStatus::Disabled;
}
if let Some(cargo_binstall) = self
.dependency_path_for_install(config, ts, "cargo-binstall")
.await
{
return BinstallStatus::Enabled(cargo_binstall);
}
BinstallStatus::Unavailable
}
async fn native_binstall(
&self,
ctx: &InstallContext,
tv: &ToolVersion,
action: NativeBinstallAction,
) -> Result<bool> {
match native_binstall::install(ctx, tv, &self.tool_name(), action).await {
Ok(true) => Ok(true),
Ok(false) => Ok(false),
Err(err) => {
debug!("native cargo binary install unavailable: {err:#}");
Ok(false)
}
}
}
fn git_url(&self) -> Option<Url> {
if let Ok(url) = Url::parse(&self.tool_name()) {
Some(url)
} else if let Some((user, repo)) = self.tool_name().split_once('/') {
format!("https://github.com/{user}/{repo}.git").parse().ok()
} else {
None
}
}
}
fn format_tool_options(options: &[&'static str]) -> String {
options
.iter()
.map(|option| format!("`{option}`"))
.collect::<Vec<_>>()
.join(", ")
}
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()?)
}
fn parse_crate_versions(response: &str) -> eyre::Result<Vec<VersionInfo>> {
let mut versions = vec![];
for version in Deserializer::from_str(response).into_iter::<CrateVersion>() {
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 CrateVersion {
vers: String,
yanked: bool,
pubtime: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::platform::Platform;
use crate::toolset::{ToolSource, parse_tool_options};
fn test_tool_version(
install_path: &std::path::Path,
options: ToolVersionOptions,
) -> ToolVersion {
let backend = Arc::new(BackendArg::new(
"cargo:tool".to_string(),
Some("cargo:tool".to_string()),
));
let request =
ToolRequest::new_opts(backend, "1.0.0", options, ToolSource::Unknown).unwrap();
let mut tv = ToolVersion::new(request, "1.0.0".to_string());
tv.install_path = Some(install_path.to_path_buf());
tv
}
#[tokio::test]
async fn exact_semver_versions_resolve_without_remote_discovery() {
let config = Config::get().await.unwrap();
let backend = CargoBackend::from_arg("cargo:tool".into());
assert_eq!(
backend
.resolve_exact_version(&config, "=1.2.3")
.await
.unwrap()
.as_deref(),
Some("1.2.3")
);
assert_eq!(
backend
.resolve_exact_version(&config, "1.2.3")
.await
.unwrap()
.as_deref(),
Some("1.2.3")
);
}
#[tokio::test]
async fn fuzzy_versions_require_remote_discovery() {
let config = Config::get().await.unwrap();
let backend = CargoBackend::from_arg("cargo:tool".into());
for version in ["latest", "1", "1.2", "^1.2.3", ">=1.2.3"] {
assert_eq!(
backend
.resolve_exact_version(&config, version)
.await
.unwrap(),
None,
"{version} should use remote discovery"
);
}
}
#[tokio::test]
async fn git_versions_keep_remote_discovery() {
let config = Config::get().await.unwrap();
let backend = CargoBackend::from_arg("cargo:owner/repo".into());
assert_eq!(
backend
.resolve_exact_version(&config, "1.2.3")
.await
.unwrap(),
None
);
}
#[test]
fn test_lockfile_options_uses_target_platform_bin() {
let mut opts = ToolVersionOptions::default();
opts.opts
.insert("bin".into(), toml::Value::String("base-bin".into()));
let mut platforms = toml::Table::new();
let mut linux = toml::Table::new();
linux.insert("bin".into(), toml::Value::String("linux-bin".into()));
platforms.insert("linux-x64".into(), toml::Value::Table(linux));
opts.opts
.insert("platforms".into(), toml::Value::Table(platforms));
let target = PlatformTarget::new(Platform::parse("linux-x64").unwrap());
let lock_opts = CargoOptions::new(&opts).lockfile_options(&target);
assert_eq!(lock_opts.get("bin").map(String::as_str), Some("linux-bin"));
}
#[test]
fn test_lockfile_options_include_crate_and_locked() {
let mut opts = ToolVersionOptions::default();
opts.opts
.insert("crate".into(), toml::Value::String("demo".into()));
opts.opts
.insert("locked".into(), toml::Value::Boolean(false));
let target = PlatformTarget::new(Platform::parse("linux-x64").unwrap());
let lock_opts = CargoOptions::new(&opts).lockfile_options(&target);
assert_eq!(lock_opts.get("crate").map(String::as_str), Some("demo"));
assert_eq!(lock_opts.get("locked").map(String::as_str), Some("false"));
}
#[test]
fn cargo_options_accepts_array_features() {
let mut opts = ToolVersionOptions::default();
opts.opts.insert(
"features".into(),
toml::Value::Array(vec![
toml::Value::String("postgres".into()),
toml::Value::Integer(1),
toml::Value::String("rustls".into()),
]),
);
let target = PlatformTarget::new(Platform::parse("linux-x64").unwrap());
let lock_opts = CargoOptions::new(&opts).lockfile_options(&target);
assert_eq!(
CargoOptions::new(&opts).features().as_deref(),
Some("postgres rustls")
);
assert_eq!(
lock_opts.get("features").map(String::as_str),
Some("postgres rustls")
);
}
#[test]
fn cargo_options_defaults_to_locked() {
let opts = ToolVersionOptions::default();
assert!(CargoOptions::new(&opts).locked());
}
#[test]
fn cargo_install_state_normalizes_features_and_defaults() {
let string_opts = parse_tool_options(
"features='rustls, postgres rustls',default-features=true,locked=true",
);
let mut array_opts = ToolVersionOptions::default();
array_opts.opts.insert(
"features".into(),
toml::Value::Array(vec![
toml::Value::String("postgres".into()),
toml::Value::String("rustls".into()),
]),
);
let expected = CargoInstallState {
features: vec!["postgres".into(), "rustls".into()],
default_features: true,
locked: true,
..Default::default()
};
assert_eq!(CargoOptions::new(&string_opts).install_state(), expected);
assert_eq!(CargoOptions::new(&array_opts).install_state(), expected);
assert_eq!(
CargoOptions::new(&ToolVersionOptions::default()).install_state(),
CargoInstallState {
default_features: true,
locked: true,
..Default::default()
}
);
}
#[test]
fn cargo_install_state_round_trips() {
let opts = parse_tool_options(
"features='rustls postgres',default-features=false,bin=sqlx,crate=sqlx-cli,locked=false",
);
let state = CargoOptions::new(&opts).install_state();
assert_eq!(
toml::from_str::<CargoInstallState>(&toml::to_string(&state).unwrap()).unwrap(),
state
);
}
#[test]
fn cargo_install_state_matches_legacy_default_and_tracks_changes() {
let backend = CargoBackend::from_arg("cargo:tool".into());
let tmp = tempfile::tempdir().unwrap();
let install_path = tmp.path().join("install");
file::create_dir_all(&install_path).unwrap();
let default_tv = test_tool_version(&install_path, ToolVersionOptions::default());
let feature_tv = test_tool_version(&install_path, parse_tool_options("features=extra"));
assert!(backend.install_state_matches(&default_tv));
assert!(!backend.install_state_matches(&feature_tv));
backend.write_install_state(&feature_tv).unwrap();
assert!(backend.install_state_matches(&feature_tv));
assert!(!backend.install_state_matches(&default_tv));
file::write(
install_path.join(CARGO_INSTALL_STATE_FILENAME),
"not valid toml =",
)
.unwrap();
assert!(!backend.install_state_matches(&feature_tv));
assert!(!backend.install_state_matches(&default_tv));
backend.write_install_state(&default_tv).unwrap();
assert!(backend.install_state_matches(&default_tv));
assert!(!backend.install_state_matches(&feature_tv));
}
#[test]
fn cargo_install_state_write_failure_is_best_effort() {
let backend = CargoBackend::from_arg("cargo:tool".into());
let tmp = tempfile::tempdir().unwrap();
let install_path = tmp.path().join("missing").join("install");
let tv = test_tool_version(&install_path, parse_tool_options("features=extra"));
backend.write_install_state_best_effort(&tv);
assert!(!install_path.join(CARGO_INSTALL_STATE_FILENAME).exists());
}
#[test]
fn cargo_install_required_options_skips_feature_options() {
let opts = parse_tool_options("features=add,default-features=false");
assert_eq!(
CargoBackend::cargo_install_required_options(&opts),
vec!["features", "default-features"]
);
}
#[test]
fn cargo_install_required_options_detects_array_features() {
let mut opts = ToolVersionOptions::default();
opts.opts.insert(
"features".into(),
toml::Value::Array(vec![
toml::Value::String("postgres".into()),
toml::Value::String("rustls".into()),
]),
);
assert_eq!(
CargoBackend::cargo_install_required_options(&opts),
vec!["features"]
);
}
#[test]
fn cargo_install_required_options_allows_binstall_supported_options() {
let opts =
parse_tool_options("bin=cargo-add,crate=cargo-edit,locked=false,default-features=true");
assert_eq!(
CargoBackend::cargo_install_required_options(&opts),
Vec::<&str>::new()
);
}
#[test]
fn cargo_install_required_options_skips_toml_bool_default_features() {
let mut opts = ToolVersionOptions::default();
opts.opts
.insert("default-features".into(), toml::Value::Boolean(false));
assert_eq!(
CargoBackend::cargo_install_required_options(&opts),
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),]
);
}
}