JesFtpFileEntryParser.java
// Generated by delombok at Mon Apr 14 16:48:01 UTC 2025
package de.larssh.jes.parser;
import static de.larssh.utils.text.Strings.NEW_LINE;
import static java.util.stream.Collectors.toList;
import java.io.BufferedReader;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import java.util.OptionalInt;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.commons.net.ftp.FTPFileEntryParser;
import de.larssh.jes.Job;
import de.larssh.jes.JobFlag;
import de.larssh.jes.JobOutput;
import de.larssh.jes.JobStatus;
import de.larssh.utils.Nullables;
import de.larssh.utils.Optionals;
import de.larssh.utils.text.Lines;
import de.larssh.utils.text.Patterns;
import de.larssh.utils.text.Strings;
import edu.umd.cs.findbugs.annotations.NonNull;
import edu.umd.cs.findbugs.annotations.Nullable;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
/**
* Implementation of {@link FTPFileEntryParser} for IBM z/OS JES spools,
* converting that information into a {@link JesFtpFile} instance.
*/
public class JesFtpFileEntryParser implements FTPFileEntryParser {
/**
* Pattern to match the job details header response line
*/
private static final Pattern PATTERN_TITLE = Pattern.compile("^JOBNAME +JOBID +OWNER +STATUS +CLASS *$", Pattern.CASE_INSENSITIVE);
/**
* Pattern to match job details lines inside response
*
* <p>
* List of named groups:
* <ul>
* <li>name
* <li>id
* <li>owner
* <li>status
* <li>class
* <li>rest
* </ul>
*/
private static final Pattern PATTERN_JOB = Pattern.compile("^(?<name>[^ ]+) +(?<id>[^ ]+) +(?<owner>[^ ]+) +(?<status>(INPUT|ACTIVE|OUTPUT)) +(?<class>[^ ]+)( +(?<rest>.*))?$", Pattern.CASE_INSENSITIVE);
/**
* Pattern to find the abend code inside a response line
*
* <p>
* List of named groups:
* <ul>
* <li>abend
* </ul>
*/
private static final Pattern PATTERN_JOB_ABEND = Pattern.compile("ABEND=(?<abend>\\S+)", Pattern.CASE_INSENSITIVE);
/**
* Pattern to find the result code inside a response line
*
* <p>
* List of named groups:
* <ul>
* <li>returnCode
* </ul>
*/
private static final Pattern PATTERN_JOB_RETURN_CODE = Pattern.compile("RC=(?<returnCode>\\d+)", Pattern.CASE_INSENSITIVE);
/**
* Pattern to match the separator response line OR an output warning
*/
private static final Pattern PATTERN_GARBAGE = Pattern.compile("^(-+|STEP, PROC, CPUT, and ELAPT unknown) *$", Pattern.CASE_INSENSITIVE);
/**
* Pattern to match the job output header response line
*/
private static final Pattern PATTERN_SUB_TITLE = Pattern.compile("^ {9}ID STEPNAME PROCSTEP C DDNAME (BYTE|REC)-COUNT( COMMENT)? *$", Pattern.CASE_INSENSITIVE);
/**
* Pattern to match job output lines inside response
*
* <p>
* List of named groups:
* <ul>
* <li>index
* <li>step
* <li>procedureStep
* <li>class
* <li>name
* <li>length
* </ul>
*/
private static final Pattern PATTERN_JOB_OUTPUT = Pattern.compile("^ {9}(?<index>\\d{3}) (?<step>.{8}) (?<procedureStep>.{8}) (?<class>.) (?<name>.{8}) +(?<length>\\d+) *$");
/**
* Pattern to match the response line containing the number of spool files
*/
private static final Pattern PATTERN_SPOOL_FILES = Pattern.compile("^\\d+ spool files *$", Pattern.CASE_INSENSITIVE);
/**
* Builds a {@link Job} object based on a job details response {@code line}.
*
* @param line job details response line
* @return {@link Job} object
*/
@SuppressWarnings("checkstyle:MultipleStringLiterals")
private Job createJob(final String line) {
final Matcher matcher = Patterns.matches(PATTERN_JOB, line).orElseThrow(() -> new JesFtpFileEntryParserException("Expected [%s] as job details line, got [%s].", PATTERN_JOB.pattern(), line));
final String jobId = matcher.group("id");
final String name = matcher.group("name");
final JobStatus status = JobStatus.valueOf(matcher.group("status"));
final String owner = matcher.group("owner");
final Optional<String> jesClass = Optionals.ofNonBlank(matcher.group("class"));
final Optional<String> rest = Optionals.ofNonBlank(matcher.group("rest"));
final OptionalInt resultCode = Optionals.mapToInt(rest.flatMap(r -> Patterns.find(PATTERN_JOB_RETURN_CODE, r)).map(m -> m.group("returnCode")), Integer::parseInt);
final Optional<String> abendCode = rest.flatMap(r -> Patterns.find(PATTERN_JOB_ABEND, r)).map(m -> m.group("abend"));
final List<JobFlag> flags = Arrays.stream(JobFlag.values()).filter(flag -> rest.map(r -> Patterns.find(flag.getRestPattern(), r).isPresent()).orElse(Boolean.FALSE)).collect(toList());
return new Job(jobId, name, status, owner, jesClass, resultCode, abendCode, flags.toArray(new JobFlag[0]));
}
/**
* Parses {@code listEntry} and builds a {@link Job} object based on the given
* content.
*
* <p>
* Job details and job outputs were joined together using
* {@link #preParse(List)} before.
*
* @param listEntry one logical line from the file listing
* @return {@link Job} object
* @throws JesFtpFileEntryParserException on unexpected {@code listEntry}
*/
@SuppressWarnings("PMD.CyclomaticComplexity")
private Job createJobAndOutputs(final String listEntry) {
final List<String> lines = new ArrayList<>(Lines.lines(listEntry));
if (lines.isEmpty()) {
throw new JesFtpFileEntryParserException("Expected [%s] as job details line, got no line.", PATTERN_JOB.pattern());
}
// First line (job)
final Job job = createJob(lines.remove(0));
// Last line (number of spool files, optional)
if (!lines.isEmpty()) {
final String spoolFiles = lines.get(lines.size() - 1);
if (Strings.matches(spoolFiles, PATTERN_SPOOL_FILES)) {
lines.remove(lines.size() - 1);
}
}
// Second line(s) (optional garbage, e.g. a separator line or an output warning)
while (!lines.isEmpty() && Strings.matches(lines.get(0), PATTERN_GARBAGE)) {
lines.remove(0);
}
// Third line (sub title)
if (lines.isEmpty()) {
return job;
}
final String subTitle = lines.remove(0);
if (!Strings.matches(subTitle, PATTERN_SUB_TITLE)) {
throw new JesFtpFileEntryParserException("Expected [%s] as sub title line, got [%s].", PATTERN_SUB_TITLE.pattern(), subTitle);
}
// Further lines (job outputs)
for (final String line : lines) {
createJobOutput(job, line);
}
return job;
}
/**
* Builds a {@link JobOutput} object based on a job output response
* {@code line}.
*
* @param job related job
* @param line job details response line
* @return {@link JobOutput} object
*/
@SuppressWarnings("checkstyle:MultipleStringLiterals")
private JobOutput createJobOutput(final Job job, final String line) {
final Matcher matcher = Patterns.matches(PATTERN_JOB_OUTPUT, line).orElseThrow(() -> new JesFtpFileEntryParserException("Expected [%s] as job output details line, got [%s].", PATTERN_JOB_OUTPUT.pattern(), line));
final int index = Integer.parseInt(matcher.group("index"));
final String name = matcher.group("name");
final int length = Integer.parseInt(matcher.group("length"));
final Optional<String> step = Optionals.ofNonBlank(matcher.group("step"));
final Optional<String> procedureStep = Optionals.ofNonBlank(matcher.group("procedureStep"));
final Optional<String> jesClass = Optionals.ofNonBlank(matcher.group("class"));
return job.createOutput(index, name, length, step, procedureStep, jesClass);
}
/**
* {@inheritDoc}
*/
@Nullable
@Override
public JesFtpFile parseFTPEntry(@Nullable final String listEntryNullable) {
final String listEntry = Nullables.orElseThrow(listEntryNullable);
return new JesFtpFile(createJobAndOutputs(listEntry), listEntry);
}
/**
* {@inheritDoc}
*/
@Nullable
@Override
@SuppressWarnings({"checkstyle:SuppressWarnings", "resource"})
public String readNextEntry(@Nullable final BufferedReader reader) throws IOException {
return Nullables.orElseThrow(reader).readLine();
}
/**
* {@inheritDoc}
*/
@NonNull
@Override
@SuppressWarnings("PMD.CyclomaticComplexity")
@SuppressFBWarnings(value = "CFS_CONFUSING_FUNCTION_SEMANTICS", justification = "returning input variable as required by interface contract")
public List<String> preParse(@Nullable final List<String> originalNullable) {
final List<String> original = Nullables.orElseThrow(originalNullable);
// Empty list
if (original.isEmpty()) {
throw new JesFtpFileEntryParserException("Parsing JES job details failed. No line found.");
}
// First line
if (!Strings.matches(original.get(0), PATTERN_TITLE)) {
throw new JesFtpFileEntryParserException("Parsing JES job details failed. Unexpected first line: [%s].", original.get(0));
}
// Iterate over original from 1 to size
// 1. ignore title line (starting at 1)
// 2. concatenate lines of the same job to one list entry
// 3. handle last line (stopping at size)
final List<String> lines = new ArrayList<>();
final List<String> linesOfCurrentJob = new ArrayList<>();
final int size = original.size();
for (int index = 1; index <= size; index += 1) {
final boolean isLast = index >= size;
if ((isLast || Patterns.matches(PATTERN_JOB, original.get(index)).isPresent()) && !linesOfCurrentJob.isEmpty()) {
lines.add(String.join(NEW_LINE, linesOfCurrentJob));
linesOfCurrentJob.clear();
}
if (!isLast) {
linesOfCurrentJob.add(original.get(index));
}
}
// The interface tells us to return the input variable.
original.clear();
original.addAll(lines);
return original;
}
@java.lang.SuppressWarnings("all")
@edu.umd.cs.findbugs.annotations.SuppressFBWarnings(justification = "generated code")
@lombok.Generated
public JesFtpFileEntryParser() {
}
}