use std::env::consts::EXE_EXTENSION;
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::sync::Arc;
use anyhow::{Context, bail};
use itertools::{Either, Itertools};
use prek_consts::env_vars::{EnvVars, EnvVarsRead};
use prek_consts::prepend_paths;
use tracing::debug;
use crate::cli::reporter::HookInstallReporter;
use crate::fs::is_executable;
use crate::git::GitCommandExt;
use crate::hook::{Hook, InstallInfo, InstalledHook};
use crate::languages::rust::RustRequest;
use crate::languages::rust::installer::RustInstaller;
use crate::languages::rust::toolchain::RustToolchain;
use crate::languages::{ExecutionEnvironment, LanguageBackend};
use crate::process::Cmd;
use crate::store::{CacheBucket, Store};
fn format_cargo_dependency(dep: &str) -> String {
let (name, version) = dep.split_once(':').unwrap_or((dep, ""));
if version.is_empty() {
format!("{name}@*")
} else {
format!("{name}@{version}")
}
}
#[derive(Debug, Eq, PartialEq)]
enum CargoCliDependency {
Crate {
name: String,
version: Option<String>,
},
Git {
url: String,
tag: Option<String>,
package: Option<String>,
},
}
impl FromStr for CargoCliDependency {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let is_url = s.starts_with("http://") || s.starts_with("https://");
if is_url {
let scheme_end = s
.find("://")
.map(|idx| idx + 3)
.with_context(|| format!("Invalid git URL `{s}`"))?;
let rest = &s[scheme_end..];
let parts: Vec<&str> = rest.rsplitn(3, ':').collect();
let (url_without_scheme, tag, package) = match parts.as_slice() {
[url] => (*url, None, None),
[tag, url] => {
if tag.is_empty() {
bail!(
"Git CLI dependency `{s}` contains an empty tag; use `cli:<url>`, `cli:<url>:<tag>`, or `cli:<url>:<tag>:<package>`"
);
}
(*url, Some(*tag), None)
}
[package, tag, url] => {
if package.is_empty() {
bail!(
"Git CLI dependency `{s}` must specify a non-empty package when using `cli:<url>:<tag>:<package>`"
);
}
let tag = if tag.is_empty() { None } else { Some(*tag) };
(*url, tag, Some(*package))
}
_ => unreachable!(),
};
let url = format!("{}{}", &s[..scheme_end], url_without_scheme);
Ok(CargoCliDependency::Git {
url,
tag: tag.map(ToString::to_string),
package: package.map(ToString::to_string),
})
} else {
let (name, version) = if let Some((pkg, ver)) = s.rsplit_once(':') {
(pkg.to_string(), Some(ver.to_string()))
} else {
(s.to_string(), None)
};
Ok(CargoCliDependency::Crate { name, version })
}
}
}
impl CargoCliDependency {
fn to_cargo_args(&self) -> Vec<&str> {
let mut args: Vec<&str> = Vec::with_capacity(2);
match self {
CargoCliDependency::Crate { name, version } => {
args.push(name);
if let Some(version) = version {
args.push("--version");
args.push(version);
}
}
CargoCliDependency::Git { url, tag, package } => {
args.push("--git");
args.push(url);
if let Some(tag) = tag {
args.push("--tag");
args.push(tag);
}
if let Some(package) = package {
args.push(package);
}
}
}
args
}
}
enum CargoInstaller<'a> {
Cargo,
Binstall(&'a Path),
}
impl CargoInstaller<'_> {
fn command(&self, toolchain: &RustToolchain, install_root: &Path) -> Cmd {
let (mut cmd, args): (Cmd, &[&str]) = match self {
Self::Cargo => (toolchain.cargo_command(), &["install", "--bins"]),
Self::Binstall(cargo_binstall) => {
(toolchain.command(cargo_binstall), &["--no-confirm"])
}
};
cmd.args(args).arg("--root").arg(install_root);
cmd
}
fn binstall_executable() -> anyhow::Result<Option<PathBuf>> {
let enabled = match EnvVars.var_as_bool(EnvVars::PREK_USE_CARGO_BINSTALL) {
Ok(enabled) => enabled.unwrap_or(false),
Err(value) => {
bail!(
"Invalid value for {}: {value:?}. Expected a boolean value",
EnvVars::PREK_USE_CARGO_BINSTALL,
);
}
};
if !enabled {
return Ok(None);
}
let executable = which::which("cargo-binstall").with_context(|| {
format!(
"{} is enabled, but `cargo-binstall` was not found on PATH",
EnvVars::PREK_USE_CARGO_BINSTALL,
)
})?;
Ok(Some(executable))
}
}
async fn find_package_dir(
repo: &Path,
binary_name: &str,
toolchain: Option<&RustToolchain>,
cargo_home: Option<&Path>,
new_path: Option<&OsStr>,
) -> anyhow::Result<Option<(PathBuf, String, bool)>> {
let mut cmd = match toolchain {
Some(toolchain) => toolchain.cargo_command(),
None => Cmd::new("cargo"),
};
if let Some(new_path) = new_path {
cmd.env(EnvVars::PATH, new_path);
}
if let Some(cargo_home) = cargo_home {
cmd.env(EnvVars::CARGO_HOME, cargo_home);
}
let output = cmd
.arg("metadata")
.arg("--format-version")
.arg("1")
.arg("--no-deps")
.arg("--manifest-path")
.arg(repo.join("Cargo.toml"))
.output()
.await?;
let stdout = str::from_utf8(&output.stdout)?
.lines()
.find(|line| line.starts_with('{'))
.ok_or(cargo_metadata::Error::NoJson)?;
let metadata: cargo_metadata::Metadata =
serde_json::from_str(stdout).context("Failed to parse cargo metadata output")?;
for package_id in &metadata.workspace_members {
let package = metadata
.packages
.iter()
.find(|p| &p.id == package_id)
.with_context(|| format!("Package not found in metadata for id: {package_id}"))?;
if package_produces_binary(package, binary_name) {
let package_dir = package
.manifest_path
.parent()
.expect("manifest should have parent")
.as_std_path()
.to_path_buf();
let is_workspace = metadata.workspace_members.len() > 1
|| package_dir != metadata.workspace_root.as_std_path();
return Ok(Some((package_dir, package.name.to_string(), is_workspace)));
}
}
Ok(None)
}
fn names_match(name: &str, binary_name: &str) -> bool {
name == binary_name || name.replace('-', "_") == binary_name.replace('-', "_")
}
fn package_produces_binary(package: &cargo_metadata::Package, binary_name: &str) -> bool {
package
.targets
.iter()
.filter(|t| t.is_bin())
.any(|t| names_match(&t.name, binary_name))
}
async fn copy_binaries(release_dir: &Path, dest_bin_dir: &Path) -> anyhow::Result<()> {
let mut entries = fs_err::tokio::read_dir(release_dir).await?;
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
if let Some(ext) = path.extension() {
if ext == "d" || ext == "rlib" || ext == "rmeta" {
continue;
}
}
if is_executable(&path) {
let dest = dest_bin_dir.join(entry.file_name());
fs_err::tokio::copy(&path, &dest).await?;
}
}
Ok(())
}
async fn install_local_project(
hook_binary: &str,
repo_path: &Path,
info: &InstallInfo,
lib_deps: &[&String],
toolchain: &RustToolchain,
cargo_home: &Path,
new_path: &OsStr,
) -> anyhow::Result<()> {
let (package_dir, package_name, is_workspace) = match find_package_dir(
repo_path,
hook_binary,
Some(toolchain),
Some(cargo_home),
Some(new_path),
)
.await
{
Err(e) => {
return Err(e.context("Failed to find package directory using cargo metadata"));
}
Ok(Some((package_dir, package_name, is_workspace))) => {
debug!(
"Found package `{package_name}` for binary `{hook_binary}` in repo `{}` at `{}`",
repo_path.display(),
package_dir.display(),
);
(package_dir, package_name, is_workspace)
}
Ok(None) => {
debug!(
"Binary `{hook_binary}` not found in cargo metadata for repo `{}`, falling back to repo root",
repo_path.display(),
);
(repo_path.to_path_buf(), String::new(), false)
}
};
if lib_deps.is_empty() {
CargoInstaller::Cargo
.command(toolchain, &info.env_path)
.args(["--path", ".", "--locked"])
.current_dir(&package_dir)
.env(EnvVars::PATH, new_path)
.env(EnvVars::CARGO_HOME, cargo_home)
.sanitize_git_repo_env()
.check(true)
.output()
.await?;
} else {
let manifest_dir = info.env_path.join("manifest");
fs_err::tokio::create_dir_all(&manifest_dir).await?;
let src_manifest = package_dir.join("Cargo.toml");
let dst_manifest = manifest_dir.join("Cargo.toml");
fs_err::tokio::copy(&src_manifest, &dst_manifest).await?;
let lock_locations = if is_workspace {
vec![repo_path.join("Cargo.lock"), package_dir.join("Cargo.lock")]
} else {
vec![package_dir.join("Cargo.lock")]
};
for lock_path in lock_locations {
if lock_path.exists() {
fs_err::tokio::copy(&lock_path, manifest_dir.join("Cargo.lock")).await?;
break;
}
}
let src_dir = package_dir.join("src");
if src_dir.exists() {
let dst_src = manifest_dir.join("src");
fs_err::tokio::create_dir_all(&dst_src).await?;
let mut entries = fs_err::tokio::read_dir(&src_dir).await?;
while let Some(entry) = entries.next_entry().await? {
if entry.file_type().await?.is_file() {
fs_err::tokio::copy(entry.path(), dst_src.join(entry.file_name())).await?;
}
}
}
let mut cmd = toolchain.cargo_command();
cmd.arg("add");
for dep in lib_deps {
cmd.arg(format_cargo_dependency(dep.as_str()));
}
cmd.current_dir(&manifest_dir)
.env(EnvVars::PATH, new_path)
.env(EnvVars::CARGO_HOME, cargo_home)
.sanitize_git_repo_env()
.check(true)
.output()
.await?;
let target_dir = info.env_path.join("target");
let mut cmd = toolchain.cargo_command();
cmd.args(["build", "--bins", "--release"])
.arg("--manifest-path")
.arg(&dst_manifest)
.arg("--target-dir")
.arg(&target_dir);
if is_workspace && !package_name.is_empty() {
cmd.args(["--package", &package_name]);
}
cmd.current_dir(&package_dir)
.env(EnvVars::PATH, new_path)
.env(EnvVars::CARGO_HOME, cargo_home)
.sanitize_git_repo_env()
.check(true)
.output()
.await?;
copy_binaries(&target_dir.join("release"), &bin_dir(&info.env_path)).await?;
fs_err::tokio::remove_dir_all(&manifest_dir).await?;
fs_err::tokio::remove_dir_all(&target_dir).await?;
}
Ok(())
}
async fn install_cli_dependency(
cli_dep: &str,
info: &InstallInfo,
toolchain: &RustToolchain,
cargo_binstall: Option<&Path>,
cargo_home: &Path,
new_path: &OsStr,
install_cwd: &Path,
) -> anyhow::Result<()> {
let dep = CargoCliDependency::from_str(cli_dep)?;
let installer = match (cargo_binstall, &dep) {
(Some(cargo_binstall), CargoCliDependency::Crate { .. }) => {
debug!(
dependency = cli_dep,
"Installing CLI dependency with cargo-binstall"
);
CargoInstaller::Binstall(cargo_binstall)
}
_ => CargoInstaller::Cargo,
};
installer
.command(toolchain, &info.env_path)
.args(dep.to_cargo_args())
.arg("--locked")
.current_dir(install_cwd)
.env(EnvVars::PATH, new_path)
.env(EnvVars::CARGO_HOME, cargo_home)
.sanitize_git_repo_env()
.check(true)
.output()
.await
.context("Failed to install Rust CLI dependency")?;
Ok(())
}
#[derive(Debug, Copy, Clone)]
pub(crate) struct Rust;
#[async_trait::async_trait(?Send)]
impl LanguageBackend for Rust {
async fn install(
&self,
store: &Store,
hook: Arc<Hook>,
install_cwd: &Path,
reporter: &HookInstallReporter,
) -> anyhow::Result<InstalledHook> {
let progress = reporter.on_install_start(&hook);
let cargo_binstall = CargoInstaller::binstall_executable()?;
let cargo_home = store.cache_path(CacheBucket::Cargo);
let installer = RustInstaller::new(store, install_cwd);
let version: &RustRequest = hook.language_request.version();
let rust = installer
.install(version, hook.language_request.toolchain_policy())
.await
.context("Failed to install rust")?;
let new_path = prepend_paths(&[rust.bin_dir()?]).context("Failed to join PATH")?;
let mut info = InstallInfo::new(&hook, &store.hooks_dir())?;
info.with_toolchain(rust.rustc.clone())
.with_language_version(rust.version.clone());
fs_err::tokio::create_dir_all(bin_dir(&info.env_path)).await?;
let (cli_deps, lib_deps): (Vec<_>, Vec<_>) =
hook.additional_dependencies.iter().partition_map(|dep| {
if let Some(stripped) = dep.strip_prefix("cli:") {
Either::Left(stripped)
} else {
Either::Right(dep)
}
});
let hook_entry = hook.entry.expect_argv_entry().split()?;
let hook_bin = hook_entry[0]
.to_str()
.context("Rust hook entry binary must be valid UTF-8")?;
if let Some(repo) = hook.repo_path() {
install_local_project(
hook_bin,
repo,
&info,
&lib_deps,
&rust,
&cargo_home,
&new_path,
)
.await?;
}
for cli_dep in cli_deps {
install_cli_dependency(
cli_dep,
&info,
&rust,
cargo_binstall.as_deref(),
&cargo_home,
&new_path,
install_cwd,
)
.await?;
}
info.persist_env_path();
reporter.on_install_complete(progress);
Ok(InstalledHook::Installed {
hook,
info: Arc::new(info),
})
}
async fn check_health(&self, _info: &InstallInfo) -> anyhow::Result<()> {
Ok(())
}
fn execution_environment(
&self,
store: &Store,
hook: &InstalledHook,
) -> anyhow::Result<ExecutionEnvironment> {
let info = hook.install_info().context("Rust hook must be installed")?;
let rust_bin = bin_dir(&info.env_path);
let rustc = rustc_path(info);
let toolchain_bin = rustc
.parent()
.context("Rust compiler has no parent directory")?;
let new_path =
prepend_paths(&[rust_bin.as_path(), toolchain_bin]).context("Failed to join PATH")?;
let mut environment = ExecutionEnvironment::new();
environment
.set_path(&new_path)
.env(EnvVars::CARGO_HOME, store.cache_path(CacheBucket::Cargo))
.env(EnvVars::RUSTC, &rustc)
.env(EnvVars::RUSTUP_AUTO_INSTALL, "0");
Ok(environment)
}
}
fn rustc_path(info: &InstallInfo) -> PathBuf {
if info.toolchain.is_dir() {
info.toolchain
.join("bin/rustc")
.with_extension(EXE_EXTENSION)
} else {
info.toolchain.clone()
}
}
pub(crate) fn bin_dir(env_path: &Path) -> PathBuf {
env_path.join("bin")
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
async fn write_file(path: &Path, content: &str) {
if let Some(parent) = path.parent() {
fs_err::tokio::create_dir_all(parent).await.unwrap();
}
fs_err::tokio::write(path, content).await.unwrap();
}
#[tokio::test]
async fn test_find_package_dir_single_package() {
let temp = TempDir::new().unwrap();
let cargo_toml = r#"
[package]
name = "my-tool"
version = "0.1.0"
edition = "2021"
"#;
write_file(&temp.path().join("Cargo.toml"), cargo_toml).await;
write_file(&temp.path().join("src/main.rs"), "fn main() {}").await;
let (path, pkg_name, is_workspace) =
find_package_dir(temp.path(), "my-tool", None, None, None)
.await
.unwrap()
.unwrap();
assert_eq!(path, temp.path());
assert_eq!(pkg_name, "my-tool");
assert!(!is_workspace);
}
#[tokio::test]
async fn test_find_package_dir_single_package_underscore_normalization() {
let temp = TempDir::new().unwrap();
let cargo_toml = r#"
[package]
name = "my-tool"
version = "0.1.0"
edition = "2021"
"#;
write_file(&temp.path().join("Cargo.toml"), cargo_toml).await;
write_file(&temp.path().join("src/main.rs"), "fn main() {}").await;
let (path, _pkg, is_workspace) = find_package_dir(temp.path(), "my_tool", None, None, None)
.await
.unwrap()
.unwrap();
assert_eq!(path, temp.path());
assert!(!is_workspace);
}
#[tokio::test]
async fn test_find_package_dir_workspace_with_root_package() {
let temp = TempDir::new().unwrap();
let cargo_toml = r#"
[package]
name = "cargo-deny"
version = "0.18.5"
edition = "2021"
[workspace]
members = ["subcrate"]
"#;
write_file(&temp.path().join("Cargo.toml"), cargo_toml).await;
write_file(&temp.path().join("src/main.rs"), "fn main() {}").await;
let subcrate_toml = r#"
[package]
name = "subcrate"
version = "0.1.0"
edition = "2021"
"#;
write_file(&temp.path().join("subcrate/Cargo.toml"), subcrate_toml).await;
write_file(&temp.path().join("subcrate/src/lib.rs"), "").await;
let (path, pkg_name, is_workspace) =
find_package_dir(temp.path(), "cargo-deny", None, None, None)
.await
.unwrap()
.unwrap();
assert_eq!(path, temp.path());
assert_eq!(pkg_name, "cargo-deny");
assert!(is_workspace);
}
#[tokio::test]
async fn test_find_package_dir_workspace_member() {
let temp = TempDir::new().unwrap();
let cargo_toml = r#"
[workspace]
members = ["cli", "lib"]
"#;
write_file(&temp.path().join("Cargo.toml"), cargo_toml).await;
let cli_toml = r#"
[package]
name = "my-cli"
version = "0.1.0"
edition = "2021"
"#;
write_file(&temp.path().join("cli/Cargo.toml"), cli_toml).await;
write_file(&temp.path().join("cli/src/main.rs"), "fn main() {}").await;
let lib_toml = r#"
[package]
name = "my-lib"
version = "0.1.0"
edition = "2021"
"#;
write_file(&temp.path().join("lib/Cargo.toml"), lib_toml).await;
write_file(&temp.path().join("lib/src/lib.rs"), "").await;
let (path, pkg_name, is_workspace) =
find_package_dir(temp.path(), "my-cli", None, None, None)
.await
.unwrap()
.unwrap();
assert_eq!(path, temp.path().join("cli"));
assert_eq!(pkg_name, "my-cli");
assert!(is_workspace);
}
#[tokio::test]
async fn test_find_package_dir_by_bin_name() {
let temp = TempDir::new().unwrap();
let cargo_toml = r#"
[workspace]
members = ["crates/typos-cli"]
"#;
write_file(&temp.path().join("Cargo.toml"), cargo_toml).await;
let cli_toml = r#"
[package]
name = "typos-cli"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "typos"
path = "src/main.rs"
"#;
write_file(&temp.path().join("crates/typos-cli/Cargo.toml"), cli_toml).await;
write_file(
&temp.path().join("crates/typos-cli/src/main.rs"),
"fn main() {}",
)
.await;
let (path, pkg_name, is_workspace) =
find_package_dir(temp.path(), "typos", None, None, None)
.await
.unwrap()
.unwrap();
assert_eq!(path, temp.path().join("crates/typos-cli"));
assert_eq!(pkg_name, "typos-cli");
assert!(is_workspace);
}
#[tokio::test]
async fn test_find_package_dir_by_src_bin_file() {
let temp = TempDir::new().unwrap();
let cargo_toml = r#"
[package]
name = "my-pkg"
version = "0.1.0"
edition = "2021"
"#;
write_file(&temp.path().join("Cargo.toml"), cargo_toml).await;
write_file(&temp.path().join("src/bin/my-tool.rs"), "fn main() {}").await;
write_file(&temp.path().join("src/lib.rs"), "").await;
let (path, _pkg, is_workspace) = find_package_dir(temp.path(), "my-tool", None, None, None)
.await
.unwrap()
.unwrap();
assert_eq!(path, temp.path());
assert!(!is_workspace);
}
#[tokio::test]
async fn test_find_package_dir_virtual_workspace_nested_member() {
let temp = TempDir::new().unwrap();
let cargo_toml = r#"
[workspace]
members = ["crates/cli"]
"#;
write_file(&temp.path().join("Cargo.toml"), cargo_toml).await;
let cli_toml = r#"
[package]
name = "virtual-cli"
version = "0.1.0"
edition = "2021"
"#;
write_file(&temp.path().join("crates/cli/Cargo.toml"), cli_toml).await;
write_file(&temp.path().join("crates/cli/src/main.rs"), "fn main() {}").await;
let (path, pkg_name, is_workspace) =
find_package_dir(temp.path(), "virtual-cli", None, None, None)
.await
.unwrap()
.unwrap();
assert_eq!(path, temp.path().join("crates/cli"));
assert_eq!(pkg_name, "virtual-cli");
assert!(is_workspace);
}
#[tokio::test]
async fn test_find_package_dir_virtual_workspace_glob_members() {
let temp = TempDir::new().unwrap();
let cargo_toml = r#"
[workspace]
members = ["crates/*"]
"#;
write_file(&temp.path().join("Cargo.toml"), cargo_toml).await;
let cli_toml = r#"
[package]
name = "my-cli"
version = "0.1.0"
edition = "2021"
"#;
write_file(&temp.path().join("crates/cli/Cargo.toml"), cli_toml).await;
write_file(&temp.path().join("crates/cli/src/main.rs"), "fn main() {}").await;
let lib_toml = r#"
[package]
name = "my-lib"
version = "0.1.0"
edition = "2021"
"#;
write_file(&temp.path().join("crates/lib/Cargo.toml"), lib_toml).await;
write_file(&temp.path().join("crates/lib/src/lib.rs"), "").await;
let (path, pkg_name, is_workspace) =
find_package_dir(temp.path(), "my-cli", None, None, None)
.await
.unwrap()
.unwrap();
assert_eq!(path, temp.path().join("crates/cli"));
assert_eq!(pkg_name, "my-cli");
assert!(is_workspace);
let result = find_package_dir(temp.path(), "my-lib", None, None, None)
.await
.unwrap();
assert!(result.is_none());
}
#[tokio::test]
async fn test_find_package_dir_no_cargo_toml() {
let temp = TempDir::new().unwrap();
let result = find_package_dir(temp.path(), "anything", None, None, None).await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("cargo metadata"));
}
#[tokio::test]
async fn test_find_package_dir_workspace_binary_not_found() {
let temp = TempDir::new().unwrap();
let cargo_toml = r#"
[workspace]
members = ["cli"]
"#;
write_file(&temp.path().join("Cargo.toml"), cargo_toml).await;
let cli_toml = r#"
[package]
name = "some-other-tool"
version = "0.1.0"
edition = "2021"
"#;
write_file(&temp.path().join("cli/Cargo.toml"), cli_toml).await;
write_file(&temp.path().join("cli/src/main.rs"), "fn main() {}").await;
let result = find_package_dir(temp.path(), "nonexistent-binary", None, None, None)
.await
.unwrap();
assert!(result.is_none());
}
#[test]
fn test_format_cargo_dependency() {
assert_eq!(format_cargo_dependency("serde"), "serde@*");
assert_eq!(format_cargo_dependency("serde:1.0"), "serde@1.0");
assert_eq!(format_cargo_dependency("tokio:1.0.0"), "tokio@1.0.0");
}
#[test]
fn test_parse_cargo_cli_dependency_crate_forms() {
assert_eq!(
CargoCliDependency::from_str("typos-cli").unwrap(),
CargoCliDependency::Crate {
name: "typos-cli".to_string(),
version: None,
}
);
assert_eq!(
CargoCliDependency::from_str("typos-cli:1.0").unwrap(),
CargoCliDependency::Crate {
name: "typos-cli".to_string(),
version: Some("1.0".to_string())
}
);
}
#[test]
fn test_parse_cargo_cli_dependency_git_valid_forms() {
let cases = [
(
"https://github.com/fish-shell/fish-shell",
CargoCliDependency::Git {
url: "https://github.com/fish-shell/fish-shell".to_string(),
tag: None,
package: None,
},
),
(
"https://github.com/fish-shell/fish-shell:v4.5.0",
CargoCliDependency::Git {
url: "https://github.com/fish-shell/fish-shell".to_string(),
tag: Some("v4.5.0".to_string()),
package: None,
},
),
(
"https://github.com/fish-shell/fish-shell::fish",
CargoCliDependency::Git {
url: "https://github.com/fish-shell/fish-shell".to_string(),
tag: None,
package: Some("fish".to_string()),
},
),
(
"https://github.com/fish-shell/fish-shell:v4.5.0:fish",
CargoCliDependency::Git {
url: "https://github.com/fish-shell/fish-shell".to_string(),
tag: Some("v4.5.0".to_string()),
package: Some("fish".to_string()),
},
),
];
for (input, expected) in cases {
assert_eq!(CargoCliDependency::from_str(input).unwrap(), expected);
}
}
#[test]
fn test_parse_cargo_cli_dependency_git_invalid_forms() {
let invalid_cases = [
"https://github.com/fish-shell/fish-shell:",
"https://github.com/fish-shell/fish-shell:v4.5.0:",
"https://github.com/fish-shell/fish-shell::",
];
for input in invalid_cases {
assert!(
CargoCliDependency::from_str(input).is_err(),
"input: {input}"
);
}
}
#[test]
fn test_format_cargo_cli_dependency() {
let cases = [
("typos-cli", vec!["typos-cli"]),
("typos-cli:1.0", vec!["typos-cli", "--version", "1.0"]),
(
"https://github.com/fish-shell/fish-shell",
vec!["--git", "https://github.com/fish-shell/fish-shell"],
),
(
"https://github.com/fish-shell/fish-shell:v4.5.0",
vec![
"--git",
"https://github.com/fish-shell/fish-shell",
"--tag",
"v4.5.0",
],
),
(
"https://github.com/fish-shell/fish-shell::fish",
vec!["--git", "https://github.com/fish-shell/fish-shell", "fish"],
),
(
"https://github.com/fish-shell/fish-shell:v4.5.0:fish",
vec![
"--git",
"https://github.com/fish-shell/fish-shell",
"--tag",
"v4.5.0",
"fish",
],
),
];
for (input, expected) in cases {
let dep = CargoCliDependency::from_str(input).unwrap();
assert_eq!(dep.to_cargo_args(), expected, "input: {input}");
}
}
}