fix: parse Gradle rich version constraints (#1639)
diff --git a/private/extensions/maven.bzl b/private/extensions/maven.bzl
index a5135a7..75f3643 100644
--- a/private/extensions/maven.bzl
+++ b/private/extensions/maven.bzl
@@ -265,6 +265,18 @@
                 amended = True
     return amended
 
+def _parse_gradle_version(version):
+    if type(version) == "string":
+        return version, False
+    if type(version) == "dict":
+        for key in ("reject", "rejectAll", "rejects"):
+            if key in version:
+                fail("Unsupported Gradle version rejection '%s' in %s" % (key, version))
+        for key in ("strictly", "require", "prefer"):
+            if key in version:
+                return version[key], key == "strictly"
+    fail("Unsupported Gradle version constraint:", version)
+
 def process_gradle_versions_file(parsed, bom_modules):
     artifacts = []
     boms = []
@@ -272,6 +284,7 @@
     for alias, value in parsed.get("libraries", {}).items():
         # Handle different dependency declaration formats
         coords = None
+        force_version = False
 
         # Case 1: Simple string notation: "group:artifact:version"
         if type(value) == "string":
@@ -290,23 +303,17 @@
                 fail("Library '%s' must have either 'module' or both 'group' and 'name' keys" % alias)
 
             # Handle version (applies to both module and group+name formats)
-            if "version.ref" in value.keys():
-                version = parsed.get("versions", {}).get(value["version.ref"])
-                if not version:
-                    fail("Unable to resolve version.ref %s" % value["version.ref"])
+            if "version.ref" in value or "version" in value:
+                raw_version = value.get("version", "")
+                version_ref = value.get("version.ref")
+                if type(raw_version) == "dict":
+                    version_ref = raw_version.get("ref", version_ref)
+                if version_ref:
+                    raw_version = parsed.get("versions", {}).get(version_ref)
+                    if not raw_version:
+                        fail("Unable to resolve version.ref %s" % version_ref)
+                version, force_version = _parse_gradle_version(raw_version)
                 coords += ":%s" % version
-            elif "version" in value.keys():
-                raw_version = value["version"]
-                if type(raw_version) == "string":
-                    coords += ":%s" % value["version"]
-                elif type(raw_version) == "dict":
-                    version_ref = raw_version.get("ref", None)
-                    if not version_ref:
-                        fail("Unable to find version ref in", value)
-                    version = parsed.get("versions", {}).get(version_ref)
-                    if not version:
-                        fail("Unable to resolve version ref", value)
-                    coords += ":%s" % version
 
             # Handle packaging (e.g., "aar" for Android libraries)
             # Note: Gradle uses "package" but we'll check common variants
@@ -322,11 +329,13 @@
 
         is_bom = type(value) == "dict" and value.get("is_bom", "false").lower() == "true"
 
-        if is_bom or module_id in bom_modules:
-            boms.append(unpack_coordinates(coords))
-        else:
-            artifact = unpack_coordinates(coords)
+        artifact = unpack_coordinates(coords)
+        if force_version:
+            artifact = struct(**(remove_empty_fields(artifact) | {"force_version": True}))
 
+        if is_bom or module_id in bom_modules:
+            boms.append(artifact)
+        else:
             if type(value) == "dict":
                 artifact_dict = {
                     "group": artifact.group,
@@ -339,8 +348,8 @@
                     artifact_dict["classifier"] = value["classifier"]
                 if "exclusions" in value.keys():
                     artifact_dict["exclusions"] = _add_exclusions(json.decode(value["exclusions"].replace("'", '"')))
-                if "force_version" in value.keys():
-                    artifact_dict["force_version"] = (value["force_version"].lower() == "true")
+                if force_version or "force_version" in value:
+                    artifact_dict["force_version"] = force_version or value["force_version"].lower() == "true"
                 artifact = struct(**artifact_dict)
 
             artifacts.append(artifact)
diff --git a/tests/unit/version_catalogs_test.bzl b/tests/unit/version_catalogs_test.bzl
index 0aa3829..5b570c6 100644
--- a/tests/unit/version_catalogs_test.bzl
+++ b/tests/unit/version_catalogs_test.bzl
@@ -1,4 +1,5 @@
-load("@bazel_skylib//lib:unittest.bzl", "asserts", "unittest")
+load("@bazel_skylib//lib:partial.bzl", "partial")
+load("@bazel_skylib//lib:unittest.bzl", "analysistest", "asserts", "unittest")
 load("@toml.bzl", "toml")
 load("//private/extensions:maven.bzl", "process_gradle_versions_file")
 
@@ -335,7 +336,118 @@
 
 is_bom_field_test = unittest.make(_is_bom_field_impl)
 
+def _rich_versions_select_version_and_force_only_strictly_impl(ctx):
+    env = unittest.begin(ctx)
+
+    for constraint, expected_version, expected_force in [
+        ('strictly = "6.1.2"', "6.1.2", True),
+        ('require = "6.1.2"', "6.1.2", False),
+        ('prefer = "6.1.2"', "6.1.2", False),
+        ('strictly = "6.1.2", require = "6.2.6", prefer = "6.0.0"', "6.1.2", True),
+        ('require = "6.2.6", prefer = "6.0.0"', "6.2.6", False),
+    ]:
+        for declaration in [
+            'module = "io.kotest:kotest-extensions-mockserver-jvm", version = { %s }' % constraint,
+            'group = "io.kotest", name = "kotest-extensions-mockserver-jvm", version.ref = "kotest"',
+        ]:
+            parsed = toml.decode("""\
+[versions]
+kotest = { %s }
+[libraries]
+kotest-extension = { %s }
+""" % (constraint, declaration))
+            artifacts, boms = process_gradle_versions_file(parsed, [])
+
+            asserts.equals(env, [], boms)
+            asserts.equals(env, 1, len(artifacts))
+            asserts.equals(env, "io.kotest", artifacts[0].group)
+            asserts.equals(env, "kotest-extensions-mockserver-jvm", artifacts[0].artifact)
+            asserts.equals(env, expected_version, artifacts[0].version)
+            asserts.equals(env, expected_force, getattr(artifacts[0], "force_version", False))
+
+    return unittest.end(env)
+
+rich_versions_select_version_and_force_only_strictly_test = unittest.make(_rich_versions_select_version_and_force_only_strictly_impl)
+
+def _strictly_preserves_artifact_metadata_impl(ctx):
+    env = unittest.begin(ctx)
+
+    parsed = toml.decode("""\
+[libraries]
+example = { module = "org.example:example", version.strictly = "1.0", force_version = "false", classifier = "tests", packaging = "aar", exclusions = "['org.example:excluded']" }
+""")
+    artifacts, _ = process_gradle_versions_file(parsed, [])
+
+    asserts.equals(env, "1.0", artifacts[0].version)
+    asserts.true(env, artifacts[0].force_version)
+    asserts.equals(env, "tests", artifacts[0].classifier)
+    asserts.equals(env, "aar", artifacts[0].packaging)
+    asserts.equals(env, [{"group": "org.example", "artifact": "excluded"}], artifacts[0].exclusions)
+
+    return unittest.end(env)
+
+strictly_preserves_artifact_metadata_test = unittest.make(_strictly_preserves_artifact_metadata_impl)
+
+def _strictly_forces_bom_versions_impl(ctx):
+    env = unittest.begin(ctx)
+
+    parsed = toml.decode("""\
+[versions]
+example = { strictly = "1.0" }
+[libraries]
+example-bom = { module = "org.example:example-bom", version.ref = "example", is_bom = "true" }
+another-bom = { module = "org.example:another-bom", version = { strictly = "2.0" } }
+""")
+    artifacts, boms = process_gradle_versions_file(parsed, ["org.example:another-bom"])
+
+    asserts.equals(env, [], artifacts)
+    asserts.equals(env, ["1.0", "2.0"], [bom.version for bom in boms])
+    asserts.equals(env, [True, True], [getattr(bom, "force_version", False) for bom in boms])
+
+    return unittest.end(env)
+
+strictly_forces_bom_versions_test = unittest.make(_strictly_forces_bom_versions_impl)
+
+def _version_rejection_target_impl(ctx):
+    process_gradle_versions_file(toml.decode(ctx.attr.catalog), [])
+    return []
+
+version_rejection_target = rule(
+    implementation = _version_rejection_target_impl,
+    attrs = {"catalog": attr.string(mandatory = True)},
+)
+
+def _version_rejections_fail_clearly_impl(ctx):
+    env = analysistest.begin(ctx)
+    asserts.expect_failure(env, "Unsupported Gradle version rejection")
+    return analysistest.end(env)
+
+version_rejections_fail_clearly_test = analysistest.make(
+    _version_rejections_fail_clearly_impl,
+    expect_failure = True,
+)
+
 def version_catalogs_test_suite():
+    rejection_tests = []
+    for key, value in [("reject", '["1.1"]'), ("rejectAll", "true"), ("rejects", '["1.1"]')]:
+        for referenced in [False, True]:
+            name = "version_%s_%s_target" % (key, "referenced" if referenced else "inline")
+            version_rejection_target(
+                name = name,
+                catalog = """\
+[versions]
+example = { strictly = "1.0", %s = %s }
+[libraries]
+example = { module = "org.example:example", %s }
+""" % (key, value, 'version.ref = "example"' if referenced else 'version = { strictly = "1.0", %s = %s }' % (key, value)),
+                tags = ["manual"],
+            )
+            rejection_tests.append(partial.make(
+                version_rejections_fail_clearly_test,
+                target_under_test = ":" + name,
+                size = "small",
+            ))
+
     unittest.suite(
         "version_catalogs_tests",
         simple_string_notation_test,
@@ -351,4 +463,8 @@
         multiple_libraries_test,
         extra_fields_test,
         is_bom_field_test,
+        partial.make(rich_versions_select_version_and_force_only_strictly_test, size = "small"),
+        partial.make(strictly_preserves_artifact_metadata_test, size = "small"),
+        partial.make(strictly_forces_bom_versions_test, size = "small"),
+        *rejection_tests
     )