diff --git a/README.md b/README.md
index e2b98a0..353b550 100644
--- a/README.md
+++ b/README.md
@@ -27,7 +27,7 @@ Whether you're tracking metrics, analyzing trends, or monitoring performance, th
## Key Features
- **Visualize Nested Data**: Display hierarchical data structures in pie charts, trend charts, and tables.
-- **Multiple File Formats**: Supports JSON, YAML, XML, and CSV files.
+- **Multiple File Formats**: Supports JSON, YAML, XML, CSV, and Excel (.xls, .xlsx) files.
- **Dynamic UI**: Interactive charts and tables that update based on your data.
- **Customizable Colors**: Define custom colors for your data points or use predefined color schemes.
- **Trend Analysis**: Track data trends over multiple builds with history charts.
@@ -85,6 +85,34 @@ The plugin supports the following file formats for data input:
#### YAML and XML
- Similar hierarchical structures as JSON are supported.
+#### Excel (`excel` provider)
+- This provider parses a single Excel sheet from an `.xls` or `.xlsx` file. By default, it processes the **first sheet** in the workbook.
+- **Structure Expectation:**
+ - The parser automatically detects the header row (the first non-empty row).
+ - Columns *before* the first column containing predominantly numeric data are treated as hierarchy levels.
+ - Columns *from* the first numeric-looking column onwards are treated as data values, with their respective header names as keys for the results.
+- **Example Data (conceptual view of a sheet):**
+ ```
+ (Sheet1 in an .xlsx or .xls file)
+ Category, SubCategory, Metric1, Value2
+ Alpha, X, 10, 100
+ Alpha, Y, 15, 150
+ Beta, Z, 20, 200
+ ```
+ In this example:
+ - "Category" and "SubCategory" would form the hierarchy (e.g., Alpha -> X).
+ - "Metric1" and "Value2" would be the data keys with their corresponding numeric values.
+- Empty rows before the header or between data rows are typically ignored.
+
+#### Multi-Sheet Excel (`excelmulti` provider)
+- This provider parses **all sheets** in an Excel workbook (.xls or .xlsx).
+- **Header Consistency Requirement:**
+ - The header from the *first successfully parsed sheet* (first non-empty sheet with a valid header) is used as a reference.
+ - Subsequent sheets **must have an identical header** (same column names in the same order) to be included in the report.
+ - Sheets with headers that do not match the reference header will be skipped, and a warning will be logged.
+- **Data Structure per Sheet:** Within each sheet, the data structure expectation is the same as for the `excel` provider (auto-detected header, hierarchy based on pre-numeric columns, values from numeric columns onwards).
+- Item IDs are generated to be unique across sheets, typically by internally prefixing them with sheet-specific information.
+
---
## Color Management
@@ -95,7 +123,7 @@ The plugin allows you to customize the colors used in the visualizations. You ca
To customize colors, add a `colors` object to your JSON, YAML, or XML file. The `colors` object should map metric keys or category names to specific colors. Colors can be defined using **HEX values** or **predefined color names**.
-> **Note**: Color customization is **not supported for CSV files** due to the format does not allow color attribute definition. For now, colors are attributed aleatory.
+> **Note**: Color customization is **not supported for CSV or Excel files** as these formats do not have a standard way to define color attributes within the data file itself for this plugin's use. For CSV and Excel, colors are attributed automatically by the charting libraries.
#### Example in JSON:
```json
@@ -146,9 +174,17 @@ You can interact with the charts and tables to drill down into specific data poi
- `relative`: Show percentage values.
- `dual`: Show both absolute and relative values.
- **`provider`**: Specify the file format and pattern for the data files.
- - **`id`**: (Required for CSV) A unique identifier for the report.
+ - **`id`**: (Optional, but recommended for CSV, Excel, and ExcelMulti if multiple reports of the same type are used) A unique identifier for the report instance. This helps in creating distinct report URLs and managing history, especially if you have multiple CSV or Excel reports in the same job.
- **`pattern`**: An Ant-style pattern to locate the data files.
+ **Examples for `provider`:**
+ - JSON: `provider: json(pattern: 'reports/**/*.json')`
+ - CSV: `provider: csv(id: 'my-csv-report', pattern: 'reports/data.csv')`
+ - Excel (single sheet): `provider: excel(pattern: 'reports/data.xlsx')`
+ - Excel (multi-sheet): `provider: excelmulti(pattern: 'reports/multi_sheet_data.xlsx')`
+ - You can also add an `id` to `excel` and `excelmulti` if needed:
+ `provider: excel(id: 'my-excel-report', pattern: 'reports/data.xlsx')`
+
## Examples
diff --git a/pom.xml b/pom.xml
index aba68c3..340a0f0 100644
--- a/pom.xml
+++ b/pom.xml
@@ -117,6 +117,25 @@
jackson2-api
+
+
+ org.apache.poi
+ poi
+ 5.4.1
+
+
+ org.apache.poi
+ poi-ooxml
+ 5.4.1
+
+
+
+
+ com.google.code.findbugs
+ jsr305
+ 3.0.2
+
+
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 0000000..6e19bb5
--- /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/model/Item.java b/src/main/java/io/jenkins/plugins/reporter/model/Item.java
index d5ebe13..811d031 100644
--- a/src/main/java/io/jenkins/plugins/reporter/model/Item.java
+++ b/src/main/java/io/jenkins/plugins/reporter/model/Item.java
@@ -72,9 +72,14 @@ public LinkedHashMap getResult() {
return result;
}
- return getItems()
+ // NPE fix: check if items list is null or empty before streaming
+ if (items == null || items.isEmpty()) { // items is the List- field
+ return new LinkedHashMap<>(); // Return empty map if no sub-items to aggregate from
+ }
+
+ return items // Now items is guaranteed not to be null and not empty
.stream()
- .map(Item::getResult)
+ .map(Item::getResult) // Recursive call
.flatMap(map -> map.entrySet().stream())
.collect(Collectors.groupingBy(Map.Entry::getKey, LinkedHashMap::new, Collectors.summingInt(Map.Entry::getValue)));
}
@@ -114,6 +119,9 @@ public void setItems(List
- items) {
}
public void addItem(Item item) {
+ if (this.items == null) {
+ this.items = new ArrayList<>();
+ }
this.items.add(item);
}
}
\ No newline at end of file
diff --git a/src/main/java/io/jenkins/plugins/reporter/model/ReportDto.java b/src/main/java/io/jenkins/plugins/reporter/model/ReportDto.java
index 08d020b..5d65a6e 100644
--- a/src/main/java/io/jenkins/plugins/reporter/model/ReportDto.java
+++ b/src/main/java/io/jenkins/plugins/reporter/model/ReportDto.java
@@ -19,6 +19,10 @@ public class ReportDto extends ReportBase {
@JsonInclude(JsonInclude.Include.NON_NULL)
private Map colors;
+ @JsonProperty(value = "parserLogMessages")
+ @JsonInclude(JsonInclude.Include.NON_EMPTY) // Only include in JSON if not empty
+ private List parserLogMessages;
+
public String getId() {
return id;
}
@@ -42,6 +46,14 @@ public Map getColors() {
public void setColors(Map colors) {
this.colors = colors;
}
+
+ public List getParserLogMessages() {
+ return parserLogMessages;
+ }
+
+ public void setParserLogMessages(List parserLogMessages) {
+ this.parserLogMessages = parserLogMessages;
+ }
@JsonIgnore
public Report toReport() {
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 0000000..b89f824
--- /dev/null
+++ b/src/main/java/io/jenkins/plugins/reporter/parser/AbstractReportParserBase.java
@@ -0,0 +1,210 @@
+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;
+ }
+
+ 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 (rowValues.stream().allMatch(StringUtils::isBlank)) {
+ messagesCollector.add(String.format("Info [%s]: Skipped row with all blank cells at data index %d.", parserName, rowIndexForLog));
+ return;
+ }
+
+ 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";
+ 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) {
+ String hierarchyCellValue = rawCellValue;
+ String originalCellValueForName = rawCellValue;
+
+ if (StringUtils.isBlank(hierarchyCellValue)) {
+ if (colIdx == 0) {
+ 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;
+ } 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));
+ }
+
+ currentItemPathId += hierarchyCellValue.replaceAll("[^a-zA-Z0-9_-]", "_") + "_";
+ String itemId = StringUtils.removeEnd(currentItemPathId, "_");
+ if (StringUtils.isBlank(itemId)) {
+ 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));
+ 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 {
+ Optional
- existingRootItem = reportDto.getItems().stream().filter(it -> it.getId().equals(currentItem.getId())).findFirst();
+ if (!existingRootItem.isPresent()) {
+ if (reportDto.getItems() == null) reportDto.setItems(new ArrayList<>());
+ reportDto.getItems().add(currentItem);
+ lastItemWasNewlyCreated = true;
+ lastItem = currentItem;
+ } else {
+ lastItem = existingRootItem.get();
+ }
+ }
+ parentId = currentItem.getId();
+ } else {
+ 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());
+ }
+ }
+
+ if (issueInHierarchy) {
+ return;
+ }
+
+ if (lastItem != null) {
+ if (lastItem.getResult() == null || lastItemWasNewlyCreated) {
+ lastItem.setResult(resultValuesMap);
+ } else {
+ 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()) {
+ messagesCollector.add(String.format("Debug [%s]: In parseRowToItems - creating direct data item. Row: %d, BaseID: %s, ColIdxValueStart: %d, Results: %s",
+ parserName, rowIndexForLog, baseItemIdPrefix, colIdxValueStart, resultValuesMap.toString()));
+ 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));
+ valueItem.setResult(resultValuesMap);
+ if (reportDto.getItems() == null) reportDto.setItems(new ArrayList<>());
+ reportDto.getItems().add(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) {
+ messagesCollector.add(String.format("Debug [%s]: In parseRowToItems - row yielded no hierarchy item and no results. Row: %d, BaseID: %s, ColIdxValueStart: %d",
+ parserName, rowIndexForLog, baseItemIdPrefix, colIdxValueStart));
+ 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 0000000..085271f
--- /dev/null
+++ b/src/main/java/io/jenkins/plugins/reporter/parser/BaseExcelParser.java
@@ -0,0 +1,194 @@
+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; // No longer directly needed, comes from AbstractReportParserBase
+import io.jenkins.plugins.reporter.parser.AbstractReportParserBase; // Added
+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 AbstractReportParserBase { // Changed superclass
+
+ private static final long serialVersionUID = 1L; // Keep existing or update if major structural change
+ // protected static final Logger LOGGER = Logger.getLogger(BaseExcelParser.class.getName()); // Use PARSER_LOGGER from base class
+ // No, PARSER_LOGGER in AbstractReportParserBase is for that class. Keep this one for BaseExcelParser specific logs.
+ 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 0000000..0deef73
--- /dev/null
+++ b/src/main/java/io/jenkins/plugins/reporter/parser/ExcelMultiReportParser.java
@@ -0,0 +1,146 @@
+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; // No longer directly used here
+// import org.apache.commons.lang3.math.NumberUtils; // No longer directly used here
+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.setParserLogMessages(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()) {
+ if (aggregatedReport.getItems() == null) aggregatedReport.setItems(new java.util.ArrayList<>()); // Defensive
+ aggregatedReport.getItems().add(item);
+ }
+ }
+ }
+
+ aggregatedReport.setParserLogMessages(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.setParserLogMessages(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();
+
+ Row actualFirstDataRow = sheet.getRow(firstDataRowIndex);
+ List firstDataRowValues = null;
+ if (actualFirstDataRow != null && !isRowEmpty(actualFirstDataRow)) {
+ firstDataRowValues = getRowValues(actualFirstDataRow);
+ }
+ this.parserMessages.add(String.format("Debug [ExcelMulti]: Sheet: %s, Header: %s", sheetName, currentSheetHeader.toString()));
+ this.parserMessages.add(String.format("Debug [ExcelMulti]: Sheet: %s, FirstDataRowValues for structure detection: %s", sheetName, (firstDataRowValues != null ? firstDataRowValues.toString() : "null")));
+
+ int colIdxValueStart = detectColumnStructure(currentSheetHeader, firstDataRowValues, this.parserMessages, "ExcelMulti");
+ this.parserMessages.add(String.format("Debug [ExcelMulti]: Sheet: %s, Detected colIdxValueStart: %d", sheetName, colIdxValueStart));
+ if (colIdxValueStart == -1) {
+ // Error already logged by detectColumnStructure
+ return report;
+ }
+
+ // Data Processing Loop
+ for (int i = firstDataRowIndex; i <= sheet.getLastRowNum(); i++) {
+ Row currentRow = sheet.getRow(i);
+ if (isRowEmpty(currentRow)) { // isRowEmpty is a protected method in BaseExcelParser
+ this.parserMessages.add(String.format("Info [ExcelMulti]: Skipped empty Excel row object at sheet row index %d.", i));
+ continue;
+ }
+ List rowValues = getRowValues(currentRow);
+ // Add the existing diagnostic log from the previous step
+ this.parserMessages.add(String.format("Debug [ExcelMulti]: Sheet: %s, Row: %d, Processing rowValues: %s", sheetName, i, rowValues.toString()));
+ parseRowToItems(report, rowValues, currentSheetHeader, colIdxValueStart, reportId, this.parserMessages, "ExcelMulti", i);
+ }
+ 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 0000000..5238539
--- /dev/null
+++ b/src/main/java/io/jenkins/plugins/reporter/parser/ExcelReportParser.java
@@ -0,0 +1,120 @@
+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; // No longer directly used here as logic moved to base
+// import org.apache.commons.lang3.math.NumberUtils; // No longer directly used here
+
+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.setParserLogMessages(this.parserMessages);
+ return reportDto;
+ }
+
+ Sheet firstSheet = workbook.getSheetAt(0);
+ ReportDto sheetReport = parseSheet(firstSheet, firstSheet.getSheetName(), this.config, this.id);
+ sheetReport.setParserLogMessages(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.setParserLogMessages(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();
+
+ Row actualFirstDataRow = sheet.getRow(firstDataRowIndex);
+ List firstDataRowValues = null;
+ if (actualFirstDataRow != null && !isRowEmpty(actualFirstDataRow)) {
+ firstDataRowValues = getRowValues(actualFirstDataRow);
+ }
+ this.parserMessages.add(String.format("Debug [Excel]: Sheet: %s, Header: %s", sheetName, header.toString()));
+ this.parserMessages.add(String.format("Debug [Excel]: Sheet: %s, FirstDataRowValues for structure detection: %s", sheetName, (firstDataRowValues != null ? firstDataRowValues.toString() : "null")));
+
+ int colIdxValueStart = detectColumnStructure(header, firstDataRowValues, this.parserMessages, "Excel");
+ this.parserMessages.add(String.format("Debug [Excel]: Sheet: %s, Detected colIdxValueStart: %d", sheetName, colIdxValueStart));
+ if (colIdxValueStart == -1) {
+ // Error already logged by detectColumnStructure
+ return report;
+ }
+
+ for (int i = firstDataRowIndex; i <= sheet.getLastRowNum(); i++) {
+ Row currentRow = sheet.getRow(i);
+ if (isRowEmpty(currentRow)) { // isRowEmpty is a protected method in BaseExcelParser
+ this.parserMessages.add(String.format("Info [Excel]: Skipped empty Excel row object at sheet row index %d.", i));
+ continue;
+ }
+ List rowValues = getRowValues(currentRow);
+ // Add the existing diagnostic log from the previous step
+ this.parserMessages.add(String.format("Debug [Excel]: Sheet: %s, Row: %d, Processing rowValues: %s", sheetName, i, rowValues.toString()));
+ parseRowToItems(report, rowValues, header, colIdxValueStart, reportId, this.parserMessages, "Excel", i);
+ }
+ 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 4417152..ee3ca39 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.setParserLogMessages(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.setParserLogMessages(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.setParserLogMessages(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.setParserLogMessages(this.parserMessages);
+ return report;
+ }
+
+ int colIdxValueStart = detectColumnStructure(header, firstActualDataRow, this.parserMessages, "CSV");
+ if (colIdxValueStart == -1) {
+ // Error logged by detectColumnStructure
+ report.setParserLogMessages(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.setParserLogMessages(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 0000000..b5b6558
--- /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 0000000..c649d4f
--- /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 0000000..8a0e652
--- /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 = result.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 = result.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 = result.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 'sample_excel_empty_sheet.csv'")), "Should log no header for the sheet named after the source CSV. 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 0000000..a8e6213
--- /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 = result.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 = result.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 = result.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 0000000..9cfaad1
--- /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 = result.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 = result.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("Info [CSV]: First column ('Val1') is numeric. Treating it as the first value column.")), "Should log correct message for first column numeric. Messages: " + result.getParserLogMessages());
+ }
+
+ @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 = result.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 = result.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 = result.findItem("BetaDB", beta.getItems()).orElse(null);
+ assertNotNull(db, "Item 'BetaDB' not found.");
+ Item queryTime = result.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 0000000..aa79290
Binary files /dev/null and b/src/test/resources/io/jenkins/plugins/reporter/provider/alpha.xlsx differ
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 0000000..24fcf17
Binary files /dev/null and b/src/test/resources/io/jenkins/plugins/reporter/provider/beta.xlsx differ
diff --git a/src/test/resources/io/jenkins/plugins/reporter/provider/sample_csv_empty.csv b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_csv_empty.csv
new file mode 100644
index 0000000..e69de29
diff --git a/src/test/resources/io/jenkins/plugins/reporter/provider/sample_csv_leading_empty_lines.csv b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_csv_leading_empty_lines.csv
new file mode 100644
index 0000000..2c96d43
--- /dev/null
+++ b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_csv_leading_empty_lines.csv
@@ -0,0 +1,3 @@
+ID,Name,Value
+1,Test,100
+2,Sample,200
diff --git a/src/test/resources/io/jenkins/plugins/reporter/provider/sample_csv_mixed_hierarchy_values.csv b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_csv_mixed_hierarchy_values.csv
new file mode 100644
index 0000000..20072a5
--- /dev/null
+++ b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_csv_mixed_hierarchy_values.csv
@@ -0,0 +1,5 @@
+System,Component,Metric,Value
+Alpha,Auth,LoginTime,120
+Alpha,Auth,LogoutTime,30
+Beta,DB,QueryTime,80
+Beta,DB,Connections,15
diff --git a/src/test/resources/io/jenkins/plugins/reporter/provider/sample_csv_no_numeric.csv b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_csv_no_numeric.csv
new file mode 100644
index 0000000..34eb755
--- /dev/null
+++ b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_csv_no_numeric.csv
@@ -0,0 +1,3 @@
+ColA,ColB,ColC
+text1,text2,text3
+textA,textB,textC
diff --git a/src/test/resources/io/jenkins/plugins/reporter/provider/sample_csv_only_header.csv b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_csv_only_header.csv
new file mode 100644
index 0000000..310e09e
--- /dev/null
+++ b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_csv_only_header.csv
@@ -0,0 +1 @@
+ColA,ColB,ColC
diff --git a/src/test/resources/io/jenkins/plugins/reporter/provider/sample_csv_only_values.csv b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_csv_only_values.csv
new file mode 100644
index 0000000..330ee3c
--- /dev/null
+++ b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_csv_only_values.csv
@@ -0,0 +1,3 @@
+Val1,Val2,Val3
+10,20,30
+40,50,60
diff --git a/src/test/resources/io/jenkins/plugins/reporter/provider/sample_csv_semicolon.csv b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_csv_semicolon.csv
new file mode 100644
index 0000000..d753841
--- /dev/null
+++ b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_csv_semicolon.csv
@@ -0,0 +1,3 @@
+Product;Version;Count
+AppA;1.0;150
+AppB;2.1;200
diff --git a/src/test/resources/io/jenkins/plugins/reporter/provider/sample_csv_standard.csv b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_csv_standard.csv
new file mode 100644
index 0000000..15d7ac2
--- /dev/null
+++ b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_csv_standard.csv
@@ -0,0 +1,3 @@
+Host,CPU,RAM,Disk
+server1,75,16,500
+server2,60,32,1000
diff --git a/src/test/resources/io/jenkins/plugins/reporter/provider/sample_csv_tab.csv b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_csv_tab.csv
new file mode 100644
index 0000000..8a2f1ef
--- /dev/null
+++ b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_csv_tab.csv
@@ -0,0 +1,3 @@
+Name Age City
+John 30 New York
+Jane 25 London
diff --git a/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_empty_sheet.csv b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_empty_sheet.csv
new file mode 100644
index 0000000..e69de29
diff --git a/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_empty_sheet.xlsx b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_empty_sheet.xlsx
new file mode 100644
index 0000000..3e48c78
Binary files /dev/null and b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_empty_sheet.xlsx differ
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 0000000..e602a3b
--- /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 0000000..bdae9f1
Binary files /dev/null and b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_multi_consistent_sheet1_Data_Alpha.xlsx differ
diff --git a/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_multi_consistent_sheet2_Data_Beta.csv b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_multi_consistent_sheet2_Data_Beta.csv
new file mode 100644
index 0000000..ebca9d1
--- /dev/null
+++ b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_multi_consistent_sheet2_Data_Beta.csv
@@ -0,0 +1,3 @@
+ID,Metric,Result
+Beta001,Time,110
+Beta002,Score,210
diff --git a/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_multi_consistent_sheet2_Data_Beta.xlsx b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_multi_consistent_sheet2_Data_Beta.xlsx
new file mode 100644
index 0000000..0d18c08
Binary files /dev/null and b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_multi_consistent_sheet2_Data_Beta.xlsx differ
diff --git a/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_multi_inconsistent_header_sheet1_Metrics.csv b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_multi_inconsistent_header_sheet1_Metrics.csv
new file mode 100644
index 0000000..46d1add
--- /dev/null
+++ b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_multi_inconsistent_header_sheet1_Metrics.csv
@@ -0,0 +1,2 @@
+System,CPU,Memory
+SysA,70,500
diff --git a/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_multi_inconsistent_header_sheet1_Metrics.xlsx b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_multi_inconsistent_header_sheet1_Metrics.xlsx
new file mode 100644
index 0000000..6c49ac1
Binary files /dev/null and b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_multi_inconsistent_header_sheet1_Metrics.xlsx differ
diff --git a/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_multi_inconsistent_header_sheet2_Stats.csv b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_multi_inconsistent_header_sheet2_Stats.csv
new file mode 100644
index 0000000..33af5a5
--- /dev/null
+++ b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_multi_inconsistent_header_sheet2_Stats.csv
@@ -0,0 +1,2 @@
+System,Disk,Network
+SysA,300,1000
diff --git a/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_multi_inconsistent_header_sheet2_Stats.xlsx b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_multi_inconsistent_header_sheet2_Stats.xlsx
new file mode 100644
index 0000000..d893fac
Binary files /dev/null and b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_multi_inconsistent_header_sheet2_Stats.xlsx differ
diff --git a/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_no_header.csv b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_no_header.csv
new file mode 100644
index 0000000..da813b6
--- /dev/null
+++ b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_no_header.csv
@@ -0,0 +1,2 @@
+1,2,3
+4,5,6
diff --git a/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_no_header.xlsx b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_no_header.xlsx
new file mode 100644
index 0000000..a2a1ad1
Binary files /dev/null and b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_no_header.xlsx differ
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 0000000..310e09e
--- /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 0000000..5219833
Binary files /dev/null and b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_only_header.xlsx differ
diff --git a/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_single_sheet.csv b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_single_sheet.csv
new file mode 100644
index 0000000..b00eb06
--- /dev/null
+++ b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_single_sheet.csv
@@ -0,0 +1,7 @@
+"","","",""
+"","","",""
+"Category","SubCategory","Value1","Value2"
+"A","X","10","20"
+"A","Y","15","25"
+"","","",""
+"B","Z","20","30"
diff --git a/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_single_sheet.xlsx b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_single_sheet.xlsx
new file mode 100644
index 0000000..5eed717
Binary files /dev/null and b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_single_sheet.xlsx differ
diff --git a/src/test/resources/io/jenkins/plugins/reporter/provider/temp_multi.gnumeric b/src/test/resources/io/jenkins/plugins/reporter/provider/temp_multi.gnumeric
new file mode 100644
index 0000000..5539b6e
Binary files /dev/null and b/src/test/resources/io/jenkins/plugins/reporter/provider/temp_multi.gnumeric differ