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.Collection; 033import java.util.Collections; 034import java.util.Comparator; 035import java.util.Formatter; 036import java.util.List; 037import java.util.Locale; 038import java.util.Map; 039import java.util.Objects; 040import java.util.Optional; 041import org.jdrupes.builder.api.BuildException; 042import org.jdrupes.builder.api.Generator; 043import static org.jdrupes.builder.api.Intent.Supply; 044import org.jdrupes.builder.api.Project; 045import static org.jdrupes.builder.api.Project.Properties.Version; 046import org.jdrupes.builder.api.Resource; 047import org.jdrupes.builder.api.ResourceRequest; 048import org.jdrupes.builder.api.ResourceType; 049import org.jdrupes.builder.api.Resources; 050import static org.jdrupes.builder.ext.bnd.BndTypes.*; 051import static org.jdrupes.builder.java.JavaTypes.*; 052import org.jdrupes.builder.java.LibraryJarFile; 053import static org.jdrupes.builder.mvnrepo.MvnProperties.*; 054import org.jdrupes.builder.mvnrepo.MvnRepoLookup; 055import static org.jdrupes.builder.mvnrepo.MvnRepoTypes.*; 056import org.jdrupes.builder.mvnrepo.PomFileGenerator; 057 058/// A [Generator] that performs a baseline evaluation between two OSGi 059/// bundles using the `bndlib` library [bnd](https://github.com/bndtools/bnd). 060/// 061/// Because OSGi repositories never became popular, Maven repository 062/// semantics are used to find the baseline bundle. The current bundle 063/// is the library supplied by the project. The [BndBaseliner] evaluates 064/// its Maven coordinates in the same way as the [PomFileGenerator] does. 065/// From these, coordinates used to lookup the previous version are derived 066/// in the form `groupId:artifactId:[,version)` 067/// 068/// The [BndBaseliner] then performs the baseline evaluation. Instructions 069/// `-diffignore` and `-diffpackages` are supported and forwarded to 070/// `bndlib`. 071/// 072/// This provider is made available as an extension. 073/// [ 075/// ](https://mvnrepository.com/artifact/org.jdrupes/jdbld-ext-bnd) 076/// 077@SuppressWarnings("PMD.TooManyStaticImports") 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().<String> get(GroupId); 163 var artifactId = Optional.ofNullable(project() 164 .<String> get(ArtifactId)).orElse(project().name()); 165 var version = project().<String> 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(report, "UTF-8", Locale.US)) { 234 var formatInfo = new FormatInfo(currentJar, baselineJar, bundleInfo, 235 infos); 236 String format = formatInfo.formatString(); 237 fmt.format(formatInfo.separatorLine()); 238 fmt.format(format, " ", "Name", "Type", "Delta", "New", "Old", 239 "Suggest", ""); 240 Diff diff = baseliner.getDiff(); 241 fmt.format(format, bundleInfo.mismatch ? "*" : " ", 242 bundleInfo.bsn, diff.getType(), diff.getDelta(), 243 currentJar.getVersion(), baselineJar.getVersion(), 244 bundleInfo.mismatch 245 && Objects.nonNull(bundleInfo.suggestedVersion) 246 ? bundleInfo.suggestedVersion 247 : "-", 248 ""); 249 if (bundleInfo.mismatch) { 250 fmt.format("%#2S\n", diff); 251 } 252 253 if (!infos.isEmpty()) { 254 fmt.format(formatInfo.separatorLine()); 255 fmt.format(format, " ", "Name", "Type", "Delta", "New", "Old", 256 "Suggest", "If Prov."); 257 for (Info info : infos) { 258 diff = info.packageDiff; 259 fmt.format(format, info.mismatch ? "*" : " ", 260 diff.getName(), diff.getType(), diff.getDelta(), 261 info.newerVersion, 262 Objects.nonNull(info.olderVersion) 263 && info.olderVersion 264 .equals(aQute.bnd.version.Version.LOWEST) 265 ? "-" 266 : info.olderVersion, 267 Objects.nonNull(info.suggestedVersion) 268 && info.suggestedVersion 269 .compareTo(info.newerVersion) <= 0 ? "ok" 270 : info.suggestedVersion, 271 Objects.nonNull(info.suggestedIfProviders) 272 ? info.suggestedIfProviders 273 : "-"); 274 if (info.mismatch) { 275 fmt.format("%#2S\n", diff); 276 } 277 } 278 } 279 fmt.flush(); 280 } catch (Exception e) { 281 throw new BuildException().from(this).cause(e); 282 } 283 return reportLocation; 284 } 285 286 /// The Class FormatInfo. 287 /// 288 private final class FormatInfo { 289 private final int maxNameLength; 290 private final int maxNewerLength; 291 private final int maxOlderLength; 292 293 /// Initializes a new format info. 294 /// 295 /// @param currentJar the current jar 296 /// @param baselineJar the baseline jar 297 /// @param bundleInfo the bundle info 298 /// @param infos the infos 299 /// @throws Exception the exception 300 /// 301 @SuppressWarnings("PMD.SignatureDeclareThrowsException") 302 private FormatInfo(Jar currentJar, Jar baselineJar, 303 BundleInfo bundleInfo, List<Info> infos) throws Exception { 304 maxNameLength = Math.max(bundleInfo.bsn.length(), infos.stream() 305 .map(info -> info.packageDiff.getName().length()) 306 .sorted(Comparator.reverseOrder()).findFirst().orElse(0)); 307 maxNewerLength = Math.max(currentJar.getVersion().length(), 308 infos.stream() 309 .map(info -> info.newerVersion.toString().length()) 310 .sorted(Comparator.reverseOrder()).findFirst().orElse(0)); 311 maxOlderLength = Math.max(baselineJar.getVersion().length(), 312 infos.stream() 313 .map(info -> info.olderVersion.toString().length()) 314 .sorted(Comparator.reverseOrder()).findFirst().orElse(0)); 315 } 316 317 /// Format string. 318 /// 319 /// @return the string 320 /// 321 private String formatString() { 322 return "%s %-" + maxNameLength + "s %-10s %-10s %-" 323 + maxNewerLength + "s %-" + maxOlderLength + "s %-10s %s\n"; 324 } 325 326 /// Separator string. 327 /// 328 /// @return the string 329 /// 330 private String separatorLine() { 331 return String.valueOf('=').repeat( 332 50 + maxNameLength + maxNewerLength + maxOlderLength) + "\n"; 333 } 334 } 335 336}