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://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}