diff --git a/docs/user/features/rust_environment.md b/docs/user/features/rust_environment.md new file mode 100644 index 00000000..8b119aa4 --- /dev/null +++ b/docs/user/features/rust_environment.md @@ -0,0 +1,59 @@ +# Rust Environment Helpers + +## About Rust Environment Helpers + +Firmware developer's machines are often not setup for Rust. As more Rust code is proliferating across the repos, this +code can be used to provide early and direct feedback about the developer's environment so it can successfully build +Rust code using the tools commonly used in the firmware build process. + +## Usage + +The primary purpose of the functionality in `rust_environment` is to be used in a build plugin wrapper. However, the +public functions may be used in other contexts as well. The following functions are available: + +- `run()` - Checks the current environment for Rust build support. + - The checks can be customized with the `custom_tool_checks` and `custom_tool_filters` parameters. +- `get_workspace_toolchain_version()` - Returns the rust toolchain version specified in the workspace toolchain file. +- `verify_workspace_rust_toolchain_is_installed()` - RVerifies the rust toolchain used in the workspace is available. + +### Integration Examples + +This section provides examples of how to use the functions available in `edk2toolext/codeql.py`. + +#### `run()` + +Call to check the environment for Rust build support: + +```python + import edk2toolext.rust_environment as rust_env + + def Run(self): + rust_env.run() +``` + +#### `get_workspace_toolchain_version()` + +Call to get Rust toolchain info: + +```python + import edk2toolext.rust_environment as rust_env + + def GetWorkspaceToolchainVersion(self): + toolchain = rust_env.get_workspace_toolchain_version() + print(f"Workspace toolchain version: {toolchain_version.toolchain}") +``` + +#### `verify_workspace_rust_toolchain_is_installed()` + +Call to verify the workspace specified toolchain is installed: + +```python + import edk2toolext.rust_environment as rust_env + + def VerifyWorkspaceRustToolchainIsInstalled(self): + rust_toolchain_info = verify_workspace_rust_toolchain_is_installed() + if rust_toolchain_info.error: + print(f"Error: {rust_toolchain_info.error}") + else: + print(f"Rust toolchain is installed: {rust_toolchain_info.toolchain}") +``` diff --git a/edk2toolext/rust_environment.py b/edk2toolext/rust_environment.py new file mode 100644 index 00000000..ccba0937 --- /dev/null +++ b/edk2toolext/rust_environment.py @@ -0,0 +1,442 @@ +# @file rust_environment.py +# +# Helpers to check that Rust tools are present needed to compile Rust code +# during firmare build. +# +# This functionality can be used to provide faster, direct feedback to a +# developer about the changes they may need to make to successfully build Rust +# code. Otherwise, the build will fail much later during firmware code +# compilation when Rust tools are invoked with messages that are ambiguous or +# difficult to find. +# +# Note: +# - Individual tools can be opted out by setting the environment variable +# `RUST_ENV_CHECK_TOOL_EXCLUSIONS` with a comma separated list of the tools +# to exclude. For example, "rustup, cargo tarpaulin" would not require that +# those tools be installed. +# +# Copyright (c) Microsoft Corporation. +# SPDX-License-Identifier: BSD-2-Clause-Patent +## +"""Rust Environment Helper Functions. + +Functions to help check that a build environment is ready to build Rust code. +""" + +import logging +import re +from io import StringIO +from typing import Callable, List, NamedTuple + +from edk2toollib.utility_functions import RunCmd + +from edk2toolext.environment import shell_environment + +WORKSPACE_TOOLCHAIN_FILE = "rust-toolchain.toml" + + +class RustToolInfo(NamedTuple): + """Represents information about a Rust tool. + + Attributes: + presence_cmd (tuple[str]): A tuple of command-line arguments to check + for the presence of the tool. + install_help (str): Help text for installing the tool. + required_version (str): The required version of the tool. + regex (str): Regular expression pattern to match the tool's version. + """ + + presence_cmd: tuple[str] + install_help: str + required_version: str + regex: str + + +class RustToolChainInfo(NamedTuple): + """Represents information about a Rust toolchain. + + Attributes: + error (bool): Indicates whether an error occurred while retrieving the + toolchain information. + toolchain (str): The name of the Rust toolchain. + """ + + error: bool + toolchain: str + + +class CustomToolFilter(NamedTuple): + """Represents a custom tool filter. + + Attributes: + filter_fn (Callable[[RustToolInfo, str], bool]): A callable function + that takes a `RustToolInfo` object and a string as input and + returns a boolean value indicating whether the tool should be + filtered or not. + error_msg (str): The error message to be displayed if the tool is + filtered. + error_only (bool): A boolean value indicating whether the error message + should be displayed only when the tool is filtered. + """ + + filter_fn: Callable[[RustToolInfo, str], bool] + error_msg: str + error_only: bool + + +def _is_corrupted_component(tool: RustToolInfo, cmd_output: str) -> bool: + """Checks if a component should be removed and reinstalled. + + Args: + tool (RustToolInfo): Tool information. + cmd_output (str): The output from a command that will be + inspected by this function. + + Returns: + bool: True if the component should be removed and added back to + correct its installation. + """ + return ( + f"error: the '{tool.presence_cmd[0]}' binary, normally " + f"provided by the '{tool.presence_cmd[0]}' component, is " + f"not applicable to the " + ) in cmd_output + + +def _verify_cmd(tool: RustToolInfo, custom_filters: List[CustomToolFilter]) -> int: + """Indicates if a command can successfully be executed. + + Args: + tool (RustToolInfo): Tool information + custom_filters (List[CustomToolFilter]): Custom filters to apply + + Returns: + int: 0 for success, 1 for missing tool, 2 for version mismatch, + 3 if a component is present but broken + 4 if a custom tool filter detected an error + """ + cmd_output = StringIO() + params = "--version" + name = tool.presence_cmd[0] + if len(tool.presence_cmd) == 2: + params = tool.presence_cmd[1] + ret = RunCmd(name, params, outstream=cmd_output, logging_level=logging.DEBUG) + + # Give precedence to custom filters as they may be more specialized + for custom_filter in custom_filters: + if ( + (custom_filter.error_only and ret != 0) or not custom_filter.error_only + ) and custom_filter.filter_fn(tool, cmd_output.getvalue()): + logging.error(custom_filter.error_msg) + return 4 + + if ret != 0: + if _is_corrupted_component(tool, cmd_output.getvalue()): + return 3 + return 1 + + # If a specific version is required, check the version, returning + # false if there is a version mismatch + if tool.required_version: + match = re.search(tool.regex, cmd_output.getvalue()) + if match is None: + logging.warning(f"Failed to verify version: {tool.required_version}") + return 0 + if match.group(0) != tool.required_version: + return 2 + + return 0 + + +def _get_required_tool_versions() -> dict[str, str]: + """Returns any tools and their required versions from the workspace toolchain file. + + Returns: + dict[str,str]: dict where the key is the tool name and the + value is the version + """ + tool_ver = {} + try: + with open(WORKSPACE_TOOLCHAIN_FILE, "r") as toml_file: + content = toml_file.read() + match = re.search(r"\[tool\]\n((?:.+\s*=\s*.+\n?)*)", content) + if match: + for line in match.group(1).splitlines(): + (tool, version) = line.split("=", maxsplit=1) + tool_ver[tool.strip()] = version.strip(" \"'") + return tool_ver + except FileNotFoundError: + # If a file is not found. Do not check any further. + return tool_ver + + +def _verify_rust_src_component_is_installed() -> bool: + """Verifies the rust-src component is installed. + + Returns: + bool: True if the rust-src component is installed for the default + toolchain or the status could not be determined, otherwise, False. + """ + toolchain_version = get_workspace_toolchain_version() + if toolchain_version.error or not toolchain_version: + # If the file is not in an expected format, let that be handled + # elsewhere and do not look further. + return True + + toolchain_version = toolchain_version.toolchain + + rustc_output = StringIO() + ret = RunCmd( + "rustc", + "--version --verbose", + outstream=rustc_output, + logging_level=logging.DEBUG, + ) + if ret != 0: + # rustc installation is checked elsewhere. Exit here on failure. + return True + + for line in rustc_output.getvalue().splitlines(): + start_index = line.lower().strip().find("host: ") + if start_index != -1: + target_triple = line[start_index + len("host: ") :] + break + else: + logging.error("Failed to get host target triple information.") + return False + + rustup_output = StringIO() + ret = RunCmd( + "rustup", + f"component list --toolchain {toolchain_version}", + outstream=rustup_output, + logging_level=logging.DEBUG, + ) + if ret != 0: + # rustup installation and the toolchain are checked elsewhere. + # Exit here on failure. + return True + + for component in rustup_output.getvalue().splitlines(): + if "rust-src (installed)" in component: + return True + + logging.error( + "The Rust toolchain is installed but the rust-src component " + "needs to be installed:\n\n" + f" rustup component add --toolchain {toolchain_version}-" + f"{target_triple} rust-src" + ) + + return False + + +def verify_workspace_rust_toolchain_is_installed() -> RustToolChainInfo: + """Verifies the rust toolchain used in the workspace is available. + + Note: This function does not use the toml library to parse the toml + file since the file is very simple and its not desirable to add the + toml module as a dependency. + + Returns: + RustToolChainInfo: A tuple that indicates if the toolchain is + available and any the toolchain version if found. + """ + toolchain_version = get_workspace_toolchain_version() + if toolchain_version.error or not toolchain_version: + # If the file is not in an expected format, let that be handled + # elsewhere and do not look further. + return RustToolChainInfo(error=False, toolchain=None) + + toolchain_version = toolchain_version.toolchain + + installed_toolchains = StringIO() + ret = RunCmd( + "rustup", + "toolchain list", + outstream=installed_toolchains, + logging_level=logging.DEBUG, + ) + + # The ability to call "rustup" is checked separately. Here do not + # continue if the command is not successful. + if ret != 0: + return RustToolChainInfo(error=False, toolchain=None) + + installed_toolchains = installed_toolchains.getvalue().splitlines() + return RustToolChainInfo( + error=not any( + toolchain_version in toolchain for toolchain in installed_toolchains + ), + toolchain=toolchain_version, + ) + + +def get_workspace_toolchain_version() -> RustToolChainInfo: + """Returns the rust toolchain version specified in the workspace toolchain file. + + Returns: + RustToolChainInfo: The rust toolchain information. If an error + occurs, the error field will be True with no toolchain info. + """ + toolchain_version = None + try: + with open(WORKSPACE_TOOLCHAIN_FILE, "r") as toml_file: + content = toml_file.read() + match = re.search(r'channel\s*=\s*"([^"]+)"', content) + if match: + toolchain_version = match.group(1) + return RustToolChainInfo(error=False, toolchain=toolchain_version) + except FileNotFoundError: + # If a file is not found. Do not check any further. + return RustToolChainInfo(error=True, toolchain=None) + + +def run( + custom_tool_checks: dict[str, RustToolInfo] = {}, + custom_tool_filters: List[CustomToolFilter] = [], +) -> None: + """Checks the current environment for Rust build support. + + Returns: + int: Then number of errors discovered. 0 indicates success. + """ + generic_rust_install_instructions = ( + "Visit https://rustup.rs/ to install Rust and cargo." + ) + tool_ver = _get_required_tool_versions() + + tools = { + "rustup": RustToolInfo( + presence_cmd=("rustup",), + install_help=generic_rust_install_instructions, + required_version=None, + regex=None, + ), + "rustc": RustToolInfo( + presence_cmd=("rustc",), + install_help=generic_rust_install_instructions, + required_version=None, + regex=None, + ), + "cargo": RustToolInfo( + presence_cmd=("cargo",), + install_help=generic_rust_install_instructions, + required_version=None, + regex=None, + ), + "cargo build": RustToolInfo( + presence_cmd=("cargo", "build --help"), + install_help=generic_rust_install_instructions, + required_version=None, + regex=None, + ), + "cargo check": RustToolInfo( + presence_cmd=("cargo", "check --help"), + install_help=generic_rust_install_instructions, + required_version=None, + regex=None, + ), + "cargo fmt": RustToolInfo( + presence_cmd=("cargo", "fmt --help"), + install_help=generic_rust_install_instructions, + required_version=None, + regex=None, + ), + "cargo test": RustToolInfo( + presence_cmd=("cargo", "test --help"), + install_help=generic_rust_install_instructions, + required_version=None, + regex=None, + ), + "cargo make": RustToolInfo( + presence_cmd=("cargo", "make --version"), + install_help=( + " cargo binstall cargo-make " + f"{('--version ' + tool_ver.get('cargo-make', '')) if 'cargo-make' in tool_ver else ''}" + "\nOR\n" + " cargo install cargo-make " + f"{('--version ' + tool_ver.get('cargo-make', '')) if 'cargo-make' in tool_ver else ''}\n" + ), + required_version=tool_ver.get("cargo-make"), + regex=r"\d+\.\d+\.\d+", + ), + "cargo tarpaulin": RustToolInfo( + presence_cmd=("cargo", "tarpaulin --version"), + install_help=( + " cargo binstall cargo-tarpaulin " + f"{('--version ' + tool_ver.get('cargo-tarpaulin', '')) if 'cargo-tarpaulin' in tool_ver else ''}" + "\nOR\n" + " cargo install cargo-tarpaulin " + f"{('--version ' + tool_ver.get('cargo-tarpaulin', '')) if 'cargo-tarpaulin' in tool_ver else ''}\n" + ), + required_version=tool_ver.get("cargo-tarpaulin"), + regex=r"\d+\.\d+\.\d+", + ), + } + tools.update(custom_tool_checks) + + excluded_tools_in_shell = shell_environment.GetEnvironment().get_shell_var( + "RUST_ENV_CHECK_TOOL_EXCLUSIONS" + ) + excluded_tools = ( + [t.strip() for t in excluded_tools_in_shell.split(",")] + if excluded_tools_in_shell + else [] + ) + + errors = 0 + for tool_name, tool_info in tools.items(): + if tool_name not in excluded_tools: + ret = _verify_cmd(tool_info, custom_tool_filters) + if ret == 1: + logging.error( + f"Rust Environment Failure: {tool_name} is not installed " + "or not on the system path.\n\n" + f"Instructions:\n{tool_info.install_help}\n\n" + f"Ensure \"{' '.join(tool_info.presence_cmd)}\" can " + "successfully be run from a terminal before trying again." + ) + errors += 1 + if ret == 2: + logging.error( + f"Rust Environment Failure: {tool_name} version mismatch.\n\n" + f"Expected version: {tool_info.required_version}\n\n" + f"Instructions:\n{tool_info.install_help}" + ) + errors += 1 + if ret == 3: + logging.error( + f"Rust Environment Failure: {tool_name} is installed " + "but does not run correctly.\n\n" + f'Run "rustc component remove {tool_name}"\n' + f' "rustc component add {tool_name}"\n\n' + f"Then try again." + ) + errors += 1 + if ret == 4: + errors += 1 + break + + rust_toolchain_info = verify_workspace_rust_toolchain_is_installed() + if rust_toolchain_info.error: + # The "rustc -Vv" command could be run in the script with the + # output given to the user. This is approach is also meant to show + # the user how to use the tools since getting the target triple is + # important. + logging.error( + f"This workspace requires the {rust_toolchain_info.toolchain} " + "toolchain.\n\n" + 'Run "rustc -Vv" and use the "host" value to install the ' + "toolchain needed:\n" + f' "rustup toolchain install {rust_toolchain_info.toolchain}-' + '"\n\n' + ' "rustup component add rust-src ' + f'{rust_toolchain_info.toolchain}-"' + ) + errors += 1 + + if not _verify_rust_src_component_is_installed(): + errors += 1 + + return errors diff --git a/tests.unit/test_rust_environment.py b/tests.unit/test_rust_environment.py new file mode 100644 index 00000000..19cce4f2 --- /dev/null +++ b/tests.unit/test_rust_environment.py @@ -0,0 +1,407 @@ +## +"""Rust Environment module unit tests.""" + +import unittest +from unittest.mock import mock_open, patch, MagicMock + +from edk2toolext.rust_environment import ( + RustToolChainInfo, + RustToolInfo, + CustomToolFilter, + _is_corrupted_component, + _verify_cmd, + _get_required_tool_versions, + _verify_rust_src_component_is_installed, + run, + verify_workspace_rust_toolchain_is_installed, +) + + +class RustEnvironmentTests(unittest.TestCase): + """Rust Environment unit tests.""" + + @classmethod + def _mock_run_cmd(cls, tool_name: str, tool_params: str, **kwargs: dict[str, any]): + if tool_name == "rustc": + if any(p in tool_params for p in ["--version", "-V"]): + if any(p in tool_params for p in ["--verbose", "-v"]): + kwargs["outstream"].write( + "rustc 1.76.0 (c560aa0e2e 2024-04-12)\n" + "binary: rustc\n" + "commit-hash: c560aa0e2e2ae245cf41c934a259a7bbe87ff96f\n" + "commit-date: 2024-04-12\n" + "host: x86_64-pc-windows-msvc\n" + "release: 1.76.0\n" + "LLVM version: 17.0.6\n" + ) + else: + kwargs["outstream"].write("rustc 1.76.0 (c560aa0e2e 2024-04-12)") + return 0 + elif tool_name == "corruptedtool": + kwargs["outstream"].write( + ( + "error: the 'corruptedtool' " + "binary, normally provided by the " + "'corruptedtool' component, " + "is not applicable to the ..." + ) + ) + return 100 + elif tool_name == "filternoerrortesttool": + kwargs["outstream"].write(("filter match string")) + return 0 + elif tool_name == "rustup": + if all(p in tool_params for p in ["component", "list", "--toolchain"]): + kwargs["outstream"].write( + "cargo-x86_64-pc-windows-msvc (installed)\n" + "clippy-x86_64-pc-windows-msvc (installed)\n" + "llvm-tools-x86_64-pc-windows-msvc\n" + "rls-x86_64-pc-windows-msvc\n" + "rust-analysis-x86_64-pc-windows-msvc\n" + "rust-analyzer-x86_64-pc-windows-msvc\n" + "rust-docs-x86_64-pc-windows-msvc (installed)\n" + "rust-src (installed)\n" + "rust-std-aarch64-apple-darwin\n" + "rust-std-aarch64-apple-ios\n" + "rust-std-aarch64-apple-ios-sim\n" + "rust-std-aarch64-linux-android\n" + "rust-std-aarch64-pc-windows-msvc\n" + "rust-std-aarch64-unknown-fuchsia\n" + "rust-std-aarch64-unknown-linux-gnu\n" + "rust-std-aarch64-unknown-linux-musl\n" + "rust-std-aarch64-unknown-none\n" + "rust-std-aarch64-unknown-none-softfloat\n" + "rust-std-aarch64-unknown-uefi\n" + "rust-std-arm-linux-androideabi\n" + "rust-std-arm-unknown-linux-gnueabi\n" + "rust-std-arm-unknown-linux-gnueabihf\n" + "rust-std-arm-unknown-linux-musleabi\n" + "rust-std-arm-unknown-linux-musleabihf\n" + "rust-std-armebv7r-none-eabi\n" + "rust-std-armebv7r-none-eabihf\n" + "rust-std-armv5te-unknown-linux-gnueabi\n" + "rust-std-armv5te-unknown-linux-musleabi\n" + "rust-std-armv7-linux-androideabi\n" + "rust-std-armv7-unknown-linux-gnueabi\n" + "rust-std-armv7-unknown-linux-gnueabihf\n" + "rust-std-armv7-unknown-linux-musleabi\n" + "rust-std-armv7-unknown-linux-musleabihf\n" + "rust-std-armv7a-none-eabi\n" + "rust-std-armv7r-none-eabi\n" + "rust-std-armv7r-none-eabihf\n" + "rust-std-i586-pc-windows-msvc\n" + "rust-std-i586-unknown-linux-gnu\n" + "rust-std-i586-unknown-linux-musl\n" + "rust-std-i686-linux-android\n" + "rust-std-i686-pc-windows-gnu\n" + "rust-std-i686-pc-windows-msvc\n" + "rust-std-i686-unknown-freebsd\n" + "rust-std-i686-unknown-linux-gnu\n" + "rust-std-i686-unknown-linux-musl\n" + "rust-std-i686-unknown-uefi\n" + "rust-std-loongarch64-unknown-linux-gnu\n" + "rust-std-loongarch64-unknown-none\n" + "rust-std-loongarch64-unknown-none-softfloat\n" + "rust-std-nvptx64-nvidia-cuda\n" + "rust-std-powerpc-unknown-linux-gnu\n" + "rust-std-powerpc64-unknown-linux-gnu\n" + "rust-std-powerpc64le-unknown-linux-gnu\n" + "rust-std-riscv32i-unknown-none-elf\n" + "rust-std-riscv32imac-unknown-none-elf\n" + "rust-std-riscv32imafc-unknown-none-elf\n" + "rust-std-riscv32imc-unknown-none-elf\n" + "rust-std-riscv64gc-unknown-linux-gnu\n" + "rust-std-riscv64gc-unknown-none-elf\n" + "rust-std-riscv64imac-unknown-none-elf\n" + "rust-std-s390x-unknown-linux-gnu\n" + "rust-std-sparc64-unknown-linux-gnu\n" + "rust-std-sparcv9-sun-solaris\n" + "rust-std-thumbv6m-none-eabi\n" + "rust-std-thumbv7em-none-eabi\n" + "rust-std-thumbv7em-none-eabihf\n" + "rust-std-thumbv7m-none-eabi\n" + "rust-std-thumbv7neon-linux-androideabi\n" + "rust-std-thumbv7neon-unknown-linux-gnueabihf\n" + "rust-std-thumbv8m.base-none-eabi\n" + "rust-std-thumbv8m.main-none-eabi\n" + "rust-std-thumbv8m.main-none-eabihf\n" + "rust-std-wasm32-unknown-emscripten\n" + "rust-std-wasm32-unknown-unknown\n" + "rust-std-wasm32-wasi\n" + "rust-std-wasm32-wasi-preview1-threads\n" + "rust-std-x86_64-apple-darwin\n" + "rust-std-x86_64-apple-ios\n" + "rust-std-x86_64-fortanix-unknown-sgx\n" + "rust-std-x86_64-linux-android\n" + "rust-std-x86_64-pc-solaris\n" + "rust-std-x86_64-pc-windows-gnu\n" + "rust-std-x86_64-pc-windows-msvc (installed)\n" + "rust-std-x86_64-unknown-freebsd\n" + "rust-std-x86_64-unknown-fuchsia\n" + "rust-std-x86_64-unknown-illumos\n" + "rust-std-x86_64-unknown-linux-gnu\n" + "rust-std-x86_64-unknown-linux-gnux32\n" + "rust-std-x86_64-unknown-linux-musl\n" + "rust-std-x86_64-unknown-netbsd\n" + "rust-std-x86_64-unknown-none\n" + "rust-std-x86_64-unknown-redox\n" + "rust-std-x86_64-unknown-uefi\n" + "rustc-x86_64-pc-windows-msvc (installed)\n" + "rustc-dev-aarch64-apple-darwin\n" + "rustc-dev-aarch64-pc-windows-msvc\n" + "rustc-dev-aarch64-unknown-linux-gnu\n" + "rustc-dev-aarch64-unknown-linux-musl\n" + "rustc-dev-arm-unknown-linux-gnueabi\n" + "rustc-dev-arm-unknown-linux-gnueabihf\n" + "rustc-dev-armv7-unknown-linux-gnueabihf\n" + "rustc-dev-i686-pc-windows-gnu\n" + "rustc-dev-i686-pc-windows-msvc\n" + "rustc-dev-i686-unknown-linux-gnu\n" + "rustc-dev-loongarch64-unknown-linux-gnu\n" + "rustc-dev-powerpc-unknown-linux-gnu\n" + "rustc-dev-powerpc64-unknown-linux-gnu\n" + "rustc-dev-powerpc64le-unknown-linux-gnu\n" + "rustc-dev-riscv64gc-unknown-linux-gnu\n" + "rustc-dev-s390x-unknown-linux-gnu\n" + "rustc-dev-x86_64-apple-darwin\n" + "rustc-dev-x86_64-pc-windows-gnu\n" + "rustc-dev-x86_64-pc-windows-msvc\n" + "rustc-dev-x86_64-unknown-freebsd\n" + "rustc-dev-x86_64-unknown-illumos\n" + "rustc-dev-x86_64-unknown-linux-gnu\n" + "rustc-dev-x86_64-unknown-linux-musl\n" + "rustc-dev-x86_64-unknown-netbsd\n" + "rustc-docs-x86_64-unknown-linux-gnu\n" + "rustfmt-x86_64-pc-windows-msvc (installed)\n" + ) + elif all(p in tool_params for p in ["toolchain", "list"]): + kwargs["outstream"].write( + "stable-x86_64-pc-windows-msvc" + "nightly-x86_64-pc-windows-msvc" + "1.71.1-x86_64-pc-windows-msvc" + "1.73.0-x86_64-pc-windows-msvc" + "1.74.0-x86_64-pc-windows-msvc" + "1.76.0-x86_64-pc-windows-msvc" + "1.77.1-x86_64-pc-windows-msvc" + "1.77.2-x86_64-pc-windows-msvc" + "ms-1.76 (default)" + "ms-1.76-x86_64-pc-windows-msvc" + "ms-stable (default)" + ) + return 0 + + return 1 + + @classmethod + def _mock_get_workspace_toolchain_version(cls) -> RustToolChainInfo: + return RustToolChainInfo(error=False, toolchain="1.76.0") + + def test_is_corrupted_component(self): + tool = RustToolInfo( + presence_cmd=("rustc", "--version"), + install_help="Install Rust compiler using rustup", + required_version="1.76.0", + regex=r"rustc (\d+\.\d+\.\d+)", + ) + cmd_output = ( + f"error: the '{tool.presence_cmd[0]}' binary, normally " + f"provided by the '{tool.presence_cmd[0]}' component, is " + "not applicable to the ..." + ) + self.assertTrue(_is_corrupted_component(tool, cmd_output)) + + cmd_output = "rustc 1.76.0 (c560aa0e2e 2024-04-12) " + self.assertFalse(_is_corrupted_component(tool, cmd_output)) + + @patch("edk2toolext.rust_environment.RunCmd") + def test_verify_cmd(self, mock_run_cmd: MagicMock): + working_tool = RustToolInfo( + presence_cmd=("rustc", "--version"), + install_help="Install Rust compiler using rustup", + required_version="1.76.0", + regex=r"\d+\.\d+\.\d+", + ) + bad_version_tool = RustToolInfo( + presence_cmd=("rustc", "--version"), + install_help="Install Rust compiler using rustup", + required_version="1.77.0", + regex=r"\d+\.\d+\.\d+", + ) + corrupted_test_tool = RustToolInfo( + presence_cmd=("corruptedtool", "--version"), + install_help="Install Corrupted Tool", + required_version="2.5.10", + regex=r"\d+\.\d+\.\d+", + ) + unknown_tool = RustToolInfo( + presence_cmd=("unknowntool",), + install_help="Install the Unknown Tool", + required_version=None, + regex=None, + ) + filter_no_error_test_tool = RustToolInfo( + presence_cmd=("filternoerrortesttool",), + install_help="Install the Filter No Error Test Tool", + required_version=None, + regex=None, + ) + + custom_filters = [ + CustomToolFilter( + filter_fn=lambda _, o: "error" in o.lower(), + error_msg="Error occurred while verifying tool", + error_only=True, + ), + CustomToolFilter( + filter_fn=lambda _, o: "warning" in o.lower(), + error_msg="Warning occurred while verifying tool", + error_only=False, + ), + CustomToolFilter( + filter_fn=lambda _, o: "filter match" in o.lower(), + error_msg="A filter matched!", + error_only=True, + ), + ] + + mock_run_cmd.side_effect = self._mock_run_cmd + + # Normal, working case, no custom filters + result = _verify_cmd(working_tool, []) + self.assertEqual(result, 0) + + # Normal, working case, custom filters + result = _verify_cmd(working_tool, custom_filters) + self.assertEqual(result, 0) + + # Tool not found + result = _verify_cmd(unknown_tool, []) + self.assertEqual(result, 1) + + # Version required and does not match + result = _verify_cmd(bad_version_tool, custom_filters) + self.assertEqual(result, 2) + + # Corrupted component, but custom filter takes precedence (on error) + result = _verify_cmd(corrupted_test_tool, custom_filters) + self.assertEqual(result, 4) + + # Corrupted component, no applicable custom filter + result = _verify_cmd(corrupted_test_tool, []) + self.assertEqual(result, 3) + + # Custom filter, but custom filter does not apply (no error) + result = _verify_cmd(filter_no_error_test_tool, custom_filters) + self.assertEqual(result, 0) + + def test_get_required_tool_versions_empty(self): + versions = _get_required_tool_versions() + self.assertIsInstance(versions, dict) + self.assertEqual(len(versions), 0) + + @patch("edk2toolext.rust_environment.RunCmd") + @patch("edk2toolext.rust_environment.get_workspace_toolchain_version") + def test_verify_rust_src_component_is_installed( + self, + mock_get_workspace_toolchain_version: MagicMock, + mock_run_cmd: MagicMock, + ): + mock_run_cmd.side_effect = self._mock_run_cmd + mock_get_workspace_toolchain_version.side_effect = ( + self._mock_get_workspace_toolchain_version + ) + result = _verify_rust_src_component_is_installed() + self.assertTrue(result) + + def test_get_required_tool_versions(self): + # Test when the workspace toolchain file exists and contains valid tool versions + with patch( + "builtins.open", + mock_open( + read_data='[toolchain]\nchannel = "1.76.0"\n\n[tool]\ncargo-make = "0.37.9"\ncargo-tarpaulin = "0.27.3"' + ), + ): + tool_versions = _get_required_tool_versions() + assert tool_versions == { + "cargo-make": "0.37.9", + "cargo-tarpaulin": "0.27.3", + } + + # Test when the workspace toolchain file does not exist + with patch("builtins.open", side_effect=FileNotFoundError): + tool_versions = _get_required_tool_versions() + assert tool_versions == {} + + @patch("edk2toolext.rust_environment.RunCmd") + def test_verify_workspace_rust_toolchain_is_installed( + self, mock_run_cmd: MagicMock + ): + mock_run_cmd.side_effect = self._mock_run_cmd + + # Test when the toolchain is not found + toolchain_info = verify_workspace_rust_toolchain_is_installed() + assert not toolchain_info.error + assert toolchain_info.toolchain is None + + # Test when the toolchain is found and stable + with patch( + "builtins.open", mock_open(read_data='[toolchain]\nchannel = "stable"') + ): + toolchain_info = verify_workspace_rust_toolchain_is_installed() + assert not toolchain_info.error + assert toolchain_info.toolchain == "stable" + + # Test when the toolchain file is not found + # Note: A file not found is not considered an error + with patch("builtins.open", side_effect=FileNotFoundError): + toolchain_info = verify_workspace_rust_toolchain_is_installed() + assert not toolchain_info.error + assert toolchain_info.toolchain is None + + @patch("edk2toolext.rust_environment.logging") + @patch("edk2toolext.rust_environment.RunCmd") + def test_run_success(self, mock_run_cmd, mock_logging): + mock_run_cmd.return_value = 0 + custom_tool_checks = { + "custom_tool": RustToolInfo( + presence_cmd=("custom_tool",), + install_help="Install custom_tool", + required_version=None, + regex=None, + ) + } + custom_tool_filters = [ + CustomToolFilter( + filter_fn=lambda tool, output: "error" in output, + error_msg="Custom tool error", + error_only=False, + ) + ] + result = run(custom_tool_checks, custom_tool_filters) + self.assertEqual(result, 0) + mock_logging.error.assert_not_called() + + @patch("edk2toolext.rust_environment.logging") + @patch("edk2toolext.rust_environment.RunCmd") + def test_run_missing_tool(self, mock_run_cmd, mock_logging): + mock_run_cmd.return_value = 1 + custom_tool_checks = { + "custom_tool": RustToolInfo( + presence_cmd=("custom_tool",), + install_help="Install custom_tool", + required_version=None, + regex=None, + ) + } + custom_tool_filters = [] + result = run(custom_tool_checks, custom_tool_filters) + self.assertGreaterEqual(result, 1) + mock_logging.error.assert_called_with( + "Rust Environment Failure: custom_tool is not installed or not on the system path.\n\n" + "Instructions:\nInstall custom_tool\n\n" + 'Ensure "custom_tool" can successfully be run from a terminal before trying again.' + ) + + +if __name__ == "__main__": + unittest.main()