use std::env::consts::EXE_EXTENSION;
use std::ffi::OsStr;
use std::ops::Deref;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::str::FromStr;
use std::sync::Arc;
use anyhow::{Context, bail};
use itertools::{Either, Itertools};
use prek_consts::env_vars::EnvVars;
use prek_consts::prepend_paths;
use tracing::debug;
use crate::cli::reporter::{HookInstallReporter, HookRunReporter};
use crate::hook::{Hook, InstallInfo, InstalledHook};
use crate::languages::LanguageImpl;
use crate::languages::rust::RustRequest;
use crate::languages::rust::installer::RustInstaller;
use crate::languages::rust::rustup::Rustup;
use crate::languages::rust::version::{Channel, EXTRA_KEY_CHANNEL};
use crate::languages::version::LanguageRequest;
use crate::process::Cmd;
use crate::run::run_by_batch;
use crate::store::{CacheBucket, Store, ToolBucket};
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
}
}
async fn find_package_dir(
repo: &Path,
binary_name: &str,
cargo: Option<&Path>,
cargo_home: Option<&Path>,
new_path: Option<&OsStr>,
) -> anyhow::Result<Option<(PathBuf, String, bool)>> {
let cargo = cargo.unwrap_or(Path::new("cargo"));
let mut cmd = Cmd::new(cargo, "cargo metadata");
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(a: &str, b: &str) -> bool {
a == b || a.replace('-', "_") == b.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();
let file_type = entry.file_type().await?;
if file_type.is_file() {
if let Some(ext) = path.extension() {
if ext == "d" || ext == "rlib" || ext == "rmeta" {
continue;
}
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let meta = entry.metadata().await?;
if meta.permissions().mode() & 0o111 != 0 {
let dest = dest_bin_dir.join(entry.file_name());
fs_err::tokio::copy(&path, &dest).await?;
}
}
#[cfg(windows)]
{
if path.extension().is_some_and(|e| e == "exe") {
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],
cargo: &Path,
cargo_home: &Path,
new_path: &OsStr,
) -> anyhow::Result<()> {
let (package_dir, package_name, is_workspace) = match find_package_dir(
repo_path,
hook_binary,
Some(cargo),
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 `{}` for binary `{}` in repo `{}` at `{}`",
package_name,
hook_binary,
repo_path.display(),
package_dir.display(),
);
(package_dir, package_name, is_workspace)
}
Ok(None) => {
debug!(
"Binary `{}` not found in cargo metadata for repo `{}`, falling back to repo root",
hook_binary,
repo_path.display(),
);
(repo_path.to_path_buf(), String::new(), false)
}
};
if lib_deps.is_empty() {
Cmd::new(cargo, "install local")
.args(["install", "--bins", "--root"])
.arg(&info.env_path)
.args(["--path", ".", "--locked"])
.current_dir(&package_dir)
.env(EnvVars::PATH, new_path)
.env(EnvVars::CARGO_HOME, cargo_home)
.remove_git_envs()
.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 = Cmd::new(cargo, "add dependencies");
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)
.remove_git_envs()
.check(true)
.output()
.await?;
let target_dir = info.env_path.join("target");
let mut cmd = Cmd::new(cargo, "build local with deps");
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)
.remove_git_envs()
.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,
cargo: &Path,
cargo_home: &Path,
new_path: &OsStr,
) -> anyhow::Result<()> {
let dep = CargoCliDependency::from_str(cli_dep)?;
let mut cmd = Cmd::new(cargo, "install cli dep");
cmd.args(["install", "--bins", "--root"])
.arg(&info.env_path)
.args(dep.to_cargo_args())
.arg("--locked");
cmd.env(EnvVars::PATH, new_path)
.env(EnvVars::CARGO_HOME, cargo_home)
.remove_git_envs()
.check(true)
.output()
.await?;
Ok(())
}
#[derive(Debug, Copy, Clone)]
pub(crate) struct Rust;
impl LanguageImpl for Rust {
async fn install(
&self,
hook: Arc<Hook>,
store: &Store,
reporter: &HookInstallReporter,
) -> anyhow::Result<InstalledHook> {
let progress = reporter.on_install_start(&hook);
let cargo_home = store.cache_path(CacheBucket::Cargo);
let rustup_dir = store.tools_path(ToolBucket::Rustup);
let rustup = Rustup::install(store, &rustup_dir).await?;
let installer = RustInstaller::new(rustup);
let (version, allows_download) = match &hook.language_request {
LanguageRequest::Any { system_only } => (&RustRequest::Any, !system_only),
LanguageRequest::Rust(version) => (version, true),
_ => unreachable!(),
};
let rust = installer
.install(version, allows_download)
.await
.context("Failed to install rust")?;
let rustc_bin = bin_dir(rust.toolchain());
let cargo = rustc_bin.join("cargo").with_extension(EXE_EXTENSION);
let new_path = prepend_paths(&[&rustc_bin]).context("Failed to join PATH")?;
let mut info = InstallInfo::new(
hook.language,
hook.env_key_dependencies().clone(),
&store.hooks_dir(),
)?;
info.with_toolchain(rust.toolchain().to_path_buf())
.with_language_version(rust.version().deref().clone());
match version {
RustRequest::Channel(channel) => {
info.with_extra(EXTRA_KEY_CHANNEL, &channel.to_string());
}
RustRequest::Any => {
let channel = rust.version().channel().unwrap_or(Channel::Stable);
info.with_extra(EXTRA_KEY_CHANNEL, &channel.to_string());
}
_ => {}
}
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_direct().split()?;
let hook_bin = &hook_entry[0];
if let Some(repo) = hook.repo_path() {
install_local_project(
hook_bin,
repo,
&info,
&lib_deps,
&cargo,
&cargo_home,
&new_path,
)
.await?;
}
for cli_dep in cli_deps {
install_cli_dependency(cli_dep, &info, &cargo, &cargo_home, &new_path).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(())
}
async fn run(
&self,
hook: &InstalledHook,
filenames: &[&Path],
store: &Store,
reporter: &HookRunReporter,
) -> anyhow::Result<(i32, Vec<u8>)> {
let progress = reporter.on_run_start(hook, filenames.len());
let env_dir = hook.env_path().expect("Rust hook must have env path");
let info = hook.install_info().expect("Rust hook must be installed");
let rust_bin = bin_dir(env_dir);
let cargo_home = store.cache_path(CacheBucket::Cargo);
let rustc_bin = bin_dir(&info.toolchain);
let new_path = prepend_paths(&[&rust_bin, &rustc_bin]).context("Failed to join PATH")?;
let entry = hook.entry.resolve(Some(&new_path), store)?;
let run = async |batch: &[&Path]| {
let mut output = Cmd::new(&entry[0], "rust hook")
.current_dir(hook.work_dir())
.args(&entry[1..])
.env(EnvVars::PATH, &new_path)
.env(EnvVars::CARGO_HOME, &cargo_home)
.env(EnvVars::RUSTUP_AUTO_INSTALL, "0")
.envs(&hook.env)
.args(&hook.args)
.args(batch)
.check(false)
.stdin(Stdio::null())
.pty_output()
.await?;
reporter.on_run_progress(progress, batch.len() as u64);
output.stdout.extend(output.stderr);
let code = output.status.code().unwrap_or(1);
anyhow::Ok((code, output.stdout))
};
let results = run_by_batch(hook, filenames, entry.argv(), run).await?;
reporter.on_run_complete(progress);
let mut combined_status = 0;
let mut combined_output = Vec::new();
for (code, output) in results {
combined_status |= code;
combined_output.extend(output);
}
Ok((combined_status, combined_output))
}
}
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}");
}
}
}