001/*
002 * JDrupes GitVersioning
003 * Copyright (C) 2025 Michael N. Lipp
004 * 
005 * This program is free software: you can redistribute it and/or modify
006 * it under the terms of the GNU Affero General Public License as
007 * published by the Free Software Foundation, either version 3 of the
008 * License, or (at your option) any later version.
009 *
010 * This program is distributed in the hope that it will be useful,
011 * but WITHOUT ANY WARRANTY; without even the implied warranty of
012 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
013 * GNU Affero General Public License for more details.
014 *
015 * You should have received a copy of the GNU Affero General Public License
016 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
017 */
018
019package org.jdrupes.gitversioning.core;
020
021import com.vdurmont.semver4j.Semver;
022import com.vdurmont.semver4j.SemverException;
023import java.io.IOException;
024import java.nio.file.Path;
025import java.util.Collections;
026import java.util.Comparator;
027import java.util.HashSet;
028import java.util.Map;
029import java.util.Optional;
030import java.util.Set;
031import java.util.concurrent.ConcurrentHashMap;
032import java.util.function.Consumer;
033import java.util.logging.Logger;
034import org.eclipse.jgit.api.Git;
035import org.eclipse.jgit.api.errors.GitAPIException;
036import org.eclipse.jgit.lib.ObjectId;
037import org.eclipse.jgit.lib.Ref;
038import org.eclipse.jgit.lib.Repository;
039import org.eclipse.jgit.revwalk.RevCommit;
040import org.eclipse.jgit.revwalk.RevObject;
041import org.eclipse.jgit.revwalk.RevTag;
042import org.eclipse.jgit.revwalk.RevWalk;
043import org.jdrupes.gitversioning.api.TagFilter;
044import org.jdrupes.gitversioning.api.TagProcessor;
045import org.jdrupes.gitversioning.api.VersionEvaluator;
046
047/**
048 * A simple version evaluator provider. It looks up the tags in the
049 * current branch, uses the tag filter to identify the version tags
050 * and selects the latest one.
051 * 
052 * It then invokes the tag processor to produce a version string.
053 */
054public class VersionEvaluatorProvider
055        implements org.jdrupes.gitversioning.api.VersionEvaluatorProvider {
056
057    /** The logger. */
058    protected final Logger log = Logger.getLogger(getClass().getName());
059    @SuppressWarnings("PMD.FieldNamingConventions")
060    private static final Map<ObjectId, Set<ObjectId>> reachableByHead
061        = new ConcurrentHashMap<>();
062    private Repository repository;
063    private Path directory;
064    private TagFilter tagFilter = new DefaultTagFilter();
065    private TagProcessor tagProcessor = new MavenStyleTagProcessor();
066
067    /**
068     * Initializes a new version evaluator provider.
069     */
070    public VersionEvaluatorProvider() {
071        // Make javadoc happy.
072    }
073
074    @Override
075    public VersionEvaluatorProvider repository(Repository repository) {
076        this.repository = repository;
077        return this;
078    }
079
080    @Override
081    public VersionEvaluator subDirectory(Path subDirectory) {
082        if (subDirectory.isAbsolute()) {
083            subDirectory = subDirectory.relativize(
084                repository.getWorkTree().toPath());
085        }
086        directory = subDirectory;
087        return this;
088    }
089
090    @Override
091    public VersionEvaluator tagFilter(TagFilter tagFilter) {
092        this.tagFilter = tagFilter;
093        return this;
094    }
095
096    @Override
097    public VersionEvaluator tagProcessor(TagProcessor tagProcessor) {
098        this.tagProcessor = tagProcessor;
099        return this;
100    }
101
102    @Override
103    public String version() {
104        try {
105            var latest = getLatestVersionTagged();
106            return tagProcessor.version(repository, directory,
107                latest.commit(), latest.tag(), latest.version().toString());
108        } catch (IOException | GitAPIException e) {
109            throw new IllegalStateException(e);
110        }
111    }
112
113    private record VersionedTag(Ref ref, String tag, Semver version) {
114    }
115
116    private record VersionedCommit(RevCommit commit, String tag,
117            Semver version) {
118    }
119
120    private VersionedCommit getLatestVersionTagged()
121            throws GitAPIException, IOException {
122        try (var git = Git.wrap(repository);
123                var revWalk = new RevWalk(repository)) {
124            var reachable = reachableCommits();
125            return git.tagList().call().stream()
126                .mapMulti((Ref ref, Consumer<
127                        VersionedTag> consumer) -> addVersionInfo(ref)
128                            .ifPresent(consumer))
129                .sorted(new Comparator<VersionedTag>() {
130                    @Override
131                    public int compare(VersionedTag obj1, VersionedTag obj2) {
132                        return obj2.version().compareTo(obj1.version());
133                    }
134                }).mapMulti((VersionedTag vt,
135                        Consumer<VersionedCommit> consumer) -> findCommit(
136                            revWalk, vt.ref()).ifPresent(
137                                c -> consumer.accept(new VersionedCommit(
138                                    c, vt.tag(), vt.version()))))
139                .filter(vc -> reachable.contains(vc.commit().getId()))
140                .findFirst().orElseGet(
141                    () -> new VersionedCommit(null, null, new Semver("0.0.0")));
142        }
143    }
144
145    private Optional<RevCommit> findCommit(RevWalk revWalk, Ref ref) {
146        try {
147            RevObject refd = revWalk.parseAny(ref.getObjectId());
148            revWalk.reset();
149            return switch (refd) {
150            case RevTag revtag -> Optional
151                .of(revWalk.parseCommit(revtag.getObject()));
152            case RevCommit revcommit -> Optional.of(revcommit);
153            default -> Optional.empty();
154            };
155        } catch (IOException e) {
156            return Optional.empty();
157        } finally {
158            revWalk.reset();
159        }
160    }
161
162    private Set<ObjectId> reachableCommits() throws IOException {
163        ObjectId headId = repository.resolve("HEAD");
164        if (headId == null) {
165            // No commits yet
166            return Collections.emptySet();
167        }
168        return reachableByHead.computeIfAbsent(
169            headId, k -> {
170                try (var revWalk = new RevWalk(repository)) {
171                    var reachable = new HashSet<ObjectId>();
172                    revWalk.markStart(revWalk.parseCommit(headId));
173                    for (RevCommit commit : revWalk) {
174                        reachable.add(commit.getId());
175                    }
176                    return reachable;
177                } catch (IOException e) {
178                    return Collections.emptySet();
179                }
180            });
181    }
182
183    private Optional<VersionedTag> addVersionInfo(Ref ref) {
184        var tag = ref.getName().substring("refs/tags/".length());
185        return tagFilter.version(tag).map(v -> {
186            try {
187                var version = new Semver(v, Semver.SemverType.LOOSE);
188                return new VersionedTag(ref, tag, version);
189            } catch (SemverException e) {
190                throw new IllegalArgumentException(
191                    "Failed to parse version: " + v, e);
192            }
193        });
194    }
195
196}