Fix coverage for crates with mixed sources (#4106)

An expansion of https://github.com/bazelbuild/rules_rust/pull/4079 with
some added testing.

closes https://github.com/bazelbuild/rules_rust/pull/4079
diff --git a/.bazelci/presubmit.yml b/.bazelci/presubmit.yml
index 7b5b0bc..0902d01 100644
--- a/.bazelci/presubmit.yml
+++ b/.bazelci/presubmit.yml
@@ -71,6 +71,18 @@
       ; 1>&2 head -50 bazel-out/_coverage/_coverage_report.dat \
       ; exit 1 \
       ; }
+  # Regression check: mixed in-tree/generated-source crates must produce
+  # real coverage, not just the zero-LF baseline record.
+  - |
+    awk '/^SF:test\/generated_inputs\/lib\.rs$/,/^end_of_record$/' \
+        bazel-out/_coverage/_coverage_report.dat \
+      | grep -qE '^LF:[1-9]' \
+    || { 1>&2 echo "No coverage for mixed-source crate (test/generated_inputs/lib.rs)" \
+      ; 1>&2 echo "Coverage was silently dropped for crates with generated sources." \
+      ; 1>&2 awk '/^SF:test\/generated_inputs\/lib\.rs$/,/^end_of_record$/' \
+          bazel-out/_coverage/_coverage_report.dat \
+      ; exit 1 \
+      ; }
 split_coverage_postprocessing_shell_commands: &split_coverage_postprocessing_shell_commands
   - echo "coverage --experimental_fetch_all_coverage_outputs" >> user.bazelrc
   - echo "coverage --experimental_split_coverage_postprocessing" >> user.bazelrc
diff --git a/rust/private/rustc.bzl b/rust/private/rustc.bzl
index c278b5f..69e0d05 100644
--- a/rust/private/rustc.bzl
+++ b/rust/private/rustc.bzl
@@ -897,6 +897,14 @@
                 return True
     return False
 
+def _args_map_bin_dir(file):
+    """Extract `bazel-out/<config>/bin` from a File whose path lives in the configuration's bin directory.
+
+    Evaluated at action-execution time so that Bazel's path mapping (`--experimental_output_paths=strip`) can
+    rewrite the `<config>` segment to `cfg` before we slice it off.
+    """
+    return "/".join(file.path.split("/", 3)[:3])
+
 def construct_arguments(
         *,
         ctx,
@@ -1326,6 +1334,25 @@
         # https://doc.rust-lang.org/rustc/instrument-coverage.html
         rustc_flags.add("--codegen=instrument-coverage")
 
+        # Crates with generated sources are compiled from the output tree
+        # (see `transform_sources`), so the coverage mapping records their
+        # files with a `bazel-out/<config>/bin/` prefix. Bazel's lcov
+        # merger silently drops all coverage for such crates, so we remap
+        # the prefix away. The prefix is derived from the crate's own
+        # output File (rather than `ctx.bin_dir`) so that Bazel's path
+        # mapping (`--experimental_output_paths=strip`) can rewrite the
+        # `<config>` segment to `cfg` before the value reaches rustc —
+        # `ctx.bin_dir` is a `root`, not a `File`, and is not subject to
+        # path-mapping rewriting. Skipped for rustdoc (which passes
+        # `remap_path_prefix=None`), since rustdoc only supports
+        # `--remap-path-prefix` behind `-Zunstable-options`.
+        if remap_path_prefix != None:
+            rustc_flags.add_all(
+                [crate_info.output],
+                format_each = "--remap-path-prefix=%s/=",
+                map_each = _args_map_bin_dir,
+            )
+
     if toolchain._experimental_link_std_dylib:
         rustc_flags.add("--codegen=prefer-dynamic")
 
diff --git a/test/unit/common.bzl b/test/unit/common.bzl
index cedf39e..26509c3 100644
--- a/test/unit/common.bzl
+++ b/test/unit/common.bzl
@@ -149,3 +149,25 @@
             real_value = action.env[key],
         ),
     )
