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/// [ 076/// ](https://mvnrepository.com/artifact/org.jdrupes/jdbld-ext-bnd) 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 if (!requested.accepts(BndBaselineEvaluationType)) { 141 return Collections.emptyList(); 142 } 143 144 // Get libraries 145 var libraries = Resources.of(new ResourceType<Resources< 146 LibraryJarFile>>() {}) 147 .addAll(project().providers(Supply) 148 .resources(project().of(LibraryJarFileType))); 149 if (libraries.stream().count() > 1) { 150 logger.atWarning().log("More than one library generated by %s," 151 + " baselining can only success for one.", project()); 152 } 153 @SuppressWarnings("unchecked") 154 var result = (Collection<T>) libraries.stream().map(this::baseline) 155 .filter(Optional::isPresent).map(Optional::get).toList(); 156 return result; 157 } 158 159 private Optional<BndBaselineEvaluation> baseline(LibraryJarFile lib) { 160 logger.atFiner().log("Baselining %s in %s", lib, project()); 161 162 var groupId = project().get(GroupId); 163 var artifactId = Optional.ofNullable(project() 164 .get(ArtifactId)).orElse(project().name()); 165 var version = project().get(Version); 166 if (groupId == null) { 167 logger.atWarning().log("Cannot baseline in %s without a groupId", 168 project()); 169 return Optional.empty(); 170 } 171 logger.atFinest().log("Baselining %s:%s:%s", groupId, artifactId, 172 version); 173 174 // Retrieve previous, relying on version boundaries for selection 175 var repoAccess = new MvnRepoLookup().probe().resolve( 176 String.format("%s:%s:[0,%s)", groupId, artifactId, version)); 177 var baselineJar = repoAccess.resources( 178 of(MvnRepoLibraryJarFileType)).findFirst(); 179 if (baselineJar.isEmpty()) { 180 return Optional.of(new DefaultBndBaselineEvaluation( 181 BndBaselineEvaluationType, project(), lib.path()).name( 182 project().rootProject().relativize(lib.path()).toString()) 183 .withBaselineArtifactMissing()); 184 } 185 logger.atFinest().log("Baselining against %s", baselineJar); 186 187 return Optional.of(bndBaseline(baselineJar.get(), lib)); 188 } 189 190 @SuppressWarnings("PMD.AvoidCatchingGenericException") 191 private BndBaselineEvaluation bndBaseline(LibraryJarFile baseline, 192 LibraryJarFile current) { 193 try (Processor processor = new Processor(); 194 Jar baselineJar = new Jar(baseline.path().toFile()); 195 Jar currentJar = new Jar(current.path().toFile())) { 196 applyInstructions(processor); 197 DiffPluginImpl differ = new DiffPluginImpl(); 198 differ.setIgnore(processor.getProperty("-diffignore")); 199 Baseline baseliner = new Baseline(processor, differ); 200 201 List<Info> infos = baseliner.baseline(currentJar, baselineJar, 202 new Instructions(processor.getProperty("-diffpackages"))) 203 .stream() 204 .sorted(Comparator.comparing(info -> info.packageName)) 205 .toList(); 206 BundleInfo bundleInfo = baseliner.getBundleInfo(); 207 var reportLocation = writeReport(baselineJar, currentJar, 208 baseliner, infos, bundleInfo); 209 var result = new DefaultBndBaselineEvaluation( 210 BndBaselineEvaluationType, project(), baseline.path()) 211 .name(bundleInfo.bsn).withReportLocation(reportLocation); 212 if (bundleInfo.mismatch && !ignoreMismatched) { 213 result.setFaulty().withReason(bundleInfo.reason); 214 } 215 return result; 216 217 } catch (Exception e) { 218 throw new BuildException().from(this).cause(e); 219 } 220 } 221 222 @SuppressWarnings({ "PMD.AvoidCatchingGenericException", 223 "PMD.CognitiveComplexity", "PMD.CyclomaticComplexity", 224 "PMD.NPathComplexity" }) 225 private Path writeReport(Jar baselineJar, Jar currentJar, 226 Baseline baseliner, List<Info> infos, BundleInfo bundleInfo) { 227 // Copied from gradle plugin and improved 228 Path reportLocation = project().buildDirectory().resolve("reports"); 229 reportLocation.toFile().mkdirs(); 230 reportLocation = reportLocation.resolve( 231 String.format("%s-baseline.txt", currentJar.getName())); 232 try (var report = Files.newOutputStream(reportLocation); 233 Formatter fmt = new Formatter( 234 report, StandardCharsets.UTF_8, Locale.US)) { 235 var formatInfo = new FormatInfo(currentJar, baselineJar, bundleInfo, 236 infos); 237 String format = formatInfo.formatString(); 238 fmt.format(formatInfo.separatorLine()); 239 fmt.format(format, " ", "Name", "Type", "Delta", "New", "Old", 240 "Suggest", ""); 241 Diff diff = baseliner.getDiff(); 242 fmt.format(format, bundleInfo.mismatch ? "*" : " ", 243 bundleInfo.bsn, diff.getType(), diff.getDelta(), 244 currentJar.getVersion(), baselineJar.getVersion(), 245 bundleInfo.mismatch 246 && Objects.nonNull(bundleInfo.suggestedVersion) 247 ? bundleInfo.suggestedVersion 248 : "-", 249 ""); 250 if (bundleInfo.mismatch) { 251 fmt.format("%#2S\n", diff); 252 } 253 254 if (!infos.isEmpty()) { 255 fmt.format(formatInfo.separatorLine()); 256 fmt.format(format, " ", "Name", "Type", "Delta", "New", "Old", 257 "Suggest", "If Prov."); 258 for (Info info : infos) { 259 diff = info.packageDiff; 260 fmt.format(format, info.mismatch ? "*" : " ", 261 diff.getName(), diff.getType(), diff.getDelta(), 262 info.newerVersion, 263 Objects.nonNull(info.olderVersion) 264 && info.olderVersion 265 .equals(aQute.bnd.version.Version.LOWEST) 266 ? "-" 267 : info.olderVersion, 268 Objects.nonNull(info.suggestedVersion) 269 && info.suggestedVersion 270 .compareTo(info.newerVersion) <= 0 ? "ok" 271 : info.suggestedVersion, 272 Objects.nonNull(info.suggestedIfProviders) 273 ? info.suggestedIfProviders 274 : "-"); 275 if (info.mismatch) { 276 fmt.format("%#2S\n", diff); 277 } 278 } 279 } 280 fmt.flush(); 281 } catch (Exception e) { 282 throw new BuildException().from(this).cause(e); 283 } 284 return reportLocation; 285 } 286 287 /// The Class FormatInfo. 288 /// 289 private final class FormatInfo { 290 private final int maxNameLength; 291 private final int maxNewerLength; 292 private final int maxOlderLength; 293 294 /// Initializes a new format info. 295 /// 296 /// @param currentJar the current jar 297 /// @param baselineJar the baseline jar 298 /// @param bundleInfo the bundle info 299 /// @param infos the infos 300 /// @throws Exception the exception 301 /// 302 @SuppressWarnings("PMD.SignatureDeclareThrowsException") 303 private FormatInfo(Jar currentJar, Jar baselineJar, 304 BundleInfo bundleInfo, List<Info> infos) throws Exception { 305 maxNameLength = Math.max(bundleInfo.bsn.length(), infos.stream() 306 .map(info -> info.packageDiff.getName().length()) 307 .sorted(Comparator.reverseOrder()).findFirst().orElse(0)); 308 maxNewerLength = Math.max(currentJar.getVersion().length(), 309 infos.stream() 310 .map(info -> info.newerVersion.toString().length()) 311 .sorted(Comparator.reverseOrder()).findFirst().orElse(0)); 312 maxOlderLength = Math.max(baselineJar.getVersion().length(), 313 infos.stream() 314 .map(info -> info.olderVersion.toString().length()) 315 .sorted(Comparator.reverseOrder()).findFirst().orElse(0)); 316 } 317 318 /// Format string. 319 /// 320 /// @return the string 321 /// 322 private String formatString() { 323 return "%s %-" + maxNameLength + "s %-10s %-10s %-" 324 + maxNewerLength + "s %-" + maxOlderLength + "s %-10s %s\n"; 325 } 326 327 /// Separator string. 328 /// 329 /// @return the string 330 /// 331 private String separatorLine() { 332 return String.valueOf('=').repeat( 333 50 + maxNameLength + maxNewerLength + maxOlderLength) + "\n"; 334 } 335 } 336 337}