diff --git a/desloppify/languages/rust/commands.py b/desloppify/languages/rust/commands.py index 9e0fc6eef..235a2311f 100644 --- a/desloppify/languages/rust/commands.py +++ b/desloppify/languages/rust/commands.py @@ -51,6 +51,7 @@ parse_cargo_errors, parse_clippy_messages, run_rustdoc_result, + scope_cargo_command, ) DetectCommand = Callable[[argparse.Namespace], None] @@ -180,11 +181,15 @@ def command(args: argparse.Namespace) -> None: cmd_clippy_warning = _make_tool_detect_command( RUST_CLIPPY_LABEL, - lambda path: run_tool_result(RUST_CLIPPY_CMD, path, parse_clippy_messages), + lambda path: run_tool_result( + scope_cargo_command(RUST_CLIPPY_CMD, path), path, parse_clippy_messages + ), ) cmd_cargo_error = _make_tool_detect_command( RUST_CHECK_LABEL, - lambda path: run_tool_result(RUST_CHECK_CMD, path, parse_cargo_errors), + lambda path: run_tool_result( + scope_cargo_command(RUST_CHECK_CMD, path), path, parse_cargo_errors + ), ) cmd_rustdoc_warning = _make_tool_detect_command( RUST_RUSTDOC_LABEL, diff --git a/desloppify/languages/rust/phases.py b/desloppify/languages/rust/phases.py index 381edb77d..935371fe6 100644 --- a/desloppify/languages/rust/phases.py +++ b/desloppify/languages/rust/phases.py @@ -49,6 +49,7 @@ parse_cargo_errors, parse_clippy_messages, run_rustdoc_result, + scope_cargo_command, ) RUST_CLIPPY_LABEL = "cargo clippy" @@ -266,7 +267,9 @@ def run(path: Path, lang) -> tuple[list[dict], dict[str, int]]: def tool_phase_clippy(): return _make_rust_tool_phase( RUST_CLIPPY_LABEL, - lambda path: run_tool_result(RUST_CLIPPY_CMD, path, parse_clippy_messages), + lambda path: run_tool_result( + scope_cargo_command(RUST_CLIPPY_CMD, path), path, parse_clippy_messages + ), "clippy_warning", tier=2, ) @@ -275,7 +278,9 @@ def tool_phase_clippy(): def tool_phase_check(): return _make_rust_tool_phase( RUST_CHECK_LABEL, - lambda path: run_tool_result(RUST_CHECK_CMD, path, parse_cargo_errors), + lambda path: run_tool_result( + scope_cargo_command(RUST_CHECK_CMD, path), path, parse_cargo_errors + ), "cargo_error", tier=3, ) diff --git a/desloppify/languages/rust/support.py b/desloppify/languages/rust/support.py index fb5afc20f..056d0cf74 100644 --- a/desloppify/languages/rust/support.py +++ b/desloppify/languages/rust/support.py @@ -523,7 +523,7 @@ def _resolve_local_dependency_entry( def find_workspace_root(path: Path | str | None) -> Path: - """Return the outermost Cargo workspace root for a file/dir when present.""" + """Return the nearest Cargo workspace root without escaping nested worktrees.""" if path is None: return get_project_root() @@ -541,7 +541,7 @@ def find_workspace_root(path: Path | str | None) -> Path: continue workspace = data.get("workspace") if isinstance(workspace, dict): - workspace_root = current + return current.resolve() return workspace_root.resolve() diff --git a/desloppify/languages/rust/tests/test_scan_scope.py b/desloppify/languages/rust/tests/test_scan_scope.py new file mode 100644 index 000000000..8841406cd --- /dev/null +++ b/desloppify/languages/rust/tests/test_scan_scope.py @@ -0,0 +1,100 @@ +"""Regression coverage for nested worktrees and member-scoped Rust scans.""" + +import json +import shlex +import subprocess + +import pytest + +from desloppify.languages.rust.support import find_workspace_root +from desloppify.languages.rust.tools import ( + CARGO_ERROR_CMD, + CLIPPY_WARNING_CMD, + run_rustdoc_result, + scope_cargo_command, +) + + +def workspace_with_members(root): + root.mkdir(parents=True) + (root / "Cargo.toml").write_text('[workspace]\nmembers = ["a", "b"]\n') + for name in ("a", "b"): + package = root / name + (package / "src").mkdir(parents=True) + (package / "Cargo.toml").write_text( + f'[package]\nname = "{name}"\nversion = "0.1.0"\n' + ) + (package / "src" / "lib.rs").write_text("pub fn value() {}\n") + return root + + +def test_workspace_discovery_stops_at_nested_worktree(tmp_path): + outer = workspace_with_members(tmp_path / "outer") + worktree = workspace_with_members(outer / ".worktrees" / "cleanup") + (worktree / ".git").write_text("gitdir: ../../.git/worktrees/cleanup\n") + + assert find_workspace_root(worktree / "a" / "src") == worktree + assert find_workspace_root(worktree) == worktree + assert find_workspace_root(outer / "a" / "src") == outer + + +@pytest.mark.parametrize("command", [CARGO_ERROR_CMD, CLIPPY_WARNING_CMD]) +def test_member_commands_preserve_checks_without_selecting_siblings(tmp_path, command): + workspace = workspace_with_members(tmp_path / "workspace with spaces") + scoped = scope_cargo_command(command, workspace / "a" / "src") + args = shlex.split(scoped) + + assert "--workspace" not in args + assert args[args.index("--manifest-path") + 1] == str( + workspace / "a" / "Cargo.toml" + ) + assert "--all-targets" in args + assert "--all-features" in args + assert "--message-format=json" in args + assert scope_cargo_command(command, workspace) == command + + +@pytest.mark.parametrize("member_scan", [False, True]) +def test_rustdoc_scopes_members_and_accepts_metadata_stderr(tmp_path, member_scan): + workspace = workspace_with_members(tmp_path / "workspace") + metadata = { + "workspace_members": ["a-id", "b-id"], + "packages": [ + { + "id": f"{name}-id", + "name": name, + "manifest_path": str(workspace / name / "Cargo.toml"), + "targets": [{"kind": ["lib"], "crate_types": ["lib"]}], + } + for name in ("a", "b") + ], + } + commands = [] + + def runner(args, **kwargs): + assert kwargs["cwd"] == str(workspace) + command = args[2] if args[:2] == ["/bin/sh", "-lc"] else " ".join(args) + commands.append(command) + if command.startswith("cargo metadata"): + return subprocess.CompletedProcess( + args, 0, json.dumps(metadata), "warning: optional workspace metadata\n" + ) + return subprocess.CompletedProcess(args, 0, "", "") + + scan_path = workspace / "a" / "src" if member_scan else workspace + result = run_rustdoc_result(scan_path, run_subprocess=runner) + + assert result.status == "empty" + assert any("--package a " in command for command in commands) + assert any("--package b " in command for command in commands) is not member_scan + + +def test_rustdoc_metadata_failure_is_not_hidden(tmp_path): + workspace = workspace_with_members(tmp_path / "workspace") + + def runner(args, **kwargs): + return subprocess.CompletedProcess(args, 1, "", "manifest could not be loaded") + + result = run_rustdoc_result(workspace, run_subprocess=runner) + assert result.status == "error" + assert "manifest could not be loaded" in result.message diff --git a/desloppify/languages/rust/tools.py b/desloppify/languages/rust/tools.py index 3bbeac5cb..f1be776d1 100644 --- a/desloppify/languages/rust/tools.py +++ b/desloppify/languages/rust/tools.py @@ -16,7 +16,7 @@ resolve_command_argv, run_tool_result, ) -from desloppify.languages.rust.support import find_workspace_root +from desloppify.languages.rust.support import find_manifest_dir, find_workspace_root CLIPPY_WARNING_CMD = ( "cargo clippy --workspace --all-targets --all-features --message-format=json " @@ -594,6 +594,15 @@ def build_rustdoc_warning_cmd(package: str) -> str: return RUSTDOC_WARNING_CMD.format(package=shlex.quote(package)) +def scope_cargo_command(command: str, scan_path: Path) -> str: + """Select the enclosing member for a member scan, not every workspace crate.""" + manifest_dir = find_manifest_dir(scan_path) + if manifest_dir is None or manifest_dir.resolve() == find_workspace_root(scan_path): + return command + manifest = shlex.quote(str((manifest_dir / "Cargo.toml").resolve())) + return command.replace("--workspace", f"--manifest-path {manifest}", 1) + + def _entry_file_exists(entry: dict[str, Any], workspace_root: Path) -> bool: file_name = entry.get("file") if not isinstance(file_name, str) or not file_name.strip(): @@ -612,12 +621,25 @@ def _filter_existing_rustdoc_entries( return [entry for entry in entries if _entry_file_exists(entry, workspace_root)] -def _extract_workspace_rustdoc_packages(payload: dict[str, Any]) -> list[str]: +def _extract_workspace_rustdoc_packages( + payload: dict[str, Any], scan_path: Path | None = None +) -> list[str]: workspace_members = set(payload.get("workspace_members") or []) packages: list[str] = [] for package in payload.get("packages") or []: if not isinstance(package, dict) or package.get("id") not in workspace_members: continue + if scan_path is not None: + manifest_path = package.get("manifest_path") + if not isinstance(manifest_path, str): + continue + package_root = Path(manifest_path).resolve().parent + scan_root = scan_path.resolve() + if not ( + package_root.is_relative_to(scan_root) + or scan_root.is_relative_to(package_root) + ): + continue name = package.get("name") if not isinstance(name, str) or not name.strip(): continue @@ -690,7 +712,7 @@ def _run_cargo_metadata( [], ) try: - data = json.loads(output) + data = json.loads(result.stdout or "") except json.JSONDecodeError as exc: return ( ToolRunResult( @@ -713,7 +735,8 @@ def _run_cargo_metadata( ), [], ) - return None, _extract_workspace_rustdoc_packages(data) + scoped_path = scan_path if scan_path.resolve() != workspace_root else None + return None, _extract_workspace_rustdoc_packages(data, scoped_path) def run_rustdoc_result(