001/*
002 * SPDX-License-Identifier: Apache-2.0
003 *
004 * Copyright 2025-2026 The Enola <https://enola.dev> Authors
005 *
006 * Licensed under the Apache License, Version 2.0 (the "License");
007 * you may not use this file except in compliance with the License.
008 * You may obtain a copy of the License at
009 *
010 *     https://www.apache.org/licenses/LICENSE-2.0
011 *
012 * Unless required by applicable law or agreed to in writing, software
013 * distributed under the License is distributed on an "AS IS" BASIS,
014 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
015 * See the License for the specific language governing permissions and
016 * limitations under the License.
017 */
018package dev.enola.connect.maven;
019
020import static com.google.common.base.Strings.isNullOrEmpty;
021
022import com.github.packageurl.MalformedPackageURLException;
023import com.github.packageurl.PackageURL;
024import com.github.packageurl.PackageURLBuilder;
025import com.google.common.base.Strings;
026
027import eu.maveniverse.maven.mima.extensions.mmr.ModelResponse;
028
029import org.eclipse.aether.artifact.Artifact;
030import org.eclipse.aether.artifact.DefaultArtifact;
031
032import java.util.Map;
033
034/**
035 * GAVR is a Maven GroupID, ArtifactID, Version, Extension (AKA Type), Classifier + Repository.
036 *
037 * <p>The GroupID, ArtifactID &amp; Version are mandatory and cannot be empty. The Extension,
038 * Classifier &amp; Repository can be empty (but never null).
039 *
040 * <p>The Maven default extension "jar" are hidden in the GAV coordinates and Package URL syntax
041 * (but is returned by {@link #extension()} API). This is different from Maven core, which always
042 * shows "jar".
043 *
044 * <p>This class itself does NOT imply any other "defaults" for Classifier &amp; Repository. Callers
045 * of this class may resolve a GAVR without repo to one with a repo using {@link
046 * Mima#origin(ModelResponse)}.
047 */
048public record GAVR(
049        String groupId,
050        String artifactId,
051        String extension,
052        String classifier,
053        String version,
054        String repo) {
055
056    // TODO Consider #performance - make this a class to cache GAV & PkgURL its representations?
057
058    /**
059     * Parse a coordinates in the {@code
060     * <groupId>:<artifactId>[:<extension>[:<classifier>]]:<version>} format, like e.g.
061     * "ch.vorburger.mariaDB4j:mariaDB4j-core:3.1.0" to a GAVR.
062     *
063     * <p>This format is not a URL; use {@link #parsePkgURL(String)} if you have a URL, or {@link
064     * #toPkgURL()} if you want one.
065     *
066     * <p>PS: This syntax does not allow specifying a repository!
067     */
068    public static GAVR parseGAV(String gav) {
069        var artifact = new DefaultArtifact(gav);
070        return new GAVR(
071                artifact.getGroupId(),
072                artifact.getArtifactId(),
073                artifact.getExtension(),
074                artifact.getClassifier(),
075                artifact.getVersion(),
076                "");
077    }
078
079    /** Return a String in the same format that {@link #parsePkgURL(String)} uses. */
080    public String toPkgURL() {
081        var builder = PackageURLBuilder.aPackageURL();
082        builder.withType("maven");
083        builder.withNamespace(groupId);
084        builder.withName(artifactId);
085        builder.withVersion(version);
086        if (!extension.isEmpty() && !"jar".equals(extension)) {
087            builder.withQualifier("type", extension);
088        }
089        if (!classifier.isEmpty()) {
090            builder.withQualifier("classifier", classifier);
091        }
092        if (!repo.isEmpty()) {
093            builder.withQualifier("repository_url", repo);
094        }
095        try {
096            return builder.build().canonicalize();
097        } catch (MalformedPackageURLException e) {
098            throw new IllegalStateException(toGAV(), e);
099        }
100    }
101
102    /**
103     * Parse a Maven Package URL. For example,
104     * "pkg:maven/ch.vorburger.mariaDB4j/mariaDB4j-core@3.1.0?classifier=javadoc". See <a
105     * href="https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#maven">Type
106     * definition</a> and its underlying <a
107     * href="https://spdx.github.io/spdx-spec/v3.0.1/annexes/pkg-url-specification/">SPDX
108     * specification</a>.
109     */
110    public static GAVR parsePkgURL(String purl) {
111        try {
112            var p = new PackageURL(purl);
113            var q = p.getQualifiers();
114            if (!"maven".equals(p.getType())) throw new IllegalArgumentException(purl);
115            // https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#maven
116            return new GAVR(
117                    p.getNamespace(),
118                    p.getName(),
119                    get(q, "type"),
120                    get(q, "classifier"),
121                    p.getVersion(),
122                    get(q, "repository_url"));
123
124        } catch (MalformedPackageURLException e) {
125            throw new IllegalArgumentException(purl, e);
126        }
127    }
128
129    private static String get(Map<String, String> q, String key) {
130        if (q == null) return "";
131        var value = q.get(key);
132        return Strings.nullToEmpty(value);
133    }
134
135    public static class Builder {
136        private String groupId;
137        private String artifactId;
138        private String extension;
139        private String classifier;
140        private String version;
141        private String repo;
142
143        public Builder groupId(String groupId) {
144            this.groupId = groupId;
145            return this;
146        }
147
148        public Builder artifactId(String artifactId) {
149            this.artifactId = artifactId;
150            return this;
151        }
152
153        public Builder extension(String extension) {
154            this.extension = extension;
155            return this;
156        }
157
158        public Builder classifier(String classifier) {
159            this.classifier = classifier;
160            return this;
161        }
162
163        public Builder version(String version) {
164            this.version = version;
165            return this;
166        }
167
168        public Builder repo(String repo) {
169            this.repo = repo;
170            return this;
171        }
172
173        public GAVR build() {
174            return new GAVR(
175                    groupId,
176                    artifactId,
177                    nullToEmpty(extension),
178                    nullToEmpty(classifier),
179                    version,
180                    nullToEmpty(repo));
181        }
182
183        private String nullToEmpty(String string) {
184            if (string == null) return "";
185            else return string;
186        }
187    }
188
189    public GAVR {
190        requireNonEmpty(groupId, "groupId");
191        requireNonEmpty(artifactId, "artifactId");
192        extension = useDefaultIfNullOrEmpty(extension, "jar");
193        requireNonNull(classifier, "classifier");
194        requireNonEmpty(version, "version");
195        requireNonNull(repo, "repo");
196    }
197
198    private String useDefaultIfNullOrEmpty(String string, String defaultValue) {
199        if (string == null) return defaultValue;
200        if (string.isEmpty()) return defaultValue;
201        return string;
202    }
203
204    private void requireNonNull(Object object, String field) {
205        if (object == null) throw new IllegalStateException(field + " cannot be null");
206    }
207
208    private void requireNonEmpty(String string, String field) {
209        if (isNullOrEmpty(string))
210            throw new IllegalStateException(field + " cannot be null or empty");
211    }
212
213    /**
214     * Return a String in the same format that {@link #parseGAV(String)} uses.
215     *
216     * <p>This omits extension "jar" when it can (contrary to how
217     * org.eclipse.aether.artifact.AbstractArtifact#toString() does it).
218     */
219    public String toGAV() {
220        var sb = // w.o. repo.length()
221                new StringBuilder(
222                        4 // max. 4x ':'
223                                + groupId.length()
224                                + artifactId.length()
225                                + extension.length()
226                                + classifier.length()
227                                + version.length());
228
229        sb.append(groupId).append(':').append(artifactId);
230
231        if ((!extension.isEmpty() && !"jar".equals(extension))
232                || ("jar".equals(extension) && !classifier.isEmpty()))
233            sb.append(':').append(extension);
234
235        if (!classifier.isEmpty()) sb.append(':').append(classifier);
236
237        sb.append(':');
238        sb.append(version);
239        return sb.toString();
240    }
241
242    public Builder toBuilder() {
243        return new Builder()
244                .groupId(groupId)
245                .artifactId(artifactId)
246                .extension(extension)
247                .classifier(classifier)
248                .version(version);
249    }
250
251    // NOT public - this is a package private internal method!
252    // NB: Artifact does NOT include the Repository! Callers will use repository() to obtain that.
253    Artifact toArtifact() {
254        return new DefaultArtifact(groupId, artifactId, classifier, extension, version);
255    }
256}