|
| 1 | +/* |
| 2 | + * Copyright 2026 Flamingock (https://www.flamingock.io) |
| 3 | + * |
| 4 | + * Licensed under the Apache License, Version 2.0 (the "License"); |
| 5 | + * you may not use this file except in compliance with the License. |
| 6 | + * You may obtain a copy of the License at |
| 7 | + * |
| 8 | + * http://www.apache.org/licenses/LICENSE-2.0 |
| 9 | + * |
| 10 | + * Unless required by applicable law or agreed to in writing, software |
| 11 | + * distributed under the License is distributed on an "AS IS" BASIS, |
| 12 | + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 13 | + * See the License for the specific language governing permissions and |
| 14 | + * limitations under the License. |
| 15 | + */ |
| 16 | +package io.flamingock.internal.common.core.response.data; |
| 17 | + |
| 18 | +import io.flamingock.internal.common.core.recovery.RecoveryIssue; |
| 19 | + |
| 20 | +import java.time.Instant; |
| 21 | +import java.util.ArrayList; |
| 22 | +import java.util.Collections; |
| 23 | +import java.util.List; |
| 24 | +import java.util.stream.Collectors; |
| 25 | + |
| 26 | +/** |
| 27 | + * Canonical renderer for {@link ExecuteResponseData}. Two outputs: |
| 28 | + * |
| 29 | + * <ul> |
| 30 | + * <li>{@link #summary(ExecuteResponseData)} — single-line, log-aggregator-friendly. |
| 31 | + * Used by {@code ExecuteOperationException#getMessage()}.</li> |
| 32 | + * <li>{@link #report(ExecuteResponseData)} — multi-line, fixed-width block intended for SLF4J |
| 33 | + * streams and {@code ExecuteOperationException#toString()}.</li> |
| 34 | + * </ul> |
| 35 | + * |
| 36 | + * <p>Both methods are fully defensive: null result, empty stages, missing per-stage state, and |
| 37 | + * partial data are tolerated; the formatter never throws. The default event listener and the |
| 38 | + * exception {@code toString()} both depend on this contract. |
| 39 | + */ |
| 40 | +public final class ExecutionReportFormatter { |
| 41 | + |
| 42 | + private static final String LINE = "========================================================================"; |
| 43 | + private static final String NEWLINE = System.lineSeparator(); |
| 44 | + |
| 45 | + private ExecutionReportFormatter() { |
| 46 | + } |
| 47 | + |
| 48 | + public static String summary(ExecuteResponseData result) { |
| 49 | + if (result == null) { |
| 50 | + return "Flamingock execution: no execution data available"; |
| 51 | + } |
| 52 | + |
| 53 | + List<StageResult> stages = nonNullStages(result); |
| 54 | + List<String> failedStageNames = failedStageNames(stages); |
| 55 | + List<String> miChangeIds = manualInterventionChangeIds(stages); |
| 56 | + |
| 57 | + String headline; |
| 58 | + if (isFailedStatus(result.getStatus()) || !failedStageNames.isEmpty()) { |
| 59 | + headline = String.format( |
| 60 | + "Flamingock execution failed: %d of %d stage(s) failed [%s]", |
| 61 | + result.getFailedStages(), |
| 62 | + result.getTotalStages(), |
| 63 | + String.join(", ", failedStageNames)); |
| 64 | + } else { |
| 65 | + headline = String.format( |
| 66 | + "Flamingock execution completed: %d stage(s)", |
| 67 | + result.getTotalStages()); |
| 68 | + } |
| 69 | + |
| 70 | + String counts = String.format( |
| 71 | + "; changes applied=%d, failed=%d, skipped=%d; duration=%dms", |
| 72 | + result.getAppliedChanges(), |
| 73 | + result.getFailedChanges(), |
| 74 | + result.getSkippedChanges(), |
| 75 | + result.getTotalDurationMs()); |
| 76 | + |
| 77 | + StringBuilder sb = new StringBuilder(headline).append(counts); |
| 78 | + if (!miChangeIds.isEmpty()) { |
| 79 | + sb.append("; manual intervention required for change(s): ") |
| 80 | + .append(String.join(", ", miChangeIds)); |
| 81 | + } |
| 82 | + return sb.toString(); |
| 83 | + } |
| 84 | + |
| 85 | + public static String report(ExecuteResponseData result) { |
| 86 | + if (result == null) { |
| 87 | + return banner("Flamingock execution report — NO DATA"); |
| 88 | + } |
| 89 | + |
| 90 | + StringBuilder sb = new StringBuilder(); |
| 91 | + String statusLabel = statusLabel(result); |
| 92 | + sb.append(LINE).append(NEWLINE) |
| 93 | + .append(" Flamingock execution report — ").append(statusLabel).append(NEWLINE) |
| 94 | + .append(LINE).append(NEWLINE); |
| 95 | + |
| 96 | + appendTimeBlock(sb, result); |
| 97 | + sb.append(NEWLINE); |
| 98 | + |
| 99 | + sb.append(" Stages: ") |
| 100 | + .append(result.getTotalStages()).append(" total — ") |
| 101 | + .append(result.getCompletedStages()).append(" completed, ") |
| 102 | + .append(result.getFailedStages()).append(" failed") |
| 103 | + .append(NEWLINE); |
| 104 | + sb.append(" Changes: ") |
| 105 | + .append(result.getTotalChanges()).append(" total — ") |
| 106 | + .append(result.getAppliedChanges()).append(" applied, ") |
| 107 | + .append(result.getSkippedChanges()).append(" skipped, ") |
| 108 | + .append(result.getFailedChanges()).append(" failed") |
| 109 | + .append(NEWLINE); |
| 110 | + |
| 111 | + List<StageResult> stages = nonNullStages(result); |
| 112 | + if (!stages.isEmpty()) { |
| 113 | + sb.append(NEWLINE).append(" Per-stage breakdown:").append(NEWLINE).append(NEWLINE); |
| 114 | + for (StageResult stage : stages) { |
| 115 | + appendStageBlock(sb, stage); |
| 116 | + sb.append(NEWLINE); |
| 117 | + } |
| 118 | + } |
| 119 | + |
| 120 | + sb.append(LINE); |
| 121 | + return sb.toString(); |
| 122 | + } |
| 123 | + |
| 124 | + private static void appendTimeBlock(StringBuilder sb, ExecuteResponseData result) { |
| 125 | + Instant start = result.getStartTime(); |
| 126 | + Instant end = result.getEndTime(); |
| 127 | + sb.append(" Started: ").append(start != null ? start.toString() : "n/a").append(NEWLINE); |
| 128 | + sb.append(" Finished: ").append(end != null ? end.toString() : "n/a").append(NEWLINE); |
| 129 | + sb.append(" Duration: ").append(result.getTotalDurationMs()).append(" ms").append(NEWLINE); |
| 130 | + } |
| 131 | + |
| 132 | + private static void appendStageBlock(StringBuilder sb, StageResult stage) { |
| 133 | + String label = stageLabel(stage); |
| 134 | + String name = stage.getStageName() != null ? stage.getStageName() : "(unnamed)"; |
| 135 | + sb.append(" ").append(label).append(' ').append(name) |
| 136 | + .append(" (").append(stage.getDurationMs()).append(" ms)").append(NEWLINE); |
| 137 | + |
| 138 | + List<ChangeResult> changes = stage.getChanges() != null ? stage.getChanges() : Collections.emptyList(); |
| 139 | + int applied = (int) changes.stream().filter(c -> c != null && c.isApplied()).count(); |
| 140 | + int skipped = (int) changes.stream().filter(c -> c != null && c.isAlreadyApplied()).count(); |
| 141 | + int failed = (int) changes.stream().filter(c -> c != null && c.isFailed()).count(); |
| 142 | + sb.append(" changes: ") |
| 143 | + .append(applied).append(" applied, ") |
| 144 | + .append(skipped).append(" skipped, ") |
| 145 | + .append(failed).append(" failed") |
| 146 | + .append(NEWLINE); |
| 147 | + |
| 148 | + StageState state = stage.getState(); |
| 149 | + if (state == null) { |
| 150 | + return; |
| 151 | + } |
| 152 | + if (state.isBlockedForManualIntervention()) { |
| 153 | + List<String> ids = changeIdsFromRecoveryIssues(state.getRecoveryIssues()); |
| 154 | + if (!ids.isEmpty()) { |
| 155 | + sb.append(" change(s) requiring intervention: ") |
| 156 | + .append(String.join(", ", ids)).append(NEWLINE); |
| 157 | + } |
| 158 | + } else if (state.isFailed()) { |
| 159 | + state.getErrorInfo().ifPresent(err -> appendErrorBlock(sb, err)); |
| 160 | + List<String> failedChangeIds = failedChangeIds(stage); |
| 161 | + if (!failedChangeIds.isEmpty()) { |
| 162 | + sb.append(" failed change(s): ") |
| 163 | + .append(String.join(", ", failedChangeIds)).append(NEWLINE); |
| 164 | + } |
| 165 | + } |
| 166 | + } |
| 167 | + |
| 168 | + private static void appendErrorBlock(StringBuilder sb, ErrorInfo err) { |
| 169 | + String errorType = err.getErrorType() != null ? err.getErrorType() : "Error"; |
| 170 | + sb.append(" error: ").append(errorType).append(NEWLINE); |
| 171 | + String message = err.getMessage(); |
| 172 | + if (message != null && !message.isEmpty()) { |
| 173 | + // Indent continuation lines so multi-line messages stay aligned with the "error:" column. |
| 174 | + String[] lines = message.split("\\R"); |
| 175 | + for (String line : lines) { |
| 176 | + sb.append(" ").append(line).append(NEWLINE); |
| 177 | + } |
| 178 | + } |
| 179 | + } |
| 180 | + |
| 181 | + private static String stageLabel(StageResult stage) { |
| 182 | + StageState state = stage.getState(); |
| 183 | + if (state == null) { |
| 184 | + return "[UNKNOWN] "; |
| 185 | + } |
| 186 | + if (state.isBlockedForManualIntervention()) { |
| 187 | + return "[BLOCKED — manual intervention required]"; |
| 188 | + } |
| 189 | + if (state.isFailed()) { |
| 190 | + return "[FAILED] "; |
| 191 | + } |
| 192 | + if (state.isCompleted()) { |
| 193 | + return "[COMPLETED]"; |
| 194 | + } |
| 195 | + if (state.isStarted()) { |
| 196 | + return "[STARTED] "; |
| 197 | + } |
| 198 | + if (state.isNotStarted()) { |
| 199 | + return "[PENDING] "; |
| 200 | + } |
| 201 | + return "[UNKNOWN] "; |
| 202 | + } |
| 203 | + |
| 204 | + private static String statusLabel(ExecuteResponseData result) { |
| 205 | + ExecutionStatus status = result.getStatus(); |
| 206 | + if (status == null) { |
| 207 | + return "UNKNOWN"; |
| 208 | + } |
| 209 | + return status.name(); |
| 210 | + } |
| 211 | + |
| 212 | + private static boolean isFailedStatus(ExecutionStatus status) { |
| 213 | + return status == ExecutionStatus.FAILED; |
| 214 | + } |
| 215 | + |
| 216 | + private static List<StageResult> nonNullStages(ExecuteResponseData result) { |
| 217 | + List<StageResult> raw = result.getStages(); |
| 218 | + if (raw == null) { |
| 219 | + return Collections.emptyList(); |
| 220 | + } |
| 221 | + List<StageResult> filtered = new ArrayList<>(raw.size()); |
| 222 | + for (StageResult s : raw) { |
| 223 | + if (s != null) { |
| 224 | + filtered.add(s); |
| 225 | + } |
| 226 | + } |
| 227 | + return filtered; |
| 228 | + } |
| 229 | + |
| 230 | + private static List<String> failedStageNames(List<StageResult> stages) { |
| 231 | + return stages.stream() |
| 232 | + .filter(s -> s.getState() != null && s.getState().isFailed()) |
| 233 | + .map(s -> s.getStageName() != null ? s.getStageName() : "(unnamed)") |
| 234 | + .collect(Collectors.toList()); |
| 235 | + } |
| 236 | + |
| 237 | + private static List<String> manualInterventionChangeIds(List<StageResult> stages) { |
| 238 | + List<String> ids = new ArrayList<>(); |
| 239 | + for (StageResult s : stages) { |
| 240 | + StageState state = s.getState(); |
| 241 | + if (state != null && state.isBlockedForManualIntervention()) { |
| 242 | + ids.addAll(changeIdsFromRecoveryIssues(state.getRecoveryIssues())); |
| 243 | + } |
| 244 | + } |
| 245 | + return ids; |
| 246 | + } |
| 247 | + |
| 248 | + private static List<String> changeIdsFromRecoveryIssues(List<RecoveryIssue> issues) { |
| 249 | + if (issues == null) { |
| 250 | + return Collections.emptyList(); |
| 251 | + } |
| 252 | + return issues.stream() |
| 253 | + .filter(i -> i != null && i.getChangeId() != null) |
| 254 | + .map(RecoveryIssue::getChangeId) |
| 255 | + .collect(Collectors.toList()); |
| 256 | + } |
| 257 | + |
| 258 | + private static List<String> failedChangeIds(StageResult stage) { |
| 259 | + List<ChangeResult> changes = stage.getChanges(); |
| 260 | + if (changes == null) { |
| 261 | + return Collections.emptyList(); |
| 262 | + } |
| 263 | + return changes.stream() |
| 264 | + .filter(c -> c != null && c.isFailed() && c.getChangeId() != null) |
| 265 | + .map(ChangeResult::getChangeId) |
| 266 | + .collect(Collectors.toList()); |
| 267 | + } |
| 268 | + |
| 269 | + private static String banner(String headline) { |
| 270 | + return LINE + NEWLINE + " " + headline + NEWLINE + LINE; |
| 271 | + } |
| 272 | +} |
0 commit comments