2 Commits

Author SHA1 Message Date
marcus 76daddbda2 fix: Historiendetails mit Token-Spalten laden und Log-Verzeichnis robust anlegen
- SqliteHistoryQueryAdapter.findAttemptsByFingerprint las die Token-/Preis-Spalten,
  die die SELECT-Liste nicht projizierte -> "no such column: 'input_tokens'" bei
  jedem Dokument mit Versuchen. Spalten in die SELECT-Liste aufgenommen (konsistent
  mit dem Schreibpfad in SqliteProcessingAttemptRepositoryAdapter.save).
- EarlyLogDirectoryInitializer las die .properties ohne die Backslash-Absicherung
  des regulaeren Konfig-Lesers; Windows-Pfade wie C:\Users\...\logs wurden von
  java.util.Properties verstuemmelt, das konfigurierte Log-Verzeichnis nie angelegt
  und der Fehler blieb unsichtbar. Backslashes vor dem Laden verdoppeln, Pfad
  normalisieren, Verzeichnis best-effort anlegen; bei Fehlschlag sichtbar auf das
  Standard-Log-Verzeichnis ausweichen (WARN nach Log4j2-Init).
- Regressionstests fuer beide Faelle ergaenzt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 17:12:08 +02:00
marcus 3da2bcc561 fix: Neue Datenbank anlegen ohne geladene Konfiguration ermoeglichen
Ohne Override und ohne geladene Konfiguration lieferte der aktive-DB-Supplier
faelschlich den Zielpfad selbst zurueck, wodurch die Selbst-Ueberschreib-Pruefung
immer ansprach und jede Anlage mit "Zielpfad ist die aktuell aktive
Datenbankdatei" abgelehnt wurde.

