Erweiterung für V2.0: M9 umgesetzt
This commit is contained in:
+325
-94
@@ -1,13 +1,20 @@
|
||||
package de.gecheckt.pdf.umbenenner.bootstrap;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiAdapter;
|
||||
import de.gecheckt.pdf.umbenenner.bootstrap.startup.StartupArguments;
|
||||
import de.gecheckt.pdf.umbenenner.bootstrap.startup.StartupMode;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.cli.SchedulerBatchCommand;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.out.bootstrap.validation.InvalidStartConfigurationException;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.out.bootstrap.validation.StartConfigurationValidator;
|
||||
@@ -58,17 +65,42 @@ import de.gecheckt.pdf.umbenenner.domain.model.RunId;
|
||||
/**
|
||||
* Orchestrator for the complete startup sequence and object graph construction.
|
||||
* <p>
|
||||
* Separates startup concerns into two distinct phases:
|
||||
* Selects the appropriate inbound adapter based on the parsed startup mode and
|
||||
* routes control to either the headless batch processing pipeline or the JavaFX
|
||||
* desktop GUI adapter. Exactly one inbound adapter is started per process run.
|
||||
*
|
||||
* <h2>Startup modes</h2>
|
||||
* <ul>
|
||||
* <li>{@link StartupMode#GUI} — Bootstrap creates and starts the
|
||||
* {@link GuiAdapter}. The GUI adapter manages the JavaFX lifecycle.
|
||||
* Bootstrap returns exit code 0 on normal GUI shutdown and exit code 1 on
|
||||
* any exception thrown by the GUI adapter before or during startup.</li>
|
||||
* <li>{@link StartupMode#HEADLESS} — Bootstrap runs the two-phase headless batch
|
||||
* pipeline described below.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <h2>Headless batch pipeline phases</h2>
|
||||
* <ol>
|
||||
* <li><strong>Bootstrap Phase:</strong> Load and validate configuration, initialize persistence schema,
|
||||
* establish run-lock, and prepare all adapters and ports.</li>
|
||||
* <li><strong>Execution Phase:</strong> Wire and execute the batch processing use case, then map outcome to exit code.</li>
|
||||
* <li><strong>Bootstrap Phase:</strong> Load and validate configuration, initialize persistence
|
||||
* schema, establish run-lock, and prepare all adapters and ports.</li>
|
||||
* <li><strong>Execution Phase:</strong> Wire and execute the batch processing use case, then
|
||||
* map outcome to exit code.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <h2>Configuration path semantics ({@code --config} option)</h2>
|
||||
* <p>
|
||||
* The startup configuration encompasses all technical infrastructure and runtime parameters
|
||||
* needed for bootstrap and execution. Once validated and the schema is initialized,
|
||||
* configuration is handed to the use case factory which extracts the minimal runtime
|
||||
* configuration for the application layer.
|
||||
* The {@code --config <path>} option is available in both startup modes but is enforced
|
||||
* differently:
|
||||
* <ul>
|
||||
* <li><strong>Headless:</strong> When a path override is present, the file must exist before
|
||||
* migration or loading is attempted. A missing file is a hard startup failure (exit code 1).
|
||||
* No silent fallback to the default path occurs. When no override is present, the default
|
||||
* path {@code config/application.properties} relative to the working directory is used.</li>
|
||||
* <li><strong>GUI:</strong> When a path override is present and the file exists, the path is
|
||||
* confirmed and the adapter is started normally. When the file does not exist, an error
|
||||
* is logged and a startup notice is forwarded to the adapter; the GUI then starts without
|
||||
* any configuration override, behaving as if {@code --config} had not been specified.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <h2>Active AI provider</h2>
|
||||
* <p>
|
||||
@@ -78,15 +110,12 @@ import de.gecheckt.pdf.umbenenner.domain.model.RunId;
|
||||
*
|
||||
* <h2>Exit code semantics</h2>
|
||||
* <ul>
|
||||
* <li>{@code 0}: Batch run executed successfully; individual document failures do not
|
||||
* change the exit code as long as the run itself completed without a hard
|
||||
* infrastructure error.</li>
|
||||
* <li>{@code 1}: Hard start, bootstrap, configuration, or persistence failure
|
||||
* that prevented the run from beginning, or a critical infrastructure failure
|
||||
* during the run.</li>
|
||||
* <li>{@code 0}: Headless batch run executed successfully (individual document failures do
|
||||
* not change the exit code), or the GUI terminated normally after the window was shown.</li>
|
||||
* <li>{@code 1}: Hard start, bootstrap, configuration, persistence, or GUI startup failure.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <h2>Adapter wiring</h2>
|
||||
* <h2>Adapter wiring (headless path)</h2>
|
||||
* <p>
|
||||
* The production constructor wires the following key adapters:
|
||||
* <ul>
|
||||
@@ -100,18 +129,16 @@ import de.gecheckt.pdf.umbenenner.domain.model.RunId;
|
||||
* <li>{@link SqliteDocumentRecordRepositoryAdapter} — manages document master records.</li>
|
||||
* <li>{@link SqliteProcessingAttemptRepositoryAdapter} — maintains attempt history.</li>
|
||||
* <li>{@link SqliteUnitOfWorkAdapter} — coordinates atomic persistence operations.</li>
|
||||
* <li>{@link FilesystemTargetFolderAdapter} — resolves unique filenames in the configured target folder.</li>
|
||||
* <li>{@link FilesystemTargetFileCopyAdapter} — copies source documents to the target folder via
|
||||
* a temporary file and final move/rename.</li>
|
||||
* <li>{@link FilesystemTargetFolderAdapter} — resolves unique filenames in the configured
|
||||
* target folder.</li>
|
||||
* <li>{@link FilesystemTargetFileCopyAdapter} — copies source documents to the target folder
|
||||
* via a temporary file and final move/rename.</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* Schema initialization is performed exactly once in {@link #run()} before the batch processing loop
|
||||
* begins. A {@link DocumentPersistenceException} during schema initialization is treated as a hard
|
||||
* startup failure and results in exit code 1.
|
||||
*/
|
||||
public class BootstrapRunner {
|
||||
|
||||
private static final Logger LOG = LogManager.getLogger(BootstrapRunner.class);
|
||||
private static final Path DEFAULT_CONFIG_PATH = Paths.get("config/application.properties");
|
||||
|
||||
private final MigrationStep migrationStep;
|
||||
private final ConfigurationPortFactory configPortFactory;
|
||||
@@ -120,26 +147,39 @@ public class BootstrapRunner {
|
||||
private final SchemaInitializationPortFactory schemaInitPortFactory;
|
||||
private final UseCaseFactory useCaseFactory;
|
||||
private final CommandFactory commandFactory;
|
||||
private final GuiAdapterFactory guiAdapterFactory;
|
||||
|
||||
/**
|
||||
* Functional interface encapsulating the legacy configuration migration step.
|
||||
* <p>
|
||||
* The production implementation calls {@link LegacyConfigurationMigrator#migrateIfLegacy}
|
||||
* on the active configuration file before any configuration is loaded. In tests, a
|
||||
* on the effective configuration file before any configuration is loaded. In tests, a
|
||||
* no-op lambda is injected so that migration does not interfere with mock configuration ports.
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface MigrationStep {
|
||||
/** Runs the legacy configuration migration if the configuration file is in legacy form. */
|
||||
void runIfNeeded();
|
||||
/**
|
||||
* Runs the legacy configuration migration if the configuration file at the given
|
||||
* path is in legacy form.
|
||||
*
|
||||
* @param effectiveConfigPath the resolved configuration file path to check and migrate
|
||||
*/
|
||||
void runIfNeeded(Path effectiveConfigPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Functional interface for creating a ConfigurationPort.
|
||||
* Functional interface for creating a {@link ConfigurationPort} bound to the resolved
|
||||
* configuration file path.
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface ConfigurationPortFactory {
|
||||
ConfigurationPort create();
|
||||
/**
|
||||
* Creates a {@link ConfigurationPort} that loads configuration from the given path.
|
||||
*
|
||||
* @param effectiveConfigPath the resolved configuration file path; never {@code null}
|
||||
* @return a new {@link ConfigurationPort}; never {@code null}
|
||||
*/
|
||||
ConfigurationPort create(Path effectiveConfigPath);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -147,6 +187,12 @@ public class BootstrapRunner {
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface RunLockPortFactory {
|
||||
/**
|
||||
* Creates a {@link RunLockPort} bound to the given lock file path.
|
||||
*
|
||||
* @param lockFilePath the lock file path; never {@code null}
|
||||
* @return a new {@link RunLockPort}; never {@code null}
|
||||
*/
|
||||
RunLockPort create(Path lockFilePath);
|
||||
}
|
||||
|
||||
@@ -155,6 +201,7 @@ public class BootstrapRunner {
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface ValidatorFactory {
|
||||
/** Creates a new {@link StartConfigurationValidator}. */
|
||||
StartConfigurationValidator create();
|
||||
}
|
||||
|
||||
@@ -163,6 +210,12 @@ public class BootstrapRunner {
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface SchemaInitializationPortFactory {
|
||||
/**
|
||||
* Creates a schema initialization port bound to the given JDBC URL.
|
||||
*
|
||||
* @param jdbcUrl the JDBC URL for the SQLite database; never {@code null}
|
||||
* @return a new {@link PersistenceSchemaInitializationPort}; never {@code null}
|
||||
*/
|
||||
PersistenceSchemaInitializationPort create(String jdbcUrl);
|
||||
}
|
||||
|
||||
@@ -173,15 +226,24 @@ public class BootstrapRunner {
|
||||
* and the run lock port. Its responsibility is to:
|
||||
* <ol>
|
||||
* <li>Extract the minimal runtime configuration needed by the application layer.</li>
|
||||
* <li>Construct all outbound adapter ports (document candidates, PDF extraction, fingerprint, persistence).</li>
|
||||
* <li>Construct all outbound adapter ports (document candidates, PDF extraction,
|
||||
* fingerprint, persistence).</li>
|
||||
* <li>Wire the use case with all required ports and dependencies.</li>
|
||||
* </ol>
|
||||
* <p>
|
||||
* This factory is the primary responsibility boundary between startup configuration
|
||||
* (complete technical infrastructure setup) and runtime configuration (minimal application needs).
|
||||
* (complete technical infrastructure setup) and runtime configuration (minimal application
|
||||
* needs).
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface UseCaseFactory {
|
||||
/**
|
||||
* Creates a wired {@link BatchRunProcessingUseCase}.
|
||||
*
|
||||
* @param startConfig the validated startup configuration; never {@code null}
|
||||
* @param runLockPort the acquired run lock port; never {@code null}
|
||||
* @return a ready-to-execute {@link BatchRunProcessingUseCase}; never {@code null}
|
||||
*/
|
||||
BatchRunProcessingUseCase create(StartConfiguration startConfig, RunLockPort runLockPort);
|
||||
}
|
||||
|
||||
@@ -190,9 +252,32 @@ public class BootstrapRunner {
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface CommandFactory {
|
||||
/**
|
||||
* Creates a {@link SchedulerBatchCommand} wrapping the given use case.
|
||||
*
|
||||
* @param useCase the batch processing use case; never {@code null}
|
||||
* @return a new {@link SchedulerBatchCommand}; never {@code null}
|
||||
*/
|
||||
SchedulerBatchCommand create(BatchRunProcessingUseCase useCase);
|
||||
}
|
||||
|
||||
/**
|
||||
* Functional interface for creating a {@link GuiAdapter}.
|
||||
* <p>
|
||||
* The production implementation returns {@code new GuiAdapter()}. In tests, a
|
||||
* lambda that returns a controllable stub can be injected to verify Bootstrap's
|
||||
* dispatch and error-handling behaviour without starting an actual JavaFX runtime.
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface GuiAdapterFactory {
|
||||
/**
|
||||
* Creates a new {@link GuiAdapter} ready to be started.
|
||||
*
|
||||
* @return a new {@link GuiAdapter}; never {@code null}
|
||||
*/
|
||||
GuiAdapter create();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the BootstrapRunner with default factories for production use.
|
||||
* <p>
|
||||
@@ -208,34 +293,23 @@ public class BootstrapRunner {
|
||||
* <li>{@link SqliteDocumentRecordRepositoryAdapter} for document master record CRUD.</li>
|
||||
* <li>{@link SqliteProcessingAttemptRepositoryAdapter} for attempt history CRUD.</li>
|
||||
* <li>{@link SqliteUnitOfWorkAdapter} for atomic persistence operations.</li>
|
||||
* <li>{@link FilesystemTargetFolderAdapter} for duplicate-safe filename resolution in the
|
||||
* configured {@code target.folder}.</li>
|
||||
* <li>{@link FilesystemTargetFileCopyAdapter} for copying source documents to the target folder
|
||||
* via a temporary file and final atomic move/rename.</li>
|
||||
* <li>{@link FilesystemTargetFolderAdapter} for duplicate-safe filename resolution.</li>
|
||||
* <li>{@link FilesystemTargetFileCopyAdapter} for copying source documents via a
|
||||
* temporary file and final atomic move/rename.</li>
|
||||
* <li>{@link GuiAdapter} as the inbound adapter for the GUI startup path.</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* Target folder availability and write access are validated in
|
||||
* {@link #loadAndValidateConfiguration()} via {@link StartConfigurationValidator} before
|
||||
* schema initialisation and batch processing begin. If the target folder does not yet exist,
|
||||
* the validator creates it; failure to do so is a hard startup error.
|
||||
* <p>
|
||||
* Schema initialisation is performed explicitly in {@link #run()} before the batch loop
|
||||
* begins. Failure during initialisation aborts the run with exit code 1.
|
||||
*/
|
||||
public BootstrapRunner() {
|
||||
this.migrationStep = () -> new LegacyConfigurationMigrator()
|
||||
.migrateIfLegacy(Paths.get("config/application.properties"));
|
||||
this.migrationStep = path -> new LegacyConfigurationMigrator().migrateIfLegacy(path);
|
||||
this.configPortFactory = PropertiesConfigurationPortAdapter::new;
|
||||
this.runLockPortFactory = FilesystemRunLockPortAdapter::new;
|
||||
this.validatorFactory = StartConfigurationValidator::new;
|
||||
this.schemaInitPortFactory = SqliteSchemaInitializationAdapter::new;
|
||||
this.useCaseFactory = (startConfig, lock) -> {
|
||||
// Extract runtime configuration from startup configuration
|
||||
AiContentSensitivity aiContentSensitivity = resolveAiContentSensitivity(startConfig.logAiSensitive());
|
||||
RuntimeConfiguration runtimeConfig = new RuntimeConfiguration(
|
||||
startConfig.maxPages(), startConfig.maxRetriesTransient(), aiContentSensitivity);
|
||||
|
||||
// Select the active AI provider adapter
|
||||
AiProviderFamily activeFamily = startConfig.multiProviderConfiguration().activeProviderFamily();
|
||||
ProviderConfiguration providerConfig = startConfig.multiProviderConfiguration().activeProviderConfiguration();
|
||||
AiInvocationPort aiInvocationPort = new AiProviderSelector().select(activeFamily, providerConfig);
|
||||
@@ -248,7 +322,6 @@ public class BootstrapRunner {
|
||||
new SqliteProcessingAttemptRepositoryAdapter(jdbcUrl);
|
||||
UnitOfWorkPort unitOfWorkPort =
|
||||
new SqliteUnitOfWorkAdapter(jdbcUrl);
|
||||
// Wire coordinator logger with AI content sensitivity setting
|
||||
ProcessingLogger coordinatorLogger = new Log4jProcessingLogger(
|
||||
DocumentProcessingCoordinator.class, aiContentSensitivity);
|
||||
TargetFolderPort targetFolderPort = new FilesystemTargetFolderAdapter(startConfig.targetFolder());
|
||||
@@ -259,7 +332,6 @@ public class BootstrapRunner {
|
||||
startConfig.maxRetriesTransient(),
|
||||
activeFamily.getIdentifier());
|
||||
|
||||
// Wire AI naming pipeline
|
||||
PromptPort promptPort = new FilesystemPromptPortAdapter(startConfig.promptTemplateFile());
|
||||
ClockPort clockPort = new SystemClockAdapter();
|
||||
AiResponseValidator aiResponseValidator = new AiResponseValidator(clockPort);
|
||||
@@ -270,7 +342,6 @@ public class BootstrapRunner {
|
||||
providerConfig.model(),
|
||||
startConfig.maxTextCharacters());
|
||||
|
||||
// Wire use case logger with AI content sensitivity setting
|
||||
ProcessingLogger useCaseLogger = new Log4jProcessingLogger(
|
||||
DefaultBatchRunProcessingUseCase.class, aiContentSensitivity);
|
||||
return new DefaultBatchRunProcessingUseCase(
|
||||
@@ -284,13 +355,16 @@ public class BootstrapRunner {
|
||||
useCaseLogger);
|
||||
};
|
||||
this.commandFactory = SchedulerBatchCommand::new;
|
||||
this.guiAdapterFactory = GuiAdapter::new;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the BootstrapRunner with custom factories for testing.
|
||||
* Creates the BootstrapRunner with custom factories for testing, without a migration step.
|
||||
* <p>
|
||||
* The migration step is set to a no-op; tests that need to exercise the migration
|
||||
* path use the full seven-parameter constructor.
|
||||
* The migration step is set to a no-op so tests that provide a pre-built
|
||||
* {@link ConfigurationPort} are not affected by file-based migration logic.
|
||||
* The GUI adapter factory defaults to {@link GuiAdapter#GuiAdapter() new GuiAdapter()};
|
||||
* tests that need to control the GUI path use the full eight-parameter constructor.
|
||||
*
|
||||
* @param configPortFactory factory for creating ConfigurationPort instances
|
||||
* @param runLockPortFactory factory for creating RunLockPort instances
|
||||
@@ -305,15 +379,17 @@ public class BootstrapRunner {
|
||||
SchemaInitializationPortFactory schemaInitPortFactory,
|
||||
UseCaseFactory useCaseFactory,
|
||||
CommandFactory commandFactory) {
|
||||
this(() -> { /* no-op: tests inject mock ConfigurationPort directly */ },
|
||||
this(path -> { /* no-op: tests inject mock ConfigurationPort directly */ },
|
||||
configPortFactory, runLockPortFactory, validatorFactory,
|
||||
schemaInitPortFactory, useCaseFactory, commandFactory);
|
||||
schemaInitPortFactory, useCaseFactory, commandFactory,
|
||||
GuiAdapter::new);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the BootstrapRunner with all factories including an explicit migration step.
|
||||
* <p>
|
||||
* Use this constructor in tests that need to exercise the full migration-then-load path.
|
||||
* The GUI adapter factory defaults to {@link GuiAdapter#GuiAdapter() new GuiAdapter()}.
|
||||
*
|
||||
* @param migrationStep the legacy configuration migration step to run before loading
|
||||
* @param configPortFactory factory for creating ConfigurationPort instances
|
||||
@@ -330,6 +406,34 @@ public class BootstrapRunner {
|
||||
SchemaInitializationPortFactory schemaInitPortFactory,
|
||||
UseCaseFactory useCaseFactory,
|
||||
CommandFactory commandFactory) {
|
||||
this(migrationStep, configPortFactory, runLockPortFactory, validatorFactory,
|
||||
schemaInitPortFactory, useCaseFactory, commandFactory, GuiAdapter::new);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the BootstrapRunner with full control over all factories, including the GUI
|
||||
* adapter factory.
|
||||
* <p>
|
||||
* Use this constructor in tests that need to exercise the GUI startup dispatch path,
|
||||
* where the GUI adapter factory must be controlled to avoid starting a real JavaFX runtime.
|
||||
*
|
||||
* @param migrationStep the legacy configuration migration step to run before loading
|
||||
* @param configPortFactory factory for creating ConfigurationPort instances
|
||||
* @param runLockPortFactory factory for creating RunLockPort instances
|
||||
* @param validatorFactory factory for creating StartConfigurationValidator instances
|
||||
* @param schemaInitPortFactory factory for creating PersistenceSchemaInitializationPort instances
|
||||
* @param useCaseFactory factory for creating BatchRunProcessingUseCase instances
|
||||
* @param commandFactory factory for creating SchedulerBatchCommand instances
|
||||
* @param guiAdapterFactory factory for creating GuiAdapter instances
|
||||
*/
|
||||
public BootstrapRunner(MigrationStep migrationStep,
|
||||
ConfigurationPortFactory configPortFactory,
|
||||
RunLockPortFactory runLockPortFactory,
|
||||
ValidatorFactory validatorFactory,
|
||||
SchemaInitializationPortFactory schemaInitPortFactory,
|
||||
UseCaseFactory useCaseFactory,
|
||||
CommandFactory commandFactory,
|
||||
GuiAdapterFactory guiAdapterFactory) {
|
||||
this.migrationStep = migrationStep;
|
||||
this.configPortFactory = configPortFactory;
|
||||
this.runLockPortFactory = runLockPortFactory;
|
||||
@@ -337,35 +441,148 @@ public class BootstrapRunner {
|
||||
this.schemaInitPortFactory = schemaInitPortFactory;
|
||||
this.useCaseFactory = useCaseFactory;
|
||||
this.commandFactory = commandFactory;
|
||||
this.guiAdapterFactory = guiAdapterFactory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the complete application startup sequence.
|
||||
* Runs the complete application startup sequence for the given startup arguments.
|
||||
* <p>
|
||||
* Startup flow consists of two phases:
|
||||
* <ol>
|
||||
* <li><strong>Bootstrap Phase (hard failures only):</strong> Load and validate configuration,
|
||||
* then initialize the SQLite persistence schema.</li>
|
||||
* <li><strong>Execution Phase (document failures tolerated):</strong> Execute the batch processing pipeline
|
||||
* with all adapters and ports wired.</li>
|
||||
* </ol>
|
||||
* Dispatches to the GUI or headless batch adapter based on the startup mode carried
|
||||
* in {@code startupArguments}:
|
||||
* <ul>
|
||||
* <li>{@link StartupMode#GUI} — delegates to {@link #startGuiMode(Optional)}, which
|
||||
* validates the optional {@code --config} path and creates and starts the GUI inbound
|
||||
* adapter. When the supplied path does not exist on disk, a startup notice is passed
|
||||
* to the adapter and the GUI starts without a configuration override. Any exception
|
||||
* thrown by the adapter before or during startup is treated as a hard startup failure
|
||||
* (exit code 1). Normal adapter termination returns exit code 0.</li>
|
||||
* <li>{@link StartupMode#HEADLESS} — runs the two-phase headless batch pipeline via
|
||||
* {@link #runHeadlessBatch(StartupArguments)}. When a {@code --config} path override
|
||||
* is present but the file does not exist, the run is immediately aborted with exit
|
||||
* code 1 (hard startup failure). No silent fallback to the default path occurs.</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param startupArguments the parsed startup arguments containing the startup mode and
|
||||
* optional configuration path override; must not be {@code null}
|
||||
* @return exit code: 0 for successful completion, 1 for any hard bootstrap,
|
||||
* configuration, persistence, or GUI startup failure
|
||||
*/
|
||||
public int run(StartupArguments startupArguments) {
|
||||
Objects.requireNonNull(startupArguments, "startupArguments must not be null");
|
||||
LOG.info("Bootstrap flow started. Startup mode: {}", startupArguments.mode());
|
||||
return switch (startupArguments.mode()) {
|
||||
case GUI -> startGuiMode(startupArguments.configPath());
|
||||
case HEADLESS -> runHeadlessBatch(startupArguments);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the complete application startup sequence using the headless batch mode defaults.
|
||||
* <p>
|
||||
* A {@link DocumentPersistenceException} during schema initialization is treated as a hard startup
|
||||
* failure and causes exit code 1. Document-level failures during the batch loop are not startup
|
||||
* failures and do not change the exit code as long as the run itself completes without a hard
|
||||
* infrastructure error.
|
||||
* This convenience overload preserves backward compatibility for callers that do not
|
||||
* supply explicit startup arguments. It delegates to {@link #run(StartupArguments)}
|
||||
* with {@link StartupMode#HEADLESS} and no configuration path override, which
|
||||
* replicates the pre-GUI startup behaviour.
|
||||
*
|
||||
* @return exit code: 0 for a technically completed run, 1 for any hard bootstrap,
|
||||
* configuration, or persistence failure
|
||||
*/
|
||||
public int run() {
|
||||
LOG.info("Bootstrap flow started.");
|
||||
return run(new StartupArguments(StartupMode.HEADLESS, Optional.empty()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the GUI inbound adapter and waits for it to terminate.
|
||||
* <p>
|
||||
* When a {@code --config} path override is present in {@code configPathOverride}, the file
|
||||
* existence is checked before the adapter is started:
|
||||
* <ul>
|
||||
* <li>If the file <em>exists</em>, the path is confirmed at INFO level. The GUI starts
|
||||
* normally. The effective configuration path is available for later milestones that
|
||||
* implement configuration loading in the GUI.</li>
|
||||
* <li>If the file does <em>not exist</em>, the error is logged and a startup notice is
|
||||
* prepared and forwarded to the adapter. The GUI then starts without any configuration
|
||||
* override, behaving as if {@code --config} had not been specified.</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* Creates the GUI adapter via the configured factory and delegates to
|
||||
* {@link GuiAdapter#start(Optional)}. Any exception thrown by the adapter before or
|
||||
* during startup is treated as a hard GUI startup failure and mapped to exit code 1.
|
||||
* Normal termination (user closes the window) returns exit code 0.
|
||||
* <p>
|
||||
* The headless batch pipeline is not entered from this method. Configuration loading,
|
||||
* schema initialization, and all batch infrastructure are not initialized in the GUI path.
|
||||
*
|
||||
* @param configPathOverride the optional {@code --config} path string from startup arguments;
|
||||
* must not be {@code null}
|
||||
* @return exit code: 0 for normal GUI shutdown, 1 for any GUI startup failure
|
||||
*/
|
||||
private int startGuiMode(Optional<String> configPathOverride) {
|
||||
Optional<String> startupNotice = Optional.empty();
|
||||
|
||||
if (configPathOverride.isPresent()) {
|
||||
Path configPath = Paths.get(configPathOverride.get());
|
||||
if (Files.exists(configPath)) {
|
||||
LOG.info("GUI startup: configuration file confirmed at: {}", configPath.toAbsolutePath());
|
||||
} else {
|
||||
LOG.error("GUI startup: --config path not found: {}. Starting GUI without configuration override.",
|
||||
configPath.toAbsolutePath());
|
||||
startupNotice = Optional.of(
|
||||
"Konfigurationsdatei nicht gefunden: " + configPath.toAbsolutePath()
|
||||
+ "\nDie GUI startet ohne Konfigurationsdatei.");
|
||||
}
|
||||
}
|
||||
|
||||
LOG.info("GUI startup: launching GUI adapter.");
|
||||
try {
|
||||
// Bootstrap Phase: prepare configuration and persistence
|
||||
migrateConfigurationIfNeeded();
|
||||
StartConfiguration config = loadAndValidateConfiguration();
|
||||
GuiAdapter guiAdapter = guiAdapterFactory.create();
|
||||
guiAdapter.start(startupNotice);
|
||||
LOG.info("GUI adapter terminated normally.");
|
||||
return 0;
|
||||
} catch (Exception e) {
|
||||
LOG.error("GUI startup failed: {}", e.getMessage(), e);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the headless batch processing pipeline for the given startup arguments.
|
||||
* <p>
|
||||
* When a {@code --config} path override is present, the file existence is checked
|
||||
* immediately. If the file does not exist, the run is aborted with exit code 1.
|
||||
* No silent fallback to the default path occurs: a missing file at a supplied
|
||||
* {@code --config} path is always a hard startup failure in headless mode.
|
||||
* <p>
|
||||
* After confirming the path (or when no override is present), the effective path is
|
||||
* resolved and the two-phase batch bootstrap executes:
|
||||
* <ol>
|
||||
* <li><strong>Bootstrap Phase (hard failures only):</strong> Legacy migration, configuration
|
||||
* loading and validation, SQLite schema initialization.</li>
|
||||
* <li><strong>Execution Phase (document failures tolerated):</strong> Run lock acquisition,
|
||||
* use case wiring, batch execution, and outcome-to-exit-code mapping.</li>
|
||||
* </ol>
|
||||
* <p>
|
||||
* Any configuration loading, validation, or schema initialization failure is a hard startup
|
||||
* error and produces exit code 1. Document-level failures during the batch loop do not
|
||||
* affect the exit code.
|
||||
*
|
||||
* @param startupArguments the parsed startup arguments; must not be {@code null}
|
||||
* @return exit code: 0 for a technically completed run, 1 for any hard startup failure
|
||||
*/
|
||||
private int runHeadlessBatch(StartupArguments startupArguments) {
|
||||
try {
|
||||
if (startupArguments.configPath().isPresent()) {
|
||||
Path overridePath = Paths.get(startupArguments.configPath().get());
|
||||
if (!Files.exists(overridePath)) {
|
||||
LOG.error("Headless startup failed: configuration file not found at --config path: {}",
|
||||
overridePath.toAbsolutePath());
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
Path effectiveConfigPath = resolveEffectiveConfigPath(startupArguments.configPath());
|
||||
migrateConfigurationIfNeeded(effectiveConfigPath);
|
||||
StartConfiguration config = loadAndValidateConfiguration(effectiveConfigPath);
|
||||
initializeSchema(config);
|
||||
// Execution Phase: run batch processing
|
||||
return executeWithStartConfiguration(config);
|
||||
} catch (ConfigurationLoadingException e) {
|
||||
LOG.error("Configuration loading failed: {}", e.getMessage());
|
||||
@@ -383,7 +600,23 @@ public class BootstrapRunner {
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the legacy configuration migration step exactly once before configuration loading.
|
||||
* Resolves the effective configuration file path.
|
||||
* <p>
|
||||
* When a {@code --config} path override is present in {@code configPathOverride}, that
|
||||
* path is used. Otherwise the default path {@code config/application.properties} relative
|
||||
* to the working directory is applied.
|
||||
*
|
||||
* @param configPathOverride the optional {@code --config} path string from startup arguments
|
||||
* @return the resolved configuration file path; never {@code null}
|
||||
*/
|
||||
static Path resolveEffectiveConfigPath(Optional<String> configPathOverride) {
|
||||
return configPathOverride
|
||||
.map(Paths::get)
|
||||
.orElse(DEFAULT_CONFIG_PATH);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the legacy configuration migration step against the effective configuration path.
|
||||
* <p>
|
||||
* If the configuration file is in the legacy flat-key format, it is migrated in-place to the
|
||||
* multi-provider schema before the normal configuration loading path is entered. If the file
|
||||
@@ -391,29 +624,25 @@ public class BootstrapRunner {
|
||||
* <p>
|
||||
* A migration failure is a hard startup error and propagates as a
|
||||
* {@link ConfigurationLoadingException}.
|
||||
*
|
||||
* @param effectiveConfigPath the resolved configuration file path; never {@code null}
|
||||
*/
|
||||
private void migrateConfigurationIfNeeded() {
|
||||
migrationStep.runIfNeeded();
|
||||
private void migrateConfigurationIfNeeded(Path effectiveConfigPath) {
|
||||
migrationStep.runIfNeeded(effectiveConfigPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads configuration via {@link ConfigurationPort} and validates it via
|
||||
* {@link StartConfigurationValidator}.
|
||||
* <p>
|
||||
* Validation includes:
|
||||
* <ul>
|
||||
* <li>{@code source.folder}: must exist, be a directory, and be readable.</li>
|
||||
* <li>{@code target.folder}: must exist as a writable directory, or be technically
|
||||
* creatable (validator attempts {@code Files.createDirectories} if absent;
|
||||
* failure here is a hard startup error).</li>
|
||||
* <li>{@code sqlite.file}: parent directory must exist.</li>
|
||||
* <li>All numeric and path constraints.</li>
|
||||
* </ul>
|
||||
* Loads configuration from the effective configuration path and validates it.
|
||||
* <p>
|
||||
* After successful validation, the active AI provider identifier is logged at INFO level.
|
||||
*
|
||||
* @param effectiveConfigPath the resolved configuration file path; never {@code null}
|
||||
* @return the validated startup configuration; never {@code null}
|
||||
* @throws ConfigurationLoadingException if the file cannot be read or parsed
|
||||
* @throws InvalidStartConfigurationException if validation fails
|
||||
*/
|
||||
private StartConfiguration loadAndValidateConfiguration() {
|
||||
ConfigurationPort configPort = configPortFactory.create();
|
||||
private StartConfiguration loadAndValidateConfiguration(Path effectiveConfigPath) {
|
||||
ConfigurationPort configPort = configPortFactory.create(effectiveConfigPath);
|
||||
StartConfiguration config = configPort.loadConfiguration();
|
||||
validatorFactory.create().validate(config);
|
||||
LOG.info("Active AI provider: {}",
|
||||
@@ -424,6 +653,8 @@ public class BootstrapRunner {
|
||||
/**
|
||||
* Initialises the SQLite persistence schema once at startup, before the batch loop begins.
|
||||
* Failure here is a hard bootstrap error and results in exit code 1.
|
||||
*
|
||||
* @param config the validated startup configuration containing the SQLite file path
|
||||
*/
|
||||
private void initializeSchema(StartConfiguration config) {
|
||||
schemaInitPortFactory.create(buildJdbcUrl(config)).initializeSchema();
|
||||
@@ -434,13 +665,6 @@ public class BootstrapRunner {
|
||||
* <p>
|
||||
* Wires all runtime dependencies, constructs adapters and the batch use case via
|
||||
* the use case factory, invokes the CLI command, and maps the outcome to an exit code.
|
||||
* <p>
|
||||
* The use case factory is responsible for extracting the minimal runtime configuration
|
||||
* from the complete startup configuration. This separation ensures the application layer
|
||||
* depends only on the configuration it actually needs, following hexagonal architecture principles.
|
||||
* <p>
|
||||
* This represents the execution phase after startup configuration is validated
|
||||
* and persistence schema is initialized.
|
||||
*
|
||||
* @param config the validated startup configuration (complete technical configuration)
|
||||
* @return exit code: 0 for batch completion, 1 for critical runtime failures
|
||||
@@ -457,6 +681,9 @@ public class BootstrapRunner {
|
||||
|
||||
/**
|
||||
* Resolves the run-lock file path from the configuration, applying a default when not set.
|
||||
*
|
||||
* @param config the startup configuration
|
||||
* @return the resolved lock file path; never {@code null} and never blank
|
||||
*/
|
||||
private Path resolveLockFilePath(StartConfiguration config) {
|
||||
Path lockFilePath = config.runtimeLockFile();
|
||||
@@ -470,6 +697,8 @@ public class BootstrapRunner {
|
||||
|
||||
/**
|
||||
* Creates a new {@link BatchRunContext} with a fresh run ID and the current timestamp.
|
||||
*
|
||||
* @return a new batch run context; never {@code null}
|
||||
*/
|
||||
private BatchRunContext createRunContext() {
|
||||
RunId runId = new RunId(UUID.randomUUID().toString());
|
||||
@@ -480,6 +709,8 @@ public class BootstrapRunner {
|
||||
/**
|
||||
* Maps a {@link BatchRunOutcome} to a process exit code and logs the run result.
|
||||
*
|
||||
* @param outcome the outcome of the batch run; never {@code null}
|
||||
* @param runContext the batch run context used for logging; never {@code null}
|
||||
* @return 0 if the batch run completed successfully; 1 otherwise
|
||||
*/
|
||||
private int mapOutcomeToExitCode(BatchRunOutcome outcome, BatchRunContext runContext) {
|
||||
|
||||
+40
-5
@@ -3,11 +3,28 @@ package de.gecheckt.pdf.umbenenner.bootstrap;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.bootstrap.startup.CliArgumentParser;
|
||||
import de.gecheckt.pdf.umbenenner.bootstrap.startup.StartupArguments;
|
||||
import de.gecheckt.pdf.umbenenner.bootstrap.startup.StartupArgumentsParseResult;
|
||||
|
||||
/**
|
||||
* Main entry point for the PDF Umbenenner application.
|
||||
* <p>
|
||||
* Delegates to {@link BootstrapRunner} for manual object graph construction
|
||||
* and execution of the startup sequence.
|
||||
* Responsible for:
|
||||
* <ol>
|
||||
* <li>Parsing raw command-line arguments via {@link CliArgumentParser}.</li>
|
||||
* <li>Aborting with exit code 1 when the arguments are invalid or unrecognised.</li>
|
||||
* <li>Delegating to {@link BootstrapRunner} for startup mode selection,
|
||||
* object graph construction, and execution.</li>
|
||||
* </ol>
|
||||
* <p>
|
||||
* Supported CLI options:
|
||||
* <ul>
|
||||
* <li>(none) — GUI startup mode (default).</li>
|
||||
* <li>{@code --headless} — Headless batch/scheduler startup mode.</li>
|
||||
* <li>{@code --config <path>} — Override for the configuration file path,
|
||||
* applicable to both startup modes.</li>
|
||||
* </ul>
|
||||
*/
|
||||
public class PdfUmbenennerApplication {
|
||||
|
||||
@@ -15,14 +32,32 @@ public class PdfUmbenennerApplication {
|
||||
|
||||
/**
|
||||
* Application entry point.
|
||||
* <p>
|
||||
* Parses the command-line arguments and delegates to {@link BootstrapRunner}.
|
||||
* If the arguments cannot be parsed, an error is logged and the process exits
|
||||
* with code 1 before any further initialisation takes place.
|
||||
*
|
||||
* @param args command line arguments (currently unused)
|
||||
* @param args command-line arguments; see class JavaDoc for supported options
|
||||
*/
|
||||
public static void main(String[] args) {
|
||||
LOG.info("Starting PDF Umbenenner application...");
|
||||
try {
|
||||
StartupArgumentsParseResult parseResult = new CliArgumentParser().parse(args);
|
||||
|
||||
if (parseResult instanceof StartupArgumentsParseResult.Invalid invalid) {
|
||||
LOG.error("Invalid command-line arguments: {}", invalid.errorMessage());
|
||||
System.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
StartupArguments startupArguments =
|
||||
((StartupArgumentsParseResult.Valid) parseResult).arguments();
|
||||
LOG.info("Startup mode: {}", startupArguments.mode());
|
||||
startupArguments.configPath().ifPresent(
|
||||
p -> LOG.info("Configuration path override: {}", p));
|
||||
|
||||
BootstrapRunner runner = new BootstrapRunner();
|
||||
int exitCode = runner.run();
|
||||
int exitCode = runner.run(startupArguments);
|
||||
if (exitCode == 0) {
|
||||
LOG.info("PDF Umbenenner application completed successfully.");
|
||||
} else {
|
||||
@@ -34,4 +69,4 @@ public class PdfUmbenennerApplication {
|
||||
System.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+46
-28
@@ -1,50 +1,68 @@
|
||||
/**
|
||||
* Bootstrap module for application startup and technical object graph construction.
|
||||
* Bootstrap module for application startup, startup mode dispatch, and technical object
|
||||
* graph construction.
|
||||
* <p>
|
||||
* Responsibility: Orchestrate the complete startup sequence in two phases: (1) bootstrap phase
|
||||
* for configuration loading, validation, and schema initialization, and (2) execution phase
|
||||
* for wiring all adapters and running the batch processing pipeline.
|
||||
* Responsibility: Parse the startup mode from command-line arguments and dispatch to the
|
||||
* appropriate inbound adapter. Exactly one inbound adapter is started per process run.
|
||||
* For the headless batch path, the bootstrap module also orchestrates configuration loading,
|
||||
* schema initialization, and the full adapter wiring.
|
||||
* <p>
|
||||
* Components:
|
||||
* <ul>
|
||||
* <li>{@link de.gecheckt.pdf.umbenenner.bootstrap.BootstrapRunner}
|
||||
* — Orchestrator of startup sequence, schema initialization, and object graph construction</li>
|
||||
* — Orchestrator of startup mode dispatch, configuration loading, schema initialization,
|
||||
* and object graph construction</li>
|
||||
* <li>{@link de.gecheckt.pdf.umbenenner.bootstrap.PdfUmbenennerApplication}
|
||||
* — Application entry point that invokes BootstrapRunner</li>
|
||||
* — Application entry point that parses CLI arguments and invokes BootstrapRunner</li>
|
||||
* <li>{@link de.gecheckt.pdf.umbenenner.bootstrap.adapter.Log4jProcessingLogger}
|
||||
* — Logging adapter for application-layer coordination and use case processing</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* Implementation approach:
|
||||
* Startup mode dispatch:
|
||||
* <ul>
|
||||
* <li>Uses factory pattern with pluggable interfaces for configuration, run lock, schema initialization, use case, and command creation</li>
|
||||
* <li>Manually constructs the object graph without framework dependencies</li>
|
||||
* <li>Ensures strict inward dependency direction: all adapters depend on ports, never the other way around</li>
|
||||
* <li>Separates startup configuration (complete technical parameters for bootstrap and adapter wiring) from
|
||||
* runtime configuration (minimal parameters the application layer actually depends on)</li>
|
||||
* <li>Schema initialization happens exactly once at startup, before document processing begins</li>
|
||||
* <li><strong>GUI mode</strong> (default): Bootstrap creates and starts the
|
||||
* {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiAdapter}. The GUI adapter manages
|
||||
* the JavaFX lifecycle. Bootstrap returns exit code 0 on normal GUI shutdown and exit
|
||||
* code 1 on any exception thrown by the GUI adapter.</li>
|
||||
* <li><strong>Headless mode</strong> ({@code --headless}): Bootstrap runs the two-phase
|
||||
* headless batch pipeline described below.</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* Startup sequence:
|
||||
* Configuration path resolution:
|
||||
* <ul>
|
||||
* <li>Load and validate complete startup configuration from properties file and environment variables</li>
|
||||
* <li>Validate target folder availability and write access; create target folder if absent
|
||||
* (failure is a hard startup error)</li>
|
||||
* <li>Initialize SQLite persistence schema (including target-copy schema evolution) via
|
||||
* {@link de.gecheckt.pdf.umbenenner.application.port.out.PersistenceSchemaInitializationPort},
|
||||
* ensuring the database is ready before any batch processing</li>
|
||||
* <li>Schema initialization failure is treated as a hard bootstrap error and causes exit code 1</li>
|
||||
* <li>Create run lock adapter and acquire exclusive lock</li>
|
||||
* <li>Wire all outbound adapters (document candidates, PDF extraction, fingerprint, persistence,
|
||||
* target folder duplicate resolution, target file copy, logging)</li>
|
||||
* <li>Wire and invoke the batch processing CLI adapter</li>
|
||||
* <li>Map batch outcome to process exit code</li>
|
||||
* <li>When {@code --config <path>} is supplied, that path is used as the effective
|
||||
* configuration file path for both the legacy migration step and the configuration port.</li>
|
||||
* <li>When {@code --config} is absent, the default path {@code config/application.properties}
|
||||
* relative to the working directory is applied.</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* Headless batch startup sequence (two phases):
|
||||
* <ul>
|
||||
* <li><em>Bootstrap phase</em> — configuration loading and validation, SQLite schema
|
||||
* initialization. Failures here are hard startup errors (exit code 1).</li>
|
||||
* <li><em>Execution phase</em> — run lock acquisition, adapter wiring, batch use case
|
||||
* execution, outcome-to-exit-code mapping. Document-level failures do not affect the
|
||||
* exit code.</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* Implementation approach:
|
||||
* <ul>
|
||||
* <li>Uses factory pattern with pluggable interfaces for GUI adapter, configuration, run lock,
|
||||
* schema initialization, use case, and command creation — enabling full unit testability</li>
|
||||
* <li>Manually constructs the object graph without framework dependencies</li>
|
||||
* <li>Ensures strict inward dependency direction: all adapters depend on ports, never the
|
||||
* other way around</li>
|
||||
* <li>Separates startup configuration (complete technical parameters for bootstrap and adapter
|
||||
* wiring) from runtime configuration (minimal parameters the application layer depends on)</li>
|
||||
* <li>Schema initialization happens exactly once at startup, before document processing begins</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* Exit codes:
|
||||
* <ul>
|
||||
* <li>0 = batch run completed technically successfully (even if individual documents failed)</li>
|
||||
* <li>1 = hard bootstrap, configuration, schema initialization, or critical infrastructure failure</li>
|
||||
* <li>0 = headless batch run completed technically successfully (even if individual documents
|
||||
* failed), or GUI terminated normally after the window was shown</li>
|
||||
* <li>1 = hard start, bootstrap, configuration, schema initialization, GUI startup, or critical
|
||||
* infrastructure failure</li>
|
||||
* </ul>
|
||||
*/
|
||||
package de.gecheckt.pdf.umbenenner.bootstrap;
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
package de.gecheckt.pdf.umbenenner.bootstrap.startup;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Parses the raw {@code String[]} arguments passed to {@code main(String[])}
|
||||
* into a structured {@link StartupArgumentsParseResult}.
|
||||
* <p>
|
||||
* Supported options:
|
||||
* <ul>
|
||||
* <li>(none) — GUI startup mode is the default.</li>
|
||||
* <li>{@code --headless} — Activates headless batch/scheduler startup mode.</li>
|
||||
* <li>{@code --config <path>} — Overrides the configuration file path for
|
||||
* either startup mode. The path token immediately follows the option token.</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* Options may appear in any order. Each option must appear at most once.
|
||||
* <p>
|
||||
* Invalid usages produce a {@link StartupArgumentsParseResult.Invalid} result:
|
||||
* <ul>
|
||||
* <li>{@code --config} without a following path value.</li>
|
||||
* <li>{@code --config} followed by another option token (starting with {@code --})
|
||||
* instead of a path value.</li>
|
||||
* <li>Duplicate occurrence of {@code --headless} or {@code --config}.</li>
|
||||
* <li>Any unrecognised argument token.</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* This class is stateless and safe for concurrent use once instantiated.
|
||||
*/
|
||||
public class CliArgumentParser {
|
||||
|
||||
private static final String OPTION_HEADLESS = "--headless";
|
||||
private static final String OPTION_CONFIG = "--config";
|
||||
|
||||
/**
|
||||
* Creates a new {@code CliArgumentParser}.
|
||||
*/
|
||||
public CliArgumentParser() {
|
||||
// No state to initialise
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the given command-line arguments and returns a structured result.
|
||||
* <p>
|
||||
* Returns {@link StartupArgumentsParseResult.Valid} when the arguments are
|
||||
* syntactically correct. Returns {@link StartupArgumentsParseResult.Invalid}
|
||||
* when any argument is unrecognised, duplicated, or when {@code --config}
|
||||
* is missing its required path value.
|
||||
* <p>
|
||||
* The concrete treatment of the configuration path (e.g. whether the file
|
||||
* exists) is outside the scope of this parser and is handled downstream
|
||||
* by Bootstrap.
|
||||
*
|
||||
* @param args the raw command-line arguments; must not be {@code null}
|
||||
* @return the parse result; never {@code null}
|
||||
* @throws NullPointerException if {@code args} is {@code null}
|
||||
*/
|
||||
public StartupArgumentsParseResult parse(String[] args) {
|
||||
java.util.Objects.requireNonNull(args, "args must not be null");
|
||||
|
||||
StartupMode mode = StartupMode.GUI;
|
||||
Optional<String> configPath = Optional.empty();
|
||||
boolean headlessSeen = false;
|
||||
boolean configSeen = false;
|
||||
|
||||
int i = 0;
|
||||
while (i < args.length) {
|
||||
String token = args[i];
|
||||
|
||||
switch (token) {
|
||||
case OPTION_HEADLESS -> {
|
||||
if (headlessSeen) {
|
||||
return new StartupArgumentsParseResult.Invalid(
|
||||
"Duplicate option: " + OPTION_HEADLESS);
|
||||
}
|
||||
headlessSeen = true;
|
||||
mode = StartupMode.HEADLESS;
|
||||
i++;
|
||||
}
|
||||
case OPTION_CONFIG -> {
|
||||
if (configSeen) {
|
||||
return new StartupArgumentsParseResult.Invalid(
|
||||
"Duplicate option: " + OPTION_CONFIG);
|
||||
}
|
||||
if (i + 1 >= args.length) {
|
||||
return new StartupArgumentsParseResult.Invalid(
|
||||
"Option " + OPTION_CONFIG + " requires a path argument but none was provided");
|
||||
}
|
||||
String pathToken = args[i + 1];
|
||||
if (pathToken.startsWith("--")) {
|
||||
return new StartupArgumentsParseResult.Invalid(
|
||||
"Option " + OPTION_CONFIG + " requires a path argument, but got option: " + pathToken);
|
||||
}
|
||||
if (pathToken.isBlank()) {
|
||||
return new StartupArgumentsParseResult.Invalid(
|
||||
"Option " + OPTION_CONFIG + " requires a non-blank path argument");
|
||||
}
|
||||
configSeen = true;
|
||||
configPath = Optional.of(pathToken);
|
||||
i += 2;
|
||||
}
|
||||
default -> {
|
||||
return new StartupArgumentsParseResult.Invalid(
|
||||
"Unknown argument: " + token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new StartupArgumentsParseResult.Valid(new StartupArguments(mode, configPath));
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package de.gecheckt.pdf.umbenenner.bootstrap.startup;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Immutable value object carrying the successfully parsed CLI startup arguments.
|
||||
* <p>
|
||||
* Produced by {@link CliArgumentParser} when command-line arguments are syntactically
|
||||
* valid. Bootstrap uses this record to determine the startup mode (GUI or headless)
|
||||
* and whether a custom configuration file path was supplied via {@code --config <path>}.
|
||||
* <p>
|
||||
* A {@code StartupArguments} instance is always consistent: it is only created when
|
||||
* parsing succeeded. Parse failures are represented by
|
||||
* {@link StartupArgumentsParseResult.Invalid} instead.
|
||||
*
|
||||
* @param mode the determined startup mode; never {@code null}
|
||||
* @param configPath the configuration file path supplied via {@code --config <path>},
|
||||
* or empty if the option was absent from the command line
|
||||
*/
|
||||
public record StartupArguments(StartupMode mode, Optional<String> configPath) {
|
||||
|
||||
/**
|
||||
* Compact canonical constructor enforcing non-null invariants.
|
||||
*
|
||||
* @param mode the startup mode; must not be {@code null}
|
||||
* @param configPath the optional configuration path; must not be {@code null}
|
||||
* @throws NullPointerException if {@code mode} or {@code configPath} is {@code null}
|
||||
*/
|
||||
public StartupArguments {
|
||||
Objects.requireNonNull(mode, "mode must not be null");
|
||||
Objects.requireNonNull(configPath, "configPath must not be null");
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package de.gecheckt.pdf.umbenenner.bootstrap.startup;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Sealed result type returned by {@link CliArgumentParser#parse(String[])}.
|
||||
* <p>
|
||||
* Bootstrap evaluates the parse result to decide the next action:
|
||||
* <ul>
|
||||
* <li>{@link Valid} — proceed with the contained {@link StartupArguments}.</li>
|
||||
* <li>{@link Invalid} — abort with a hard startup error (exit code 1);
|
||||
* the error message describes the specific CLI misuse to be logged.</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* The sealed hierarchy ensures that every caller must handle both cases
|
||||
* explicitly, preventing silent omission of error handling.
|
||||
*/
|
||||
public sealed interface StartupArgumentsParseResult {
|
||||
|
||||
/**
|
||||
* Represents a successfully parsed command line.
|
||||
* <p>
|
||||
* Bootstrap may proceed normally with the contained {@link StartupArguments}.
|
||||
*
|
||||
* @param arguments the parsed startup arguments; never {@code null}
|
||||
*/
|
||||
record Valid(StartupArguments arguments) implements StartupArgumentsParseResult {
|
||||
|
||||
/**
|
||||
* Creates a valid parse result.
|
||||
*
|
||||
* @param arguments the parsed startup arguments; must not be {@code null}
|
||||
* @throws NullPointerException if {@code arguments} is {@code null}
|
||||
*/
|
||||
public Valid {
|
||||
Objects.requireNonNull(arguments, "arguments must not be null");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a failed parse due to invalid or unsupported CLI usage.
|
||||
* <p>
|
||||
* Bootstrap must treat this as a hard startup error: log the error message
|
||||
* and terminate with exit code 1. The error message is intended for the
|
||||
* operator and should be concise and actionable.
|
||||
*
|
||||
* @param errorMessage a human-readable description of the parse failure;
|
||||
* never {@code null}
|
||||
*/
|
||||
record Invalid(String errorMessage) implements StartupArgumentsParseResult {
|
||||
|
||||
/**
|
||||
* Creates an invalid parse result.
|
||||
*
|
||||
* @param errorMessage the error description; must not be {@code null}
|
||||
* @throws NullPointerException if {@code errorMessage} is {@code null}
|
||||
*/
|
||||
public Invalid {
|
||||
Objects.requireNonNull(errorMessage, "errorMessage must not be null");
|
||||
}
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package de.gecheckt.pdf.umbenenner.bootstrap.startup;
|
||||
|
||||
/**
|
||||
* Enumerates the supported application startup modes.
|
||||
* <p>
|
||||
* The startup mode is determined from the raw CLI arguments by
|
||||
* {@link CliArgumentParser} and carried in {@link StartupArguments}.
|
||||
* Bootstrap uses the mode to select the appropriate inbound adapter
|
||||
* for the current process.
|
||||
* <p>
|
||||
* Exactly one mode is active per process run. The default mode when
|
||||
* no explicit option is present is {@link #GUI}.
|
||||
*/
|
||||
public enum StartupMode {
|
||||
|
||||
/**
|
||||
* GUI startup mode.
|
||||
* <p>
|
||||
* Selected when the {@code --headless} option is absent from the command line.
|
||||
* Directs Bootstrap to start the JavaFX desktop adapter. This is the
|
||||
* default startup mode for interactive use.
|
||||
*/
|
||||
GUI,
|
||||
|
||||
/**
|
||||
* Headless batch/scheduler startup mode.
|
||||
* <p>
|
||||
* Activated via the {@code --headless} CLI option. Directs Bootstrap to
|
||||
* start the existing batch-processing CLI adapter without any GUI
|
||||
* initialization. Suitable for automated execution via a task scheduler.
|
||||
*/
|
||||
HEADLESS
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Startup argument model and command-line parsing for the application entry point.
|
||||
* <p>
|
||||
* Responsibility: Represent, parse, and validate the CLI arguments passed to
|
||||
* {@code main(String[])} so that Bootstrap can derive the startup mode and
|
||||
* configuration path without any further raw string handling.
|
||||
* <p>
|
||||
* Components:
|
||||
* <ul>
|
||||
* <li>{@link de.gecheckt.pdf.umbenenner.bootstrap.startup.StartupMode}
|
||||
* — Enumerates the two supported startup modes (GUI default, headless batch).</li>
|
||||
* <li>{@link de.gecheckt.pdf.umbenenner.bootstrap.startup.StartupArguments}
|
||||
* — Immutable value object carrying the successfully parsed startup mode
|
||||
* and optional configuration file path.</li>
|
||||
* <li>{@link de.gecheckt.pdf.umbenenner.bootstrap.startup.StartupArgumentsParseResult}
|
||||
* — Sealed result type produced by the parser; either
|
||||
* {@link de.gecheckt.pdf.umbenenner.bootstrap.startup.StartupArgumentsParseResult.Valid Valid}
|
||||
* or
|
||||
* {@link de.gecheckt.pdf.umbenenner.bootstrap.startup.StartupArgumentsParseResult.Invalid Invalid}.</li>
|
||||
* <li>{@link de.gecheckt.pdf.umbenenner.bootstrap.startup.CliArgumentParser}
|
||||
* — Parses a raw {@code String[]} and returns a {@code StartupArgumentsParseResult}.</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* Supported CLI options:
|
||||
* <ul>
|
||||
* <li>(none) — GUI startup is the default when no options are present.</li>
|
||||
* <li>{@code --headless} — Activates the headless batch/scheduler startup mode.</li>
|
||||
* <li>{@code --config <path>} — Overrides the configuration file path for either startup mode.</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* Invalid usages (result in {@code Invalid} parse result):
|
||||
* <ul>
|
||||
* <li>{@code --config} without a following path value.</li>
|
||||
* <li>{@code --config} followed by another option token instead of a path.</li>
|
||||
* <li>Duplicate occurrence of {@code --headless} or {@code --config}.</li>
|
||||
* <li>Any unrecognised argument token.</li>
|
||||
* </ul>
|
||||
*/
|
||||
package de.gecheckt.pdf.umbenenner.bootstrap.startup;
|
||||
Reference in New Issue
Block a user