Provide a mechanism to list all resolved direct deps for a workspace (#1510)

You can do this by running `bazel run @maven//:direct_deps`
diff --git a/private/lib/coordinates.bzl b/private/lib/coordinates.bzl
index afc4ed1..9bbb40d 100644
--- a/private/lib/coordinates.bzl
+++ b/private/lib/coordinates.bzl
@@ -37,6 +37,10 @@
             classifier = coords.get("classifier", None),
         )
 
+    if type(coords) != "string":
+        # Already a struct, return as-is
+        return coords
+
     pieces = coords.split(":")
     if len(pieces) < 2:
         fail("Could not parse maven coordinate: %s" % coords)
@@ -101,6 +105,34 @@
 
     fail("Could not parse maven coordinate: %s" % coords)
 
+def to_key(coords):
+    """Converts coordinates to a versionless key for lookups.
+
+    The key format is `group:artifact[:packaging[:classifier]]` where packaging
+    and classifier are only included if they differ from "jar".
+
+    Args:
+        coords: Either a string in Gradle External format, a dict with "group",
+               "artifact", and optional "packaging"/"classifier" keys, or a
+               struct from unpack_coordinates.
+
+    Returns:
+        A string key suitable for artifact lookups.
+    """
+    unpacked = unpack_coordinates(coords)
+
+    key = "%s:%s" % (unpacked.group, unpacked.artifact)
+
+    packaging = getattr(unpacked, "packaging", None) or "jar"
+    classifier = getattr(unpacked, "classifier", None) or "jar"
+
+    if classifier != "jar":
+        key += ":%s:%s" % (packaging, classifier)
+    elif packaging != "jar":
+        key += ":%s" % packaging
+
+    return key
+
 def to_external_form(coords):
     """Formats `coords` as a string suitable for use by tools such as Gradle.
 
diff --git a/private/rules/coursier.bzl b/private/rules/coursier.bzl
index 22b3a6a..84b83c8 100644
--- a/private/rules/coursier.bzl
+++ b/private/rules/coursier.bzl
@@ -30,6 +30,7 @@
     "COURSIER_CLI_GITHUB_ASSET_URL",
     "COURSIER_CLI_SHA256",
 )
+load("//private/lib:coordinates.bzl", "to_key", "unpack_coordinates")
 load("//private/lib:urls.bzl", "remove_auth_from_url")
 load("//private/rules:v1_lock_file.bzl", "v1_lock_file")
 load("//private/rules:v2_lock_file.bzl", "v2_lock_file")
@@ -83,6 +84,14 @@
 )
 """
 