Der Supplier signalisiert "keine aktive Datenbank" nun mit null; der Use-Case
ueberspringt in diesem Fall die Pruefung und laesst die Anlage zu.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 14:26:24 +02:00
8 changed files with 559 additions and 51 deletions
@@ -215,7 +215,10 @@ public class SqliteHistoryQueryAdapter implements HistoryQueryPort {
status, failure_class, failure_message, retryable, status, failure_class, failure_message, retryable,
ai_provider, model_name, prompt_identifier, processed_page_count, sent_character_count, ai_provider, model_name, prompt_identifier, processed_page_count, sent_character_count,
ai_raw_response, ai_reasoning, resolved_date, date_source, validated_title, ai_raw_response, ai_reasoning, resolved_date, date_source, validated_title,
final_target_file_name final_target_file_name,
input_tokens, output_tokens,
cache_creation_input_tokens, cache_read_input_tokens,
price_input_per_token_nano_usd, price_output_per_token_nano_usd
FROM processing_attempt FROM processing_attempt
WHERE fingerprint = ? WHERE fingerprint = ?
ORDER BY attempt_number ASC ORDER BY attempt_number ASC
@@ -0,0 +1,163 @@
package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
import static org.assertj.core.api.Assertions.assertThat;
import java.nio.file.Path;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
import de.gecheckt.pdf.umbenenner.domain.model.RunId;
/**
* Tests für {@link SqliteHistoryQueryAdapter#findAttemptsByFingerprint}.
*
* <p>Deckt insbesondere die Regression ab, dass der Historien-Leseadapter die
* Token- und Preis-Snapshot-Spalten mappen kann. Die zugehörige SELECT-Liste muss
* dieselben Spalten projizieren, die der Mapper liest andernfalls schlägt jede
* Detailabfrage mit {@code no such column: 'input_tokens'} fehl.
*/
class SqliteHistoryQueryAdapterTest {
private String jdbcUrl;
private SqliteSchemaInitializationAdapter schemaAdapter;
private SqliteProcessingAttemptRepositoryAdapter repository;
private SqliteHistoryQueryAdapter historyQuery;
@TempDir
Path tempDir;
@BeforeEach
void setUp() {
Path dbFile = tempDir.resolve("history-query-test.db");
jdbcUrl = "jdbc:sqlite:" + dbFile.toAbsolutePath();
schemaAdapter = new SqliteSchemaInitializationAdapter(jdbcUrl);
repository = new SqliteProcessingAttemptRepositoryAdapter(jdbcUrl);
historyQuery = new SqliteHistoryQueryAdapter(jdbcUrl);
schemaAdapter.initializeSchema();
}
/**
* Ein Versuch mit gesetzten Token- und Preis-Werten muss ohne Exception geladen und
* die Werte müssen korrekt zurückgemappt werden.
*/
@Test
void findAttemptsByFingerprint_liestTokenUndPreisSpalten() {
DocumentFingerprint fp = fingerprint("aa");
insertDocumentRecord(fp);
Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS);
ProcessingAttempt attempt = new ProcessingAttempt(
fp, new RunId("run-tokens"), 1, now, now.plusSeconds(1),
ProcessingStatus.PROPOSAL_READY,
null, null, false,
"claude",
"claude-haiku-4-5-20251001", "prompt-v1", 3, 4200,
"{\"title\":\"x\"}", "reasoning",
null, null, "Titel", "2026-07-01 - Titel.pdf",
1500L, 800L, 200L, 100L, 250L, 2000L);
repository.save(attempt);
List<ProcessingAttempt> loaded = historyQuery.findAttemptsByFingerprint(fp);
assertThat(loaded).hasSize(1);
ProcessingAttempt result = loaded.get(0);
assertThat(result.inputTokens()).isEqualTo(1500L);
assertThat(result.outputTokens()).isEqualTo(800L);
assertThat(result.cacheCreationInputTokens()).isEqualTo(200L);
assertThat(result.cacheReadInputTokens()).isEqualTo(100L);
assertThat(result.priceInputPerTokenNanoUsd()).isEqualTo(250L);
assertThat(result.priceOutputPerTokenNanoUsd()).isEqualTo(2000L);
}
/**
* Ein Versuch ohne Token-/Preis-Werte (typisch für Skip-/Pre-Check-Pfade und
* historische Bestände) muss {@code null} für alle Token-Felder zurückliefern,
* ohne dass die Abfrage fehlschlägt.
*/
@Test
void findAttemptsByFingerprint_nullTokenWerteBleibenNull() {
DocumentFingerprint fp = fingerprint("bb");
insertDocumentRecord(fp);
Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS);
ProcessingAttempt attempt = ProcessingAttempt.withoutAiFields(
fp, new RunId("run-null"), 1, now, now.plusSeconds(1),
ProcessingStatus.FAILED_RETRYABLE, "Err", "msg", true);
repository.save(attempt);
List<ProcessingAttempt> loaded = historyQuery.findAttemptsByFingerprint(fp);
assertThat(loaded).hasSize(1);
ProcessingAttempt result = loaded.get(0);
assertThat(result.inputTokens()).isNull();
assertThat(result.outputTokens()).isNull();
assertThat(result.cacheCreationInputTokens()).isNull();
assertThat(result.cacheReadInputTokens()).isNull();
assertThat(result.priceInputPerTokenNanoUsd()).isNull();
assertThat(result.priceOutputPerTokenNanoUsd()).isNull();
}
/**
* Mehrere Versuche müssen aufsteigend nach {@code attempt_number} zurückgegeben werden.
*/
@Test
void findAttemptsByFingerprint_sortiertAufsteigend() {
DocumentFingerprint fp = fingerprint("cc");
insertDocumentRecord(fp);
Instant base = Instant.now().truncatedTo(ChronoUnit.MICROS);
repository.save(ProcessingAttempt.withoutAiFields(
fp, new RunId("run-1"), 1, base, base.plusSeconds(1),
ProcessingStatus.FAILED_RETRYABLE, "Err", "msg", true));
repository.save(new ProcessingAttempt(
fp, new RunId("run-2"), 2, base.plusSeconds(10), base.plusSeconds(11),
ProcessingStatus.PROPOSAL_READY,
null, null, false, "claude",
"claude-haiku-4-5-20251001", "prompt-v1", 1, 100,
"{}", "r", null, null, "T", "2026-07-01 - T.pdf",
10L, 20L, null, null, 250L, 2000L));
List<ProcessingAttempt> loaded = historyQuery.findAttemptsByFingerprint(fp);
assertThat(loaded).hasSize(2);
assertThat(loaded.get(0).attemptNumber()).isEqualTo(1);
assertThat(loaded.get(1).attemptNumber()).isEqualTo(2);
assertThat(loaded.get(1).inputTokens()).isEqualTo(10L);
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private void insertDocumentRecord(DocumentFingerprint fp) {
try (Connection conn = DriverManager.getConnection(jdbcUrl);
PreparedStatement ps = conn.prepareStatement("""
INSERT INTO document_record
(fingerprint, last_known_source_locator, last_known_source_file_name,
overall_status, created_at, updated_at)
VALUES (?, '/tmp/test.pdf', 'test.pdf', 'READY_FOR_AI',
strftime('%Y-%m-%dT%H:%M:%SZ', 'now'),
strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))""")) {
ps.setString(1, fp.sha256Hex());
ps.executeUpdate();
} catch (SQLException e) {
throw new RuntimeException("Failed to insert test document record", e);
}
}
private static DocumentFingerprint fingerprint(String suffix) {
return new DocumentFingerprint("0".repeat(64 - suffix.length()) + suffix);
}
}
@@ -41,7 +41,9 @@ import de.gecheckt.pdf.umbenenner.application.port.out.DatabaseCreationPort;
* Aktive DB und Zielpfad werden über {@link Path#toRealPath(java.nio.file.LinkOption...)} * Aktive DB und Zielpfad werden über {@link Path#toRealPath(java.nio.file.LinkOption...)}
* normalisiert verglichen. Für noch nicht existierende Dateien wird das Elternverzeichnis * normalisiert verglichen. Für noch nicht existierende Dateien wird das Elternverzeichnis
* real aufgelöst und der Dateiname normalisiert verglichen. Auf Windows erfolgt der * real aufgelöst und der Dateiname normalisiert verglichen. Auf Windows erfolgt der
* Vergleich case-insensitive. * Vergleich case-insensitive. Ist aktuell keine aktive Datenbank in Betrieb (der
* {@link ActiveDatabasePathSupplier} liefert {@code null}), entfällt die Prüfung und die
* Anlage fährt fort es gibt dann keine in Benutzung befindliche Datei zu schützen.
*/ */
public class DefaultCreateNewDatabaseUseCase implements CreateNewDatabaseUseCase { public class DefaultCreateNewDatabaseUseCase implements CreateNewDatabaseUseCase {
@@ -66,7 +68,9 @@ public class DefaultCreateNewDatabaseUseCase implements CreateNewDatabaseUseCase
/** /**
* Liefert den Pfad der aktuell aktiven SQLite-Datei. * Liefert den Pfad der aktuell aktiven SQLite-Datei.
* *
* @return den absoluten Pfad der aktiven Datei; nie {@code null} * @return den absoluten Pfad der aktiven Datei oder {@code null}, wenn aktuell keine
* aktive Datenbank in Betrieb ist. Bei {@code null} entfällt die
* Selbst-Überschreib-Prüfung und die Anlage darf fortfahren.
*/ */
Path get(); Path get();
} }
@@ -95,6 +99,9 @@ public class DefaultCreateNewDatabaseUseCase implements CreateNewDatabaseUseCase
/** /**
* Orchestriert den vollständigen Anlage- und Wechselvorgang. * Orchestriert den vollständigen Anlage- und Wechselvorgang.
* <p>
* Liefert der {@link ActiveDatabasePathSupplier} {@code null} (keine aktive Datenbank in
* Betrieb), entfällt die Selbst-Überschreib-Prüfung und die Anlage fährt fort.
* *
* @param targetFile der vom Benutzer ausgewählte Zielpfad; darf nicht {@code null} sein * @param targetFile der vom Benutzer ausgewählte Zielpfad; darf nicht {@code null} sein
* @return strukturiertes Ergebnis mit Erfolg oder klassifiziertem Fehler; nie {@code null} * @return strukturiertes Ergebnis mit Erfolg oder klassifiziertem Fehler; nie {@code null}
@@ -106,14 +113,13 @@ public class DefaultCreateNewDatabaseUseCase implements CreateNewDatabaseUseCase
LOG.info("Neue Datenbank anlegen: angeforderter Zielpfad = {}", absoluteTarget); LOG.info("Neue Datenbank anlegen: angeforderter Zielpfad = {}", absoluteTarget);
// Schritt 1: Pfad-Sicherheitsprüfung // Schritt 1: Pfad-Sicherheitsprüfung
// Ohne aktive Datenbank (Supplier liefert null) gibt es nichts zu schützen; die
// Selbst-Überschreib-Prüfung entfällt und die Anlage darf fortfahren.
Path activeDb = activeDatabasePathSupplier.get(); Path activeDb = activeDatabasePathSupplier.get();
if (activeDb == null) { if (activeDb == null) {
LOG.error("Aktiver Datenbankpfad ist nicht ermittelbar Anlage abgebrochen."); LOG.info("Keine aktive Datenbank in Betrieb Selbst-Überschreib-Prüfung entfällt, "
return new CreateNewDatabaseResult.CreationFailed( + "Anlage fährt fort.");
CreateNewDatabaseResult.Phase.PATH_RESOLUTION, } else {
"Aktiver Datenbankpfad konnte nicht ermittelt werden.",
null);
}
Path absoluteActive = activeDb.toAbsolutePath().normalize(); Path absoluteActive = activeDb.toAbsolutePath().normalize();
boolean sameFile; boolean sameFile;
try { try {
@@ -130,6 +136,7 @@ public class DefaultCreateNewDatabaseUseCase implements CreateNewDatabaseUseCase
absoluteTarget); absoluteTarget);
return new CreateNewDatabaseResult.SameAsActiveDatabase(absoluteTarget); return new CreateNewDatabaseResult.SameAsActiveDatabase(absoluteTarget);
} }
}
// Schritt 2: Temp-Datei im Zielverzeichnis vorbereiten // Schritt 2: Temp-Datei im Zielverzeichnis vorbereiten
Path parent = absoluteTarget.getParent(); Path parent = absoluteTarget.getParent();
@@ -168,6 +168,63 @@ class DefaultCreateNewDatabaseUseCaseTest {
assertThat(contextOverride.get()).isEqualTo(target.toAbsolutePath().normalize()); assertThat(contextOverride.get()).isEqualTo(target.toAbsolutePath().normalize());
} }
@Test
void createNewDatabase_shouldProceed_whenNoActiveDatabase(@TempDir Path tempDir) throws IOException {
// Szenario: GUI ohne geladene Konfiguration und ohne Override → Supplier liefert null.
// Es gibt keine aktive DB zu schützen; die Anlage muss durchlaufen.
Path target = tempDir.resolve("neu.sqlite");
AtomicReference<Path> capturedTemp = new AtomicReference<>();
DatabaseCreationPort creation = tempFile -> {
try {
Files.writeString(tempFile, "migrated-content");
} catch (IOException e) {
throw new IllegalStateException(e);
}
capturedTemp.set(tempFile);
return new DatabaseCreationPort.DatabaseCreationResult.Success(tempFile);
};
AtomicReference<Path> contextOverride = new AtomicReference<>();
DefaultCreateNewDatabaseUseCase useCase = new DefaultCreateNewDatabaseUseCase(
creation, trackingActiveContext(contextOverride), () -> null);
CreateNewDatabaseResult result = useCase.createNewDatabase(target);
assertThat(result).isInstanceOf(CreateNewDatabaseResult.Success.class);
assertThat(Files.exists(target)).isTrue();
assertThat(Files.readString(target)).isEqualTo("migrated-content");
assertThat(capturedTemp.get()).isNotNull();
assertThat(Files.exists(capturedTemp.get())).isFalse();
assertThat(contextOverride.get()).isEqualTo(target.toAbsolutePath().normalize());
}
@Test
void createNewDatabase_shouldOverwriteExistingTarget_whenNoActiveDatabase(@TempDir Path tempDir)
throws IOException {
// Kein aktiver DB-Kontext (Supplier liefert null), aber Zieldatei existiert bereits →
// Überschreiben ist erlaubt, weil keine in Benutzung befindliche DB betroffen ist.
Path target = tempDir.resolve("existing.sqlite");
Files.writeString(target, "alter-inhalt");
DatabaseCreationPort creation = tempFile -> {
try {
Files.writeString(tempFile, "neu-und-leer");
} catch (IOException e) {
throw new IllegalStateException(e);
}
return new DatabaseCreationPort.DatabaseCreationResult.Success(tempFile);
};
AtomicReference<Path> contextOverride = new AtomicReference<>();
DefaultCreateNewDatabaseUseCase useCase = new DefaultCreateNewDatabaseUseCase(
creation, trackingActiveContext(contextOverride), () -> null);
CreateNewDatabaseResult result = useCase.createNewDatabase(target);
assertThat(result).isInstanceOf(CreateNewDatabaseResult.Success.class);
assertThat(Files.readString(target)).isEqualTo("neu-und-leer");
assertThat(contextOverride.get()).isEqualTo(target.toAbsolutePath().normalize());
}
@Test @Test
void isSameFile_shouldReturnTrue_forIdenticalNonExistingFiles(@TempDir Path tempDir) throws IOException { void isSameFile_shouldReturnTrue_forIdenticalNonExistingFiles(@TempDir Path tempDir) throws IOException {
Path a = tempDir.resolve("data.sqlite"); Path a = tempDir.resolve("data.sqlite");
@@ -2042,6 +2042,9 @@ public class BootstrapRunner {
* {@link #activeDatabaseContext}. Die zugrundeliegende Liefer-Lambda für den aktiven * {@link #activeDatabaseContext}. Die zugrundeliegende Liefer-Lambda für den aktiven
* DB-Pfad bevorzugt das Override im Kontext-Port; ist keines gesetzt, fällt sie auf den * DB-Pfad bevorzugt das Override im Kontext-Port; ist keines gesetzt, fällt sie auf den
* {@code sqliteFile}-Wert der aus {@code configFilePath} geladenen Konfiguration zurück. * {@code sqliteFile}-Wert der aus {@code configFilePath} geladenen Konfiguration zurück.
* Existiert weder ein Override noch eine ladbare Konfiguration, gibt es keine aktive
* Datenbank: die Lambda liefert dann {@code null}, wodurch der Use-Case die
* Selbst-Überschreib-Prüfung überspringt und die Anlage zulässt.
* <p> * <p>
* Pfad-Validierungs- und Move-Schritte verbleiben im Use-Case; der Adapter führt * Pfad-Validierungs- und Move-Schritte verbleiben im Use-Case; der Adapter führt
* Anlage, Migration und Verbindungstest aus. * Anlage, Migration und Verbindungstest aus.
@@ -2061,9 +2064,10 @@ public class BootstrapRunner {
return override.get(); return override.get();
} }
if (configFilePath == null || !Files.exists(configFilePath)) { if (configFilePath == null || !Files.exists(configFilePath)) {
// Ohne Konfiguration und ohne Override: Zielpfad selbst als „aktiv" werten, // Ohne Override und ohne geladene Konfiguration existiert keine aktive
// damit die Pfadprüfung nur das Selbst-Überschreiben verhindert. // Datenbank. null signalisiert dem Use-Case: keine Selbst-Überschreib-Prüfung
return targetFile.toAbsolutePath().normalize(); // nötig, die Anlage darf fortfahren.
return null;
} }
try { try {
migrateConfigurationIfNeeded(configFilePath); migrateConfigurationIfNeeded(configFilePath);
@@ -2071,9 +2075,9 @@ public class BootstrapRunner {
return config.sqliteFile().toAbsolutePath().normalize(); return config.sqliteFile().toAbsolutePath().normalize();
} catch (Exception e) { } catch (Exception e) {
LOG.warn("GUI-Datenbankanlage: aktive DB konnte nicht aus {} ermittelt werden: {} — " LOG.warn("GUI-Datenbankanlage: aktive DB konnte nicht aus {} ermittelt werden: {} — "
+ "Zielpfad wird stattdessen als aktiv angenommen.", + "es wird keine aktive DB angenommen.",
configFilePath, e.getMessage()); configFilePath, e.getMessage());
return targetFile.toAbsolutePath().normalize(); return null;
} }
}; };
DefaultCreateNewDatabaseUseCase useCase = new DefaultCreateNewDatabaseUseCase( DefaultCreateNewDatabaseUseCase useCase = new DefaultCreateNewDatabaseUseCase(
@@ -43,9 +43,11 @@ public class PdfUmbenennerApplication {
* @param args command-line arguments; see class JavaDoc for supported options * @param args command-line arguments; see class JavaDoc for supported options
*/ */
public static void main(String[] args) { public static void main(String[] args) {
EarlyLogDirectoryInitializer.LogDirectorySetupResult logSetup =
EarlyLogDirectoryInitializer.applyFromArgs(args); EarlyLogDirectoryInitializer.applyFromArgs(args);
Logger LOG = LogManager.getLogger(PdfUmbenennerApplication.class); Logger LOG = LogManager.getLogger(PdfUmbenennerApplication.class);
LOG.info("Starting PDF Umbenenner application..."); LOG.info("Starting PDF Umbenenner application...");
logEffectiveLogDirectory(LOG, logSetup);
try { try {
StartupArgumentsParseResult parseResult = new CliArgumentParser().parse(args); StartupArgumentsParseResult parseResult = new CliArgumentParser().parse(args);
@@ -74,4 +76,31 @@ public class PdfUmbenennerApplication {
System.exit(1); System.exit(1);
} }
} }
/**
* Protokolliert nach der Log4j2-Initialisierung sichtbar, welches Log-Verzeichnis
* effektiv genutzt wird.
* <p>
* Musste die Anwendung wegen eines nicht anlegbaren konfigurierten Verzeichnisses auf
* das Standard-Log-Verzeichnis ausweichen, wird dies als Warnung mit konfiguriertem und
* tatsächlichem Pfad ausgegeben, damit der frühere stille Fehlschlag nachvollziehbar ist.
* Diese erste Logzeile landet im effektiv genutzten Log und macht den Speicherort auffindbar.
*
* @param log der bereits initialisierte Logger; darf nicht {@code null} sein
* @param logSetup das Ergebnis der frühen Log-Verzeichnis-Auflösung; darf nicht {@code null} sein
*/
private static void logEffectiveLogDirectory(Logger log,
EarlyLogDirectoryInitializer.LogDirectorySetupResult logSetup) {
String fallbackDirectory = System.getProperty("user.home") + "/pdf-umbenenner/logs";
if (logSetup.fallbackUsed()) {
log.warn("Konfiguriertes Log-Verzeichnis '{}' konnte nicht angelegt oder genutzt werden "
+ "Ausweichen auf Standard-Log-Verzeichnis '{}'.",
logSetup.configuredValue(), fallbackDirectory);
} else if (logSetup.appliedDirectory() != null) {
log.info("Effektives Log-Verzeichnis: {}", logSetup.appliedDirectory());
} else {
log.info("Kein Log-Verzeichnis konfiguriert Standard-Log-Verzeichnis '{}' wird genutzt.",
fallbackDirectory);
}
}
} }
@@ -1,7 +1,8 @@
package de.gecheckt.pdf.umbenenner.bootstrap.startup; package de.gecheckt.pdf.umbenenner.bootstrap.startup;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.StringReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
@@ -19,9 +20,22 @@ import java.util.Properties;
* Default-Verzeichnis zurück, damit auch im MSI-Betrieb (Arbeitsverzeichnis unter * Default-Verzeichnis zurück, damit auch im MSI-Betrieb (Arbeitsverzeichnis unter
* {@code Program Files}, typischerweise nicht beschreibbar) eine Log-Datei entsteht. * {@code Program Files}, typischerweise nicht beschreibbar) eine Log-Datei entsteht.
* <p> * <p>
* Diese Klasse vermeidet bewusst jede Logger-Nutzung und schluckt sämtliche Fehler: * <strong>Backslash-Behandlung:</strong> Wie der reguläre Konfigurationsleser werden
* Sie soll niemals den Programmstart verhindern, sondern lediglich einen frühen * Backslashes vor dem {@link Properties}-Laden verdoppelt, damit Windows-Pfade wie
* Best-Effort-Hinweis an Log4j2 liefern. * {@code C:\Users\...\logs} nicht durch die Escape-Interpretation von
* {@link Properties} verstümmelt werden. Der aufgelöste Pfad wird zusätzlich auf
* Forward-Slashes normalisiert.
* <p>
* <strong>Verzeichnisanlage:</strong> Das konfigurierte Log-Verzeichnis wird per
* Best-Effort mit {@link Files#createDirectories(Path, java.nio.file.attribute.FileAttribute[])}
* angelegt. Gelingt das nicht (z. B. fehlende Rechte oder ungültiger Pfad), bleibt die
* System-Property ungesetzt, sodass der in {@code log4j2.xml} hinterlegte, garantiert
* beschreibbare Fallback greift. Der Start wird niemals verhindert.
* <p>
* Diese Klasse vermeidet bewusst jede Logger-Nutzung (sie läuft vor der Log4j2-Init).
* Das Ergebnis wird als {@link LogDirectorySetupResult} zurückgegeben, damit der Aufrufer
* nach erfolgter Log4j2-Initialisierung eine sichtbare Meldung über das effektive
* Log-Verzeichnis bzw. einen genutzten Fallback protokollieren kann.
*/ */
public final class EarlyLogDirectoryInitializer { public final class EarlyLogDirectoryInitializer {
@@ -34,28 +48,82 @@ public final class EarlyLogDirectoryInitializer {
// utility // utility
} }
/**
* Ergebnis der frühen Log-Verzeichnis-Auflösung.
* <p>
* Dient ausschließlich der nachgelagerten, sichtbaren Protokollierung nach der
* Log4j2-Initialisierung. Er trifft keine weitere Entscheidung.
*
* @param configuredValue der aus der Konfiguration aufgelöste, normalisierte Wert von
* {@code log.directory}, oder {@code null}, wenn keiner ermittelt
* werden konnte
* @param appliedDirectory das tatsächlich als System-Property gesetzte und angelegte
* Verzeichnis, oder {@code null}, wenn kein konfiguriertes
* Verzeichnis angewandt wurde (Fallback greift)
* @param fallbackUsed {@code true}, wenn ein konfiguriertes Log-Verzeichnis vorlag,
* aber nicht angelegt/genutzt werden konnte und deshalb auf das
* Standard-Log-Verzeichnis ausgewichen wird
*/
public record LogDirectorySetupResult(String configuredValue, String appliedDirectory,
boolean fallbackUsed) {
private static LogDirectorySetupResult notConfigured() {
return new LogDirectorySetupResult(null, null, false);
}
private static LogDirectorySetupResult applied(String configuredValue, String appliedDirectory) {
return new LogDirectorySetupResult(configuredValue, appliedDirectory, false);
}
private static LogDirectorySetupResult fallback(String configuredValue) {
return new LogDirectorySetupResult(configuredValue, null, true);
}
}
/** /**
* Versucht, aus der aktiven Konfigurationsdatei den Wert von {@code log.directory} * Versucht, aus der aktiven Konfigurationsdatei den Wert von {@code log.directory}
* zu lesen, und setzt ihn als System-Property, sofern er ein nicht-leerer String ist. * zu lesen, das Verzeichnis anzulegen und den Pfad als System-Property zu setzen.
* <p> * <p>
* Greift {@code --config <pfad>} auf, ansonsten {@code config/application.properties} * Greift {@code --config <pfad>} auf, ansonsten {@code config/application.properties}
* relativ zum Arbeitsverzeichnis. Ist kein Wert ableitbar, bleibt die System-Property * relativ zum Arbeitsverzeichnis. Ist bereits eine System-Property gesetzt, wird sie
* unverändert; in diesem Fall greift der in {@code log4j2.xml} hinterlegte Fallback. * respektiert. Kann kein Wert ermittelt oder das Verzeichnis nicht angelegt werden,
* bleibt die System-Property ungesetzt; in diesem Fall greift der in {@code log4j2.xml}
* hinterlegte Fallback.
* *
* @param args Kommandozeilenargumente, dürfen {@code null} sein * @param args Kommandozeilenargumente, dürfen {@code null} sein
* @return das Ergebnis der Auflösung für die nachgelagerte, sichtbare Protokollierung;
* nie {@code null}
*/ */
public static void applyFromArgs(String[] args) { public static LogDirectorySetupResult applyFromArgs(String[] args) {
try {
if (isLogPropertyAlreadySet()) { if (isLogPropertyAlreadySet()) {
return; return LogDirectorySetupResult.applied(null, System.getProperty(SYSTEM_PROPERTY_KEY));
} }
String configuredValue = null;
try {
Path configPath = resolveConfigPath(args); Path configPath = resolveConfigPath(args);
if (configPath == null || !Files.isRegularFile(configPath)) { if (configPath == null || !Files.isRegularFile(configPath)) {
return; return LogDirectorySetupResult.notConfigured();
} }
applyLogDirectoryFromConfig(configPath); configuredValue = readLogDirectoryValue(configPath);
} catch (IOException | RuntimeException ignored) { if (configuredValue == null || configuredValue.isBlank()) {
// bewusst still: Log4j2-Fallback aus log4j2.xml übernimmt ansonsten return LogDirectorySetupResult.notConfigured();
}
Path directory = Paths.get(configuredValue);
Files.createDirectories(directory);
String applied = directory.toString();
System.setProperty(SYSTEM_PROPERTY_KEY, applied);
return LogDirectorySetupResult.applied(configuredValue, applied);
} catch (IOException | RuntimeException e) {
// Bewusst still: kein Logger verfügbar. Bei bekanntem, aber nicht nutzbarem
// konfiguriertem Wert wird ein Fallback signalisiert, damit der Aufrufer nach
// der Log4j2-Init eine sichtbare Warnung ausgeben kann. Die System-Property
// bleibt ungesetzt, sodass der log4j2.xml-Fallback greift.
if (configuredValue != null && !configuredValue.isBlank()) {
return LogDirectorySetupResult.fallback(configuredValue);
}
return LogDirectorySetupResult.notConfigured();
} }
} }
@@ -64,15 +132,26 @@ public final class EarlyLogDirectoryInitializer {
return val != null && !val.isBlank(); return val != null && !val.isBlank();
} }
private static void applyLogDirectoryFromConfig(Path configPath) throws IOException { /**
* Liest den Wert von {@code log.directory} aus der Konfigurationsdatei, verdoppelt zuvor
* Backslashes (analog zum regulären Konfigurationsleser) und normalisiert den Pfad auf
* Forward-Slashes.
*
* @param configPath Pfad zur Konfigurationsdatei; darf nicht {@code null} sein
* @return der normalisierte Wert, oder {@code null}, wenn nicht vorhanden oder leer
* @throws IOException wenn die Datei nicht gelesen werden kann
*/
private static String readLogDirectoryValue(Path configPath) throws IOException {
String content = Files.readString(configPath, StandardCharsets.UTF_8);
// Backslashes verdoppeln, sonst interpretiert Properties z. B. \U/\D/\t als Escape.
String escapedContent = content.replace("\\", "\\\\");
Properties properties = new Properties(); Properties properties = new Properties();
try (InputStream in = Files.newInputStream(configPath)) { properties.load(new StringReader(escapedContent));
properties.load(in);
}
String value = properties.getProperty(CONFIG_PROPERTY_KEY); String value = properties.getProperty(CONFIG_PROPERTY_KEY);
if (value != null && !value.isBlank()) { if (value == null || value.isBlank()) {
System.setProperty(SYSTEM_PROPERTY_KEY, value.trim()); return null;
} }
return value.trim().replace('\\', '/');
} }
private static Path resolveConfigPath(String[] args) { private static Path resolveConfigPath(String[] args) {
@@ -0,0 +1,166 @@
package de.gecheckt.pdf.umbenenner.bootstrap.startup;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledOnOs;
import org.junit.jupiter.api.condition.OS;
import org.junit.jupiter.api.io.TempDir;
import de.gecheckt.pdf.umbenenner.bootstrap.startup.EarlyLogDirectoryInitializer.LogDirectorySetupResult;
/**
* Tests für {@link EarlyLogDirectoryInitializer}.
*
* <p>Deckt insbesondere die Regression ab, dass Windows-Pfade mit Backslashes nicht durch
* die Escape-Interpretation von {@link java.util.Properties} verstümmelt werden, sowie das
* Best-Effort-Anlegen des Verzeichnisses und den sichtbaren Fallback, wenn das konfigurierte
* Verzeichnis nicht nutzbar ist.
*
* <p>Die System-Property {@code log.directory} ist globaler Prozesszustand; sie wird pro Test
* gesichert, geleert und anschließend wiederhergestellt.
*/
class EarlyLogDirectoryInitializerTest {
private static final String KEY = "log.directory";
private String originalProperty;
@TempDir
Path tempDir;
@BeforeEach
void clearLogDirectoryProperty() {
originalProperty = System.getProperty(KEY);
System.clearProperty(KEY);
}
@AfterEach
void restoreLogDirectoryProperty() {
if (originalProperty == null) {
System.clearProperty(KEY);
} else {
System.setProperty(KEY, originalProperty);
}
}
/**
* Regression: Ein Windows-Pfad mit einfachen Backslashes darf beim frühen Einlesen nicht
* verstümmelt werden; das Verzeichnis muss angelegt und exakt referenziert werden.
*/
@Test
@EnabledOnOs(OS.WINDOWS)
void windowsBackslashPfad_wirdNichtVerstuemmeltUndAngelegt() throws IOException {
Path expected = tempDir.resolve("winlogs");
String backslashValue = expected.toString().replace('/', '\\');
Path config = writeConfig("log.directory=" + backslashValue);
LogDirectorySetupResult result = EarlyLogDirectoryInitializer.applyFromArgs(
new String[]{"--config", config.toString()});
assertFalse(result.fallbackUsed(), "Ein anlegbares Verzeichnis darf keinen Fallback auslösen");
assertTrue(Files.isDirectory(expected), "Konfiguriertes Log-Verzeichnis muss angelegt worden sein");
String property = System.getProperty(KEY);
assertNotNull(property, "System-Property log.directory muss gesetzt sein");
assertTrue(Files.isSameFile(expected, Paths.get(property)),
"System-Property muss auf das konfigurierte Verzeichnis zeigen, nicht auf einen verstümmelten Pfad");
}
/**
* Ein noch nicht existentes, aber anlegbares Verzeichnis wird angelegt und als
* System-Property gesetzt.
*/
@Test
void anlegbaresVerzeichnis_wirdAngelegtUndGesetzt() throws IOException {
Path expected = tempDir.resolve("logs-sub");
String value = expected.toString().replace('\\', '/');
Path config = writeConfig("log.directory=" + value);
LogDirectorySetupResult result = EarlyLogDirectoryInitializer.applyFromArgs(
new String[]{"--config", config.toString()});
assertFalse(result.fallbackUsed());
assertTrue(Files.isDirectory(expected));
String property = System.getProperty(KEY);
assertNotNull(property);
assertTrue(Files.isSameFile(expected, Paths.get(property)));
}
/**
* Ein nicht anlegbares Verzeichnis (Unterverzeichnis unterhalb einer regulären Datei)
* darf keine System-Property setzen und muss den Fallback signalisieren.
*/
@Test
void nichtAnlegbaresVerzeichnis_signalisiertFallbackOhneProperty() throws IOException {
Path blockingFile = tempDir.resolve("blocker.txt");
Files.writeString(blockingFile, "x", StandardCharsets.UTF_8);
String value = blockingFile.toString().replace('\\', '/') + "/sub";
Path config = writeConfig("log.directory=" + value);
LogDirectorySetupResult result = EarlyLogDirectoryInitializer.applyFromArgs(
new String[]{"--config", config.toString()});
assertTrue(result.fallbackUsed(), "Nicht anlegbares Verzeichnis muss Fallback signalisieren");
assertNull(System.getProperty(KEY), "Bei Fallback darf keine System-Property gesetzt sein");
}
/**
* Ist {@code log.directory} nicht konfiguriert, bleibt die System-Property ungesetzt und es
* wird kein Fallback signalisiert.
*/
@Test
void keinLogVerzeichnis_setztKeineProperty() throws IOException {
Path config = writeConfig("source.folder=/tmp");
LogDirectorySetupResult result = EarlyLogDirectoryInitializer.applyFromArgs(
new String[]{"--config", config.toString()});
assertFalse(result.fallbackUsed());
assertNull(result.appliedDirectory());
assertNull(System.getProperty(KEY));
}
/**
* Eine bereits gesetzte System-Property wird respektiert und nicht überschrieben.
*/
@Test
void bereitsGesetztePropertyWirdRespektiert() {
System.setProperty(KEY, "C:/vorgegeben/logs");
LogDirectorySetupResult result = EarlyLogDirectoryInitializer.applyFromArgs(new String[]{});
assertFalse(result.fallbackUsed());
assertEquals("C:/vorgegeben/logs", result.appliedDirectory());
assertEquals("C:/vorgegeben/logs", System.getProperty(KEY));
}
/**
* Zeigt {@code --config} auf eine nicht existente Datei, bleibt die System-Property ungesetzt.
*/
@Test
void fehlendeKonfigdatei_setztKeineProperty() {
LogDirectorySetupResult result = EarlyLogDirectoryInitializer.applyFromArgs(
new String[]{"--config", tempDir.resolve("does-not-exist.properties").toString()});
assertFalse(result.fallbackUsed());
assertNull(System.getProperty(KEY));
}
private Path writeConfig(String content) throws IOException {
Path config = tempDir.resolve("application.properties");
Files.writeString(config, content + "\n", StandardCharsets.UTF_8);
return config;
}
}