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