""" Tests for rust-analyzer detection logic. These tests describe the expected behavior of RustAnalyzer._ensure_rust_analyzer_installed(): 1. Rustup should be checked FIRST (avoids picking up incorrect PATH aliases) 2. Common installation locations (Homebrew, cargo, Scoop) should be checked as fallback 3. System PATH should be checked last (can pick up incompatible versions) 4. Error messages should list all searched locations 5. Windows-specific paths should be checked on Windows WHY these tests matter: - Users install rust-analyzer via Homebrew, cargo, Scoop, or system packages - not just rustup - macOS Homebrew installs to /opt/homebrew/bin (Apple Silicon) or /usr/local/bin (Intel) - Windows users install via Scoop, Chocolatey, or cargo - Detection failing means Serena is unusable for Rust, even when rust-analyzer is correctly installed - Without these tests, the detection logic can silently break for non-rustup users """ import os import pathlib import sys from unittest.mock import MagicMock, patch import pytest # Platform detection for skipping platform-specific tests IS_WINDOWS = sys.platform == "win32" IS_UNIX = sys.platform != "win32" class TestRustAnalyzerDetection: """Unit tests for rust-analyzer binary detection logic.""" @pytest.mark.rust def test_detect_from_path_when_functional(self): """ GIVEN rustup is not available AND rust-analyzer IS in system PATH and is functional WHEN _ensure_rust_analyzer_installed is called THEN it should return the path from shutil.which WHY: PATH is checked after rustup but before common locations. This covers standalone installs via Nix, distro packages, etc. The binary is verified to be functional to avoid broken rustup proxy symlinks. Fixes: https://github.com/oraios/serena/issues/800 """ from solidlsp.language_servers.rust_analyzer import RustAnalyzer # Mock rustup to be unavailable with patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value=None): with patch.object(RustAnalyzer.DependencyProvider, "_get_rustup_version", return_value=None): # Mock PATH to have rust-analyzer with patch("shutil.which") as mock_which: mock_which.return_value = "/custom/bin/rust-analyzer" with patch("os.path.isfile", return_value=True): with patch("os.access", return_value=True): with patch.object(RustAnalyzer.DependencyProvider, "_is_rust_analyzer_functional", return_value=True): result = RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() assert result == "/custom/bin/rust-analyzer" mock_which.assert_called_with("rust-analyzer") @pytest.mark.rust def test_detect_from_nix_path_without_rustup(self): """ GIVEN rustup is NOT installed AND rust-analyzer is installed via Nix and available in PATH WHEN _ensure_rust_analyzer_installed is called THEN it should find and use the Nix-provided rust-analyzer WHY: NixOS users install rust-analyzer via home-manager or nix-env, which places it in ~/.nix-profile/bin/ on PATH. Without rustup, Serena should still detect and use this binary. Fixes: https://github.com/oraios/serena/issues/800 """ from solidlsp.language_servers.rust_analyzer import RustAnalyzer nix_path = "/home/user/.nix-profile/bin/rust-analyzer" with patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value=None): with patch.object(RustAnalyzer.DependencyProvider, "_get_rustup_version", return_value=None): with patch("shutil.which", return_value=nix_path): with patch("os.path.isfile", return_value=True): with patch("os.access", return_value=True): with patch.object(RustAnalyzer.DependencyProvider, "_is_rust_analyzer_functional", return_value=True): result = RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() assert result == nix_path @pytest.mark.rust def test_skip_broken_rustup_proxy_in_path(self): """ GIVEN rustup is NOT available via 'rustup which' AND rust-analyzer in PATH is a broken rustup proxy symlink WHEN _ensure_rust_analyzer_installed is called THEN it should skip the broken proxy and fall through to common paths WHY: On some systems, rust-analyzer in PATH is a symlink to rustup. If rustup doesn't have the component installed, running the binary fails. We verify functionality with --version before accepting a PATH result. """ from solidlsp.language_servers.rust_analyzer import RustAnalyzer # Use platform-appropriate paths and binary names home = pathlib.Path.home() if IS_WINDOWS: binary_name = "rust-analyzer.exe" broken_proxy_path = str(home / "AppData" / "Local" / "Microsoft" / "WindowsApps" / binary_name) cargo_path = str(home / ".cargo" / "bin" / binary_name) else: binary_name = "rust-analyzer" broken_proxy_path = "/usr/bin/rust-analyzer" cargo_path = os.path.expanduser("~/.cargo/bin/rust-analyzer") def mock_isfile(path): return path in [broken_proxy_path, cargo_path] def mock_access(path, mode): return path in [broken_proxy_path, cargo_path] with patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value=None): with patch.object(RustAnalyzer.DependencyProvider, "_get_rustup_version", return_value=None): with patch("shutil.which", return_value=broken_proxy_path): with patch("os.path.isfile", side_effect=mock_isfile): with patch("os.access", side_effect=mock_access): # The PATH binary is a broken rustup proxy with patch.object(RustAnalyzer.DependencyProvider, "_is_rust_analyzer_functional", return_value=False): result = RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() # Should skip broken proxy and find cargo-installed version assert result == cargo_path @pytest.mark.rust @pytest.mark.skipif(IS_WINDOWS, reason="Homebrew paths only apply to macOS/Linux") def test_detect_from_homebrew_apple_silicon_path(self): """ GIVEN rustup is NOT available AND rust-analyzer is installed via Homebrew on Apple Silicon Mac AND it is NOT in PATH (shutil.which returns None) WHEN _ensure_rust_analyzer_installed is called THEN it should find /opt/homebrew/bin/rust-analyzer WHY: Apple Silicon Macs use /opt/homebrew/bin for Homebrew. This path should be checked as fallback when rustup is unavailable. """ from solidlsp.language_servers.rust_analyzer import RustAnalyzer def mock_isfile(path): return path == "/opt/homebrew/bin/rust-analyzer" def mock_access(path, mode): return path == "/opt/homebrew/bin/rust-analyzer" with patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value=None): with patch("shutil.which", return_value=None): with patch("os.path.isfile", side_effect=mock_isfile): with patch("os.access", side_effect=mock_access): result = RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() assert result == "/opt/homebrew/bin/rust-analyzer" @pytest.mark.rust @pytest.mark.skipif(IS_WINDOWS, reason="Homebrew paths only apply to macOS/Linux") def test_detect_from_homebrew_intel_path(self): """ GIVEN rustup is NOT available AND rust-analyzer is installed via Homebrew on Intel Mac AND it is NOT in PATH WHEN _ensure_rust_analyzer_installed is called THEN it should find /usr/local/bin/rust-analyzer WHY: Intel Macs use /usr/local/bin for Homebrew. Linux systems may also install to this location. """ from solidlsp.language_servers.rust_analyzer import RustAnalyzer def mock_isfile(path): return path == "/usr/local/bin/rust-analyzer" def mock_access(path, mode): return path == "/usr/local/bin/rust-analyzer" with patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value=None): with patch("shutil.which", return_value=None): with patch("os.path.isfile", side_effect=mock_isfile): with patch("os.access", side_effect=mock_access): result = RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() assert result == "/usr/local/bin/rust-analyzer" @pytest.mark.rust @pytest.mark.skipif(IS_WINDOWS, reason="Unix cargo path - Windows has separate test") def test_detect_from_cargo_install_path(self): """ GIVEN rustup is NOT available AND rust-analyzer is installed via `cargo install rust-analyzer` AND it is NOT in PATH or Homebrew locations WHEN _ensure_rust_analyzer_installed is called THEN it should find ~/.cargo/bin/rust-analyzer WHY: `cargo install rust-analyzer` is a common installation method. The binary lands in ~/.cargo/bin which may not be in PATH. """ from solidlsp.language_servers.rust_analyzer import RustAnalyzer cargo_path = os.path.expanduser("~/.cargo/bin/rust-analyzer") def mock_isfile(path): return path == cargo_path def mock_access(path, mode): return path == cargo_path with patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value=None): with patch("shutil.which", return_value=None): with patch("os.path.isfile", side_effect=mock_isfile): with patch("os.access", side_effect=mock_access): result = RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() assert result == cargo_path @pytest.mark.rust def test_detect_from_rustup_when_available(self): """ GIVEN rustup has rust-analyzer installed WHEN _ensure_rust_analyzer_installed is called THEN it should return the rustup path WHY: Rustup is checked FIRST to avoid picking up incorrect aliases from PATH. This ensures compatibility with the toolchain. """ from solidlsp.language_servers.rust_analyzer import RustAnalyzer with patch("shutil.which", return_value=None): with patch("os.path.isfile", return_value=False): with patch.object( RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value="/home/user/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/bin/rust-analyzer", ): result = RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() assert "rustup" in result or ".rustup" in result @pytest.mark.rust @pytest.mark.skipif(IS_WINDOWS, reason="Unix error messages - Windows has separate test") def test_error_message_lists_searched_locations_when_not_found(self): """ GIVEN rust-analyzer is NOT installed anywhere AND rustup is NOT installed WHEN _ensure_rust_analyzer_installed is called THEN it should raise RuntimeError with helpful message listing searched locations WHY: Users need to know WHERE Serena looked so they can fix their installation. The old error "Neither rust-analyzer nor rustup is installed" was unhelpful. """ from solidlsp.language_servers.rust_analyzer import RustAnalyzer with patch("shutil.which", return_value=None): with patch("os.path.isfile", return_value=False): with patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value=None): with patch.object(RustAnalyzer.DependencyProvider, "_get_rustup_version", return_value=None): with pytest.raises(RuntimeError) as exc_info: RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() error_message = str(exc_info.value) # Error should list the locations that were searched (Unix paths) assert "/opt/homebrew/bin/rust-analyzer" in error_message or "Homebrew" in error_message assert "cargo" in error_message.lower() or ".cargo/bin" in error_message # Error should suggest installation methods assert "rustup" in error_message.lower() or "Rustup" in error_message @pytest.mark.rust def test_detection_priority_prefers_rustup_over_path_and_common_locations(self): """ GIVEN rust-analyzer is available via rustup AND rust-analyzer also exists in PATH and common locations WHEN _ensure_rust_analyzer_installed is called THEN it should return the rustup version WHY: Rustup provides version management and ensures compatibility. Using PATH directly can pick up incorrect aliases or incompatible versions that cause LSP crashes (as discovered in CI failures). """ from solidlsp.language_servers.rust_analyzer import RustAnalyzer rustup_path = "/home/user/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/bin/rust-analyzer" # Rustup has rust-analyzer, PATH also has it, common locations also exist with patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value=rustup_path): with patch("shutil.which", return_value="/custom/path/rust-analyzer"): with patch("os.path.isfile", return_value=True): with patch("os.access", return_value=True): result = RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() # Should use rustup version, NOT PATH or common locations assert result == rustup_path @pytest.mark.rust @pytest.mark.skipif(IS_WINDOWS, reason="Uses Unix paths - Windows has different behavior") def test_skips_nonexecutable_files(self): """ GIVEN a file exists at a detection path but is NOT executable WHEN _ensure_rust_analyzer_installed is called THEN it should skip that path and continue checking others WHY: A non-executable file (e.g., broken symlink, wrong permissions) should not be returned as a valid rust-analyzer path. """ from solidlsp.language_servers.rust_analyzer import RustAnalyzer def mock_isfile(path): # File exists at Homebrew location but not executable return path == "/opt/homebrew/bin/rust-analyzer" def mock_access(path, mode): # Homebrew location exists but not executable if path == "/opt/homebrew/bin/rust-analyzer": return False # Cargo location is executable if path == os.path.expanduser("~/.cargo/bin/rust-analyzer"): return True return False def mock_isfile_for_cargo(path): return path in ["/opt/homebrew/bin/rust-analyzer", os.path.expanduser("~/.cargo/bin/rust-analyzer")] with patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value=None): with patch("shutil.which", return_value=None): with patch("os.path.isfile", side_effect=mock_isfile_for_cargo): with patch("os.access", side_effect=mock_access): result = RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() # Should skip non-executable Homebrew and use cargo assert result == os.path.expanduser("~/.cargo/bin/rust-analyzer") @pytest.mark.rust def test_detect_from_scoop_shims_path_on_windows(self): """ GIVEN rustup is NOT available AND rust-analyzer is installed via Scoop on Windows AND it is NOT in PATH WHEN _ensure_rust_analyzer_installed is called THEN it should find ~/scoop/shims/rust-analyzer.exe WHY: Scoop is a popular package manager for Windows. The binary lands in ~/scoop/shims which may not be in PATH. """ from solidlsp.language_servers.rust_analyzer import RustAnalyzer home = pathlib.Path.home() scoop_path = str(home / "scoop" / "shims" / "rust-analyzer.exe") def mock_isfile(path): return path == scoop_path def mock_access(path, mode): return path == scoop_path with patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value=None): with patch("platform.system", return_value="Windows"): with patch("shutil.which", return_value=None): with patch("os.path.isfile", side_effect=mock_isfile): with patch("os.access", side_effect=mock_access): result = RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() assert result == scoop_path @pytest.mark.rust def test_detect_from_cargo_path_on_windows(self): """ GIVEN rustup is NOT available AND rust-analyzer is installed via cargo on Windows AND it is NOT in PATH or Scoop locations WHEN _ensure_rust_analyzer_installed is called THEN it should find ~/.cargo/bin/rust-analyzer.exe WHY: `cargo install rust-analyzer` works on Windows. The binary has .exe extension and lands in ~/.cargo/bin. """ from solidlsp.language_servers.rust_analyzer import RustAnalyzer home = pathlib.Path.home() cargo_path = str(home / ".cargo" / "bin" / "rust-analyzer.exe") def mock_isfile(path): return path == cargo_path def mock_access(path, mode): return path == cargo_path with patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value=None): with patch("platform.system", return_value="Windows"): with patch("shutil.which", return_value=None): with patch("os.path.isfile", side_effect=mock_isfile): with patch("os.access", side_effect=mock_access): result = RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() assert result == cargo_path @pytest.mark.rust def test_windows_error_message_suggests_windows_package_managers(self): """ GIVEN rust-analyzer is NOT installed anywhere on Windows AND rustup is NOT installed WHEN _ensure_rust_analyzer_installed is called THEN it should raise RuntimeError with Windows-specific installation suggestions WHY: Windows users need Windows-specific package manager suggestions (Scoop, Chocolatey) instead of Homebrew/apt. """ from solidlsp.language_servers.rust_analyzer import RustAnalyzer with patch("platform.system", return_value="Windows"): with patch("shutil.which", return_value=None): with patch("os.path.isfile", return_value=False): with patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value=None): with patch.object(RustAnalyzer.DependencyProvider, "_get_rustup_version", return_value=None): with pytest.raises(RuntimeError) as exc_info: RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() error_message = str(exc_info.value) # Error should suggest Windows-specific package managers assert "Scoop" in error_message or "scoop" in error_message assert "Chocolatey" in error_message or "choco" in error_message # Should NOT suggest Homebrew on Windows assert "Homebrew" not in error_message and "brew" not in error_message @pytest.mark.rust def test_auto_install_via_rustup_when_not_found(self): """ GIVEN rust-analyzer is NOT installed anywhere AND rustup IS installed WHEN _ensure_rust_analyzer_installed is called AND rustup component add succeeds THEN it should return the rustup-installed path WHY: Serena should auto-install rust-analyzer via rustup when possible. This matches the original behavior and enables CI to work without pre-installing. """ from solidlsp.language_servers.rust_analyzer import RustAnalyzer with patch("shutil.which", return_value=None): with patch("os.path.isfile", return_value=False): with patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup") as mock_rustup_path: # First call returns None (not installed), second returns path (after install) mock_rustup_path.side_effect = [None, "/home/user/.rustup/toolchains/stable/bin/rust-analyzer"] with patch.object(RustAnalyzer.DependencyProvider, "_get_rustup_version", return_value="1.70.0"): with patch("subprocess.run") as mock_run: mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") result = RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() assert result == "/home/user/.rustup/toolchains/stable/bin/rust-analyzer" mock_run.assert_called_once() assert mock_run.call_args[0][0] == ["rustup", "component", "add", "rust-analyzer"] @pytest.mark.rust def test_auto_install_failure_falls_through_to_common_paths(self): """ GIVEN rust-analyzer is NOT installed anywhere AND rustup IS installed WHEN _ensure_rust_analyzer_installed is called AND rustup component add FAILS THEN it should fall through to common paths and eventually raise helpful error WHY: The new resilient behavior tries all fallback options before failing. When rustup auto-install fails, we try common paths (Homebrew, cargo, etc.) as a last resort. This is more robust than failing immediately. The error message should still help users install rust-analyzer. """ from solidlsp.language_servers.rust_analyzer import RustAnalyzer with patch("shutil.which", return_value=None): with patch("os.path.isfile", return_value=False): with patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value=None): with patch.object(RustAnalyzer.DependencyProvider, "_get_rustup_version", return_value="1.70.0"): with patch("subprocess.run") as mock_run: mock_run.return_value = MagicMock( returncode=1, stdout="", stderr="error: component 'rust-analyzer' is not available" ) with pytest.raises(RuntimeError) as exc_info: RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() error_message = str(exc_info.value) # Error should provide helpful installation instructions assert "rust-analyzer is not installed" in error_message.lower() assert "rustup" in error_message.lower() # Should suggest rustup installation @pytest.mark.rust def test_auto_install_success_but_binary_not_found_falls_through(self): """ GIVEN rust-analyzer is NOT installed anywhere AND rustup IS installed WHEN _ensure_rust_analyzer_installed is called AND rustup component add SUCCEEDS BUT the binary is still not found after installation THEN it should fall through to common paths and eventually raise helpful error WHY: Even if rustup install reports success but binary isn't found, we try common paths as fallback. The final error provides installation guidance to help users resolve the issue. """ from solidlsp.language_servers.rust_analyzer import RustAnalyzer with patch("shutil.which", return_value=None): with patch("os.path.isfile", return_value=False): with patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value=None): with patch.object(RustAnalyzer.DependencyProvider, "_get_rustup_version", return_value="1.70.0"): with patch("subprocess.run") as mock_run: mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") with pytest.raises(RuntimeError) as exc_info: RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() error_message = str(exc_info.value) # Error should indicate rust-analyzer is not available and provide install instructions assert "rust-analyzer is not installed" in error_message.lower() assert "searched locations" in error_message.lower() # Should show what was checked class TestRustAnalyzerDetectionIntegration: """ Integration tests that verify detection works on the current system. These tests are skipped if rust-analyzer is not installed. """ @pytest.mark.rust def test_detection_finds_installed_rust_analyzer(self): """ GIVEN rust-analyzer is installed on this system (via any method) WHEN _ensure_rust_analyzer_installed is called THEN it should return a valid path This test verifies the detection logic works end-to-end on the current system. """ import shutil from solidlsp.language_servers.rust_analyzer import RustAnalyzer # Skip if rust-analyzer is not installed at all if not shutil.which("rust-analyzer"): # Check common locations common_paths = [ "/opt/homebrew/bin/rust-analyzer", "/usr/local/bin/rust-analyzer", os.path.expanduser("~/.cargo/bin/rust-analyzer"), ] if not any(os.path.isfile(p) and os.access(p, os.X_OK) for p in common_paths): pytest.skip("rust-analyzer not installed on this system") result = RustAnalyzer.DependencyProvider._ensure_rust_analyzer_installed() assert result is not None assert os.path.isfile(result) assert os.access(result, os.X_OK)