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