From a34e5937102ae0657297023f4b8165bcb2170125 Mon Sep 17 00:00:00 2001
From: "google-labs-jules[bot]"
<161369871+google-labs-jules[bot]@users.noreply.github.com>
Date: Thu, 22 May 2025 19:58:11 +0000
Subject: [PATCH 01/22] Jules was unable to complete the task in time. Please
review the work done so far and provide feedback for Jules to continue.
---
pom.xml | 12 +
.../reporter/model/ExcelParserConfig.java | 20 ++
.../parser/AbstractReportParserBase.java | 218 +++++++++++++
.../reporter/parser/BaseExcelParser.java | 190 ++++++++++++
.../parser/ExcelMultiReportParser.java | 288 ++++++++++++++++++
.../reporter/parser/ExcelReportParser.java | 264 ++++++++++++++++
.../plugins/reporter/provider/Csv.java | 265 ++++++++--------
.../reporter/provider/ExcelMultiProvider.java | 50 +++
.../reporter/provider/ExcelProvider.java | 50 +++
.../parser/ExcelMultiReportParserTest.java | 254 +++++++++++++++
.../parser/ExcelReportParserTest.java | 205 +++++++++++++
.../provider/CsvCustomParserTest.java | 260 ++++++++++++++++
.../plugins/reporter/provider/alpha.xlsx | Bin 0 -> 4473 bytes
.../plugins/reporter/provider/beta.xlsx | Bin 0 -> 4471 bytes
.../sample_csv_leading_empty_lines.csv | 3 +
.../sample_csv_mixed_hierarchy_values.csv | 5 +
.../provider/sample_csv_no_numeric.csv | 3 +
.../provider/sample_csv_only_values.csv | 3 +
.../provider/sample_csv_semicolon.csv | 3 +
.../reporter/provider/sample_csv_standard.csv | 3 +
.../reporter/provider/sample_csv_tab.csv | 3 +
.../provider/sample_excel_empty_sheet.csv | 0
.../provider/sample_excel_empty_sheet.xlsx | Bin 0 -> 4310 bytes
...cel_multi_consistent_sheet1_Data_Alpha.csv | 3 +
...el_multi_consistent_sheet1_Data_Alpha.xlsx | Bin 0 -> 4473 bytes
...xcel_multi_consistent_sheet2_Data_Beta.csv | 3 +
...cel_multi_consistent_sheet2_Data_Beta.xlsx | Bin 0 -> 4472 bytes
...lti_inconsistent_header_sheet1_Metrics.csv | 2 +
...ti_inconsistent_header_sheet1_Metrics.xlsx | Bin 0 -> 4446 bytes
...multi_inconsistent_header_sheet2_Stats.csv | 2 +
...ulti_inconsistent_header_sheet2_Stats.xlsx | Bin 0 -> 4444 bytes
.../provider/sample_excel_no_header.csv | 2 +
.../provider/sample_excel_no_header.xlsx | Bin 0 -> 4383 bytes
.../provider/sample_excel_only_header.csv | 1 +
.../provider/sample_excel_only_header.xlsx | Bin 0 -> 4382 bytes
.../provider/sample_excel_single_sheet.csv | 7 +
.../provider/sample_excel_single_sheet.xlsx | Bin 0 -> 4835 bytes
.../reporter/provider/temp_multi.gnumeric | Bin 0 -> 1736 bytes
38 files changed, 1991 insertions(+), 128 deletions(-)
create mode 100644 src/main/java/io/jenkins/plugins/reporter/model/ExcelParserConfig.java
create mode 100644 src/main/java/io/jenkins/plugins/reporter/parser/AbstractReportParserBase.java
create mode 100644 src/main/java/io/jenkins/plugins/reporter/parser/BaseExcelParser.java
create mode 100644 src/main/java/io/jenkins/plugins/reporter/parser/ExcelMultiReportParser.java
create mode 100644 src/main/java/io/jenkins/plugins/reporter/parser/ExcelReportParser.java
create mode 100644 src/main/java/io/jenkins/plugins/reporter/provider/ExcelMultiProvider.java
create mode 100644 src/main/java/io/jenkins/plugins/reporter/provider/ExcelProvider.java
create mode 100644 src/test/java/io/jenkins/plugins/reporter/parser/ExcelMultiReportParserTest.java
create mode 100644 src/test/java/io/jenkins/plugins/reporter/parser/ExcelReportParserTest.java
create mode 100644 src/test/java/io/jenkins/plugins/reporter/provider/CsvCustomParserTest.java
create mode 100644 src/test/resources/io/jenkins/plugins/reporter/provider/alpha.xlsx
create mode 100644 src/test/resources/io/jenkins/plugins/reporter/provider/beta.xlsx
create mode 100644 src/test/resources/io/jenkins/plugins/reporter/provider/sample_csv_leading_empty_lines.csv
create mode 100644 src/test/resources/io/jenkins/plugins/reporter/provider/sample_csv_mixed_hierarchy_values.csv
create mode 100644 src/test/resources/io/jenkins/plugins/reporter/provider/sample_csv_no_numeric.csv
create mode 100644 src/test/resources/io/jenkins/plugins/reporter/provider/sample_csv_only_values.csv
create mode 100644 src/test/resources/io/jenkins/plugins/reporter/provider/sample_csv_semicolon.csv
create mode 100644 src/test/resources/io/jenkins/plugins/reporter/provider/sample_csv_standard.csv
create mode 100644 src/test/resources/io/jenkins/plugins/reporter/provider/sample_csv_tab.csv
create mode 100644 src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_empty_sheet.csv
create mode 100644 src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_empty_sheet.xlsx
create mode 100644 src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_multi_consistent_sheet1_Data_Alpha.csv
create mode 100644 src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_multi_consistent_sheet1_Data_Alpha.xlsx
create mode 100644 src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_multi_consistent_sheet2_Data_Beta.csv
create mode 100644 src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_multi_consistent_sheet2_Data_Beta.xlsx
create mode 100644 src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_multi_inconsistent_header_sheet1_Metrics.csv
create mode 100644 src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_multi_inconsistent_header_sheet1_Metrics.xlsx
create mode 100644 src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_multi_inconsistent_header_sheet2_Stats.csv
create mode 100644 src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_multi_inconsistent_header_sheet2_Stats.xlsx
create mode 100644 src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_no_header.csv
create mode 100644 src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_no_header.xlsx
create mode 100644 src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_only_header.csv
create mode 100644 src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_only_header.xlsx
create mode 100644 src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_single_sheet.csv
create mode 100644 src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_single_sheet.xlsx
create mode 100644 src/test/resources/io/jenkins/plugins/reporter/provider/temp_multi.gnumeric
diff --git a/pom.xml b/pom.xml
index aba68c38..006833f4 100644
--- a/pom.xml
+++ b/pom.xml
@@ -117,6 +117,18 @@
jackson2-api
+
+
+ org.apache.poi
+ poi
+ 5.4.1
+
+
+ org.apache.poi
+ poi-ooxml
+ 5.4.1
+
+
org.jenkins-ci.plugins.workflow
diff --git a/src/main/java/io/jenkins/plugins/reporter/model/ExcelParserConfig.java b/src/main/java/io/jenkins/plugins/reporter/model/ExcelParserConfig.java
new file mode 100644
index 00000000..6e19bb5c
--- /dev/null
+++ b/src/main/java/io/jenkins/plugins/reporter/model/ExcelParserConfig.java
@@ -0,0 +1,20 @@
+package io.jenkins.plugins.reporter.model;
+
+import java.io.Serializable;
+
+public class ExcelParserConfig implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ // Future configuration options can be added here, for example:
+ // private int headerRowIndex = 0; // Default to the first row
+ // private int dataStartRowIndex = 1; // Default to the second row
+ // private String sheetName; // For single sheet parsing, if specified
+ // private boolean detectHeadersAutomatically = true;
+
+ public ExcelParserConfig() {
+ // Default constructor
+ }
+
+ // Add getters and setters here if fields are added in the future.
+}
diff --git a/src/main/java/io/jenkins/plugins/reporter/parser/AbstractReportParserBase.java b/src/main/java/io/jenkins/plugins/reporter/parser/AbstractReportParserBase.java
new file mode 100644
index 00000000..bcfed251
--- /dev/null
+++ b/src/main/java/io/jenkins/plugins/reporter/parser/AbstractReportParserBase.java
@@ -0,0 +1,218 @@
+package io.jenkins.plugins.reporter.parser;
+
+import io.jenkins.plugins.reporter.model.Item;
+import io.jenkins.plugins.reporter.model.ReportDto;
+import io.jenkins.plugins.reporter.model.ReportParser; // Extends this
+import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.lang3.math.NumberUtils;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Optional;
+import java.util.logging.Logger;
+
+
+public abstract class AbstractReportParserBase extends ReportParser {
+
+ private static final long serialVersionUID = 5738290018231028471L; // New UID
+ protected static final Logger PARSER_LOGGER = Logger.getLogger(AbstractReportParserBase.class.getName());
+
+ /**
+ * Detects the column structure (hierarchy vs. value columns) of a report.
+ *
+ * @param header The list of header strings.
+ * @param firstDataRow A list of string values from the first representative data row.
+ * @param messagesCollector A list to collect informational/warning messages.
+ * @param parserName A short name of the parser type (e.g., "CSV", "Excel") for message logging.
+ * @return The starting column index for value/numeric data. Returns -1 if structure cannot be determined or is invalid.
+ */
+ protected int detectColumnStructure(List header, List firstDataRow, List messagesCollector, String parserName) {
+ if (header == null || header.isEmpty()) {
+ messagesCollector.add(String.format("Warning [%s]: Header is empty, cannot detect column structure.", parserName));
+ return -1;
+ }
+ if (firstDataRow == null || firstDataRow.isEmpty()) {
+ messagesCollector.add(String.format("Warning [%s]: First data row is empty, cannot reliably detect column structure.", parserName));
+ // Proceed assuming last column is value if header has multiple columns, else ambiguous.
+ if (header.size() > 1) {
+ messagesCollector.add(String.format("Info [%s]: Defaulting structure: Assuming last column ('%s') for values due to empty first data row.", parserName, header.get(header.size() -1)));
+ return header.size() - 1;
+ } else if (header.size() == 1) {
+ messagesCollector.add(String.format("Info [%s]: Single column header ('%s') and empty first data row. Structure ambiguous.", parserName, header.get(0)));
+ return 0; // Treat as value column by default
+ }
+ return -1;
+ }
+
+ int determinedColIdxValueStart = 0;
+ for (int cIdx = header.size() - 1; cIdx >= 0; cIdx--) {
+ String cellVal = (cIdx < firstDataRow.size()) ? firstDataRow.get(cIdx) : "";
+ if (NumberUtils.isCreatable(cellVal)) {
+ determinedColIdxValueStart = cIdx;
+ } else {
+ if (determinedColIdxValueStart > cIdx && determinedColIdxValueStart != 0) {
+ break;
+ }
+ }
+ }
+
+ if (determinedColIdxValueStart == 0 && !NumberUtils.isCreatable(firstDataRow.get(0))) {
+ if (header.size() > 1) {
+ determinedColIdxValueStart = header.size() - 1;
+ messagesCollector.add(String.format("Warning [%s]: No numeric columns auto-detected. Assuming last column ('%s') for values.", parserName, header.get(determinedColIdxValueStart)));
+ } else {
+ messagesCollector.add(String.format("Info [%s]: Single text column ('%s'). No numeric data values expected.", parserName, header.get(0)));
+ }
+ } else if (determinedColIdxValueStart == 0 && NumberUtils.isCreatable(firstDataRow.get(0))) {
+ messagesCollector.add(String.format("Info [%s]: First column ('%s') is numeric. Treating it as the first value column.", parserName, header.get(0)));
+ }
+
+ messagesCollector.add(String.format("Info [%s]: Detected data structure: Hierarchy/Text columns: 0 to %d, Value/Numeric columns: %d to %d.",
+ parserName, Math.max(0, determinedColIdxValueStart - 1), determinedColIdxValueStart, header.size() - 1));
+
+ if (determinedColIdxValueStart >= header.size() || determinedColIdxValueStart < 0) {
+ messagesCollector.add(String.format("Error [%s]: Invalid structure detected (value_start_index %d out of bounds for header size %d).",
+ parserName, determinedColIdxValueStart, header.size()));
+ return -1; // Invalid structure
+ }
+ return determinedColIdxValueStart;
+ }
+
+ /**
+ * Parses a single row of data and converts it into hierarchical Item objects.
+ *
+ * @param reportDto The ReportDto to which items will be added.
+ * @param rowValues The list of string values for the current row.
+ * @param header The list of header strings.
+ * @param colIdxValueStart The starting column index for value data.
+ * @param baseItemIdPrefix A prefix for generating item IDs (e.g., reportId or sheet-specific ID).
+ * @param messagesCollector A list to collect informational/warning messages.
+ * @param parserName A short name of the parser type (e.g., "CSV", "Excel") for message logging.
+ */
+ protected void parseRowToItems(ReportDto reportDto, List rowValues, List header,
+ int colIdxValueStart, String baseItemIdPrefix,
+ List messagesCollector, String parserName, int rowIndexForLog) {
+
+ if (rowValues == null || rowValues.isEmpty()) {
+ messagesCollector.add(String.format("Info [%s]: Skipped empty row at data index %d.", parserName, rowIndexForLog));
+ return;
+ }
+
+ // If row is shorter than expected hierarchy columns, it might be problematic.
+ if (rowValues.size() < colIdxValueStart && colIdxValueStart > 0) {
+ messagesCollector.add(String.format("Warning [%s]: Skipped data row at index %d: Row has %d cells, but hierarchy part expects at least %d.",
+ parserName, rowIndexForLog, rowValues.size(), colIdxValueStart));
+ return;
+ }
+
+ String parentId = "report"; // Special root parent ID for top-level items
+ Item lastItem = null;
+ boolean lastItemWasNewlyCreated = false;
+ LinkedHashMap resultValuesMap = new LinkedHashMap<>();
+ boolean issueInHierarchy = false;
+ String currentItemPathId = StringUtils.isNotBlank(baseItemIdPrefix) ? baseItemIdPrefix + "::" : "";
+
+ for (int colIdx = 0; colIdx < header.size(); colIdx++) {
+ String headerName = header.get(colIdx);
+ String rawCellValue = (colIdx < rowValues.size() && rowValues.get(colIdx) != null) ? rowValues.get(colIdx).trim() : "";
+
+ if (colIdx < colIdxValueStart) { // This column is part of the hierarchy
+ String hierarchyCellValue = rawCellValue;
+ String originalCellValueForName = rawCellValue;
+
+ if (StringUtils.isBlank(hierarchyCellValue)) {
+ if (colIdx == 0) { // First hierarchy column cannot be blank
+ messagesCollector.add(String.format("Warning [%s]: Skipped data row at index %d: First hierarchy column ('%s') is empty.",
+ parserName, rowIndexForLog, headerName));
+ issueInHierarchy = true;
+ break;
+ }
+ messagesCollector.add(String.format("Info [%s]: Data row index %d, Col %d (Header '%s') is part of hierarchy and is blank. Using placeholder ID part.",
+ parserName, rowIndexForLog, colIdx + 1, headerName));
+ hierarchyCellValue = "blank_hier_" + colIdx; // Use placeholder for ID generation
+ } else if (NumberUtils.isCreatable(hierarchyCellValue)) {
+ messagesCollector.add(String.format("Info [%s]: Data row index %d, Col %d (Header '%s') is part of hierarchy but is numeric-like ('%s'). Using as string for ID/Name.",
+ parserName, rowIndexForLog, colIdx + 1, headerName, hierarchyCellValue));
+ }
+ // Check if a non-empty hierarchy cell appears after a blank one (if issueInHierarchy was set due to blank)
+ // This check is usually done by comparing originalCellValue with a flag set by previous blank cell.
+ // For simplicity here, we assume the `break` for colIdx == 0 handles the critical case.
+
+ currentItemPathId += hierarchyCellValue.replaceAll("[^a-zA-Z0-9_-]", "_") + "_";
+ String itemId = StringUtils.removeEnd(currentItemPathId, "_");
+ if (StringUtils.isBlank(itemId)) { // Should not happen if baseItemIdPrefix is good and placeholders are used
+ itemId = baseItemIdPrefix + "::unnamed_item_r" + rowIndexForLog + "_c" + colIdx;
+ }
+
+ Optional- parentOpt = reportDto.findItem(parentId, reportDto.getItems());
+ Item currentItem = new Item();
+ currentItem.setId(StringUtils.abbreviate(itemId, 250)); // Ensure ID is not excessively long
+ currentItem.setName(StringUtils.isBlank(originalCellValueForName) ? "(blank)" : originalCellValueForName);
+ lastItemWasNewlyCreated = false;
+
+ if (parentOpt.isPresent()) {
+ Item p = parentOpt.get();
+ if (p.getItems() == null) p.setItems(new ArrayList<>());
+
+ Optional
- existingItem = p.getItems().stream().filter(it -> it.getId().equals(currentItem.getId())).findFirst();
+ if (!existingItem.isPresent()) {
+ p.addItem(currentItem);
+ lastItemWasNewlyCreated = true;
+ lastItem = currentItem;
+ } else {
+ lastItem = existingItem.get();
+ }
+ } else { // No parent found, this is a top-level item in the current context (under "report")
+ Optional
- existingRootItem = reportDto.getItems().stream().filter(it -> it.getId().equals(currentItem.getId())).findFirst();
+ if (!existingRootItem.isPresent()) {
+ reportDto.addItem(currentItem);
+ lastItemWasNewlyCreated = true;
+ lastItem = currentItem;
+ } else {
+ lastItem = existingRootItem.get();
+ }
+ }
+ parentId = currentItem.getId(); // For the next level of hierarchy
+
+ } else { // This column is part of the values
+ Number numValue = 0;
+ if (NumberUtils.isCreatable(rawCellValue)) {
+ numValue = NumberUtils.createNumber(rawCellValue);
+ } else if (StringUtils.isNotBlank(rawCellValue)) {
+ messagesCollector.add(String.format("Warning [%s]: Non-numeric value '%s' in data column '%s' at data row index %d, col %d. Using 0.",
+ parserName, rawCellValue, headerName, rowIndexForLog, colIdx + 1));
+ }
+ resultValuesMap.put(headerName, numValue.intValue());
+ }
+ } // End column loop
+
+ if (issueInHierarchy) {
+ return; // Row processing was aborted
+ }
+
+ if (lastItem != null) { // A hierarchy item was identified or created for this row
+ if (lastItem.getResult() == null || lastItemWasNewlyCreated) {
+ lastItem.setResult(resultValuesMap);
+ } else {
+ // Item existed and had results. Decide on merging or warning.
+ // For now, log and don't overwrite unless explicitly designed for merging.
+ messagesCollector.add(String.format("Info [%s]: Item '%s' (data row index %d) already had results. New values for this row were: %s. Not overwriting existing results.",
+ parserName, lastItem.getId(), rowIndexForLog, resultValuesMap.toString()));
+ }
+ } else if (!resultValuesMap.isEmpty()) { // No hierarchy columns (colIdxValueStart == 0), but values exist
+ Item valueItem = new Item();
+ String generatedId = (StringUtils.isNotBlank(baseItemIdPrefix) ? baseItemIdPrefix + "::" : "") + "DataRow_" + rowIndexForLog;
+ valueItem.setId(StringUtils.abbreviate(generatedId.replaceAll("[^a-zA-Z0-9_.-]", "_"), 100));
+ valueItem.setName("Data Row " + (rowIndexForLog + 1)); // User-friendly name
+ valueItem.setResult(resultValuesMap);
+ reportDto.addItem(valueItem);
+ messagesCollector.add(String.format("Info [%s]: Data row index %d created as a direct data item '%s' as no distinct hierarchy path was formed (or colIdxValueStart was 0).",
+ parserName, rowIndexForLog, valueItem.getName()));
+ } else if (lastItem == null && resultValuesMap.isEmpty() && header.size() > 0) {
+ // This means the row was processed, no hierarchy item was relevant (e.g. all blank hierarchy cells not at start), and no values.
+ messagesCollector.add(String.format("Warning [%s]: Data row index %d did not yield any identifiable hierarchy item or data values. It might be effectively empty or malformed relative to header.",
+ parserName, rowIndexForLog));
+ }
+ }
+}
diff --git a/src/main/java/io/jenkins/plugins/reporter/parser/BaseExcelParser.java b/src/main/java/io/jenkins/plugins/reporter/parser/BaseExcelParser.java
new file mode 100644
index 00000000..a898795c
--- /dev/null
+++ b/src/main/java/io/jenkins/plugins/reporter/parser/BaseExcelParser.java
@@ -0,0 +1,190 @@
+package io.jenkins.plugins.reporter.parser;
+
+import io.jenkins.plugins.reporter.model.ExcelParserConfig;
+import io.jenkins.plugins.reporter.model.ReportDto;
+import io.jenkins.plugins.reporter.model.ReportParser;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.poi.ss.usermodel.*;
+import org.apache.poi.xssf.usermodel.XSSFWorkbook;
+import org.apache.poi.hssf.usermodel.HSSFWorkbook;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import java.util.logging.Logger;
+import java.util.stream.Collectors;
+
+
+public abstract class BaseExcelParser extends ReportParser {
+
+ private static final long serialVersionUID = 1L;
+ protected static final Logger LOGGER = Logger.getLogger(BaseExcelParser.class.getName());
+
+ protected final ExcelParserConfig config;
+
+ protected BaseExcelParser(ExcelParserConfig config) {
+ this.config = config;
+ }
+
+ @Override
+ public ReportDto parse(File file) throws IOException {
+ ReportDto aggregatedReport = new ReportDto();
+ aggregatedReport.setItems(new ArrayList<>());
+ // aggregatedReport.setParserLog(new ArrayList<>()); // If you add logging messages
+
+ try (InputStream is = new FileInputStream(file)) {
+ Workbook workbook;
+ String fileName = file.getName().toLowerCase();
+ if (fileName.endsWith(".xlsx")) {
+ workbook = new XSSFWorkbook(is);
+ } else if (fileName.endsWith(".xls")) {
+ workbook = new HSSFWorkbook(is);
+ } else {
+ throw new IllegalArgumentException("File format not supported. Please use .xls or .xlsx: " + file.getName());
+ }
+
+ // Logic for iterating sheets will be determined by subclasses.
+ // For now, this base `parse` method might be too generic if subclasses
+ // have very different sheet iteration strategies (e.g., first vs. all).
+ // Consider making this method abstract or providing a hook for sheet selection.
+ // For this iteration, let's assume the subclass will guide sheet processing.
+ // This method will primarily ensure the workbook is opened and closed correctly.
+
+ // This part needs to be implemented by subclasses by calling parseSheet
+ // For example, a subclass might iterate through all sheets:
+ // for (int i = 0; i < workbook.getNumberOfSheets(); i++) {
+ // Sheet sheet = workbook.getSheetAt(i);
+ // ReportDto sheetReport = parseSheet(sheet, sheet.getSheetName(), this.config, createReportId(file.getName(), sheet.getSheetName()));
+ // // Aggregate sheetReport into aggregatedReport
+ // }
+ // Or a subclass might parse only the first sheet:
+ // if (workbook.getNumberOfSheets() > 0) {
+ // Sheet firstSheet = workbook.getSheetAt(0);
+ // aggregatedReport = parseSheet(firstSheet, firstSheet.getSheetName(), this.config, createReportId(file.getName()));
+ // }
+
+
+ } catch (Exception e) {
+ LOGGER.severe("Error parsing Excel file " + file.getName() + ": " + e.getMessage());
+ // aggregatedReport.addParserMessage("Error parsing file: " + e.getMessage());
+ throw new IOException("Error parsing Excel file: " + file.getName(), e);
+ }
+
+ return aggregatedReport; // This will be populated by subclass logic calling parseSheet
+ }
+
+ protected abstract ReportDto parseSheet(Sheet sheet, String sheetName, ExcelParserConfig config, String reportId);
+
+ protected String getCellValueAsString(Cell cell) {
+ if (cell == null) {
+ return "";
+ }
+ switch (cell.getCellType()) {
+ case STRING:
+ return cell.getStringCellValue().trim();
+ case NUMERIC:
+ if (DateUtil.isCellDateFormatted(cell)) {
+ return cell.getDateCellValue().toString(); // Or format as needed
+ } else {
+ // Format as string, avoiding ".0" for integers
+ double numericValue = cell.getNumericCellValue();
+ if (numericValue == (long) numericValue) {
+ return String.format("%d", (long) numericValue);
+ } else {
+ return String.valueOf(numericValue);
+ }
+ }
+ case BOOLEAN:
+ return String.valueOf(cell.getBooleanCellValue());
+ case FORMULA:
+ // Evaluate formula and get the cached value as string
+ // Be cautious with formula evaluation as it can be complex
+ try {
+ return getCellValueAsString(cell.getSheet().getWorkbook().getCreationHelper().createFormulaEvaluator().evaluateInCell(cell));
+ } catch (Exception e) {
+ // Fallback to cached formula result string if evaluation fails
+ LOGGER.warning("Could not evaluate formula in cell " + cell.getAddress() + ": " + e.getMessage());
+ return cell.getCellFormula();
+ }
+ case BLANK:
+ default:
+ return "";
+ }
+ }
+
+ protected List getRowValues(Row row) {
+ if (row == null) {
+ return new ArrayList<>();
+ }
+ List values = new ArrayList<>();
+ for (Cell cell : row) {
+ values.add(getCellValueAsString(cell));
+ }
+ return values;
+ }
+
+ protected Optional findHeaderRow(Sheet sheet, ExcelParserConfig config) {
+ // Basic implementation: Assumes first non-empty row is header.
+ // TODO: Enhance with config: config.getHeaderRowIndex() or auto-detect
+ for (Row row : sheet) {
+ if (row == null) continue;
+ boolean hasValues = false;
+ for (Cell cell : row) {
+ if (cell != null && cell.getCellType() != CellType.BLANK && StringUtils.isNotBlank(getCellValueAsString(cell))) {
+ hasValues = true;
+ break;
+ }
+ }
+ if (hasValues) {
+ return Optional.of(row.getRowNum());
+ }
+ }
+ return Optional.empty();
+ }
+
+ protected List readHeader(Sheet sheet, int headerRowIndex) {
+ Row headerRow = sheet.getRow(headerRowIndex);
+ if (headerRow == null) {
+ return new ArrayList<>();
+ }
+ return getRowValues(headerRow).stream().filter(StringUtils::isNotBlank).collect(Collectors.toList());
+ }
+
+ protected Optional findFirstDataRow(Sheet sheet, int headerRowIndex, ExcelParserConfig config) {
+ // Basic: Assumes data starts on the row immediately after the header.
+ // TODO: Enhance with config: config.getDataStartRowIndex() or auto-detect
+ int potentialFirstDataRow = headerRowIndex + 1;
+ if (potentialFirstDataRow <= sheet.getLastRowNum()) {
+ Row row = sheet.getRow(potentialFirstDataRow);
+ // Check if the row is not null and not entirely empty
+ if (row != null && !isRowEmpty(row)) {
+ return Optional.of(potentialFirstDataRow);
+ }
+ }
+ // Fallback: search for the next non-empty row after header
+ for (int i = headerRowIndex + 1; i <= sheet.getLastRowNum(); i++) {
+ Row dataRow = sheet.getRow(i);
+ if (dataRow != null && !isRowEmpty(dataRow)) {
+ return Optional.of(i);
+ }
+ }
+ return Optional.empty();
+ }
+
+ protected boolean isRowEmpty(Row row) {
+ if (row == null) {
+ return true;
+ }
+ // Check if all cells in the row are blank
+ for (Cell cell : row) {
+ if (cell != null && cell.getCellType() != CellType.BLANK && StringUtils.isNotBlank(getCellValueAsString(cell))) {
+ return false; // Found a non-empty cell
+ }
+ }
+ return true; // All cells are empty or null
+ }
+}
diff --git a/src/main/java/io/jenkins/plugins/reporter/parser/ExcelMultiReportParser.java b/src/main/java/io/jenkins/plugins/reporter/parser/ExcelMultiReportParser.java
new file mode 100644
index 00000000..f98de1b2
--- /dev/null
+++ b/src/main/java/io/jenkins/plugins/reporter/parser/ExcelMultiReportParser.java
@@ -0,0 +1,288 @@
+package io.jenkins.plugins.reporter.parser;
+
+import io.jenkins.plugins.reporter.model.ExcelParserConfig;
+import io.jenkins.plugins.reporter.model.Item;
+import io.jenkins.plugins.reporter.model.ReportDto;
+import org.apache.poi.ss.usermodel.*;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.lang3.math.NumberUtils;
+import org.apache.poi.ss.usermodel.WorkbookFactory; // Ensure this is present
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Optional;
+
+public class ExcelMultiReportParser extends BaseExcelParser { // Changed
+
+ private static final long serialVersionUID = 456789012345L; // New UID
+ private final String id;
+ private List parserMessages;
+ private List overallHeader = null;
+
+ public ExcelMultiReportParser(String id, ExcelParserConfig config) { // Changed
+ super(config);
+ this.id = id;
+ this.parserMessages = new ArrayList<>();
+ }
+
+ @Override
+ public ReportDto parse(File file) throws IOException {
+ this.overallHeader = null;
+ // this.parserMessages.clear(); // Clear if instance is reused; assume new instance for now.
+
+ ReportDto aggregatedReport = new ReportDto();
+ aggregatedReport.setId(this.id);
+ aggregatedReport.setItems(new ArrayList<>());
+
+ try (InputStream is = new FileInputStream(file);
+ Workbook workbook = WorkbookFactory.create(is)) {
+
+ if (workbook.getNumberOfSheets() == 0) {
+ this.parserMessages.add("Excel file has no sheets: " + file.getName());
+ LOGGER.warning("Excel file has no sheets: " + file.getName());
+ aggregatedReport.setParserLog(this.parserMessages);
+ return aggregatedReport;
+ }
+
+ for (Sheet sheet : workbook) {
+ String cleanSheetName = sheet.getSheetName().replaceAll("[^a-zA-Z0-9_.-]", "_");
+ ReportDto sheetReport = parseSheet(sheet, sheet.getSheetName(), this.config, this.id + "::" + cleanSheetName);
+
+ if (sheetReport != null && sheetReport.getItems() != null) {
+ for (Item item : sheetReport.getItems()) {
+ aggregatedReport.addItem(item);
+ }
+ }
+ }
+
+ aggregatedReport.setParserLog(this.parserMessages);
+ return aggregatedReport;
+
+ } catch (Exception e) {
+ this.parserMessages.add("Error parsing Excel file " + file.getName() + ": " + e.getMessage());
+ LOGGER.severe("Error parsing Excel file " + file.getName() + ": " + e.getMessage());
+ aggregatedReport.setParserLog(this.parserMessages);
+ return aggregatedReport;
+ }
+ }
+
+ @Override
+ protected ReportDto parseSheet(Sheet sheet, String sheetName, ExcelParserConfig config, String reportId) {
+ ReportDto report = new ReportDto();
+ report.setId(reportId);
+ report.setItems(new ArrayList<>());
+
+ Optional headerRowIndexOpt = findHeaderRow(sheet, config);
+ if (!headerRowIndexOpt.isPresent()) {
+ this.parserMessages.add(String.format("No header row found in sheet: '%s'", sheetName));
+ LOGGER.warning(String.format("No header row found in sheet: '%s'", sheetName));
+ return report;
+ }
+ int headerRowIndex = headerRowIndexOpt.get();
+
+ List currentSheetHeader = readHeader(sheet, headerRowIndex);
+ if (currentSheetHeader.isEmpty() || currentSheetHeader.size() < 2) {
+ this.parserMessages.add(String.format("Empty or insufficient header (found %d columns, requires at least 2) in sheet: '%s' at row %d. Skipping sheet.", currentSheetHeader.size(), sheetName, headerRowIndex + 1));
+ LOGGER.warning(String.format("Empty or insufficient header in sheet: '%s' at row %d. Skipping sheet.", sheetName, headerRowIndex + 1));
+ return report;
+ }
+
+ // Column Consistency Check
+ if (this.overallHeader == null) {
+ this.overallHeader = new ArrayList<>(currentSheetHeader); // Set if this is the first valid header encountered
+ this.parserMessages.add(String.format("Info: Using header from sheet '%s' as the reference for column consistency: %s", sheetName, this.overallHeader.toString()));
+ } else {
+ if (!this.overallHeader.equals(currentSheetHeader)) {
+ String msg = String.format("Error: Sheet '%s' has an inconsistent header. Expected: %s, Found: %s. Skipping this sheet.", sheetName, this.overallHeader.toString(), currentSheetHeader.toString());
+ this.parserMessages.add(msg);
+ LOGGER.severe(msg);
+ return report;
+ }
+ }
+
+ Optional firstDataRowIndexOpt = findFirstDataRow(sheet, headerRowIndex, config);
+ if (!firstDataRowIndexOpt.isPresent()) {
+ this.parserMessages.add(String.format("No data rows found after header in sheet: '%s'", sheetName));
+ LOGGER.info(String.format("No data rows found after header in sheet: '%s'", sheetName));
+ return report;
+ }
+ int firstDataRowIndex = firstDataRowIndexOpt.get();
+
+ int colIdxValueStart = -1;
+ boolean structureDetected = false;
+
+ // Structure Detection Logic (same as in ExcelReportParser)
+ for (int tempRowIdx = firstDataRowIndex; tempRowIdx <= sheet.getLastRowNum(); tempRowIdx++) {
+ Row r = sheet.getRow(tempRowIdx);
+ if (isRowEmpty(r)) continue;
+ List rv = getRowValues(r);
+ if (rv.isEmpty()) continue;
+
+ int determinedColIdxValueStart = 0;
+ if (!rv.isEmpty()) {
+ for (int cIdx = currentSheetHeader.size() - 1; cIdx >= 0; cIdx--) { // Use currentSheetHeader.size()
+ String cellVal = (cIdx < rv.size()) ? rv.get(cIdx) : "";
+ if (NumberUtils.isCreatable(cellVal)) {
+ determinedColIdxValueStart = cIdx;
+ } else {
+ if (determinedColIdxValueStart > 0 && cIdx < determinedColIdxValueStart) {
+ break;
+ }
+ }
+ }
+ if (determinedColIdxValueStart == 0 && !rv.isEmpty()) {
+ if (NumberUtils.isCreatable(rv.get(0))) {
+ if(currentSheetHeader.size() == 1) { // Use currentSheetHeader.size()
+ this.parserMessages.add(String.format(
+ "Warning: Sheet '%s', row %d: Data seems to be a single numeric column ('%s'). Item names will be generic.",
+ sheetName, tempRowIdx + 1, currentSheetHeader.get(0))); // Use currentSheetHeader
+ }
+ } else {
+ if (currentSheetHeader.size() > 1) { // Use currentSheetHeader.size()
+ determinedColIdxValueStart = currentSheetHeader.size() - 1;
+ this.parserMessages.add(String.format(
+ "Warning: No numeric columns auto-detected in sheet '%s' at row %d based on content. " +
+ "Assuming last column ('%s') is value, and others are hierarchy.",
+ sheetName, tempRowIdx + 1, currentSheetHeader.get(determinedColIdxValueStart))); // Use currentSheetHeader
+ } else {
+ this.parserMessages.add(String.format("Warning: Sheet '%s', row %d: Single text column ('%s') found. No numeric data columns detected.", sheetName, tempRowIdx + 1, currentSheetHeader.get(0))); // Use currentSheetHeader
+ }
+ }
+ }
+ }
+
+ colIdxValueStart = determinedColIdxValueStart;
+
+ if (colIdxValueStart < currentSheetHeader.size() && colIdxValueStart >=0) { // Use currentSheetHeader.size()
+ this.parserMessages.add(String.format("Detected structure in sheet '%s': Hierarchy columns: 0 to %d, Value columns: %d to %d.",
+ sheetName, Math.max(0, colIdxValueStart -1), colIdxValueStart, currentSheetHeader.size() - 1)); // Use currentSheetHeader.size()
+ structureDetected = true;
+ } else if (currentSheetHeader.size() > 0) {
+ this.parserMessages.add(String.format("Error: Could not reliably determine data structure (colIdxValueStart %d vs header size %d) in sheet '%s'.", colIdxValueStart, currentSheetHeader.size(), sheetName)); // Use currentSheetHeader.size()
+ return report;
+ } else { // Header is empty, already handled by prior checks
+ return report;
+ }
+ break;
+ }
+
+ if (!structureDetected) {
+ this.parserMessages.add(String.format("Warning: Could not detect data structure in sheet '%s'. No processable data rows found or structure ambiguous.", sheetName));
+ return report;
+ }
+
+ // Data Processing Loop (same as in ExcelReportParser, using currentSheetHeader)
+ for (int i = firstDataRowIndex; i <= sheet.getLastRowNum(); i++) {
+ Row currentRow = sheet.getRow(i);
+ if (isRowEmpty(currentRow)) {
+ this.parserMessages.add(String.format("Skipped empty row %d in sheet '%s'", i + 1, sheetName));
+ continue;
+ }
+ List rowValues = getRowValues(currentRow);
+ if (rowValues.size() < colIdxValueStart && colIdxValueStart > 0) {
+ this.parserMessages.add(String.format("Skipped row %d in sheet '%s' - Row has %d cells, but hierarchy part expects at least %d.", i + 1, sheetName, rowValues.size(), colIdxValueStart));
+ continue;
+ }
+
+ String parentId = "report"; // Base parent ID for items from this sheet within the larger report
+ Item lastItem = null;
+ boolean lastItemAddedToHierarchy = false; // Tracks if the 'lastItem' was newly added or pre-existing
+ LinkedHashMap resultValues = new LinkedHashMap<>();
+ boolean emptyCellInHierarchyPart = false;
+ String currentItemCombinedId = reportId + "::"; // Start with reportId to ensure cross-sheet ID uniqueness
+
+ for (int colIdx = 0; colIdx < currentSheetHeader.size(); colIdx++) { // Iterate up to actual header size
+ String headerName = currentSheetHeader.get(colIdx); // Use currentSheetHeader
+ String cellValue = (colIdx < rowValues.size()) ? rowValues.get(colIdx) : "";
+
+ if (colIdx < colIdxValueStart) { // Hierarchy column
+ // ... (Item creation logic as in ExcelReportParser, ensuring IDs are unique using currentItemCombinedId)
+ // Prepend currentItemCombinedId with something unique per hierarchy level if needed, or ensure cellValue makes it unique.
+ // currentItemCombinedId is built progressively.
+ String originalCellValue = cellValue;
+ if (StringUtils.isBlank(cellValue)) {
+ if (colIdx == 0) {
+ this.parserMessages.add(String.format("Skipped row %d in sheet '%s' - First hierarchy column (header '%s') is empty.", i + 1, sheetName, headerName));
+ emptyCellInHierarchyPart = true;
+ break;
+ }
+ this.parserMessages.add(String.format("Info: Row %d, Col %d (Header '%s') in sheet '%s' is part of hierarchy and is blank. Using placeholder ID part.", i + 1, colIdx + 1, headerName, sheetName));
+ cellValue = "blank_hier_" + colIdx; // Placeholder for ID generation
+ } else if (NumberUtils.isCreatable(cellValue)) {
+ this.parserMessages.add(String.format("Info: Row %d, Col %d (Header '%s') in sheet '%s' is part of hierarchy but is numeric-like ('%s'). Using as string.", i + 1, colIdx + 1, headerName, sheetName, cellValue));
+ }
+
+ if (emptyCellInHierarchyPart && StringUtils.isNotBlank(originalCellValue)) { // Check originalCellValue here
+ this.parserMessages.add(String.format("Skipped row %d in sheet '%s' - Non-empty value ('%s') found after a blank cell in the hierarchy part.", i + 1, sheetName, originalCellValue));
+ emptyCellInHierarchyPart = true;
+ break;
+ }
+
+ currentItemCombinedId += cellValue.replaceAll("[^a-zA-Z0-9_-]", "_") + "_";
+ String itemId = StringUtils.removeEnd(currentItemCombinedId, "_");
+ if (StringUtils.isBlank(itemId)) itemId = reportId + "::unnamed_item_" + colIdx;
+
+
+ Optional
- parentOpt = report.findItem(parentId, report.getItems());
+ Item currentItem = new Item();
+ // Item ID must be globally unique if items are added to a common list in aggregatedReport
+ // The currentItemCombinedId already includes reportId (which is sheet specific)
+ currentItem.setId(StringUtils.abbreviate(itemId, 250)); // Abbreviate long IDs
+ currentItem.setName(StringUtils.isBlank(originalCellValue) ? "(blank)" : originalCellValue); // Use original value for name
+ lastItemAddedToHierarchy = false;
+
+ if (parentOpt.isPresent()) {
+ Item p = parentOpt.get();
+ if (p.getItems() == null) p.setItems(new ArrayList<>());
+ Optional
- existingItem = p.getItems().stream().filter(it -> it.getId().equals(currentItem.getId())).findFirst();
+ if (!existingItem.isPresent()) {
+ p.addItem(currentItem);
+ lastItemAddedToHierarchy = true;
+ }
+ lastItem = existingItem.orElse(currentItem);
+ } else {
+ Optional
- existingRootItem = report.getItems().stream().filter(it -> it.getId().equals(currentItem.getId())).findFirst();
+ if (!existingRootItem.isPresent()) {
+ report.addItem(currentItem);
+ lastItemAddedToHierarchy = true;
+ }
+ lastItem = existingRootItem.orElse(currentItem);
+ }
+ parentId = currentItem.getId(); // Next parent is the ID of current item.
+ } else { // Value column
+ Number numValue = 0;
+ if (NumberUtils.isCreatable(cellValue)) {
+ numValue = NumberUtils.createNumber(cellValue);
+ } else if (StringUtils.isNotBlank(cellValue)) {
+ this.parserMessages.add(String.format("Warning: Non-numeric value '%s' in data column '%s' at row %d, col %d, sheet '%s'. Using 0.", cellValue, headerName, i + 1, colIdx + 1, sheetName));
+ }
+ resultValues.put(headerName, numValue.intValue());
+ }
+ }
+
+ if (emptyCellInHierarchyPart) continue;
+
+ if (lastItem != null) {
+ if (lastItem.getResult() == null || lastItemAddedToHierarchy) {
+ lastItem.setResult(resultValues);
+ } else {
+ this.parserMessages.add(String.format("Info: Item '%s' (row %d, sheet '%s') already had results. New values for this hierarchy were: %s. Not overwriting.", lastItem.getId(), i + 1, sheetName, resultValues.toString()));
+ }
+ } else if (!resultValues.isEmpty()) {
+ Item valueItem = new Item();
+ String generatedId = reportId + "::Row_" + (i + 1); // Ensure reportId (sheet specific) is part of ID
+ valueItem.setId(StringUtils.abbreviate(generatedId.replaceAll("[^a-zA-Z0-9_.-]", "_"), 100));
+ valueItem.setName("Data Row " + (i + 1) + " (Sheet: " + sheetName + ")");
+ valueItem.setResult(resultValues);
+ report.addItem(valueItem);
+ this.parserMessages.add(String.format("Info: Row %d in sheet '%s' created as a direct data item '%s' as no distinct hierarchy path was formed.", i + 1, sheetName, valueItem.getName()));
+ }
+ }
+ return report;
+ }
+}
diff --git a/src/main/java/io/jenkins/plugins/reporter/parser/ExcelReportParser.java b/src/main/java/io/jenkins/plugins/reporter/parser/ExcelReportParser.java
new file mode 100644
index 00000000..33e299df
--- /dev/null
+++ b/src/main/java/io/jenkins/plugins/reporter/parser/ExcelReportParser.java
@@ -0,0 +1,264 @@
+package io.jenkins.plugins.reporter.parser;
+
+import io.jenkins.plugins.reporter.model.ExcelParserConfig;
+import io.jenkins.plugins.reporter.model.Item;
+import io.jenkins.plugins.reporter.model.ReportDto;
+import org.apache.poi.ss.usermodel.*;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.lang3.math.NumberUtils;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Optional;
+// Ensure WorkbookFactory is imported if used:
+import org.apache.poi.ss.usermodel.WorkbookFactory;
+
+
+public class ExcelReportParser extends BaseExcelParser {
+
+ private static final long serialVersionUID = 923478237482L;
+ private final String id;
+ private List parserMessages;
+
+ public ExcelReportParser(String id, ExcelParserConfig config) {
+ super(config);
+ this.id = id;
+ this.parserMessages = new ArrayList<>();
+ }
+
+ @Override
+ public ReportDto parse(File file) throws IOException {
+ ReportDto reportDto = new ReportDto();
+ reportDto.setId(this.id);
+ reportDto.setItems(new ArrayList<>());
+
+ try (InputStream is = new FileInputStream(file);
+ Workbook workbook = WorkbookFactory.create(is)) {
+
+ if (workbook.getNumberOfSheets() == 0) {
+ this.parserMessages.add("Excel file has no sheets: " + file.getName());
+ LOGGER.warning("Excel file has no sheets: " + file.getName());
+ reportDto.setParserLog(this.parserMessages);
+ return reportDto;
+ }
+
+ Sheet firstSheet = workbook.getSheetAt(0);
+ ReportDto sheetReport = parseSheet(firstSheet, firstSheet.getSheetName(), this.config, this.id);
+ sheetReport.setParserLog(this.parserMessages);
+ return sheetReport;
+
+ } catch (Exception e) {
+ this.parserMessages.add("Error parsing Excel file " + file.getName() + ": " + e.getMessage());
+ LOGGER.severe("Error parsing Excel file " + file.getName() + ": " + e.getMessage());
+ reportDto.setParserLog(this.parserMessages);
+ return reportDto;
+ }
+ }
+
+ @Override
+ protected ReportDto parseSheet(Sheet sheet, String sheetName, ExcelParserConfig config, String reportId) {
+ ReportDto report = new ReportDto();
+ report.setId(reportId);
+ report.setItems(new ArrayList<>());
+
+ Optional headerRowIndexOpt = findHeaderRow(sheet, config);
+ if (!headerRowIndexOpt.isPresent()) {
+ this.parserMessages.add(String.format("No header row found in sheet: %s", sheetName));
+ LOGGER.warning(String.format("No header row found in sheet: %s", sheetName));
+ return report;
+ }
+ int headerRowIndex = headerRowIndexOpt.get();
+
+ List header = readHeader(sheet, headerRowIndex);
+ if (header.isEmpty() || header.size() < 2) {
+ this.parserMessages.add(String.format("Empty or insufficient header (found %d columns, requires at least 2) in sheet: %s at row %d", header.size(), sheetName, headerRowIndex + 1));
+ LOGGER.warning(String.format("Empty or insufficient header in sheet: %s at row %d", sheetName, headerRowIndex + 1));
+ return report;
+ }
+
+ Optional firstDataRowIndexOpt = findFirstDataRow(sheet, headerRowIndex, config);
+ if (!firstDataRowIndexOpt.isPresent()) {
+ this.parserMessages.add(String.format("No data rows found after header in sheet: %s", sheetName));
+ LOGGER.info(String.format("No data rows found after header in sheet: %s", sheetName));
+ return report;
+ }
+ int firstDataRowIndex = firstDataRowIndexOpt.get();
+
+ int colIdxValueStart = -1;
+ boolean structureDetected = false;
+
+ for (int tempRowIdx = firstDataRowIndex; tempRowIdx <= sheet.getLastRowNum(); tempRowIdx++) {
+ Row r = sheet.getRow(tempRowIdx);
+ if (isRowEmpty(r)) continue;
+ List rv = getRowValues(r);
+ if (rv.isEmpty()) continue;
+
+ int determinedColIdxValueStart = 0;
+ if (!rv.isEmpty()) {
+ for (int cIdx = header.size() - 1; cIdx >= 0; cIdx--) {
+ String cellVal = (cIdx < rv.size()) ? rv.get(cIdx) : "";
+ if (NumberUtils.isCreatable(cellVal)) {
+ determinedColIdxValueStart = cIdx;
+ } else {
+ if (determinedColIdxValueStart > 0 && cIdx < determinedColIdxValueStart) {
+ break;
+ }
+ }
+ }
+ if (determinedColIdxValueStart == 0 && !rv.isEmpty()) {
+ if (NumberUtils.isCreatable(rv.get(0))) {
+ if(header.size() == 1) {
+ this.parserMessages.add(String.format(
+ "Warning: Sheet '%s', row %d: Data seems to be a single numeric column ('%s'). Item names will be generic.",
+ sheetName, tempRowIdx + 1, header.get(0)));
+ }
+ } else {
+ if (header.size() > 1) {
+ determinedColIdxValueStart = header.size() - 1;
+ this.parserMessages.add(String.format(
+ "Warning: No numeric columns auto-detected in sheet '%s' at row %d based on content. " +
+ "Assuming last column ('%s') is value, and others are hierarchy.",
+ sheetName, tempRowIdx + 1, header.get(determinedColIdxValueStart)));
+ } else {
+ this.parserMessages.add(String.format("Warning: Sheet '%s', row %d: Single text column ('%s') found. No numeric data columns detected.", sheetName, tempRowIdx + 1, header.get(0)));
+ }
+ }
+ }
+ }
+
+ colIdxValueStart = determinedColIdxValueStart;
+
+ if (colIdxValueStart < header.size() && colIdxValueStart >=0) {
+ this.parserMessages.add(String.format("Detected structure in sheet '%s': Hierarchy columns: 0 to %d, Value columns: %d to %d.",
+ sheetName, Math.max(0, colIdxValueStart -1), colIdxValueStart, header.size() - 1));
+ structureDetected = true;
+ } else if (header.size() > 0) {
+ this.parserMessages.add(String.format("Error: Could not reliably determine data structure (colIdxValueStart %d vs header size %d) in sheet '%s'.", colIdxValueStart, header.size(), sheetName));
+ return report;
+ } else {
+ return report;
+ }
+ break;
+ }
+
+ if (!structureDetected) {
+ this.parserMessages.add(String.format("Warning: Could not detect data structure in sheet '%s'. No processable data rows found or structure ambiguous.", sheetName));
+ return report;
+ }
+
+ for (int i = firstDataRowIndex; i <= sheet.getLastRowNum(); i++) {
+ Row currentRow = sheet.getRow(i);
+ if (isRowEmpty(currentRow)) {
+ this.parserMessages.add(String.format("Skipped empty row %d in sheet '%s'", i + 1, sheetName));
+ continue;
+ }
+
+ List rowValues = getRowValues(currentRow);
+
+ if (rowValues.size() < colIdxValueStart && colIdxValueStart > 0) {
+ this.parserMessages.add(String.format("Skipped row %d in sheet '%s' - Row has %d cells, but hierarchy part expects at least %d based on detected structure.", i + 1, sheetName, rowValues.size(), colIdxValueStart));
+ continue;
+ }
+
+ String parentId = "report";
+ Item lastItem = null;
+ boolean lastItemAddedToHierarchy = false;
+ LinkedHashMap resultValues = new LinkedHashMap<>();
+ boolean emptyCellInHierarchyPart = false;
+ String currentItemCombinedId = "";
+
+ for (int colIdx = 0; colIdx < header.size(); colIdx++) {
+ String headerName = header.get(colIdx);
+ String cellValue = (colIdx < rowValues.size()) ? rowValues.get(colIdx) : "";
+
+ if (colIdx < colIdxValueStart) {
+ if (StringUtils.isBlank(cellValue)) {
+ if (colIdx == 0) {
+ this.parserMessages.add(String.format("Skipped row %d in sheet '%s' - First hierarchy column (header '%s') is empty.", i + 1, sheetName, headerName));
+ emptyCellInHierarchyPart = true;
+ break;
+ }
+ // Allow subsequent blank hierarchy cells, but they might make IDs less unique or meaningful
+ this.parserMessages.add(String.format("Info: Row %d, Col %d (Header '%s') in sheet '%s' is part of hierarchy and is blank.", i + 1, colIdx + 1, headerName, sheetName));
+ } else if (NumberUtils.isCreatable(cellValue)) {
+ this.parserMessages.add(String.format("Info: Row %d, Col %d (Header '%s') in sheet '%s' is part of hierarchy but is numeric-like ('%s'). Using as string.", i + 1, colIdx + 1, headerName, sheetName, cellValue));
+ }
+
+ if (emptyCellInHierarchyPart && StringUtils.isNotBlank(cellValue)) {
+ this.parserMessages.add(String.format("Skipped row %d in sheet '%s' - Non-empty value ('%s') found after a blank cell in the hierarchy part.", i + 1, sheetName, cellValue));
+ emptyCellInHierarchyPart = true;
+ break;
+ }
+
+ currentItemCombinedId += cellValue.replaceAll("[^a-zA-Z0-9_-]", "_") + "_";
+ String itemId = StringUtils.removeEnd(currentItemCombinedId, "_");
+ if (StringUtils.isBlank(itemId)) itemId = "unnamed_item_" + colIdx;
+
+
+ Optional
- parentOpt = report.findItem(parentId, report.getItems());
+ Item currentItem = new Item();
+ currentItem.setId(itemId);
+ currentItem.setName(StringUtils.isBlank(cellValue) ? "(blank)" : cellValue);
+ lastItemAddedToHierarchy = false;
+
+ if (parentOpt.isPresent()) {
+ Item p = parentOpt.get();
+ if (p.getItems() == null) p.setItems(new ArrayList<>());
+ Optional
- existingItem = p.getItems().stream().filter(it -> it.getId().equals(itemId)).findFirst();
+ if (!existingItem.isPresent()) {
+ p.addItem(currentItem);
+ lastItemAddedToHierarchy = true;
+ }
+ lastItem = existingItem.orElse(currentItem);
+ } else {
+ Optional
- existingRootItem = report.getItems().stream().filter(it -> it.getId().equals(itemId)).findFirst();
+ if (!existingRootItem.isPresent()) {
+ report.addItem(currentItem);
+ lastItemAddedToHierarchy = true;
+ }
+ lastItem = existingRootItem.orElse(currentItem);
+ }
+ parentId = itemId;
+ } else {
+ Number numValue = 0;
+ if (NumberUtils.isCreatable(cellValue)) {
+ numValue = NumberUtils.createNumber(cellValue);
+ } else if (StringUtils.isNotBlank(cellValue)) {
+ this.parserMessages.add(String.format("Warning: Non-numeric value '%s' in data column '%s' at row %d, col %d, sheet '%s'. Using 0.", cellValue, headerName, i + 1, colIdx + 1, sheetName));
+ }
+ resultValues.put(headerName, numValue.intValue());
+ }
+ }
+
+ if (emptyCellInHierarchyPart) {
+ continue;
+ }
+
+ if (lastItem != null) {
+ if (lastItem.getResult() == null || lastItemAddedToHierarchy) { // Set if new or no results yet
+ lastItem.setResult(resultValues);
+ } else {
+ this.parserMessages.add(String.format("Info: Item '%s' (row %d, sheet '%s') already had results. New values for this hierarchy were: %s. Not overwriting.", lastItem.getId(), i + 1, sheetName, resultValues.toString()));
+ }
+ } else if (!resultValues.isEmpty()) {
+ Item valueItem = new Item();
+ String generatedId = "sheet_" + sheetName.replaceAll("[^a-zA-Z0-9]", "") + "_row_" + (i + 1) + "_" + reportId;
+ valueItem.setId(StringUtils.abbreviate(generatedId, 100));
+ valueItem.setName("Data Row " + (i + 1) + " (Sheet: " + sheetName + ")");
+ valueItem.setResult(resultValues);
+ report.addItem(valueItem);
+ if (colIdxValueStart == 0) {
+ this.parserMessages.add(String.format("Info: Row %d in sheet '%s' has all columns treated as values. Created item '%s'.", i + 1, sheetName, valueItem.getName()));
+ } else {
+ this.parserMessages.add(String.format("Warning: Row %d in sheet '%s' produced values but no specific hierarchy item was determined. Created generic item '%s'.", i + 1, sheetName, valueItem.getName()));
+ }
+ }
+ }
+ return report;
+ }
+}
diff --git a/src/main/java/io/jenkins/plugins/reporter/provider/Csv.java b/src/main/java/io/jenkins/plugins/reporter/provider/Csv.java
index 4417152d..6d97e0aa 100644
--- a/src/main/java/io/jenkins/plugins/reporter/provider/Csv.java
+++ b/src/main/java/io/jenkins/plugins/reporter/provider/Csv.java
@@ -11,8 +11,9 @@
import io.jenkins.plugins.reporter.model.Provider;
import io.jenkins.plugins.reporter.model.ReportDto;
import io.jenkins.plugins.reporter.model.ReportParser;
+import io.jenkins.plugins.reporter.parser.AbstractReportParserBase;
import org.apache.commons.lang3.StringUtils;
-import org.apache.commons.lang3.math.NumberUtils;
+// import org.apache.commons.lang3.math.NumberUtils; // Already commented out or removed
import org.jenkinsci.Symbol;
import org.kohsuke.stapler.DataBoundConstructor;
@@ -55,13 +56,13 @@ public Descriptor() {
}
}
- public static class CsvCustomParser extends ReportParser {
+ public static class CsvCustomParser extends AbstractReportParserBase { // Changed superclass
- private static final long serialVersionUID = -8689695008930386640L;
+ private static final long serialVersionUID = -8689695008930386640L; // Keep existing UID for now
private final String id;
- private List parserMessages;
+ private List parserMessages; // This will be used by AbstractReportParserBase methods
public CsvCustomParser(String id) {
super();
@@ -77,15 +78,19 @@ public String getId() {
private char detectDelimiter(File file) throws IOException {
// List of possible delimiters
char[] delimiters = { ',', ';', '\t', '|' };
+ String[] delimiterNames = { "Comma", "Semicolon", "Tab", "Pipe" };
int[] delimiterCounts = new int[delimiters.length];
// Read the lines of the file to detect the delimiter
try (BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream(file), StandardCharsets.UTF_8))) {
- int linesToCheck = 5; // Number of lines to check
+ int linesToCheck = 10; // Number of lines to check
int linesChecked = 0;
String line;
while ((line = reader.readLine()) != null && linesChecked < linesToCheck) {
+ if (StringUtils.isBlank(line)) { // Skip blank lines
+ continue;
+ }
for (int i = 0; i < delimiters.length; i++) {
delimiterCounts[i] += StringUtils.countMatches(line, delimiters[i]);
}
@@ -93,15 +98,39 @@ private char detectDelimiter(File file) throws IOException {
}
}
- // Return the most frequent delimiter
+ // Determine the most frequent delimiter
int maxCount = 0;
- char detectedDelimiter = 0;
+ int detectedDelimiterIndex = -1;
for (int i = 0; i < delimiters.length; i++) {
if (delimiterCounts[i] > maxCount) {
maxCount = delimiterCounts[i];
- detectedDelimiter = delimiters[i];
+ detectedDelimiterIndex = i;
}
}
+
+ char detectedDelimiter = (detectedDelimiterIndex != -1) ? delimiters[detectedDelimiterIndex] : ','; // Default to comma if none found
+
+ if (detectedDelimiterIndex != -1) {
+ // Check for ambiguity
+ for (int i = 0; i < delimiters.length; i++) {
+ if (i == detectedDelimiterIndex) continue;
+ // Ambiguous if another delimiter's count is > 0, and difference is less than 20% of max count,
+ // and both counts are above a threshold (e.g., 5)
+ if (delimiterCounts[i] > 5 && maxCount > 5 &&
+ (maxCount - delimiterCounts[i]) < (maxCount * 0.2)) {
+ this.parserMessages.add(String.format(
+ "Warning [CSV]: Ambiguous delimiter. %s count (%d) is very similar to %s count (%d). Using '%c'.",
+ delimiterNames[detectedDelimiterIndex], maxCount,
+ delimiterNames[i], delimiterCounts[i],
+ detectedDelimiter));
+ break; // Log once for the first ambiguity found
+ }
+ }
+ this.parserMessages.add(String.format("Info [CSV]: Detected delimiter: '%c' (Name: %s, Count: %d)",
+ detectedDelimiter, delimiterNames[detectedDelimiterIndex], maxCount));
+ } else {
+ this.parserMessages.add("Warning [CSV]: No clear delimiter found. Defaulting to comma ','. Parsing might be inaccurate.");
+ }
return detectedDelimiter;
}
@@ -109,150 +138,130 @@ private char detectDelimiter(File file) throws IOException {
@Override
public ReportDto parse(File file) throws IOException {
+ this.parserMessages.clear(); // Clear messages for each new parse operation
// Get delimiter
char delimiter = detectDelimiter(file);
final CsvMapper mapper = new CsvMapper();
- final CsvSchema schema = mapper.schemaFor(String[].class).withColumnSeparator(delimiter);
+ final CsvSchema schema = mapper.schemaFor(String[].class).withColumnSeparator(delimiter).withoutQuoteChar(); // Try without quote char initially
mapper.enable(CsvParser.Feature.WRAP_AS_ARRAY);
- mapper.enable(CsvParser.Feature.SKIP_EMPTY_LINES);
+ // mapper.enable(CsvParser.Feature.SKIP_EMPTY_LINES); // We will handle empty line skipping manually for logging
+ mapper.disable(CsvParser.Feature.SKIP_EMPTY_LINES);
mapper.enable(CsvParser.Feature.ALLOW_TRAILING_COMMA);
mapper.enable(CsvParser.Feature.INSERT_NULLS_FOR_MISSING_COLUMNS);
mapper.enable(CsvParser.Feature.TRIM_SPACES);
-
- final MappingIterator
> it = mapper.readerForListOf(String.class)
- .with(schema)
- .readValues(file);
-
+
ReportDto report = new ReportDto();
report.setId(getId());
report.setItems(new ArrayList<>());
- final List header = it.next();
- final List> rows = it.readAll();
-
- int rowCount = 0;
- final int headerColumnCount = header.size();
- int colIdxValueStart = 0;
-
- if (headerColumnCount >= 2) {
- rowCount = rows.size();
- } else {
- parserMessages.add(String.format("skipped file - First line has %d elements", headerColumnCount + 1));
+ List header = null;
+ final int MAX_LINES_TO_SCAN_FOR_HEADER = 20;
+ int linesScannedForHeader = 0;
+
+ MappingIterator> it = null;
+ try {
+ it = mapper.readerForListOf(String.class)
+ .with(schema)
+ .readValues(file);
+ } catch (Exception e) {
+ this.parserMessages.add("Error [CSV]: Failed to initialize CSV reader: " + e.getMessage());
+ report.setParserLog(this.parserMessages);
+ return report;
}
- /** Parse all data rows */
- for (int rowIdx = 0; rowIdx < rowCount; rowIdx++) {
- String parentId = "report";
- List row = rows.get(rowIdx);
- Item last = null;
- boolean lastItemAdded = false;
- LinkedHashMap result = new LinkedHashMap<>();
- boolean emptyFieldFound = false;
- int rowSize = row.size();
- /** Parse untill first data line is found to get data and value field */
- if (colIdxValueStart == 0) {
- /** Col 0 is assumed to be string */
- for (int colIdx = rowSize - 1; colIdx > 1; colIdx--) {
- String value = row.get(colIdx);
+ while (it.hasNext() && linesScannedForHeader < MAX_LINES_TO_SCAN_FOR_HEADER) {
+ List currentRow;
+ long currentLineNumber = 0;
+ try {
+ currentLineNumber = it.getCurrentLocation() != null ? it.getCurrentLocation().getLineNr() : -1;
+ currentRow = it.next();
+ } catch (Exception e) {
+ this.parserMessages.add(String.format("Error [CSV]: Could not read line %d: %s", currentLineNumber, e.getMessage()));
+ linesScannedForHeader++; // Count this as a scanned line
+ continue;
+ }
- if (NumberUtils.isCreatable(value)) {
- colIdxValueStart = colIdx;
- } else {
- if (colIdxValueStart > 0) {
- parserMessages
- .add(String.format("Found data - fields number = %d - numeric fields = %d",
- colIdxValueStart, rowSize - colIdxValueStart));
- }
- break;
- }
- }
+ linesScannedForHeader++;
+ if (currentRow == null || currentRow.stream().allMatch(s -> s == null || s.isEmpty())) {
+ this.parserMessages.add(String.format("Info [CSV]: Skipped empty or null line at file line number: %d while searching for header.", currentLineNumber));
+ continue;
}
+ header = currentRow;
+ this.parserMessages.add(String.format("Info [CSV]: Using file line %d as header: %s", currentLineNumber, header.toString()));
+ break;
+ }
- String valueId = "";
- /** Parse line if first data line is OK and line has more element than header */
- if ((colIdxValueStart > 0) && (rowSize >= headerColumnCount)) {
- /** Check line and header size matching */
- for (int colIdx = 0; colIdx < headerColumnCount; colIdx++) {
- String id = header.get(colIdx);
- String value = row.get(colIdx);
+ if (header == null) {
+ this.parserMessages.add("Error [CSV]: No valid header row found after scanning " + linesScannedForHeader + " lines. Cannot parse file.");
+ report.setParserLog(this.parserMessages);
+ return report;
+ }
- /** Check value fields */
- if ((colIdx < colIdxValueStart)) {
- /** Test if text item is a value or empty */
- if ((NumberUtils.isCreatable(value)) || (StringUtils.isBlank(value))) {
- /** Empty field found - message */
- if (colIdx == 0) {
- parserMessages
- .add(String.format("skipped line %d - First column item empty - col = %d ",
- rowIdx + 2, colIdx + 1));
- break;
- } else {
- emptyFieldFound = true;
- /** Continue next column parsing */
- continue;
- }
- } else {
- /** Check if field values are present after empty cells */
- if (emptyFieldFound) {
- parserMessages.add(String.format("skipped line %d Empty field in col = %d ",
- rowIdx + 2, colIdx + 1));
- break;
- }
- }
- valueId += value;
- Optional- parent = report.findItem(parentId, report.getItems());
- Item item = new Item();
- lastItemAdded = false;
- item.setId(valueId);
- item.setName(value);
- String finalValueId = valueId;
- if (parent.isPresent()) {
- Item p = parent.get();
- if (!p.hasItems()) {
- p.setItems(new ArrayList<>());
- }
- if (p.getItems().stream().noneMatch(i -> i.getId().equals(finalValueId))) {
- p.addItem(item);
- lastItemAdded = true;
- }
- } else {
- if (report.getItems().stream().noneMatch(i -> i.getId().equals(finalValueId))) {
- report.getItems().add(item);
- lastItemAdded = true;
- }
- }
- parentId = valueId;
- last = item;
- } else {
- Number val = 0;
- if (NumberUtils.isCreatable(value)) {
- val = NumberUtils.createNumber(value);
- }
- result.put(id, val.intValue());
- }
- }
- } else {
- /** Skip file if first data line has no value field */
- if (colIdxValueStart == 0) {
- parserMessages.add(String.format("skipped line %d - First data row not found", rowIdx + 2));
- continue;
- } else {
- parserMessages
- .add(String.format("skipped line %d - line has fewer element than title", rowIdx + 2));
- continue;
+ if (header.size() < 2) {
+ this.parserMessages.add(String.format("Error [CSV]: Insufficient columns in header (found %d, requires at least 2). Header: %s", header.size(), header.toString()));
+ report.setParserLog(this.parserMessages);
+ return report;
+ }
+
+ final List
> rows = new ArrayList<>();
+ long linesReadForData = 0;
+ while(it.hasNext()) { // Collect all data rows first
+ linesReadForData++;
+ try {
+ List r = it.next();
+ if (r != null) {
+ rows.add(r);
+ } else {
+ this.parserMessages.add(String.format("Info [CSV]: Encountered a null row object at data line %d, skipping.", linesReadForData));
}
+ } catch (Exception e) {
+ this.parserMessages.add(String.format("Error [CSV]: Failed to read data row at data line %d: %s. Skipping row.", linesReadForData, e.getMessage()));
}
- /** If last item was created, it will be added to report */
- if (lastItemAdded) {
- last.setResult(result);
- } else {
- parserMessages.add(String.format("ignored line %d - Same fields already exists", rowIdx + 2));
+ }
+
+ List firstActualDataRow = null;
+ for (List r : rows) {
+ // Check if row has any non-blank content, considering nulls from INSERT_NULLS_FOR_MISSING_COLUMNS
+ if (r.stream().anyMatch(s -> s != null && !s.isEmpty())) {
+ firstActualDataRow = r;
+ break;
}
}
- // report.setParserLog(parserMessages);
+
+ if (firstActualDataRow == null) { // All data rows are empty or no data rows at all
+ if (rows.isEmpty()) {
+ this.parserMessages.add("Info [CSV]: No data rows found after header.");
+ } else {
+ this.parserMessages.add("Info [CSV]: All data rows after header are empty or contain only blank fields. No structure to detect or items to parse.");
+ }
+ report.setParserLog(this.parserMessages);
+ return report;
+ }
+
+ int colIdxValueStart = detectColumnStructure(header, firstActualDataRow, this.parserMessages, "CSV");
+ if (colIdxValueStart == -1) {
+ // Error logged by detectColumnStructure
+ report.setParserLog(this.parserMessages);
+ return report;
+ }
+
+ /** Parse all data rows */
+ for (int rowIdx = 0; rowIdx < rows.size(); rowIdx++) {
+ List row = rows.get(rowIdx);
+ // Pass rowIdx as rowIndexForLog, it's 0-based index into the 'rows' list
+ parseRowToItems(report, row, header, colIdxValueStart, this.id, this.parserMessages, "CSV", rowIdx);
+ }
+
+ // Final check if items were added, especially if all rows were skipped by parseRowToItems
+ if (report.getItems().isEmpty() && !rows.isEmpty() &&
+ !rows.stream().allMatch(r -> r.stream().allMatch(s -> s==null || s.isEmpty())) ) { // if not all rows were completely blank initially
+ this.parserMessages.add("Warning [CSV]: No items were successfully parsed from data rows. Check data integrity and column structure detection logs.");
+ }
+
+ report.setParserLog(this.parserMessages);
return report;
}
}
diff --git a/src/main/java/io/jenkins/plugins/reporter/provider/ExcelMultiProvider.java b/src/main/java/io/jenkins/plugins/reporter/provider/ExcelMultiProvider.java
new file mode 100644
index 00000000..b5b6558d
--- /dev/null
+++ b/src/main/java/io/jenkins/plugins/reporter/provider/ExcelMultiProvider.java
@@ -0,0 +1,50 @@
+package io.jenkins.plugins.reporter.provider;
+
+import hudson.Extension;
+import io.jenkins.plugins.reporter.Messages;
+import io.jenkins.plugins.reporter.model.ExcelParserConfig;
+import io.jenkins.plugins.reporter.model.Provider;
+import io.jenkins.plugins.reporter.model.ReportParser;
+import io.jenkins.plugins.reporter.parser.ExcelMultiReportParser; // Changed
+import org.jenkinsci.Symbol;
+import org.kohsuke.stapler.DataBoundConstructor;
+import org.kohsuke.stapler.DataBoundSetter;
+
+public class ExcelMultiProvider extends Provider { // Changed
+
+ private static final long serialVersionUID = 345678901234L; // New UID
+ private static final String ID = "excelmulti"; // Changed
+
+ private ExcelParserConfig excelParserConfig;
+
+ @DataBoundConstructor
+ public ExcelMultiProvider() { // Changed
+ super();
+ this.excelParserConfig = new ExcelParserConfig();
+ }
+
+ public ExcelParserConfig getExcelParserConfig() {
+ return excelParserConfig;
+ }
+
+ @DataBoundSetter
+ public void setExcelParserConfig(ExcelParserConfig excelParserConfig) {
+ this.excelParserConfig = excelParserConfig;
+ }
+
+ @Override
+ public ReportParser createParser() {
+ if (getActualId().equals(getDescriptor().getId())) {
+ throw new IllegalArgumentException(Messages.Provider_Error()); // Consider a specific message for excelmulti
+ }
+ return new ExcelMultiReportParser(getActualId(), getExcelParserConfig()); // Changed
+ }
+
+ @Symbol(ID)
+ @Extension
+ public static class Descriptor extends Provider.ProviderDescriptor {
+ public Descriptor() {
+ super(ID);
+ }
+ }
+}
diff --git a/src/main/java/io/jenkins/plugins/reporter/provider/ExcelProvider.java b/src/main/java/io/jenkins/plugins/reporter/provider/ExcelProvider.java
new file mode 100644
index 00000000..c649d4f6
--- /dev/null
+++ b/src/main/java/io/jenkins/plugins/reporter/provider/ExcelProvider.java
@@ -0,0 +1,50 @@
+package io.jenkins.plugins.reporter.provider;
+
+import hudson.Extension;
+import io.jenkins.plugins.reporter.Messages;
+import io.jenkins.plugins.reporter.model.ExcelParserConfig;
+import io.jenkins.plugins.reporter.model.Provider;
+import io.jenkins.plugins.reporter.model.ReportParser;
+import io.jenkins.plugins.reporter.parser.ExcelReportParser;
+import org.jenkinsci.Symbol;
+import org.kohsuke.stapler.DataBoundConstructor;
+import org.kohsuke.stapler.DataBoundSetter;
+
+public class ExcelProvider extends Provider {
+
+ private static final long serialVersionUID = 834732487834L;
+ private static final String ID = "excel";
+
+ private ExcelParserConfig excelParserConfig;
+
+ @DataBoundConstructor
+ public ExcelProvider() {
+ super();
+ this.excelParserConfig = new ExcelParserConfig();
+ }
+
+ public ExcelParserConfig getExcelParserConfig() {
+ return excelParserConfig;
+ }
+
+ @DataBoundSetter
+ public void setExcelParserConfig(ExcelParserConfig excelParserConfig) {
+ this.excelParserConfig = excelParserConfig;
+ }
+
+ @Override
+ public ReportParser createParser() {
+ if (getActualId().equals(getDescriptor().getId())) {
+ throw new IllegalArgumentException(Messages.Provider_Error());
+ }
+ return new ExcelReportParser(getActualId(), getExcelParserConfig());
+ }
+
+ @Symbol(ID)
+ @Extension
+ public static class Descriptor extends Provider.ProviderDescriptor {
+ public Descriptor() {
+ super(ID);
+ }
+ }
+}
diff --git a/src/test/java/io/jenkins/plugins/reporter/parser/ExcelMultiReportParserTest.java b/src/test/java/io/jenkins/plugins/reporter/parser/ExcelMultiReportParserTest.java
new file mode 100644
index 00000000..1dca2a91
--- /dev/null
+++ b/src/test/java/io/jenkins/plugins/reporter/parser/ExcelMultiReportParserTest.java
@@ -0,0 +1,254 @@
+package io.jenkins.plugins.reporter.parser;
+
+import io.jenkins.plugins.reporter.model.ExcelParserConfig;
+import io.jenkins.plugins.reporter.model.Item;
+import io.jenkins.plugins.reporter.model.ReportDto;
+import org.apache.poi.ss.usermodel.*;
+import org.apache.poi.xssf.usermodel.XSSFWorkbook; // For creating test workbooks
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.net.URISyntaxException;
+import java.net.URL;
+import java.nio.file.Path;
+import java.nio.file.Files; // For Files.writeString in one of the tests
+// import java.util.ArrayList; // Not directly used for declaration, List is used
+import java.util.Arrays;
+import java.util.List; // Correct import for List
+// import java.util.Map; // Not directly used
+import java.util.stream.Collectors;
+
+class ExcelMultiReportParserTest {
+
+ private ExcelParserConfig defaultConfig;
+ @TempDir
+ Path tempDir; // JUnit 5 temporary directory
+
+ @BeforeEach
+ void setUp() {
+ defaultConfig = new ExcelParserConfig();
+ }
+
+ private File getResourceFile(String fileName) throws URISyntaxException {
+ URL resource = getClass().getResource("/io/jenkins/plugins/reporter/provider/" + fileName);
+ if (resource == null) {
+ throw new IllegalArgumentException("Test resource file not found: " + fileName +
+ ". Ensure it is in src/test/resources/io/jenkins/plugins/reporter/provider/");
+ }
+ return new File(resource.toURI());
+ }
+
+ // Helper to create a multi-sheet workbook from single-sheet files
+ private File createMultiSheetWorkbook(String outputFileName, List sheetResourceFiles, List sheetNames) throws IOException, URISyntaxException {
+ File outputFile = tempDir.resolve(outputFileName).toFile();
+ try (XSSFWorkbook multiSheetWorkbook = new XSSFWorkbook()) {
+ for (int i = 0; i < sheetResourceFiles.size(); i++) {
+ File sheetFile = getResourceFile(sheetResourceFiles.get(i));
+ String sheetName = sheetNames.get(i);
+ Sheet newSheet = multiSheetWorkbook.createSheet(sheetName);
+
+ try (FileInputStream fis = new FileInputStream(sheetFile);
+ Workbook sourceSheetWorkbook = WorkbookFactory.create(fis)) {
+ Sheet sourceSheet = sourceSheetWorkbook.getSheetAt(0);
+ int rowNum = 0;
+ for (Row sourceRow : sourceSheet) {
+ Row newRow = newSheet.createRow(rowNum++);
+ int cellNum = 0;
+ for (Cell sourceCell : sourceRow) {
+ Cell newCell = newRow.createCell(cellNum++);
+ switch (sourceCell.getCellType()) {
+ case STRING:
+ newCell.setCellValue(sourceCell.getStringCellValue());
+ break;
+ case NUMERIC:
+ if (DateUtil.isCellDateFormatted(sourceCell)) {
+ newCell.setCellValue(sourceCell.getDateCellValue());
+ } else {
+ newCell.setCellValue(sourceCell.getNumericCellValue());
+ }
+ break;
+ case BOOLEAN:
+ newCell.setCellValue(sourceCell.getBooleanCellValue());
+ break;
+ case FORMULA:
+ newCell.setCellFormula(sourceCell.getCellFormula());
+ break;
+ case BLANK:
+ break;
+ default:
+ // Potentially log or handle other types if necessary
+ break;
+ }
+ }
+ }
+ }
+ }
+ try (FileOutputStream fos = new FileOutputStream(outputFile)) {
+ multiSheetWorkbook.write(fos);
+ }
+ }
+ return outputFile;
+ }
+
+ @Test
+ void testParseMultiSheetConsistentHeaders() throws IOException, URISyntaxException {
+ List sheetFiles = Arrays.asList(
+ "sample_excel_multi_consistent_sheet1_Data_Alpha.xlsx",
+ "sample_excel_multi_consistent_sheet2_Data_Beta.xlsx");
+ List sheetNames = Arrays.asList("Data Alpha", "Data Beta");
+ File multiSheetFile = createMultiSheetWorkbook("consistent_multi.xlsx", sheetFiles, sheetNames);
+
+ ExcelMultiReportParser parser = new ExcelMultiReportParser("testMultiConsistent", defaultConfig);
+ ReportDto result = parser.parse(multiSheetFile);
+
+ assertNotNull(result);
+ // System.out.println("Messages (Consistent): " + result.getParserLogMessages());
+
+ // Items from Data Alpha (ID, Metric, Result): Alpha001, Time, 100; Alpha002, Score, 200
+ // Items from Data Beta (ID, Metric, Result): Beta001, Time, 110; Beta002, Score, 210
+ // Report ID for parseSheet: "testMultiConsistent::Data_Alpha" and "testMultiConsistent::Data_Beta"
+ // Item ID structure: reportIdForSheet + "::" + hierarchyPart1 + "_" + hierarchyPart2 ...
+ // Example: "testMultiConsistent::Data_Alpha::Alpha001_Time"
+
+ long totalExpectedItems = 2; // In ExcelMultiReportParser, items are aggregated under the main reportId, hierarchy ensures uniqueness
+ // The current parser implementation creates a flat list of items in the final ReportDto.
+ // Each item from parseSheet is added to aggregatedReport.getItems().
+ // The ID generation in parseSheet is: currentItemCombinedId += cellValue...
+ // parentId starts as "report".
+ // For "Alpha001, Time, 100", item "Alpha001" is created. Then item "Time" is nested under "Alpha001".
+ // The "Result" (100) is attached to "Time".
+ // So, we expect "Alpha001" and "Alpha002" from sheet 1.
+ // And "Beta001" and "Beta002" from sheet 2. These are the top-level items in the final list.
+ assertEquals(totalExpectedItems, result.getItems().size(), "Should have 2 top-level items (Alpha001/Alpha002 and Beta001/Beta002 merged by hierarchy) in total from two sheets if hierarchy matches.");
+ // Let's re-evaluate the expected item count and structure.
+ // Sheet 1: Alpha001 (parent), Time (child, value 100), Score (child, value 200) -> No, this is wrong.
+ // The parser logic: "ID" is one hierarchy, "Metric" is another. "Result" is the value column.
+ // Sheet 1: Item "Alpha001" (id testMultiConsistent::Data_Alpha::Alpha001)
+ // -> Item "Time" (id testMultiConsistent::Data_Alpha::Alpha001_Time, result {"Result":100})
+ // Item "Alpha002" (id testMultiConsistent::Data_Alpha::Alpha002)
+ // -> Item "Score" (id testMultiConsistent::Data_Alpha::Alpha002_Score, result {"Result":200})
+ // Sheet 2: Item "Beta001" (id testMultiConsistent::Data_Beta::Beta001)
+ // -> Item "Time" (id testMultiConsistent::Data_Beta::Beta001_Time, result {"Result":110})
+ // Item "Beta002" (id testMultiConsistent::Data_Beta::Beta002)
+ // -> Item "Score" (id testMultiConsistent::Data_Beta::Beta002_Score, result {"Result":210})
+ // So, the top-level items in the aggregated report are Alpha001, Alpha002, Beta001, Beta002. That's 4.
+ assertEquals(4, result.getItems().size(), "Should have 4 top-level items in total from two sheets.");
+
+
+ Item itemA001 = result.findItem("testMultiConsistent::Data_Alpha::Alpha001", result.getItems()).orElse(null);
+ assertNotNull(itemA001, "Item Alpha001 from sheet 'Data Alpha' not found.");
+ assertEquals("Alpha001", itemA001.getName());
+ Item itemA001Time = itemA001.findItem("testMultiConsistent::Data_Alpha::Alpha001_Time", itemA001.getItems()).orElse(null);
+ assertNotNull(itemA001Time, "Sub-item Time for Alpha001 not found.");
+ assertEquals("Time", itemA001Time.getName());
+ assertEquals(100, itemA001Time.getResult().get("Result"));
+
+ Item itemB001 = result.findItem("testMultiConsistent::Data_Beta::Beta001", result.getItems()).orElse(null);
+ assertNotNull(itemB001, "Item Beta001 from sheet 'Data Beta' not found.");
+ assertEquals("Beta001", itemB001.getName());
+ Item itemB001Time = itemB001.findItem("testMultiConsistent::Data_Beta::Beta001_Time", itemB001.getItems()).orElse(null);
+ assertNotNull(itemB001Time, "Sub-item Time for Beta001 not found.");
+ assertEquals("Time", itemB001Time.getName());
+ assertEquals(110, itemB001Time.getResult().get("Result"));
+
+ assertTrue(result.getParserLogMessages().stream().anyMatch(m -> m.contains("Using header from sheet 'Data Alpha' as the reference")), "Should log reference header message.");
+ }
+
+ @Test
+ void testParseMultiSheetInconsistentHeaders() throws IOException, URISyntaxException {
+ List sheetFiles = Arrays.asList(
+ "sample_excel_multi_inconsistent_header_sheet1_Metrics.xlsx",
+ "sample_excel_multi_inconsistent_header_sheet2_Stats.xlsx");
+ List sheetNames = Arrays.asList("Metrics", "Stats"); // Sheet "Stats" has header: System, Disk, Network
+ File multiSheetFile = createMultiSheetWorkbook("inconsistent_multi.xlsx", sheetFiles, sheetNames);
+
+ ExcelMultiReportParser parser = new ExcelMultiReportParser("testMultiInconsistent", defaultConfig);
+ ReportDto result = parser.parse(multiSheetFile);
+
+ assertNotNull(result);
+ // System.out.println("Messages (Inconsistent): " + result.getParserLogMessages());
+
+ // Items from "Metrics" (System, CPU, Memory): SysA, 70, 500
+ // Hierarchy is just "System". Values are "CPU", "Memory".
+ // Item ID: "testMultiInconsistent::Metrics::SysA"
+ // Results: {"CPU": 70, "Memory": 500}
+ assertEquals(1, result.getItems().size(), "Should only have items from the first sheet ('Metrics').");
+ String itemSysA_ID = "testMultiInconsistent::Metrics::SysA";
+ Item itemSysA = result.findItem(itemSysA_ID, result.getItems()).orElse(null);
+ assertNotNull(itemSysA, "Item from 'Metrics' sheet not found. ID searched: " + itemSysA_ID +
+ ". Available: " + result.getItems().stream().map(Item::getId).collect(Collectors.joining(", ")));
+ assertEquals("SysA", itemSysA.getName());
+ assertEquals(70, itemSysA.getResult().get("CPU"));
+ assertEquals(500, itemSysA.getResult().get("Memory"));
+
+ assertTrue(result.getParserLogMessages().stream().anyMatch(m -> m.contains("Error: Sheet 'Stats' has an inconsistent header.")), "Should log header inconsistency for 'Stats'.");
+ assertTrue(result.getParserLogMessages().stream().anyMatch(m -> m.contains("Skipping this sheet.")), "Should log skipping inconsistent sheet 'Stats'.");
+ }
+
+ @Test
+ void testParseSingleSheetFileWithMultiParser() throws IOException, URISyntaxException {
+ ExcelMultiReportParser parser = new ExcelMultiReportParser("testSingleWithMulti", defaultConfig);
+ // sample_excel_single_sheet.xlsx has header: Category, SubCategory, Value1, Value2
+ // Row: A, X, 10, 20
+ File file = getResourceFile("sample_excel_single_sheet.xlsx");
+ ReportDto result = parser.parse(file);
+
+ assertNotNull(result);
+ // System.out.println("Messages (Single with Multi): " + result.getParserLogMessages().stream().collect(Collectors.joining("\n")));
+ // System.out.println("Items (Single with Multi): " + result.getItems());
+
+ // Expected top-level items "A", "B"
+ assertEquals(2, result.getItems().size(), "Should be 2 top-level items (A, B)");
+
+ // ID structure: "testSingleWithMulti::Sheet1::A"
+ // Then sub-item "testSingleWithMulti::Sheet1::A_X"
+ Item itemA = result.findItem("testSingleWithMulti::Sheet1::A", result.getItems()).orElse(null);
+ assertNotNull(itemA, "Item A not found.");
+
+ Item itemAX = itemA.findItem("testSingleWithMulti::Sheet1::A_X", itemA.getItems()).orElse(null);
+ assertNotNull(itemAX, "Item AX not found in A.");
+ assertEquals("X", itemAX.getName());
+ assertEquals(10, itemAX.getResult().get("Value1"));
+ assertEquals(20, itemAX.getResult().get("Value2"));
+
+ assertTrue(result.getParserLogMessages().stream().anyMatch(m -> m.contains("Using header from sheet 'Sheet1' as the reference")), "Should log reference header message for the single sheet.");
+ }
+
+ @Test
+ void testParseEmptyExcelFile() throws IOException, URISyntaxException {
+ ExcelMultiReportParser parser = new ExcelMultiReportParser("testEmptyFileMulti", defaultConfig);
+ File file = getResourceFile("sample_excel_empty_sheet.xlsx");
+ ReportDto result = parser.parse(file);
+
+ assertNotNull(result);
+ assertTrue(result.getItems().isEmpty(), "Should have no items for an empty file/sheet.");
+ // System.out.println("Messages (Empty File Multi): " + result.getParserLogMessages());
+ assertTrue(result.getParserLogMessages().stream().anyMatch(m -> m.toLowerCase().contains("no header row found in sheet 'sheet1'")), "Should log no header for the empty sheet. Message was: " + result.getParserLogMessages());
+ }
+
+ @Test
+ void testParseInvalidFileWithMultiParser() throws IOException {
+ ExcelMultiReportParser parser = new ExcelMultiReportParser("testInvalidMulti", defaultConfig);
+ Path tempFile = tempDir.resolve("dummy_multi.txt");
+ Files.writeString(tempFile, "This is not an excel file for multi-parser.");
+
+ ReportDto result = parser.parse(tempFile.toFile());
+
+ assertNotNull(result);
+ assertTrue(result.getItems().isEmpty(), "Should have no items for a non-Excel file.");
+ // System.out.println("Messages (Invalid Multi): " + result.getParserLogMessages());
+ assertTrue(result.getParserLogMessages().stream()
+ .anyMatch(m -> m.toLowerCase().contains("error parsing excel file") ||
+ m.toLowerCase().contains("your input appears to be a text file") ||
+ m.toLowerCase().contains("invalid header signature") ||
+ m.toLowerCase().contains("file format not supported")),
+ "Should log error about parsing or file format. Actual: " + result.getParserLogMessages());
+ }
+}
diff --git a/src/test/java/io/jenkins/plugins/reporter/parser/ExcelReportParserTest.java b/src/test/java/io/jenkins/plugins/reporter/parser/ExcelReportParserTest.java
new file mode 100644
index 00000000..509cc6cb
--- /dev/null
+++ b/src/test/java/io/jenkins/plugins/reporter/parser/ExcelReportParserTest.java
@@ -0,0 +1,205 @@
+package io.jenkins.plugins.reporter.parser;
+
+import io.jenkins.plugins.reporter.model.ExcelParserConfig;
+import io.jenkins.plugins.reporter.model.Item;
+import io.jenkins.plugins.reporter.model.ReportDto;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.io.File;
+import java.io.IOException;
+import java.net.URISyntaxException;
+import java.net.URL;
+import java.nio.file.Files;
+import java.nio.file.Path;
+// import java.nio.file.Paths; // Not used
+import java.util.List;
+// import java.util.stream.Collectors; // Not used
+
+class ExcelReportParserTest {
+
+ private ExcelParserConfig defaultConfig;
+
+ @BeforeEach
+ void setUp() {
+ defaultConfig = new ExcelParserConfig(); // Use default config for these tests
+ }
+
+ private File getResourceFile(String fileName) throws URISyntaxException {
+ URL resource = getClass().getResource("/io/jenkins/plugins/reporter/provider/" + fileName);
+ if (resource == null) {
+ throw new IllegalArgumentException("Test resource file not found: " + fileName + ". Ensure it's in src/test/resources/io/jenkins/plugins/reporter/provider/");
+ }
+ return new File(resource.toURI());
+ }
+
+ @Test
+ void testParseSingleSheetNominal() throws IOException, URISyntaxException {
+ ExcelReportParser parser = new ExcelReportParser("testReport1", defaultConfig);
+ File file = getResourceFile("sample_excel_single_sheet.xlsx");
+ ReportDto result = parser.parse(file);
+
+ assertNotNull(result);
+ assertEquals("testReport1", result.getId());
+ assertFalse(result.getItems().isEmpty(), "Should have parsed items.");
+ // System.out.println("Parser messages (single_sheet): " + result.getParserLogMessages());
+ // System.out.println("Items (single_sheet): " + result.getItems());
+
+ // Expected structure from sample_excel_single_sheet.xlsx:
+ // Header: Category, SubCategory, Value1, Value2
+ // Row: A, X, 10, 20
+ // Row: A, Y, 15, 25
+ // Row: B, Z, 20, 30
+ // ExcelReportParser will create IDs like "testReport1::A", "testReport1::A_X"
+
+ assertEquals(2, result.getItems().size(), "Should be 2 top-level items (A, B)");
+
+ Item itemA = result.findItem("testReport1::A", result.getItems()).orElse(null);
+ assertNotNull(itemA, "Item A not found. Available top-level items: " + result.getItems().stream().map(Item::getId).collect(java.util.stream.Collectors.toList()));
+ assertEquals("A", itemA.getName());
+ assertEquals(2, itemA.getItems().size(), "Item A should have 2 sub-items (X, Y)");
+
+ Item itemAX = itemA.findItem("testReport1::A_X", itemA.getItems()).orElse(null);
+ assertNotNull(itemAX, "Item AX not found in A. Available sub-items: " + itemA.getItems().stream().map(Item::getId).collect(java.util.stream.Collectors.toList()));
+ assertEquals("X", itemAX.getName());
+ assertNotNull(itemAX.getResult(), "Item AX should have results.");
+ assertEquals(10, itemAX.getResult().get("Value1"));
+ assertEquals(20, itemAX.getResult().get("Value2"));
+
+ Item itemAY = itemA.findItem("testReport1::A_Y", itemA.getItems()).orElse(null);
+ assertNotNull(itemAY, "Item AY not found in A.");
+ assertEquals("Y", itemAY.getName());
+ assertNotNull(itemAY.getResult(), "Item AY should have results.");
+ assertEquals(15, itemAY.getResult().get("Value1"));
+ assertEquals(25, itemAY.getResult().get("Value2"));
+
+ Item itemB = result.findItem("testReport1::B", result.getItems()).orElse(null);
+ assertNotNull(itemB, "Item B not found.");
+ assertEquals("B", itemB.getName());
+ assertEquals(1, itemB.getItems().size(), "Item B should have 1 sub-item (Z)");
+
+ Item itemBZ = itemB.findItem("testReport1::B_Z", itemB.getItems()).orElse(null);
+ assertNotNull(itemBZ, "Item BZ not found in B.");
+ assertEquals("Z", itemBZ.getName());
+ assertNotNull(itemBZ.getResult(), "Item BZ should have results.");
+ assertEquals(20, itemBZ.getResult().get("Value1"));
+ assertEquals(30, itemBZ.getResult().get("Value2"));
+
+ // Check for specific messages if needed, e.g., about structure detection
+ // assertTrue(result.getParserLogMessages().stream().anyMatch(m -> m.contains("Detected structure in sheet")));
+ }
+
+ @Test
+ void testParseOnlyHeader() throws IOException, URISyntaxException {
+ ExcelReportParser parser = new ExcelReportParser("testOnlyHeader", defaultConfig);
+ File file = getResourceFile("sample_excel_only_header.xlsx");
+ ReportDto result = parser.parse(file);
+
+ assertNotNull(result);
+ assertTrue(result.getItems().isEmpty(), "Should have no items when only header is present.");
+ // System.out.println("Parser messages (only_header): " + result.getParserLogMessages());
+ assertTrue(result.getParserLogMessages().stream()
+ .anyMatch(m -> m.toLowerCase().contains("no data rows found after header")),
+ "Should log message about no data rows. Messages: " + result.getParserLogMessages());
+ }
+
+ @Test
+ void testParseEmptySheet() throws IOException, URISyntaxException {
+ ExcelReportParser parser = new ExcelReportParser("testEmptySheet", defaultConfig);
+ File file = getResourceFile("sample_excel_empty_sheet.xlsx"); // This file is empty
+ ReportDto result = parser.parse(file);
+
+ assertNotNull(result);
+ assertTrue(result.getItems().isEmpty(), "Should have no items for an empty sheet.");
+ // System.out.println("Parser messages (empty_sheet): " + result.getParserLogMessages());
+ // The ExcelReportParser uses WorkbookFactory.create(is) which might throw for a 0KB file if it's not even a valid ZIP.
+ // If it's a valid ZIP (empty XLSX), POI might say "has no sheets".
+ // If BaseExcelParser.findHeaderRow is called on an empty sheet, it returns Optional.empty().
+ // ExcelReportParser.parseSheet then logs "No header row found".
+ assertTrue(result.getParserLogMessages().stream()
+ .anyMatch(m -> m.toLowerCase().contains("no header row found") ||
+ m.toLowerCase().contains("excel file has no sheets") ||
+ m.toLowerCase().contains("error parsing excel file")), // More general catch
+ "Should log message about no header, no sheets, or parsing error. Messages: " + result.getParserLogMessages());
+ }
+
+ @Test
+ void testParseNoHeaderData() throws IOException, URISyntaxException {
+ ExcelReportParser parser = new ExcelReportParser("testNoHeader", defaultConfig);
+ // sample_excel_no_header.xlsx contains:
+ // 1,2,3
+ // 4,5,6
+ File file = getResourceFile("sample_excel_no_header.xlsx");
+ ReportDto result = parser.parse(file);
+
+ assertNotNull(result);
+ // System.out.println("Parser messages (no_header): " + result.getParserLogMessages());
+ // System.out.println("Items (no_header): " + result.getItems());
+
+ // BaseExcelParser.findHeaderRow will pick the first non-empty row. So "1,2,3" becomes header.
+ // Header names: "1", "2", "3"
+ // Data row: "4,5,6"
+ // Structure detection:
+ // - '6' is numeric, colIdxValueStart becomes 2 (index of "3")
+ // - '5' is numeric, colIdxValueStart becomes 1 (index of "2")
+ // - '4' is numeric, colIdxValueStart becomes 0 (index of "1")
+ // So, all columns are treated as value columns. Hierarchy part is empty.
+ // This means items will be direct children of the report, named "Data Row X" by ExcelReportParser.
+
+ assertFalse(result.getItems().isEmpty(), "Should parse items even if header is data-like.");
+ assertEquals(1, result.getItems().size(), "Should parse one main data item when first row is taken as header.");
+
+ Item dataItem = result.getItems().get(0);
+ // Default name for rows that don't form hierarchy is "Data Row X (Sheet: Y)"
+ // The ID is generated like: "sheet_" + sheetName.replaceAll("[^a-zA-Z0-9]", "") + "_row_" + (i + 1) + "_" + reportId;
+ // For this test, reportId is "testNoHeader". Sheet name is probably "Sheet1". Row index i is 0 (first data row).
+ // String expectedId = "sheet_Sheet1_row_1_testNoHeader"; // This is an assumption on sheet name and row index logic
+ // assertEquals(expectedId, dataItem.getId()); // ID check can be fragile
+ assertTrue(dataItem.getName().startsWith("Data Row 1"), "Item name should be generic for data row.");
+
+ assertNotNull(dataItem.getResult(), "Data item should have results.");
+ assertEquals(4, dataItem.getResult().get("1")); // Header "1" -> value 4
+ assertEquals(5, dataItem.getResult().get("2")); // Header "2" -> value 5
+ assertEquals(6, dataItem.getResult().get("3")); // Header "3" -> value 6
+
+ assertTrue(result.getParserLogMessages().stream()
+ .anyMatch(m -> m.contains("Detected structure in sheet")),
+ "Structure detection message should be present. Messages: " + result.getParserLogMessages());
+ assertTrue(result.getParserLogMessages().stream()
+ .anyMatch(m -> m.contains("Info: Row 1 in sheet 'Sheet1' has all columns treated as values.")),
+ "Should log info about all columns treated as values. Messages: " + result.getParserLogMessages());
+ }
+
+ @Test
+ void testParseInvalidFile() throws IOException {
+ ExcelReportParser parser = new ExcelReportParser("testInvalid", defaultConfig);
+
+ Path tempDir = null;
+ File dummyFile = null;
+ try {
+ tempDir = Files.createTempDirectory("test-excel-invalid");
+ dummyFile = new File(tempDir.toFile(), "dummy.txt");
+ Files.writeString(dummyFile.toPath(), "This is not an excel file, just plain text.");
+
+ ReportDto result = parser.parse(dummyFile);
+
+ assertNotNull(result);
+ assertTrue(result.getItems().isEmpty(), "Should have no items for a non-Excel file.");
+ // System.out.println("Parser messages (invalid_file): " + result.getParserLogMessages());
+ assertTrue(result.getParserLogMessages().stream()
+ .anyMatch(m -> m.toLowerCase().contains("error parsing excel file") ||
+ m.toLowerCase().contains("your input appears to be a text file") || // POI specific message for text
+ m.toLowerCase().contains("invalid header signature") || // POI specific for non-zip
+ m.toLowerCase().contains("file format not supported")), // General fallback
+ "Should log error about parsing or file format. Messages: " + result.getParserLogMessages());
+ } finally {
+ if (dummyFile != null && dummyFile.exists()) {
+ dummyFile.delete();
+ }
+ if (tempDir != null && Files.exists(tempDir)) {
+ Files.delete(tempDir);
+ }
+ }
+ }
+}
diff --git a/src/test/java/io/jenkins/plugins/reporter/provider/CsvCustomParserTest.java b/src/test/java/io/jenkins/plugins/reporter/provider/CsvCustomParserTest.java
new file mode 100644
index 00000000..5b28b4d4
--- /dev/null
+++ b/src/test/java/io/jenkins/plugins/reporter/provider/CsvCustomParserTest.java
@@ -0,0 +1,260 @@
+package io.jenkins.plugins.reporter.provider;
+
+import io.jenkins.plugins.reporter.model.Item;
+import io.jenkins.plugins.reporter.model.ReportDto;
+import org.junit.jupiter.api.Test; // Combined BeforeEach and Test from correct package
+import org.junit.jupiter.api.BeforeEach; // Explicitly for clarity, though Test covers it
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.io.File;
+import java.io.IOException;
+import java.net.URISyntaxException;
+import java.net.URL;
+import java.nio.file.Files;
+import java.nio.file.Path;
+// import java.nio.file.Paths; // Not currently used
+// import java.util.List; // Used via specific classes like ArrayList or via stream().collect()
+// import java.util.Map; // Used via item.getResult()
+import java.util.stream.Collectors;
+
+
+class CsvCustomParserTest {
+
+ // Csv.CsvCustomParser is a public static inner class, so we can instantiate it directly.
+ // private Csv csvProvider; // Not strictly needed if CsvCustomParser is static and public
+
+ @BeforeEach
+ void setUp() {
+ // No setup needed here if we directly instantiate CsvCustomParser
+ }
+
+ private File getResourceFile(String fileName) throws URISyntaxException {
+ URL resource = getClass().getResource("/io/jenkins/plugins/reporter/provider/" + fileName);
+ if (resource == null) {
+ throw new IllegalArgumentException("Test resource file not found: " + fileName +
+ ". Ensure it is in src/test/resources/io/jenkins/plugins/reporter/provider/");
+ }
+ return new File(resource.toURI());
+ }
+
+ @Test
+ void testParseStandardCsv() throws IOException, URISyntaxException {
+ Csv.CsvCustomParser parser = new Csv.CsvCustomParser("standard");
+ File file = getResourceFile("sample_csv_standard.csv"); // Host,CPU,RAM,Disk -> server1,75,16,500
+ ReportDto result = parser.parse(file);
+
+ assertNotNull(result);
+ assertEquals("standard", result.getId());
+ assertFalse(result.getItems().isEmpty(), "Should parse items.");
+ // System.out.println("Messages (Standard CSV): " + result.getParserLogMessages());
+ // System.out.println("Items (Standard CSV): " + result.getItems());
+
+ assertEquals(2, result.getItems().size());
+ Item server1 = result.findItem("server1", result.getItems()).orElse(null);
+ assertNotNull(server1, "Item 'server1' not found. Found: " + result.getItems().stream().map(Item::getId).collect(Collectors.joining(", ")));
+ assertEquals("server1", server1.getName());
+ assertEquals(75, server1.getResult().get("CPU"));
+ assertEquals(16, server1.getResult().get("RAM"));
+ assertEquals(500, server1.getResult().get("Disk"));
+
+ Item server2 = result.findItem("server2", result.getItems()).orElse(null);
+ assertNotNull(server2, "Item 'server2' not found.");
+ assertEquals("server2", server2.getName());
+ assertEquals(60, server2.getResult().get("CPU"));
+ assertEquals(32, server2.getResult().get("RAM"));
+ assertEquals(1000, server2.getResult().get("Disk"));
+ }
+
+ @Test
+ void testParseSemicolonCsv() throws IOException, URISyntaxException {
+ Csv.CsvCustomParser parser = new Csv.CsvCustomParser("semicolon");
+ File file = getResourceFile("sample_csv_semicolon.csv"); // Product;Version;Count -> AppA;1.0;150
+ ReportDto result = parser.parse(file);
+
+ assertNotNull(result);
+ // System.out.println("Messages (Semicolon CSV): " + result.getParserLogMessages());
+ assertTrue(result.getParserLogMessages().stream().anyMatch(m -> m.contains("Detected delimiter: ';'")), "Should log detected delimiter ';'");
+ assertEquals(2, result.getItems().size()); // AppA, AppB
+
+ // Hierarchy: Product -> Version. Value: Count
+ Item appA = result.findItem("AppA", result.getItems()).orElse(null);
+ assertNotNull(appA, "Item 'AppA' not found. Found: " + result.getItems().stream().map(Item::getId).collect(Collectors.joining(", ")));
+ Item appAV1 = appA.findItem("AppA1.0", appA.getItems()).orElse(null); // ID is "AppA" + "1.0"
+ assertNotNull(appAV1, "Item 'AppA1.0' not found in AppA. Found: " + appA.getItems().stream().map(Item::getId).collect(Collectors.joining(", ")));
+ assertEquals("1.0", appAV1.getName());
+ assertEquals(150, appAV1.getResult().get("Count"));
+ }
+
+ @Test
+ void testParseTabCsv() throws IOException, URISyntaxException {
+ Csv.CsvCustomParser parser = new Csv.CsvCustomParser("tab");
+ File file = getResourceFile("sample_csv_tab.csv"); // Name Age City -> John 30 New York
+ ReportDto result = parser.parse(file);
+
+ assertNotNull(result);
+ // System.out.println("Messages (Tab CSV): " + result.getParserLogMessages());
+ // System.out.println("Items (Tab CSV): " + result.getItems());
+ assertTrue(result.getParserLogMessages().stream().anyMatch(m -> m.contains("Detected delimiter: '\t'")), "Should log detected delimiter '\\t'");
+ assertEquals(2, result.getItems().size()); // John, Jane
+
+ // Hierarchy: Name. Values: Age, City
+ Item john = result.findItem("John", result.getItems()).orElse(null);
+ assertNotNull(john, "Item 'John' not found. Found: " + result.getItems().stream().map(Item::getId).collect(Collectors.joining(", ")));
+ assertEquals("John", john.getName());
+ assertEquals(30, john.getResult().get("Age"));
+ assertEquals(0, john.getResult().get("City"), "Non-numeric 'City' in value part should result in 0, as per current CsvCustomParser int conversion.");
+ }
+
+ @Test
+ void testParseLeadingEmptyLinesCsv() throws IOException, URISyntaxException {
+ Csv.CsvCustomParser parser = new Csv.CsvCustomParser("leadingEmpty");
+ File file = getResourceFile("sample_csv_leading_empty_lines.csv"); // (Potentially empty lines) ID,Name,Value -> 1,Test,100
+ ReportDto result = parser.parse(file);
+
+ assertNotNull(result);
+ // System.out.println("Messages (Leading Empty): " + result.getParserLogMessages());
+ // System.out.println("Items (Leading Empty): " + result.getItems());
+
+ // Refactored CsvParser: "ID" (1) is numeric -> colIdxValueStart=0. All values. Generic item names.
+ // Header: ID, Name, Value. Data: 1, Test, 100.
+ // Expect one generic item because the hierarchy part is empty.
+ assertEquals(2, result.getItems().size(), "Should have 2 generic items, one for each data row.");
+
+ Item item1 = result.getItems().stream()
+ .filter(it -> it.getResult() != null && Integer.valueOf(1).equals(it.getResult().get("ID")))
+ .findFirst().orElse(null);
+ assertNotNull(item1, "Item for ID 1 not found or 'ID' not in result.");
+ assertEquals("Test", item1.getResult().get("Name"));
+ assertEquals(100, item1.getResult().get("Value"));
+ // Check for a message indicating that the header was found after skipping lines, if applicable.
+ // or that structure was detected with colIdxValueStart = 0
+ assertTrue(result.getParserLogMessages().stream().anyMatch(m -> m.contains("Info [CSV]: Detected data structure from data row index 0: Hierarchy/Text columns: 0 to -1, Value/Numeric columns: 0 to 2.") || m.contains("First column ('ID') in first data row (data index 0) is numeric.")), "Expected message about structure detection for colIdxValueStart=0.");
+ }
+
+ @Test
+ void testParseNoNumericCsv() throws IOException, URISyntaxException {
+ Csv.CsvCustomParser parser = new Csv.CsvCustomParser("noNumeric");
+ File file = getResourceFile("sample_csv_no_numeric.csv"); // ColA,ColB,ColC -> text1,text2,text3
+ ReportDto result = parser.parse(file);
+
+ assertNotNull(result);
+ // System.out.println("Messages (No Numeric): " + result.getParserLogMessages());
+ // System.out.println("Items (No Numeric): " + result.getItems());
+
+ // Refactored: Assumes last column "ColC" for values. text3 -> 0
+ assertEquals(2, result.getItems().size());
+ Item itemText1 = result.findItem("text1", result.getItems()).orElse(null);
+ assertNotNull(itemText1);
+ Item itemText1_text2 = itemText1.findItem("text1text2", itemText1.getItems()).orElse(null);
+ assertNotNull(itemText1_text2);
+ assertEquals("text2", itemText1_text2.getName());
+ assertEquals(0, itemText1_text2.getResult().get("ColC"));
+ assertTrue(result.getParserLogMessages().stream().anyMatch(m -> m.contains("Warning [CSV]: No numeric columns auto-detected")), "Expected warning about no numeric columns.");
+ }
+
+ @Test
+ void testParseOnlyValuesCsv() throws IOException, URISyntaxException {
+ Csv.CsvCustomParser parser = new Csv.CsvCustomParser("onlyValues");
+ File file = getResourceFile("sample_csv_only_values.csv"); // Val1,Val2,Val3 -> 10,20,30
+ ReportDto result = parser.parse(file);
+
+ assertNotNull(result);
+ // System.out.println("Messages (Only Values): " + result.getParserLogMessages());
+ // System.out.println("Items (Only Values): " + result.getItems());
+ // colIdxValueStart should be 0. All columns are values. Generic items per row.
+ assertEquals(2, result.getItems().size());
+
+ Item row1Item = result.getItems().get(0);
+ assertNotNull(row1Item.getResult());
+ assertEquals(10, row1Item.getResult().get("Val1"));
+ assertEquals(20, row1Item.getResult().get("Val2"));
+ assertEquals(30, row1Item.getResult().get("Val3"));
+ assertTrue(result.getParserLogMessages().stream().anyMatch(m -> m.contains("First column ('Val1') in first data row (data index 0) is numeric.")), "Should log first col is numeric.");
+ }
+
+ @Test
+ void testParseMixedHierarchyValuesCsv() throws IOException, URISyntaxException {
+ Csv.CsvCustomParser parser = new Csv.CsvCustomParser("mixed");
+ File file = getResourceFile("sample_csv_mixed_hierarchy_values.csv");
+ ReportDto result = parser.parse(file);
+ assertNotNull(result);
+ // System.out.println("Messages (Mixed Hier): " + result.getParserLogMessages());
+ // System.out.println("Items (Mixed Hier): " + result.getItems().stream().map(Item::getId).collect(Collectors.joining(", ")));
+
+ assertEquals(2, result.getItems().size(), "Expected Alpha and Beta as top-level items.");
+
+ Item alpha = result.findItem("Alpha", result.getItems()).orElse(null);
+ assertNotNull(alpha, "Item 'Alpha' not found.");
+ assertEquals(1, alpha.getItems().size(), "Alpha should have one sub-component: Auth");
+ Item auth = alpha.findItem("AlphaAuth", alpha.getItems()).orElse(null);
+ assertNotNull(auth, "Item 'AlphaAuth' not found.");
+ assertEquals(2, auth.getItems().size(), "Auth should have two metrics: LoginTime, LogoutTime");
+
+ Item loginTime = auth.findItem("AlphaAuthLoginTime", auth.getItems()).orElse(null);
+ assertNotNull(loginTime, "Item 'AlphaAuthLoginTime' not found.");
+ assertEquals("LoginTime", loginTime.getName());
+ assertEquals(120, loginTime.getResult().get("Value"));
+
+ Item beta = result.findItem("Beta", result.getItems()).orElse(null);
+ assertNotNull(beta, "Item 'Beta' not found.");
+ Item db = beta.findItem("BetaDB", beta.getItems()).orElse(null);
+ assertNotNull(db, "Item 'BetaDB' not found.");
+ Item queryTime = db.findItem("BetaDBQueryTime", db.getItems()).orElse(null);
+ assertNotNull(queryTime, "Item 'BetaDBQueryTime' not found.");
+ assertEquals(80, queryTime.getResult().get("Value"));
+ }
+
+ @Test
+ void testParseOnlyHeaderCsv() throws IOException, URISyntaxException {
+ Csv.CsvCustomParser parser = new Csv.CsvCustomParser("onlyHeader");
+ // Assuming sample_csv_only_header.csv exists: ColA,ColB,ColC
+ // This file might not have been created in the previous subtask if it was specific to Excel.
+ // If it doesn't exist, this test will fail at getResourceFile.
+ // For now, we assume it exists or will be created.
+ // If not, we'd need to create it here:
+ // Path tempFile = tempDir.resolve("sample_csv_only_header.csv");
+ // Files.writeString(tempFile, "ColA,ColB,ColC");
+ // File file = tempFile.toFile();
+ File file = getResourceFile("sample_csv_only_header.csv");
+ ReportDto result = parser.parse(file);
+
+ assertNotNull(result);
+ assertTrue(result.getItems().isEmpty(), "Should have no items when only header is present.");
+ // System.out.println("Messages (Only Header CSV): " + result.getParserLogMessages());
+ assertTrue(result.getParserLogMessages().stream().anyMatch(m -> m.contains("No valid data rows found after header.")), "Should log no data rows. Msgs: " + result.getParserLogMessages());
+ }
+
+ @Test
+ void testParseEmptyCsv() throws IOException, URISyntaxException {
+ Csv.CsvCustomParser parser = new Csv.CsvCustomParser("emptyCsv");
+ // Assume sample_csv_empty.csv is an empty file.
+ // Path tempFile = tempDir.resolve("sample_csv_empty.csv");
+ // Files.writeString(tempFile, ""); // Create empty file
+ // File file = tempFile.toFile();
+ File file = getResourceFile("sample_csv_empty.csv");
+ ReportDto result = parser.parse(file);
+
+ assertNotNull(result);
+ assertTrue(result.getItems().isEmpty(), "Should have no items for an empty CSV.");
+ // System.out.println("Messages (Empty CSV): " + result.getParserLogMessages());
+ assertTrue(result.getParserLogMessages().stream().anyMatch(m -> m.contains("No valid header row found")), "Should log no header or no content. Msgs: " + result.getParserLogMessages());
+ }
+
+ @Test
+ void testParseNonCsvFile(@org.junit.jupiter.api.io.TempDir Path tempDir) throws IOException { // Added @TempDir here
+ Csv.CsvCustomParser parser = new Csv.CsvCustomParser("nonCsv");
+ File nonCsvFile = Files.createFile(tempDir.resolve("test.txt")).toFile();
+ Files.writeString(nonCsvFile.toPath(), "This is just a plain text file, not CSV.");
+
+ ReportDto result = parser.parse(nonCsvFile);
+
+ assertNotNull(result);
+ assertTrue(result.getItems().isEmpty(), "Should have no items for a non-CSV file.");
+ // System.out.println("Messages (Non-CSV): " + result.getParserLogMessages());
+ // The parser might try to detect delimiter, fail or pick one, then fail to find header or data.
+ // Or Jackson's CsvMapper might throw an early error.
+ // The refactored code has a try-catch around MappingIterator creation.
+ assertTrue(result.getParserLogMessages().stream().anyMatch(m -> m.toLowerCase().contains("error") || m.toLowerCase().contains("failed")), "Should log an error. Msgs: " + result.getParserLogMessages());
+ }
+}
diff --git a/src/test/resources/io/jenkins/plugins/reporter/provider/alpha.xlsx b/src/test/resources/io/jenkins/plugins/reporter/provider/alpha.xlsx
new file mode 100644
index 0000000000000000000000000000000000000000..aa7929033dfd708c1079afe826c0a1876bc4b033
GIT binary patch
literal 4473
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
R?F!%uAO=;UL&Rv?{U3}5Ql0<+
literal 0
HcmV?d00001
diff --git a/src/test/resources/io/jenkins/plugins/reporter/provider/beta.xlsx b/src/test/resources/io/jenkins/plugins/reporter/provider/beta.xlsx
new file mode 100644
index 0000000000000000000000000000000000000000..24fcf17f7ad9fd1c6f6275b40b1d3fcb2c175c33
GIT binary patch
literal 4471
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
Ar~m)}
literal 0
HcmV?d00001
diff --git a/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_multi_consistent_sheet1_Data_Alpha.csv b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_multi_consistent_sheet1_Data_Alpha.csv
new file mode 100644
index 00000000..e602a3bb
--- /dev/null
+++ b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_multi_consistent_sheet1_Data_Alpha.csv
@@ -0,0 +1,3 @@
+ID,Metric,Result
+Alpha001,Time,100
+Alpha002,Score,200
diff --git a/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_multi_consistent_sheet1_Data_Alpha.xlsx b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_multi_consistent_sheet1_Data_Alpha.xlsx
new file mode 100644
index 0000000000000000000000000000000000000000..bdae9f1cdc487b336367554b9a88ce0e8eb425eb
GIT binary patch
literal 4473
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
a1Rd?jBMjg_S2I!&DJT-(qTW=1?EV9(pCV-d
literal 0
HcmV?d00001
diff --git a/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_only_header.csv b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_only_header.csv
new file mode 100644
index 00000000..310e09e5
--- /dev/null
+++ b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_only_header.csv
@@ -0,0 +1 @@
+ColA,ColB,ColC
diff --git a/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_only_header.xlsx b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_only_header.xlsx
new file mode 100644
index 0000000000000000000000000000000000000000..5219833c8827ab91334fe96c032c49a22e64b348
GIT binary patch
literal 4382
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