diff --git a/clang-tools-manager/src/downloader/mod.rs b/clang-tools-manager/src/downloader/mod.rs index a288548d..cf623f60 100644 --- a/clang-tools-manager/src/downloader/mod.rs +++ b/clang-tools-manager/src/downloader/mod.rs @@ -117,6 +117,8 @@ fn chmod_file(path: &Path, mode: Option) -> std::io::Result<()> { #[cfg(test)] mod tests { + use std::path::Path; + use crate::DownloadError; use super::download; @@ -192,4 +194,27 @@ mod tests { assert!(matches!(err, DownloadError::RequestError(_))); mock.assert(); } + + #[tokio::test] + async fn fail_temp_file() { + // initializing twice exercises the "already initialized" path. + initialize_logger(); + initialize_logger(); + let mut server = Server::new_async().await; + let url_path = "/test"; + let url = Url::parse(server.url().as_str()) + .unwrap() + .join(url_path) + .unwrap(); + let mock = server + .mock("GET", url_path) + .with_status(200) + .with_body("some content") + .create(); + // An empty path has no parent and cannot be opened as a (temporary) file. + let err = download(&url, Path::new(""), 1).await.unwrap_err(); + println!("{}", err); + assert!(matches!(err, DownloadError::TempFile(..))); + mock.assert(); + } } diff --git a/clang-tools-manager/src/downloader/native_packages/mod.rs b/clang-tools-manager/src/downloader/native_packages/mod.rs index db3c15c2..0016fbb5 100644 --- a/clang-tools-manager/src/downloader/native_packages/mod.rs +++ b/clang-tools-manager/src/downloader/native_packages/mod.rs @@ -127,3 +127,105 @@ pub async fn try_install_package( } Ok(None) } + +#[cfg(all(test, unix))] +mod tests { + //! These tests use a fake `brew` (and fake clang tools) in an isolated `PATH`. + //! Thus, no real package manager is ever invoked. + + use std::path::Path; + + use semver::{Version, VersionReq}; + use tempfile::TempDir; + + use super::{get_available_package_managers, try_install_package}; + use crate::{ + ClangTool, + test_utils_unix::{enable_logs, fake_clang_tool, fake_exe, isolate_path}, + version::GetToolError, + }; + + const TOOL: ClangTool = ClangTool::ClangFormat; + + /// A fake `brew` where `brew list` exits with `list_status`, + /// and `brew install` exits with `install_status`. + fn fake_brew(dir: &Path, list_status: u8, install_status: u8) { + let script = format!( + "if [ \"$1\" = \"list\" ]; then exit {list_status}; fi\n\ + exit {install_status}" + ); + fake_exe(dir, "brew", &script); + } + + async fn run() -> Result, GetToolError> { + let req = VersionReq::parse("17").unwrap(); + try_install_package(&TOOL, &req, &Version::new(17, 0, 0)).await + } + + fn setup() -> TempDir { + enable_logs(); + let tmp = TempDir::new().unwrap(); + isolate_path(tmp.path()); + tmp + } + + #[tokio::test] + async fn no_package_managers() { + let _tmp = setup(); + assert!(get_available_package_managers().is_empty()); + assert!(run().await.unwrap().is_none()); + } + + #[tokio::test] + async fn already_installed() { + let tmp = setup(); + fake_brew(tmp.path(), 0, 1); + assert_eq!(get_available_package_managers().len(), 1); + fake_clang_tool(tmp.path(), "clang-format-17", "17.0.6"); + let result = run().await.unwrap().unwrap(); + assert_eq!(result.version, Version::new(17, 0, 6)); + assert_eq!(result.path, tmp.path().join("clang-format-17")); + } + + #[tokio::test] + async fn install_success() { + let tmp = setup(); + fake_brew(tmp.path(), 1, 0); + fake_clang_tool(tmp.path(), "clang-format-17", "17.0.6"); + let result = run().await.unwrap().unwrap(); + assert_eq!(result.version, Version::new(17, 0, 6)); + } + + #[tokio::test] + async fn install_version_mismatch() { + let tmp = setup(); + fake_brew(tmp.path(), 0, 0); + // the found binary does not satisfy the version requirement + fake_clang_tool(tmp.path(), "clang-format", "16.0.0"); + assert!(run().await.unwrap().is_none()); + } + + #[tokio::test] + async fn install_failure() { + let tmp = setup(); + fake_brew(tmp.path(), 1, 1); + assert!(run().await.unwrap().is_none()); + } + + #[tokio::test] + async fn installed_tool_not_found() { + let tmp = setup(); + fake_brew(tmp.path(), 1, 0); + let err = run().await.unwrap_err(); + assert!(matches!(err, GetToolError::GetClangPathError(_))); + } + + #[tokio::test] + async fn installed_tool_no_version() { + let tmp = setup(); + fake_brew(tmp.path(), 1, 0); + fake_exe(tmp.path(), "clang-format-17", "echo \"no version info\""); + let err = run().await.unwrap_err(); + assert!(matches!(err, GetToolError::GetClangVersion(_))); + } +} diff --git a/clang-tools-manager/src/downloader/native_packages/unix.rs b/clang-tools-manager/src/downloader/native_packages/unix.rs index 4aa9becd..90545228 100644 --- a/clang-tools-manager/src/downloader/native_packages/unix.rs +++ b/clang-tools-manager/src/downloader/native_packages/unix.rs @@ -232,20 +232,23 @@ mod llvm_apt_install { pub async fn install_llvm_via_apt( ver_major: String, package_name: &str, + ) -> Result<(), PackageManagerError> { + install_llvm_via_apt_from(LLVM_INSTALL_SCRIPT_URL, ver_major, package_name).await + } + + /// The implementation of [`install_llvm_via_apt()`] with + /// a configurable `script_url` (to allow mocking in tests). + pub(super) async fn install_llvm_via_apt_from( + script_url: &str, + ver_major: String, + package_name: &str, ) -> Result<(), PackageManagerError> { let download_path = UnixPackageManager::get_cache_dir().join("llvm_apt_install.sh"); if !download_path.exists() || !UnixPackageManager::is_cache_valid(&download_path, Some(Duration::from_hours(24))) { - log::info!( - "Downloading LLVM APT repository installation script from {LLVM_INSTALL_SCRIPT_URL}" - ); - download( - &Url::parse(LLVM_INSTALL_SCRIPT_URL)?, - &download_path, - 60 * 2, - ) - .await?; + log::info!("Downloading LLVM APT repository installation script from {script_url}"); + download(&Url::parse(script_url)?, &download_path, 60 * 2).await?; chmod_file(&download_path, Some(0o111))?; } let has_sudo = UnixPackageManager::has_sudo(); @@ -287,3 +290,303 @@ mod llvm_apt_install { Ok(()) } } + +#[cfg(test)] +mod tests { + //! These tests use fake executables (shell scripts) in an isolated `PATH`. + //! Thus, no real package manager (nor `sudo`) is ever invoked. + + use semver::Version; + use tempfile::TempDir; + + use super::{PackageManager, PackageManagerError, UnixPackageManager}; + use crate::{ + ClangTool, + test_utils_unix::{enable_logs, fake_exe, isolate_path}, + }; + + /// A fake `sudo` that just runs the given command. + const SUDO: &str = "exec \"$@\""; + const SUCCESS: &str = "exit 0"; + const FAILURE: &str = "echo \"intentional failure\" >&2\nexit 1"; + + fn all_managers() -> Vec { + vec![ + #[cfg(target_os = "linux")] + UnixPackageManager::Apt, + #[cfg(target_os = "linux")] + UnixPackageManager::Dnf, + #[cfg(target_os = "linux")] + UnixPackageManager::PacMan, + UnixPackageManager::Homebrew, + ] + } + + /// Creates a temp directory and isolates the `PATH` to it. + fn setup() -> TempDir { + enable_logs(); + let tmp = TempDir::new().unwrap(); + isolate_path(tmp.path()); + tmp + } + + async fn install( + mgr: &UnixPackageManager, + version: Option<&Version>, + ) -> Result<(), PackageManagerError> { + mgr.install_package("clang", version).await + } + + fn assert_install_error(result: Result<(), PackageManagerError>, expected_mgr: &str) { + let err = result.unwrap_err(); + println!("{err}"); + assert!(matches!( + err, + PackageManagerError::InstallationError { manager, stderr, .. } + if manager == expected_mgr && stderr.contains("intentional failure") + )); + } + + #[test] + fn names() { + let ver = Version::new(17, 0, 0); + for mgr in all_managers() { + assert_eq!( + mgr.to_string().to_lowercase().replace("homebrew", "brew"), + mgr.as_str() + ); + let pkg = mgr.get_package_name(&ClangTool::ClangFormat); + let with_ver = mgr.pkg_name_with_version(&pkg, Some(&ver)); + assert!(with_ver.starts_with(&pkg)); + assert!(with_ver.ends_with("17")); + assert_eq!(mgr.pkg_name_with_version(&pkg, None), pkg); + } + let listed = UnixPackageManager::list_managers() + .iter() + .map(|m| m.to_string()) + .collect::>(); + let expected = all_managers() + .iter() + .map(|m| m.to_string()) + .collect::>(); + assert_eq!(listed, expected); + } + + #[test] + fn is_installed() { + let tmp = setup(); + for mgr in all_managers() { + assert!(!mgr.is_installed()); + fake_exe(tmp.path(), mgr.as_str(), SUCCESS); + assert!(mgr.is_installed()); + } + } + + #[test] + fn is_installed_package() { + let tmp = setup(); + let ver = Version::new(17, 0, 0); + for mgr in all_managers() { + // package manager not found + assert!(!mgr.is_installed_package("clang", Some(&ver))); + fake_exe(tmp.path(), mgr.as_str(), SUCCESS); + assert!(mgr.is_installed_package("clang", Some(&ver))); + fake_exe(tmp.path(), mgr.as_str(), FAILURE); + assert!(!mgr.is_installed_package("clang", None)); + } + } + + #[tokio::test] + async fn install_with_sudo() { + let tmp = setup(); + fake_exe(tmp.path(), "sudo", SUDO); + for mgr in all_managers() { + fake_exe(tmp.path(), mgr.as_str(), SUCCESS); + install(&mgr, Some(&Version::new(17, 0, 0))).await.unwrap(); + } + } + + #[tokio::test] + async fn install_without_sudo() { + let tmp = setup(); + for mgr in all_managers() { + fake_exe(tmp.path(), mgr.as_str(), SUCCESS); + install(&mgr, None).await.unwrap(); + } + } + + #[tokio::test] + async fn install_failure() { + let tmp = setup(); + for mgr in all_managers() { + fake_exe(tmp.path(), mgr.as_str(), FAILURE); + // `apt` falls back to LLVM's PPA only when a version is specified. + assert_install_error(install(&mgr, None).await, mgr.as_str()); + } + } + + #[tokio::test] + async fn install_missing_manager() { + let _tmp = setup(); + for mgr in all_managers() { + let err = install(&mgr, None).await.unwrap_err(); + assert!(matches!(err, PackageManagerError::Io(_))); + } + } + + #[cfg(target_os = "linux")] + mod llvm_ppa { + use std::{fs, path::Path}; + + use mockito::Server; + use semver::Version; + use tempfile::TempDir; + + use super::{FAILURE, SUCCESS, SUDO, assert_install_error, fake_exe, install, setup}; + use crate::{ + downloader::native_packages::{ + PackageManagerError, + unix::{UnixPackageManager, llvm_apt_install::install_llvm_via_apt_from}, + }, + test_utils_unix::set_cache_dir, + }; + + const MARKER: &str = "ppa_added"; + + /// Creates a fake `bash` that (if `succeed`) adds a marker file to signify the PPA was added, + /// and a fake `apt` that only succeeds if the PPA marker file exists. + fn fake_ppa(dir: &Path, succeed: bool) { + let marker = dir.join(MARKER); + let marker = marker.to_string_lossy(); + let bash = if succeed { + format!(": > \"{marker}\"") + } else { + FAILURE.to_string() + }; + fake_exe(dir, "bash", &bash); + let apt = format!("if [ -f \"{marker}\" ]; then exit 0; fi\n{FAILURE}"); + fake_exe(dir, "apt", &apt); + } + + /// Sets the cache dir to a new temp dir, and (optionally) seeds it with + /// a fresh copy of the LLVM install script (to avoid downloading it). + fn seed_cache(seed: bool) -> TempDir { + let cache = TempDir::new().unwrap(); + set_cache_dir(cache.path()); + if seed { + fs::write(cache.path().join("llvm_apt_install.sh"), "exit 0").unwrap(); + } + cache + } + + async fn apt_install() -> Result<(), PackageManagerError> { + install(&UnixPackageManager::Apt, Some(&Version::new(17, 0, 0))).await + } + + #[tokio::test] + async fn with_sudo() { + let tmp = setup(); + let _cache = seed_cache(true); + fake_exe(tmp.path(), "sudo", SUDO); + fake_ppa(tmp.path(), true); + apt_install().await.unwrap(); + assert!(tmp.path().join(MARKER).exists()); + } + + #[tokio::test] + async fn without_sudo() { + let tmp = setup(); + let _cache = seed_cache(true); + fake_ppa(tmp.path(), true); + apt_install().await.unwrap(); + assert!(tmp.path().join(MARKER).exists()); + } + + #[tokio::test] + async fn script_failure() { + for sudo in [true, false] { + let tmp = setup(); + let _cache = seed_cache(true); + if sudo { + fake_exe(tmp.path(), "sudo", SUDO); + } + fake_ppa(tmp.path(), false); + let err = apt_install().await.unwrap_err(); + println!("{err}"); + assert!(matches!( + err, + PackageManagerError::LlvmPpaError(msg) if msg.contains("intentional failure") + )); + } + } + + #[tokio::test] + async fn apt_failure_after_ppa() { + for sudo in [true, false] { + let tmp = setup(); + let _cache = seed_cache(true); + if sudo { + fake_exe(tmp.path(), "sudo", SUDO); + } + fake_exe(tmp.path(), "bash", SUCCESS); + fake_exe(tmp.path(), "apt", FAILURE); + assert_install_error(apt_install().await, "apt (with LLVM PPA)"); + } + } + + #[tokio::test] + async fn apt_missing_after_ppa() { + let tmp = setup(); + let _cache = seed_cache(true); + fake_exe(tmp.path(), "bash", SUCCESS); + // the script URL is not used because the script is already cached. + let err = install_llvm_via_apt_from("not used", "17".to_string(), "clang-format-17") + .await + .unwrap_err(); + assert!(matches!(err, PackageManagerError::Io(_))); + } + + #[tokio::test] + async fn download_script() { + let tmp = setup(); + let cache = seed_cache(false); + fake_ppa(tmp.path(), true); + let mut server = Server::new_async().await; + let mock = server + .mock("GET", "/llvm.sh") + .with_status(200) + .with_body("exit 0") + .create(); + let url = format!("{}/llvm.sh", server.url()); + install_llvm_via_apt_from(&url, "17".to_string(), "clang-format-17") + .await + .unwrap(); + mock.assert(); + assert!(cache.path().join("llvm_apt_install.sh").exists()); + } + + #[tokio::test] + async fn bad_script_url() { + let _tmp = setup(); + let _cache = seed_cache(false); + let err = install_llvm_via_apt_from("not a url", "17".to_string(), "clang-format-17") + .await + .unwrap_err(); + assert!(matches!(err, PackageManagerError::UrlParseError(_))); + } + + #[tokio::test] + async fn failed_script_download() { + let _tmp = setup(); + let _cache = seed_cache(false); + let mut server = Server::new_async().await; + let mock = server.mock("GET", "/llvm.sh").with_status(404).create(); + let url = format!("{}/llvm.sh", server.url()); + let err = install_llvm_via_apt_from(&url, "17".to_string(), "clang-format-17") + .await + .unwrap_err(); + mock.assert(); + assert!(matches!(err, PackageManagerError::DownloadError(_))); + } + } +} diff --git a/clang-tools-manager/src/downloader/pypi.rs b/clang-tools-manager/src/downloader/pypi.rs index 5f67310e..a92c77ce 100644 --- a/clang-tools-manager/src/downloader/pypi.rs +++ b/clang-tools-manager/src/downloader/pypi.rs @@ -611,9 +611,16 @@ const EXTRACTED_CHUNK_SIZE: u64 = 1024; #[cfg(test)] mod test { - use std::str::FromStr; + use std::{fs, io::Write, path::Path, str::FromStr}; - use super::{LinuxLibC, PlatformOs, PlatformTag, PyPiReleaseInfo, WheelTags}; + use semver::VersionReq; + use tempfile::TempDir; + + use super::{ + LinuxLibC, PlatformOs, PlatformTag, PyPiDownloadError, PyPiDownloader, PyPiProjectInfo, + PyPiReleaseInfo, WheelTags, + }; + use crate::ClangTool; #[test] fn bad_json_digest() { @@ -728,4 +735,194 @@ mod test { let tags = WheelTags::from_str(wheel_name).unwrap(); assert_eq!(tags.platform.arch.as_str(), "x86_64"); } + + #[test] + fn skip_malformed_version() { + log::set_max_level(log::LevelFilter::Trace); + let json = serde_json::json!({ "releases": { "1.0": [], "not-a-version": [] } }); + let info = serde_json::from_value::(json).unwrap(); + let err = PyPiDownloader::get_best_pypi_release( + &ClangTool::ClangFormat, + &info, + &VersionReq::STAR, + ) + .unwrap_err(); + assert!(matches!(err, PyPiDownloadError::NoVersionFound)); + } + + const BIN_PATH_IN_WHEEL: &str = if cfg!(windows) { + "clang_format/data/bin/clang-format.exe" + } else { + "clang_format/data/bin/clang-format" + }; + + /// Creates a (uncompressed) zip file at `path` with the given `entries` (name, content). + fn make_wheel(path: &Path, entries: &[(&str, &[u8])]) { + let mut zip = zip::ZipWriter::new(fs::File::create(path).unwrap()); + let options = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Stored); + for (name, content) in entries { + zip.start_file(*name, options).unwrap(); + zip.write_all(content).unwrap(); + } + zip.finish().unwrap(); + } + + #[test] + fn extract_bin() { + let tmp = TempDir::new().unwrap(); + let wheel = tmp.path().join("test.whl"); + // larger than the chunk size used for extraction + let content = vec![b'a'; 3000]; + make_wheel( + &wheel, + &[("other.txt", b"ignored"), (BIN_PATH_IN_WHEEL, &content)], + ); + let extracted = tmp.path().join("bin").join("clang-format-17"); + PyPiDownloader::extract_bin(&ClangTool::ClangFormat, &wheel, &extracted).unwrap(); + assert_eq!(fs::read(&extracted).unwrap(), content); + // extracting again is skipped because the existing file is the same size. + PyPiDownloader::extract_bin(&ClangTool::ClangFormat, &wheel, &extracted).unwrap(); + assert_eq!(fs::read(&extracted).unwrap(), content); + } + + #[test] + fn extract_bin_overwrite() { + let tmp = TempDir::new().unwrap(); + let wheel = tmp.path().join("test.whl"); + make_wheel(&wheel, &[(BIN_PATH_IN_WHEEL, b"new content")]); + let extracted = tmp.path().join("clang-format-17"); + // an existing file of a different size is overwritten + fs::write(&extracted, b"old").unwrap(); + PyPiDownloader::extract_bin(&ClangTool::ClangFormat, &wheel, &extracted).unwrap(); + assert_eq!(fs::read(&extracted).unwrap(), b"new content"); + } + + #[test] + fn extract_empty_bin() { + let tmp = TempDir::new().unwrap(); + let wheel = tmp.path().join("test.whl"); + make_wheel(&wheel, &[(BIN_PATH_IN_WHEEL, b"")]); + let extracted = tmp.path().join("clang-format-17"); + PyPiDownloader::extract_bin(&ClangTool::ClangFormat, &wheel, &extracted).unwrap(); + assert!(fs::read(&extracted).unwrap().is_empty()); + } + + #[test] + fn extract_missing_bin() { + log::set_max_level(log::LevelFilter::Trace); + let tmp = TempDir::new().unwrap(); + let wheel = tmp.path().join("test.whl"); + make_wheel( + &wheel, + &[("clang_format/data/bin/clang-tidy", b"wrong tool")], + ); + let extracted = tmp.path().join("clang-format-17"); + let err = + PyPiDownloader::extract_bin(&ClangTool::ClangFormat, &wheel, &extracted).unwrap_err(); + assert!(matches!(err, PyPiDownloadError::ExecutableNotFound)); + assert!(!extracted.exists()); + } + + /// These tests use a fake `ldd` executable in an isolated `PATH`. + #[cfg(target_os = "linux")] + mod fake_ldd { + use std::fs; + + use semver::{Version, VersionReq}; + use tempfile::TempDir; + + use super::{BIN_PATH_IN_WHEEL, make_wheel}; + use crate::{ + Cacher, ClangTool, PyPiDownloader, + downloader::pypi::LinuxLibC, + test_utils_unix::{enable_logs, fake_exe, isolate_path, set_cache_dir}, + }; + + fn setup(ldd_output: Option<&str>) -> TempDir { + enable_logs(); + let tmp = TempDir::new().unwrap(); + isolate_path(tmp.path()); + if let Some(out) = ldd_output { + fake_exe(tmp.path(), "ldd", &format!("echo \"{out}\"")); + } + tmp + } + + #[test] + fn musl() { + let _tmp = setup(Some( + "musl libc (x86_64)\nVersion 1.2.2\nDynamic Program Loader", + )); + assert_eq!(LinuxLibC::get_musl_version(), Some(Version::new(1, 2, 2))); + assert_eq!(LinuxLibC::get_glibc_version(), None); + let musl = |minor| LinuxLibC::Musl { + version: Version::new(1, minor, 0), + }; + assert!(musl(2).is_compatible_with_system()); + assert!(!musl(3).is_compatible_with_system()); + let glibc = LinuxLibC::Glibc { + version: Version::new(2, 17, 0), + }; + assert!(!glibc.is_compatible_with_system()); + } + + #[test] + fn glibc() { + let _tmp = setup(Some( + "ldd (Ubuntu GLIBC 2.39-0ubuntu8.7) 2.39\nCopyright (C) 2024", + )); + assert_eq!(LinuxLibC::get_musl_version(), None); + assert_eq!(LinuxLibC::get_glibc_version(), Some(Version::new(2, 39, 0))); + } + + #[test] + fn no_ldd() { + let _tmp = setup(None); + assert_eq!(LinuxLibC::get_musl_version(), None); + assert_eq!(LinuxLibC::get_glibc_version(), None); + } + + /// Uses a pre-seeded cache (PyPI info and wheel), so no network access is needed. + #[tokio::test] + async fn download_cached_wheel_without_digest() { + let tmp = setup(Some("ldd (Ubuntu GLIBC 2.39-0ubuntu8.7) 2.39")); + let cache = TempDir::new().unwrap(); + set_cache_dir(cache.path()); + let pypi_cache = PyPiDownloader::get_cache_dir().join("pypi"); + fs::create_dir_all(&pypi_cache).unwrap(); + let filename = format!( + "clang_format-17.0.6-py2.py3-none-manylinux_2_17_{}.whl", + std::env::consts::ARCH + ); + let json = serde_json::json!({ + "releases": { + "17.0.6": [{ + "url": format!("https://example.invalid/{filename}"), + "filename": filename, + "size": 1, + "digests": {}, + "yanked": false + }] + } + }) + .to_string(); + fs::write(pypi_cache.join("clang-format_pypi.json"), json).unwrap(); + let content = b"#!/bin/sh\necho \"clang-format version 17.0.6\"\n"; + make_wheel( + &pypi_cache.join("clang-format_17.0.6.whl"), + &[(BIN_PATH_IN_WHEEL, content)], + ); + let out_dir = tmp.path().join("out"); + let req = VersionReq::parse("17").unwrap(); + for _ in 0..2 { + let bin = + PyPiDownloader::download_tool(&ClangTool::ClangFormat, &req, Some(&out_dir)) + .await + .unwrap(); + assert_eq!(bin, out_dir.join("clang-format-17")); + assert_eq!(fs::read(&bin).unwrap(), content); + } + } + } } diff --git a/clang-tools-manager/src/downloader/static_dist.rs b/clang-tools-manager/src/downloader/static_dist.rs index 759bbec4..3f48938f 100644 --- a/clang-tools-manager/src/downloader/static_dist.rs +++ b/clang-tools-manager/src/downloader/static_dist.rs @@ -220,8 +220,12 @@ mod supported_platform { #[cfg(test)] mod tests { - use super::StaticDistDownloader; + use std::fs; + + use super::{StaticDistDownloadError, StaticDistDownloader}; + use crate::ClangTool; use semver::VersionReq; + use tempfile::TempDir; #[test] fn find_none() { @@ -229,5 +233,53 @@ mod supported_platform { let suitable_version = StaticDistDownloader::find_suitable_version(&req_ver); assert_eq!(suitable_version, None); } + + /// Uses a pre-seeded cache (binary and SHA512SUMS), so no network access is needed. + #[tokio::test] + async fn cached_in_directory() { + use sha2::{Digest, Sha512}; + + log::set_max_level(log::LevelFilter::Trace); + let cache = TempDir::new().unwrap(); + unsafe { + std::env::set_var("CPP_LINTER_CACHE", cache.path()); + } + let out_dir = TempDir::new().unwrap(); + let suffix = std::env::consts::EXE_SUFFIX; + let bin = out_dir.path().join(format!("clang-format-17{suffix}")); + let content = b"not really clang-format"; + fs::write(&bin, content).unwrap(); + let hash = Sha512::digest(content) + .iter() + .map(|b| format!("{b:02x}")) + .collect::(); + // list checksums for all supported platforms + let mut sums = String::new(); + for platform in ["linux", "macos", "windows"] { + for arch in ["amd64", "arm64"] { + sums.push_str(&format!( + "{hash} clang-format-17_{platform}-{arch}{suffix}\n" + )); + } + } + let sha512_sums = cache.path().join("static_dist").join("SHA512SUMS"); + fs::create_dir_all(sha512_sums.parent().unwrap()).unwrap(); + fs::write(&sha512_sums, sums).unwrap(); + + let req = VersionReq::parse("17").unwrap(); + let tool = ClangTool::ClangFormat; + let out_dir_path = out_dir.path().to_path_buf(); + let result = StaticDistDownloader::download_tool(&tool, &req, Some(&out_dir_path)) + .await + .unwrap(); + assert_eq!(result, bin); + + // a corrupt SHA512SUMS file (missing the tool's entry) + fs::write(&sha512_sums, format!("{hash} some-other-file\n")).unwrap(); + let err = StaticDistDownloader::download_tool(&tool, &req, Some(&out_dir_path)) + .await + .unwrap_err(); + assert!(matches!(err, StaticDistDownloadError::Sha512Corruption)); + } } } diff --git a/clang-tools-manager/src/lib.rs b/clang-tools-manager/src/lib.rs index 3568cfd8..ebab2bcb 100644 --- a/clang-tools-manager/src/lib.rs +++ b/clang-tools-manager/src/lib.rs @@ -25,3 +25,6 @@ mod progress_bar; pub use progress_bar::ProgressBar; pub mod logger; + +#[cfg(all(test, unix))] +mod test_utils_unix; diff --git a/clang-tools-manager/src/logger.rs b/clang-tools-manager/src/logger.rs index c90d47d8..1ffa2319 100644 --- a/clang-tools-manager/src/logger.rs +++ b/clang-tools-manager/src/logger.rs @@ -112,7 +112,9 @@ pub fn try_init_logger() { mod test { use std::env; - use super::try_init_logger; + use log::{Level, Log, Record}; + + use super::{SimpleLogger, try_init_logger}; #[test] fn trace_log() { @@ -123,4 +125,43 @@ mod test { log::set_max_level(log::LevelFilter::Trace); log::trace!("A dummy log statement for code coverage"); } + + #[test] + fn all_records() { + unsafe { + env::remove_var("CPP_LINTER_COLOR"); + } + try_init_logger(); + // initializing twice exercises the "already initialized" path. + try_init_logger(); + let logger = SimpleLogger; + let levels = [ + Level::Error, + Level::Warn, + Level::Info, + Level::Debug, + Level::Trace, + ]; + for module in [Some("clang_tools_manager"), Some("other_crate"), None] { + for level in levels { + logger.log( + &Record::builder() + .args(format_args!("A dummy {level} log statement")) + .level(level) + .module_path(module) + .line(Some(42)) + .build(), + ); + } + } + // a log command used for CI log grouping (always written) + logger.log( + &Record::builder() + .args(format_args!("::group::dummy group")) + .target("CI_LOG_GROUPING") + .level(Level::Trace) + .build(), + ); + logger.flush(); + } } diff --git a/clang-tools-manager/src/progress_bar.rs b/clang-tools-manager/src/progress_bar.rs index 0eaa6915..2d338ea3 100644 --- a/clang-tools-manager/src/progress_bar.rs +++ b/clang-tools-manager/src/progress_bar.rs @@ -171,4 +171,28 @@ mod tests { } progress_bar.finish().unwrap(); } + + #[test] + fn interactive_no_total() { + let mut progress_bar = ProgressBar::new(None, "Processing"); + // simulate an interactive terminal + progress_bar.is_interactive = true; + progress_bar.render().unwrap(); + for _ in 0..5 { + progress_bar.inc(1).unwrap(); + } + progress_bar.finish().unwrap(); + } + + #[test] + fn interactive_with_total() { + let mut progress_bar = ProgressBar::new(Some(NonZero::new(100).unwrap()), "Processing"); + // simulate an interactive terminal + progress_bar.is_interactive = true; + progress_bar.render().unwrap(); + for _ in 0..100 { + progress_bar.inc(1).unwrap(); + } + progress_bar.finish().unwrap(); + } } diff --git a/clang-tools-manager/src/test_utils_unix.rs b/clang-tools-manager/src/test_utils_unix.rs new file mode 100644 index 00000000..2cb836bf --- /dev/null +++ b/clang-tools-manager/src/test_utils_unix.rs @@ -0,0 +1,49 @@ +//! Helpers shared by unit tests. +//! +//! These helpers mutate process-wide state (like the `PATH` environment variable). +//! This is safe because `cargo nextest` runs each test in a separate process. + +use std::{ + fs, + os::unix::fs::PermissionsExt, + path::{Path, PathBuf}, +}; + +/// Writes an executable `#!/bin/sh` script named `name` (with the given `body`) into `dir`. +/// +/// The `body` should only use shell builtins because the `PATH` is +/// expected to be isolated with [`isolate_path()`]. +pub fn fake_exe(dir: &Path, name: &str, body: &str) -> PathBuf { + let path = dir.join(name); + fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).unwrap(); + path +} + +/// Writes a fake clang tool executable (named `name`) into `dir` +/// that reports the given `version` when invoked. +pub fn fake_clang_tool(dir: &Path, name: &str, version: &str) -> PathBuf { + fake_exe(dir, name, &format!("echo \"{name} version {version}\"")) +} + +/// Sets the `PATH` environment variable to only the given `dir`. +/// +/// This ensures no real executable (like `sudo` or a package manager) +/// can be invoked by the code under test. +pub fn isolate_path(dir: &Path) { + unsafe { + std::env::set_var("PATH", dir); + } +} + +/// Sets the `CPP_LINTER_CACHE` environment variable to the given `dir`. +pub fn set_cache_dir(dir: &Path) { + unsafe { + std::env::set_var("CPP_LINTER_CACHE", dir); + } +} + +/// Enables all log levels, so arguments passed to `log` macros are evaluated. +pub fn enable_logs() { + log::set_max_level(log::LevelFilter::Trace); +} diff --git a/clang-tools-manager/src/tool.rs b/clang-tools-manager/src/tool.rs index 9027f9b0..5aff0e66 100644 --- a/clang-tools-manager/src/tool.rs +++ b/clang-tools-manager/src/tool.rs @@ -259,4 +259,47 @@ mod tests { CLANG_FORMAT.get_exe_path(&RequestedVersion::Path(PathBuf::from("non-existent-path"))); assert!(tool_exe.is_err()); } + + #[cfg(unix)] + mod fake_tools { + use std::fs; + + use tempfile::TempDir; + + use super::CLANG_FORMAT; + use crate::{ + test_utils_unix::{enable_logs, fake_clang_tool, fake_exe}, + tool::GetClangVersionError, + }; + + #[test] + fn capture_no_version() { + let tmp = TempDir::new().unwrap(); + let exe = fake_exe(tmp.path(), "clang-format", "echo \"no version info\""); + let err = CLANG_FORMAT.capture_version(&exe).unwrap_err(); + println!("{err}"); + assert!(matches!(err, GetClangVersionError::VersionParse(_))); + } + + #[test] + fn symlink() { + enable_logs(); + let tmp = TempDir::new().unwrap(); + let bin_17 = fake_clang_tool(tmp.path(), "clang-format-17", "17.0.6"); + let bin_18 = fake_clang_tool(tmp.path(), "clang-format-18", "18.1.8"); + let link = tmp.path().join("clang-format"); + + // create a new symlink + CLANG_FORMAT.symlink_bin(&bin_17, &link, false).unwrap(); + assert_eq!(fs::read_link(&link).unwrap(), bin_17); + + // do not overwrite an existing symlink + CLANG_FORMAT.symlink_bin(&bin_18, &link, false).unwrap(); + assert_eq!(fs::read_link(&link).unwrap(), bin_17); + + // overwrite an existing symlink + CLANG_FORMAT.symlink_bin(&bin_18, &link, true).unwrap(); + assert_eq!(fs::read_link(&link).unwrap(), bin_18); + } + } } diff --git a/clang-tools-manager/src/utils.rs b/clang-tools-manager/src/utils.rs index d32a5465..7854386b 100644 --- a/clang-tools-manager/src/utils.rs +++ b/clang-tools-manager/src/utils.rs @@ -64,7 +64,19 @@ pub fn lock_path(path: &Path) -> Result { mod tests { use std::{env::current_dir, path::PathBuf}; - use super::normalize_path; + use super::{lock_path, normalize_path}; + + #[test] + fn normalize_leading_current_dir() { + let src = PathBuf::from("./tests/ignored_paths"); + assert_eq!(normalize_path(&src), PathBuf::from("tests/ignored_paths")); + } + + #[test] + fn lock_empty_path() { + // An empty path has no parent and cannot be opened as a (lock) file. + assert!(lock_path(&PathBuf::new()).is_err()); + } #[test] fn normalize_redirects() { diff --git a/clang-tools-manager/src/version.rs b/clang-tools-manager/src/version.rs index f52be55f..ceae1265 100644 --- a/clang-tools-manager/src/version.rs +++ b/clang-tools-manager/src/version.rs @@ -252,7 +252,7 @@ mod tests { use semver::VersionReq; use tempfile::TempDir; - use super::RequestedVersion; + use super::{RequestedVersion, RequestedVersionParsingError}; use crate::{ClangTool, utils::normalize_path}; // See also crate::tool::tests module for other `RequestedVersion::from_str()` tests. @@ -265,23 +265,52 @@ mod tests { let parent = this_canonical.parent().unwrap(); let expected = normalize_path(parent); let req_ver = RequestedVersion::from_str(this_path_str).unwrap(); - if let RequestedVersion::Path(parsed) = req_ver { - assert_eq!(&parsed, &expected); - } + assert_eq!(req_ver, RequestedVersion::Path(expected)); + + assert!(matches!( + RequestedVersion::from_str("file.rs").unwrap_err(), + RequestedVersionParsingError::InvalidInput(_) + )); + } + + #[test] + fn validate_version_dir() { + let req_ver = RequestedVersion::from_str("src").unwrap(); + let expected = normalize_path(&PathBuf::from("src").canonicalize().unwrap()); + assert_eq!(req_ver, RequestedVersion::Path(expected)); + } - assert!(RequestedVersion::from_str("file.rs").is_err()); + #[test] + fn validate_version_non_canonical_path() { + // A file in the working directory has an empty parent path, which cannot be canonicalized. + let err = RequestedVersion::from_str("Cargo.toml").unwrap_err(); + println!("{err}"); + assert!(matches!( + err, + RequestedVersionParsingError::NonCanonicalPath(..) + )); + } + + #[test] + fn validate_version_no_value() { + assert_eq!( + RequestedVersion::from_str("CPP-LINTER-VERSION").unwrap(), + RequestedVersion::NoValue + ); } #[test] fn validate_version_exact() { let req_ver = RequestedVersion::from_str("12").unwrap(); - if let RequestedVersion::Requirement(req) = req_ver { - assert_eq!(req.to_string(), "^12"); - } + assert_eq!( + req_ver, + RequestedVersion::Requirement(VersionReq::parse("^12").unwrap()) + ); } #[tokio::test] async fn eval_no_value() { + log::set_max_level(log::LevelFilter::Trace); let result = RequestedVersion::NoValue .eval_tool(&ClangTool::ClangFormat, false, None, false) .await @@ -352,4 +381,208 @@ mod tests { // assert!(version_req.matches(&clang_path.version)); } } + + /// These tests use fake clang tools (and package managers) in an isolated `PATH` + /// and an isolated (pre-seeded) cache. Thus, no network access is needed. + #[cfg(unix)] + mod fake_tools { + use std::{fs, path::Path}; + + use semver::{Version, VersionReq}; + use tempfile::TempDir; + + use crate::{ + ClangTool, GetToolError, RequestedVersion, + test_utils_unix::{ + enable_logs, fake_clang_tool, fake_exe, isolate_path, set_cache_dir, + }, + }; + + const TOOL: ClangTool = ClangTool::ClangFormat; + + struct TestEnv { + /// The only directory in `PATH`. + bin: TempDir, + /// The cache directory. + cache: TempDir, + /// The directory to "download" binaries into. + out: TempDir, + } + + impl TestEnv { + fn new() -> Self { + enable_logs(); + let env = Self { + bin: TempDir::new().unwrap(), + cache: TempDir::new().unwrap(), + out: TempDir::new().unwrap(), + }; + isolate_path(env.bin.path()); + set_cache_dir(env.cache.path()); + env + } + + /// Seed the PyPI cache with info that has no releases (so download from PyPI fails). + fn seed_empty_pypi(&self) { + let pypi = self.cache.path().join("pypi"); + fs::create_dir_all(&pypi).unwrap(); + fs::write( + pypi.join("clang-format_pypi.json"), + serde_json::json!({"releases": {}}).to_string(), + ) + .unwrap(); + } + + /// Seed the static binary (in the [`Self::out`] dir) and the cached SHA512SUMS file. + /// + /// If `valid` is false, then the checksum will not match the binary. + fn seed_static_dist(&self, valid: bool) { + use sha2::{Digest, Sha512}; + + let bin = fake_clang_tool(self.out.path(), "clang-format-17", "17.0.6"); + let hash = if valid { + Sha512::digest(fs::read(bin).unwrap()) + .iter() + .map(|b| format!("{b:02x}")) + .collect::() + } else { + "deadbeef".to_string() + }; + // list checksums for all supported (unix) platforms + let mut checksums = String::new(); + for platform in ["linux", "macos"] { + for arch in ["amd64", "arm64"] { + checksums.push_str(&format!("{hash} clang-format-17_{platform}-{arch}\n")); + } + } + let sums = self.cache.path().join("static_dist"); + fs::create_dir_all(&sums).unwrap(); + fs::write(sums.join("SHA512SUMS"), checksums).unwrap(); + } + + async fn eval( + &self, + requested: RequestedVersion, + allow_pkg_mgr: bool, + ) -> Result, GetToolError> { + let out = self.out.path().to_path_buf(); + requested + .eval_tool(&TOOL, false, Some(&out), allow_pkg_mgr) + .await + } + } + + fn req(spec: &str) -> RequestedVersion { + RequestedVersion::Requirement(VersionReq::parse(spec).unwrap()) + } + + fn assert_version(path: &Path, result: crate::ClangVersion, version: &str) { + assert_eq!(result.path, path); + assert_eq!(result.version, Version::parse(version).unwrap()); + } + + #[tokio::test] + async fn eval_path() { + let env = TestEnv::new(); + let exe = fake_clang_tool(env.bin.path(), "clang-format", "17.0.6"); + let result = env + .eval(RequestedVersion::Path(env.bin.path().to_path_buf()), false) + .await + .unwrap() + .unwrap(); + assert_version(&exe, result, "17.0.6"); + } + + #[tokio::test] + async fn eval_system_default() { + let env = TestEnv::new(); + let exe = fake_clang_tool(env.bin.path(), "clang-format", "17.0.6"); + let result = env + .eval(RequestedVersion::SystemDefault, false) + .await + .unwrap() + .unwrap(); + assert_version(&exe, result, "17.0.6"); + } + + #[tokio::test] + async fn eval_installed_requirement() { + let env = TestEnv::new(); + let exe = fake_clang_tool(env.bin.path(), "clang-format-17", "17.0.6"); + let result = env.eval(req("17"), false).await.unwrap().unwrap(); + assert_version(&exe, result, "17.0.6"); + } + + #[tokio::test] + async fn eval_unsupported_requirement() { + let env = TestEnv::new(); + let err = env.eval(req("=8"), false).await.unwrap_err(); + assert!(matches!(err, GetToolError::UnsupportedVersion)); + } + + #[tokio::test] + async fn eval_cached_bin() { + let env = TestEnv::new(); + // installed version does not satisfy the requirement + fake_clang_tool(env.bin.path(), "clang-format", "16.0.0"); + let cached_bin_dir = env.cache.path().join("bin"); + fs::create_dir_all(&cached_bin_dir).unwrap(); + let cached = fake_clang_tool(&cached_bin_dir, "clang-format-17.0.0", "17.0.6"); + let result = env.eval(req("17"), false).await.unwrap().unwrap(); + assert_version(&cached, result, "17.0.6"); + } + + #[tokio::test] + async fn eval_static_dist_fallback() { + let env = TestEnv::new(); + // cached version does not satisfy the requirement + let cached_bin_dir = env.cache.path().join("bin"); + fs::create_dir_all(&cached_bin_dir).unwrap(); + fake_clang_tool(&cached_bin_dir, "clang-format-17.0.0", "16.0.0"); + env.seed_empty_pypi(); + env.seed_static_dist(true); + // no package managers are available. + let result = env.eval(req("17"), true).await.unwrap().unwrap(); + assert_version(&env.out.path().join("clang-format-17"), result, "17.0.6"); + let symlink = env.out.path().join("clang-format"); + assert_eq!( + fs::read_link(symlink).unwrap(), + env.out.path().join("clang-format-17") + ); + } + + #[tokio::test] + async fn eval_static_dist_failure() { + let env = TestEnv::new(); + env.seed_empty_pypi(); + env.seed_static_dist(false); + let err = env.eval(req("17"), false).await.unwrap_err(); + assert!(matches!(err, GetToolError::NotFound)); + } + + #[tokio::test] + async fn eval_package_manager() { + let env = TestEnv::new(); + env.seed_empty_pypi(); + let marker = env.bin.path().join("installed"); + let marker = marker.to_string_lossy(); + // A fake `brew` that "installs" clang-format v17 (by creating a marker file). + fake_exe( + env.bin.path(), + "brew", + &format!("if [ \"$1\" = \"list\" ]; then exit 1; fi\n: > \"{marker}\""), + ); + // A fake clang-format that reports v17 only after being "installed". + let exe = fake_exe( + env.bin.path(), + "clang-format-17", + &format!( + "if [ -f \"{marker}\" ]; then echo \"version 17.0.6\"; \ + else echo \"version 16.0.0\"; fi" + ), + ); + let result = env.eval(req("17"), true).await.unwrap().unwrap(); + assert_version(&exe, result, "17.0.6"); + } + } } diff --git a/cpp-linter/src/clang_tools/clang_format.rs b/cpp-linter/src/clang_tools/clang_format.rs index d4435129..fe158a74 100644 --- a/cpp-linter/src/clang_tools/clang_format.rs +++ b/cpp-linter/src/clang_tools/clang_format.rs @@ -316,8 +316,143 @@ mod tests { use std::ops::RangeInclusive; - use super::{summarize_style, three_way_diff}; - use crate::clang_tools::make_patch; + use std::{ + fs, + path::{Path, PathBuf}, + sync::Mutex, + }; + + use super::{run_clang_format, summarize_style, three_way_diff}; + use crate::{ + clang_tools::make_patch, + cli::{ClangParams, LinesChangedOnly}, + common_fs::FileObj, + error::ClangCaptureError, + test_common::setup_tmp_workspace, + }; + + fn make_clang_params(repo_root: &Path, clang_format_command: PathBuf) -> ClangParams { + ClangParams { + style: "llvm".to_string(), + clang_format_command: Some(clang_format_command), + repo_root: repo_root.to_path_buf(), + ..Default::default() + } + } + + #[test] + fn format_command_not_found() { + let tmp_workspace = setup_tmp_workspace(); + let clang_params = make_clang_params( + tmp_workspace.path(), + tmp_workspace.path().join("non-existent-clang-format"), + ); + let file = Mutex::new(FileObj::new(PathBuf::from("demo/demo.cpp"))); + let err = run_clang_format(&mut file.lock().unwrap(), &clang_params).unwrap_err(); + assert!(matches!(err, ClangCaptureError::FailedToRunCommand { .. })); + } + + #[cfg(unix)] + mod fake_tool { + use super::*; + use crate::test_common::make_fake_tool; + + #[test] + fn missing_source_file() { + let tmp_workspace = setup_tmp_workspace(); + let tools_dir = tempfile::tempdir().unwrap(); + let fake_format = make_fake_tool(tools_dir.path(), "clang-format", ""); + let clang_params = make_clang_params(tmp_workspace.path(), fake_format); + let file = Mutex::new(FileObj::new(PathBuf::from("demo/missing.cpp"))); + let err = run_clang_format(&mut file.lock().unwrap(), &clang_params).unwrap_err(); + assert!(matches!(err, ClangCaptureError::ReadFileFailed { .. })); + } + + #[test] + fn non_utf8_output() { + let tmp_workspace = setup_tmp_workspace(); + let tools_dir = tempfile::tempdir().unwrap(); + let fake_format = + make_fake_tool(tools_dir.path(), "clang-format", r"printf '\377\376'"); + let clang_params = make_clang_params(tmp_workspace.path(), fake_format); + let file = Mutex::new(FileObj::new(PathBuf::from("demo/demo.cpp"))); + let err = run_clang_format(&mut file.lock().unwrap(), &clang_params).unwrap_err(); + assert!(matches!(err, ClangCaptureError::NonUtf8Output { .. })); + } + + #[test] + fn errors_reported_in_stderr() { + let tmp_workspace = setup_tmp_workspace(); + let tools_dir = tempfile::tempdir().unwrap(); + let fake_format = make_fake_tool( + tools_dir.path(), + "clang-format", + "echo 'some error' >&2; exit 1", + ); + let clang_params = make_clang_params(tmp_workspace.path(), fake_format); + // simulate a file already patched by clang-tidy + let patched_path = clang_params.get_cache_path().join("demo/demo.cpp"); + fs::create_dir_all(patched_path.parent().unwrap()).unwrap(); + fs::copy("tests/demo/demo.cpp", &patched_path).unwrap(); + let mut file = FileObj::new(PathBuf::from("demo/demo.cpp")); + file.patched_path = Some(patched_path); + let file = Mutex::new(file); + let logs = run_clang_format(&mut file.lock().unwrap(), &clang_params).unwrap(); + let errors = logs + .iter() + .filter(|(lvl, msg)| *lvl == log::Level::Debug && msg.contains("some error")) + .count(); + assert_eq!(errors, 2); + } + + #[test] + fn tidy_patch_not_readable() { + let tmp_workspace = setup_tmp_workspace(); + let tools_dir = tempfile::tempdir().unwrap(); + let fake_format = make_fake_tool(tools_dir.path(), "clang-format", ""); + let mut clang_params = make_clang_params(tmp_workspace.path(), fake_format); + clang_params.lines_changed_only = LinesChangedOnly::Diff; + // the patched path exists, but it is a directory + let patched_path = clang_params.get_cache_path().join("demo/demo.cpp"); + fs::create_dir_all(&patched_path).unwrap(); + #[allow(clippy::single_range_in_vec_init)] + let mut file = FileObj::from(PathBuf::from("demo/demo.cpp"), vec![2], vec![1..=5]); + file.patched_path = Some(patched_path); + let file = Mutex::new(file); + let err = run_clang_format(&mut file.lock().unwrap(), &clang_params).unwrap_err(); + assert!(matches!(err, ClangCaptureError::ReadFileFailed { .. })); + } + + #[test] + fn format_tidy_patch_fails() { + let tmp_workspace = setup_tmp_workspace(); + let tools_dir = tempfile::tempdir().unwrap(); + // the tool removes itself, so it cannot be invoked a second time + let fake_format = make_fake_tool(tools_dir.path(), "clang-format", r#"rm -f "$0""#); + let clang_params = make_clang_params(tmp_workspace.path(), fake_format); + let patched_path = clang_params.get_cache_path().join("demo/demo.cpp"); + fs::create_dir_all(patched_path.parent().unwrap()).unwrap(); + fs::copy("tests/demo/demo.cpp", &patched_path).unwrap(); + let mut file = FileObj::new(PathBuf::from("demo/demo.cpp")); + file.patched_path = Some(patched_path); + let file = Mutex::new(file); + let err = run_clang_format(&mut file.lock().unwrap(), &clang_params).unwrap_err(); + assert!(matches!(err, ClangCaptureError::FailedToRunCommand { .. })); + } + + #[test] + fn cache_not_writable() { + let tmp_workspace = setup_tmp_workspace(); + let tools_dir = tempfile::tempdir().unwrap(); + let fake_format = make_fake_tool(tools_dir.path(), "clang-format", ""); + let clang_params = make_clang_params(tmp_workspace.path(), fake_format); + // a directory where the patched file should be cached + fs::create_dir_all(clang_params.get_cache_path().join("demo/demo.cpp")).unwrap(); + let file = Mutex::new(FileObj::new(PathBuf::from("demo/demo.cpp"))); + let err = run_clang_format(&mut file.lock().unwrap(), &clang_params).unwrap_err(); + assert!(matches!(err, ClangCaptureError::WriteFileFailed { .. })); + } + } fn formalize_style(style: &str, expected: &str) { assert_eq!(summarize_style(style), expected); diff --git a/cpp-linter/src/clang_tools/clang_tidy.rs b/cpp-linter/src/clang_tools/clang_tidy.rs index 76960de4..f6c364f7 100644 --- a/cpp-linter/src/clang_tools/clang_tidy.rs +++ b/cpp-linter/src/clang_tools/clang_tidy.rs @@ -440,7 +440,8 @@ mod test { }; use super::{ - CLANG_DIAGNOSTICS_URL, NOTE_HEADER, RestoreOnDrop, TidyNotification, run_clang_tidy, + CLANG_DIAGNOSTICS_URL, ClangCaptureError, CompilationUnit, NOTE_HEADER, RestoreOnDrop, + TidyNotification, run_clang_tidy, }; #[test] @@ -708,4 +709,128 @@ TrenchBroom/TrenchBroom/common/test/src/mdl/tst_ReadFreeImageTexture.cpp:44:48: assert_eq!(fs::read_to_string(&file_path).unwrap(), original); } + + #[test] + fn parse_non_utf8_output() { + let err = parse_tidy_output(&[0xFF, 0xFE], &None, &PathBuf::from(".")).unwrap_err(); + assert!(matches!(err, ClangCaptureError::NonUtf8Output { .. })); + } + + #[test] + fn parse_with_database_units() { + let build_dir = env::current_dir().unwrap().join("build"); + let database_json = Some(vec![ + CompilationUnit { + directory: build_dir.to_string_lossy().to_string(), + file: "../other.cpp".to_string(), + }, + CompilationUnit { + directory: build_dir.to_string_lossy().to_string(), + file: "../demo.cpp".to_string(), + }, + ]); + let tidy_out = format!( + "{}:1:1: warning: some rationale [modernize-use-auto]\n\ + {}:2:1: warning: other rationale [modernize-use-auto]\n", + build_dir.join("../demo.cpp").to_string_lossy(), + build_dir.join("../unknown.cpp").to_string_lossy(), + ); + let notes = parse_tidy_output( + tidy_out.as_bytes(), + &database_json, + &env::current_dir().unwrap(), + ) + .unwrap(); + assert_eq!(notes.len(), 2); + // path of a file in the database is resolved and made relative to the repo root + assert_eq!(notes[0].filename, "demo.cpp"); + // path of a file not in the database is also made relative to the repo root + assert_eq!(notes[1].filename, "unknown.cpp"); + } + + fn make_clang_params( + repo_root: &std::path::Path, + clang_tidy_command: Option, + ) -> ClangParams { + ClangParams { + tidy_checks: "-*,modernize-use-auto".to_string(), + clang_tidy_command, + repo_root: repo_root.to_path_buf(), + ..Default::default() + } + } + + #[test] + fn tidy_missing_source_file() { + let tmp_workspace = crate::test_common::setup_tmp_workspace(); + let clang_params = + make_clang_params(tmp_workspace.path(), Some(PathBuf::from("clang-tidy"))); + let file = Mutex::new(FileObj::new(PathBuf::from("demo/missing.cpp"))); + let err = run_clang_tidy(&mut file.lock().unwrap(), &clang_params).unwrap_err(); + assert!(matches!(err, ClangCaptureError::ReadFileFailed { .. })); + } + + #[test] + fn tidy_command_not_found() { + let tmp_workspace = crate::test_common::setup_tmp_workspace(); + let clang_params = make_clang_params( + tmp_workspace.path(), + Some(tmp_workspace.path().join("non-existent-clang-tidy")), + ); + let file = Mutex::new(FileObj::new(PathBuf::from("demo/demo.cpp"))); + let err = run_clang_tidy(&mut file.lock().unwrap(), &clang_params).unwrap_err(); + assert!(matches!(err, ClangCaptureError::FailedToRunCommand { .. })); + // the source file is left untouched + assert_eq!( + fs::read_to_string(tmp_workspace.path().join("demo/demo.cpp")).unwrap(), + fs::read_to_string("tests/demo/demo.cpp").unwrap() + ); + } + + #[cfg(unix)] + #[test] + fn tidy_cache_not_writable() { + let tmp_workspace = crate::test_common::setup_tmp_workspace(); + let tools_dir = tempfile::tempdir().unwrap(); + let fake_tidy = crate::test_common::make_fake_tool(tools_dir.path(), "clang-tidy", ""); + let clang_params = make_clang_params(tmp_workspace.path(), Some(fake_tidy)); + // a directory where the patched file should be cached + fs::create_dir_all(clang_params.get_cache_path().join("demo/demo.cpp")).unwrap(); + let file = Mutex::new(FileObj::new(PathBuf::from("demo/demo.cpp"))); + let err = run_clang_tidy(&mut file.lock().unwrap(), &clang_params).unwrap_err(); + assert!(matches!(err, ClangCaptureError::WriteFileFailed { .. })); + } + + #[cfg(target_os = "linux")] + #[test] + fn tidy_source_not_restorable() { + let tmp_workspace = crate::test_common::setup_tmp_workspace(); + let tools_dir = tempfile::tempdir().unwrap(); + // replace the source file with a symlink to a readable (but not writable) file + let fake_tidy = crate::test_common::make_fake_tool( + tools_dir.path(), + "clang-tidy", + "rm -f demo/demo.cpp && ln -s /proc/version demo/demo.cpp", + ); + let clang_params = make_clang_params(tmp_workspace.path(), Some(fake_tidy)); + let file = Mutex::new(FileObj::new(PathBuf::from("demo/demo.cpp"))); + let err = run_clang_tidy(&mut file.lock().unwrap(), &clang_params).unwrap_err(); + assert!(matches!(err, ClangCaptureError::WriteFileFailed { .. })); + } + + #[cfg(unix)] + #[test] + fn tidy_non_utf8_output() { + let tmp_workspace = crate::test_common::setup_tmp_workspace(); + let tools_dir = tempfile::tempdir().unwrap(); + let fake_tidy = crate::test_common::make_fake_tool( + tools_dir.path(), + "clang-tidy", + r"printf '\377\376'; printf 'some summary' >&2", + ); + let clang_params = make_clang_params(tmp_workspace.path(), Some(fake_tidy)); + let file = Mutex::new(FileObj::new(PathBuf::from("demo/demo.cpp"))); + let err = run_clang_tidy(&mut file.lock().unwrap(), &clang_params).unwrap_err(); + assert!(matches!(err, ClangCaptureError::NonUtf8Output { .. })); + } } diff --git a/cpp-linter/src/cli/mod.rs b/cpp-linter/src/cli/mod.rs index 1eecddb1..b8a1adcc 100644 --- a/cpp-linter/src/cli/mod.rs +++ b/cpp-linter/src/cli/mod.rs @@ -149,7 +149,7 @@ pub struct GeneralOptions { /// compatible platforms). #[arg( long, - default_missing_value = "false", + default_missing_value = "true", action = ArgAction::SetTrue, value_parser = FalseyValueParser::new(), conflicts_with = "no_mod_sys", @@ -162,7 +162,7 @@ pub struct GeneralOptions { /// useful in sensitive CI environments like self-hosted runners. #[arg( long, - default_missing_value = "false", + default_missing_value = "true", action = ArgAction::SetTrue, value_parser = FalseyValueParser::new(), conflicts_with = "mod_sys", @@ -626,6 +626,23 @@ mod test { println!("{}", cli.unwrap_err()); } + #[test] + fn mod_sys_flags() { + let args = Cli::parse_from(vec!["cpp-linter"]); + assert!(!args.general_options.mod_sys); + assert!(!args.general_options.no_mod_sys); + + let args = Cli::parse_from(vec!["cpp-linter", "--mod-sys"]); + assert!(args.general_options.mod_sys); + assert!(!args.general_options.no_mod_sys); + + let args = Cli::parse_from(vec!["cpp-linter", "--no-mod-sys"]); + assert!(!args.general_options.mod_sys); + assert!(args.general_options.no_mod_sys); + + assert!(Cli::try_parse_from(vec!["cpp-linter", "--mod-sys", "--no-mod-sys"]).is_err()); + } + #[test] fn extra_arg_0() { let args = Cli::parse_from(vec!["cpp-linter"]); diff --git a/cpp-linter/src/cli/structs.rs b/cpp-linter/src/cli/structs.rs index 25a40605..732261bb 100644 --- a/cpp-linter/src/cli/structs.rs +++ b/cpp-linter/src/cli/structs.rs @@ -406,6 +406,29 @@ mod test { ); } + #[test] + fn display_other_enum_variants() { + assert_eq!(format!("{}", LinesChangedOnly::Off), "false"); + assert_eq!(format!("{}", LinesChangedOnly::On), "true"); + assert_eq!(format!("{}", ThreadComments::On), "true"); + assert_eq!(format!("{}", ThreadComments::Off), "false"); + } + + #[test] + fn lines_changed_only_validity() { + for (added_lines, diff_chunks) in [(false, false), (true, false), (false, true)] { + assert!(LinesChangedOnly::Off.is_change_valid(added_lines, diff_chunks)); + assert_eq!( + LinesChangedOnly::Diff.is_change_valid(added_lines, diff_chunks), + diff_chunks + ); + assert_eq!( + LinesChangedOnly::On.is_change_valid(added_lines, diff_chunks), + added_lines + ); + } + } + #[test] fn absolute_db_path() { let tmp_dir = tempfile::tempdir().unwrap(); diff --git a/cpp-linter/src/common_fs.rs b/cpp-linter/src/common_fs.rs index b66f1503..9fb7e308 100644 --- a/cpp-linter/src/common_fs.rs +++ b/cpp-linter/src/common_fs.rs @@ -498,4 +498,77 @@ mod test { "unexpected suggestion: {suggestion}" ); } + + #[test] + fn unfixed_tidy_diagnostics() { + use crate::clang_tools::clang_tidy::{TidyAdvice, TidyNotification}; + + let repo_root = TempDir::new().unwrap(); + let file_name = PathBuf::from("test_file.cpp"); + let original_content = "line1\nline2\nline3\nline4\n"; + fs::write(repo_root.path().join(&file_name), original_content).unwrap(); + + // Patched file changes only line 1 + let patched_content = "LINE1\nline2\nline3\nline4\n"; + let patched_file = NamedTempFile::new().unwrap(); + fs::write(patched_file.path(), patched_content).unwrap(); + + let note = |line: u32, suggestion: Vec| TidyNotification { + filename: "test_file.cpp".to_string(), + line, + cols: 1, + severity: "warning".to_string(), + rationale: format!("rationale for line {line}"), + diagnostic: "readability-identifier-length".to_string(), + suggestion, + fixed_lines: vec![], + }; + let mut file_obj = FileObj::from(file_name, vec![1, 3], vec![1..=1, 3..=4]); + file_obj.patched_path = Some(patched_file.path().to_path_buf()); + file_obj.tidy_advice = Some(TidyAdvice { + notes: vec![ + // merged into the existing suggestion for line 1 + note(1, vec![]), + // not in any existing suggestion; creates a new suggestion + note(3, vec!["line3".to_string()]), + ], + }); + + let mut review_comments = ReviewComments::default(); + file_obj + .make_suggestions_from_patch(&mut review_comments, false, repo_root.path()) + .unwrap(); + assert_eq!(review_comments.comments.len(), 2); + assert!( + review_comments.comments[0] + .suggestion + .contains("rationale for line 1") + ); + let new_suggestion = &review_comments.comments[1]; + assert_eq!(new_suggestion.line_start, 3); + assert_eq!(new_suggestion.line_end, 3); + assert!(new_suggestion.suggestion.contains("rationale for line 3")); + assert!(new_suggestion.suggestion.contains("```cpp\nline3\n```")); + + // Processing the same file again merges duplicate suggestions + file_obj + .make_suggestions_from_patch(&mut review_comments, false, repo_root.path()) + .unwrap(); + assert_eq!(review_comments.comments.len(), 2); + assert_eq!( + review_comments.comments[0] + .suggestion + .matches("```suggestion\nLINE1\n```") + .count(), + 2 + ); + + // Only counts the unfixed diagnostics in summary-only mode + let mut summary_comments = ReviewComments::default(); + file_obj + .make_suggestions_from_patch(&mut summary_comments, true, repo_root.path()) + .unwrap(); + assert!(summary_comments.comments.is_empty()); + assert_eq!(summary_comments.tool_total, 3); + } } diff --git a/cpp-linter/src/git.rs b/cpp-linter/src/git.rs index 04af495a..eb8cb46d 100644 --- a/cpp-linter/src/git.rs +++ b/cpp-linter/src/git.rs @@ -6,7 +6,7 @@ #![cfg(test)] mod test { - #![allow(clippy::unwrap_used, clippy::panic, clippy::expect_used)] + #![allow(clippy::unwrap_used, clippy::expect_used)] use std::{ env::{self, current_dir, set_current_dir}, @@ -21,24 +21,18 @@ mod test { const TEST_REPO_URL: &str = "https://github.com/cpp-linter/cpp-linter"; // used to setup a testing stage - fn clone_repo(sha: Option<&str>, path: &str, patch_path: Option<&str>) { + fn clone_repo(sha: &str, path: &str, patch_path: Option<&str>) { let ok = Command::new("git") .args(["clone", TEST_REPO_URL, path]) .status() .expect("Failed to clone repo"); - if !ok.success() { - panic!("Failed to clone repo"); - } - if let Some(sha) = sha { - let ok = Command::new("git") - .args(["-c", "advice.detachedHead=false", "checkout", sha]) - .current_dir(path) - .status() - .expect("Failed to checkout commit"); - if !ok.success() { - panic!("Failed to checkout commit"); - } - } + assert!(ok.success(), "Failed to clone repo"); + let ok = Command::new("git") + .args(["-c", "advice.detachedHead=false", "checkout", sha]) + .current_dir(path) + .status() + .expect("Failed to checkout commit"); + assert!(ok.success(), "Failed to checkout commit"); if let Some(patch) = patch_path { let canonical_patch_path = crate::common_fs::mk_path_abs(patch).unwrap(); let patch_path = canonical_patch_path.to_str().unwrap(); @@ -47,17 +41,16 @@ mod test { .current_dir(path) .status() .expect("Failed to apply patch and stage its changes"); - if !ok.success() { - panic!("Failed to apply patch and stage its changes"); - } + assert!(ok.success(), "Failed to apply patch and stage its changes"); let ok = Command::new("git") .args(["status", "-s"]) .current_dir(path) .status() .expect("Failed to get git status after applying patch"); - if !ok.success() { - panic!("Failed to get git status after applying patch"); - } + assert!( + ok.success(), + "Failed to get git status after applying patch" + ); } } @@ -74,11 +67,7 @@ mod test { patch_path: Option<&str>, ignore_staged: bool, ) -> Vec { - clone_repo( - Some(sha), - tmp.path().as_os_str().to_str().unwrap(), - patch_path, - ); + clone_repo(sha, tmp.path().as_os_str().to_str().unwrap(), patch_path); // avoid use of REST API when testing in CI unsafe { env::set_var("GITHUB_ACTIONS", "false"); diff --git a/cpp-linter/src/lib.rs b/cpp-linter/src/lib.rs index 3c2ab01a..740326e2 100644 --- a/cpp-linter/src/lib.rs +++ b/cpp-linter/src/lib.rs @@ -26,7 +26,7 @@ pub mod sarif; #[cfg(test)] pub(crate) mod test_common { - #![allow(clippy::unwrap_used)] + #![allow(clippy::unwrap_used, clippy::panic)] use std::{fs, path::PathBuf}; @@ -45,4 +45,29 @@ pub(crate) mod test_common { } tmp_workspace } + + /// Poison the given `mutex` by panicking in a thread that holds its lock. + pub fn poison_mutex(mutex: &std::sync::Arc>) { + let mutex = mutex.clone(); + std::thread::spawn(move || { + let _lock = mutex.lock().unwrap(); + panic!("intentionally poison the mutex"); + }) + .join() + .unwrap_err(); + } + + /// Create an executable shell script (in the given `dir`) that can be used in + /// place of a clang tool. The script's `body` is executed by `/bin/sh`. + /// + /// This is useful to simulate unusual behavior of clang tools. + #[cfg(unix)] + pub fn make_fake_tool(dir: &std::path::Path, name: &str, body: &str) -> PathBuf { + use std::os::unix::fs::PermissionsExt; + + let path = dir.join(name); + fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).unwrap(); + path + } } diff --git a/cpp-linter/src/rest_client.rs b/cpp-linter/src/rest_client.rs index ea9b4919..a624ccc9 100644 --- a/cpp-linter/src/rest_client.rs +++ b/cpp-linter/src/rest_client.rs @@ -604,6 +604,8 @@ mod test { use semver::Version; use tempfile::{NamedTempFile, tempdir}; + use git_bot_feedback::AnnotationLevel; + use super::{RestClient, USER_OUTREACH}; use crate::{ clang_tools::{ @@ -611,8 +613,9 @@ mod test { clang_format::FormatAdvice, clang_tidy::{TidyAdvice, TidyNotification}, }, - cli::FeedbackInput, + cli::{FeedbackInput, ThreadComments}, common_fs::FileObj, + error::ClientError, }; use clang_tools_manager::logger::try_init_logger; @@ -722,4 +725,181 @@ mod test { "checks-failed=0\nclang-format-checks-failed=0\nclang-tidy-checks-failed=0\n" ); } + + // ************************* tests for other feedback paths + + fn make_dummy_files(severity: &str) -> Vec>> { + let filename = String::from("tests/demo/demo.cpp"); + let mut file = FileObj::new(PathBuf::from(&filename)); + file.tidy_advice = Some(TidyAdvice { + notes: vec![TidyNotification { + filename, + line: 1, + cols: 1, + severity: severity.to_string(), + rationale: String::from("A test dummy rationale"), + diagnostic: String::from("clang-diagnostic-error"), + suggestion: vec![String::from("int main() {}")], + fixed_lines: vec![1], + }], + }); + file.format_advice = Some(FormatAdvice { + #[allow(clippy::single_range_in_vec_init)] + replacements: vec![1..=2], + }); + vec![Arc::new(Mutex::new(file))] + } + + fn dummy_versions() -> ClangVersions { + ClangVersions { + format_version: Some(Version::new(1, 2, 3)), + tidy_version: Some(Version::new(1, 2, 3)), + } + } + + #[tokio::test] + async fn thread_comment_without_summary() { + unsafe { + env::remove_var("GITHUB_ACTIONS"); + } + let tmp_dir = tempdir().unwrap(); + let mut rest_api_client = RestClient::new().unwrap(); + for thread_comments in [ThreadComments::On, ThreadComments::Update] { + let feedback_inputs = FeedbackInput { + thread_comments, + repo_root: tmp_dir.path().to_path_buf(), + ..Default::default() + }; + let checks_failed = rest_api_client + .post_feedback( + &make_dummy_files("error"), + feedback_inputs, + dummy_versions(), + ) + .await + .unwrap(); + assert_eq!(checks_failed, 2); + } + } + + #[test] + fn annotation_severity() { + let annotations = RestClient::make_annotations(&make_dummy_files("error"), "llvm").unwrap(); + assert!( + annotations + .iter() + .any(|a| matches!(a.severity, AnnotationLevel::Error)) + ); + let annotations = RestClient::make_annotations(&make_dummy_files("note"), "llvm").unwrap(); + // both the clang-format and clang-tidy annotations are notices + assert_eq!(annotations.len(), 2); + assert!( + annotations + .iter() + .all(|a| matches!(a.severity, AnnotationLevel::Notice)) + ); + } + + #[test] + fn comment_without_tidy_concerns() { + let files = make_dummy_files("warning"); + let comment = RestClient::make_comment(&files, 1, 0, &dummy_versions(), None).unwrap(); + assert!(comment.contains("clang-format (v1.2.3) reports")); + assert!(!comment.contains("clang-tidy (v1.2.3) reports")); + } + + #[tokio::test] + async fn summary_output_file_errors() { + unsafe { + env::remove_var("GITHUB_ACTIONS"); + } + let tmp_dir = tempdir().unwrap(); + let mut rest_api_client = RestClient::new().unwrap(); + + // the parent "directory" is actually a file + let not_a_dir = tmp_dir.path().join("not-a-dir"); + std::fs::write(¬_a_dir, "").unwrap(); + let feedback_inputs = FeedbackInput { + summary_output_file: Some(not_a_dir.join("summary.md")), + repo_root: tmp_dir.path().to_path_buf(), + ..Default::default() + }; + let err = rest_api_client + .post_feedback( + &make_dummy_files("warning"), + feedback_inputs, + dummy_versions(), + ) + .await + .unwrap_err(); + assert!(matches!(err, ClientError::MkDirFailed { .. })); + + // the output "file" is actually a directory + let feedback_inputs = FeedbackInput { + summary_output_file: Some(tmp_dir.path().to_path_buf()), + repo_root: tmp_dir.path().to_path_buf(), + ..Default::default() + }; + let err = rest_api_client + .post_feedback( + &make_dummy_files("warning"), + feedback_inputs, + dummy_versions(), + ) + .await + .unwrap_err(); + assert!(matches!( + err, + ClientError::SummaryOutputFileWriteFailed { .. } + )); + + // the output path has no parent (and is not a writable file) + #[cfg(unix)] + { + let feedback_inputs = FeedbackInput { + summary_output_file: Some(PathBuf::from("/")), + repo_root: tmp_dir.path().to_path_buf(), + ..Default::default() + }; + let err = rest_api_client + .post_feedback( + &make_dummy_files("warning"), + feedback_inputs, + dummy_versions(), + ) + .await + .unwrap_err(); + assert!(matches!( + err, + ClientError::SummaryOutputFileWriteFailed { .. } + )); + } + } + + #[test] + fn comment_with_poisoned_mutex() { + let files = make_dummy_files("warning"); + crate::test_common::poison_mutex(&files[0]); + // poisoned while making the clang-format comment + assert!(RestClient::make_comment(&files, 1, 1, &dummy_versions(), None).is_err()); + // poisoned while making the clang-tidy comment + assert!(RestClient::make_comment(&files, 0, 1, &dummy_versions(), None).is_err()); + } + + #[test] + fn comment_too_long_for_tidy_note() { + let files = make_dummy_files("warning"); + let unlimited = RestClient::make_comment(&files, 0, 1, &dummy_versions(), None).unwrap(); + assert!(unlimited.contains("A test dummy rationale")); + // one byte short of the room needed for the clang-tidy note + let limited = RestClient::make_comment( + &files, + 0, + 1, + &dummy_versions(), + Some(unlimited.len() as u64 - 1), + ) + .unwrap(); + assert!(!limited.contains("A test dummy rationale")); + } } diff --git a/cpp-linter/src/run.rs b/cpp-linter/src/run.rs index 5935c61f..721a4081 100644 --- a/cpp-linter/src/run.rs +++ b/cpp-linter/src/run.rs @@ -238,6 +238,24 @@ pub(crate) mod test { drop(tmp_gh_out); } + #[tokio::test] + async fn no_mod_sys() { + let tmp_gh_out = setup_tmp_gh_out_path(); + let tmp_workspace = setup_tmp_workspace(); + run_main(vec![ + "cpp-linter".to_string(), + "-l".to_string(), + "false".to_string(), + "--no-mod-sys".to_string(), + "--repo-root".to_string(), + tmp_workspace.path().to_str().unwrap().to_string(), + "demo/demo.cpp".to_string(), + ]) + .await + .unwrap(); + drop(tmp_gh_out); + } + #[tokio::test] async fn version_command() { let tmp_gh_out = setup_tmp_gh_out_path(); @@ -327,6 +345,24 @@ pub(crate) mod test { drop(tmp_gh_out); } + #[tokio::test] + async fn only_not_ignored() { + let tmp_gh_out = setup_tmp_gh_out_path(); + let tmp_workspace = setup_tmp_workspace(); + run_main(vec![ + "cpp-linter".to_string(), + "-l".to_string(), + "false".to_string(), + // only specify a not-ignored pattern, so the ignored set is empty + "-i=!demo".to_string(), + "--repo-root".to_string(), + tmp_workspace.path().to_str().unwrap().to_string(), + ]) + .await + .unwrap(); + drop(tmp_gh_out); + } + #[tokio::test] async fn bad_repo_root() { let tmp_gh_out = setup_tmp_gh_out_path(); diff --git a/cpp-linter/src/sarif.rs b/cpp-linter/src/sarif.rs index 7cebcd52..8ded7444 100644 --- a/cpp-linter/src/sarif.rs +++ b/cpp-linter/src/sarif.rs @@ -536,4 +536,36 @@ mod test { serde_json::from_str(&std::fs::read_to_string(&out_path).unwrap()).unwrap(); assert_eq!(written, to_json(&log)); } + + #[test] + fn write_sarif_file_errors() { + use crate::error::ClientError; + + let tmp_dir = tempfile::tempdir().unwrap(); + let log = SarifLog::new(&make_files(), None, tmp_dir.path()).unwrap(); + + // the parent "directory" is actually a file + let not_a_dir = tmp_dir.path().join("not-a-dir"); + std::fs::write(¬_a_dir, "").unwrap(); + let err = log.write(¬_a_dir.join("clang-tidy.sarif")).unwrap_err(); + assert!(matches!(err, ClientError::MkDirFailed { .. })); + + // the output "file" is actually a directory + let err = log.write(tmp_dir.path()).unwrap_err(); + assert!(matches!(err, ClientError::SarifFileWriteFailed { .. })); + + // the output path has no parent (and is not a writable file) + #[cfg(unix)] + { + let err = log.write(Path::new("/")).unwrap_err(); + assert!(matches!(err, ClientError::SarifFileWriteFailed { .. })); + } + } + + #[test] + fn poisoned_mutex() { + let files = make_files(); + crate::test_common::poison_mutex(&files[0]); + assert!(SarifLog::new(&files, None, Path::new(".")).is_err()); + } }