Skip to main content

deps_gradle/
formatter.rs

1//! Version formatting for Gradle ecosystem.
2
3use deps_core::lsp_helpers::{
4    DiagnosticMessages, DiagnosticPolicy, OsvNaming, PackageNaming, PackageRendering,
5    RequirementMatcher, RequirementResolution, SourcePolicy, compile_requirement_unless,
6};
7use deps_core::{
8    ConcreteVersion, InvalidPackageName, PackageName, VersionReq, is_safe_maven_coordinate_segment,
9};
10
11pub struct GradleFormatter;
12
13/// Unresolved Gradle variable reference (`$var`, `${var}`), or an explicit empty
14/// version-catalog entry (`[versions] foo = ""`) that a `version.ref` could point at.
15fn is_unresolved(requirement: &str) -> bool {
16    requirement.is_empty() || requirement.contains('$')
17}
18
19/// Strips Gradle's rich-version strict/preferred shorthand
20/// (`{strictlyVersion}!!{preferredVersion}`, e.g. the degenerate suffix form `1.2.3!!`
21/// or the full infix form `[1.7,1.8[!!1.7.25`), returning only the `strictlyVersion`
22/// half. Per Gradle's rich-version semantics, `strictly` is the hard constraint and
23/// `preferred` is only a soft conflict-resolution tiebreak among versions that already
24/// satisfy it — a version inside the strict range/pin satisfies the requirement
25/// regardless of whether it matches the preferred pointer, so every "does this version
26/// satisfy the requirement" comparison in [`gradle_version_matches`] and
27/// [`GradleFormatter::compile_requirement`] must operate on the `strictlyVersion`
28/// constraint alone, never the preference. `requirement` is `trim_end`ed before the
29/// marker check: `libs.versions.toml` catalog values reach here un-trimmed
30/// (`catalog::extract_version` returns the raw string verbatim), unlike the DSL
31/// parsers, which already trim their capture.
32fn strip_strict_marker(requirement: &str) -> &str {
33    requirement
34        .trim_end()
35        .split_once("!!")
36        .map_or(requirement, |(strictly, _)| strictly.trim_end())
37}
38
39/// A `-SNAPSHOT` pin (e.g. `7.0.0-SNAPSHOT`) resolves against Maven Central's snapshot
40/// repository, which `deps-maven`'s `MavenCentralRegistry` (reused for Gradle resolution)
41/// never queries — release-repo `maven-metadata.xml` never lists snapshot versions, so
42/// `available` can never contain one. Treated as always satisfied, like an unresolved
43/// variable or `latest.*`.
44fn is_snapshot(requirement: &str) -> bool {
45    requirement.ends_with("-SNAPSHOT")
46}
47
48/// Decides whether `version` satisfies a Gradle `requirement` — shared by
49/// `version_satisfies_requirement` and [`GradleFormatter::compile_requirement`]'s matcher,
50/// since Gradle has no separate "loose" vs. "precise" comparator to distinguish (mirrors
51/// `deps-maven`'s formatter, which shares the same shape for the same reason).
52///
53/// #249 review (M4, root cause of S1): this function's branch order is a separate copy from
54/// `compile_requirement`'s below — the malformed-range guard that function adds ahead of its
55/// own copy of this order has no equivalent here (this function has none; a malformed range
56/// simply falls through to `crate::range::satisfies`'s fail-closed `false`, which is correct
57/// for the "loose satisfies" question this function answers). Reordering the branches here
58/// must be checked against `compile_requirement`'s branch order and guard placement too.
59fn gradle_version_matches(version: &str, requirement: &str) -> bool {
60    // Checked on the raw string *before* stripping the `!!` marker: an unresolved
61    // Gradle variable reference can appear in the `strictlyVersion` half (e.g.
62    // `${r}!!1.7.25`), and stripping first would discard the `$` along with it,
63    // silently treating an unresolved requirement as a concrete one to compare.
64    //
65    // Deliberate, harmless asymmetry with `compile_requirement` below, which has no
66    // equivalent raw-string check: `strip_strict_marker` already returns the
67    // `strictlyVersion` half with any `$` intact, so the post-strip `is_unresolved`
68    // check a few lines down covers the same case on its own — this raw-string check
69    // is pure defense-in-depth for a caller of this loose matcher standalone. Its
70    // only observable effect is over-permissive, never under-permissive: a
71    // `preferredVersion` half containing an unresolved variable (e.g.
72    // `[1.7,1.8[!!${r}`) makes this function report "satisfied" for every version,
73    // including ones outside the strict range, whereas `compile_requirement`'s
74    // matcher (no raw-string check, and never reached with such a requirement in
75    // production since `requirement_is_unsatisfiable` gates on the raw-string
76    // `requirement_is_unresolved` first) would correctly reject an out-of-range
77    // version. Never produces a false "outdated" badge or a spurious edit, so this
78    // is not a bug — just don't "fix" the two functions back into lockstep by
79    // deleting this check without checking C3's post-strip coverage still holds.
80    if is_unresolved(requirement) {
81        return true;
82    }
83    let requirement = strip_strict_marker(requirement);
84    // Unresolved Gradle variable reference (`$var`/`${var}`), or an empty version-catalog
85    // entry (`[versions] foo = ""`) — skip comparison. Re-checked post-strip for the
86    // degenerate case where the `strictlyVersion` half itself is empty (a malformed
87    // bare `"!!"` requirement), which the raw check above does not catch since the raw
88    // string is `"!!"`, not empty.
89    if is_unresolved(requirement) {
90        return true;
91    }
92    if requirement == "latest" || requirement.starts_with("latest.") {
93        return true;
94    }
95    if is_snapshot(requirement) {
96        return true;
97    }
98    if let Some(prefix) = requirement.strip_suffix('+') {
99        return version == prefix.trim_end_matches('.') || version.starts_with(prefix);
100    }
101    // `]` is included alongside `[`/`(` because Gradle's reversed-bracket exclusive
102    // notation (`]1.2,1.5]`) is a leading delimiter in its own right, not just a
103    // trailing one.
104    if requirement.starts_with(['[', '(', ']']) {
105        return crate::range::satisfies(version, requirement);
106    }
107    version == requirement
108}
109
110/// Precise Gradle version/range matcher, compiled once per dependency by
111/// [`GradleFormatter::compile_requirement`] — a bracket-interval range is parsed once into a
112/// [`deps_maven::interval::VersionRange`] here rather than being re-parsed for every
113/// candidate version scanned. `requirement_is_unsatisfiable` already gates on
114/// `requirement_is_unresolved` before calling `compile_requirement`, so the unresolved and
115/// `latest.*` short-circuits are unreachable from that caller in practice; they stay so this
116/// matcher is correct if used standalone.
117enum GradleMatcher {
118    /// Unresolved `$var`/`${var}`, `latest`/`latest.*`, or a `-SNAPSHOT` pin.
119    AlwaysSatisfied,
120    /// A dynamic `1.0.+` prefix — the text before the trailing `+`.
121    DynamicPrefix(String),
122    /// A bracket-interval range, pre-parsed by [`crate::range::parse_range`].
123    Range(deps_maven::interval::VersionRange),
124    /// A bare exact version.
125    Exact(String),
126}
127
128impl RequirementMatcher for GradleMatcher {
129    fn matches(&self, version: &ConcreteVersion) -> Option<bool> {
130        let version = version.as_str();
131        Some(match self {
132            Self::AlwaysSatisfied => true,
133            Self::DynamicPrefix(prefix) => {
134                version == prefix.trim_end_matches('.') || version.starts_with(prefix.as_str())
135            }
136            Self::Range(range) => deps_maven::interval::contains(version, range),
137            Self::Exact(target) => version == target,
138        })
139    }
140}
141
142impl PackageNaming for GradleFormatter {
143    /// Validates a Gradle coordinate's `group:artifact` shape and character set.
144    ///
145    /// Gradle resolves through `deps_maven::MavenCentralRegistry` and shares Maven's
146    /// `groupId:artifactId` coordinate shape (see `deps-gradle/src/ecosystem.rs`), so this
147    /// mirrors [`deps_maven`]'s `MavenFormatter::validate_package_name` exactly, reusing
148    /// [`is_safe_maven_coordinate_segment`] rather than duplicating it — letting the
149    /// "Invalid package name" diagnostic surface the accurate reason instead of the
150    /// generic "Unknown package" a registry-side rejection produces (#375).
151    ///
152    /// Unlike Maven's `${property}`-specific `is_unresolved`, this uses Gradle's own
153    /// `is_unresolved`, which also short-circuits on an unresolved `$var`/`${var}`
154    /// reference or Gradle-catalog-alias placeholder — valid Gradle syntax, not a
155    /// malformed coordinate.
156    ///
157    /// # Errors
158    ///
159    /// Returns [`InvalidPackageName`] if `name` has no `:` separator, or if either the
160    /// `group` or `artifact` segment fails [`is_safe_maven_coordinate_segment`] — but
161    /// never when `name` is unresolved per `is_unresolved`, which is accepted instead.
162    fn validate_package_name(&self, name: &str) -> Result<(), InvalidPackageName> {
163        if is_unresolved(name) {
164            return Ok(());
165        }
166        let Some((group_id, artifact_id)) = name.split_once(':') else {
167            return Err(InvalidPackageName::new(
168                "coordinate must be in 'group:artifact' form",
169            ));
170        };
171        if !is_safe_maven_coordinate_segment(group_id) {
172            return Err(InvalidPackageName::new("group contains invalid characters"));
173        }
174        if !is_safe_maven_coordinate_segment(artifact_id) {
175            return Err(InvalidPackageName::new(
176                "artifact contains invalid characters",
177            ));
178        }
179        Ok(())
180    }
181}
182
183impl PackageRendering for GradleFormatter {
184    fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
185        let version = version.as_str();
186        version.to_string()
187    }
188
189    /// Preserves Gradle's rich-version strict/preferred shorthand
190    /// (`{strictlyVersion}!!{preferredVersion}`) when `current` carries it — a bare
191    /// `format_version_for_text_edit` replacement would otherwise silently downgrade
192    /// a strict constraint to a normal one.
193    ///
194    /// Only the degenerate suffix form (`1.2.3!!`, no `preferredVersion`) is
195    /// rewritten — to `{version}!!` — since the strict pin itself is what "update
196    /// version" means to bump there, and there is nothing else in the requirement to
197    /// preserve. The full infix form (`[1.7,1.8[!!1.7.25`) is left unchanged rather
198    /// than rewriting the `preferredVersion` half: since Gradle's strict constraint
199    /// always wins conflict resolution, bumping the preference to a version outside
200    /// the hand-written strict range (a likely outcome for "update to latest") would
201    /// silently write a no-op edit that *looks* like an update but changes nothing —
202    /// worse than the original silently-dropped-marker bug, since the manifest now
203    /// reads as though it were updated. There is no single rewrite that is safe in
204    /// general without inspecting the strict range's bounds, which is out of scope
205    /// here. `current` is `trim`med before the marker check for the same
206    /// un-trimmed-catalog-value reason as `strip_strict_marker` above. A no-op
207    /// return here is safely excluded from `deps-core`'s `collect_update_all_edits`
208    /// ("Update N outdated dependencies" lens) by its own no-op guard.
209    fn format_version_replacing(&self, version: &ConcreteVersion, current: &str) -> String {
210        let version = version.as_str();
211        let trimmed = current.trim();
212        match trimmed.split_once("!!") {
213            Some((_, "")) => format!("{version}!!"),
214            Some(_) => trimmed.to_string(),
215            None => self.format_version_for_text_edit(&ConcreteVersion::new(version)),
216        }
217    }
218
219    fn package_url(&self, name: &PackageName) -> String {
220        deps_maven::registry::package_url(name.as_str())
221    }
222}
223
224impl RequirementResolution for GradleFormatter {
225    fn version_satisfies_requirement(&self, version: &ConcreteVersion, requirement: &str) -> bool {
226        let version = version.as_str();
227        gradle_version_matches(version, requirement)
228    }
229
230    fn requirement_is_unresolved(&self, requirement: &VersionReq) -> bool {
231        is_unresolved(requirement.as_str())
232    }
233
234    /// Uses [`compile_requirement_unless`] (see that function and
235    /// [`deps_core::lsp_helpers::RequirementResolution::compile_requirement`] for the shared "undecidable" contract).
236    ///
237    /// The undecidable predicate rejects a malformed range (leading `[`/`(`/`]` but
238    /// `crate::range::parse_range` fails) — checked unconditionally, first, before any
239    /// other branch: without this guard ahead of the `AlwaysSatisfied`/dynamic-prefix
240    /// short-circuits below, a malformed bracket range that also happens to end in `+`
241    /// (e.g. `"[1.0,2.0]+"`) would be misclassified as a dynamic prefix — which decides
242    /// `Some(false)` for every real candidate — instead of correctly suppressing the check.
243    ///
244    /// #249 review (M4): this is a separate branch-order copy from `gradle_version_matches`
245    /// above — see the note on that function before reordering either one.
246    fn compile_requirement(&self, requirement: &VersionReq) -> Option<Box<dyn RequirementMatcher>> {
247        // `!!` is Gradle's rich-version strict/preferred shorthand (see
248        // `gradle_version_matches`/`strip_strict_marker`) — stripped once here, first, so
249        // every branch below (the malformed-range guard, dynamic-prefix, range, exact)
250        // operates on the `strictlyVersion` spelling underneath without needing its own
251        // separate strip. Unlike `gradle_version_matches` (re-derives everything from the raw
252        // string on every call), this matcher is pre-parsed once, so the stripped spelling must
253        // be what actually gets stored in the `GradleMatcher` variant — storing the unstripped
254        // string would make e.g. `Exact` compare against a target that includes `"!!"`.
255        let requirement = strip_strict_marker(requirement.as_str());
256        compile_requirement_unless(
257            requirement,
258            |r| r.starts_with(['[', '(', ']']) && crate::range::parse_range(r).is_none(),
259            |r| {
260                if is_unresolved(&r) || r == "latest" || r.starts_with("latest.") {
261                    return GradleMatcher::AlwaysSatisfied;
262                }
263                if is_snapshot(&r) {
264                    return GradleMatcher::AlwaysSatisfied;
265                }
266                if let Some(prefix) = r.strip_suffix('+') {
267                    return GradleMatcher::DynamicPrefix(prefix.to_string());
268                }
269                // `]` is included alongside `[`/`(` because Gradle's reversed-bracket
270                // exclusive notation (`]1.2,1.5]`) is a leading delimiter in its own
271                // right, not just a trailing one. The undecidable guard above already
272                // ensures `parse_range` succeeds here.
273                if r.starts_with(['[', '(', ']'])
274                    && let Some(range) = crate::range::parse_range(&r)
275                {
276                    return GradleMatcher::Range(range);
277                }
278                GradleMatcher::Exact(r)
279            },
280        )
281    }
282}
283
284impl DiagnosticMessages for GradleFormatter {}
285
286impl DiagnosticPolicy for GradleFormatter {}
287
288impl SourcePolicy for GradleFormatter {}
289
290impl OsvNaming for GradleFormatter {}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295    use deps_core::lsp_helpers::RequirementStatus;
296
297    #[test]
298    fn test_format_version() {
299        let f = GradleFormatter;
300        assert_eq!(
301            f.format_version_for_text_edit(&ConcreteVersion::new("3.2.0")),
302            "3.2.0"
303        );
304        assert_eq!(
305            f.format_version_for_text_edit(&ConcreteVersion::new("1.0.0-SNAPSHOT")),
306            "1.0.0-SNAPSHOT"
307        );
308    }
309
310    #[test]
311    fn test_format_version_replacing_preserves_strict_marker() {
312        let f = GradleFormatter;
313        assert_eq!(
314            f.format_version_replacing(&ConcreteVersion::new("1.2.4"), "1.2.3!!"),
315            "1.2.4!!"
316        );
317    }
318
319    #[test]
320    fn test_format_version_replacing_no_marker_stays_plain() {
321        let f = GradleFormatter;
322        assert_eq!(
323            f.format_version_replacing(&ConcreteVersion::new("1.2.4"), "1.2.3"),
324            "1.2.4"
325        );
326    }
327
328    /// M1: a version-catalog entry's raw value is not trimmed by the parser
329    /// (unlike the Groovy/Kotlin DSL capture), so trailing whitespace must not
330    /// defeat the suffix-marker check.
331    #[test]
332    fn test_format_version_replacing_preserves_strict_marker_with_trailing_whitespace() {
333        let f = GradleFormatter;
334        assert_eq!(
335            f.format_version_replacing(&ConcreteVersion::new("1.2.4"), "1.2.3!! "),
336            "1.2.4!!"
337        );
338    }
339
340    /// S1/C2: the full `{strictlyVersion}!!{preferredVersion}` shorthand has no
341    /// single version to bump to — rewriting the `preferredVersion` half to a value
342    /// outside the untouched strict range would write a self-contradictory
343    /// constraint (Gradle's strict range always wins, so the bump would silently
344    /// have no effect while the manifest reads as updated). Must return the
345    /// declared text unchanged rather than destroying the range or writing a
346    /// misleading no-op.
347    #[test]
348    fn test_format_version_replacing_infix_shorthand_is_unchanged() {
349        let f = GradleFormatter;
350        assert_eq!(
351            f.format_version_replacing(&ConcreteVersion::new("9.9.9"), "[1.7, 1.8[!!1.7.25"),
352            "[1.7, 1.8[!!1.7.25"
353        );
354    }
355
356    /// M1: same trailing-whitespace tolerance as the suffix form, for the infix form.
357    #[test]
358    fn test_format_version_replacing_infix_shorthand_with_trailing_whitespace() {
359        let f = GradleFormatter;
360        assert_eq!(
361            f.format_version_replacing(&ConcreteVersion::new("9.9.9"), "[1.7,1.8[!!1.7.25 "),
362            "[1.7,1.8[!!1.7.25"
363        );
364    }
365
366    #[test]
367    fn test_package_url() {
368        let f = GradleFormatter;
369        assert_eq!(
370            f.package_url(&PackageName::new(
371                "org.springframework.boot:spring-boot-starter"
372            )),
373            "https://central.sonatype.com/artifact/org.springframework.boot/spring-boot-starter"
374        );
375    }
376
377    #[test]
378    fn test_version_satisfies() {
379        let f = GradleFormatter;
380        assert!(f.version_satisfies_requirement(&ConcreteVersion::new("3.2.0"), "3.2.0"));
381        assert!(!f.version_satisfies_requirement(&ConcreteVersion::new("3.2.0"), "3.1.0"));
382        assert!(!f.version_satisfies_requirement(&ConcreteVersion::new("3.2.0"), "3.2.1"));
383    }
384
385    #[test]
386    fn test_version_satisfies_dynamic_prefix() {
387        let f = GradleFormatter;
388        assert!(f.version_satisfies_requirement(&ConcreteVersion::new("1.0.5"), "1.0.+"));
389        assert!(f.version_satisfies_requirement(&ConcreteVersion::new("1.0"), "1.0.+"));
390        assert!(!f.version_satisfies_requirement(&ConcreteVersion::new("1.1.0"), "1.0.+"));
391        // Prefix boundary: "2.10.+" must not false-match "2.1.5" via a naive
392        // non-dot-anchored prefix check.
393        assert!(!f.version_satisfies_requirement(&ConcreteVersion::new("2.1.5"), "2.10.+"));
394        assert!(f.version_satisfies_requirement(&ConcreteVersion::new("2.10.5"), "2.10.+"));
395    }
396
397    #[test]
398    fn test_version_satisfies_latest_selector() {
399        let f = GradleFormatter;
400        assert!(f.version_satisfies_requirement(&ConcreteVersion::new("3.2.0"), "latest.release"));
401        assert!(f.version_satisfies_requirement(
402            &ConcreteVersion::new("3.2.0-SNAPSHOT"),
403            "latest.integration"
404        ));
405    }
406
407    #[test]
408    fn test_version_satisfies_range() {
409        let f = GradleFormatter;
410        assert!(f.version_satisfies_requirement(&ConcreteVersion::new("1.5.0"), "[1.0,2.0)"));
411        assert!(!f.version_satisfies_requirement(&ConcreteVersion::new("2.0.0"), "[1.0,2.0)"));
412        assert!(f.version_satisfies_requirement(&ConcreteVersion::new("1.0.0"), "[1.0.0]"));
413        assert!(!f.version_satisfies_requirement(&ConcreteVersion::new("1.0.1"), "[1.0.0]"));
414    }
415
416    #[test]
417    fn test_version_satisfies_reversed_bracket_range() {
418        let f = GradleFormatter;
419        // `implementation 'com.google.guava:guava:[30.0,31.0['` — Gradle's documented
420        // exclusive-upper-bound notation, leading with `[` but trailing with `[` instead of
421        // `)`/`]`.
422        assert!(f.version_satisfies_requirement(&ConcreteVersion::new("30.5"), "[30.0,31.0["));
423        assert!(!f.version_satisfies_requirement(&ConcreteVersion::new("31.0"), "[30.0,31.0["));
424        // Exclusive-lower-bound notation, which leads with `]` rather than `[`/`(`.
425        assert!(!f.version_satisfies_requirement(&ConcreteVersion::new("1.2"), "]1.2,1.5]"));
426        assert!(f.version_satisfies_requirement(&ConcreteVersion::new("1.3"), "]1.2,1.5]"));
427    }
428
429    /// S2/C1: the full `{strictlyVersion}!!{preferredVersion}` shorthand matches
430    /// against the strict range alone — per Gradle's rich-version semantics,
431    /// `strictly` is the hard constraint and `preferred` only breaks ties among
432    /// versions that already satisfy it. A version inside the range but different
433    /// from the preferred pointer (`1.7.30`) still satisfies the requirement;
434    /// only a version genuinely outside the range (`1.8.0`) does not.
435    #[test]
436    fn test_version_satisfies_strict_range_with_preferred() {
437        let f = GradleFormatter;
438        assert!(
439            f.version_satisfies_requirement(&ConcreteVersion::new("1.7.25"), "[1.7,1.8[!!1.7.25")
440        );
441        assert!(
442            f.version_satisfies_requirement(&ConcreteVersion::new("1.7.30"), "[1.7,1.8[!!1.7.25")
443        );
444        assert!(
445            !f.version_satisfies_requirement(&ConcreteVersion::new("1.8.0"), "[1.7,1.8[!!1.7.25")
446        );
447    }
448
449    /// C3: an unresolved Gradle variable inside the `strictlyVersion` half must
450    /// short-circuit to "satisfied" the same as a bare unresolved variable —
451    /// stripping the `!!` marker before checking must never discard the `$`.
452    #[test]
453    fn test_version_satisfies_unresolved_variable_with_strict_marker() {
454        let f = GradleFormatter;
455        assert!(f.version_satisfies_requirement(&ConcreteVersion::new("1.7.25"), "${r}!!1.7.25"));
456    }
457
458    /// C3: same guarantee when the unresolved variable sits in the
459    /// `preferredVersion` half instead — the raw-string check runs before
460    /// `strip_strict_marker` discards that half entirely, so it still sees the `$`.
461    #[test]
462    fn test_version_satisfies_unresolved_variable_in_preferred_half() {
463        let f = GradleFormatter;
464        assert!(
465            f.version_satisfies_requirement(&ConcreteVersion::new("1.7.25"), "[1.7,1.8[!!${r}")
466        );
467    }
468
469    /// M3: the discriminating case for the raw-string pre-check's documented
470    /// asymmetry with `compile_requirement` — `1.7.25` above is inside the strict
471    /// range regardless of the pre-check, so it doesn't prove anything on its own.
472    /// `1.8.0` is genuinely outside `[1.7,1.8[`; the loose matcher still reports it
473    /// as satisfied only because the raw-string pre-check short-circuits before the
474    /// range is ever consulted. Deliberately over-permissive and unreachable in
475    /// production (see the pre-check's doc comment); this pins the behavior so a
476    /// future change to the pre-check doesn't silently alter it.
477    #[test]
478    fn test_version_satisfies_unresolved_variable_in_preferred_half_over_permissive() {
479        let f = GradleFormatter;
480        assert!(f.version_satisfies_requirement(&ConcreteVersion::new("1.8.0"), "[1.7,1.8[!!${r}"));
481    }
482
483    #[test]
484    fn test_version_satisfies_unresolved_bare_variable() {
485        let f = GradleFormatter;
486        assert!(f.version_satisfies_requirement(&ConcreteVersion::new("3.14.0"), "$someVersion"));
487    }
488
489    #[test]
490    fn test_version_satisfies_unresolved_braced_variable() {
491        let f = GradleFormatter;
492        assert!(f.version_satisfies_requirement(&ConcreteVersion::new("3.14.0"), "${someVersion}"));
493    }
494
495    #[test]
496    fn test_version_satisfies_unresolved_compound_variable() {
497        let f = GradleFormatter;
498        assert!(f.version_satisfies_requirement(&ConcreteVersion::new("3.14.0"), "1.0.0-$suffix"));
499    }
500
501    #[test]
502    fn test_validate_package_name_accepts_valid_coordinate() {
503        let f = GradleFormatter;
504        assert!(f.validate_package_name("com.google.guava:guava").is_ok());
505    }
506
507    #[test]
508    fn test_validate_package_name_rejects_missing_colon() {
509        let f = GradleFormatter;
510        assert!(f.validate_package_name("com.google.guava").is_err());
511    }
512
513    #[test]
514    fn test_validate_package_name_rejects_invalid_group() {
515        let f = GradleFormatter;
516        assert!(f.validate_package_name("com</group>:guava").is_err());
517    }
518
519    #[test]
520    fn test_validate_package_name_rejects_invalid_artifact() {
521        let f = GradleFormatter;
522        assert!(f.validate_package_name("com.google.guava:..").is_err());
523    }
524
525    /// Gradle's own `is_unresolved` (unresolved `$var`/`${var}` or catalog-alias
526    /// placeholder) is valid Gradle syntax, not a malformed coordinate — must be
527    /// accepted, mirroring Maven's `${property}` treatment.
528    #[test]
529    fn test_validate_package_name_accepts_unresolved_variable() {
530        let f = GradleFormatter;
531        assert!(f.validate_package_name("$group:guava").is_ok());
532        assert!(f.validate_package_name("com.google.guava:${name}").is_ok());
533    }
534
535    #[test]
536    fn test_normalize_is_identity() {
537        let f = GradleFormatter;
538        assert_eq!(
539            f.normalize_package_name(&PackageName::new("com.google.guava:guava")),
540            "com.google.guava:guava"
541        );
542    }
543
544    #[test]
545    fn test_requirement_status_unresolved_bare_variable() {
546        let f = GradleFormatter;
547        assert_eq!(
548            f.requirement_status(
549                &VersionReq::new("$someVersion"),
550                &ConcreteVersion::new("3.14.0")
551            ),
552            RequirementStatus::Unresolved
553        );
554    }
555
556    #[test]
557    fn test_requirement_status_unresolved_braced_variable() {
558        let f = GradleFormatter;
559        assert_eq!(
560            f.requirement_status(
561                &VersionReq::new("${someVersion}"),
562                &ConcreteVersion::new("3.14.0")
563            ),
564            RequirementStatus::Unresolved
565        );
566    }
567
568    #[test]
569    fn test_requirement_status_unresolved_dangling_catalog_ref() {
570        // Synthetic `$alias` produced by `catalog::extract_version` for a `version.ref`
571        // missing from `[versions]` — must be treated the same as an unresolved variable.
572        let f = GradleFormatter;
573        assert_eq!(
574            f.requirement_status(
575                &VersionReq::new("$missing"),
576                &ConcreteVersion::new("3.14.0")
577            ),
578            RequirementStatus::Unresolved
579        );
580    }
581
582    #[test]
583    fn test_requirement_status_up_to_date() {
584        let f = GradleFormatter;
585        assert_eq!(
586            f.requirement_status(&VersionReq::new("3.2.0"), &ConcreteVersion::new("3.2.0")),
587            RequirementStatus::UpToDate
588        );
589    }
590
591    #[test]
592    fn test_requirement_status_outdated() {
593        let f = GradleFormatter;
594        assert_eq!(
595            f.requirement_status(&VersionReq::new("3.1.0"), &ConcreteVersion::new("3.2.0")),
596            RequirementStatus::Outdated
597        );
598    }
599
600    #[test]
601    fn test_compile_requirement_exact() {
602        let f = GradleFormatter;
603        let matcher = f
604            .compile_requirement(&VersionReq::new("3.2.0"))
605            .expect("Gradle requirement always compiles");
606        assert_eq!(matcher.matches(&ConcreteVersion::new("3.2.0")), Some(true));
607        assert_eq!(matcher.matches(&ConcreteVersion::new("3.1.0")), Some(false));
608    }
609
610    #[test]
611    fn test_compile_requirement_range() {
612        let f = GradleFormatter;
613        let matcher = f
614            .compile_requirement(&VersionReq::new("[1.0,2.0)"))
615            .unwrap();
616        assert_eq!(matcher.matches(&ConcreteVersion::new("1.5.0")), Some(true));
617        assert_eq!(matcher.matches(&ConcreteVersion::new("2.0.0")), Some(false));
618    }
619
620    /// S2: `requirement_status` must reach `UpToDate` for a strict pin sitting on
621    /// the latest version, not stay permanently `Outdated` — the round trip through
622    /// `format_version_replacing` (which preserves `!!`) would otherwise leave a
623    /// warning the editor can never clear.
624    #[test]
625    fn test_requirement_status_strict_marker_up_to_date() {
626        let f = GradleFormatter;
627        assert_eq!(
628            f.requirement_status(&VersionReq::new("1.2.3!!"), &ConcreteVersion::new("1.2.3")),
629            RequirementStatus::UpToDate
630        );
631    }
632
633    /// C1: matches against the strict range, not the preferred pointer — a
634    /// registry that never publishes the exact preferred version must not produce
635    /// a false "no published version satisfies this requirement" warning for every
636    /// other version genuinely inside the strict range. See
637    /// `test_version_satisfies_strict_range_with_preferred`'s doc for why.
638    #[test]
639    fn test_compile_requirement_strict_range_with_preferred() {
640        let f = GradleFormatter;
641        let matcher = f
642            .compile_requirement(&VersionReq::new("[1.7,1.8[!!1.7.25"))
643            .unwrap();
644        assert_eq!(matcher.matches(&ConcreteVersion::new("1.7.25")), Some(true));
645        assert_eq!(matcher.matches(&ConcreteVersion::new("1.7.30")), Some(true));
646        assert_eq!(matcher.matches(&ConcreteVersion::new("1.8.0")), Some(false));
647    }
648
649    #[test]
650    fn test_compile_requirement_malformed_range_returns_none() {
651        let f = GradleFormatter;
652        assert!(
653            f.compile_requirement(&VersionReq::new("[1.0,2.0"))
654                .is_none()
655        );
656    }
657
658    /// #268 rebase re-verification: the malformed-range guard runs on the post-strip
659    /// `strictlyVersion` half, same as `crate::range::parse_range` below it — a
660    /// missing closing delimiter must still be rejected even with an infix
661    /// `!!{preferred}` shorthand attached, not misparsed as valid because the `!!`
662    /// suffix confuses the range grammar.
663    #[test]
664    fn test_compile_requirement_malformed_range_with_preferred_returns_none() {
665        let f = GradleFormatter;
666        assert!(
667            f.compile_requirement(&VersionReq::new("[1.0,2.0!!1.5"))
668                .is_none()
669        );
670    }
671
672    /// S6: mirrors deps-maven's snapshot guard — Gradle resolves through the same
673    /// `MavenCentralRegistry`, which never queries the snapshot repository.
674    #[test]
675    fn test_compile_requirement_snapshot_always_satisfied() {
676        let f = GradleFormatter;
677        let matcher = f
678            .compile_requirement(&VersionReq::new("7.0.0-SNAPSHOT"))
679            .unwrap();
680        assert_eq!(matcher.matches(&ConcreteVersion::new("6.9.0")), Some(true));
681    }
682
683    #[test]
684    fn test_version_satisfies_snapshot() {
685        let f = GradleFormatter;
686        assert!(f.version_satisfies_requirement(&ConcreteVersion::new("6.9.0"), "7.0.0-SNAPSHOT"));
687    }
688
689    /// #249 review regression: a malformed bracket range that also happens to end in `+`
690    /// must still be rejected (`None`), not misclassified as a dynamic prefix by checking
691    /// the `strip_suffix('+')` branch before the malformed-range guard — that would make
692    /// every real candidate decide `Some(false)`, a false "unsatisfiable" ERROR for a typo.
693    #[test]
694    fn test_compile_requirement_malformed_range_rejected_even_with_trailing_plus() {
695        let f = GradleFormatter;
696        for malformed in ["[1.0,2.0]+", "[1.0,2.+", "(1.0,2.0)+", "]1.0,2.0]+"] {
697            assert!(
698                f.compile_requirement(&VersionReq::new(malformed)).is_none(),
699                "expected None for {malformed:?}"
700            );
701        }
702    }
703
704    #[test]
705    fn test_version_satisfies_strict_shorthand() {
706        let f = GradleFormatter;
707        assert!(f.version_satisfies_requirement(&ConcreteVersion::new("1.2.3"), "1.2.3!!"));
708        assert!(!f.version_satisfies_requirement(&ConcreteVersion::new("1.2.4"), "1.2.3!!"));
709    }
710
711    #[test]
712    fn test_compile_requirement_strict_shorthand() {
713        let f = GradleFormatter;
714        let matcher = f.compile_requirement(&VersionReq::new("1.2.3!!")).unwrap();
715        assert_eq!(matcher.matches(&ConcreteVersion::new("1.2.3")), Some(true));
716        assert_eq!(matcher.matches(&ConcreteVersion::new("1.2.4")), Some(false));
717    }
718
719    /// M6: `compile_requirement`'s range-validity guard must strip `!!` the same way
720    /// `gradle_version_matches` does — otherwise a valid strict range like
721    /// `"[1.0,2.0)!!"` fails `parse_range` (the suffix isn't range grammar) and the
722    /// guard wrongly suppresses the diagnostic instead of compiling the matcher.
723    #[test]
724    fn test_compile_requirement_strict_range() {
725        let f = GradleFormatter;
726        let matcher = f
727            .compile_requirement(&VersionReq::new("[1.0,2.0)!!"))
728            .expect("strict range must still compile a matcher");
729        assert_eq!(matcher.matches(&ConcreteVersion::new("1.5.0")), Some(true));
730        assert_eq!(matcher.matches(&ConcreteVersion::new("2.0.0")), Some(false));
731    }
732}