+_BUILD_DIRECT_DEPS = """
+sh_binary(
+    name = "direct_deps",
+    srcs = ["direct_deps.sh"],
+    visibility = ["//visibility:public"],
+)
+"""
+
 _BUILD_OUTDATED = """
 sh_binary(
     name = "outdated",
@@ -422,6 +431,71 @@
         executable = True,
     )
 
+def get_direct_dependencies(all_artifacts, input_artifacts):
+    """Returns the resolved coordinates for the given input (direct) artifacts.
+
+    Args:
+        all_artifacts: The list of all resolved artifacts from importer.get_artifacts(),
+                      each with a "coordinates" field in Gradle External format.
+        input_artifacts: A list of dicts with "group", "artifact", and optionally
+                        "classifier" and "packaging" keys representing the direct
+                        dependencies the user requested.
+
+    Returns:
+        A sorted list of resolved coordinates in Gradle External format.
+    """
+
+    # Build a lookup from versionless key to full coordinates.
+    # We store both the full key and a simplified group:artifact key
+    # to handle cases where input doesn't specify packaging but the
+    # resolved artifact has non-jar packaging (e.g., pom, aar).
+    resolved_lookup = {}
+    simple_lookup = {}
+    for artifact in all_artifacts:
+        coords = artifact.get("coordinates", "")
+        if coords:
+            full_key = to_key(coords)
+            resolved_lookup[full_key] = coords
+            # Also store by simple group:artifact for fallback matching
+            unpacked = unpack_coordinates(coords)
+            simple_key = "%s:%s" % (unpacked.group, unpacked.artifact)
+            # Only use simple key if no classifier (classifiers are intentional)
+            classifier = getattr(unpacked, "classifier", None)
+            if not classifier:
+                simple_lookup[simple_key] = coords
+
+    direct_deps = {}
+    for input_artifact in input_artifacts:
+        key = to_key(input_artifact)
+        resolved = resolved_lookup.get(key)
+        if not resolved:
+            # Fallback: try simple group:artifact lookup for artifacts where
+            # user didn't specify packaging but resolution found non-jar packaging
+            unpacked = unpack_coordinates(input_artifact)
+            simple_key = "%s:%s" % (unpacked.group, unpacked.artifact)
+            resolved = simple_lookup.get(simple_key)
+        if resolved:
+            direct_deps[resolved] = True
+
+    return sorted(direct_deps.keys())
+
+def _add_direct_deps_files(repository_ctx, direct_deps):
+    """Creates the direct_deps.sh script file.
+
+    Args:
+        repository_ctx: The repository context.
+        direct_deps: A list of resolved coordinates in Gradle External format.
+    """
+    script_content = "#!/bin/bash\n"
+    for dep in direct_deps:
+        script_content += "echo '%s'\n" % dep
+
+    repository_ctx.file(
+        "direct_deps.sh",
+        script_content,
+        executable = True,
+    )
+
 def is_repin_required(repository_ctx):
     env_var_names = repository_ctx.os.environ.keys()
     return "RULES_JVM_EXTERNAL_REPIN" not in env_var_names and "REPIN" not in env_var_names
@@ -683,9 +757,13 @@
 
     pin_target = generate_pin_target(repository_ctx, unpinned_pin_target)
 
+    all_artifacts = importer.get_artifacts(maven_install_json_content)
+    direct_deps = get_direct_dependencies(all_artifacts, artifacts)
+    _add_direct_deps_files(repository_ctx, direct_deps)
+
     repository_ctx.file(
         "BUILD",
-        (_BUILD + _BUILD_OUTDATED).format(
+        (_BUILD + _BUILD_OUTDATED + _BUILD_DIRECT_DEPS).format(
             visibilities = ",".join(["\"%s\"" % s for s in (["//visibility:public"] if not repository_ctx.attr.strict_visibility else repository_ctx.attr.strict_visibility_value)]),
             repository_name = repository_ctx.name,
             imports = generated_imports,
@@ -1373,9 +1451,13 @@
         outdated_build_file_content = _BUILD_OUTDATED
         _add_outdated_files(repository_ctx, artifacts, boms, repositories)
 
+    all_artifacts = v2_lock_file.get_artifacts(lock_file_contents)
+    direct_deps = get_direct_dependencies(all_artifacts, artifacts)
+    _add_direct_deps_files(repository_ctx, direct_deps)
+
     repository_ctx.file(
         "BUILD",
-        (_BUILD + _BUILD_PIN + outdated_build_file_content).format(
+        (_BUILD + _BUILD_PIN + outdated_build_file_content + _BUILD_DIRECT_DEPS).format(
             visibilities = ",".join(["\"%s\"" % s for s in (["//visibility:public"] if not repository_ctx.attr.strict_visibility else repository_ctx.attr.strict_visibility_value)]),
             repository_name = repository_name,
             imports = generated_imports,
diff --git a/private/rules/v2_lock_file.bzl b/private/rules/v2_lock_file.bzl
index 5e5eaa0..a6168cc 100644
--- a/private/rules/v2_lock_file.bzl
+++ b/private/rules/v2_lock_file.bzl
@@ -11,7 +11,7 @@
 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 # See the License for the specific language governing permissions and
 
-load("//private/lib:coordinates.bzl", "to_external_form")
+load("//private/lib:coordinates.bzl", "to_external_form", "to_key")
 
 _REQUIRED_KEYS = ["artifacts", "dependencies", "repositories"]
 
@@ -85,23 +85,6 @@
 
     return coords
 
-def _to_key(unpacked):
-    coords = "%s:%s" % (unpacked["group"], unpacked["artifact"])
-
-    extension = unpacked.get("packaging", "jar")
-    if not extension:
-        extension = "jar"
-    classifier = unpacked.get("classifier", "jar")
-    if not classifier:
-        classifier = "jar"
-
-    if classifier != "jar":
-        coords += ":%s:%s" % (extension, classifier)
-    elif extension != "jar":
-        coords += ":%s" % extension
-
-    return coords
-
 def _from_key(key, spoofed_version):
     expected = "%s:%s" % (key, spoofed_version)
 
@@ -147,7 +130,7 @@
         for (classifier, shasum) in data.get("shasums", {}).items():
             root_unpacked["classifier"] = classifier
             coordinates = to_external_form(root_unpacked)
-            key = _to_key(root_unpacked)
+            key = to_key(root_unpacked)
 
             urls = []
             for (repo, artifacts_within_repo) in repositories.items():
diff --git a/tests/integration/BUILD b/tests/integration/BUILD
index 75ec593..10709ae 100644
--- a/tests/integration/BUILD
+++ b/tests/integration/BUILD
@@ -27,6 +27,7 @@
         "@testonly_testing//:com_google_auto_value_auto_value_annotations_1_6_3",
         "@testonly_testing//:com_google_auto_value_auto_value_annotations",
         "@testonly_testing//:defs",
+        "@testonly_testing//:direct_deps",
         "@testonly_testing//:outdated",
         "@testonly_testing//:pin",
     ],
diff --git a/tests/unit/coursier_test.bzl b/tests/unit/coursier_test.bzl
index 2c66e61..acbc6ec 100644
--- a/tests/unit/coursier_test.bzl
+++ b/tests/unit/coursier_test.bzl
@@ -4,6 +4,7 @@
     "//private/rules:coursier.bzl",
     "compute_dependency_inputs_signature",
     "get_coursier_cache_or_default",
+    "get_direct_dependencies",
     "get_netrc_lines_from_entries",
     infer = "infer_artifact_path_from_primary_and_repos",
 )
@@ -619,6 +620,43 @@
 
 calculate_inputs_hash_uses_excluded_artifacts_test = add_test(_calculate_inputs_hash_uses_excluded_artifacts_test)
 
+def _get_direct_dependencies_test_impl(ctx):
+    env = unittest.begin(ctx)
+
+    all_artifacts = [
+        {"coordinates": "com.google.guava:guava:31.0-jre"},
+        {"coordinates": "com.google.code.gson:gson:2.10.1"},
+        {"coordinates": "junit:junit:4.13.2"},
+        {"coordinates": "io.netty:netty-tcnative:2.0.61.Final"},
+        {"coordinates": "io.netty:netty-tcnative:2.0.61.Final:linux-x86_64"},
+        {"coordinates": "com.example:lib:1.0@aar"},
+    ]
+
+    input_artifacts = [
+        # Basic: should resolve to 31.0-jre even though 30.0-jre was requested
+        {"group": "com.google.guava", "artifact": "guava", "version": "30.0-jre"},
+        # Duplicate: same artifact requested again, should be deduplicated
+        {"group": "com.google.guava", "artifact": "guava", "version": "29.0-jre"},
+        # With classifier: should match the linux-x86_64 variant
+        {"group": "io.netty", "artifact": "netty-tcnative", "version": "2.0.60.Final", "classifier": "linux-x86_64"},
+        # With packaging: should match the aar
+        {"group": "com.example", "artifact": "lib", "version": "1.0", "packaging": "aar"},
+        # Missing: should be skipped
+        {"group": "com.example", "artifact": "not-present", "version": "1.0"},
+    ]
+
+    result = get_direct_dependencies(all_artifacts, input_artifacts)
+
+    # Should have 3 results: guava (deduplicated), netty with classifier, and aar
+    asserts.equals(env, 3, len(result))
+    asserts.true(env, "com.google.guava:guava:31.0-jre" in result)
+    asserts.true(env, "io.netty:netty-tcnative:2.0.61.Final:linux-x86_64" in result)
+    asserts.true(env, "com.example:lib:1.0@aar" in result)
+
+    return unittest.end(env)
+
+get_direct_dependencies_test = add_test(_get_direct_dependencies_test_impl)
+
 def coursier_test_suite():
     unittest.suite(
         "coursier_tests",