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