secondaryCache) {
+ if (primaryValue != null && secondaryValue != null && !Objects.deepEquals(primaryValue, secondaryValue)) {
+ logger.warn(
+ "Cache inconsistency detected for key {}, overwriting secondary cache value with primary cache value: {} -> {}",
+ key,
+ secondaryValue,
+ primaryValue);
+ secondaryCache.putViaInternalKey(key, primaryValue);
+ return primaryValue;
+ }
+ return super.resolveViaInternalKey(key, primaryValue, primaryCache, secondaryValue, secondaryCache);
+ }
+ };
+
+ private static final Logger logger = LoggerFactory.getLogger(CacheReplacementStrategy.class);
+
+ /**
+ * Resolves a conflict between two caches by applying the appropriate replacement strategy.
+ * If a value is null in one cache but not the other, it will be copied to the cache where it is missing.
+ *
+ * The default implementation does not perform any replacement and simply returns the primary value.
+ *
+ * @param The type of cache key used in both caches
+ * @param The type of the cache values
+ * @param key The cache key where the conflict occurred
+ * @param primaryValue The value of the primary cache
+ * @param primaryCache The primary cache where the value was found
+ * @param secondaryValue The value of the secondary cache
+ * @param secondaryCache The secondary cache where the value was found
+ *
+ * @return The resolved cache value to be used (may be null)
+ */
+ public @Nullable T resolve(
+ String key,
+ @Nullable T primaryValue,
+ Cache primaryCache,
+ @Nullable T secondaryValue,
+ Cache secondaryCache) {
+ if (primaryValue == null && secondaryValue != null) {
+ primaryCache.put(key, secondaryValue);
+ return secondaryValue;
+ }
+ if (primaryValue != null && secondaryValue == null) {
+ secondaryCache.put(key, primaryValue);
+ return primaryValue;
+ }
+ return primaryValue;
+ }
+
+ /**
+ * Resolves a conflict between two caches by applying the appropriate replacement strategy.
+ * If a value is null in one cache but not the other, it will be copied to the cache where it is missing.
+ *
+ * The default implementation does not perform any replacement and simply returns the primary value.
+ *
+ * @param The type of cache key used in both caches
+ * @param The type of the cache values
+ * @param key The cache key where the conflict occurred
+ * @param primaryValue The value of the primary cache
+ * @param primaryCache The primary cache where the value was found
+ * @param secondaryValue The value of the secondary cache
+ * @param secondaryCache The secondary cache where the value was found
+ *
+ * @return The resolved cache value to be used (may be null)
+ * @deprecated This method exposes internal cache key handling and should not be used in general code.
+ */
+ @Deprecated(forRemoval = false)
+ @Nullable T resolveViaInternalKey(
+ K key,
+ @Nullable T primaryValue,
+ Cache primaryCache,
+ @Nullable T secondaryValue,
+ Cache secondaryCache) {
+ if (primaryValue == null && secondaryValue != null) {
+ primaryCache.putViaInternalKey(key, secondaryValue);
+ return secondaryValue;
+ }
+ if (primaryValue != null && secondaryValue == null) {
+ secondaryCache.putViaInternalKey(key, primaryValue);
+ }
+ return primaryValue;
+ }
+}
diff --git a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheType.java b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheType.java
new file mode 100644
index 00000000..db69b29d
--- /dev/null
+++ b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheType.java
@@ -0,0 +1,7 @@
+/* Licensed under MIT 2026. */
+package edu.kit.kastel.sdq.lissa.ratlr.cache;
+
+public enum CacheType {
+ LOCAL,
+ REDIS,
+}
diff --git a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/HierarchicalCache.java b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/HierarchicalCache.java
new file mode 100644
index 00000000..0b8e9102
--- /dev/null
+++ b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/HierarchicalCache.java
@@ -0,0 +1,112 @@
+/* Licensed under MIT 2025-2026. */
+package edu.kit.kastel.sdq.lissa.ratlr.cache;
+
+import java.util.Objects;
+
+import org.jspecify.annotations.Nullable;
+
+/**
+ * Implements a hierarchical cache that composes multiple cache implementations.
+ * This class manages synchronization and conflict resolution between multiple cache layers
+ * (e.g., Redis and local file cache), providing a unified view across the cache hierarchy.
+ *
+ * The cache hierarchy operates as follows:
+ * 1. Attempts to retrieve/store values in the primary cache
+ * 2. Falls back to secondary cache if missing in the primary
+ * 3. Automatically synchronizes values between layers when needed
+ * 4. Applies conflict resolution strategy when values differ between layers
+ *
+ * @param The type of cache key used in this cache
+ */
+class HierarchicalCache implements Cache {
+
+ private final CacheParameter cacheParameter;
+
+ /**
+ * Primary cache in the hierarchy (typically Redis).
+ */
+ private final Cache primaryCache;
+
+ /**
+ * Secondary cache in the hierarchy (typically local file cache).
+ */
+ private final Cache secondaryCache;
+
+ /**
+ * Strategy for resolving conflicts between cache layers.
+ */
+ private final CacheReplacementStrategy conflictResolution;
+
+ /**
+ * Creates a new hierarchical cache instance.
+ *
+ * @param cacheParameter The cache parameter configuration
+ * @param primaryCache The primary cache (e.g., Redis)
+ * @param secondaryCache The secondary cache (e.g., local file)
+ * @param conflictResolution Strategy for resolving conflicts between cache layers
+ */
+ HierarchicalCache(
+ CacheParameter cacheParameter,
+ Cache primaryCache,
+ Cache secondaryCache,
+ CacheReplacementStrategy conflictResolution) {
+ this.cacheParameter = Objects.requireNonNull(cacheParameter);
+ this.primaryCache = Objects.requireNonNull(primaryCache);
+ this.secondaryCache = Objects.requireNonNull(secondaryCache);
+ this.conflictResolution = Objects.requireNonNull(conflictResolution);
+ }
+
+ @Override
+ public synchronized @Nullable T get(String key, Class clazz) {
+ T primaryValue = primaryCache.get(key, clazz);
+ T secondaryValue = secondaryCache.get(key, clazz);
+ return conflictResolution.resolve(key, primaryValue, primaryCache, secondaryValue, secondaryCache);
+ }
+
+ @Override
+ @SuppressWarnings("deprecation")
+ public synchronized @Nullable T getViaInternalKey(K key, Class clazz) {
+ T primaryValue = primaryCache.getViaInternalKey(key, clazz);
+ T secondaryValue = secondaryCache.getViaInternalKey(key, clazz);
+ return conflictResolution.resolveViaInternalKey(
+ key, primaryValue, primaryCache, secondaryValue, secondaryCache);
+ }
+
+ @Override
+ public synchronized void put(String key, String value) {
+ primaryCache.put(key, value);
+ secondaryCache.put(key, value);
+ }
+
+ @Override
+ @SuppressWarnings("deprecation")
+ public synchronized void putViaInternalKey(K key, T value) {
+ primaryCache.putViaInternalKey(key, value);
+ secondaryCache.putViaInternalKey(key, value);
+ }
+
+ @Override
+ public synchronized void put(String key, T value) {
+ primaryCache.put(key, value);
+ secondaryCache.put(key, value);
+ }
+
+ @Override
+ public void flush() {
+ primaryCache.flush();
+ secondaryCache.flush();
+ }
+
+ @Override
+ public boolean containsKey(String key) {
+ if (primaryCache.containsKey(key)) {
+ return true;
+ }
+ return secondaryCache.containsKey(key);
+ }
+
+ @Override
+ public CacheParameter getCacheParameter() {
+ return cacheParameter;
+ }
+}
diff --git a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/LocalCache.java b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/LocalCache.java
index 5c8b3091..eff1d2fc 100644
--- a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/LocalCache.java
+++ b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/LocalCache.java
@@ -10,6 +10,7 @@
import org.jspecify.annotations.Nullable;
+import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -18,8 +19,11 @@
* This class provides a thread-safe implementation of a cache that persists its contents
* to a JSON file. It includes automatic flushing of changes when a certain threshold
* of modifications is reached.
+ *
+ * @param The type of cache key used in this cache
*/
-class LocalCache {
+class LocalCache implements Cache {
+
private final ObjectMapper mapper;
/**
@@ -47,10 +51,11 @@ class LocalCache {
* or a new file will be created.
*
* @param cacheFile The path to the cache file
+ * @param cacheParameter The cache parameter configuration
*/
LocalCache(String cacheFile, CacheParameter cacheParameter) {
- this.cacheParameter = cacheParameter;
- this.cacheFile = new File(cacheFile);
+ this.cacheParameter = Objects.requireNonNull(cacheParameter);
+ this.cacheFile = new File(Objects.requireNonNull(cacheFile));
mapper = new ObjectMapper();
createLocalStore();
}
@@ -114,15 +119,11 @@ public synchronized void write() {
}
}
- /**
- * Retrieves a value from the cache.
- *
- * @param key The cache key to look up
- * @return The cached value, or null if not found
- */
- public synchronized @Nullable String get(String key) {
+ @Override
+ public synchronized @Nullable T get(String key, Class clazz) {
K cacheKey = cacheParameter.createCacheKey(key);
- return cache.get(cacheKey.localKey());
+ String jsonData = cache.get(cacheKey.localKey());
+ return Cache.convert(jsonData, clazz, mapper);
}
/**
@@ -132,29 +133,17 @@ public synchronized void write() {
* @return The cached value, or null if not found
* @deprecated This method exposes internal cache key handling and should not be used in general code.
*/
+ @Override
@Deprecated(forRemoval = false)
- public synchronized @Nullable String getViaInternalKey(K key) {
- return cache.get(key.localKey());
+ public synchronized @Nullable T getViaInternalKey(K key, Class clazz) {
+ String jsonData = cache.get(key.localKey());
+ return Cache.convert(jsonData, clazz, mapper);
}
- /**
- * Stores a value in the cache.
- * If the value is different from the existing value (if any), the dirty counter is incremented.
- * If the dirty counter exceeds the maximum threshold, the cache is automatically flushed to disk.
- *
- * @param key The cache key to store the value under
- * @param value The value to store
- */
+ @Override
public synchronized void put(String key, String value) {
K cacheKey = cacheParameter.createCacheKey(key);
- String old = cache.put(cacheKey.localKey(), value);
- if (old == null || !old.equals(value)) {
- dirty++;
- }
-
- if (dirty > MAX_DIRTY) {
- write();
- }
+ putViaInternalKey(cacheKey, value);
}
/**
@@ -166,10 +155,17 @@ public synchronized void put(String key, String value) {
* @param value The value to store
* @deprecated This method exposes internal cache key handling and should not be used in general code.
*/
+ @Override
@Deprecated(forRemoval = false)
- public synchronized void putViaInternalKey(K cacheKey, String value) {
- String old = cache.put(cacheKey.localKey(), value);
- if (old == null || !old.equals(value)) {
+ public synchronized void putViaInternalKey(K cacheKey, T value) {
+ String jsonValue;
+ try {
+ jsonValue = mapper.writeValueAsString(Objects.requireNonNull(value));
+ } catch (JsonProcessingException e) {
+ throw new IllegalArgumentException("Could not serialize object", e);
+ }
+ String old = cache.put(cacheKey.localKey(), jsonValue);
+ if (old == null || !old.equals(jsonValue)) {
dirty++;
}
@@ -178,17 +174,30 @@ public synchronized void putViaInternalKey(K cacheKey, String value) {
}
}
+ @Override
+ public synchronized void put(String key, T value) {
+ K cacheKey = cacheParameter.createCacheKey(key);
+ putViaInternalKey(cacheKey, value);
+ }
+
+ @Override
+ public void flush() {
+ write();
+ }
+
/**
* Returns true if and only if this map contains a mapping for a key
*
* @param key The cache key to look up
* @return true if this map contains a mapping for the specified key
*/
+ @Override
public boolean containsKey(String key) {
K cacheKey = cacheParameter.createCacheKey(key);
return cache.containsKey(cacheKey.localKey());
}
+ @Override
public CacheParameter getCacheParameter() {
return this.cacheParameter;
}
diff --git a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/RedisCache.java b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/RedisCache.java
index be550e88..0028a4c7 100644
--- a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/RedisCache.java
+++ b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/RedisCache.java
@@ -5,116 +5,77 @@
import java.util.*;
import org.jspecify.annotations.Nullable;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import edu.kit.kastel.sdq.lissa.ratlr.utils.Environment;
+import redis.clients.jedis.RedisClient;
import redis.clients.jedis.UnifiedJedis;
/**
- * Implements a Redis-based cache with local file backup.
- * This class provides a caching mechanism that primarily uses Redis for storage,
- * with a local file cache as a fallback. It supports storing and retrieving both
- * string values and serialized objects.
+ * Implements a Redis-based cache for storing and retrieving values. For multi-layer caching with
+ * synchronization and conflict resolution, use {@link HierarchicalCache}.
*
- * The cache can operate in three modes:
- * 1. Redis-only: When Redis is available and local cache is not configured
- * 2. Local-only: When Redis is unavailable and local cache is configured
- * 3. Hybrid: When both Redis and local cache are available (default)
+ * The cache will fail to initialize if Redis is unavailable.
+ *
+ * @param The type of cache key used in this cache
*/
class RedisCache implements Cache {
- private static final Logger logger = LoggerFactory.getLogger(RedisCache.class);
private final CacheParameter cacheParameter;
-
- /**
- * Local file-based cache used as a backup.
- */
- private final @Nullable LocalCache localCache;
-
private final ObjectMapper mapper;
/**
* Redis client instance.
*/
- private @Nullable UnifiedJedis jedis;
-
- /**
- * Flag indicating whether to replace local cache entries on conflict with Redis values.
- */
- private final boolean replaceLocalCacheOnConflict;
+ private UnifiedJedis jedis;
/**
- * Creates a new Redis cache instance with an optional local cache backup.
+ * Creates a new Redis cache instance.
+ * This constructor will throw an exception if Redis is unavailable.
*
- * @param localCache The local cache to use as backup, or null if no backup is needed
- * @throws IllegalArgumentException If neither Redis nor local cache can be initialized
+ * @param cacheParameter The cache parameter configuration
+ * @param mapper The ObjectMapper for JSON operations
+ * @throws IllegalArgumentException If Redis connection cannot be established
*/
- RedisCache(
- CacheParameter cacheParameter, @Nullable LocalCache localCache, boolean replaceLocalCacheOnConflict) {
+ RedisCache(CacheParameter cacheParameter, ObjectMapper mapper) {
this.cacheParameter = Objects.requireNonNull(cacheParameter);
- this.localCache = localCache == null || !localCache.isReady() ? null : localCache;
- if (this.localCache != null && !this.getCacheParameter().equals(this.localCache.getCacheParameter())) {
- throw new IllegalArgumentException("Cache parameter of local cache does not match the one of Redis cache");
- }
-
- mapper = new ObjectMapper();
+ this.mapper = Objects.requireNonNull(mapper);
createRedisConnection();
- if (jedis == null && this.localCache == null) {
- throw new IllegalArgumentException("Could not create cache");
+ if (jedis == null) {
+ throw new IllegalArgumentException("Could not connect to Redis");
}
- this.replaceLocalCacheOnConflict = replaceLocalCacheOnConflict;
}
@Override
public void flush() {
- if (localCache != null) {
- localCache.write();
- }
+ // Redis doesn't require manual flushing
}
@Override
public boolean containsKey(String key) {
K cacheKey = cacheParameter.createCacheKey(key);
- if (jedis != null && jedis.exists(cacheKey.toJsonKey())) {
- return true;
- }
- return localCache != null && localCache.containsKey(key);
+ return jedis.exists(cacheKey.toJsonKey());
}
/**
* Establishes a connection to the Redis server.
* The Redis URL can be configured through the REDIS_URL environment variable.
- * If the connection fails, the cache will fall back to using only the local cache.
*/
private void createRedisConnection() {
- try {
- String redisUrl = "redis://localhost:6379";
- if (Environment.getenv("REDIS_URL") != null) {
- redisUrl = Environment.getenv("REDIS_URL");
- }
- jedis = new UnifiedJedis(redisUrl);
- // Check if connection is working
- jedis.ping();
- } catch (Exception e) {
- logger.warn("Could not connect to Redis, using file cache instead");
- jedis = null;
+ String redisUrl = "redis://localhost:6379";
+ if (Environment.getenv("REDIS_URL") != null) {
+ redisUrl = Environment.getenv("REDIS_URL");
}
+ jedis = RedisClient.create(redisUrl);
+ // Check if connection is working
+ jedis.ping();
}
/**
* Retrieves a value from the cache and deserializes it to the specified type.
- * The method first attempts to retrieve the value from Redis, and if not found,
- * falls back to the local cache.
- * If the value is found in the local cache and Redis is available, it will be synchronized to Redis.
- * If the value is found in Redis and the local cache is available, it will be synchronized to the local cache.
- * In case of a mismatch between Redis and local cache values, a warning is logged and the replacement strategy
- * is applied: if {@link #replaceLocalCacheOnConflict} is true, the Redis value takes precedence and replaces
- * the local cache value; otherwise, the Redis cache value is returned without modification.
*
* @param The type to deserialize the value to
* @param key The cache key to look up
@@ -122,86 +83,21 @@ private void createRedisConnection() {
* @return The deserialized value, or null if not found
*/
@Override
- public synchronized T get(String key, Class clazz) {
+ public synchronized @Nullable T get(String key, Class clazz) {
K cacheKey = cacheParameter.createCacheKey(key);
- String jsonData = jedis == null ? null : jedis.hget(cacheKey.toJsonKey(), "data");
- if (localCache == null) {
- return convert(jsonData, clazz);
- }
- String localData = localCache.get(key);
- // Value is in redis cache but not in local cache
- if (localData == null && jsonData != null) {
- localCache.put(key, jsonData);
- }
- // Value is in local cache but not in redis cache
- if (localData != null && jsonData == null && jedis != null) {
- jedis.hset(cacheKey.toJsonKey(), "data", localData);
- }
- // Value is in both caches, but they differ
- if (replaceLocalCacheOnConflict && jsonData != null && localData != null && !jsonData.equals(localData)) {
- logger.info("Cache inconsistency detected for key {}, using Redis value and replacing local one", key);
- localCache.put(key, jsonData);
- }
-
- String valueToReturn = jsonData != null ? jsonData : localData;
- return convert(valueToReturn, clazz);
+ String jsonData = jedis.hget(cacheKey.toJsonKey(), "data");
+ return Cache.convert(jsonData, clazz, mapper);
}
@Override
@SuppressWarnings("deprecation")
public synchronized @Nullable T getViaInternalKey(K cacheKey, Class clazz) {
- String jsonData = jedis == null ? null : jedis.hget(cacheKey.toJsonKey(), "data");
- if (localCache == null) {
- return convert(jsonData, clazz);
- }
- String localData = localCache.getViaInternalKey(cacheKey);
- // Value is in redis cache but not in local cache
- if (localData == null && jsonData != null) {
- localCache.putViaInternalKey(cacheKey, jsonData);
- }
- // Value is in local cache but not in redis cache
- if (localData != null && jsonData == null && jedis != null) {
- jedis.hset(cacheKey.toJsonKey(), "data", localData);
- }
- // Value is in both caches, but they differ
- if (replaceLocalCacheOnConflict && jsonData != null && localData != null && !jsonData.equals(localData)) {
- logger.info("Cache inconsistency detected for key {}, using Redis value and replacing local one", cacheKey);
- localCache.putViaInternalKey(cacheKey, jsonData);
- }
-
- String valueToReturn = jsonData != null ? jsonData : localData;
- return convert(valueToReturn, clazz);
- }
-
- /**
- * Converts a JSON string to an object of the specified type.
- * If the target type is String, the JSON string is returned as is.
- *
- * @param The type to convert to
- * @param jsonData The JSON string to convert
- * @param clazz The class of the target type
- * @return The converted object, or null if jsonData is null
- * @throws IllegalArgumentException If the JSON cannot be deserialized to the target type
- */
- @SuppressWarnings("unchecked")
- private @Nullable T convert(@Nullable String jsonData, Class clazz) {
- if (jsonData == null) {
- return null;
- }
- if (clazz == String.class) {
- return (T) jsonData;
- }
-
- try {
- return mapper.readValue(jsonData, clazz);
- } catch (JsonProcessingException e) {
- throw new IllegalArgumentException("Could not deserialize object", e);
- }
+ String jsonData = jedis.hget(cacheKey.toJsonKey(), "data");
+ return Cache.convert(jsonData, clazz, mapper);
}
/**
* Stores a string value in the cache.
- * The value is stored in both Redis (if available) and the local cache (if configured).
* When storing in Redis, a timestamp is also recorded.
*
* @param key The cache key to store the value under
@@ -210,14 +106,9 @@ public synchronized T get(String key, Class clazz) {
@Override
public synchronized void put(String key, String value) {
K cacheKey = cacheParameter.createCacheKey(key);
- if (jedis != null) {
- String jsonKey = cacheKey.toJsonKey();
- jedis.hset(jsonKey, "data", value);
- jedis.hset(jsonKey, "timestamp", String.valueOf(Instant.now().getEpochSecond()));
- }
- if (localCache != null) {
- localCache.put(key, value);
- }
+ String jsonKey = cacheKey.toJsonKey();
+ jedis.hset(jsonKey, "data", value);
+ jedis.hset(jsonKey, "timestamp", String.valueOf(Instant.now().getEpochSecond()));
}
/**
@@ -248,14 +139,9 @@ public synchronized void putViaInternalKey(K key, T value) {
} catch (JsonProcessingException e) {
throw new IllegalArgumentException("Could not serialize object", e);
}
- if (jedis != null) {
- String jsonKey = key.toJsonKey();
- jedis.hset(jsonKey, "data", data);
- jedis.hset(jsonKey, "timestamp", String.valueOf(Instant.now().getEpochSecond()));
- }
- if (localCache != null) {
- localCache.putViaInternalKey(key, data);
- }
+ String jsonKey = key.toJsonKey();
+ jedis.hset(jsonKey, "data", data);
+ jedis.hset(jsonKey, "timestamp", String.valueOf(Instant.now().getEpochSecond()));
}
@Override
diff --git a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/utils/Environment.java b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/utils/Environment.java
index 2c2dd4cb..427e7328 100644
--- a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/utils/Environment.java
+++ b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/utils/Environment.java
@@ -1,4 +1,4 @@
-/* Licensed under MIT 2025. */
+/* Licensed under MIT 2025-2026. */
package edu.kit.kastel.sdq.lissa.ratlr.utils;
import java.nio.file.Files;
@@ -34,7 +34,7 @@
public final class Environment {
private static final Logger logger = LoggerFactory.getLogger(Environment.class);
/** The loaded .env configuration, or null if no .env file exists */
- private static final @Nullable Dotenv DOTENV = load();
+ private static volatile @Nullable Dotenv dotenv = load();
private Environment() {
throw new IllegalAccessError("Utility class");
@@ -53,7 +53,7 @@ private Environment() {
* @return The value of the environment variable, or null if not found
*/
public static @Nullable String getenv(String key) {
- String dotenvValue = DOTENV == null ? null : DOTENV.get(key);
+ String dotenvValue = dotenv == null ? null : dotenv.get(key);
if (dotenvValue != null) return dotenvValue;
return System.getenv(key);
}
@@ -93,8 +93,8 @@ public static String getenvNonNull(String key) {
* @return The loaded Dotenv configuration, or null if no .env file exists
*/
private static synchronized @Nullable Dotenv load() {
- if (DOTENV != null) {
- return DOTENV;
+ if (dotenv != null) {
+ return dotenv;
}
if (Files.exists(Path.of(".env"))) {
@@ -104,4 +104,25 @@ public static String getenvNonNull(String key) {
return null;
}
}
+
+ /**
+ * Overwrites the current .env configuration with a new one from the specified path.
+ * This method:
+ *
+ * - Checks if a .env file exists at the given path
+ * - If found, loads and sets the new configuration
+ * - If not found, logs a warning and retains the existing configuration
+ *
+ *
+ * The method is synchronized to ensure thread safety when updating the configuration.
+ *
+ * @param path The path to the new .env file
+ */
+ public static synchronized void overwrite(Path path) {
+ if (Files.exists(path)) {
+ dotenv = Dotenv.configure().filename(path.toString()).load();
+ } else {
+ logger.warn("No .env file found at '{}', using system environment variables", path);
+ }
+ }
}
diff --git a/src/test/java/edu/kit/kastel/sdq/lissa/ratlr/ArchitectureTest.java b/src/test/java/edu/kit/kastel/sdq/lissa/ratlr/ArchitectureTest.java
index fa896498..db0086a5 100644
--- a/src/test/java/edu/kit/kastel/sdq/lissa/ratlr/ArchitectureTest.java
+++ b/src/test/java/edu/kit/kastel/sdq/lissa/ratlr/ArchitectureTest.java
@@ -29,6 +29,7 @@
import edu.kit.kastel.sdq.lissa.cli.command.OptimizeCommand;
import edu.kit.kastel.sdq.lissa.ratlr.cache.CacheKey;
+import edu.kit.kastel.sdq.lissa.ratlr.cache.CacheManager;
import edu.kit.kastel.sdq.lissa.ratlr.cache.CacheParameter;
import edu.kit.kastel.sdq.lissa.ratlr.cache.classifier.ClassifierCacheParameter;
import edu.kit.kastel.sdq.lissa.ratlr.cache.embedding.EmbeddingCacheParameter;
@@ -356,4 +357,34 @@ public void check(JavaClass clazz, ConditionEvents events) {
}
}
});
+
+ /**
+ * Rule that enforces that CacheManager.resetDefaultInstance() is only called from Test classes.
+ *
+ * The resetDefaultInstance() method should only be used to reset the singleton state between tests.
+ * It must never be called from production code or other test classes.
+ */
+ @ArchTest
+ static final ArchRule cacheManagerResetOnlyInTests = noClasses()
+ .that()
+ .haveNameNotMatching(".*Test.*")
+ .should()
+ .callMethod(CacheManager.class, "resetDefaultInstance")
+ .because(
+ "CacheManager.resetDefaultInstance() is only intended for testing purposes in CacheTest and must not be used elsewhere");
+
+ /**
+ * Rule that enforces that Environment.overwrite() is only called from test classes.
+ *
+ * The overwrite() method is intended for testing purposes to override environment variables.
+ * For production usage the regular .env file shall be used.
+ */
+ @ArchTest
+ static final ArchRule environmentOverwriteOnlyInTests = noClasses()
+ .that()
+ .haveNameNotMatching(".*Test.*")
+ .should()
+ .callMethod(Environment.class, "overwrite", Path.class)
+ .because(
+ "Environment.overwrite() is only intended for testing purposes and may not be used elsewhere. Use the regular .env instead.");
}
diff --git a/src/test/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheReplacementStrategyTest.java b/src/test/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheReplacementStrategyTest.java
new file mode 100644
index 00000000..2fbadaeb
--- /dev/null
+++ b/src/test/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheReplacementStrategyTest.java
@@ -0,0 +1,428 @@
+/* Licensed under MIT 2025-2026. */
+package edu.kit.kastel.sdq.lissa.ratlr.cache;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.nio.file.Path;
+import java.util.Objects;
+
+import org.jspecify.annotations.NullMarked;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import edu.kit.kastel.sdq.lissa.ratlr.utils.KeyGenerator;
+
+/**
+ * Comprehensive tests for CacheReplacementStrategy enum implementations.
+ * These tests verify correct conflict resolution behavior with various data types
+ * including strings, objects, and null values.
+ */
+@NullMarked
+class CacheReplacementStrategyTest {
+ private static final String TEST_KEY = "test-key";
+ private static final String TEST_VALUE = "test-value";
+ private static final TestObject TEST_OBJECT = new TestObject("test", 42);
+ private static final String CONFLICTING_VALUE = "conflicting-value";
+
+ @TempDir
+ private Path tempCacheDir;
+
+ private Cache primaryCache;
+ private Cache secondaryCache;
+ private TestCacheKey cacheKeyInstance;
+
+ @BeforeEach
+ void setUp() {
+ primaryCache = createLocalCache("primary");
+ secondaryCache = createLocalCache("secondary");
+ cacheKeyInstance = TestCacheKey.of(new TestCacheParameter(), "test");
+ }
+
+ /**
+ * Factory method to create a LocalCache instance for testing
+ */
+ private Cache createLocalCache(String cachePrefix) {
+ return new LocalCache<>(
+ tempCacheDir.resolve(cachePrefix + "_cache.json").toString(), new TestCacheParameter());
+ }
+
+ // ==================== NONE Strategy String Tests ====================
+
+ @Test
+ @DisplayName("NONE strategy: with string values - identical")
+ void testNoneStrategyStringIdentical() {
+ // Given both caches have identical values
+ primaryCache.put(TEST_KEY, TEST_VALUE);
+ secondaryCache.put(TEST_KEY, TEST_VALUE);
+ assertEquals(TEST_VALUE, primaryCache.get(TEST_KEY, String.class));
+ assertEquals(TEST_VALUE, secondaryCache.get(TEST_KEY, String.class));
+
+ CacheReplacementStrategy strategy = CacheReplacementStrategy.NONE;
+ String primaryValue = primaryCache.get(TEST_KEY, String.class);
+ String secondaryValue = secondaryCache.get(TEST_KEY, String.class);
+
+ // When resolving the values
+ String result = strategy.resolve(TEST_KEY, primaryValue, primaryCache, secondaryValue, secondaryCache);
+
+ // Then the primary value is returned and caches are unchanged
+ assertEquals(TEST_VALUE, result);
+ assertEquals(TEST_VALUE, primaryCache.get(TEST_KEY, String.class));
+ assertEquals(TEST_VALUE, secondaryCache.get(TEST_KEY, String.class));
+ }
+
+ @Test
+ @DisplayName("NONE strategy: with string values - conflicting")
+ void testNoneStrategyStringConflicting() {
+ // Given primary and secondary have different values
+ primaryCache.put(TEST_KEY, TEST_VALUE);
+ secondaryCache.put(TEST_KEY, CONFLICTING_VALUE);
+ assertEquals(TEST_VALUE, primaryCache.get(TEST_KEY, String.class));
+ assertEquals(CONFLICTING_VALUE, secondaryCache.get(TEST_KEY, String.class));
+
+ CacheReplacementStrategy strategy = CacheReplacementStrategy.NONE;
+ String primaryValue = primaryCache.get(TEST_KEY, String.class);
+ String secondaryValue = secondaryCache.get(TEST_KEY, String.class);
+
+ // When resolving the conflict
+ String result = strategy.resolve(TEST_KEY, primaryValue, primaryCache, secondaryValue, secondaryCache);
+
+ // Then the primary value is returned and caches remain unchanged
+ assertEquals(TEST_VALUE, result);
+ assertEquals(TEST_VALUE, primaryCache.get(TEST_KEY, String.class));
+ assertEquals(CONFLICTING_VALUE, secondaryCache.get(TEST_KEY, String.class));
+ }
+
+ @Test
+ @DisplayName("NONE strategy: with null primary")
+ void testNoneStrategyNullPrimary() {
+ // Given primary is null but secondary has a value
+ secondaryCache.put(TEST_KEY, TEST_VALUE);
+ assertNull(primaryCache.get(TEST_KEY, String.class));
+ assertEquals(TEST_VALUE, secondaryCache.get(TEST_KEY, String.class));
+
+ CacheReplacementStrategy strategy = CacheReplacementStrategy.NONE;
+ String primaryValue = primaryCache.get(TEST_KEY, String.class);
+ String secondaryValue = secondaryCache.get(TEST_KEY, String.class);
+
+ // When resolving with null primary
+ String result = strategy.resolve(TEST_KEY, primaryValue, primaryCache, secondaryValue, secondaryCache);
+
+ // Then the secondary value is backfilled to primary and returned
+ assertEquals(TEST_VALUE, result);
+ assertEquals(TEST_VALUE, primaryCache.get(TEST_KEY, String.class));
+ assertEquals(TEST_VALUE, secondaryCache.get(TEST_KEY, String.class));
+ }
+
+ @Test
+ @DisplayName("NONE strategy: with object values - deep equal but different instances")
+ void testNoneStrategyObjectDeepEqual() {
+ // Given both caches have objects with same content but different instances
+ TestObject obj1 = new TestObject("test", 42);
+ TestObject obj2 = new TestObject("test", 42);
+
+ primaryCache.put(TEST_KEY, obj1);
+ secondaryCache.put(TEST_KEY, obj2);
+ assertEquals(obj1, primaryCache.get(TEST_KEY, TestObject.class));
+ assertEquals(obj2, secondaryCache.get(TEST_KEY, TestObject.class));
+
+ CacheReplacementStrategy strategy = CacheReplacementStrategy.NONE;
+ TestObject primaryValue = primaryCache.get(TEST_KEY, TestObject.class);
+ TestObject secondaryValue = secondaryCache.get(TEST_KEY, TestObject.class);
+
+ // When resolving
+ TestObject result = strategy.resolve(TEST_KEY, primaryValue, primaryCache, secondaryValue, secondaryCache);
+
+ // Then primary value is returned (deep equal objects are considered same)
+ assertEquals(obj1, result);
+ assertEquals(obj2, result);
+ }
+
+ // ==================== ERROR Strategy Conflict Tests ====================
+
+ @Test
+ @DisplayName("ERROR strategy: throws when string values conflict")
+ void testErrorStrategyStringConflict() {
+ // Given primary and secondary have conflicting string values
+ primaryCache.put(TEST_KEY, TEST_VALUE);
+ secondaryCache.put(TEST_KEY, CONFLICTING_VALUE);
+ assertEquals(TEST_VALUE, primaryCache.get(TEST_KEY, String.class));
+ assertEquals(CONFLICTING_VALUE, secondaryCache.get(TEST_KEY, String.class));
+
+ CacheReplacementStrategy strategy = CacheReplacementStrategy.ERROR;
+ String primaryValue = primaryCache.get(TEST_KEY, String.class);
+ String secondaryValue = secondaryCache.get(TEST_KEY, String.class);
+
+ // When resolving conflicting values
+ // Then an exception is thrown
+ assertThrows(
+ IllegalStateException.class,
+ () -> strategy.resolve(TEST_KEY, primaryValue, primaryCache, secondaryValue, secondaryCache));
+ }
+
+ @Test
+ @DisplayName("ERROR strategy: tolerates null vs non-null in different layers")
+ void testErrorStrategyNullTolerance() {
+ // Given primary has a value but secondary is null
+ primaryCache.put(TEST_KEY, TEST_VALUE);
+ assertEquals(TEST_VALUE, primaryCache.get(TEST_KEY, String.class));
+ assertNull(secondaryCache.get(TEST_KEY, String.class));
+
+ CacheReplacementStrategy strategy = CacheReplacementStrategy.ERROR;
+ String primaryValue = primaryCache.get(TEST_KEY, String.class);
+ String secondaryValue = secondaryCache.get(TEST_KEY, String.class);
+
+ // When resolving with one null value
+ String result = strategy.resolve(TEST_KEY, primaryValue, primaryCache, secondaryValue, secondaryCache);
+
+ // Then no exception is thrown and primary value is returned
+ assertEquals(TEST_VALUE, result);
+ }
+
+ @Test
+ @DisplayName("ERROR strategy: accepts identical objects")
+ void testErrorStrategyIdenticalObjects() {
+ // Given both caches have equal objects
+ TestObject obj1 = new TestObject("test", 42);
+ TestObject obj2 = new TestObject("test", 42);
+
+ primaryCache.put(TEST_KEY, obj1);
+ secondaryCache.put(TEST_KEY, obj2);
+ assertEquals(obj1, primaryCache.get(TEST_KEY, TestObject.class));
+ assertEquals(obj2, secondaryCache.get(TEST_KEY, TestObject.class));
+
+ CacheReplacementStrategy strategy = CacheReplacementStrategy.ERROR;
+ TestObject primaryValue = primaryCache.get(TEST_KEY, TestObject.class);
+ TestObject secondaryValue = secondaryCache.get(TEST_KEY, TestObject.class);
+
+ // When resolving identical (deep equal) objects
+ TestObject result = strategy.resolve(TEST_KEY, primaryValue, primaryCache, secondaryValue, secondaryCache);
+
+ // Then no exception and primary value is returned
+ assertEquals(obj1, result);
+ }
+
+ // ==================== OVERWRITE Strategy Tests ====================
+
+ @Test
+ @DisplayName("OVERWRITE strategy: overwrites secondary on conflict")
+ void testOverwriteStrategyObjectConflict() {
+ // Given secondary has a different value
+ TestObject secondary = new TestObject("secondary", 2);
+
+ primaryCache.put(TEST_KEY, TEST_OBJECT);
+ secondaryCache.put(TEST_KEY, secondary);
+ assertEquals(TEST_OBJECT, primaryCache.get(TEST_KEY, TestObject.class));
+ assertEquals(secondary, secondaryCache.get(TEST_KEY, TestObject.class));
+
+ CacheReplacementStrategy strategy = CacheReplacementStrategy.OVERWRITE;
+ TestObject primaryValue = primaryCache.get(TEST_KEY, TestObject.class);
+ TestObject secondaryValue = secondaryCache.get(TEST_KEY, TestObject.class);
+
+ // When resolving the conflict
+ TestObject result = strategy.resolve(TEST_KEY, primaryValue, primaryCache, secondaryValue, secondaryCache);
+
+ // Then primary value is used and secondary is overwritten
+ assertEquals(TEST_OBJECT, result);
+ assertEquals(TEST_OBJECT, secondaryCache.get(TEST_KEY, TestObject.class));
+ }
+
+ @Test
+ @DisplayName("OVERWRITE strategy: does not overwrite when values are identical")
+ void testOverwriteStrategyNoOverwriteOnIdentical() {
+ // Given both caches have the same value
+ primaryCache.put(TEST_KEY, TEST_OBJECT);
+ secondaryCache.put(TEST_KEY, TEST_OBJECT);
+ assertEquals(TEST_OBJECT, primaryCache.get(TEST_KEY, TestObject.class));
+ assertEquals(TEST_OBJECT, secondaryCache.get(TEST_KEY, TestObject.class));
+
+ CacheReplacementStrategy strategy = CacheReplacementStrategy.OVERWRITE;
+ TestObject primaryValue = primaryCache.get(TEST_KEY, TestObject.class);
+ TestObject secondaryValue = secondaryCache.get(TEST_KEY, TestObject.class);
+
+ // When resolving identical values
+ TestObject result = strategy.resolve(TEST_KEY, primaryValue, primaryCache, secondaryValue, secondaryCache);
+
+ // Then no overwrite occurs
+ assertEquals(TEST_OBJECT, result);
+ }
+
+ @Test
+ @DisplayName("OVERWRITE strategy: does backfill when secondary is null")
+ void testOverwriteStrategyNoOverwriteWhenSecondaryNull() {
+ // Given primary has value but secondary is empty
+ primaryCache.put(TEST_KEY, TEST_OBJECT);
+ assertEquals(TEST_OBJECT, primaryCache.get(TEST_KEY, TestObject.class));
+ assertNull(secondaryCache.get(TEST_KEY, TestObject.class));
+
+ CacheReplacementStrategy strategy = CacheReplacementStrategy.OVERWRITE;
+ TestObject primaryValue = primaryCache.get(TEST_KEY, TestObject.class);
+ TestObject secondaryValue = secondaryCache.get(TEST_KEY, TestObject.class);
+
+ // When resolving with null secondary
+ TestObject result = strategy.resolve(TEST_KEY, primaryValue, primaryCache, secondaryValue, secondaryCache);
+
+ // Then value is backfilled to secondary
+ assertEquals(TEST_OBJECT, result);
+ assertEquals(TEST_OBJECT, secondaryCache.get(TEST_KEY, TestObject.class));
+ }
+
+ @Test
+ @DisplayName("OVERWRITE strategy: handles null primary with non-null secondary")
+ void testOverwriteStrategyNullPrimary() {
+ // Given secondary has value but primary is empty
+ secondaryCache.put(TEST_KEY, TEST_OBJECT);
+ assertNull(primaryCache.get(TEST_KEY, TestObject.class));
+ assertEquals(TEST_OBJECT, secondaryCache.get(TEST_KEY, TestObject.class));
+
+ CacheReplacementStrategy strategy = CacheReplacementStrategy.OVERWRITE;
+ TestObject primaryValue = primaryCache.get(TEST_KEY, TestObject.class);
+ TestObject secondaryValue = secondaryCache.get(TEST_KEY, TestObject.class);
+
+ // When resolving with null primary
+ TestObject result = strategy.resolve(TEST_KEY, primaryValue, primaryCache, secondaryValue, secondaryCache);
+
+ // Then secondary value is backfilled to primary
+ assertEquals(TEST_OBJECT, result);
+ assertEquals(TEST_OBJECT, primaryCache.get(TEST_KEY, TestObject.class));
+ }
+
+ // ==================== ViaInternalKey Strategy Tests ====================
+
+ @Test
+ @DisplayName("NONE strategy via internal key: string backfill to primary when secondary has value")
+ void testNoneStrategyViaInternalKeyStringBackfillPrimary() {
+ // Given secondary has a string value but primary is null
+ secondaryCache.putViaInternalKey(cacheKeyInstance, TEST_VALUE);
+ assertNull(primaryCache.getViaInternalKey(cacheKeyInstance, String.class));
+ assertEquals(TEST_VALUE, secondaryCache.getViaInternalKey(cacheKeyInstance, String.class));
+
+ CacheReplacementStrategy strategy = CacheReplacementStrategy.NONE;
+ String primaryValue = primaryCache.getViaInternalKey(cacheKeyInstance, String.class);
+ String secondaryValue = secondaryCache.getViaInternalKey(cacheKeyInstance, String.class);
+
+ // When resolving via internal key
+ String result = strategy.resolveViaInternalKey(
+ cacheKeyInstance, primaryValue, primaryCache, secondaryValue, secondaryCache);
+
+ // Then the secondary value is backfilled to primary using internal key
+ assertEquals(TEST_VALUE, result);
+ // Verify it was stored under the internal key, not the string representation of the key
+ assertEquals(TEST_VALUE, primaryCache.getViaInternalKey(cacheKeyInstance, String.class));
+ assertEquals(TEST_VALUE, secondaryCache.getViaInternalKey(cacheKeyInstance, String.class));
+ }
+
+ @Test
+ @DisplayName("OVERWRITE strategy via internal key: string overwrite of secondary cache")
+ void testOverwriteStrategyViaInternalKeyStringConflict() {
+ // Given both caches have different string values
+ primaryCache.putViaInternalKey(cacheKeyInstance, TEST_VALUE);
+ secondaryCache.putViaInternalKey(cacheKeyInstance, CONFLICTING_VALUE);
+ assertEquals(TEST_VALUE, primaryCache.getViaInternalKey(cacheKeyInstance, String.class));
+ assertEquals(CONFLICTING_VALUE, secondaryCache.getViaInternalKey(cacheKeyInstance, String.class));
+
+ CacheReplacementStrategy strategy = CacheReplacementStrategy.OVERWRITE;
+ String primaryValue = primaryCache.getViaInternalKey(cacheKeyInstance, String.class);
+ String secondaryValue = secondaryCache.getViaInternalKey(cacheKeyInstance, String.class);
+
+ // When resolving via internal key
+ String result = strategy.resolveViaInternalKey(
+ cacheKeyInstance, primaryValue, primaryCache, secondaryValue, secondaryCache);
+
+ // Then primary value overwrites secondary via internal key
+ assertEquals(TEST_VALUE, result);
+ // Verify the secondary was updated with the internal key, not a converted string key
+ assertEquals(TEST_VALUE, secondaryCache.getViaInternalKey(cacheKeyInstance, String.class));
+ }
+
+ @Test
+ @DisplayName("OVERWRITE strategy via internal key: string backfill to secondary when primary is null")
+ void testOverwriteStrategyViaInternalKeyStringBackfillSecondary() {
+ // Given primary is null but secondary has a string value
+ secondaryCache.putViaInternalKey(cacheKeyInstance, TEST_VALUE);
+ assertNull(primaryCache.getViaInternalKey(cacheKeyInstance, String.class));
+ assertEquals(TEST_VALUE, secondaryCache.getViaInternalKey(cacheKeyInstance, String.class));
+
+ CacheReplacementStrategy strategy = CacheReplacementStrategy.OVERWRITE;
+ String primaryValue = primaryCache.getViaInternalKey(cacheKeyInstance, String.class);
+ String secondaryValue = secondaryCache.getViaInternalKey(cacheKeyInstance, String.class);
+
+ // When resolving via internal key
+ String result = strategy.resolveViaInternalKey(
+ cacheKeyInstance, primaryValue, primaryCache, secondaryValue, secondaryCache);
+
+ // Then the secondary value is backfilled to primary using internal key
+ assertEquals(TEST_VALUE, result);
+ assertEquals(TEST_VALUE, primaryCache.getViaInternalKey(cacheKeyInstance, String.class));
+ }
+ // ==================== Helper Classes ====================
+
+ static class TestObject {
+ public String name;
+ public int value;
+
+ @SuppressWarnings("unused")
+ TestObject() {
+ // For Jackson deserialization
+ }
+
+ TestObject(String name, int value) {
+ this.name = name;
+ this.value = value;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (!(o instanceof TestObject that)) return false;
+ return value == that.value && Objects.equals(name, that.name);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(name, value);
+ }
+
+ @Override
+ public String toString() {
+ return "TestObject{" + "name='" + name + '\'' + ", value=" + value + '}';
+ }
+ }
+
+ static class TestCacheKey implements CacheKey {
+ private final String keyValue;
+
+ private TestCacheKey(String content) {
+ this.keyValue = KeyGenerator.generateKey(content);
+ }
+
+ static TestCacheKey of(CacheParameter cacheParameter, String content) {
+ // Access cacheParameter to satisfy architecture test requirements
+ String parameters = cacheParameter.parameters();
+ return new TestCacheKey(content + parameters);
+ }
+
+ @Override
+ public String localKey() {
+ return keyValue;
+ }
+
+ @Override
+ public String toString() {
+ return keyValue;
+ }
+ }
+
+ static class TestCacheParameter implements CacheParameter {
+ @Override
+ public String parameters() {
+ return "test-cache";
+ }
+
+ @Override
+ public TestCacheKey createCacheKey(String content) {
+ return TestCacheKey.of(this, content);
+ }
+ }
+}
diff --git a/src/test/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheTest.java b/src/test/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheTest.java
new file mode 100644
index 00000000..e2a8c6fe
--- /dev/null
+++ b/src/test/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheTest.java
@@ -0,0 +1,170 @@
+/* Licensed under MIT 2025-2026. */
+package edu.kit.kastel.sdq.lissa.ratlr.cache;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import org.jspecify.annotations.NullMarked;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import edu.kit.kastel.sdq.lissa.ratlr.utils.Environment;
+import edu.kit.kastel.sdq.lissa.ratlr.utils.KeyGenerator;
+
+/**
+ * Unit tests for LocalCache implementation.
+ * These tests ensure that the local cache correctly persists and retrieves cache entries
+ * while maintaining backward compatibility with existing cache files.
+ */
+@NullMarked
+class CacheTest {
+ @TempDir
+ private Path tempCacheDir;
+
+ @BeforeAll
+ static void init() {
+ Environment.overwrite(Path.of("src/test/resources/.env-test"));
+ }
+
+ @BeforeEach
+ void setup() throws IOException {
+ // Reset the default cache manager singleton for each test
+ CacheManager.setCacheDir(tempCacheDir.toString());
+ }
+
+ @AfterEach
+ void teardown() {
+ // Clean up the cache manager after each test
+ CacheManager.resetDefaultInstance();
+ }
+
+ @Test
+ @DisplayName("New cache entries are written to cache file")
+ void testWriteNewEntry() throws IOException {
+ Cache cache = createLocalCache();
+
+ cache.put("key1", "value1");
+ cache.flush();
+
+ Path cacheFile = tempCacheDir.resolve("test_cache.json");
+ assertTrue(Files.exists(cacheFile));
+ String content = Files.readString(cacheFile);
+ assertTrue(content.contains("value1"));
+ }
+
+ @Test
+ @DisplayName("Existing cache entries are retrieved from cache file")
+ void testRetrieveExistingEntry() {
+ Cache cache1 = createLocalCache();
+ cache1.put("key1", "value1");
+ cache1.flush();
+
+ Cache cache2 = createLocalCache();
+ String value = cache2.get("key1", String.class);
+
+ assertEquals("value1", value);
+ }
+
+ @Test
+ @DisplayName("Objects are serialized and deserialized correctly")
+ void testObjectSerialization() {
+ Cache cache = createLocalCache();
+ TestObject obj = new TestObject("test", 42);
+ cache.put("key1", obj);
+ cache.flush();
+
+ Cache cache2 = createLocalCache();
+ TestObject retrieved = cache2.get("key1", TestObject.class);
+
+ assertNotNull(retrieved);
+ assertEquals("test", retrieved.name);
+ assertEquals(42, retrieved.value);
+ }
+
+ @Test
+ @DisplayName("Legacy cache files are backward compatible")
+ void testBackwardCompatibility() throws IOException {
+ Path sourceCacheFile = Path.of("src/test/resources/cache/test-local-cache-sample.json");
+ Path cacheFile = tempCacheDir.resolve("test_cache.json");
+ Files.copy(sourceCacheFile, cacheFile);
+
+ Cache cache = createLocalCache();
+ String value1 = cache.get("test-key-1", String.class);
+ String value2 = cache.get("test-key-2", String.class);
+ String value3 = cache.get("test-key-3", String.class);
+
+ assertEquals("test-value-1", value1);
+ assertEquals("test-value-2", value2);
+ assertEquals("test-value-3", value3);
+ }
+
+ // Helper classes and methods
+
+ /**
+ * Simple test object for serialization/deserialization testing
+ */
+ static class TestObject {
+ public String name = "";
+ public int value;
+
+ @SuppressWarnings("unused")
+ TestObject() {
+ // For Jackson deserialization
+ }
+
+ TestObject(String name, int value) {
+ this.name = name;
+ this.value = value;
+ }
+ }
+
+ /**
+ * Mock CacheKey implementation for testing
+ */
+ static class TestCacheKey implements CacheKey {
+ private final String localKeyValue;
+
+ private TestCacheKey(String content) {
+ this.localKeyValue = KeyGenerator.generateKey(content);
+ }
+
+ @SuppressWarnings("unused")
+ static TestCacheKey of(CacheParameter cacheParameter, String content) {
+ return new TestCacheKey(content);
+ }
+
+ @Override
+ public String localKey() {
+ return localKeyValue;
+ }
+ }
+
+ /**
+ * Mock CacheParameter implementation for testing
+ */
+ static class TestCacheParameter implements CacheParameter {
+ @Override
+ public String parameters() {
+ return "test-cache";
+ }
+
+ @Override
+ public TestCacheKey createCacheKey(String content) {
+ return TestCacheKey.of(this, content);
+ }
+ }
+
+ /**
+ * Factory method to create a LocalCache instance for testing
+ */
+ private Cache createLocalCache() {
+ return new LocalCache<>(tempCacheDir.resolve("test_cache.json").toString(), new TestCacheParameter());
+ }
+}
diff --git a/src/test/java/edu/kit/kastel/sdq/lissa/ratlr/cache/HierarchicalCacheTest.java b/src/test/java/edu/kit/kastel/sdq/lissa/ratlr/cache/HierarchicalCacheTest.java
new file mode 100644
index 00000000..50393975
--- /dev/null
+++ b/src/test/java/edu/kit/kastel/sdq/lissa/ratlr/cache/HierarchicalCacheTest.java
@@ -0,0 +1,151 @@
+/* Licensed under MIT 2025-2026. */
+package edu.kit.kastel.sdq.lissa.ratlr.cache;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+import org.jspecify.annotations.NullMarked;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+
+import edu.kit.kastel.sdq.lissa.ratlr.utils.KeyGenerator;
+
+/**
+ * Tests for HierarchicalCache layer synchronization behavior.
+ * These tests verify that HierarchicalCache correctly synchronizes reads and writes across
+ * multiple cache layers without requiring a real Redis or Docker instance.
+ *
+ * For tests of conflict resolution strategies, see {@link CacheReplacementStrategyTest}.
+ */
+@NullMarked
+@MockitoSettings(strictness = Strictness.LENIENT)
+@ExtendWith(MockitoExtension.class)
+class HierarchicalCacheTest {
+ private static final String TEST_KEY = "test-key";
+
+ @Mock
+ private Cache primaryCache;
+
+ @Mock
+ private Cache secondaryCache;
+
+ @Mock
+ private CacheParameter cacheParameter;
+
+ private TestCacheKey cacheKeyInstance;
+
+ @BeforeEach
+ void setUp() {
+ cacheKeyInstance = TestCacheKey.of(cacheParameter, "test");
+ when(cacheParameter.createCacheKey(anyString())).thenReturn(cacheKeyInstance);
+ }
+
+ @Test
+ @DisplayName("put() writes to both primary and secondary cache")
+ void testPutObjectWritesToBothCaches() {
+ HierarchicalCache cache =
+ new HierarchicalCache<>(cacheParameter, primaryCache, secondaryCache, CacheReplacementStrategy.NONE);
+
+ TestObject testObj = new TestObject("test", 42);
+ cache.put(TEST_KEY, testObj);
+
+ verify(primaryCache).put(eq(TEST_KEY), same(testObj));
+ verify(secondaryCache).put(eq(TEST_KEY), same(testObj));
+ }
+
+ @Test
+ @DisplayName("containsKey() returns true if primary cache contains key")
+ void testContainsKeyInPrimary() {
+ HierarchicalCache cache =
+ new HierarchicalCache<>(cacheParameter, primaryCache, secondaryCache, CacheReplacementStrategy.NONE);
+
+ when(primaryCache.containsKey(TEST_KEY)).thenReturn(true);
+ when(secondaryCache.containsKey(TEST_KEY)).thenReturn(false);
+
+ assertTrue(cache.containsKey(TEST_KEY));
+ }
+
+ @Test
+ @DisplayName("containsKey() returns true if secondary cache contains key")
+ void testContainsKeyInSecondary() {
+ HierarchicalCache cache =
+ new HierarchicalCache<>(cacheParameter, primaryCache, secondaryCache, CacheReplacementStrategy.NONE);
+
+ when(primaryCache.containsKey(TEST_KEY)).thenReturn(false);
+ when(secondaryCache.containsKey(TEST_KEY)).thenReturn(true);
+
+ assertTrue(cache.containsKey(TEST_KEY));
+ }
+
+ @Test
+ @DisplayName("containsKey() returns false if neither cache contains key")
+ void testContainsKeyInNeither() {
+ HierarchicalCache cache =
+ new HierarchicalCache<>(cacheParameter, primaryCache, secondaryCache, CacheReplacementStrategy.NONE);
+
+ when(primaryCache.containsKey(TEST_KEY)).thenReturn(false);
+ when(secondaryCache.containsKey(TEST_KEY)).thenReturn(false);
+
+ assertFalse(cache.containsKey(TEST_KEY));
+ }
+
+ @Test
+ @DisplayName("flush() flushes both caches")
+ void testFlushBothCaches() {
+ HierarchicalCache cache =
+ new HierarchicalCache<>(cacheParameter, primaryCache, secondaryCache, CacheReplacementStrategy.NONE);
+
+ cache.flush();
+
+ verify(primaryCache).flush();
+ verify(secondaryCache).flush();
+ }
+
+ // ==================== Helper Classes ====================
+
+ static class TestObject {
+ public String name;
+ public int value;
+
+ @SuppressWarnings("unused")
+ TestObject() {
+ // For Jackson deserialization
+ }
+
+ TestObject(String name, int value) {
+ this.name = name;
+ this.value = value;
+ }
+ }
+
+ static class TestCacheKey implements CacheKey {
+ private final String keyValue;
+
+ private TestCacheKey(String content) {
+ this.keyValue = KeyGenerator.generateKey(content);
+ }
+
+ static TestCacheKey of(CacheParameter cacheParameter, String content) {
+ // Access cacheParameter to satisfy architecture test requirements
+ String parameters = cacheParameter.parameters();
+ return new TestCacheKey(content + parameters);
+ }
+
+ @Override
+ public String toJsonKey() {
+ return keyValue;
+ }
+
+ @Override
+ public String localKey() {
+ return keyValue;
+ }
+ }
+}
diff --git a/src/test/java/edu/kit/kastel/sdq/lissa/ratlr/e2e/Requirement2RequirementE2ETest.java b/src/test/java/edu/kit/kastel/sdq/lissa/ratlr/e2e/Requirement2RequirementE2ETest.java
index 30aa038d..7a4298b0 100644
--- a/src/test/java/edu/kit/kastel/sdq/lissa/ratlr/e2e/Requirement2RequirementE2ETest.java
+++ b/src/test/java/edu/kit/kastel/sdq/lissa/ratlr/e2e/Requirement2RequirementE2ETest.java
@@ -5,7 +5,6 @@
import static edu.kit.kastel.sdq.lissa.ratlr.Statistics.getTraceLinksFromGoldStandard;
import java.io.File;
-import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
@@ -13,7 +12,7 @@
import java.util.stream.Stream;
import org.junit.jupiter.api.Assertions;
-import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
@@ -23,20 +22,13 @@
import edu.kit.kastel.sdq.lissa.ratlr.Evaluation;
import edu.kit.kastel.sdq.lissa.ratlr.Optimization;
import edu.kit.kastel.sdq.lissa.ratlr.knowledge.TraceLink;
+import edu.kit.kastel.sdq.lissa.ratlr.utils.Environment;
class Requirement2RequirementE2ETest {
- @BeforeEach
- void setUp() throws IOException {
- File envFile = new File(".env");
- if (!envFile.exists() && System.getenv("CI") != null) {
- Files.writeString(envFile.toPath(), """
-OLLAMA_EMBEDDING_HOST=http://localhost:11434
-OLLAMA_HOST=http://localhost:11434
-OPENAI_ORGANIZATION_ID=DUMMY
-OPENAI_API_KEY=sk-DUMMY
-""");
- }
+ @BeforeAll
+ static void init() {
+ Environment.overwrite(Path.of("src/test/resources/.env-test"));
}
@Test
diff --git a/src/test/resources/.env-test b/src/test/resources/.env-test
new file mode 100644
index 00000000..47a579c4
--- /dev/null
+++ b/src/test/resources/.env-test
@@ -0,0 +1,6 @@
+OLLAMA_EMBEDDING_HOST=http://localhost:11434
+OLLAMA_HOST=http://localhost:11434
+OPENAI_ORGANIZATION_ID=DUMMY
+OPENAI_API_KEY=DUMMY
+CACHE_HIERARCHY=LOCAL
+CACHE_REPLACEMENT_STRATEGY=ERROR
diff --git a/src/test/resources/cache/test-local-cache-sample.json b/src/test/resources/cache/test-local-cache-sample.json
new file mode 100644
index 00000000..393112d8
--- /dev/null
+++ b/src/test/resources/cache/test-local-cache-sample.json
@@ -0,0 +1 @@
+{"a77ed447-329b-3d78-9206-894fe61c39c4":"test-value-3","2f80bb0a-fd35-369d-ba09-af947f0de0cf":"test-value-2","478af172-93d7-3e4e-87dc-4d7e36ce3d77":"test-value-1"}
\ No newline at end of file