001/*
002 * JDrupes Builder
003 * Copyright (C) 2026 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.builder.ext.bnd;
020
021import aQute.bnd.differ.Baseline;
022import aQute.bnd.differ.Baseline.BundleInfo;
023import aQute.bnd.differ.Baseline.Info;
024import aQute.bnd.differ.DiffPluginImpl;
025import aQute.bnd.osgi.Instructions;
026import aQute.bnd.osgi.Jar;
027import aQute.bnd.osgi.Processor;
028import aQute.bnd.service.diff.Diff;
029import com.google.common.flogger.FluentLogger;
030import java.nio.charset.StandardCharsets;
031import java.nio.file.Files;
032import java.nio.file.Path;
033import java.util.Collection;
034import java.util.Collections;
035import java.util.Comparator;
036import java.util.Formatter;
037import java.util.List;
038import java.util.Locale;
039import java.util.Map;
040import java.util.Objects;
041import java.util.Optional;
042import org.jdrupes.builder.api.BuildException;
043import static org.jdrupes.builder.api.CoreProperties.*;
044import org.jdrupes.builder.api.Generator;
045import static org.jdrupes.builder.api.Intent.Supply;
046import org.jdrupes.builder.api.Project;
047import org.jdrupes.builder.api.Resource;
048import org.jdrupes.builder.api.ResourceRequest;
049import org.jdrupes.builder.api.ResourceType;
050import org.jdrupes.builder.api.Resources;
051import static org.jdrupes.builder.ext.bnd.BndTypes.*;
052import static org.jdrupes.builder.java.JavaTypes.*;
053import org.jdrupes.builder.java.LibraryJarFile;
054import static org.jdrupes.builder.mvnrepo.MvnProperties.*;
055import org.jdrupes.builder.mvnrepo.MvnRepoLookup;
056import static org.jdrupes.builder.mvnrepo.MvnRepoTypes.*;
057import org.jdrupes.builder.mvnrepo.PomFileGenerator;
058
059/// A [Generator] that performs a baseline evaluation between two OSGi
060/// bundles using the `bndlib` library [bnd](https://github.com/bndtools/bnd).
061/// 
062/// Because OSGi repositories never became popular, Maven repository
063/// semantics are used to find the baseline bundle. The current bundle
064/// is the library supplied by the project. The [BndBaseliner] evaluates
065/// its Maven coordinates in the same way as the [PomFileGenerator] does.
066/// From these, coordinates used to lookup the previous version are derived
067/// in the form `groupId:artifactId:[,version)`
068/// 
069/// The [BndBaseliner] then performs the baseline evaluation. Instructions
070/// `-diffignore` and `-diffpackages` are supported and forwarded to
071/// `bndlib`.
072/// 
073/// This provider is made available as an extension.
074/// [![org.jdrupes:jdbld-ext-bnd:](
075/// https://img.shields.io/maven-metadata/v?metadataUrl=https%3A%2F%2Fcodeberg.org%2Fapi%2Fpackages%2FJDrupes%2Fmaven%2Forg%2Fjdrupes%2Fjdbld-ext-bnd%2Fmaven-metadata.xml&strategy=releaseProperty)
076/// ](https://codeberg.org/JDrupes/-/packages/maven/org.jdrupes:jdbld-ext-bnd/versions)
077///
078public class BndBaseliner extends AbstractBndGenerator {
079
080    private static final FluentLogger logger = FluentLogger.forEnclosingClass();
081    private boolean ignoreMismatched;
082
083    /// Initializes a new bnd baseliner.
084    ///
085    /// @param project the project
086    ///
087    public BndBaseliner(Project project) {
088        super(project);
089    }
090
091    /// Add the instruction specified by key and value.
092    ///
093    /// @param key the key
094    /// @param value the value
095    /// @return the bnd baseliner
096    ///
097    @Override
098    public BndBaseliner instruction(String key, String value) {
099        super.instruction(key, value);
100        return this;
101    }
102
103    /// Add the given instructions for the baseliner.
104    ///
105    /// @param instructions the instructions
106    /// @return the bnd baseliner
107    ///
108    @Override
109    public BndBaseliner instructions(Map<String, String> instructions) {
110        super.instructions(instructions);
111        return this;
112    }
113
114    /// Add the instructions from the given bnd (properties) file.
115    ///
116    /// @param bndFile the bnd file
117    /// @return the bnd baseliner
118    ///
119    @Override
120    public BndBaseliner instructions(Path bndFile) {
121        super.instructions(bndFile);
122        return this;
123    }
124
125    /// Ignore mismatches in the baseline evaluation. When invoked,
126    /// the [BndBaseliner] will not set the faulty flag on the
127    /// [BndBaselineEvaluation] if there are mismatches.
128    ///
129    /// @return the bnd baseliner
130    ///
131    public BndBaseliner ignoreMismatches() {
132        this.ignoreMismatched = true;
133        return this;
134    }
135
136    @Override
137    @SuppressWarnings("PMD.AvoidLiteralsInIfCondition")
138    protected <T extends Resource> Collection<T>
139            doProvide(ResourceRequest<T> requested) {
140        // Check if provided and evaluate for most special type
141        if (!requested.accepts(BndBaselineEvaluationType)) {
142            return Collections.emptyList();
143        }
144        if (!requested.isFor(BndBaselineEvaluationType)) {
145            @SuppressWarnings("unchecked")
146            var result = (Collection<T>)
147                resources(of(BndBaselineEvaluationType)).toList();
148            return result;
149        }
150
151        // Get libraries
152        var libraries = Resources.of(new ResourceType<Resources<
153                LibraryJarFile>>() {})
154            .addAll(project().providers(Supply)
155                .resources(project().of(LibraryJarFileType)));
156        if (libraries.stream().count() > 1) {
157            logger.atWarning().log("More than one library generated by %s,"
158                + " baselining can only success for one.", project());
159        }
160        @SuppressWarnings("unchecked")
161        var result = (Collection<T>) libraries.stream().map(this::baseline)
162            .filter(Optional::isPresent).map(Optional::get).toList();
163        return result;
164    }
165
166    private Optional<BndBaselineEvaluation> baseline(LibraryJarFile lib) {
167        logger.atFiner().log("Baselining %s in %s", lib, project());
168
169        var groupId = project().get(GroupId);
170        var artifactId = Optional.ofNullable(project()
171            .get(ArtifactId)).orElse(project().name());
172        var version = project().get(Version);
173        if (groupId == null) {
174            logger.atWarning().log("Cannot baseline in %s without a groupId",
175                project());
176            return Optional.empty();
177        }
178        logger.atFinest().log("Baselining %s:%s:%s", groupId, artifactId,
179            version);
180
181        // Retrieve previous, relying on version boundaries for selection
182        var repoAccess = new MvnRepoLookup().probe().resolve(
183            String.format("%s:%s:[0,%s)", groupId, artifactId, version));
184        var baselineJar = repoAccess.resources(
185            of(MvnRepoLibraryJarFileType)).findFirst();
186        if (baselineJar.isEmpty()) {
187            return Optional.of(new DefaultBndBaselineEvaluation(
188                BndBaselineEvaluationType, project(), lib.path()).name(
189                    project().rootProject().relativize(lib.path()).toString())
190                    .withBaselineArtifactMissing());
191        }
192        logger.atFinest().log("Baselining against %s", baselineJar);
193
194        return Optional.of(bndBaseline(baselineJar.get(), lib));
195    }
196
197    @SuppressWarnings("PMD.AvoidCatchingGenericException")
198    private BndBaselineEvaluation bndBaseline(LibraryJarFile baseline,
199            LibraryJarFile current) {
200        try (Processor processor = new Processor();
201                Jar baselineJar = new Jar(baseline.path().toFile());
202                Jar currentJar = new Jar(current.path().toFile())) {
203            applyInstructions(processor);
204            DiffPluginImpl differ = new DiffPluginImpl();
205            differ.setIgnore(processor.getProperty("-diffignore"));
206            Baseline baseliner = new Baseline(processor, differ);
207
208            List<Info> infos = baseliner.baseline(currentJar, baselineJar,
209                new Instructions(processor.getProperty("-diffpackages")))
210                .stream()
211                .sorted(Comparator.comparing(info -> info.packageName))
212                .toList();
213            BundleInfo bundleInfo = baseliner.getBundleInfo();
214            var reportLocation = writeReport(baselineJar, currentJar,
215                baseliner, infos, bundleInfo);
216            var result = new DefaultBndBaselineEvaluation(
217                BndBaselineEvaluationType, project(), baseline.path())
218                    .name(bundleInfo.bsn).withReportLocation(reportLocation);
219            if (bundleInfo.mismatch && !ignoreMismatched) {
220                result.setFaulty().withReason(bundleInfo.reason);
221            }
222            return result;
223
224        } catch (Exception e) {
225            throw new BuildException().from(this).cause(e);
226        }
227    }
228
229    @SuppressWarnings({ "PMD.AvoidCatchingGenericException",
230        "PMD.CognitiveComplexity", "PMD.CyclomaticComplexity",
231        "PMD.NPathComplexity" })
232    private Path writeReport(Jar baselineJar, Jar currentJar,
233            Baseline baseliner, List<Info> infos, BundleInfo bundleInfo) {
234        // Copied from gradle plugin and improved
235        Path reportLocation = project().buildDirectory().resolve("reports");
236        reportLocation.toFile().mkdirs();
237        reportLocation = reportLocation.resolve(
238            String.format("%s-baseline.txt", currentJar.getName()));
239        try (var report = Files.newOutputStream(reportLocation);
240                Formatter fmt = new Formatter(
241                    report, StandardCharsets.UTF_8, Locale.US)) {
242            var formatInfo = new FormatInfo(currentJar, baselineJar, bundleInfo,
243                infos);
244            String format = formatInfo.formatString();
245            fmt.format(formatInfo.separatorLine());
246            fmt.format(format, " ", "Name", "Type", "Delta", "New", "Old",
247                "Suggest", "");
248            Diff diff = baseliner.getDiff();
249            fmt.format(format, bundleInfo.mismatch ? "*" : " ",
250                bundleInfo.bsn, diff.getType(), diff.getDelta(),
251                currentJar.getVersion(), baselineJar.getVersion(),
252                bundleInfo.mismatch
253                    && Objects.nonNull(bundleInfo.suggestedVersion)
254                        ? bundleInfo.suggestedVersion
255                        : "-",
256                "");
257            if (bundleInfo.mismatch) {
258                fmt.format("%#2S\n", diff);
259            }
260
261            if (!infos.isEmpty()) {
262                fmt.format(formatInfo.separatorLine());
263                fmt.format(format, " ", "Name", "Type", "Delta", "New", "Old",
264                    "Suggest", "If Prov.");
265                for (Info info : infos) {
266                    diff = info.packageDiff;
267                    fmt.format(format, info.mismatch ? "*" : " ",
268                        diff.getName(), diff.getType(), diff.getDelta(),
269                        info.newerVersion,
270                        Objects.nonNull(info.olderVersion)
271                            && info.olderVersion
272                                .equals(aQute.bnd.version.Version.LOWEST)
273                                    ? "-"
274                                    : info.olderVersion,
275                        Objects.nonNull(info.suggestedVersion)
276                            && info.suggestedVersion
277                                .compareTo(info.newerVersion) <= 0 ? "ok"
278                                    : info.suggestedVersion,
279                        Objects.nonNull(info.suggestedIfProviders)
280                            ? info.suggestedIfProviders
281                            : "-");
282                    if (info.mismatch) {
283                        fmt.format("%#2S\n", diff);
284                    }
285                }
286            }
287            fmt.flush();
288        } catch (Exception e) {
289            throw new BuildException().from(this).cause(e);
290        }
291        return reportLocation;
292    }
293
294    /// The Class FormatInfo.
295    ///
296    private final class FormatInfo {
297        private final int maxNameLength;
298        private final int maxNewerLength;
299        private final int maxOlderLength;
300
301        /// Initializes a new format info.
302        ///
303        /// @param currentJar the current jar
304        /// @param baselineJar the baseline jar
305        /// @param bundleInfo the bundle info
306        /// @param infos the infos
307        /// @throws Exception the exception
308        ///
309        @SuppressWarnings("PMD.SignatureDeclareThrowsException")
310        private FormatInfo(Jar currentJar, Jar baselineJar,
311                BundleInfo bundleInfo, List<Info> infos) throws Exception {
312            maxNameLength = Math.max(bundleInfo.bsn.length(), infos.stream()
313                .map(info -> info.packageDiff.getName().length())
314                .sorted(Comparator.reverseOrder()).findFirst().orElse(0));
315            maxNewerLength = Math.max(currentJar.getVersion().length(),
316                infos.stream()
317                    .map(info -> info.newerVersion.toString().length())
318                    .sorted(Comparator.reverseOrder()).findFirst().orElse(0));
319            maxOlderLength = Math.max(baselineJar.getVersion().length(),
320                infos.stream()
321                    .map(info -> info.olderVersion.toString().length())
322                    .sorted(Comparator.reverseOrder()).findFirst().orElse(0));
323        }
324
325        /// Format string.
326        ///
327        /// @return the string
328        ///
329        private String formatString() {
330            return "%s %-" + maxNameLength + "s %-10s %-10s %-"
331                + maxNewerLength + "s %-" + maxOlderLength + "s %-10s %s\n";
332        }
333
334        /// Separator string.
335        ///
336        /// @return the string
337        ///
338        private String separatorLine() {
339            return String.valueOf('=').repeat(
340                50 + maxNameLength + maxNewerLength + maxOlderLength) + "\n";
341        }
342    }
343
344}