+
+def get_bin_dir_from_action(action):
+    """Extract `bazel-out/<config>/bin` from an action.
+
+    Inspects argv first so the path-mapping (`--experimental_output_paths=strip`)
+    form `bazel-out/cfg/bin` is returned when active, otherwise falls back to
+    the first output's `.dirname` (handles `-ST-<hash>` config-transition
+    suffixes).
+
+    Args:
+        action: The action to extract the bin directory from.
+
+    Returns:
+        The bin directory path as a string.
+    """
+    for arg in action.argv:
+        if arg.startswith("bazel-out/cfg/bin/"):
+            return "bazel-out/cfg/bin"
+    bin_dir = action.outputs.to_list()[0].dirname
+    if "/bin/" in bin_dir:
+        bin_dir = bin_dir.split("/bin/")[0] + "/bin"
+    return bin_dir
diff --git a/test/unit/native_deps/native_deps_test.bzl b/test/unit/native_deps/native_deps_test.bzl
index dd9858c..2042320 100644
--- a/test/unit/native_deps/native_deps_test.bzl
+++ b/test/unit/native_deps/native_deps_test.bzl
@@ -11,35 +11,12 @@
     "assert_argv_contains_prefix_not",
     "assert_argv_contains_prefix_suffix",
     "assert_list_contains_adjacent_elements",
+    "get_bin_dir_from_action",
 )
 
 def _get_toolchain(ctx):
     return ctx.attr._toolchain[platform_common.ToolchainInfo]
 
-def _get_bin_dir_from_action(action):
-    """Extract the bin directory from an action's outputs.
-
-    This handles config transitions that add suffixes like -ST-<hash>, as
-    well as Bazel path mapping (`--experimental_output_paths=strip` plus a
-    `supports-path-mapping` requirement on the action), which rewrites
-    argv entries to live under `bazel-out/cfg/bin/...` even though the
-    File's `.dirname` still returns the un-mapped, configuration-specific
-    path.
-
-    Args:
-        action: The action to extract the bin directory from.
-
-    Returns:
-        The bin directory path as a string.
-    """
-    for arg in action.argv:
-        if arg.startswith("bazel-out/cfg/bin/"):
-            return "bazel-out/cfg/bin"
-    bin_dir = action.outputs.to_list()[0].dirname
-    if "/bin/" in bin_dir:
-        bin_dir = bin_dir.split("/bin/")[0] + "/bin"
-    return bin_dir
-
 def _get_darwin_component(arg):
     """Extract darwin component from a path.
 
@@ -213,7 +190,7 @@
 
     toolchain = _get_toolchain(ctx)
     link_args = _extract_linker_args(action.argv)
-    bin_dir = _get_bin_dir_from_action(action)
+    bin_dir = get_bin_dir_from_action(action)
 
     # Validate bin_dir structure (ignoring ST-{hash} suffix from config transitions)
     _assert_bin_dir_structure(env, ctx, bin_dir, toolchain)
@@ -296,7 +273,7 @@
     action = tut.actions[0]
 
     linker_args = _extract_linker_args(action.argv)
-    bin_dir = _get_bin_dir_from_action(action)
+    bin_dir = get_bin_dir_from_action(action)
 
     # Validate bin_dir structure (ignoring ST-{hash} suffix from config transitions)
     _assert_bin_dir_structure(env, ctx, bin_dir, toolchain)
diff --git a/test/unit/remap_path_prefix/remap_path_prefix_test.bzl b/test/unit/remap_path_prefix/remap_path_prefix_test.bzl
index a5e4d67..0dbdce9 100644
--- a/test/unit/remap_path_prefix/remap_path_prefix_test.bzl
+++ b/test/unit/remap_path_prefix/remap_path_prefix_test.bzl
@@ -6,7 +6,10 @@
 load(
     "//test/unit:common.bzl",
     "assert_action_mnemonic",
+    "assert_argv_contains",
+    "assert_argv_contains_not",
     "assert_list_contains_adjacent_elements",
+    "get_bin_dir_from_action",
 )
 
 def _remap_path_prefix_test_impl(ctx):
@@ -42,6 +45,68 @@
 
 _subst_flags_test = analysistest.make(_subst_flags_test_impl)
 
+def _coverage_remap_path_prefix_test_impl(ctx):
+    """Verify a single `--remap-path-prefix` flag covers the bin directory.
+
+    The flag is derived from a `File` via `map_each` so Bazel's path
+    mapping (`--experimental_output_paths=strip`) rewrites the
+    `<config>` segment to `cfg` before it reaches rustc — one flag
+    works for both the path-mapped and un-mapped forms.
+    """
+    env = analysistest.begin(ctx)
+    target = analysistest.target_under_test(env)
+
+    action = target.actions[0]
+    assert_action_mnemonic(env, action, "Rustc")
+
+    # If the host toolchain does not support coverage (no `llvm_cov` or
+    # missing `profiler_builtins`) the instrument-coverage flag will be
+    # absent and the remap flag is not expected either.
+    if "--codegen=instrument-coverage" not in action.argv:
+        return analysistest.end(env)
+
+    bin_dir = get_bin_dir_from_action(action)
+    assert_argv_contains(env, action, "--remap-path-prefix={}/=".format(bin_dir))
+
+    return analysistest.end(env)
+
+_coverage_remap_path_prefix_test = analysistest.make(
+    _coverage_remap_path_prefix_test_impl,
+    config_settings = {
+        "//command_line_option:collect_code_coverage": True,
+    },
+)
+
+_coverage_remap_path_prefix_path_mapping_test = analysistest.make(
+    _coverage_remap_path_prefix_test_impl,
+    config_settings = {
+        "//command_line_option:collect_code_coverage": True,
+        "//command_line_option:experimental_output_paths": "strip",
+    },
+)
+
+def _no_coverage_remap_path_prefix_test_impl(ctx):
+    """Verify the coverage-specific remap flag is absent without coverage."""
+    env = analysistest.begin(ctx)
+    target = analysistest.target_under_test(env)
+
+    action = target.actions[0]
+    assert_action_mnemonic(env, action, "Rustc")
+
+    assert_argv_contains_not(env, action, "--codegen=instrument-coverage")
+
+    bin_dir = get_bin_dir_from_action(action)
+    assert_argv_contains_not(env, action, "--remap-path-prefix={}/=".format(bin_dir))
+
+    return analysistest.end(env)
+
+_no_coverage_remap_path_prefix_test = analysistest.make(
+    _no_coverage_remap_path_prefix_test_impl,
+    config_settings = {
+        "//command_line_option:collect_code_coverage": False,
+    },
+)
+
 def remap_path_prefix_test_suite(name):
     """Entry-point macro called from the BUILD file.
 
