diff --git a/pdf-umbenenner-adapter-in-gui/pom.xml b/pdf-umbenenner-adapter-in-gui/pom.xml
new file mode 100644
index 0000000..179440e
--- /dev/null
+++ b/pdf-umbenenner-adapter-in-gui/pom.xml
@@ -0,0 +1,180 @@
+
+
+ * This class is the designated entry point through which Bootstrap launches the + * graphical user interface. It acts as the inbound adapter boundary: it receives + * control from Bootstrap and delegates to the JavaFX application lifecycle via + * {@link PdfUmbenennerGuiApplication}. + *
+ * Responsibilities of this adapter: + *
+ * This class must not be instantiated or called by any module other than Bootstrap. + * Domain, application, CLI adapter, and outbound adapter modules must remain + * free of dependencies on this class and on JavaFX in general. + *
+ * The actual JavaFX {@link Application} subclass ({@link PdfUmbenennerGuiApplication}) + * and all GUI view components are separate classes within this package. This entry + * point class coordinates the hand-off from the Bootstrap layer into the JavaFX lifecycle. + * + *
+ * The adapter launches a minimal GUI shell that proves the GUI startup path works. + * It does not provide a configuration editor, file operations, validation, provider + * controls, or any other functionality beyond the technical startup proof. + */ +public class GuiAdapter { + + private static final Logger LOG = LogManager.getLogger(GuiAdapter.class); + + /** + * Creates a new {@code GuiAdapter} instance. + *
+ * Bootstrap is responsible for constructing this adapter and invoking + * {@link #start()} at the appropriate point in the application lifecycle. + * No JavaFX initialization is performed during construction; the JavaFX + * runtime is only started when {@link #start()} is called. + */ + public GuiAdapter() { + // Bootstrap constructs this adapter before deciding to start the GUI. + // JavaFX initialization is deferred to start() to ensure the headless + // path is never burdened with premature JavaFX class loading. + } + + /** + * Starts the JavaFX GUI and blocks until the user closes the window. + *
+ * When {@code startupNotice} is present, the notice string is forwarded to + * {@link PdfUmbenennerGuiApplication} so it can be displayed to the user on startup. + * This is used when Bootstrap has detected a problem with the supplied + * {@code --config} path (e.g. the file was not found) and wishes to inform the + * user before the normal GUI shell is shown. + *
+ * This method delegates to {@link Application#launch(Class, String...)} with + * {@link PdfUmbenennerGuiApplication} as the application class. The call blocks + * until the JavaFX application terminates (typically when the user closes the + * main window). + *
+ * This method must be called from a thread that is permitted to run a JavaFX + * application (typically the main thread). It must not be called on the JavaFX + * Application Thread itself, and must not be called more than once per JVM process. + *
+ * Upon normal GUI shutdown (user closes the window), this method returns
+ * normally. Bootstrap is responsible for deriving the appropriate exit code
+ * from the return.
+ *
+ * @param startupNotice an optional message to display to the user in the GUI on startup;
+ * when empty, no notice is shown; must not be {@code null}
+ * @throws IllegalStateException if the JavaFX runtime cannot be initialised
+ * or if the platform is not supported
+ */
+ public void start(Optional
+ * This class is the JavaFX lifecycle entry point launched by
+ * {@link GuiAdapter#start(java.util.Optional)}. It creates the primary stage with a
+ * minimal, neutral layout that proves the GUI startup path is technically functional.
+ * The layout is deliberately structured as a {@link BorderPane} so that later milestones
+ * can populate individual regions (header, center content, bottom message area) without
+ * requiring an architectural restructuring.
+ *
+ *
+ * When Bootstrap forwards a startup notice (e.g. because the supplied {@code --config}
+ * path was not found), the notice is passed as an application parameter prefixed with
+ * {@link #STARTUP_NOTICE_ARG_PREFIX}. This class reads the parameter via
+ * {@link Application#getParameters()} and displays it prominently in the center region
+ * instead of the default placeholder.
+ *
+ *
+ * The shell displays the application window with a neutral title and either a static
+ * placeholder label or a startup notice. It contains no configuration editor, no file
+ * operations, no validation, no provider controls, and no message area. These are the
+ * responsibility of later milestones.
+ *
+ *
+ * The {@link #start(Stage)} method is called by the JavaFX runtime on the
+ * JavaFX Application Thread. No blocking operations are performed during
+ * stage setup.
+ */
+public class PdfUmbenennerGuiApplication extends Application {
+
+ private static final Logger LOG = LogManager.getLogger(PdfUmbenennerGuiApplication.class);
+
+ /**
+ * Argument prefix used to forward a startup notice from {@link GuiAdapter} to this
+ * application via {@link Application#launch(Class, String[])}.
+ *
+ * When an argument beginning with this prefix is present in the raw parameter list,
+ * the remainder of the argument string is treated as the notice text to display.
+ */
+ static final String STARTUP_NOTICE_ARG_PREFIX = "--startup-notice=";
+
+ private static final String WINDOW_TITLE = "PDF-Umbenenner";
+ private static final double DEFAULT_WIDTH = 800;
+ private static final double DEFAULT_HEIGHT = 600;
+
+ /**
+ * Creates a new instance of the JavaFX application.
+ *
+ * This no-argument constructor is required by the JavaFX runtime, which
+ * instantiates the {@link Application} subclass reflectively.
+ */
+ public PdfUmbenennerGuiApplication() {
+ // Required by JavaFX runtime for reflective instantiation.
+ }
+
+ /**
+ * Initializes and shows the primary stage with a minimal GUI shell.
+ *
+ * The stage is set up with a {@link BorderPane} root layout. When a startup notice is
+ * present (forwarded via application parameters by {@link GuiAdapter}), the notice is
+ * displayed prominently in red in the center region. Otherwise a neutral placeholder
+ * label is shown. The layout structure is chosen to allow incremental extension in later
+ * milestones without requiring a root-level restructuring.
+ *
+ * Start and shutdown events are logged via Log4j2 to satisfy the GUI logging requirements.
+ *
+ * @param primaryStage the primary stage provided by the JavaFX runtime; never {@code null}
+ */
+ @Override
+ public void start(Stage primaryStage) {
+ LOG.info("GUI-Shell: JavaFX-Oberfläche wird initialisiert.");
+
+ BorderPane root = new BorderPane();
+
+ Optional
+ * Searches the raw parameter list for an argument beginning with
+ * {@link #STARTUP_NOTICE_ARG_PREFIX} and returns the remainder as the notice text.
+ * Returns an empty Optional when no such argument is present.
+ *
+ * @return the startup notice text, or an empty Optional if no notice was forwarded
+ */
+ private Optional
+ * Logs the GUI shutdown event. No additional cleanup is required
+ * for the minimal shell.
+ */
+ @Override
+ public void stop() {
+ LOG.info("GUI-Shell: JavaFX-Anwendung wird beendet.");
+ }
+}
diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/package-info.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/package-info.java
new file mode 100644
index 0000000..d1f0353
--- /dev/null
+++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/package-info.java
@@ -0,0 +1,24 @@
+/**
+ * Inbound adapter for the JavaFX desktop GUI.
+ *
+ * This package contains the technical entry point and supporting classes that implement
+ * the graphical user interface as an inbound adapter. The GUI adapter depends exclusively
+ * on the application layer's inbound port contracts and domain types; it introduces no
+ * dependencies into domain, application, CLI adapter, or outbound adapter modules.
+ *
+ * Architectural position:
+ *
+ * Threading contract: every potentially blocking operation (file I/O, network, database access)
+ * must run on a background worker thread. All UI updates must be dispatched via
+ * {@code Platform.runLater} on the JavaFX Application Thread.
+ *
+ * Bootstrap wires the concrete use case implementations into this adapter via constructor
+ * injection; the adapter itself holds no knowledge of concrete implementations.
+ */
+package de.gecheckt.pdf.umbenenner.adapter.in.gui;
diff --git a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiAdapterSmokeTest.java b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiAdapterSmokeTest.java
new file mode 100644
index 0000000..b3a045f
--- /dev/null
+++ b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiAdapterSmokeTest.java
@@ -0,0 +1,245 @@
+package de.gecheckt.pdf.umbenenner.adapter.in.gui;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.Optional;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+
+import javafx.application.Platform;
+import javafx.scene.control.Label;
+
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.MethodOrderer;
+import org.junit.jupiter.api.Order;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestMethodOrder;
+
+/**
+ * Monocle-based headless smoke tests for the GUI adapter module.
+ *
+ * These tests verify that the JavaFX platform can be initialized and operated
+ * under headless conditions using the Monocle headless Glass implementation.
+ * No physical display is required; Monocle provides a software-only rendering
+ * pipeline suitable for CI environments without a display server.
+ *
+ *
+ * This class covers the headless GUI startup path defined for the GUI infrastructure:
+ *
+ * {@link javafx.application.Application#launch} is a blocking, single-use JVM call.
+ * The full application lifecycle (launch → start → Stage.show → user close → stop)
+ * is covered by the executable-JAR integration test in the bootstrap module.
+ * This smoke test covers the platform and node layer only.
+ *
+ *
+ * The FX Application Thread is started once per test class via {@link Platform#startup}.
+ * Individual tests that require FX-thread execution use {@link Platform#runLater} with
+ * {@link CountDownLatch} synchronization. {@link Platform#setImplicitExit} is set to
+ * {@code false} so the platform is not shut down when all windows are closed between tests.
+ *
+ *
+ * Monocle is activated via the {@code glass.platform} and {@code monocle.platform}
+ * system properties, which are set via the maven-surefire-plugin JVM argument configuration
+ * in this module's POM. No programmatic property setting is required in the test code.
+ */
+@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
+class GuiAdapterSmokeTest {
+
+ private static final long FX_TIMEOUT_SECONDS = 10;
+
+ private static final AtomicBoolean PLATFORM_STARTED = new AtomicBoolean(false);
+
+ /**
+ * Initializes the JavaFX platform once for all tests in this class using
+ * {@link Platform#startup}. Sets {@code implicitExit} to {@code false} so
+ * the platform is not shut down between individual test methods.
+ *
+ * @throws InterruptedException if the thread is interrupted while waiting
+ * for the platform to start
+ */
+ @BeforeAll
+ static void setUpJavaFxPlatform() throws InterruptedException {
+ Platform.setImplicitExit(false);
+ CountDownLatch startLatch = new CountDownLatch(1);
+ Platform.startup(() -> {
+ PLATFORM_STARTED.set(true);
+ startLatch.countDown();
+ });
+ assertTrue(
+ startLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
+ "JavaFX Platform must start within " + FX_TIMEOUT_SECONDS + " seconds under Monocle headless");
+ }
+
+ /**
+ * Shuts down the JavaFX platform after all tests in this class have run.
+ */
+ @AfterAll
+ static void tearDownJavaFxPlatform() {
+ Platform.exit();
+ }
+
+ // =========================================================================
+ // Platform initialization
+ // =========================================================================
+
+ /**
+ * Verifies that the JavaFX Platform starts successfully under the Monocle
+ * headless configuration. This is the fundamental precondition for all other
+ * GUI smoke tests.
+ */
+ @Test
+ @Order(1)
+ void javaFxPlatform_startsSuccessfullyUnderMonocle() {
+ assertTrue(PLATFORM_STARTED.get(),
+ "JavaFX Platform must have started successfully under Monocle headless");
+ }
+
+ // =========================================================================
+ // Node creation on FX thread
+ // =========================================================================
+
+ /**
+ * Verifies that JavaFX nodes can be created and operated on the FX Application
+ * Thread when the platform is running under Monocle. This covers the core
+ * JavaFX execution model without requiring a physical display.
+ *
+ * @throws Exception if the FX thread task fails or times out
+ */
+ @Test
+ @Order(2)
+ void javaFxNodes_canBeCreatedOnFxThread() throws Exception {
+ CountDownLatch latch = new CountDownLatch(1);
+ AtomicBoolean nodeCreated = new AtomicBoolean(false);
+ AtomicReference
+ * Note: This test verifies the conditional branching logic in {@link GuiAdapter#start}
+ * at the argument-building level only. The actual {@code Application.launch()} call
+ * would block and is therefore not executed here. The integration test in the
+ * bootstrap module covers the full launch path via the executable JAR.
+ */
+ @Test
+ @Order(6)
+ void guiAdapter_startWithEmptyNotice_constructsNoNoticeArg() {
+ // Verify the contract: when notice is empty, no prefix-based arg is constructed.
+ Optional
+ * These tests verify the structural contract of the GUI adapter entry point:
+ * that it can be constructed without errors and without triggering premature
+ * JavaFX initialization.
+ *
+ * The actual {@link GuiAdapter#start()} method delegates to
+ * {@link javafx.application.Application#launch} which requires a JavaFX runtime.
+ * Full GUI startup is tested separately via headless Monocle smoke tests.
+ */
+class GuiAdapterTest {
+
+ @Test
+ void constructorCreatesInstanceWithoutError() {
+ GuiAdapter adapter = new GuiAdapter();
+ assertNotNull(adapter);
+ }
+}
diff --git a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/PdfUmbenennerGuiApplicationTest.java b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/PdfUmbenennerGuiApplicationTest.java
new file mode 100644
index 0000000..bc14694
--- /dev/null
+++ b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/PdfUmbenennerGuiApplicationTest.java
@@ -0,0 +1,22 @@
+package de.gecheckt.pdf.umbenenner.adapter.in.gui;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+/**
+ * Unit tests for {@link PdfUmbenennerGuiApplication}.
+ *
+ * These tests verify that the JavaFX application class can be instantiated
+ * without triggering the JavaFX runtime. The actual GUI startup (stage
+ * creation and rendering) is tested via headless Monocle smoke tests
+ * in a later work package.
+ */
+class PdfUmbenennerGuiApplicationTest {
+
+ @Test
+ void constructorCreatesInstanceWithoutError() {
+ PdfUmbenennerGuiApplication app = new PdfUmbenennerGuiApplication();
+ assertNotNull(app);
+ }
+}
diff --git a/pdf-umbenenner-bootstrap/pom.xml b/pdf-umbenenner-bootstrap/pom.xml
index b1813b3..eb787e0 100644
--- a/pdf-umbenenner-bootstrap/pom.xml
+++ b/pdf-umbenenner-bootstrap/pom.xml
@@ -26,6 +26,11 @@
- * 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.
+ *
+ *
- * 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
@@ -78,15 +110,12 @@ import de.gecheckt.pdf.umbenenner.domain.model.RunId;
*
*
* The production constructor wires the following key adapters:
*
- * 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.
*
* 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:
*
* 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}.
+ *
+ * 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.
*
@@ -208,34 +293,23 @@ public class BootstrapRunner {
*
- * 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.
- *
- * 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.
*
- * 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.
*
* 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.
+ *
+ * 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.
*
- * Startup flow consists of two phases:
- *
- * 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.
+ *
+ * When a {@code --config} path override is present in {@code configPathOverride}, the file
+ * existence is checked before the adapter is started:
+ *
+ * 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.
+ *
+ * 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
+ * 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.
+ *
+ * After confirming the path (or when no override is present), the effective path is
+ * resolved and the two-phase batch bootstrap executes:
+ *
+ * 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.
+ *
+ * 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
* 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 {
*
* 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}.
- *
- * Validation includes:
- *
* 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 {
*
* 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.
- *
- * 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.
- *
- * 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) {
diff --git a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/PdfUmbenennerApplication.java b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/PdfUmbenennerApplication.java
index aad7288..e2da4d9 100644
--- a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/PdfUmbenennerApplication.java
+++ b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/PdfUmbenennerApplication.java
@@ -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.
*
- * Delegates to {@link BootstrapRunner} for manual object graph construction
- * and execution of the startup sequence.
+ * Responsible for:
+ *
+ * Supported CLI options:
+ *
+ * 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);
}
}
-}
\ No newline at end of file
+}
diff --git a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/package-info.java b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/package-info.java
index 83dc24f..2d5d6f3 100644
--- a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/package-info.java
+++ b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/package-info.java
@@ -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.
*
- * 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.
*
* Components:
*
- * Implementation approach:
+ * Startup mode dispatch:
*
- * Startup sequence:
+ * Configuration path resolution:
*
+ * Headless batch startup sequence (two phases):
+ *
+ * Implementation approach:
+ *
* Exit codes:
*
+ * Supported options:
+ *
+ * Options may appear in any order. Each option must appear at most once.
+ *
+ * Invalid usages produce a {@link StartupArgumentsParseResult.Invalid} result:
+ *
+ * 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.
+ *
+ * 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.
+ *
+ * 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
+ * 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
+ * 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
+ * Bootstrap evaluates the parse result to decide the next action:
+ *
+ * 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.
+ *
+ * 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.
+ *
+ * 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");
+ }
+ }
+}
diff --git a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/startup/StartupMode.java b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/startup/StartupMode.java
new file mode 100644
index 0000000..9d123c9
--- /dev/null
+++ b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/startup/StartupMode.java
@@ -0,0 +1,33 @@
+package de.gecheckt.pdf.umbenenner.bootstrap.startup;
+
+/**
+ * Enumerates the supported application startup modes.
+ *
+ * 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.
+ *
+ * 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.
+ *
+ * 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.
+ *
+ * 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
+}
diff --git a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/startup/package-info.java b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/startup/package-info.java
new file mode 100644
index 0000000..a9c169d
--- /dev/null
+++ b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/startup/package-info.java
@@ -0,0 +1,39 @@
+/**
+ * Startup argument model and command-line parsing for the application entry point.
+ *
+ * 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.
+ *
+ * Components:
+ *
+ * Supported CLI options:
+ *
+ * Invalid usages (result in {@code Invalid} parse result):
+ *
+ * Covers the distinct behavior for GUI and headless startup modes when the path
+ * supplied via {@code --config} refers to a file that does or does not exist:
+ *
+ * Covers the two startup paths (GUI and headless) and the config path resolution
+ * logic, using controlled factory injection to avoid real JavaFX and file I/O.
+ */
+class BootstrapRunnerStartupDispatchTest {
+
+ @TempDir
+ Path tempDir;
+
+ // --- GUI dispatch ---
+
+ @Test
+ void run_withGuiMode_returnsZeroWhenGuiStartSucceeds() {
+ BootstrapRunner runner = runnerWithGuiFactory(() -> new GuiAdapter() {
+ @Override
+ public void start(Optional
+ * This is the automated proof that the headless start path does not initialize the
+ * JavaFX Application class. The {@link BootstrapRunner.GuiAdapterFactory} is the sole
+ * gateway through which the JavaFX lifecycle would be entered. If the factory is never
+ * called, the JavaFX Application class is never loaded or initialized in the headless path.
+ *
+ * A tracking factory is injected that records whether it was called. The test runs a
+ * full headless batch cycle and then asserts the factory was not invoked.
+ */
+ @Test
+ void headlessStart_doesNotInvokeGuiAdapterFactory() {
+ AtomicReference
+ * Covers all supported option combinations and all documented error cases.
+ */
+class CliArgumentParserTest {
+
+ private final CliArgumentParser parser = new CliArgumentParser();
+
+ // =========================================================================
+ // Valid combinations
+ // =========================================================================
+
+ @Test
+ void noArgs_returnsGuiModeWithNoConfigPath() {
+ StartupArgumentsParseResult result = parser.parse(new String[]{});
+
+ StartupArguments args = assertValid(result);
+ assertEquals(StartupMode.GUI, args.mode(), "No args must default to GUI mode");
+ assertEquals(Optional.empty(), args.configPath(), "No args must produce empty configPath");
+ }
+
+ @Test
+ void headlessOnly_returnsHeadlessModeWithNoConfigPath() {
+ StartupArgumentsParseResult result = parser.parse(new String[]{"--headless"});
+
+ StartupArguments args = assertValid(result);
+ assertEquals(StartupMode.HEADLESS, args.mode(), "--headless must set HEADLESS mode");
+ assertEquals(Optional.empty(), args.configPath(), "--headless without --config must produce empty configPath");
+ }
+
+ @Test
+ void configOnly_returnsGuiModeWithConfigPath() {
+ StartupArgumentsParseResult result = parser.parse(new String[]{"--config", "config/app.properties"});
+
+ StartupArguments args = assertValid(result);
+ assertEquals(StartupMode.GUI, args.mode(), "--config alone must default to GUI mode");
+ assertEquals(Optional.of("config/app.properties"), args.configPath(),
+ "--config must supply the given path");
+ }
+
+ @Test
+ void headlessThenConfig_returnsHeadlessModeWithConfigPath() {
+ StartupArgumentsParseResult result = parser.parse(
+ new String[]{"--headless", "--config", "C:\\config\\app.properties"});
+
+ StartupArguments args = assertValid(result);
+ assertEquals(StartupMode.HEADLESS, args.mode());
+ assertEquals(Optional.of("C:\\config\\app.properties"), args.configPath());
+ }
+
+ @Test
+ void configThenHeadless_returnsHeadlessModeWithConfigPath() {
+ StartupArgumentsParseResult result = parser.parse(
+ new String[]{"--config", "my.properties", "--headless"});
+
+ StartupArguments args = assertValid(result);
+ assertEquals(StartupMode.HEADLESS, args.mode(),
+ "--headless after --config must still produce HEADLESS mode");
+ assertEquals(Optional.of("my.properties"), args.configPath());
+ }
+
+ @Test
+ void configWithAbsolutePath_acceptsWindowsPathWithDriveLetter() {
+ StartupArgumentsParseResult result = parser.parse(
+ new String[]{"--config", "S:\\shared\\config\\application.properties"});
+
+ StartupArguments args = assertValid(result);
+ assertEquals(Optional.of("S:\\shared\\config\\application.properties"), args.configPath(),
+ "Mapped drive paths must be accepted as config path values");
+ }
+
+ // =========================================================================
+ // Invalid combinations
+ // =========================================================================
+
+ @Test
+ void configWithoutValue_returnsInvalid() {
+ StartupArgumentsParseResult result = parser.parse(new String[]{"--config"});
+
+ assertInvalid(result, "--config");
+ }
+
+ @Test
+ void configFollowedByAnotherOption_returnsInvalid() {
+ StartupArgumentsParseResult result = parser.parse(new String[]{"--config", "--headless"});
+
+ assertInvalid(result, "--config");
+ }
+
+ @Test
+ void unknownArgument_returnsInvalid() {
+ StartupArgumentsParseResult result = parser.parse(new String[]{"--unknown"});
+
+ assertInvalid(result, "--unknown");
+ }
+
+ @Test
+ void unknownPositionalArgument_returnsInvalid() {
+ StartupArgumentsParseResult result = parser.parse(new String[]{"somevalue"});
+
+ assertInvalid(result, "somevalue");
+ }
+
+ @Test
+ void duplicateHeadless_returnsInvalid() {
+ StartupArgumentsParseResult result = parser.parse(new String[]{"--headless", "--headless"});
+
+ assertInvalid(result, "--headless");
+ }
+
+ @Test
+ void duplicateConfig_returnsInvalid() {
+ StartupArgumentsParseResult result = parser.parse(
+ new String[]{"--config", "a.properties", "--config", "b.properties"});
+
+ assertInvalid(result, "--config");
+ }
+
+ @Test
+ void configWithBlankValue_returnsInvalid() {
+ StartupArgumentsParseResult result = parser.parse(new String[]{"--config", " "});
+
+ assertInvalid(result, "--config");
+ }
+
+ @Test
+ void unknownArgAfterValidArgs_returnsInvalid() {
+ StartupArgumentsParseResult result = parser.parse(
+ new String[]{"--headless", "--config", "app.properties", "--extra"});
+
+ assertInvalid(result, "--extra");
+ }
+
+ // =========================================================================
+ // Helpers
+ // =========================================================================
+
+ /**
+ * Asserts the result is Valid and returns the contained {@link StartupArguments}.
+ */
+ private static StartupArguments assertValid(StartupArgumentsParseResult result) {
+ assertInstanceOf(StartupArgumentsParseResult.Valid.class, result,
+ "Expected Valid parse result but got: " + result);
+ return ((StartupArgumentsParseResult.Valid) result).arguments();
+ }
+
+ /**
+ * Asserts the result is Invalid and that the error message contains the given fragment.
+ */
+ private static void assertInvalid(StartupArgumentsParseResult result, String expectedFragment) {
+ assertInstanceOf(StartupArgumentsParseResult.Invalid.class, result,
+ "Expected Invalid parse result but got: " + result);
+ String errorMessage = ((StartupArgumentsParseResult.Invalid) result).errorMessage();
+ assertTrue(errorMessage.contains(expectedFragment),
+ "Error message should contain '" + expectedFragment + "' but was: " + errorMessage);
+ }
+}
diff --git a/pom.xml b/pom.xml
index ca4b85f..c6c1dbe 100644
--- a/pom.xml
+++ b/pom.xml
@@ -11,6 +11,7 @@
Startup notice
+ * Current scope
+ * Explicit non-goals
+ *
+ *
+ *
+ * Threading
+ *
+ *
+ * Scope
+ *
+ *
+ *
+ * Excluded from this scope
+ * Threading
+ * Monocle configuration
+ * Startup modes
+ *
+ *
+ *
+ * Headless batch pipeline phases
*
- *
+ *
+ * Configuration path semantics ({@code --config} option)
*
+ *
*
* Active AI provider
* Exit code semantics
*
- *
*
- * Adapter wiring
+ * Adapter wiring (headless path)
*
@@ -100,18 +129,16 @@ import de.gecheckt.pdf.umbenenner.domain.model.RunId;
*
- *
*
*
- *
+ * Dispatches to the GUI or headless batch adapter based on the startup mode carried
+ * in {@code startupArguments}:
+ *
+ *
+ *
+ * @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.
*
+ *
+ *
+ *
+ *
- *
+ * Loads configuration from the effective configuration path and validates it.
*
+ *
+ *
+ *
*/
public class PdfUmbenennerApplication {
@@ -15,14 +32,32 @@ public class PdfUmbenennerApplication {
/**
* Application entry point.
+ *
*
*
- *
*
- *
+ *
+ *
+ *
+ *
*
- *
*/
package de.gecheckt.pdf.umbenenner.bootstrap;
\ No newline at end of file
diff --git a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/startup/CliArgumentParser.java b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/startup/CliArgumentParser.java
new file mode 100644
index 0000000..d52d247
--- /dev/null
+++ b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/startup/CliArgumentParser.java
@@ -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}.
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ */
+package de.gecheckt.pdf.umbenenner.bootstrap.startup;
diff --git a/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerConfigPathSemanticsTest.java b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerConfigPathSemanticsTest.java
new file mode 100644
index 0000000..68a5b7e
--- /dev/null
+++ b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerConfigPathSemanticsTest.java
@@ -0,0 +1,381 @@
+package de.gecheckt.pdf.umbenenner.bootstrap;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.apache.logging.log4j.Level;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.core.LogEvent;
+import org.apache.logging.log4j.core.LoggerContext;
+import org.apache.logging.log4j.core.appender.AbstractAppender;
+import org.apache.logging.log4j.core.config.Configuration;
+import org.apache.logging.log4j.core.config.Property;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import de.gecheckt.pdf.umbenenner.adapter.in.cli.SchedulerBatchCommand;
+import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiAdapter;
+import de.gecheckt.pdf.umbenenner.adapter.out.bootstrap.validation.StartConfigurationValidator;
+import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
+import de.gecheckt.pdf.umbenenner.application.config.provider.MultiProviderConfiguration;
+import de.gecheckt.pdf.umbenenner.application.config.provider.ProviderConfiguration;
+import de.gecheckt.pdf.umbenenner.application.config.startup.StartConfiguration;
+import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunOutcome;
+import de.gecheckt.pdf.umbenenner.application.port.out.ConfigurationPort;
+import de.gecheckt.pdf.umbenenner.bootstrap.startup.StartupArguments;
+import de.gecheckt.pdf.umbenenner.bootstrap.startup.StartupMode;
+
+/**
+ * Tests for the {@code --config} path semantics enforced by {@link BootstrapRunner}.
+ *
+ *
+ */
+class BootstrapRunnerConfigPathSemanticsTest {
+
+ @TempDir
+ Path tempDir;
+
+ // =========================================================================
+ // Headless: non-existent --config path
+ // =========================================================================
+
+ @Test
+ void runHeadless_withNonExistentConfigPath_returnsExitCode1() {
+ String nonExistentPath = tempDir.resolve("does-not-exist.properties").toString();
+ AtomicBoolean configPortCalled = new AtomicBoolean(false);
+
+ BootstrapRunner runner = new BootstrapRunner(
+ configPath -> {
+ configPortCalled.set(true);
+ return buildValidConfigPort();
+ },
+ lockFile -> new MockRunLockPort(),
+ StartConfigurationValidator::new,
+ jdbcUrl -> new MockSchemaInitializationPort(),
+ (config, lock) -> new MockRunBatchProcessingUseCase(true),
+ SchedulerBatchCommand::new
+ );
+
+ int exitCode = runner.run(new StartupArguments(StartupMode.HEADLESS, Optional.of(nonExistentPath)));
+
+ assertEquals(1, exitCode,
+ "Headless start with non-existent --config path must return exit code 1");
+ assertFalse(configPortCalled.get(),
+ "ConfigurationPort must not be called when --config file does not exist");
+ }
+
+ @Test
+ void runHeadless_withNonExistentConfigPath_logsError() {
+ String nonExistentPath = tempDir.resolve("missing.properties").toString();
+ List