diff --git a/.config/nextest.toml b/.config/nextest.toml index ea25cde97..3e4c93dde 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -19,6 +19,10 @@ default-filter = "binary_id(prek::languages) and test(deno::)" inherits = "ci-core" default-filter = "binary_id(prek::languages) and (test(docker::) or test(docker_image::))" +[profile.lang-dotnet] +inherits = "ci-core" +default-filter = "binary_id(prek::languages) and test(dotnet::)" + [profile.lang-golang] inherits = "ci-core" default-filter = "binary_id(prek::languages) and test(golang::)" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 65a7b25ba..66974f2ed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,7 @@ env: GHC_VERSION: "9.14.1" # Preinstalled in ubuntu-24.04 runner image CABAL_VERSION: "3.16.1.0" JULIA_VERSION: "1.12.4" + DOTNET_VERSION: "10.0" DENO_VERSION: "2" # Cargo env vars @@ -413,6 +414,7 @@ jobs: - bun - deno - docker + - dotnet - golang - haskell - julia @@ -534,6 +536,12 @@ jobs: with: version: ${{ env.JULIA_VERSION }} + - name: "Install .NET" + if: ${{ matrix.language == 'dotnet' }} + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + - name: "Run language tests" if: ${{ matrix.os != 'windows-latest' }} env: diff --git a/crates/prek-consts/src/env_vars.rs b/crates/prek-consts/src/env_vars.rs index 2ba2ab4c3..2cbdd150e 100644 --- a/crates/prek-consts/src/env_vars.rs +++ b/crates/prek-consts/src/env_vars.rs @@ -96,6 +96,9 @@ impl EnvVars { pub const RUSTUP_AUTO_INSTALL: &'static str = "RUSTUP_AUTO_INSTALL"; pub const CARGO_HOME: &'static str = "CARGO_HOME"; pub const RUSTUP_HOME: &'static str = "RUSTUP_HOME"; + + // .NET related + pub const DOTNET_ROOT: &'static str = "DOTNET_ROOT"; } impl EnvVars { diff --git a/crates/prek/src/languages/dotnet/dotnet.rs b/crates/prek/src/languages/dotnet/dotnet.rs new file mode 100644 index 000000000..7e5715150 --- /dev/null +++ b/crates/prek/src/languages/dotnet/dotnet.rs @@ -0,0 +1,353 @@ +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::sync::Arc; + +use anyhow::{Context, Result}; +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::dotnet::installer::{installer_from_store, query_dotnet_version}; +use crate::process::Cmd; +use crate::run::run_by_batch; +use crate::store::Store; + +#[derive(Debug, Copy, Clone)] +pub(crate) struct Dotnet; + +fn tools_path(env_path: &Path) -> PathBuf { + env_path.join("tools") +} + +impl LanguageImpl for Dotnet { + async fn install( + &self, + hook: Arc, + store: &Store, + reporter: &HookInstallReporter, + ) -> Result { + let progress = reporter.on_install_start(&hook); + + let mut info = InstallInfo::new( + hook.language, + hook.env_key_dependencies().clone(), + &store.hooks_dir(), + )?; + + debug!(%hook, target = %info.env_path.display(), "Installing dotnet environment"); + + // Install or find dotnet SDK + let allows_download = hook.language_request.allows_download(); + let installer = installer_from_store(store); + let dotnet_result = installer + .install(&hook.language_request, allows_download) + .await + .context("Failed to install or find dotnet SDK")?; + + let tool_path = tools_path(&info.env_path); + if !hook.additional_dependencies.is_empty() { + fs_err::tokio::create_dir_all(&tool_path).await?; + for dep in &hook.additional_dependencies { + install_tool(dotnet_result.dotnet(), &tool_path, dep).await?; + } + } + + info.with_language_version(dotnet_result.version().clone()) + .with_toolchain(dotnet_result.dotnet().to_path_buf()); + info.persist_env_path(); + + reporter.on_install_complete(progress); + + Ok(InstalledHook::Installed { + hook, + info: Arc::new(info), + }) + } + + async fn check_health(&self, info: &InstallInfo) -> Result<()> { + let current_version = query_dotnet_version(&info.toolchain) + .await + .context("Failed to query current dotnet info")?; + + // Only check major.minor for compatibility + if current_version.major != info.language_version.major + || current_version.minor != info.language_version.minor + { + anyhow::bail!( + "dotnet version mismatch: expected `{}.{}`, found `{}.{}`", + info.language_version.major, + info.language_version.minor, + current_version.major, + current_version.minor + ); + } + + Ok(()) + } + + async fn run( + &self, + hook: &InstalledHook, + filenames: &[&Path], + _store: &Store, + reporter: &HookRunReporter, + ) -> Result<(i32, Vec)> { + let progress = reporter.on_run_start(hook, filenames.len()); + + let env_dir = hook.env_path().ok_or_else(|| { + anyhow::anyhow! { + "Dotnet hook missing env_path; try re-installing hook." + } + })?; + let tool_path = tools_path(env_dir); + let toolchain_path = hook + .install_info() + .ok_or_else(|| { + anyhow::anyhow! { + "Dotnet hook missing install info, try re-installing hook." + } + })? + .toolchain + .clone(); + + // Resolve any symlinks in the dotnet executable path and use its parent + // directory as both the PATH entry and DOTNET_ROOT. This avoids setting + // DOTNET_ROOT to a shim directory such as /usr/bin. + let canonical_path = fs_err::tokio::canonicalize(&toolchain_path) + .await + .context("Failed to resolve dotnet toolchain path")?; + + let dotnet_root = canonical_path + .parent() + .map(Path::to_path_buf) + .ok_or_else(|| anyhow::anyhow!("Canonicalized dotnet executable must have parent"))?; + + let new_path = prepend_paths(&[&tool_path, &dotnet_root]).context("Failed to join PATH")?; + let entry = hook.entry.resolve(Some(&new_path))?; + + let run = async |batch: &[&Path]| { + let mut output = Cmd::new(&entry[0], "run dotnet hook") + .current_dir(hook.work_dir()) + .args(&entry[1..]) + .env(EnvVars::PATH, &new_path) + .env(EnvVars::DOTNET_ROOT, &dotnet_root) + .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, 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)) + } +} + +/// Install a dotnet tool as an additional dependency. +/// +/// The dependency can be specified as: +/// - `package` - installs latest version +/// - `package:version` - installs specific version +async fn install_tool(dotnet: &Path, tool_path: &Path, dependency: &str) -> Result<()> { + let (package, version) = dependency + .split_once(':') + .map_or((dependency, None), |(pkg, ver)| (pkg, Some(ver))); + + // Helper to build the command with shared arguments + let build_cmd = |action: &str| { + let mut c = Cmd::new(dotnet, format!("dotnet tool {action}")); + c.arg("tool") + .arg(action) + .arg("--tool-path") + .arg(tool_path) + .arg(package); + if let Some(ver) = version { + c.arg("--version").arg(ver); + } + c + }; + + // Attempt dotnet install + let install_res = build_cmd("install").check(true).output().await; + + if let Err(err) = install_res { + let msg = err.to_string(); + + if msg.contains("is already installed") { + debug!( + "dotnet tool '{package}' already installed at {:?}, attempting update", + tool_path + ); + + // Attempt update instead + build_cmd("update") + .check(true) + .output() + .await + .with_context(|| format!("Failed to update dotnet tool: {dependency}"))?; + } else { + return Err(err).with_context(|| format!("Failed to install dotnet tool {dependency}")); + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use rustc_hash::FxHashSet; + + use crate::config::Language; + use crate::hook::InstallInfo; + use crate::languages::LanguageImpl; + use crate::languages::dotnet::installer::query_dotnet_version; + + use super::{Dotnet, install_tool, tools_path}; + + fn dotnet_path() -> Option { + which::which("dotnet").ok() + } + + #[tokio::test] + async fn test_check_health() -> anyhow::Result<()> { + let Some(dotnet_path) = dotnet_path() else { + // Skip test if dotnet not found in PATH + return Ok(()); + }; + let version = query_dotnet_version(&dotnet_path).await?; + + let temp_dir = tempfile::tempdir()?; + let mut install_info = + InstallInfo::new(Language::Dotnet, FxHashSet::default(), temp_dir.path())?; + install_info + .with_language_version(version) + .with_toolchain(dotnet_path); + + // Test the Dotnet impl directly + let result = Dotnet.check_health(&install_info).await; + assert!(result.is_ok()); + + // Also test through Language dispatch + let result = Language::Dotnet.check_health(&install_info).await; + assert!(result.is_ok()); + + Ok(()) + } + + #[tokio::test] + async fn test_check_health_version_mismatch() -> anyhow::Result<()> { + let Some(dotnet_path) = dotnet_path() else { + // Skip test if dotnet not found in PATH + return Ok(()); + }; + + let temp_dir = tempfile::tempdir()?; + let mut install_info = + InstallInfo::new(Language::Dotnet, FxHashSet::default(), temp_dir.path())?; + // Use a fake version that won't match the actual dotnet version + install_info + .with_language_version(semver::Version::new(1, 0, 0)) + .with_toolchain(dotnet_path); + + let result = Dotnet.check_health(&install_info).await; + assert!(result.is_err()); + + let err = result.unwrap_err().to_string(); + assert!( + err.contains("dotnet version mismatch"), + "expected version mismatch error, got: {err}" + ); + + Ok(()) + } + + #[tokio::test] + async fn test_tools_path_function() { + let env_path = std::path::Path::new("/test/path"); + let tools = tools_path(env_path); + assert_eq!(tools, env_path.join("tools")); + } + + #[tokio::test] + async fn test_install_tool_with_version() -> anyhow::Result<()> { + let temp_dir = tempfile::tempdir()?; + let fake_dotnet = temp_dir.path().join("fake_dotnet"); + let tool_path = temp_dir.path().join("tools"); + + // Create fake dotnet that will succeed + fs_err::tokio::write( + &fake_dotnet, + "#!/bin/sh\necho 'Tool installed successfully'\n", + ) + .await?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let perms = std::fs::Permissions::from_mode(0o755); + std::fs::set_permissions(&fake_dotnet, perms)?; + } + + fs_err::tokio::create_dir_all(&tool_path).await?; + + // Test install_tool with version specifier + let _result = install_tool(&fake_dotnet, &tool_path, "package:1.0.0").await; + // This will likely fail but we're testing the parsing logic + + Ok(()) + } + + #[tokio::test] + async fn test_install_tool_already_installed() -> anyhow::Result<()> { + let temp_dir = tempfile::tempdir()?; + let fake_dotnet = temp_dir.path().join("fake_dotnet"); + let tool_path = temp_dir.path().join("tools"); + + // Create fake dotnet that simulates "already installed" error + fs_err::tokio::write( + &fake_dotnet, + r#"#!/bin/sh +if [ "$1" = "tool" ] && [ "$2" = "install" ]; then + echo "Tool 'test-tool' is already installed" + exit 1 +fi +"#, + ) + .await?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let perms = std::fs::Permissions::from_mode(0o755); + std::fs::set_permissions(&fake_dotnet, perms)?; + } + + fs_err::tokio::create_dir_all(&tool_path).await?; + + // This will test the "already installed" error handling path + let _result = install_tool(&fake_dotnet, &tool_path, "test-tool").await; + // The result will be an error, which tests our error paths + + Ok(()) + } +} diff --git a/crates/prek/src/languages/dotnet/installer.rs b/crates/prek/src/languages/dotnet/installer.rs new file mode 100644 index 000000000..600fc991e --- /dev/null +++ b/crates/prek/src/languages/dotnet/installer.rs @@ -0,0 +1,544 @@ +use std::fmt::Display; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail}; +use semver::Version; +use tracing::debug; + +use crate::fs::LockedFile; +use crate::http::REQWEST_CLIENT; +use crate::languages::dotnet::DotnetRequest; +use crate::languages::version::LanguageRequest; +use crate::process::Cmd; +use crate::store::{Store, ToolBucket}; + +/// Result of a dotnet installation or discovery. +#[derive(Debug, Clone)] +pub(crate) struct DotnetResult { + dotnet: PathBuf, + version: Version, +} + +impl Display for DotnetResult { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}@{}", self.dotnet.display(), self.version)?; + Ok(()) + } +} + +impl DotnetResult { + pub(crate) fn new(dotnet: PathBuf, version: Version) -> Self { + Self { dotnet, version } + } + + pub(crate) fn dotnet(&self) -> &Path { + &self.dotnet + } + + pub(crate) fn version(&self) -> &Version { + &self.version + } +} + +pub(crate) struct DotnetInstaller { + /// The base directory for all managed dotnet installations (e.g., .../tools/dotnet) + root: PathBuf, +} + +impl DotnetInstaller { + pub(crate) fn new(root: PathBuf) -> Self { + Self { root } + } + + /// Install or find dotnet SDK based on the language request. + pub(crate) async fn install( + &self, + request: &LanguageRequest, + allows_download: bool, + ) -> Result { + fs_err::tokio::create_dir_all(&self.root).await?; + let _lock = LockedFile::acquire(self.root.join(".lock"), "dotnet").await?; + + if let Some(result) = self.find_system_dotnet(request).await? { + debug!(%result, "Using system dotnet"); + return Ok(result); + } + + if let Some(result) = self.find_installed(request).await? { + debug!(%result, "Using existing managed dotnet"); + return Ok(result); + } + + if matches!(request, LanguageRequest::Any { system_only: true }) { + bail!("No system dotnet installation found"); + } + + if !allows_download { + bail!("No suitable dotnet version found and downloads are disabled"); + } + + // We use the requested version string to determine the target directory. + // If no version is specified (e.g. "LTS"), the install script will resolve it. + let version_str = to_dotnet_install_version(request); + let target_dir_name = version_str.as_deref().unwrap_or("default"); + let install_dir = self.root.join(target_dir_name); + + // If the directory already exists but find_installed missed it, it might be partial. + // We clean it to ensure a fresh, valid install. + if install_dir.exists() { + fs_err::tokio::remove_dir_all(&install_dir).await?; + } + fs_err::tokio::create_dir_all(&install_dir).await?; + + debug!(request = ?version_str, path = %install_dir.display(), "Installing dotnet SDK"); + self.download(&install_dir, version_str.as_deref()).await?; + + // Verify the installation and get the actual specific version (e.g. 8.0.401) + let installed = self + .query_installation_at(&install_dir) + .await + .context("Failed to verify newly installed dotnet")?; + + let final_dir = self.root.join(installed.version().to_string()); + if install_dir != final_dir { + if final_dir.exists() { + // Verify the existing final_dir is healthy before using it + if let Ok(existing_result) = self.query_installation_at(&final_dir).await { + // Existing installation is healthy, remove our new one and use existing + fs_err::tokio::remove_dir_all(&install_dir).await?; + return Ok(existing_result); + } + // Existing installation is corrupt, replace it with our new one + debug!( + "Existing installation at {} is corrupt, replacing with new installation", + final_dir.display() + ); + fs_err::tokio::remove_dir_all(&final_dir).await?; + fs_err::tokio::rename(&install_dir, &final_dir).await?; + } else { + fs_err::tokio::rename(&install_dir, &final_dir).await?; + } + return Ok(DotnetResult::new( + dotnet_executable(&final_dir), + installed.version().clone(), + )); + } + + Ok(installed) + } + + /// Scans the root directory for all subdirectories and finds the first one matching the request. + async fn find_installed(&self, request: &LanguageRequest) -> Result> { + if !self.root.exists() { + return Ok(None); + } + let mut entries = fs_err::tokio::read_dir(&self.root).await?; + let mut found_versions = Vec::new(); + + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + if !path.is_dir() + || path + .file_name() + .and_then(|n| n.to_str()) + .is_some_and(|s| s.starts_with('.')) + { + continue; + } + + // Check if this directory contains a valid dotnet installation + if let Ok(version) = query_dotnet_version(&dotnet_executable(&path)).await { + if version_satisfies_request(&version, request) { + found_versions.push(DotnetResult::new(dotnet_executable(&path), version)); + } + } + } + + // Sort by version descending to pick the newest compatible version + found_versions.sort_by(|a, b| b.version().cmp(a.version())); + Ok(found_versions.into_iter().next()) + } + + async fn find_system_dotnet(&self, request: &LanguageRequest) -> Result> { + if let Ok(system_dotnet) = which::which("dotnet") { + if let Ok(version) = query_dotnet_version(&system_dotnet).await { + if version_satisfies_request(&version, request) { + return Ok(Some(DotnetResult::new(system_dotnet, version))); + } + } + } + Ok(None) + } + + async fn query_installation_at(&self, install_dir: &Path) -> Result { + let dotnet_exe = dotnet_executable(install_dir); + if !dotnet_exe.exists() { + bail!("dotnet executable not found at {}", dotnet_exe.display()); + } + let version = query_dotnet_version(&dotnet_exe).await?; + Ok(DotnetResult::new(dotnet_exe, version)) + } + + async fn download(&self, install_dir: &Path, version: Option<&str>) -> Result<()> { + #[cfg(unix)] + { + self.install_dotnet_unix(install_dir, version).await + } + + #[cfg(windows)] + { + self.install_dotnet_windows(install_dir, version).await + } + } + + #[cfg(unix)] + async fn install_dotnet_unix(&self, install_dir: &Path, version: Option<&str>) -> Result<()> { + let script_url = "https://dot.net/v1/dotnet-install.sh"; + let script_path = install_dir.join("dotnet-install.sh"); + + let response = REQWEST_CLIENT + .get(script_url) + .send() + .await? + .error_for_status() + .with_context(|| { + format!("failed to download dotnet install script from {script_url}") + })?; + let script_content = response.bytes().await?; + fs_err::tokio::write(&script_path, &script_content).await?; + + // Set permissions + let mut perms = fs_err::tokio::metadata(&script_path).await?.permissions(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + perms.set_mode(0o755); + } + fs_err::tokio::set_permissions(&script_path, perms).await?; + + let mut cmd = Cmd::new("bash", "dotnet-install.sh"); + cmd.arg(&script_path).arg("--install-dir").arg(install_dir); + add_channel_args_unix(&mut cmd, version); + + cmd.check(true).output().await?; + Ok(()) + } + + #[cfg(windows)] + async fn install_dotnet_windows( + &self, + install_dir: &Path, + version: Option<&str>, + ) -> Result<()> { + let script_url = "https://dot.net/v1/dotnet-install.ps1"; + let script_path = install_dir.join("dotnet-install.ps1"); + + let response = REQWEST_CLIENT + .get(script_url) + .send() + .await? + .error_for_status() + .with_context(|| { + format!("failed to download dotnet install script from {script_url}") + })?; + let script_content = response.bytes().await?; + fs_err::tokio::write(&script_path, &script_content).await?; + + // PowerShell invocation optimized for Windows execution policy and path handling + let mut cmd = Cmd::new("powershell.exe", "dotnet-install.ps1"); + cmd.arg("-NoProfile") + .arg("-ExecutionPolicy") + .arg("Bypass") + .arg("-NonInteractive") + .arg("-File") + .arg(&script_path) + .arg("-InstallDir") + .arg(install_dir); + add_channel_args_windows(&mut cmd, version); + + cmd.check(true).output().await?; + Ok(()) + } +} + +pub(crate) async fn query_dotnet_version(dotnet: &Path) -> Result { + let mut cmd = Cmd::new(dotnet, "get dotnet version"); + if let Some(parent) = dotnet.parent() { + cmd.current_dir(parent); + } + let stdout = cmd.arg("--version").check(true).output().await?.stdout; + let version_str = String::from_utf8_lossy(&stdout).trim().to_string(); + parse_dotnet_version(&version_str) + .context(format!("Failed to parse version from: {version_str}")) +} + +pub(crate) fn parse_dotnet_version(version_str: &str) -> Option { + let base_version = version_str.split('-').next()?; + let parts: Vec<&str> = base_version.split('.').collect(); + if parts.len() >= 2 { + let major: u64 = parts[0].parse().ok()?; + let minor: u64 = parts[1].parse().ok()?; + let patch: u64 = parts.get(2).and_then(|p| p.parse().ok()).unwrap_or(0); + Some(Version::new(major, minor, patch)) + } else { + None + } +} + +pub(crate) fn dotnet_executable(dotnet_dir: &Path) -> PathBuf { + if cfg!(windows) { + dotnet_dir.join("dotnet.exe") + } else { + dotnet_dir.join("dotnet") + } +} + +fn version_satisfies_request(version: &Version, request: &LanguageRequest) -> bool { + match request { + LanguageRequest::Any { .. } => true, + LanguageRequest::Dotnet(req) => match req { + DotnetRequest::Any => true, + DotnetRequest::Major(major) => version.major == *major, + DotnetRequest::MajorMinor(major, minor) => { + version.major == *major && version.minor == *minor + } + DotnetRequest::MajorMinorPatch(major, minor, patch) => { + version.major == *major && version.minor == *minor && version.patch == *patch + } + }, + _ => false, + } +} + +fn to_dotnet_install_version(request: &LanguageRequest) -> Option { + match request { + LanguageRequest::Any { .. } => None, + LanguageRequest::Dotnet(req) => req.to_install_version(), + _ => None, + } +} + +/// Helper to determine if a string looks like a full semantic version (x.y.z) +/// or a channel (x.y). +fn is_full_version(ver: &str) -> bool { + // A version is considered "full" if semver can parse it directly + // or if it has 3 or more components. + // "8.0" has 2 parts -> Channel. + // "8.0.100" has 3 parts -> Version. + Version::parse(ver).is_ok() || ver.split('.').count() >= 3 +} + +fn add_channel_args_unix(cmd: &mut Cmd, version: Option<&str>) { + if let Some(ver) = version { + if is_full_version(ver) { + cmd.arg("--version").arg(ver); + } else { + // "8.0" or "LTS" or "STS" + cmd.arg("--channel").arg(ver); + } + } else { + cmd.arg("--channel").arg("LTS"); + } +} + +#[cfg(windows)] +fn add_channel_args_windows(cmd: &mut Cmd, version: Option<&str>) { + if let Some(ver) = version { + if is_full_version(ver) { + cmd.arg("-Version").arg(ver); + } else { + cmd.arg("-Channel").arg(ver); + } + } else { + cmd.arg("-Channel").arg("LTS"); + } +} + +pub(crate) fn installer_from_store(store: &Store) -> DotnetInstaller { + let dotnet_dir = store.tools_path(ToolBucket::Dotnet); + DotnetInstaller::new(dotnet_dir) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::languages::dotnet::DotnetRequest; + use semver::Version; + + #[test] + fn test_parse_dotnet_version() { + assert_eq!( + parse_dotnet_version("8.0.100"), + Some(Version::new(8, 0, 100)) + ); + assert_eq!(parse_dotnet_version("10.0.1"), Some(Version::new(10, 0, 1))); + assert_eq!(parse_dotnet_version("8.0"), Some(Version::new(8, 0, 0))); + assert_eq!( + parse_dotnet_version("8.0.100-preview.1"), + Some(Version::new(8, 0, 100)) + ); + assert_eq!(parse_dotnet_version("invalid"), None); + assert_eq!(parse_dotnet_version("8"), None); + assert_eq!(parse_dotnet_version(""), None); + } + + #[test] + fn test_dotnet_executable() { + let dir = std::path::Path::new("/test/path"); + let exe = dotnet_executable(dir); + + if cfg!(windows) { + assert_eq!(exe, dir.join("dotnet.exe")); + } else { + assert_eq!(exe, dir.join("dotnet")); + } + } + + #[test] + fn test_version_satisfies_request() { + let version = Version::new(8, 0, 100); + + // Test Any request + assert!(version_satisfies_request( + &version, + &LanguageRequest::Any { system_only: false } + )); + assert!(version_satisfies_request( + &version, + &LanguageRequest::Any { system_only: true } + )); + + // Test Dotnet requests + assert!(version_satisfies_request( + &version, + &LanguageRequest::Dotnet(DotnetRequest::Any) + )); + assert!(version_satisfies_request( + &version, + &LanguageRequest::Dotnet(DotnetRequest::Major(8)) + )); + assert!(!version_satisfies_request( + &version, + &LanguageRequest::Dotnet(DotnetRequest::Major(9)) + )); + assert!(version_satisfies_request( + &version, + &LanguageRequest::Dotnet(DotnetRequest::MajorMinor(8, 0)) + )); + assert!(!version_satisfies_request( + &version, + &LanguageRequest::Dotnet(DotnetRequest::MajorMinor(8, 1)) + )); + assert!(version_satisfies_request( + &version, + &LanguageRequest::Dotnet(DotnetRequest::MajorMinorPatch(8, 0, 100)) + )); + assert!(!version_satisfies_request( + &version, + &LanguageRequest::Dotnet(DotnetRequest::MajorMinorPatch(8, 0, 101)) + )); + + // Test non-dotnet request + assert!(!version_satisfies_request( + &version, + &LanguageRequest::Python(crate::languages::python::PythonRequest::Any) + )); + } + + #[test] + fn test_to_dotnet_install_version() { + assert_eq!( + to_dotnet_install_version(&LanguageRequest::Any { system_only: false }), + None + ); + assert_eq!( + to_dotnet_install_version(&LanguageRequest::Dotnet(DotnetRequest::Major(8))), + Some("8.0".to_string()) + ); + assert_eq!( + to_dotnet_install_version(&LanguageRequest::Python( + crate::languages::python::PythonRequest::Any + )), + None + ); + } + + #[test] + fn test_is_full_version() { + assert!(is_full_version("8.0.100")); + assert!(is_full_version("1.2.3")); + assert!(!is_full_version("8.0")); + assert!(!is_full_version("8")); + assert!(!is_full_version("LTS")); + assert!(!is_full_version("STS")); + } + + #[test] + fn test_dotnet_result() { + let path = std::path::PathBuf::from("/usr/bin/dotnet"); + let version = Version::new(8, 0, 100); + let result = DotnetResult::new(path.clone(), version.clone()); + + assert_eq!(result.dotnet(), path.as_path()); + assert_eq!(result.version(), &version); + + let display_str = result.to_string(); + assert!(display_str.contains("/usr/bin/dotnet")); + assert!(display_str.contains("8.0.100")); + } + + #[test] + fn test_display_format_error() { + let path = std::path::PathBuf::from("/usr/bin/dotnet"); + let version = Version::new(8, 0, 100); + let result = DotnetResult::new(path, version); + + // Test the Display implementation + let display_str = format!("{result}"); + assert!(display_str.contains("/usr/bin/dotnet@8.0.100")); + + use std::fmt::{self, Write}; + struct FailingFormatter; + impl Write for FailingFormatter { + fn write_str(&mut self, _s: &str) -> fmt::Result { + Err(fmt::Error) + } + } + + let mut failing_fmt = FailingFormatter; + let write_result = write!(failing_fmt, "{result}"); + assert!(write_result.is_err()); + } + + #[cfg(unix)] + #[test] + fn test_add_channel_args_unix_coverage() { + let mut cmd = Cmd::new("test", "test"); + add_channel_args_unix(&mut cmd, Some("8.0.100")); + assert!(format!("{cmd}").contains("--version 8.0.100")); + + let mut cmd = Cmd::new("test", "test"); + add_channel_args_unix(&mut cmd, Some("8.0")); + assert!(format!("{cmd}").contains("--channel 8.0")); + + let mut cmd = Cmd::new("test", "test"); + add_channel_args_unix(&mut cmd, None); + assert!(format!("{cmd}").contains("--channel LTS")); + } + + #[cfg(windows)] + #[test] + fn test_add_channel_args_windows_coverage() { + let mut cmd = Cmd::new("test", "test"); + add_channel_args_windows(&mut cmd, Some("8.0.100")); + assert!(format!("{cmd}").contains("-Version 8.0.100")); + + let mut cmd = Cmd::new("test", "test"); + add_channel_args_windows(&mut cmd, Some("8.0")); + assert!(format!("{cmd}").contains("-Channel 8.0")); + + let mut cmd = Cmd::new("test", "test"); + add_channel_args_windows(&mut cmd, None); + assert!(format!("{cmd}").contains("-Channel LTS")); + } +} diff --git a/crates/prek/src/languages/dotnet/mod.rs b/crates/prek/src/languages/dotnet/mod.rs new file mode 100644 index 000000000..57b124a7d --- /dev/null +++ b/crates/prek/src/languages/dotnet/mod.rs @@ -0,0 +1,7 @@ +#[allow(clippy::module_inception)] +mod dotnet; +pub(crate) mod installer; +mod version; + +pub(crate) use dotnet::Dotnet; +pub(crate) use version::DotnetRequest; diff --git a/crates/prek/src/languages/dotnet/version.rs b/crates/prek/src/languages/dotnet/version.rs new file mode 100644 index 000000000..8f6256677 --- /dev/null +++ b/crates/prek/src/languages/dotnet/version.rs @@ -0,0 +1,219 @@ +//! .NET SDK version request parsing. +//! +//! Supports version formats like: +//! - `8.0` or `8.0.100` - specific version +//! - `8` - major version only +//! - `net8.0`, `net9.0`, or `net10.0` - TFM-style versions +use std::str::FromStr; + +use crate::hook::InstallInfo; +use crate::languages::version::{Error, try_into_u64_slice}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum DotnetRequest { + Any, + Major(u64), + MajorMinor(u64, u64), + MajorMinorPatch(u64, u64, u64), +} + +impl FromStr for DotnetRequest { + type Err = Error; + + fn from_str(request: &str) -> Result { + if request.is_empty() { + return Ok(Self::Any); + } + + // Handle TFM-style versions like "net8.0", "net9.0", or "net10.0" + let version_str = request + .strip_prefix("net") + .or_else(|| request.strip_prefix("dotnet")) + .unwrap_or(request); + + if version_str.is_empty() { + return Ok(Self::Any); + } + + Self::parse_version_numbers(version_str, request) + } +} + +impl DotnetRequest { + pub(crate) fn is_any(&self) -> bool { + matches!(self, DotnetRequest::Any) + } + + fn parse_version_numbers(version_str: &str, original_request: &str) -> Result { + let parts = try_into_u64_slice(version_str) + .map_err(|_| Error::InvalidVersion(original_request.to_string()))?; + + match parts[..] { + [major] => Ok(DotnetRequest::Major(major)), + [major, minor] => Ok(DotnetRequest::MajorMinor(major, minor)), + [major, minor, patch] => Ok(DotnetRequest::MajorMinorPatch(major, minor, patch)), + _ => Err(Error::InvalidVersion(original_request.to_string())), + } + } + + pub(crate) fn satisfied_by(&self, install_info: &InstallInfo) -> bool { + let version = &install_info.language_version; + match self { + DotnetRequest::Any => true, + DotnetRequest::Major(major) => version.major == *major, + DotnetRequest::MajorMinor(major, minor) => { + version.major == *major && version.minor == *minor + } + DotnetRequest::MajorMinorPatch(major, minor, patch) => { + version.major == *major && version.minor == *minor && version.patch == *patch + } + } + } + + /// Convert to a version string suitable for dotnet-install script. + pub(crate) fn to_install_version(&self) -> Option { + match self { + DotnetRequest::Any => None, + DotnetRequest::Major(major) => Some(format!("{major}.0")), + DotnetRequest::MajorMinor(major, minor) => Some(format!("{major}.{minor}")), + DotnetRequest::MajorMinorPatch(major, minor, patch) => { + Some(format!("{major}.{minor}.{patch}")) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Language; + use crate::languages::version::LanguageRequest; + use rustc_hash::FxHashSet; + use std::path::PathBuf; + + #[test] + fn test_parse_dotnet_request() { + // Empty request + assert_eq!(DotnetRequest::from_str("").unwrap(), DotnetRequest::Any); + + // Major only + assert_eq!( + DotnetRequest::from_str("8").unwrap(), + DotnetRequest::Major(8) + ); + + // Major.minor + assert_eq!( + DotnetRequest::from_str("8.0").unwrap(), + DotnetRequest::MajorMinor(8, 0) + ); + assert_eq!( + DotnetRequest::from_str("9.0").unwrap(), + DotnetRequest::MajorMinor(9, 0) + ); + + // Full version + assert_eq!( + DotnetRequest::from_str("8.0.100").unwrap(), + DotnetRequest::MajorMinorPatch(8, 0, 100) + ); + + // TFM-style versions + assert_eq!( + DotnetRequest::from_str("net8.0").unwrap(), + DotnetRequest::MajorMinor(8, 0) + ); + assert_eq!( + DotnetRequest::from_str("net9.0").unwrap(), + DotnetRequest::MajorMinor(9, 0) + ); + assert_eq!( + DotnetRequest::from_str("net10.0").unwrap(), + DotnetRequest::MajorMinor(10, 0) + ); + + // dotnet prefix + assert_eq!( + DotnetRequest::from_str("dotnet8.0").unwrap(), + DotnetRequest::MajorMinor(8, 0) + ); + + // Invalid versions + assert!(DotnetRequest::from_str("invalid").is_err()); + assert!(DotnetRequest::from_str("8.0.100.1").is_err()); + assert!(DotnetRequest::from_str("8.a").is_err()); + } + + #[test] + fn test_is_any() { + assert!(DotnetRequest::Any.is_any()); + assert!(!DotnetRequest::Major(8).is_any()); + assert!(!DotnetRequest::MajorMinor(8, 0).is_any()); + assert!(!DotnetRequest::MajorMinorPatch(8, 0, 100).is_any()); + + // Test through LanguageRequest dispatch + let req = LanguageRequest::parse(Language::Dotnet, "net").unwrap(); + assert!(req.is_any()); + let req = LanguageRequest::parse(Language::Dotnet, "8").unwrap(); + assert!(!req.is_any()); + } + + #[test] + fn test_tool_buckets() { + let buckets = Language::Dotnet.tool_buckets(); + assert_eq!(buckets.len(), 1); + assert!(buckets.iter().any(|b| b.as_ref() == "dotnet")); + } + + #[test] + fn test_parse_net_prefix_only() { + // "net" alone should return Any + assert_eq!(DotnetRequest::from_str("net").unwrap(), DotnetRequest::Any); + assert_eq!( + DotnetRequest::from_str("dotnet").unwrap(), + DotnetRequest::Any + ); + } + + #[test] + fn test_satisfied_by() -> anyhow::Result<()> { + let temp_dir = tempfile::tempdir()?; + let mut install_info = + InstallInfo::new(Language::Dotnet, FxHashSet::default(), temp_dir.path())?; + install_info + .with_language_version(semver::Version::new(8, 0, 100)) + .with_toolchain(PathBuf::from("/usr/share/dotnet/dotnet")); + + assert!(DotnetRequest::Any.satisfied_by(&install_info)); + assert!(DotnetRequest::Major(8).satisfied_by(&install_info)); + assert!(DotnetRequest::MajorMinor(8, 0).satisfied_by(&install_info)); + assert!(DotnetRequest::MajorMinorPatch(8, 0, 100).satisfied_by(&install_info)); + assert!(!DotnetRequest::MajorMinorPatch(8, 0, 101).satisfied_by(&install_info)); + assert!(!DotnetRequest::Major(9).satisfied_by(&install_info)); + + // Test through LanguageRequest dispatch + let req = LanguageRequest::parse(Language::Dotnet, "8").unwrap(); + assert!(req.satisfied_by(&install_info)); + let req = LanguageRequest::parse(Language::Dotnet, "9").unwrap(); + assert!(!req.satisfied_by(&install_info)); + + Ok(()) + } + + #[test] + fn test_to_install_version() { + assert_eq!(DotnetRequest::Any.to_install_version(), None); + assert_eq!( + DotnetRequest::Major(8).to_install_version(), + Some("8.0".to_string()) + ); + assert_eq!( + DotnetRequest::MajorMinor(8, 0).to_install_version(), + Some("8.0".to_string()) + ); + assert_eq!( + DotnetRequest::MajorMinorPatch(8, 0, 100).to_install_version(), + Some("8.0.100".to_string()) + ); + } +} diff --git a/crates/prek/src/languages/mod.rs b/crates/prek/src/languages/mod.rs index 8ce9d8242..24837b630 100644 --- a/crates/prek/src/languages/mod.rs +++ b/crates/prek/src/languages/mod.rs @@ -19,6 +19,7 @@ mod bun; mod deno; mod docker; mod docker_image; +mod dotnet; mod fail; mod golang; mod haskell; @@ -38,6 +39,7 @@ static BUN: bun::Bun = bun::Bun; static DENO: deno::Deno = deno::Deno; static DOCKER: docker::Docker = docker::Docker; static DOCKER_IMAGE: docker_image::DockerImage = docker_image::DockerImage; +static DOTNET: dotnet::Dotnet = dotnet::Dotnet; static FAIL: fail::Fail = fail::Fail; static GOLANG: golang::Golang = golang::Golang; static HASKELL: haskell::Haskell = haskell::Haskell; @@ -110,7 +112,7 @@ impl LanguageImpl for Unimplemented { // dart: only system version, support env, support additional deps // docker_image: only system version, no env, no additional deps // docker: only system version, support env, no additional deps -// dotnet: only system version, support env, no additional deps +// dotnet: install requested version, support env, support additional deps // fail: only system version, no env, no additional deps // golang: install requested version, support env, support additional deps // haskell: only system version, support env, support additional deps @@ -134,6 +136,7 @@ impl Language { | Self::Deno | Self::Docker | Self::DockerImage + | Self::Dotnet | Self::Fail | Self::Golang | Self::Haskell @@ -160,6 +163,7 @@ impl Language { pub fn tool_buckets(self) -> &'static [ToolBucket] { match self { Self::Bun => &[ToolBucket::Bun], + Self::Dotnet => &[ToolBucket::Dotnet], Self::Deno => &[ToolBucket::Deno], Self::Golang => &[ToolBucket::Go], Self::Node => &[ToolBucket::Node], @@ -187,6 +191,7 @@ impl Language { matches!( self, Self::Bun + | Self::Dotnet | Self::Deno | Self::Golang | Self::Node @@ -209,7 +214,6 @@ impl Language { | Self::Script | Self::System | Self::Docker - | Self::Dotnet | Self::Swift ) } @@ -225,6 +229,7 @@ impl Language { Self::Deno => DENO.install(hook, store, reporter).await, Self::Docker => DOCKER.install(hook, store, reporter).await, Self::DockerImage => DOCKER_IMAGE.install(hook, store, reporter).await, + Self::Dotnet => DOTNET.install(hook, store, reporter).await, Self::Fail => FAIL.install(hook, store, reporter).await, Self::Golang => GOLANG.install(hook, store, reporter).await, Self::Haskell => HASKELL.install(hook, store, reporter).await, @@ -248,6 +253,7 @@ impl Language { Self::Deno => DENO.check_health(info).await, Self::Docker => DOCKER.check_health(info).await, Self::DockerImage => DOCKER_IMAGE.check_health(info).await, + Self::Dotnet => DOTNET.check_health(info).await, Self::Fail => FAIL.check_health(info).await, Self::Golang => GOLANG.check_health(info).await, Self::Haskell => HASKELL.check_health(info).await, @@ -300,6 +306,7 @@ impl Language { Self::Deno => DENO.run(hook, filenames, store, reporter).await, Self::Docker => DOCKER.run(hook, filenames, store, reporter).await, Self::DockerImage => DOCKER_IMAGE.run(hook, filenames, store, reporter).await, + Self::Dotnet => DOTNET.run(hook, filenames, store, reporter).await, Self::Fail => FAIL.run(hook, filenames, store, reporter).await, Self::Golang => GOLANG.run(hook, filenames, store, reporter).await, Self::Haskell => HASKELL.run(hook, filenames, store, reporter).await, diff --git a/crates/prek/src/languages/version.rs b/crates/prek/src/languages/version.rs index d1866c57a..44dd3f2f0 100644 --- a/crates/prek/src/languages/version.rs +++ b/crates/prek/src/languages/version.rs @@ -4,6 +4,7 @@ use crate::config::Language; use crate::hook::InstallInfo; use crate::languages::bun::BunRequest; use crate::languages::deno::DenoRequest; +use crate::languages::dotnet::DotnetRequest; use crate::languages::golang::GoRequest; use crate::languages::node::NodeRequest; use crate::languages::python::PythonRequest; @@ -20,6 +21,7 @@ pub(crate) enum Error { pub(crate) enum LanguageRequest { Any { system_only: bool }, Bun(BunRequest), + Dotnet(DotnetRequest), Deno(DenoRequest), Golang(GoRequest), Ruby(RubyRequest), @@ -35,6 +37,7 @@ impl LanguageRequest { match self { LanguageRequest::Any { .. } => true, LanguageRequest::Bun(req) => req.is_any(), + LanguageRequest::Dotnet(req) => req.is_any(), LanguageRequest::Deno(req) => req.is_any(), LanguageRequest::Golang(req) => req.is_any(), LanguageRequest::Node(req) => req.is_any(), @@ -77,6 +80,7 @@ impl LanguageRequest { Ok(match lang { Language::Bun => Self::Bun(request.parse()?), + Language::Dotnet => Self::Dotnet(request.parse()?), Language::Deno => Self::Deno(request.parse()?), Language::Golang => Self::Golang(request.parse()?), Language::Node => Self::Node(request.parse()?), @@ -91,6 +95,7 @@ impl LanguageRequest { match self { LanguageRequest::Any { .. } => true, LanguageRequest::Bun(req) => req.satisfied_by(install_info), + LanguageRequest::Dotnet(req) => req.satisfied_by(install_info), LanguageRequest::Deno(req) => req.satisfied_by(install_info), LanguageRequest::Golang(req) => req.satisfied_by(install_info), LanguageRequest::Node(req) => req.satisfied_by(install_info), diff --git a/crates/prek/src/store.rs b/crates/prek/src/store.rs index 36c71f326..033737a1a 100644 --- a/crates/prek/src/store.rs +++ b/crates/prek/src/store.rs @@ -448,6 +448,7 @@ pub(crate) enum ToolBucket { Ruby, Rustup, Bun, + Dotnet, Deno, } diff --git a/crates/prek/tests/languages/dotnet.rs b/crates/prek/tests/languages/dotnet.rs new file mode 100644 index 000000000..b29b6f41b --- /dev/null +++ b/crates/prek/tests/languages/dotnet.rs @@ -0,0 +1,603 @@ +use assert_fs::fixture::{FileWriteStr, PathChild, PathCreateDir}; +use prek_consts::PRE_COMMIT_HOOKS_YAML; +use prek_consts::env_vars::EnvVars; +use std::ffi::OsString; + +use crate::common::{TestContext, cmd_snapshot, git_cmd}; + +/// Helper to create a fake dotnet binary that exits with an error (shadowing the system dotnet). +/// Returns the new PATH environment variable value. +fn shadow_dotnet(context: &TestContext) -> OsString { + let fake_bin_dir = context.home_dir().child("fake_bin"); + fake_bin_dir.create_dir_all().unwrap(); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let fake_dotnet = fake_bin_dir.child("dotnet"); + fake_dotnet.write_str("#!/bin/sh\nexit 127\n").unwrap(); + std::fs::set_permissions(fake_dotnet.path(), std::fs::Permissions::from_mode(0o755)) + .unwrap(); + } + + #[cfg(windows)] + { + let fake_dotnet = fake_bin_dir.child("dotnet.cmd"); + fake_dotnet.write_str("@echo off\nexit /b 127\n").unwrap(); + } + + let original_path = EnvVars::var_os(EnvVars::PATH).unwrap_or_default(); + let mut new_path = OsString::from(fake_bin_dir.path()); + let sep = if cfg!(windows) { ";" } else { ":" }; + new_path.push(sep); + new_path.push(&original_path); + new_path +} + +#[test] +fn language_version() { + if !EnvVars::is_set(EnvVars::CI) { + return; + } + + let context = TestContext::new(); + context.init_project(); + + context.write_pre_commit_config(indoc::indoc! {r" + repos: + - repo: local + hooks: + - id: local + name: local + language: dotnet + entry: dotnet --version + language_version: '10.0' + always_run: true + verbose: true + pass_filenames: false + "}); + + context.git_add("."); + + let output = context.run().output().unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(output.status.success(), "hook should pass"); + assert!( + stdout.contains("10.0"), + "output should contain version 10.0, got: {stdout}" + ); +} + +/// Test that multiple different SDK versions can coexist in the tool store. +#[test] +fn multiple_sdk_versions() { + if !EnvVars::is_set(EnvVars::CI) { + return; + } + + let context = TestContext::new(); + context.init_project(); + + context.write_pre_commit_config(indoc::indoc! {r" + repos: + - repo: local + hooks: + - id: hook-8 + name: hook-8 + language: dotnet + entry: dotnet --version + language_version: '8.0' + always_run: true + pass_filenames: false + - id: hook-10 + name: hook-10 + language: dotnet + entry: dotnet --version + language_version: '10.0' + always_run: true + pass_filenames: false + "}); + + context.git_add("."); + + let shadowed_path = shadow_dotnet(&context); + + // Run with the shadowed path to ensure managed versions are used + let output = context.run().env("PATH", &shadowed_path).output().unwrap(); + assert!(output.status.success(), "hooks should pass"); + + // Verify both versions exist in the tool bucket + // Path structure: [HOME]/tools/dotnet/[VERSION]/... + let dotnet_tool_root = context.home_dir().child("tools").child("dotnet"); + + let mut found_8 = false; + let mut found_10 = false; + + for entry in std::fs::read_dir(dotnet_tool_root.path()) + .unwrap() + .flatten() + { + let name = entry.file_name().to_string_lossy().to_string(); + if name.starts_with('8') { + found_8 = true; + } + if name.starts_with("10") { + found_10 = true; + } + } + + assert!(found_8, "Managed dotnet 8.x should exist"); + assert!(found_10, "Managed dotnet 10.x should exist"); +} + +/// Test invalid `language_version` format is rejected. +#[test] +fn invalid_language_version() { + let context = TestContext::new(); + context.init_project(); + + context.write_pre_commit_config(indoc::indoc! {r" + repos: + - repo: local + hooks: + - id: local + name: local + language: dotnet + entry: dotnet --version + language_version: 'invalid-version' + always_run: true + verbose: true + pass_filenames: false + "}); + + context.git_add("."); + + cmd_snapshot!(context.filters(), context.run(), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Failed to init hooks + caused by: Invalid hook `local` + caused by: Invalid `language_version` value: `invalid-version` + "); +} + +/// Test that `additional_dependencies` are installed correctly. +#[test] +fn additional_dependencies() { + if !EnvVars::is_set(EnvVars::CI) { + return; + } + + let context = TestContext::new(); + context.init_project(); + + context.write_pre_commit_config(indoc::indoc! {r#" + repos: + - repo: local + hooks: + - id: local + name: local + language: dotnet + entry: dotnet-outdated --version + additional_dependencies: ["dotnet-outdated-tool"] + always_run: true + verbose: true + pass_filenames: false + "#}); + + context.git_add("."); + + let output = context.run().output().unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(output.status.success(), "hook should pass"); + assert!( + stdout.contains("dotnet-outdated") || stdout.contains("Nuget"), + "output should mention the tool" + ); +} + +/// Test installing a specific version of a dotnet tool. +#[test] +fn additional_dependencies_with_version() { + if !EnvVars::is_set(EnvVars::CI) { + return; + } + + let context = TestContext::new(); + context.init_project(); + + context.write_pre_commit_config(indoc::indoc! {r#" + repos: + - repo: local + hooks: + - id: local + name: local + language: dotnet + entry: dotnet-outdated --version + additional_dependencies: ["dotnet-outdated-tool:4.6.0"] + always_run: true + verbose: true + pass_filenames: false + "#}); + + context.git_add("."); + + let output = context.run().output().unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(output.status.success(), "hook should pass"); + assert!( + stdout.contains("4.6.0"), + "should install specific version 4.6.0" + ); +} + +/// Test that additional dependencies in a remote repo are installed correctly. +#[test] +fn additional_dependencies_in_remote_repo() -> anyhow::Result<()> { + if !EnvVars::is_set(EnvVars::CI) { + return Ok(()); + } + + let repo = TestContext::new(); + repo.init_project(); + + let repo_path = repo.work_dir(); + repo_path + .child(PRE_COMMIT_HOOKS_YAML) + .write_str(indoc::indoc! {r#" + - id: dotnet-outdated + name: dotnet-outdated + language: dotnet + entry: dotnet-outdated --version + additional_dependencies: ["dotnet-outdated-tool"] + "#})?; + repo.git_add("."); + repo.git_commit("Add manifest"); + git_cmd(repo.work_dir()) + .args(["tag", "v0.1.0", "-m", "v0.1.0"]) + .output()?; + + let context = TestContext::new(); + context.init_project(); + context.write_pre_commit_config(&indoc::formatdoc! {r" + repos: + - repo: {} + rev: v0.1.0 + hooks: + - id: dotnet-outdated + verbose: true + pass_filenames: false + ", repo_path.display()}); + + context.git_add("."); + + let output = context.run().output().unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(output.status.success(), "hook should pass"); + assert!(stdout.contains("dotnet-outdated") || stdout.contains("Nuget")); + + Ok(()) +} + +/// Ensure that stderr from hooks is captured and shown to the user. +#[test] +fn hook_stderr() -> anyhow::Result<()> { + if !EnvVars::is_set(EnvVars::CI) { + return Ok(()); + } + + let context = TestContext::new(); + context.init_project(); + + context.write_pre_commit_config(indoc::indoc! {r" + repos: + - repo: local + hooks: + - id: local + name: local + language: dotnet + entry: dotnet run --project ./hook + "}); + + // Create a minimal console app that writes to stderr + context.work_dir().child("hook").create_dir_all()?; + context + .work_dir() + .child("hook/hook.csproj") + .write_str(indoc::indoc! {r#" + + + Exe + net8.0 + disable + + + "#})?; + context + .work_dir() + .child("hook/Program.cs") + .write_str(indoc::indoc! {r#" + using System; + Console.Error.WriteLine("Error from hook"); + Console.Error.Flush(); + Environment.Exit(1); + "#})?; + + context.git_add("."); + + cmd_snapshot!(context.filters(), context.run(), @r" + success: false + exit_code: 1 + ----- stdout ----- + local....................................................................Failed + - hook id: local + - exit code: 1 + + Error from hook + + ----- stderr ----- + "); + + Ok(()) +} + +/// Test that `language_version: system` fails when no system dotnet is available. +#[test] +fn system_only_fails_without_dotnet() { + let context = TestContext::new(); + context.init_project(); + + context.write_pre_commit_config(indoc::indoc! {r" + repos: + - repo: local + hooks: + - id: local + name: local + language: dotnet + entry: dotnet --version + language_version: system + always_run: true + pass_filenames: false + "}); + + context.git_add("."); + + let shadowed_path = shadow_dotnet(&context); + + cmd_snapshot!(context.filters(), context.run().env("PATH", &shadowed_path), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Failed to install hook `local` + caused by: Failed to install or find dotnet SDK + caused by: No system dotnet installation found + "); +} + +/// Test that requesting an unavailable dotnet version fails gracefully. +#[test] +fn unavailable_version_fails() { + if !EnvVars::is_set(EnvVars::CI) { + return; + } + + let context = TestContext::new(); + context.init_project(); + + // Request a version that is invalid or won't exist in modern channels + context.write_pre_commit_config(indoc::indoc! {r" + repos: + - repo: local + hooks: + - id: local + name: local + language: dotnet + entry: dotnet --version + language_version: '0.1.0' + always_run: true + pass_filenames: false + "}); + + context.git_add("."); + + let shadowed_path = shadow_dotnet(&context); + + let output = context.run().env("PATH", &shadowed_path).output().unwrap(); + assert!( + !output.status.success(), + "should fail when requesting unavailable version" + ); +} + +/// Test that default `language_version` works. +#[test] +fn default_language_version() { + if !EnvVars::is_set(EnvVars::CI) { + return; + } + + let context = TestContext::new(); + context.init_project(); + + context.write_pre_commit_config(indoc::indoc! {r" + repos: + - repo: local + hooks: + - id: local + name: local + language: dotnet + entry: dotnet --version + always_run: true + verbose: true + pass_filenames: false + "}); + + context.git_add("."); + + let output = context.run().output().unwrap(); + assert!(output.status.success()); +} + +/// Test TFM-style version specification (net9.0, net10.0, etc.). +#[test] +fn tfm_style_language_version() { + if !EnvVars::is_set(EnvVars::CI) { + return; + } + + let context = TestContext::new(); + context.init_project(); + + context.write_pre_commit_config(indoc::indoc! {r" + repos: + - repo: local + hooks: + - id: local + name: local + language: dotnet + entry: dotnet --version + language_version: 'net10.0' + always_run: true + verbose: true + pass_filenames: false + "}); + + context.git_add("."); + + let output = context.run().output().unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(output.status.success()); + assert!(stdout.contains("10.0")); +} + +/// Test major-only version specification. +#[test] +fn major_only_language_version() { + if !EnvVars::is_set(EnvVars::CI) { + return; + } + + let context = TestContext::new(); + context.init_project(); + + context.write_pre_commit_config(indoc::indoc! {r" + repos: + - repo: local + hooks: + - id: local + name: local + language: dotnet + entry: dotnet --version + language_version: '8' + always_run: true + verbose: true + pass_filenames: false + "}); + + context.git_add("."); + + let output = context.run().output().unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(output.status.success()); + assert!(stdout.contains("8.")); +} + +/// Test that `types: [c#]` filter correctly matches .cs files. +#[test] +fn csharp_type_filter() -> anyhow::Result<()> { + let context = TestContext::new(); + context.init_project(); + + context + .work_dir() + .child("Program.cs") + .write_str("class Program { }")?; + + context + .work_dir() + .child("readme.txt") + .write_str("This is a readme")?; + + context.write_pre_commit_config(indoc::indoc! {r#" + repos: + - repo: local + hooks: + - id: csharp-echo + name: csharp-echo + language: system + entry: "echo files:" + types: [c#] + verbose: true + "#}); + + context.git_add("."); + + cmd_snapshot!(context.filters(), context.run(), @r" + success: true + exit_code: 0 + ----- stdout ----- + csharp-echo..............................................................Passed + - hook id: csharp-echo + - duration: [TIME] + + files: Program.cs + + ----- stderr ----- + "); + + Ok(()) +} + +/// Test that dotnet tools are installed in an isolated environment. +#[test] +fn tools_isolated_environment() -> anyhow::Result<()> { + if !EnvVars::is_set(EnvVars::CI) { + return Ok(()); + } + + let context = TestContext::new(); + context.init_project(); + + context.write_pre_commit_config(indoc::indoc! {r#" + repos: + - repo: local + hooks: + - id: local + name: local + language: dotnet + entry: dotnet-outdated --version + additional_dependencies: ["dotnet-outdated-tool"] + always_run: true + pass_filenames: false + "#}); + + context.git_add("."); + + let output = context.run().output().unwrap(); + assert!(output.status.success()); + + let hooks_path = context.home_dir().child("hooks"); + + let dotnet_env = std::fs::read_dir(hooks_path.path())? + .flatten() + .find(|entry| entry.file_name().to_string_lossy().starts_with("dotnet-")); + + assert!(dotnet_env.is_some(), "dotnet environment should exist"); + let tools_path = dotnet_env.unwrap().path().join("tools"); + assert!(tools_path.exists()); + + let tool_exists = std::fs::read_dir(&tools_path)?.flatten().any(|entry| { + entry + .file_name() + .to_string_lossy() + .starts_with("dotnet-outdated") + }); + assert!(tool_exists, "dotnet-outdated should be in isolated path"); + + Ok(()) +} diff --git a/crates/prek/tests/languages/main.rs b/crates/prek/tests/languages/main.rs index 4f4162e79..cb5bc39a4 100644 --- a/crates/prek/tests/languages/main.rs +++ b/crates/prek/tests/languages/main.rs @@ -7,6 +7,7 @@ mod deno; mod docker; #[cfg(all(feature = "docker", target_os = "linux"))] mod docker_image; +mod dotnet; mod fail; mod golang; mod haskell; diff --git a/crates/prek/tests/run.rs b/crates/prek/tests/run.rs index 83aa44137..c49e87a65 100644 --- a/crates/prek/tests/run.rs +++ b/crates/prek/tests/run.rs @@ -226,8 +226,8 @@ fn invalid_config() { hooks: - id: trailing-whitespace name: trailing-whitespace - language: dotnet - additional_dependencies: ["dotnet@6"] + language: swift + additional_dependencies: ["swift-format@5.0.0"] entry: echo Hello, world! "#}); context.git_add("."); @@ -240,7 +240,7 @@ fn invalid_config() { ----- stderr ----- error: Failed to init hooks caused by: Invalid hook `trailing-whitespace` - caused by: Hook specified `additional_dependencies: dotnet@6` but the language `dotnet` does not support installing dependencies for now + caused by: Hook specified `additional_dependencies: swift-format@5.0.0` but the language `swift` does not support installing dependencies for now "); context.write_pre_commit_config(indoc::indoc! {r" diff --git a/docs/languages.md b/docs/languages.md index ea1d744be..d1f729ad9 100644 --- a/docs/languages.md +++ b/docs/languages.md @@ -122,9 +122,36 @@ If the image already defines an `ENTRYPOINT`, you can omit `--entrypoint` in `en ### dotnet -**Status in prek:** Not supported yet. +**Status in prek:** ✅ Supported. + +prek supports .NET tool-based hooks. Tools specified in `additional_dependencies` are installed using `dotnet tool install --tool-path` and made available on the PATH when the hook runs. + +#### `language_version` + +You can request a specific .NET SDK version: + +- `language_version: "8"` – any .NET 8.x SDK +- `language_version: "8.0"` – any .NET 8.0.x SDK +- `language_version: "8.0.100"` – exactly .NET SDK 8.0.100 + +prek first looks for a matching system-installed `dotnet`, then falls back to downloading the SDK via the official install script if needed. -Tracking: [#48](https://github.com/j178/prek/issues/48) +#### `additional_dependencies` + +Tools are specified in `additional_dependencies` as either `package:version` (to pin a specific version) or just `package` (to install the latest available version): + +```yaml +repos: + - repo: https://github.com/example/csharpier-hook + rev: v1.0.0 + hooks: + - id: csharpier + additional_dependencies: + # Pin to a specific version + - "csharpier:1.2.6" + # Or install the latest version available + - "dotnet-format" +``` ### fail