@@ -78,6 +143,38 @@
         edition = "2021",
     )
 
+    # A library whose source set contains a generated file — this is the
+    # case `transform_sources` is designed for, and the one that
+    # previously lost all coverage data because the records pointed at
+    # `bazel-out/.../bin/...`.
+    write_file(
+        name = "mixed_inline_src",
+        out = "mixed_inline.rs",
+        content = [
+            "pub fn inline() {}",
+            "",
+        ],
+    )
+
+    write_file(
+        name = "mixed_generated_src",
+        out = "mixed_generated.rs",
+        content = [
+            "pub fn generated() {}",
+            "",
+        ],
+    )
+
+    rust_library(
+        name = "remap_mixed_lib",
+        srcs = [
+            ":mixed_inline.rs",
+            ":mixed_generated_src",
+        ],
+        crate_root = ":mixed_inline.rs",
+        edition = "2021",
+    )
+
     _remap_path_prefix_test(
         name = "remap_path_prefix_lib_test",
         target_under_test = ":remap_lib",
@@ -98,11 +195,29 @@
         target_under_test = ":remap_bin",
     )
 
+    _coverage_remap_path_prefix_test(
+        name = "coverage_remap_path_prefix_mixed_lib_test",
+        target_under_test = ":remap_mixed_lib",
+    )
+
+    _coverage_remap_path_prefix_path_mapping_test(
+        name = "coverage_remap_path_prefix_path_mapping_mixed_lib_test",
+        target_under_test = ":remap_mixed_lib",
+    )
+
+    _no_coverage_remap_path_prefix_test(
+        name = "no_coverage_remap_path_prefix_lib_test",
+        target_under_test = ":remap_lib",
+    )
+
     tests = [
         ":remap_path_prefix_lib_test",
         ":remap_path_prefix_bin_test",
         ":subst_flags_lib_test",
         ":subst_flags_bin_test",
+        ":coverage_remap_path_prefix_mixed_lib_test",
+        ":coverage_remap_path_prefix_path_mapping_mixed_lib_test",
+        ":no_coverage_remap_path_prefix_lib_test",
     ]
 
     native.test_suite(