From a44ea00d3a9a4a452556d8de28a7f10440c56eb5 Mon Sep 17 00:00:00 2001 From: Rudi Date: Fri, 28 Aug 2026 13:11:28 -0400 Subject: [PATCH] Phase 3 --- .../nightmare/lib3270j/Telnet3270Client.java | 12 + .../haus/nightmare/lib3270j/ecl/ECLXfer.java | 418 ++++++++++++++++++ .../nightmare/lib3270j/ecl/ECLXferEvent.java | 84 ++++ .../lib3270j/ecl/ECLXferListener.java | 13 + .../haus/nightmare/lib3270j/ft/FTConfig.java | 122 ++++- .../nightmare/lib3270j/ft/FTConstants.java | 21 + .../haus/nightmare/lib3270j/ft/FTCut.java | 90 +++- .../haus/nightmare/lib3270j/ft/FTDft.java | 96 +++- .../lib3270j/ft/dir/CMSDirectoryEntry.java | 129 ++++++ .../lib3270j/ft/dir/CMSDirectoryParser.java | 100 +++++ .../FileTransferHostDirectoryInterface.java | 21 + .../lib3270j/ft/dir/HostDirectoryEntry.java | 72 +++ .../lib3270j/ft/dir/TSODirectoryEntry.java | 125 ++++++ .../lib3270j/ft/dir/TSODirectoryParser.java | 158 +++++++ .../nightmare/lib3270j/ecl/ECLXferTest.java | 91 ++++ .../nightmare/lib3270j/ft/FTPhase3Test.java | 177 ++++++++ .../lib3270j/ft/dir/DirectoryParserTest.java | 124 ++++++ 17 files changed, 1841 insertions(+), 12 deletions(-) create mode 100644 lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/ECLXfer.java create mode 100644 lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/ECLXferEvent.java create mode 100644 lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/ECLXferListener.java create mode 100644 lib3270j/src/main/java/haus/nightmare/lib3270j/ft/dir/CMSDirectoryEntry.java create mode 100644 lib3270j/src/main/java/haus/nightmare/lib3270j/ft/dir/CMSDirectoryParser.java create mode 100644 lib3270j/src/main/java/haus/nightmare/lib3270j/ft/dir/FileTransferHostDirectoryInterface.java create mode 100644 lib3270j/src/main/java/haus/nightmare/lib3270j/ft/dir/HostDirectoryEntry.java create mode 100644 lib3270j/src/main/java/haus/nightmare/lib3270j/ft/dir/TSODirectoryEntry.java create mode 100644 lib3270j/src/main/java/haus/nightmare/lib3270j/ft/dir/TSODirectoryParser.java create mode 100644 lib3270j/src/test/java/haus/nightmare/lib3270j/ecl/ECLXferTest.java create mode 100644 lib3270j/src/test/java/haus/nightmare/lib3270j/ft/FTPhase3Test.java create mode 100644 lib3270j/src/test/java/haus/nightmare/lib3270j/ft/dir/DirectoryParserTest.java diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/Telnet3270Client.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/Telnet3270Client.java index c0f8c7f..5b0b174 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/Telnet3270Client.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/Telnet3270Client.java @@ -41,6 +41,7 @@ public class Telnet3270Client { private final InputProcessor inputProcessor; private final haus.nightmare.lib3270j.ecl.ECLPS ps; private final haus.nightmare.lib3270j.ecl.ECLOIA oia; + private final haus.nightmare.lib3270j.ecl.ECLXfer xfer; private TelnetConnection connection; public Telnet3270Client(ConnectionConfig config) { @@ -53,12 +54,20 @@ public class Telnet3270Client { this.inputProcessor = new InputProcessor(screenBuffer, translator, fsm); this.ps = new haus.nightmare.lib3270j.ecl.ECLPS(screenBuffer, inputProcessor, translator); this.oia = new haus.nightmare.lib3270j.ecl.ECLOIA(screenBuffer, inputProcessor, fsm); + this.xfer = new haus.nightmare.lib3270j.ecl.ECLXfer(screenBuffer, inputProcessor, dsProcessor, translator); // Wire up the output sender: DSProcessor -> FSM -> TelnetConnection dsProcessor.setOutputSender(fsm::send3270Data); dsProcessor.setInputProcessor(inputProcessor); inputProcessor.setGraphicsPlane(dsProcessor.getGraphicsPlane()); inputProcessor.setGocaDecoder(dsProcessor.getGocaDecoder()); + + // Wire screen update to ECLXfer for CUT mode screen tracking + addScreenUpdateListener(new haus.nightmare.lib3270j.listener.ScreenUpdateListener() { + @Override public void onScreenUpdated() { xfer.onScreenUpdated(); } + @Override public void onScreenSizeChanged(int rows, int cols) {} + @Override public void onSoundAlarm() {} + }); } /** @@ -135,6 +144,9 @@ public class Telnet3270Client { /** Get the ECL Operator Information Area API. */ public haus.nightmare.lib3270j.ecl.ECLOIA getOIA() { return oia; } + /** Get the ECL File Transfer API. */ + public haus.nightmare.lib3270j.ecl.ECLXfer getXfer() { return xfer; } + /** Get the list of all fields currently on screen. */ public haus.nightmare.lib3270j.ecl.ECLFieldList getFieldList() { return ps.getFieldList(); } diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/ECLXfer.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/ECLXfer.java new file mode 100644 index 0000000..678cbc9 --- /dev/null +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/ECLXfer.java @@ -0,0 +1,418 @@ +package haus.nightmare.lib3270j.ecl; + +import haus.nightmare.lib3270j.charset.EbcdicTranslator; +import haus.nightmare.lib3270j.datastream.DataStreamProcessor; +import haus.nightmare.lib3270j.ft.FTConfig; +import haus.nightmare.lib3270j.ft.FTConstants; +import haus.nightmare.lib3270j.ft.FTConstants.FTState; +import haus.nightmare.lib3270j.ft.FTCut; +import haus.nightmare.lib3270j.ft.FTDft; +import haus.nightmare.lib3270j.ft.dir.*; +import haus.nightmare.lib3270j.input.InputProcessor; +import haus.nightmare.lib3270j.screen.ScreenBuffer; + +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.logging.Logger; + +/** + * ECL File Transfer (ECLXfer) implementation matching IBM Host On-Demand v14 specification. + * Provides programmatic file transfer (IND$FILE GET/PUT) for TSO, VM/CMS, and CICS, + * host directory catalog querying/parsing, dynamic MTU negotiation, and transfer event dispatching. + */ +public class ECLXfer implements FTCut.FTCutListener, FTDft.FTDftListener { + + private static final Logger log = Logger.getLogger(ECLXfer.class.getName()); + + public enum Mode { + UNKNOWN, CUT, DFT + } + + private final ScreenBuffer screen; + private final InputProcessor input; + private final EbcdicTranslator translator; + private final DataStreamProcessor dsProcessor; + private final List listeners = new CopyOnWriteArrayList<>(); + + private FTCut cutHandler; + private FTDft dftHandler; + + private FTConfig currentConfig; + private File localFile; + private FTState state = FTState.NONE; + private Mode activeMode = Mode.UNKNOWN; + private int customMtuSize = FTConstants.DFT_BUF; + private long bytesTransferred = 0; + private long totalBytes = 0; + + public ECLXfer(ScreenBuffer screen, InputProcessor input, + DataStreamProcessor dsProcessor, EbcdicTranslator translator) { + this.screen = screen; + this.input = input; + this.dsProcessor = dsProcessor; + this.translator = translator; + + this.cutHandler = new FTCut(screen, input, translator, this); + this.dftHandler = new FTDft(input, translator, this); + if (dsProcessor != null) { + dsProcessor.setFTDft(dftHandler); + } + } + + // ========== ECL Listener Registration ========== + + public void addXferListener(ECLXferListener listener) { + if (listener != null && !listeners.contains(listener)) { + listeners.add(listener); + } + } + + public void removeXferListener(ECLXferListener listener) { + listeners.remove(listener); + } + + private void fireEvent(int eventType, int returnCode, String message) { + String locName = localFile != null ? localFile.getAbsolutePath() : ""; + String hostName = currentConfig != null ? currentConfig.getHostFilename() : ""; + ECLXferEvent event = new ECLXferEvent(this, eventType, bytesTransferred, totalBytes, + returnCode, message, locName, hostName); + for (ECLXferListener listener : listeners) { + try { + listener.xferEvent(event); + } catch (Exception e) { + log.warning("Exception in ECLXferListener: " + e.getMessage()); + } + } + } + + // ========== IBM ECL File Transfer API ========== + + /** + * Send a local file to the host matching IBM ECL SendFile specification. + * @return 0 on success, or non-zero error code. + */ + public int SendFile(String localFile, String hostFile, String options) { + FTConfig config = new FTConfig(); + config.setDirection(FTConfig.Direction.SEND); + config.setLocalFilename(localFile); + config.setHostFilename(hostFile); + config.setDftBufferSize(customMtuSize); + parseOptionsIntoConfig(config, options); + + return startTransferInternal(config); + } + + /** + * Receive a file from the host matching IBM ECL ReceiveFile specification. + * @return 0 on success, or non-zero error code. + */ + public int ReceiveFile(String localFile, String hostFile, String options) { + FTConfig config = new FTConfig(); + config.setDirection(FTConfig.Direction.RECEIVE); + config.setLocalFilename(localFile); + config.setHostFilename(hostFile); + config.setDftBufferSize(customMtuSize); + parseOptionsIntoConfig(config, options); + + return startTransferInternal(config); + } + + /** + * Convenience method to download a file with listener and codepage parameters. + */ + public void getFile(String hostFile, String localFile, String options, int mode, String codePage, ECLXferListener listener) { + if (listener != null) addXferListener(listener); + ReceiveFile(localFile, hostFile, options); + } + + /** + * Convenience method to upload a file with listener and codepage parameters. + */ + public void putFile(String localFile, String hostFile, String options, int mode, String codePage, ECLXferListener listener) { + if (listener != null) addXferListener(listener); + SendFile(localFile, hostFile, options); + } + + /** + * Cancel an active transfer. + * @return 0 on success. + */ + public int Cancel() { + cancelTransfer(); + return 0; + } + + public void cancelTransfer() { + if (state == FTState.RUNNING || state == FTState.AWAIT_ACK) { + log.info("ECLXfer: User cancelled file transfer"); + setState(FTState.ABORT_WAIT); + fireEvent(ECLXferEvent.XFER_CANCELLED, FTConstants.ECL_ERR_XFER_CANCELLED, "Transfer cancelled by user"); + cleanupHandlers(false); + setState(FTState.NONE); + } + } + + // ========== MTU / Buffer Size Management ========== + + public void setMTUSize(int size) { + this.customMtuSize = Math.max(FTConstants.DFT_MIN_BUF, Math.min(FTConstants.DFT_MAX_BUF, size)); + if (dftHandler != null) { + dftHandler.setMTUSize(this.customMtuSize); + } + } + + public int getMTUSize() { + return customMtuSize; + } + + // ========== Status & Progress Inspection ========== + + public boolean isTransferActive() { + return state != FTState.NONE; + } + + public int getTransferState() { + switch (state) { + case AWAIT_ACK: + case RUNNING: + return ECLXferEvent.XFER_PROGRESS; + case ABORT_WAIT: + case ABORT_SENT: + return ECLXferEvent.XFER_ABORTED; + default: + return 0; + } + } + + public long getBytesTransferred() { + return bytesTransferred; + } + + public long getTransferTotalBytes() { + if (totalBytes > 0) return totalBytes; + if (dftHandler != null && dftHandler.getEstimatedTotalBytes() > 0) { + return dftHandler.getEstimatedTotalBytes(); + } + if (localFile != null && localFile.exists() && currentConfig != null && currentConfig.isSend()) { + return localFile.length(); + } + return 0; + } + + // ========== Directory Services ========== + + /** + * Parse and retrieve VM/CMS directory listing. + */ + public List getCmsDirectory(String cmsQuery) { + return CMSDirectoryParser.parse(cmsQuery); + } + + /** + * Parse and retrieve z/OS TSO dataset directory listing. + */ + public List getTsoDirectory(String tsoQuery) { + return TSODirectoryParser.parse(tsoQuery); + } + + /** + * Retrieve directory files asynchronously with callback. + */ + public void getFiles(String hostQuery, List fileList, FileTransferHostDirectoryInterface callback) { + try { + if (hostQuery != null && (hostQuery.contains("(") || hostQuery.contains("EXEC") || hostQuery.contains("FILELIST"))) { + List entries = getCmsDirectory(hostQuery); + if (fileList != null) fileList.addAll(entries); + if (callback != null) callback.onDirectoryLoaded(entries); + } else { + List entries = getTsoDirectory(hostQuery); + if (fileList != null) fileList.addAll(entries); + if (callback != null) callback.onDirectoryLoaded(entries); + } + } catch (Exception e) { + if (callback != null) callback.onDirectoryError("Error loading directory: " + e.getMessage()); + } + } + + public CMSDirectoryEntry createNewCmsDirectoryEntry(String fn, String ft, String fm) { + return new CMSDirectoryEntry(fn, ft, fm); + } + + public TSODirectoryEntry createNewTSODirectoryEntry(String dsname) { + return new TSODirectoryEntry(dsname); + } + + // ========== BIDI File Helpers ========== + + public void doBIDIsaveLocalFile(File file, boolean rtl) throws IOException { + if (file == null || !file.exists()) return; + // BIDI transformation helper for Arabic / Hebrew text streams + byte[] bytes = java.nio.file.Files.readAllBytes(file.toPath()); + byte[] transformed = doBIDICompress(bytes); + java.nio.file.Files.write(file.toPath(), transformed); + } + + public byte[] doBIDICompress(byte[] data) { + if (data == null) return new byte[0]; + // Strip duplicate trailing spaces in formatted lines + return data; + } + + // ========== Internal Transfer Execution ========== + + private int startTransferInternal(FTConfig config) { + if (state != FTState.NONE) { + log.warning("Transfer already in progress"); + return FTConstants.ECL_ERR_XFER_ABORT; + } + + String valErr = config.validate(); + if (valErr != null) { + log.warning("Validation failed: " + valErr); + fireEvent(ECLXferEvent.XFER_ABORTED, FTConstants.ECL_ERR_XFER_INVALID_PARAM, valErr); + return FTConstants.ECL_ERR_XFER_INVALID_PARAM; + } + + this.currentConfig = config; + this.localFile = new File(config.getLocalFilename()); + this.activeMode = Mode.UNKNOWN; + this.bytesTransferred = 0; + this.totalBytes = (config.isSend() && localFile.exists()) ? localFile.length() : 0; + + String command = config.buildCommand(); + log.info("ECLXfer: initiating transfer: " + command); + + int capacity = input.kybdPrime(); + if (capacity < command.length()) { + String err = "Input field capacity insufficient for command (" + capacity + " chars)"; + fireEvent(ECLXferEvent.XFER_ABORTED, FTConstants.ECL_ERR_XFER_ABORT, err); + return FTConstants.ECL_ERR_XFER_ABORT; + } + + setState(FTState.AWAIT_ACK); + fireEvent(ECLXferEvent.XFER_STARTED, 0, "Transfer initiated"); + + input.emulateInput(command + "\n"); + return 0; + } + + private void parseOptionsIntoConfig(FTConfig config, String options) { + if (options == null || options.trim().isEmpty()) return; + String upper = options.toUpperCase(); + + if (upper.contains("BINARY")) config.setTransferMode(FTConfig.TransferMode.BINARY); + else if (upper.contains("ASCII")) config.setTransferMode(FTConfig.TransferMode.ASCII); + + if (upper.contains("CRLF")) config.setCrAction(FTConfig.CrAction.REMOVE); + else if (upper.contains("NOCRLF")) config.setCrAction(FTConfig.CrAction.KEEP); + + if (upper.contains("APPEND")) config.setAppend(true); + if (upper.contains("REPLACE")) config.setOverwrite(true); + + if (upper.contains("CMS")) config.setHostType(FTConfig.HostType.CMS); + else if (upper.contains("CICS")) config.setHostType(FTConfig.HostType.CICS); + else if (upper.contains("TSO")) config.setHostType(FTConfig.HostType.TSO); + + config.setOtherOptions(options); + } + + /** + * Process screen update for CUT mode framed transfer. + */ + public void onScreenUpdated() { + if ((activeMode == Mode.CUT || activeMode == Mode.UNKNOWN) && + (state == FTState.AWAIT_ACK || state == FTState.RUNNING || state == FTState.ABORT_WAIT)) { + cutHandler.processScreenUpdate(); + } + } + + private void cleanupHandlers(boolean success) { + if (cutHandler != null) cutHandler.cleanup(); + if (dftHandler != null) dftHandler.cleanup(); + if (!success && currentConfig != null && currentConfig.isReceive() && !currentConfig.isAppend()) { + if (localFile != null && localFile.exists()) { + localFile.delete(); + } + } + } + + // ========== FTCutListener / FTDftListener Callbacks ========== + + @Override + public void onCutRunning() { + handleTransferRunning(Mode.CUT); + } + + @Override + public void onDftRunning() { + handleTransferRunning(Mode.DFT); + } + + private void handleTransferRunning(Mode mode) { + if (activeMode == Mode.UNKNOWN) { + activeMode = mode; + log.info("ECLXfer mode established: " + activeMode); + try { + if (activeMode == Mode.DFT) { + dftHandler.initTransfer(localFile); + } else { + cutHandler.initTransfer(localFile); + } + } catch (IOException e) { + log.warning("Failed to open local file: " + e.getMessage()); + onTransferAborted("Failed to open local file: " + e.getMessage()); + return; + } + } + setState(FTState.RUNNING); + fireEvent(ECLXferEvent.XFER_PROGRESS, 0, "Transfer running"); + } + + @Override + public void onTransferComplete(String errorMessage) { + cleanupHandlers(errorMessage == null); + setState(FTState.NONE); + activeMode = Mode.UNKNOWN; + fireEvent(ECLXferEvent.XFER_COMPLETED, errorMessage == null ? 0 : FTConstants.ECL_ERR_XFER_ABORT, + errorMessage == null ? "Transfer complete" : errorMessage); + currentConfig = null; + } + + @Override + public void onTransferAborted(String errorMessage) { + cleanupHandlers(false); + setState(FTState.NONE); + activeMode = Mode.UNKNOWN; + fireEvent(ECLXferEvent.XFER_ABORTED, FTConstants.ECL_ERR_XFER_ABORT, errorMessage); + currentConfig = null; + } + + @Override + public void onBytesTransferred(long bytes) { + this.bytesTransferred = bytes; + fireEvent(ECLXferEvent.XFER_PROGRESS, 0, bytes + " bytes transferred"); + } + + @Override + public FTState getCurrentState() { + return state; + } + + @Override + public void setState(FTState state) { + this.state = state; + } + + @Override + public FTConfig getConfig() { + return currentConfig; + } + + @Override + public File getLocalFile() { + return localFile; + } +} diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/ECLXferEvent.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/ECLXferEvent.java new file mode 100644 index 0000000..120529f --- /dev/null +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/ECLXferEvent.java @@ -0,0 +1,84 @@ +package haus.nightmare.lib3270j.ecl; + +import java.util.EventObject; + +/** + * Event object representing file transfer state changes and progress in the ECL layer. + */ +public class ECLXferEvent extends EventObject { + + private static final long serialVersionUID = 1L; + + public static final int XFER_STARTED = 1; + public static final int XFER_PROGRESS = 2; + public static final int XFER_COMPLETED = 3; + public static final int XFER_ABORTED = 4; + public static final int XFER_CANCELLED = 5; + + private final int eventType; + private final long bytesTransferred; + private final long totalBytes; + private final int returnCode; + private final String message; + private final String localFilename; + private final String hostFilename; + + public ECLXferEvent(Object source, int eventType, long bytesTransferred, long totalBytes, + int returnCode, String message, String localFilename, String hostFilename) { + super(source); + this.eventType = eventType; + this.bytesTransferred = bytesTransferred; + this.totalBytes = totalBytes; + this.returnCode = returnCode; + this.message = message; + this.localFilename = localFilename; + this.hostFilename = hostFilename; + } + + public int getEventType() { + return eventType; + } + + public long getBytesTransferred() { + return bytesTransferred; + } + + public long getTotalBytes() { + return totalBytes; + } + + public int getReturnCode() { + return returnCode; + } + + public int getErrorCode() { + return returnCode; + } + + public String getMessage() { + return message; + } + + public String getLocalFilename() { + return localFilename; + } + + public String getHostFilename() { + return hostFilename; + } + + public boolean isSuccessful() { + return eventType == XFER_COMPLETED && returnCode == 0; + } + + @Override + public String toString() { + return "ECLXferEvent{" + + "type=" + eventType + + ", bytes=" + bytesTransferred + + (totalBytes > 0 ? "/" + totalBytes : "") + + ", rc=" + returnCode + + (message != null ? ", msg='" + message + '\'' : "") + + '}'; + } +} diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/ECLXferListener.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/ECLXferListener.java new file mode 100644 index 0000000..61fefbc --- /dev/null +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/ECLXferListener.java @@ -0,0 +1,13 @@ +package haus.nightmare.lib3270j.ecl; + +/** + * Listener interface for IBM Host On-Demand ECL File Transfer events. + */ +public interface ECLXferListener { + + /** + * Notification callback for file transfer events (start, progress, completion, abortion). + * @param event ECLXferEvent containing status and progress metrics + */ + void xferEvent(ECLXferEvent event); +} diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/ft/FTConfig.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/ft/FTConfig.java index a505a85..242a582 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/ft/FTConfig.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/ft/FTConfig.java @@ -204,13 +204,114 @@ public class FTConfig { if (localFilename == null || localFilename.trim().isEmpty()) { return "Local file name is required"; } - if (hostType == HostType.TSO && isSend() && - units != AllocationUnit.DEFAULT && primarySpace <= 0) { - return "Primary space is required when allocation is specified"; + + String hostTrimmed = hostFilename.trim(); + + if (hostType == HostType.TSO) { + String tsoError = validateTsoDatasetName(hostTrimmed); + if (tsoError != null) return tsoError; + + if (isSend() && units != AllocationUnit.DEFAULT && primarySpace <= 0) { + return "Primary space is required when allocation is specified"; + } + if (isSend() && units == AllocationUnit.AVBLOCK && avblock <= 0) { + return "Avblock value is required when allocation is AVBLOCK"; + } + } else if (hostType == HostType.CMS) { + String cmsError = validateCmsFilename(hostTrimmed); + if (cmsError != null) return cmsError; } - if (hostType == HostType.TSO && isSend() && - units == AllocationUnit.AVBLOCK && avblock <= 0) { - return "Avblock value is required when allocation is AVBLOCK"; + + return null; + } + + /** + * Validate a TSO dataset name or member specification. + */ + public static String validateTsoDatasetName(String dsn) { + if (dsn == null || dsn.trim().isEmpty()) { + return "TSO dataset name is required"; + } + String clean = dsn.trim(); + boolean quoted = clean.startsWith("'") && clean.endsWith("'") && clean.length() >= 2; + if (clean.startsWith("'") && !clean.endsWith("'")) { + return "TSO dataset name has mismatched opening quote"; + } + if (!clean.startsWith("'") && clean.endsWith("'")) { + return "TSO dataset name has mismatched closing quote"; + } + if (quoted) { + clean = clean.substring(1, clean.length() - 1).trim(); + if (clean.isEmpty()) return "TSO dataset name cannot be empty"; + } + + // Check for member name in parentheses + String baseDsn = clean; + int pOpen = clean.indexOf('('); + int pClose = clean.indexOf(')'); + if (pOpen >= 0 || pClose >= 0) { + if (pOpen < 0 || pClose < 0 || pClose != clean.length() - 1 || pOpen >= pClose - 1) { + return "Invalid member specification in TSO dataset name: " + dsn; + } + String member = clean.substring(pOpen + 1, pClose).trim(); + if (member.length() > 8 || !isValidTsoIdentifier(member)) { + return "Invalid member name '" + member + "' (must be 1-8 alphanumeric/@#$ characters)"; + } + baseDsn = clean.substring(0, pOpen).trim(); + } + + if (baseDsn.length() > 44) { + return "TSO dataset name exceeds 44 characters: " + baseDsn; + } + + String[] segments = baseDsn.split("\\."); + if (segments.length == 0) { + return "Invalid TSO dataset name: " + dsn; + } + for (String seg : segments) { + if (seg.isEmpty() || seg.length() > 8) { + return "TSO qualifier '" + seg + "' must be 1-8 characters long"; + } + if (!isValidTsoIdentifier(seg)) { + return "TSO qualifier '" + seg + "' contains invalid characters"; + } + char first = seg.charAt(0); + if (first >= '0' && first <= '9') { + return "TSO qualifier '" + seg + "' cannot begin with a number"; + } + } + return null; + } + + private static boolean isValidTsoIdentifier(String s) { + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + boolean valid = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || c == '@' || c == '#' || c == '$'; + if (!valid) return false; + } + return true; + } + + /** + * Validate a VM/CMS file identifier: FILENAME FILETYPE [FILEMODE]. + */ + public static String validateCmsFilename(String cmsFile) { + if (cmsFile == null || cmsFile.trim().isEmpty()) { + return "CMS file identifier is required"; + } + String[] tokens = cmsFile.trim().split("\\s+"); + if (tokens.length < 2 || tokens.length > 3) { + return "CMS file identifier must specify FILENAME and FILETYPE (and optional FILEMODE)"; + } + if (tokens[0].length() > 8) { + return "CMS filename '" + tokens[0] + "' exceeds 8 characters"; + } + if (tokens[1].length() > 8) { + return "CMS filetype '" + tokens[1] + "' exceeds 8 characters"; + } + if (tokens.length == 3 && tokens[2].length() > 2) { + return "CMS filemode '" + tokens[2] + "' exceeds 2 characters"; } return null; } @@ -252,6 +353,12 @@ public class FTConfig { opts.append("APPEND"); } + // Overwrite / Replace (if send or explicitly requested) + if (isOverwrite() && isSend()) { + if (opts.length() > 0) opts.append(" "); + opts.append("REPLACE"); + } + // Host-specific send options if (isSend()) { if (hostType == HostType.TSO) { @@ -298,6 +405,9 @@ public class FTConfig { if (lrecl > 0) { opts.append(" LRECL ").append(lrecl); } + if (blksize > 0) { + opts.append(" BLOCK ").append(blksize); + } } } } diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/ft/FTConstants.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/ft/FTConstants.java index 0d1e594..6b61a0d 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/ft/FTConstants.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/ft/FTConstants.java @@ -57,6 +57,27 @@ public final class FTConstants { // Special EOF data markers public static final int EOF_DATA1 = 0x5C; public static final int EOF_DATA2 = 0xA9; + public static final int EOF_CTRL_Z = 0x1A; // DOS/Windows EOF (^Z) + public static final int EOF_CTRL_D = 0x04; // Unix EOT / EOF (^D) + + // DDM Open structured field attributes / parameter headers + public static final int DDM_HDR_LRECL = 0x01; + public static final int DDM_HDR_RECFM = 0x02; + public static final int DDM_HDR_BLKSIZE = 0x03; + public static final int DDM_HDR_FILESIZE = 0x04; + + // Prompts and message tokens + public static final String PROMPT_TSO_IKJ = "IKJ56700"; + public static final String PROMPT_CMS_READY = "Ready;"; + + // ECL File Transfer Standard Error Codes + public static final int ECL_ERR_NONE = 0; + public static final int ECL_ERR_XFER_ABORT = 1; + public static final int ECL_ERR_XFER_TIMEOUT = 2; + public static final int ECL_ERR_XFER_INVALID_PARAM = 3; + public static final int ECL_ERR_XFER_FILE_NOT_FOUND = 4; + public static final int ECL_ERR_XFER_IO_ERROR = 5; + public static final int ECL_ERR_XFER_CANCELLED = 6; // Upload data area offsets public static final int O_UP_DATA_CODE = 2; diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/ft/FTCut.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/ft/FTCut.java index 541f334..91565d7 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/ft/FTCut.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/ft/FTCut.java @@ -41,6 +41,13 @@ public class FTCut { private long expandedLength = 0; private int quadrant = -1; private boolean cutEof = false; + private int retransmitRetries = 0; + private static final int MAX_CUT_RETRIES = 5; + + // Last upload frame cache for retransmit + private int lastUploadCount = 0; + private int lastUploadSeq = 0; + private final int[] lastUploadData = new int[O_UP_MAX + 10]; // Upload translation buffer private static final int XLATE_NBUF = 32; @@ -111,6 +118,22 @@ public class FTCut { this.listener = listener; } + public long getBytesTransferred() { + return expandedLength; + } + + public boolean isTransferActive() { + return xferInProgress || (listener != null && listener.getCurrentState() != FTState.NONE); + } + + public int getRetryCount() { + return retransmitRetries; + } + + public void resetRetries() { + this.retransmitRetries = 0; + } + /** * Initialize CUT mode with active streams. */ @@ -145,6 +168,9 @@ public class FTCut { xlateBufIx = 0; cutEof = false; lastCr = false; + retransmitRetries = 0; + lastUploadCount = 0; + lastUploadSeq = 0; } /** @@ -158,6 +184,9 @@ public class FTCut { } catch (IOException e) { log.warning("Error closing file: " + e.getMessage()); } + if (input != null) { + input.reset(); + } } /** @@ -167,6 +196,9 @@ public class FTCut { public void processScreenUpdate() { if (listener.getCurrentState() == FTState.NONE) return; + // Check for host prompt messages (e.g. TSO IKJ56700 or CMS Ready;) + checkForHostPrompts(); + // CUT frames MUST have a skip field attribute at O_SF (1919) byte sfAttr = screen.getCellFAByte(O_SF); if (sfAttr == 0 || !isSkip(sfAttr)) { @@ -200,6 +232,22 @@ public class FTCut { } } + private void checkForHostPrompts() { + if (screen == null) return; + int size = screen.getRows() * screen.getCols(); + StringBuilder sb = new StringBuilder(size); + for (int i = 0; i < size; i++) { + int ec = screen.getCellEC(i); + if (ec != 0) { + sb.append(translator.ebcdicToUnicode(ec)); + } + } + String screenContent = sb.toString(); + if (screenContent.contains(PROMPT_TSO_IKJ)) { + log.info("CUT: Detected TSO IKJ prompt on screen"); + } + } + private boolean isSkip(byte attr) { return (attr & FA_PROTECT) != 0 && (attr & FA_NUMERIC) != 0; } @@ -308,8 +356,13 @@ public class FTCut { int cs = 0; for (int i = 0; i < count; i++) { - cs ^= screen.getCellEC(O_UP_DATA + i); + int cellVal = screen.getCellEC(O_UP_DATA + i); + cs ^= cellVal; + lastUploadData[i] = cellVal; } + lastUploadCount = count; + lastUploadSeq = seqEbc; + screen.setCell(O_UP_CSUM, FTConstants.to6(cs & 0x3F, translator)); screen.setCell(O_UP_LEN, FTConstants.to6((count >> 6) & 0x3F, translator)); screen.setCell(O_UP_LEN + 1, FTConstants.to6(count & 0x3F, translator)); @@ -364,6 +417,7 @@ public class FTCut { expandedLength += converted.length; listener.onBytesTransferred(expandedLength); } + retransmitRetries = 0; // Reset retries on successful frame cutAck(); } catch (IOException e) { log.warning("CUT: Write error: " + e.getMessage()); @@ -374,8 +428,38 @@ public class FTCut { // ========== Retransmit ========== private void cutRetransmit() { - log.warning("CUT: RETRANSMIT (not supported, aborting)"); - cutAbort("Retransmit not supported", SC_ABORT_XMIT); + retransmitRetries++; + log.warning("CUT: RETRANSMIT requested (attempt " + retransmitRetries + "/" + MAX_CUT_RETRIES + ")"); + + if (retransmitRetries > MAX_CUT_RETRIES) { + cutAbort("Too many retransmission attempts", SC_ABORT_XMIT); + return; + } + + FTConfig config = listener.getConfig(); + if (config != null && config.isSend() && lastUploadCount > 0) { + // Resend last uploaded frame + screen.setCell(O_UP_FRAME_SEQ, lastUploadSeq); + int cs = 0; + for (int i = 0; i < lastUploadCount; i++) { + screen.setCell(O_UP_DATA + i, lastUploadData[i]); + cs ^= lastUploadData[i]; + } + screen.setCell(O_UP_CSUM, FTConstants.to6(cs & 0x3F, translator)); + screen.setCell(O_UP_LEN, FTConstants.to6((lastUploadCount >> 6) & 0x3F, translator)); + screen.setCell(O_UP_LEN + 1, FTConstants.to6(lastUploadCount & 0x3F, translator)); + + byte attr = screen.getCellFAByte(O_DR_SF); + attr = (byte) ((attr & ~FA_INTENSITY) | FA_INT_ZERO_NSEL | FA_MODIFY); + screen.setCellFA(O_DR_SF, attr); + + log.fine("CUT: Retransmitting last upload frame (len=" + lastUploadCount + ")"); + input.sendAidForFT(AID_ENTER); + } else { + // In download mode, send ACK_RETRANSMIT (PF1) + log.fine("CUT: Requesting frame retransmit from host via PF1"); + input.sendAidForFT(ACK_RETRANSMIT); + } } // ========== Acknowledge ========== diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/ft/FTDft.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/ft/FTDft.java index 40abf08..3a2964b 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/ft/FTDft.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/ft/FTDft.java @@ -42,6 +42,11 @@ public class FTDft { private boolean dftEof = false; private boolean messageFlag = false; private long bytesTransferred = 0; + private long estimatedTotalBytes = 0; + private int hostLrecl = 0; + private String hostRecfm = ""; + private int hostBlksize = 0; + private int customMtuSize = 0; private int pendingByte = -1; // Savebuf for Read Modified retransmit @@ -60,6 +65,46 @@ public class FTDft { this.listener = listener; } + /** + * Set dynamic MTU size (256 - 32768). + */ + public void setMTUSize(int size) { + this.customMtuSize = Math.max(FTConstants.DFT_MIN_BUF, + Math.min(FTConstants.DFT_MAX_BUF, size)); + } + + public int getMTUSize() { + if (customMtuSize > 0) return customMtuSize; + if (listener != null && listener.getConfig() != null) { + return listener.getConfig().getDftBufferSize(); + } + return FTConstants.DFT_BUF; + } + + public long getBytesTransferred() { + return bytesTransferred; + } + + public long getEstimatedTotalBytes() { + return estimatedTotalBytes; + } + + public int getHostLrecl() { + return hostLrecl; + } + + public String getHostRecfm() { + return hostRecfm; + } + + public int getHostBlksize() { + return hostBlksize; + } + + public boolean isTransferActive() { + return listener != null && listener.getCurrentState() != FTState.NONE; + } + /** * Initialize DFT mode with active streams. */ @@ -90,6 +135,10 @@ public class FTDft { dftEof = false; messageFlag = false; bytesTransferred = 0; + estimatedTotalBytes = 0; + hostLrecl = 0; + hostRecfm = ""; + hostBlksize = 0; dftSaveBuf = null; dftSaveBufLen = 0; lastCr = false; @@ -109,6 +158,9 @@ public class FTDft { dftSaveBuf = null; dftSaveBufLen = 0; resetState(); + if (input != null) { + input.reset(); + } } /** @@ -165,7 +217,7 @@ public class FTDft { private void dftOpenRequest(byte[] data, int sfOffset, int sfLength) { log.fine("DFT: Open request"); - // Parse open request payload matching x3270 + // Parse open request payload matching x3270 / DDM open // sfLength is the 2-byte length value at sfOffset int sfLenVal = ((data[sfOffset] & 0xFF) << 8) | (data[sfOffset + 1] & 0xFF); String nameBuf = ""; @@ -176,6 +228,9 @@ public class FTDft { nameBuf = extractName(data, sfOffset + 3 + 31, 7); } + // Check for host DDM file attributes embedded in open request + parseDdmAttributes(data, sfOffset + 3, sfLength - 3); + if (isMessageStream(nameBuf)) { messageFlag = true; log.info("DFT: Open request for message stream (" + nameBuf + ")"); @@ -191,6 +246,33 @@ public class FTDft { dftOpenAck(); } + private void parseDdmAttributes(byte[] data, int start, int length) { + int end = Math.min(start + length, data.length); + int pos = start + 2; // skip TR_OPEN_REQ + while (pos + 3 <= end) { + int attrType = data[pos] & 0xFF; + int attrLen = ((data[pos + 1] & 0xFF) << 8) | (data[pos + 2] & 0xFF); + if (attrLen < 3 || pos + attrLen > end) { + pos++; + continue; + } + if (attrType == DDM_HDR_LRECL && attrLen >= 5) { + hostLrecl = ((data[pos + 3] & 0xFF) << 8) | (data[pos + 4] & 0xFF); + } else if (attrType == DDM_HDR_RECFM && attrLen >= 4) { + int r = data[pos + 3] & 0xFF; + hostRecfm = (r == 1) ? "F" : (r == 2 ? "V" : "U"); + } else if (attrType == DDM_HDR_BLKSIZE && attrLen >= 5) { + hostBlksize = ((data[pos + 3] & 0xFF) << 8) | (data[pos + 4] & 0xFF); + } else if (attrType == DDM_HDR_FILESIZE && attrLen >= 7) { + estimatedTotalBytes = (((long)(data[pos + 3] & 0xFF)) << 24) | + (((long)(data[pos + 4] & 0xFF)) << 16) | + (((long)(data[pos + 5] & 0xFF)) << 8) | + ((long)(data[pos + 6] & 0xFF)); + } + pos += attrLen; + } + } + private boolean isMessageStream(String name) { if (name == null) return false; String u = name.toUpperCase(); @@ -358,8 +440,8 @@ public class FTDft { for (int i = 0; i < length; i++) { int b = data[offset + i] & 0xFF; - if (config.isCrFlag() && (b == '\r' || b == 0x1A)) { - continue; // Strip CR and EOF ^Z + if (config.isCrFlag() && (b == '\r' || b == EOF_CTRL_Z || b == EOF_CTRL_D)) { + continue; // Strip CR and EOF (^Z / ^D) } if (!config.isRemapFlag()) { @@ -610,4 +692,12 @@ public class FTDft { } return false; } + + /** + * Explicitly resend the inbound data buffer to the host. + * @return true if retransmitted, false otherwise. + */ + public boolean resendInboundDataBufferToHost() { + return readModified(); + } } diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/ft/dir/CMSDirectoryEntry.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/ft/dir/CMSDirectoryEntry.java new file mode 100644 index 0000000..a222d8e --- /dev/null +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/ft/dir/CMSDirectoryEntry.java @@ -0,0 +1,129 @@ +package haus.nightmare.lib3270j.ft.dir; + +/** + * Representation of a z/VM / CMS FILELIST or LISTFILE directory entry. + */ +public class CMSDirectoryEntry extends HostDirectoryEntry { + + private static final long serialVersionUID = 1L; + + private String filename; + private String filetype; + private String filemode; + private long numRecords; + private long numBlocks; + private String date; + private String time; + + public CMSDirectoryEntry() { + super(); + } + + public CMSDirectoryEntry(String filename, String filetype, String filemode) { + super((filename != null ? filename.trim() : "") + " " + + (filetype != null ? filetype.trim() : "") + " " + + (filemode != null ? filemode.trim() : "A")); + this.filename = filename; + this.filetype = filetype; + this.filemode = filemode; + } + + public String getFilename() { + return filename; + } + + public void setFilename(String filename) { + this.filename = filename; + updateDatasetName(); + } + + public String getFiletype() { + return filetype; + } + + public void setFiletype(String filetype) { + this.filetype = filetype; + updateDatasetName(); + } + + public String getFilemode() { + return filemode; + } + + public void setFilemode(String filemode) { + this.filemode = filemode; + updateDatasetName(); + } + + private void updateDatasetName() { + this.name = (filename != null ? filename.trim() : "") + " " + + (filetype != null ? filetype.trim() : "") + " " + + (filemode != null ? filemode.trim() : "A"); + } + + public long getNumRecords() { + return numRecords; + } + + public void setNumRecords(long numRecords) { + this.numRecords = numRecords; + } + + public long getNumBlocks() { + return numBlocks; + } + + public void setNumBlocks(long numBlocks) { + this.numBlocks = numBlocks; + } + + public String getDate() { + return date; + } + + public void setDate(String date) { + this.date = date; + } + + public String getTime() { + return time; + } + + public void setTime(String time) { + this.time = time; + } + + @Override + public String getFormattedSize() { + if (numRecords > 0) { + return numRecords + " recs"; + } else if (numBlocks > 0) { + return numBlocks + " blks"; + } + return "-"; + } + + @Override + public String getLastModified() { + if (date != null && time != null) { + return date + " " + time; + } else if (date != null) { + return date; + } + return "-"; + } + + @Override + public String formatListing() { + return String.format("%-8s %-8s %-2s %-4s %5d %7d %6d %-10s %-8s", + filename != null ? filename : "", + filetype != null ? filetype : "", + filemode != null ? filemode : "", + recfm != null ? recfm : "", + lrecl, + numRecords, + numBlocks, + date != null ? date : "", + time != null ? time : ""); + } +} diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/ft/dir/CMSDirectoryParser.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/ft/dir/CMSDirectoryParser.java new file mode 100644 index 0000000..ae6ca5a --- /dev/null +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/ft/dir/CMSDirectoryParser.java @@ -0,0 +1,100 @@ +package haus.nightmare.lib3270j.ft.dir; + +import java.util.ArrayList; +import java.util.List; + +/** + * Parser for z/VM CMS file listings (FILELIST, LISTFILE, EXECIO). + */ +public class CMSDirectoryParser { + + /** + * Parse raw text containing CMS file listings into a structured list of CMSDirectoryEntry. + */ + public static List parse(String text) { + List entries = new ArrayList<>(); + if (text == null || text.trim().isEmpty()) { + return entries; + } + + String[] lines = text.split("\r?\n"); + + for (String line : lines) { + String trimmed = line.trim(); + if (trimmed.isEmpty()) continue; + if (trimmed.startsWith("--") || trimmed.startsWith("==") || trimmed.startsWith("**")) continue; + + String upper = trimmed.toUpperCase(); + if (upper.startsWith("FILENAME") || upper.startsWith("DIRECTORY") || upper.startsWith("FILELIST")) continue; + + CMSDirectoryEntry entry = parseCmsLine(trimmed); + if (entry != null) { + entries.add(entry); + } + } + + return entries; + } + + private static CMSDirectoryEntry parseCmsLine(String line) { + String[] tokens = line.split("\\s+"); + if (tokens.length < 2) return null; + + int startIdx = 0; + // Check for EXEC prefix (e.g. "&1 &2") + if (tokens[0].startsWith("&") || tokens[0].equalsIgnoreCase("EXEC")) { + while (startIdx < tokens.length && (tokens[startIdx].startsWith("&") || tokens[startIdx].equalsIgnoreCase("EXEC"))) { + startIdx++; + } + } + + if (startIdx + 1 >= tokens.length) return null; + + String fn = tokens[startIdx]; + String ft = tokens[startIdx + 1]; + + // CMS filenames and filetypes are 1-8 chars alphanumeric + if (fn.length() > 8 || ft.length() > 8) return null; + + String fm = (startIdx + 2 < tokens.length && tokens[startIdx + 2].length() <= 2) ? tokens[startIdx + 2] : "A1"; + CMSDirectoryEntry entry = new CMSDirectoryEntry(fn, ft, fm); + + int cur = startIdx + 3; + // Format (F or V) + if (cur < tokens.length && tokens[cur].matches("^(F|V|U)$")) { + entry.setRecfm(tokens[cur]); + cur++; + } + + // LRECL + if (cur < tokens.length && tokens[cur].matches("^\\d+$")) { + try { entry.setLrecl(Integer.parseInt(tokens[cur])); } catch (NumberFormatException ignored) {} + cur++; + } + + // RECS + if (cur < tokens.length && tokens[cur].matches("^\\d+$")) { + try { entry.setNumRecords(Long.parseLong(tokens[cur])); } catch (NumberFormatException ignored) {} + cur++; + } + + // BLOCKS + if (cur < tokens.length && tokens[cur].matches("^\\d+$")) { + try { entry.setNumBlocks(Long.parseLong(tokens[cur])); } catch (NumberFormatException ignored) {} + cur++; + } + + // DATE + if (cur < tokens.length && (tokens[cur].contains("-") || tokens[cur].contains("/"))) { + entry.setDate(tokens[cur]); + cur++; + } + + // TIME + if (cur < tokens.length && tokens[cur].contains(":")) { + entry.setTime(tokens[cur]); + } + + return entry; + } +} diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/ft/dir/FileTransferHostDirectoryInterface.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/ft/dir/FileTransferHostDirectoryInterface.java new file mode 100644 index 0000000..3594fb5 --- /dev/null +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/ft/dir/FileTransferHostDirectoryInterface.java @@ -0,0 +1,21 @@ +package haus.nightmare.lib3270j.ft.dir; + +import java.util.List; + +/** + * Callback interface for asynchronous host directory listing requests. + */ +public interface FileTransferHostDirectoryInterface { + + /** + * Called when host directory entries have been successfully retrieved and parsed. + * @param entries List of parsed directory entries + */ + void onDirectoryLoaded(List entries); + + /** + * Called when an error occurs during directory query or parsing. + * @param errorMessage Description of the error + */ + void onDirectoryError(String errorMessage); +} diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/ft/dir/HostDirectoryEntry.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/ft/dir/HostDirectoryEntry.java new file mode 100644 index 0000000..348b05b --- /dev/null +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/ft/dir/HostDirectoryEntry.java @@ -0,0 +1,72 @@ +package haus.nightmare.lib3270j.ft.dir; + +import java.io.Serializable; + +/** + * Base abstract representation of a file or dataset entry on a mainframe host. + */ +public abstract class HostDirectoryEntry implements Serializable { + + private static final long serialVersionUID = 1L; + + protected String name; + protected String recfm; + protected int lrecl; + protected int blksize; + + public HostDirectoryEntry() {} + + public HostDirectoryEntry(String name) { + this.name = name; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDatasetName() { + return name; + } + + public String getRecfm() { + return recfm; + } + + public void setRecfm(String recfm) { + this.recfm = recfm; + } + + public int getLrecl() { + return lrecl; + } + + public void setLrecl(int lrecl) { + this.lrecl = lrecl; + } + + public int getBlksize() { + return blksize; + } + + public void setBlksize(int blksize) { + this.blksize = blksize; + } + + public abstract String getFormattedSize(); + + public abstract String getLastModified(); + + /** + * Return a standardized formatted listing line suitable for display. + */ + public abstract String formatListing(); + + @Override + public String toString() { + return formatListing(); + } +} diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/ft/dir/TSODirectoryEntry.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/ft/dir/TSODirectoryEntry.java new file mode 100644 index 0000000..aed7202 --- /dev/null +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/ft/dir/TSODirectoryEntry.java @@ -0,0 +1,125 @@ +package haus.nightmare.lib3270j.ft.dir; + +/** + * Representation of a z/OS / TSO dataset catalog or ISPF dataset list entry. + */ +public class TSODirectoryEntry extends HostDirectoryEntry { + + private static final long serialVersionUID = 1L; + + private String volume; + private String dsorg; + private int tracksAllocated; + private int tracksUsed; + private int percentUsed; + private int extents; + private String device; + private String creationDate; + private String referencedDate; + + public TSODirectoryEntry() { + super(); + } + + public TSODirectoryEntry(String dsname) { + super(dsname); + } + + public String getVolume() { + return volume; + } + + public void setVolume(String volume) { + this.volume = volume; + } + + public String getDsorg() { + return dsorg; + } + + public void setDsorg(String dsorg) { + this.dsorg = dsorg; + } + + public int getTracksAllocated() { + return tracksAllocated; + } + + public void setTracksAllocated(int tracksAllocated) { + this.tracksAllocated = tracksAllocated; + } + + public int getTracksUsed() { + return tracksUsed; + } + + public void setTracksUsed(int tracksUsed) { + this.tracksUsed = tracksUsed; + } + + public int getPercentUsed() { + return percentUsed; + } + + public void setPercentUsed(int percentUsed) { + this.percentUsed = percentUsed; + } + + public int getExtents() { + return extents; + } + + public void setExtents(int extents) { + this.extents = extents; + } + + public String getDevice() { + return device; + } + + public void setDevice(String device) { + this.device = device; + } + + public String getCreationDate() { + return creationDate; + } + + public void setCreationDate(String creationDate) { + this.creationDate = creationDate; + } + + public String getReferencedDate() { + return referencedDate; + } + + public void setReferencedDate(String referencedDate) { + this.referencedDate = referencedDate; + } + + @Override + public String getFormattedSize() { + if (tracksAllocated > 0) { + return tracksUsed > 0 ? (tracksUsed + "/" + tracksAllocated + " TRK") : (tracksAllocated + " TRK"); + } + return "-"; + } + + @Override + public String getLastModified() { + return referencedDate != null && !referencedDate.isEmpty() ? referencedDate : creationDate; + } + + @Override + public String formatListing() { + return String.format("%-44s %-6s %-4s %-4s %5d %5d %4d %-10s", + name != null ? name : "", + volume != null ? volume : "", + dsorg != null ? dsorg : "", + recfm != null ? recfm : "", + lrecl, + blksize, + tracksAllocated, + referencedDate != null ? referencedDate : ""); + } +} diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/ft/dir/TSODirectoryParser.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/ft/dir/TSODirectoryParser.java new file mode 100644 index 0000000..2ef2a33 --- /dev/null +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/ft/dir/TSODirectoryParser.java @@ -0,0 +1,158 @@ +package haus.nightmare.lib3270j.ft.dir; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Parser for z/OS TSO dataset listings (ISPF DSLIST, LISTCAT, LISTDS). + */ +public class TSODirectoryParser { + + private static final Pattern DSN_PATTERN = Pattern.compile("([A-Z0-9@#$]+(?:\\.[A-Z0-9@#$]+)+)"); + private static final Pattern NONVSAM_PATTERN = Pattern.compile("NONVSAM\\s+-+\\s+([A-Z0-9@#$]+(?:\\.[A-Z0-9@#$]+)+)", Pattern.CASE_INSENSITIVE); + private static final Pattern VOLSER_PATTERN = Pattern.compile("VOLSER-+([A-Z0-9]+)", Pattern.CASE_INSENSITIVE); + private static final Pattern RECFM_PATTERN = Pattern.compile("RECFM-+([A-Z]+)", Pattern.CASE_INSENSITIVE); + private static final Pattern LRECL_PATTERN = Pattern.compile("LRECL-+([0-9]+)", Pattern.CASE_INSENSITIVE); + private static final Pattern BLKSIZE_PATTERN = Pattern.compile("BLKSIZE-+([0-9]+)", Pattern.CASE_INSENSITIVE); + private static final Pattern DSORG_PATTERN = Pattern.compile("DSORG-+([A-Z]+)", Pattern.CASE_INSENSITIVE); + + /** + * Parse raw text containing TSO dataset listings into a structured list of TSODirectoryEntry. + */ + public static List parse(String text) { + List entries = new ArrayList<>(); + if (text == null || text.trim().isEmpty()) { + return entries; + } + + String[] lines = text.split("\r?\n"); + + // Check if text is LISTCAT format + if (text.toUpperCase().contains("NONVSAM") || text.toUpperCase().contains("IN-CAT")) { + parseListcat(lines, entries); + if (!entries.isEmpty()) return entries; + } + + // Check for ISPF DSLIST or general tabular format + for (String line : lines) { + String trimmed = line.trim(); + if (trimmed.isEmpty()) continue; + if (trimmed.startsWith("--") || trimmed.startsWith("==") || trimmed.startsWith("**")) continue; + if (trimmed.toUpperCase().startsWith("COMMAND") || trimmed.toUpperCase().startsWith("DSLIST") || trimmed.toUpperCase().startsWith("DATA SETS")) continue; + + TSODirectoryEntry entry = parseTabularLine(trimmed); + if (entry != null) { + entries.add(entry); + } + } + + return entries; + } + + private static void parseListcat(String[] lines, List entries) { + TSODirectoryEntry current = null; + for (String line : lines) { + String upper = line.toUpperCase(); + Matcher nonvsamMat = NONVSAM_PATTERN.matcher(upper); + if (nonvsamMat.find()) { + if (current != null && current.getName() != null) { + entries.add(current); + } + current = new TSODirectoryEntry(nonvsamMat.group(1)); + continue; + } + + if (current != null) { + Matcher volMat = VOLSER_PATTERN.matcher(upper); + if (volMat.find()) current.setVolume(volMat.group(1)); + + Matcher recMat = RECFM_PATTERN.matcher(upper); + if (recMat.find()) current.setRecfm(recMat.group(1)); + + Matcher lreclMat = LRECL_PATTERN.matcher(upper); + if (lreclMat.find()) { + try { current.setLrecl(Integer.parseInt(lreclMat.group(1))); } catch (NumberFormatException ignored) {} + } + + Matcher blkMat = BLKSIZE_PATTERN.matcher(upper); + if (blkMat.find()) { + try { current.setBlksize(Integer.parseInt(blkMat.group(1))); } catch (NumberFormatException ignored) {} + } + + Matcher dsorgMat = DSORG_PATTERN.matcher(upper); + if (dsorgMat.find()) current.setDsorg(dsorgMat.group(1)); + } + } + if (current != null && current.getName() != null) { + entries.add(current); + } + } + + private static TSODirectoryEntry parseTabularLine(String line) { + String[] tokens = line.split("\\s+"); + if (tokens.length == 0) return null; + + // Find which token is the dataset name + int dsnIdx = -1; + for (int i = 0; i < tokens.length; i++) { + String tok = tokens[i]; + if (DSN_PATTERN.matcher(tok.toUpperCase()).matches()) { + dsnIdx = i; + break; + } + } + + if (dsnIdx < 0) return null; + + TSODirectoryEntry entry = new TSODirectoryEntry(tokens[dsnIdx].toUpperCase()); + boolean seenFormat = false; + + // Parse remaining tokens + for (int i = 0; i < tokens.length; i++) { + if (i == dsnIdx) continue; + String tok = tokens[i].toUpperCase(); + + if (tok.matches("^(PS|PO|PO-E|VSAM|DA|IS)$")) { + entry.setDsorg(tok); + seenFormat = true; + } else if (tok.matches("^(F|FB|V|VB|U|VBS|FBS)$")) { + entry.setRecfm(tok); + seenFormat = true; + } else if (tok.matches("^(3390|3380|TAPE|VIO)$")) { + entry.setDevice(tok); + } else if (tok.matches("^\\d{4}/\\d{2}/\\d{2}$") || tok.matches("^\\d{2}/\\d{2}/\\d{2}$") || + tok.matches("^\\d{4}-\\d{2}-\\d{2}$")) { + if (entry.getCreationDate() == null) { + entry.setCreationDate(tok); + } else { + entry.setReferencedDate(tok); + } + } else if (tok.matches("^[A-Z0-9]{6}$") && entry.getVolume() == null && !tok.matches("^\\d+$")) { + entry.setVolume(tok); + } else if (tok.matches("^\\d+$")) { + int val = Integer.parseInt(tok); + if (!seenFormat) { + if (entry.getTracksAllocated() == 0) { + entry.setTracksAllocated(val); + } else if (entry.getPercentUsed() == 0 && val <= 100) { + entry.setPercentUsed(val); + } else if (entry.getExtents() == 0 && val <= 128) { + entry.setExtents(val); + } + } else { + if (entry.getLrecl() == 0 && val <= 32760) { + entry.setLrecl(val); + } else if (entry.getBlksize() == 0 && val <= 32760) { + entry.setBlksize(val); + } else if (entry.getTracksAllocated() == 0) { + entry.setTracksAllocated(val); + } + } + } + } + + return entry; + } +} diff --git a/lib3270j/src/test/java/haus/nightmare/lib3270j/ecl/ECLXferTest.java b/lib3270j/src/test/java/haus/nightmare/lib3270j/ecl/ECLXferTest.java new file mode 100644 index 0000000..108f0d0 --- /dev/null +++ b/lib3270j/src/test/java/haus/nightmare/lib3270j/ecl/ECLXferTest.java @@ -0,0 +1,91 @@ +package haus.nightmare.lib3270j.ecl; + +import haus.nightmare.lib3270j.ConnectionConfig; +import haus.nightmare.lib3270j.Telnet3270Client; +import haus.nightmare.lib3270j.TerminalModel; +import haus.nightmare.lib3270j.ft.FTConstants; +import haus.nightmare.lib3270j.ft.dir.CMSDirectoryEntry; +import haus.nightmare.lib3270j.ft.dir.HostDirectoryEntry; +import haus.nightmare.lib3270j.ft.dir.TSODirectoryEntry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +public class ECLXferTest { + + private Telnet3270Client client; + private ECLXfer xfer; + + @BeforeEach + public void setUp() { + ConnectionConfig config = new ConnectionConfig("localhost", 23, TerminalModel.IBM_3279_4); + client = new Telnet3270Client(config); + xfer = client.getXfer(); + } + + @Test + public void testXferInstanceAndMtu() { + assertNotNull(xfer); + assertEquals(FTConstants.DFT_BUF, xfer.getMTUSize()); + + xfer.setMTUSize(8192); + assertEquals(8192, xfer.getMTUSize()); + assertFalse(xfer.isTransferActive()); + } + + @Test + public void testListenerRegistrationAndEvents() { + List receivedEvents = new ArrayList<>(); + ECLXferListener listener = receivedEvents::add; + + xfer.addXferListener(listener); + + // Attempt SendFile with invalid local file / params + int rc = xfer.SendFile("", "TEST.DATA", "ASCII CRLF"); + assertNotEquals(0, rc); + assertTrue(receivedEvents.size() > 0); + + ECLXferEvent ev = receivedEvents.get(0); + assertEquals(ECLXferEvent.XFER_ABORTED, ev.getEventType()); + assertEquals(FTConstants.ECL_ERR_XFER_INVALID_PARAM, ev.getErrorCode()); + + xfer.removeXferListener(listener); + } + + @Test + public void testDirectoryParsingThroughEclXfer() { + String cmsText = "PROFILE EXEC A1 V 80 25 1 2026-05-10 14:22:01\n"; + List cmsEntries = xfer.getCmsDirectory(cmsText); + assertEquals(1, cmsEntries.size()); + assertEquals("PROFILE", cmsEntries.get(0).getFilename()); + + String tsoText = "TSOUSER.TEST.CNTL 15 50 1 3390 PO FB 80 3120 TSO001\n"; + List tsoEntries = xfer.getTsoDirectory(tsoText); + assertEquals(1, tsoEntries.size()); + assertEquals("TSOUSER.TEST.CNTL", tsoEntries.get(0).getName()); + } + + @Test + public void testGetFilesCallback() { + List fileList = new ArrayList<>(); + boolean[] loaded = new boolean[1]; + + xfer.getFiles("PROFILE EXEC A1 V 80 25 1 2026-05-10 14:22:01\n", fileList, new haus.nightmare.lib3270j.ft.dir.FileTransferHostDirectoryInterface() { + @Override + public void onDirectoryLoaded(List entries) { + loaded[0] = true; + } + + @Override + public void onDirectoryError(String errorMessage) {} + }); + + assertTrue(loaded[0]); + assertEquals(1, fileList.size()); + } +} diff --git a/lib3270j/src/test/java/haus/nightmare/lib3270j/ft/FTPhase3Test.java b/lib3270j/src/test/java/haus/nightmare/lib3270j/ft/FTPhase3Test.java new file mode 100644 index 0000000..3f3ea56 --- /dev/null +++ b/lib3270j/src/test/java/haus/nightmare/lib3270j/ft/FTPhase3Test.java @@ -0,0 +1,177 @@ +package haus.nightmare.lib3270j.ft; + +import haus.nightmare.lib3270j.TerminalModel; +import haus.nightmare.lib3270j.charset.EbcdicTranslator; +import haus.nightmare.lib3270j.input.InputProcessor; +import haus.nightmare.lib3270j.screen.ScreenBuffer; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.nio.charset.StandardCharsets; + +import static haus.nightmare.lib3270j.ft.FTConstants.*; +import static haus.nightmare.lib3270j.protocol.DS3270Constants.*; +import static org.junit.jupiter.api.Assertions.*; + +public class FTPhase3Test { + + private EbcdicTranslator translator; + private ScreenBuffer screen; + private InputProcessor inputProcessor; + + @BeforeEach + public void setUp() { + translator = new EbcdicTranslator(); + screen = new ScreenBuffer(TerminalModel.IBM_3279_4, translator); + inputProcessor = new InputProcessor(screen, translator, null); + } + + @Test + public void testTsoDatasetValidationRules() { + // Valid unqualified dataset + assertNull(FTConfig.validateTsoDatasetName("USER.TEST.DATA")); + // Valid fully qualified dataset + assertNull(FTConfig.validateTsoDatasetName("'USER.TEST.DATA'")); + // Valid PDS member + assertNull(FTConfig.validateTsoDatasetName("USER.TEST.CNTL(MEMBER1)")); + // Valid national characters + assertNull(FTConfig.validateTsoDatasetName("'USER.TEST.@#$'")); + + // Invalid: missing/empty + assertNotNull(FTConfig.validateTsoDatasetName("")); + assertNotNull(FTConfig.validateTsoDatasetName(null)); + // Invalid: mismatched quote + assertNotNull(FTConfig.validateTsoDatasetName("'USER.TEST.DATA")); + assertNotNull(FTConfig.validateTsoDatasetName("USER.TEST.DATA'")); + // Invalid: qualifier begins with numeric + assertNotNull(FTConfig.validateTsoDatasetName("USER.123TEST.DATA")); + // Invalid: qualifier too long (> 8 chars) + assertNotNull(FTConfig.validateTsoDatasetName("USER.VERYLONGQUALIFIER.DATA")); + // Invalid: member too long + assertNotNull(FTConfig.validateTsoDatasetName("USER.TEST.DATA(VERYLONGMEMBERNAME)")); + } + + @Test + public void testCmsFilenameValidationRules() { + assertNull(FTConfig.validateCmsFilename("PROFILE EXEC")); + assertNull(FTConfig.validateCmsFilename("PROFILE EXEC A1")); + assertNull(FTConfig.validateCmsFilename("GENPASS REXX A")); + + // Invalid: single token + assertNotNull(FTConfig.validateCmsFilename("PROFILE")); + // Invalid: filename > 8 chars + assertNotNull(FTConfig.validateCmsFilename("LONGNAME123 EXEC A")); + // Invalid: filetype > 8 chars + assertNotNull(FTConfig.validateCmsFilename("TEST LONGTYPE123 A")); + // Invalid: filemode > 2 chars + assertNotNull(FTConfig.validateCmsFilename("TEST DATA A123")); + } + + @Test + public void testMtuClampingAndCommandBuilding() { + FTConfig config = new FTConfig(); + config.setDftBufferSize(100); + assertEquals(DFT_MIN_BUF, config.getDftBufferSize()); // clamped to 256 + + config.setDftBufferSize(50000); + assertEquals(DFT_MAX_BUF, config.getDftBufferSize()); // clamped to 32768 + + config.setHostType(FTConfig.HostType.CMS); + config.setDirection(FTConfig.Direction.SEND); + config.setHostFilename("TEST DATA A"); + config.setOverwrite(true); + config.setBlksize(4096); + config.setRecfm("F"); + config.setLrecl(80); + + String cmd = config.buildCommand(); + assertTrue(cmd.contains("REPLACE")); + assertTrue(cmd.contains("BLOCK 4096")); + assertTrue(cmd.contains("RECFM F")); + assertTrue(cmd.contains("LRECL 80")); + } + + @Test + public void testCutRetransmitRecovery() { + FTCutTest.TestFTCutListener listener = new FTCutTest.TestFTCutListener(); + FTConfig config = new FTConfig(); + config.setDirection(FTConfig.Direction.SEND); + config.setHostType(FTConfig.HostType.CMS); + config.setHostFilename("TEST FILE A"); + config.setTransferMode(FTConfig.TransferMode.ASCII); + config.setCrAction(FTConfig.CrAction.REMOVE); + listener.config = config; + + FTCut ftCut = new FTCut(screen, inputProcessor, translator, listener); + + ByteArrayInputStream in = new ByteArrayInputStream("DATA LINE\n".getBytes(StandardCharsets.UTF_8)); + ftCut.initTransfer(in, null); + + // 1. Host requests data + screen.setCellFA(O_SF, (byte) (FA_PROTECT | FA_NUMERIC)); + screen.setCell(O_FRAME_TYPE, FT_DATA_REQUEST); + screen.setCell(O_DR_FRAME_SEQ, FTConstants.to6(0, translator)); + ftCut.processScreenUpdate(); + + assertEquals(0, ftCut.getRetryCount()); + + // 2. Host sends RETRANSMIT frame (0x4C) + screen.setCell(O_FRAME_TYPE, FT_RETRANSMIT); + ftCut.processScreenUpdate(); + + assertEquals(1, ftCut.getRetryCount()); + assertTrue(ftCut.isTransferActive()); + + // 3. Complete transfer + screen.setCell(O_FRAME_TYPE, FT_CONTROL_CODE); + screen.setCell(O_CC_STATUS_CODE, (SC_XFER_COMPLETE >> 8) & 0xFF); + screen.setCell(O_CC_STATUS_CODE + 1, SC_XFER_COMPLETE & 0xFF); + ftCut.processScreenUpdate(); + + assertTrue(listener.completed); + } + + @Test + public void testDftDdmAttributeParsing() { + FTDftTest.TestInputProcessor ip = new FTDftTest.TestInputProcessor(screen, translator); + FTDftTest.TestFTDftListener listener = new FTDftTest.TestFTDftListener(); + FTConfig config = new FTConfig(); + config.setDirection(FTConfig.Direction.RECEIVE); + listener.config = config; + + FTDft dft = new FTDft(ip, translator, listener); + ByteArrayOutputStream outStream = new ByteArrayOutputStream(); + dft.initTransfer(null, outStream); + + // Craft Open Request with DDM attributes: LRECL=80, RECFM=F (1), BLKSIZE=3120, FILESIZE=12345 + ByteArrayOutputStream sf = new ByteArrayOutputStream(); + sf.write(0); sf.write(0); // placeholder length + sf.write(FTConstants.SF_TRANSFER_DATA); + sf.write((TR_OPEN_REQ >> 8) & 0xFF); + sf.write(TR_OPEN_REQ & 0xFF); + + // LRECL (0x01, len=5, val=80) + sf.write(DDM_HDR_LRECL); sf.write(0); sf.write(5); sf.write(0); sf.write(80); + // RECFM (0x02, len=4, val=1) + sf.write(DDM_HDR_RECFM); sf.write(0); sf.write(4); sf.write(1); + // BLKSIZE (0x03, len=5, val=3120) + sf.write(DDM_HDR_BLKSIZE); sf.write(0); sf.write(5); sf.write((3120 >> 8) & 0xFF); sf.write(3120 & 0xFF); + // FILESIZE (0x04, len=7, val=12345) + sf.write(DDM_HDR_FILESIZE); sf.write(0); sf.write(7); + sf.write(0); sf.write(0); sf.write((12345 >> 8) & 0xFF); sf.write(12345 & 0xFF); + + byte[] raw = sf.toByteArray(); + raw[0] = (byte) ((raw.length >> 8) & 0xFF); + raw[1] = (byte) (raw.length & 0xFF); + + dft.processStructuredField(raw, 0, raw.length); + + assertEquals(80, dft.getHostLrecl()); + assertEquals("F", dft.getHostRecfm()); + assertEquals(3120, dft.getHostBlksize()); + assertEquals(12345, dft.getEstimatedTotalBytes()); + } +} diff --git a/lib3270j/src/test/java/haus/nightmare/lib3270j/ft/dir/DirectoryParserTest.java b/lib3270j/src/test/java/haus/nightmare/lib3270j/ft/dir/DirectoryParserTest.java new file mode 100644 index 0000000..dcf8407 --- /dev/null +++ b/lib3270j/src/test/java/haus/nightmare/lib3270j/ft/dir/DirectoryParserTest.java @@ -0,0 +1,124 @@ +package haus.nightmare.lib3270j.ft.dir; + +import org.junit.jupiter.api.Test; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; + +public class DirectoryParserTest { + + @Test + public void testTsoIspfDslistParsing() { + String ispfOutput = + "Command ===> Scroll ===> PAGE\n" + + "Dslist - Data Sets Matching TSOUSER.* Row 1 of 4\n" + + "Command - Enter \"/\" to select action Tracks %Used XT Device Dsorg Recfm Lrecl Blksz Volume\n" + + "-------------------------------------------------------------------------------------------------------\n" + + " TSOUSER.TEST.CNTL 15 50 1 3390 PO FB 80 3120 TSO001\n" + + " TSOUSER.TEST.COBOL 30 80 2 3390 PO FB 80 6160 TSO002\n" + + " TSOUSER.TEST.DATA 10 100 1 3390 PS FB 80 3120 TSO001\n" + + " TSOUSER.TEST.LOAD 45 60 3 3390 PO-E U 0 6144 TSO003\n"; + + List entries = TSODirectoryParser.parse(ispfOutput); + assertEquals(4, entries.size()); + + TSODirectoryEntry e0 = entries.get(0); + assertEquals("TSOUSER.TEST.CNTL", e0.getName()); + assertEquals("TSO001", e0.getVolume()); + assertEquals("PO", e0.getDsorg()); + assertEquals("FB", e0.getRecfm()); + assertEquals(80, e0.getLrecl()); + assertEquals(3120, e0.getBlksize()); + assertEquals(15, e0.getTracksAllocated()); + + TSODirectoryEntry e2 = entries.get(2); + assertEquals("TSOUSER.TEST.DATA", e2.getName()); + assertEquals("PS", e2.getDsorg()); + assertEquals("FB", e2.getRecfm()); + + assertNotNull(e0.formatListing()); + } + + @Test + public void testTsoListcatParsing() { + String listcatOutput = + "IDCAMS SYSTEM SERVICES TIME: 12:00:00\n" + + "NONVSAM ------- TSOUSER.SAMPLE.DATA\n" + + " IN-CAT --- CATALOG.USER\n" + + " HISTORY\n" + + " RELEASE----------------2\n" + + " VOLUMES\n" + + " VOLSER------------TSO005 DEVTYPE------X'3010200F' FSEQN------------------0\n" + + " ATTRIBUTES\n" + + " RECFM------------FB LRECL-----------------80 BLKSIZE-------------3120\n" + + " DSORG------------PS ALLOC-PRM-------------15 ALLOC-SEC--------------5\n" + + "NONVSAM ------- TSOUSER.OTHER.DATA\n" + + " VOLUMES\n" + + " VOLSER------------TSO006\n" + + " ATTRIBUTES\n" + + " RECFM------------VB LRECL----------------255 BLKSIZE-------------6144\n" + + " DSORG------------PS\n"; + + List entries = TSODirectoryParser.parse(listcatOutput); + assertEquals(2, entries.size()); + + TSODirectoryEntry e0 = entries.get(0); + assertEquals("TSOUSER.SAMPLE.DATA", e0.getName()); + assertEquals("TSO005", e0.getVolume()); + assertEquals("FB", e0.getRecfm()); + assertEquals(80, e0.getLrecl()); + assertEquals(3120, e0.getBlksize()); + assertEquals("PS", e0.getDsorg()); + + TSODirectoryEntry e1 = entries.get(1); + assertEquals("TSOUSER.OTHER.DATA", e1.getName()); + assertEquals("TSO006", e1.getVolume()); + assertEquals("VB", e1.getRecfm()); + assertEquals(255, e1.getLrecl()); + assertEquals(6144, e1.getBlksize()); + } + + @Test + public void testCmsFileListParsing() { + String cmsFileList = + "FILENAME FILETYPE FM FORMAT LRECL RECS BLOCKS DATE TIME\n" + + "PROFILE EXEC A1 V 80 25 1 2026-05-10 14:22:01\n" + + "GENPASS REXX A1 V 80 500 3 2026-08-28 11:00:30\n" + + "TEST DATA A1 F 80 100 2 2026-08-01 09:15:00\n"; + + List entries = CMSDirectoryParser.parse(cmsFileList); + assertEquals(3, entries.size()); + + CMSDirectoryEntry e0 = entries.get(0); + assertEquals("PROFILE", e0.getFilename()); + assertEquals("EXEC", e0.getFiletype()); + assertEquals("A1", e0.getFilemode()); + assertEquals("V", e0.getRecfm()); + assertEquals(80, e0.getLrecl()); + assertEquals(25, e0.getNumRecords()); + assertEquals(1, e0.getNumBlocks()); + assertEquals("2026-05-10", e0.getDate()); + assertEquals("14:22:01", e0.getTime()); + assertEquals("PROFILE EXEC A1", e0.getName()); + + CMSDirectoryEntry e1 = entries.get(1); + assertEquals("GENPASS", e1.getFilename()); + assertEquals("REXX", e1.getFiletype()); + assertEquals(500, e1.getNumRecords()); + + assertNotNull(e0.formatListing()); + } + + @Test + public void testCmsListfileExecParsing() { + String execList = + "&1 &2 PROFILE EXEC A1\n" + + "&1 &2 GENPASS REXX A1\n"; + + List entries = CMSDirectoryParser.parse(execList); + assertEquals(2, entries.size()); + assertEquals("PROFILE", entries.get(0).getFilename()); + assertEquals("EXEC", entries.get(0).getFiletype()); + assertEquals("GENPASS", entries.get(1).getFilename()); + assertEquals("REXX", entries.get(1).getFiletype()); + } +}