If the file already exists, the existing configuration is returned.
+ * @param path configuration path, must not be {@code null}
+ * @param def fallback value, must not be {@code null}
+ * @return resolved value, never {@code null}
*
- * @param file configuration file, must not be {@code null}
- * @return result object describing the outcome
+ * @since 1.1.0
*/
- public @NotNull ConfigLoadResult tryCreate(@NotNull File file) {
-
- if (file.exists()) {
- try {
- return new ConfigLoadResult(
- ConfigLoadStatus.ALREADY_EXISTS,
- get(file),
- null
- );
- } catch (Exception e) {
- return new ConfigLoadResult(ConfigLoadStatus.IO_ERROR, null, e);
- }
- }
-
- try {
- ensureParentDirectories(file);
- YamlConfig config = loadInternal(file);
- return new ConfigLoadResult(ConfigLoadStatus.CREATED, config, null);
- } catch (Exception e) {
- return new ConfigLoadResult(ConfigLoadStatus.IO_ERROR, null, e);
- }
+ public @NotNull String getStringOrDefault(@NotNull String path, @NotNull String def) {
+ String value = config.getString(path);
+ return value != null ? value : def;
}
/**
- * Stops tracking the specified configuration file.
+ * Returns an integer value or a default.
*
- * @param file configuration file, must not be {@code null}
+ * @param path configuration path, must not be {@code null}
+ * @param def fallback value
+ * @return resolved value
+ *
+ * @since 1.1.0
*/
- public void unload(@NotNull File file) {
- watcher.unregister(normalize(file.toPath()));
+ public int getIntOrDefault(@NotNull String path, int def) {
+ return config.getInt(path, def);
}
/**
- * Returns all currently tracked configuration snapshots.
+ * Returns a boolean value or a default.
+ *
+ * @param path configuration path, must not be {@code null}
+ * @param def fallback value
+ * @return resolved value
*
- * @return collection of configurations, never {@code null}
+ * @since 1.1.0
*/
- public @NotNull Collection getAll() {
- return watcher.getAll();
+ public boolean getBooleanOrDefault(@NotNull String path, boolean def) {
+ return config.getBoolean(path, def);
}
/**
- * Internal method responsible for loading and registering configurations.
+ * Returns a double value or a default.
*
- * @param file configuration file, must not be {@code null}
- * @return loaded {@link YamlConfig} snapshot
+ * @param path configuration path, must not be {@code null}
+ * @param def fallback value
+ * @return resolved value
+ *
+ * @since 1.1.0
*/
- private @NotNull YamlConfig loadInternal(@NotNull File file) {
-
- ensureParentDirectories(file);
-
- YamlConfig config = new YamlConfig(
- file,
- YamlConfiguration.loadConfiguration(file)
- );
-
- try {
- watcher.register(config);
- } catch (IOException e) {
- throw new RuntimeException("Failed to register watcher for " + file, e);
- }
-
- return config;
+ public double getDoubleOrDefault(@NotNull String path, double def) {
+ return config.getDouble(path, def);
}
/**
- * Ensures that the parent directories of the file exist.
+ * Returns a float value or a default.
*
- * @param file file whose parent directories should be verified
+ * @param path configuration path, must not be {@code null}
+ * @param def fallback value
+ * @return resolved value
*
- * @throws IllegalStateException if directory creation fails
+ * @since 1.1.0
*/
- private void ensureParentDirectories(@NotNull File file) {
-
- File parent = file.getParentFile();
-
- if (parent != null && !parent.exists()) {
- if (!parent.mkdirs() && !parent.exists()) {
- throw new IllegalStateException("Failed to create directories: " + parent);
- }
- }
+ public float getFloatOrDefault(@NotNull String path, float def) {
+ return (float) config.getDouble(path, def);
}
/**
- * Normalizes a path to ensure consistent identity.
+ * Checks whether a value exists at the given path.
+ *
+ * @param path configuration path, must not be {@code null}
+ * @return {@code true} if present, otherwise {@code false}
*
- * @param path raw path, must not be {@code null}
- * @return normalized absolute path
+ * @since 1.1.0
*/
- private static @NotNull Path normalize(@NotNull Path path) {
- return path.toAbsolutePath().normalize();
+ public boolean has(@NotNull String path) {
+ return config.contains(path);
}
}
\ No newline at end of file
diff --git a/src/main/java/dev/spexx/configurationAPI/events/ConfigReloadedEvent.java b/src/main/java/dev/spexx/configurationAPI/events/ConfigReloadedEvent.java
index cd5b84e..f51d3cf 100644
--- a/src/main/java/dev/spexx/configurationAPI/events/ConfigReloadedEvent.java
+++ b/src/main/java/dev/spexx/configurationAPI/events/ConfigReloadedEvent.java
@@ -23,6 +23,13 @@
* All fields are immutable and represent a complete snapshot of the reload
* operation at the time the event was created.
*
+ * Event Guarantees
+ *
+ * - Both {@link #getOldConfig()} and {@link #getNewConfig()} are non-null
+ * - Checksums represent valid SHA-256 hashes of the file contents
+ * - The event is only fired when a real content change is detected
+ *
+ *
* @apiNote
* Consumers should treat {@link YamlConfig} instances as read-only snapshots.
* To obtain the most recent configuration state, query the managing component
@@ -34,31 +41,43 @@ public final class ConfigReloadedEvent extends Event {
/**
* Static handler list required by the Bukkit event system.
+ *
+ * @since 1.0.5
*/
private static final HandlerList HANDLERS = new HandlerList();
/**
* Previous configuration snapshot before reload.
+ *
+ * @since 1.0.5
*/
private final @NotNull YamlConfig oldConfig;
/**
* Updated configuration snapshot after reload.
+ *
+ * @since 1.0.5
*/
private final @NotNull YamlConfig newConfig;
/**
* SHA-256 checksum of the configuration file before reload.
+ *
+ * @since 1.0.5
*/
private final @NotNull String oldChecksum;
/**
* SHA-256 checksum of the configuration file after reload.
+ *
+ * @since 1.0.5
*/
private final @NotNull String newChecksum;
/**
* Time required to reload the configuration, measured in milliseconds.
+ *
+ * @since 1.0.5
*/
private final int reloadTimeMs;
@@ -68,11 +87,18 @@ public final class ConfigReloadedEvent extends Event {
* This constructor is typically invoked internally by the configuration
* watcher after detecting and applying a file change.
*
+ * All parameters are required and must represent a valid transition
+ * from one configuration state to another.
+ *
* @param oldConfig previous configuration snapshot, must not be {@code null}
* @param newConfig updated configuration snapshot, must not be {@code null}
* @param oldChecksum checksum of the file before reload, must not be {@code null}
* @param newChecksum checksum of the file after reload, must not be {@code null}
* @param reloadTimeMs time taken to reload the configuration in milliseconds
+ *
+ * @throws NullPointerException if any non-null parameter is {@code null}
+ *
+ * @since 1.0.5
*/
public ConfigReloadedEvent(
@NotNull YamlConfig oldConfig,
@@ -95,6 +121,8 @@ public ConfigReloadedEvent(
* was applied.
*
* @return previous configuration snapshot, never {@code null}
+ *
+ * @since 1.0.5
*/
public @NotNull YamlConfig getOldConfig() {
return oldConfig;
@@ -107,6 +135,8 @@ public ConfigReloadedEvent(
* contents of the configuration file.
*
* @return updated configuration snapshot, never {@code null}
+ *
+ * @since 1.0.5
*/
public @NotNull YamlConfig getNewConfig() {
return newConfig;
@@ -116,6 +146,8 @@ public ConfigReloadedEvent(
* Returns the checksum of the configuration file before reload.
*
* @return previous SHA-256 checksum, never {@code null}
+ *
+ * @since 1.0.5
*/
public @NotNull String getOldChecksum() {
return oldChecksum;
@@ -125,6 +157,8 @@ public ConfigReloadedEvent(
* Returns the checksum of the configuration file after reload.
*
* @return new SHA-256 checksum, never {@code null}
+ *
+ * @since 1.0.5
*/
public @NotNull String getNewChecksum() {
return newChecksum;
@@ -138,6 +172,8 @@ public ConfigReloadedEvent(
* configuration snapshot.
*
* @return reload duration in milliseconds
+ *
+ * @since 1.0.5
*/
public int getReloadTimeMs() {
return reloadTimeMs;
@@ -147,6 +183,8 @@ public int getReloadTimeMs() {
* Returns the handler list for this event instance.
*
* @return handler list, never {@code null}
+ *
+ * @since 1.0.5
*/
@Override
public @NotNull HandlerList getHandlers() {
@@ -157,6 +195,8 @@ public int getReloadTimeMs() {
* Returns the static handler list required by the Bukkit event system.
*
* @return static handler list, never {@code null}
+ *
+ * @since 1.0.5
*/
public static @NotNull HandlerList getHandlerList() {
return HANDLERS;
diff --git a/src/main/java/dev/spexx/configurationAPI/manager/ConfigManager.java b/src/main/java/dev/spexx/configurationAPI/manager/ConfigManager.java
index 0142344..038efc5 100644
--- a/src/main/java/dev/spexx/configurationAPI/manager/ConfigManager.java
+++ b/src/main/java/dev/spexx/configurationAPI/manager/ConfigManager.java
@@ -7,7 +7,6 @@
import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.plugin.java.JavaPlugin;
import org.jetbrains.annotations.NotNull;
-import org.jetbrains.annotations.Nullable;
import java.io.File;
import java.io.IOException;
@@ -30,17 +29,6 @@
* configuration state. This class delegates all state management to the watcher
* and does not maintain its own cache.
*
- * Lifecycle
- * This class provides multiple levels of control over configuration handling:
- *
- * - {@link #get(File)} — retrieve an already loaded configuration
- * - {@link #getOrLoad(File)} — retrieve or load if missing
- * - {@link #load(File)} — explicitly load and register
- * - {@link #createIfMissing(File)} — create file if needed, then load
- * - {@link #tryLoad(File)} — safe load with structured result
- * - {@link #tryCreate(File)} — safe create with structured result
- *
- *
* Thread Safety
* This class is thread-safe. The underlying watcher uses concurrent data structures
* and atomic replacement of {@link YamlConfig} instances.
@@ -58,13 +46,15 @@ public final class ConfigManager {
/**
* Creates a new {@code ConfigManager}.
*
- * This initializes and starts the {@link GlobalConfigWatcher}, which begins
+ *
Initializes and starts the {@link GlobalConfigWatcher}, which begins
* monitoring registered configuration files for changes.
*
* @param plugin owning plugin instance, must not be {@code null}
*
* @throws NullPointerException if {@code plugin} is {@code null}
* @throws RuntimeException if watcher initialization fails
+ *
+ * @since 1.1.0
*/
public ConfigManager(@NotNull JavaPlugin plugin) {
this.plugin = Objects.requireNonNull(plugin, "plugin");
@@ -80,16 +70,19 @@ public ConfigManager(@NotNull JavaPlugin plugin) {
/**
* Returns the latest configuration snapshot for the given file.
*
- * The configuration must have been previously loaded using one of the
- * loading methods. This method does not attempt to load the file.
+ * This method does not attempt to load the file. The configuration must
+ * already be registered.
*
* @param file configuration file, must not be {@code null}
* @return latest {@link YamlConfig} snapshot, never {@code null}
*
* @throws NullPointerException if {@code file} is {@code null}
* @throws IllegalStateException if the configuration is not loaded
+ *
+ * @since 1.1.0
*/
public @NotNull YamlConfig get(@NotNull File file) {
+
Path path = normalize(file.toPath());
YamlConfig config = watcher.get(path);
@@ -103,15 +96,16 @@ public ConfigManager(@NotNull JavaPlugin plugin) {
/**
* Returns an existing configuration or loads it if not already tracked.
*
- * If the configuration is not registered, it will be loaded from disk
- * and registered with the watcher.
+ * If the configuration is not registered, it is loaded and registered
+ * with the watcher.
*
* @param file configuration file, must not be {@code null}
* @return latest {@link YamlConfig} snapshot, never {@code null}
*
- * @throws NullPointerException if {@code file} is {@code null}
+ * @since 1.1.0
*/
public @NotNull YamlConfig getOrLoad(@NotNull File file) {
+
Path path = normalize(file.toPath());
YamlConfig existing = watcher.get(path);
@@ -125,49 +119,35 @@ public ConfigManager(@NotNull JavaPlugin plugin) {
/**
* Loads a configuration file intended for internal plugin usage.
*
- * This method guarantees that the configuration file exists by copying it
- * from the plugin JAR if it is not already present in the plugin's data folder.
- *
- * If the file already exists, it is simply loaded and returned.
+ * This method guarantees that the file exists by copying it from the
+ * plugin JAR if missing.
*
- * Behavior:
+ * Behavior
*
- * - If the file does not exist, it is copied from the plugin resources
- * - If the file exists, it is loaded normally
- * - The configuration is always registered with the watcher
+ * - Validates that the resource exists in the plugin JAR
+ * - Copies the resource if the file does not exist
+ * - Loads and registers the configuration
*
*
- * Failure Conditions:
- * This method will throw an exception if the resource does not exist inside
- * the plugin JAR. It is intended strictly for internal configuration files that
- * are guaranteed to be packaged with the plugin.
+ * @param resourceName resource name (e.g. {@code "config.yml"})
+ * @return loaded configuration snapshot
*
- * Example usage:
- *
- * YamlConfig config = manager.getInternal("config.yml");
- *
+ * @throws IllegalArgumentException if resource is missing in JAR
*
- * @param resourceName name of the resource inside the plugin JAR (e.g. {@code "config.yml"}), must not be {@code null}
- * @return loaded {@link YamlConfig} snapshot, never {@code null}
- *
- * @throws NullPointerException if {@code resourceName} is {@code null}
- * @throws IllegalArgumentException if the resource does not exist in the plugin JAR
- * @throws RuntimeException if loading or registration fails
+ * @since 1.1.0
*/
public @NotNull YamlConfig getInternal(@NotNull String resourceName) {
Objects.requireNonNull(resourceName, "resourceName");
- // Validate resource exists in JAR
+ // Ensure resource exists inside JAR
if (plugin.getResource(resourceName) == null) {
- throw new IllegalArgumentException(
- "Resource not found in plugin JAR: " + resourceName
- );
+ throw new IllegalArgumentException("Resource not found in plugin JAR: " + resourceName);
}
File file = new File(plugin.getDataFolder(), resourceName);
- // Copy if missing
+ // Copy resource if missing
if (!file.exists()) {
plugin.saveResource(resourceName, false);
}
@@ -175,122 +155,100 @@ public ConfigManager(@NotNull JavaPlugin plugin) {
return getOrLoad(file);
}
- /**
- * Explicitly loads a configuration file and registers it with the watcher.
- *
- * This method always attempts to load the file regardless of its current
- * registration state.
- *
- * @param file configuration file, must not be {@code null}
- * @return loaded {@link YamlConfig} snapshot, never {@code null}
- *
- * @throws NullPointerException if {@code file} is {@code null}
- * @throws RuntimeException if loading or registration fails
- */
- public @NotNull YamlConfig load(@NotNull File file) {
- return loadInternal(file);
- }
-
- /**
- * Ensures the configuration file exists, creating parent directories if needed,
- * and then loads and registers it.
- *
- * @param file configuration file, must not be {@code null}
- * @return loaded {@link YamlConfig} snapshot, never {@code null}
- *
- * @throws NullPointerException if {@code file} is {@code null}
- * @throws RuntimeException if loading or registration fails
- */
- public @NotNull YamlConfig createIfMissing(@NotNull File file) {
- if (!file.exists()) {
- ensureParentDirectories(file);
- }
- return loadInternal(file);
- }
-
/**
* Attempts to load a configuration file safely.
*
- * This method does not throw exceptions. Instead, it returns a structured
- * {@link ConfigLoadResult} describing the outcome.
+ * Never throws exceptions. Instead, returns a structured result.
*
- * @param file configuration file, must not be {@code null}
- * @return result object containing status, configuration, or error
+ * @param file configuration file
+ * @return result describing outcome
+ *
+ * @since 1.1.0
*/
public @NotNull ConfigLoadResult tryLoad(@NotNull File file) {
try {
- YamlConfig config = loadInternal(file);
- return new ConfigLoadResult(ConfigLoadStatus.LOADED, config, null);
+ return new ConfigLoadResult(ConfigLoadStatus.LOADED, loadInternal(file), null);
} catch (Exception e) {
return new ConfigLoadResult(ConfigLoadStatus.IO_ERROR, null, e);
}
}
/**
- * Attempts to create and load a configuration file safely.
+ * Attempts to create and load a configuration safely.
*
- * If the file already exists, the existing configuration is returned.
+ * @param file configuration file
+ * @return result describing outcome
*
- * @param file configuration file, must not be {@code null}
- * @return result object describing the outcome
+ * @since 1.1.0
*/
public @NotNull ConfigLoadResult tryCreate(@NotNull File file) {
if (file.exists()) {
- try {
- return new ConfigLoadResult(
- ConfigLoadStatus.ALREADY_EXISTS,
- get(file),
- null
- );
- } catch (Exception e) {
- return new ConfigLoadResult(ConfigLoadStatus.IO_ERROR, null, e);
- }
+ return new ConfigLoadResult(ConfigLoadStatus.ALREADY_EXISTS, get(file), null);
}
try {
ensureParentDirectories(file);
- YamlConfig config = loadInternal(file);
- return new ConfigLoadResult(ConfigLoadStatus.CREATED, config, null);
+ return new ConfigLoadResult(ConfigLoadStatus.CREATED, loadInternal(file), null);
} catch (Exception e) {
return new ConfigLoadResult(ConfigLoadStatus.IO_ERROR, null, e);
}
}
/**
- * Stops tracking the specified configuration file.
+ * Stops tracking a configuration file.
*
- * @param file configuration file, must not be {@code null}
+ * @param file configuration file
+ *
+ * @since 1.1.0
*/
public void unload(@NotNull File file) {
watcher.unregister(normalize(file.toPath()));
}
/**
- * Returns all currently tracked configuration snapshots.
+ * Returns all tracked configurations.
+ *
+ * @return collection of configurations
*
- * @return collection of configurations, never {@code null}
+ * @since 1.1.0
*/
public @NotNull Collection getAll() {
return watcher.getAll();
}
/**
- * Internal method responsible for loading and registering configurations.
+ * Stops the underlying watcher.
*
- * @param file configuration file, must not be {@code null}
- * @return loaded {@link YamlConfig} snapshot
+ * Must be called during plugin shutdown to avoid thread leaks.
+ *
+ * @since 1.1.0
+ */
+ public void shutdown() {
+ watcher.stop();
+ }
+
+ /**
+ * Internal load implementation.
+ *
+ * @param file configuration file
+ * @return loaded configuration
+ *
+ * @since 1.1.0
*/
private @NotNull YamlConfig loadInternal(@NotNull File file) {
+ // Ensure directory structure exists
ensureParentDirectories(file);
+ // Load YAML into memory
YamlConfig config = new YamlConfig(
file,
YamlConfiguration.loadConfiguration(file)
);
try {
+ // Register with watcher for live updates
watcher.register(config);
} catch (IOException e) {
throw new RuntimeException("Failed to register watcher for " + file, e);
@@ -300,11 +258,11 @@ public void unload(@NotNull File file) {
}
/**
- * Ensures that the parent directories of the file exist.
+ * Ensures parent directories exist.
*
- * @param file file whose parent directories should be verified
+ * @param file file whose parent directories should exist
*
- * @throws IllegalStateException if directory creation fails
+ * @since 1.1.0
*/
private void ensureParentDirectories(@NotNull File file) {
@@ -318,10 +276,12 @@ private void ensureParentDirectories(@NotNull File file) {
}
/**
- * Normalizes a path to ensure consistent identity.
+ * Normalizes a file path.
*
- * @param path raw path, must not be {@code null}
+ * @param path raw path
* @return normalized absolute path
+ *
+ * @since 1.1.0
*/
private static @NotNull Path normalize(@NotNull Path path) {
return path.toAbsolutePath().normalize();
diff --git a/src/main/java/dev/spexx/configurationAPI/util/FileChecksum.java b/src/main/java/dev/spexx/configurationAPI/util/FileChecksum.java
index 5d47d42..6d64e2d 100644
--- a/src/main/java/dev/spexx/configurationAPI/util/FileChecksum.java
+++ b/src/main/java/dev/spexx/configurationAPI/util/FileChecksum.java
@@ -18,18 +18,22 @@
* may be expensive for large files and should not be performed excessively
* in performance-critical code paths without appropriate caching or throttling.
*
+ * Thread Safety
+ * This class is stateless and thread-safe. All methods are static and do not
+ * maintain internal state.
+ *
* @apiNote
- * This class is stateless and thread-safe. All methods are static and do not
- * maintain internal state.
+ * This utility is designed for correctness over performance. Consumers should
+ * cache results where appropriate if repeated checksum calculations are required.
*
* @implSpec
* The {@link #sha256(File)} method computes a SHA-256 hash using a buffered
- * stream and returns the result as a lowercase hexadecimal string.
+ * stream and returns the result as a lowercase hexadecimal string.
*
* @implNote
* A fixed buffer size of 8192 bytes is used for reading file data. The method
* relies on {@link MessageDigest} and standard Java I/O without using
- * memory-mapped files or other advanced optimizations.
+ * memory-mapped files or other advanced optimizations.
*
* @since 1.0.0
*/
@@ -37,6 +41,10 @@ public final class FileChecksum {
/**
* Private constructor to prevent instantiation.
+ *
+ * This class is not intended to be instantiated.
+ *
+ * @since 1.0.0
*/
private FileChecksum() {
}
@@ -49,26 +57,46 @@ private FileChecksum() {
*
* The resulting hash is returned as a lowercase hexadecimal string.
*
+ * Behavior
+ *
+ * - Reads the file sequentially in fixed-size chunks
+ * - Feeds each chunk into the message digest
+ * - Produces a deterministic SHA-256 hash
+ *
+ *
+ * Failure Conditions
+ * If the file cannot be read or the hashing algorithm is unavailable,
+ * a {@link RuntimeException} is thrown wrapping the original cause.
+ *
* @param file the file to compute the checksum for, must not be {@code null}
* @return the SHA-256 checksum as a lowercase hexadecimal string, never {@code null}
*
+ * @throws NullPointerException if {@code file} is {@code null}
* @throws RuntimeException if an error occurs while reading the file or
* computing the checksum
+ *
+ * @since 1.0.0
*/
public static @NotNull String sha256(@NotNull File file) {
+
try (FileInputStream fis = new FileInputStream(file)) {
+ // Initialize SHA-256 digest
MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ // Buffer used for chunked reading (8 KB)
byte[] buffer = new byte[8192];
int read;
+ // Read file in chunks and update digest incrementally
while ((read = fis.read(buffer)) != -1) {
digest.update(buffer, 0, read);
}
+ // Finalize hash computation
byte[] hash = digest.digest();
+ // Convert byte array to hexadecimal string representation
StringBuilder hex = new StringBuilder(hash.length * 2);
for (byte b : hash) {
hex.append(String.format("%02x", b));
@@ -77,6 +105,7 @@ private FileChecksum() {
return hex.toString();
} catch (Exception e) {
+ // Wrap checked exceptions into runtime exception for API simplicity
throw new RuntimeException("Failed to compute checksum for " + file, e);
}
}
diff --git a/src/main/java/dev/spexx/configurationAPI/watcher/GlobalConfigWatcher.java b/src/main/java/dev/spexx/configurationAPI/watcher/GlobalConfigWatcher.java
index 00b1e59..32bdb7c 100644
--- a/src/main/java/dev/spexx/configurationAPI/watcher/GlobalConfigWatcher.java
+++ b/src/main/java/dev/spexx/configurationAPI/watcher/GlobalConfigWatcher.java
@@ -16,71 +16,74 @@
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.logging.Level;
import static java.nio.file.StandardWatchEventKinds.*;
/**
- * Global watcher responsible for monitoring multiple configuration files
- * using a single {@link WatchService} instance.
+ * Global watcher responsible for monitoring multiple configuration files.
*
- * This class acts as the single source of truth for all tracked
- * {@link YamlConfig} instances. It maintains the latest configuration
- * snapshots and updates them automatically when file system changes
- * are detected.
+ * This class maintains a thread-safe registry of configuration snapshots and
+ * automatically reloads them when changes are detected on disk.
*
- * Behavior
- *
- * - {@link StandardWatchEventKinds#ENTRY_CREATE} — loads or reloads configuration
- * - {@link StandardWatchEventKinds#ENTRY_MODIFY} — reloads configuration if content changed
- * - {@link StandardWatchEventKinds#ENTRY_DELETE} — removes configuration from tracking
- *
+ * Reload operations are validated before being applied. If a configuration
+ * fails to load (for example due to invalid YAML), the previous snapshot is
+ * preserved and no reload event is fired.
*
- * Threading
- * File system events are processed on a dedicated daemon thread.
- * Bukkit events are dispatched synchronously on the main server thread.
- *
- * Consistency
- * Configuration instances are replaced atomically. Consumers will always
- * observe either the previous or the updated snapshot, never a partially
- * updated state.
- *
- * Change Detection
- * Changes are detected using SHA-256 checksums. Identical file contents
- * will not trigger reloads even if file system events occur.
- *
- * @since 1.0.5
+ * @since 1.1.0
*/
public final class GlobalConfigWatcher {
/**
- * Minimum time between reload attempts for the same file, in milliseconds.
+ * Minimum delay between reload attempts for the same file.
+ *
+ * This prevents excessive reloads caused by rapid file system events
+ * (for example, editors writing files in multiple steps).
*/
private static final long DEBOUNCE_MS = 300;
+ /**
+ * Owning plugin instance used for scheduling and logging.
+ */
private final @NotNull JavaPlugin plugin;
+
+ /**
+ * Underlying {@link WatchService} used to monitor file system changes.
+ */
private final @NotNull WatchService watchService;
/**
- * Current configuration snapshots indexed by normalized path.
+ * Active configuration snapshots indexed by normalized file path.
+ *
+ * Each entry represents the latest known valid configuration state.
*/
private final Map configs = new ConcurrentHashMap<>();
/**
- * Last known checksums for tracked files.
+ * Cached checksums used to detect content changes.
*/
private final Map checksums = new ConcurrentHashMap<>();
/**
- * Last reload timestamps used for debounce protection.
+ * Tracks the last reload time for each file to apply debounce protection.
*/
private final Map lastReload = new ConcurrentHashMap<>();
/**
* Set of directories currently registered with the watch service.
+ *
+ * Directories are registered once to avoid redundant registrations.
*/
private final Set watchedDirs = ConcurrentHashMap.newKeySet();
+ /**
+ * Indicates whether the watcher thread is currently running.
+ */
private final AtomicBoolean running = new AtomicBoolean(false);
+
+ /**
+ * Background thread responsible for processing file system events.
+ */
private Thread thread;
/**
@@ -88,6 +91,8 @@ public final class GlobalConfigWatcher {
*
* @param plugin owning plugin instance, must not be {@code null}
* @throws IOException if the watch service cannot be initialized
+ *
+ * @since 1.1.0
*/
public GlobalConfigWatcher(@NotNull JavaPlugin plugin) throws IOException {
this.plugin = plugin;
@@ -97,20 +102,37 @@ public GlobalConfigWatcher(@NotNull JavaPlugin plugin) throws IOException {
/**
* Registers a configuration file for monitoring.
*
- * The parent directory of the file is registered with the
- * {@link WatchService} if not already tracked.
+ * This method performs the following actions:
+ *
+ * - Normalizes the file path for consistent identity
+ * - Adds the configuration snapshot to the registry
+ * - Computes and stores its checksum
+ * - Registers the parent directory with the watch service if needed
+ *
+ *
+ * If the configuration is already registered, the method returns
+ * without performing any additional work.
*
* @param config configuration snapshot to register, must not be {@code null}
* @throws IOException if directory registration fails
+ *
+ * @since 1.1.0
*/
public void register(@NotNull YamlConfig config) throws IOException {
+ // Normalize path to ensure consistent lookup
Path path = normalize(config.file().toPath());
Path dir = path.getParent();
- configs.put(path, config);
+ // Prevent duplicate registration
+ if (configs.putIfAbsent(path, config) != null) {
+ return;
+ }
+
+ // Store checksum for change detection
checksums.put(path, FileChecksum.sha256(config.file()));
+ // Register directory only once
if (watchedDirs.add(dir)) {
dir.register(watchService, ENTRY_CREATE, ENTRY_MODIFY, ENTRY_DELETE);
}
@@ -119,9 +141,16 @@ public void register(@NotNull YamlConfig config) throws IOException {
/**
* Unregisters a configuration file from monitoring.
*
- * This removes all associated state for the specified path.
+ * This removes all associated state, including:
+ *
+ * - Configuration snapshot
+ * - Checksum cache
+ * - Debounce tracking
+ *
*
- * @param path configuration path, must not be {@code null}
+ * @param path configuration file path, must not be {@code null}
+ *
+ * @since 1.1.0
*/
public void unregister(@NotNull Path path) {
Path normalized = normalize(path);
@@ -134,7 +163,10 @@ public void unregister(@NotNull Path path) {
/**
* Starts the watcher thread.
*
- * This method is idempotent. Calling it multiple times has no effect.
+ * This method is idempotent. Calling it multiple times will not create
+ * additional threads.
+ *
+ * @since 1.1.0
*/
public void start() {
if (running.get()) {
@@ -151,8 +183,14 @@ public void start() {
/**
* Stops the watcher and releases system resources.
*
- * The watcher thread is interrupted and the underlying
- * {@link WatchService} is closed.
+ * This method:
+ *
+ * - Stops the watcher loop
+ * - Closes the {@link WatchService}
+ * - Interrupts the watcher thread
+ *
+ *
+ * @since 1.1.0
*/
public void stop() {
running.set(false);
@@ -168,125 +206,182 @@ public void stop() {
}
/**
- * Main watcher loop that processes file system events.
- */
- private void run() {
-
- while (running.get()) {
- try {
- WatchKey key = watchService.take();
- Path dir = (Path) key.watchable();
-
- for (WatchEvent> event : key.pollEvents()) {
-
- if (event.kind() == OVERFLOW) {
- continue;
- }
-
- Path path = normalize(dir.resolve((Path) event.context()));
-
- long now = System.currentTimeMillis();
- long last = lastReload.getOrDefault(path, 0L);
-
- if (now - last < DEBOUNCE_MS) {
- continue;
- }
-
- lastReload.put(path, now);
- handleFileEvent(path, event.kind());
- }
-
- if (!key.reset()) {
- plugin.getLogger().warning("[ConfigWatcher] Watch key no longer valid.");
- }
-
- } catch (ClosedWatchServiceException e) {
- return;
- } catch (InterruptedException e) {
- Thread.currentThread().interrupt();
- return;
- } catch (Exception e) {
- plugin.getLogger().warning("[ConfigWatcher] Unexpected error: " + e.getMessage());
- }
- }
- }
-
- /**
- * Handles a file system event for a tracked configuration file.
+ * Handles a file system event affecting a tracked configuration file.
+ *
+ * This method performs the following steps:
+ *
+ * - Validates that the file is still tracked
+ * - Applies debounce logic to prevent excessive reloads
+ * - Computes a checksum to detect actual content changes
+ * - Loads and validates the YAML configuration
+ * - Applies rollback protection if the configuration is invalid
+ * - Replaces the previous snapshot atomically
+ * - Dispatches a {@link ConfigReloadedEvent} on successful reload
+ *
+ *
+ * Validation
+ * If the YAML file is syntactically invalid or results in an empty
+ * configuration while the file is non-empty, the reload is aborted and the
+ * previous configuration snapshot is preserved.
+ *
+ * Event Dispatch
+ * The reload event is only fired if both the previous configuration and
+ * checksum are available. This ensures that events always represent a valid
+ * transition from one known state to another.
*
- * @param path affected file path, must not be {@code null}
- * @param kind event type, must not be {@code null}
+ * @param path the affected file path, must not be {@code null}
+ * @param kind the type of file system event, must not be {@code null}
+ *
+ * @since 1.1.0
*/
private void handleFileEvent(@NotNull Path path, WatchEvent.@NotNull Kind> kind) {
- File file = path.toFile();
-
+ // Ignore files that are not currently tracked
if (!configs.containsKey(path)) {
return;
}
- // Handle deletion
+ File file = path.toFile();
+
+ // Handle file deletion
if (kind == ENTRY_DELETE) {
- if (configs.remove(path) != null) {
- checksums.remove(path);
- lastReload.remove(path);
- plugin.getLogger().info("[ConfigWatcher] Removed: " + path.getFileName());
- }
+ configs.remove(path);
+ checksums.remove(path);
+ lastReload.remove(path);
return;
}
- // Ignore if file does not exist
+ // Ignore events for files that no longer exist
if (!file.exists()) {
return;
}
try {
+ // Compute new checksum for change detection
String newChecksum = FileChecksum.sha256(file);
- @Nullable String oldChecksum = checksums.get(path);
+ // Retrieve previous checksum (may be null if not initialized properly)
+ String oldChecksum = checksums.get(path);
+
+ // Skip reload if content has not changed
if (oldChecksum != null && oldChecksum.equals(newChecksum)) {
return;
}
+ // Load YAML configuration from disk
+ YamlConfiguration yaml = YamlConfiguration.loadConfiguration(file);
+
+ // Validate YAML: prevent silent failures from replacing valid config
+ if (yaml.getKeys(false).isEmpty() && file.length() > 0) {
+ plugin.getLogger().warning(
+ "[ConfigWatcher] Invalid YAML detected, keeping previous configuration: " + file.getName()
+ );
+ return;
+ }
+
long start = System.nanoTime();
- @Nullable YamlConfig oldConfig = configs.get(path);
+ // Retrieve previous snapshot (may be null on first load)
+ YamlConfig oldConfig = configs.get(path);
- YamlConfig newConfig = new YamlConfig(
- file,
- YamlConfiguration.loadConfiguration(file)
- );
+ // Create new immutable snapshot
+ YamlConfig newConfig = new YamlConfig(file, yaml);
+ // Atomically replace configuration
configs.put(path, newConfig);
checksums.put(path, newChecksum);
int timeMs = (int) ((System.nanoTime() - start) / 1_000_000);
+ /*
+ * Only fire event if we have a valid previous state.
+ * This avoids passing null values into the event and ensures
+ * a meaningful state transition.
+ */
if (oldConfig != null && oldChecksum != null) {
- var scheduler = plugin.getServer().getScheduler();
- var pluginManager = plugin.getServer().getPluginManager();
-
- scheduler.runTask(plugin, () ->
- pluginManager.callEvent(new ConfigReloadedEvent(
- oldConfig, newConfig, oldChecksum, newChecksum, timeMs
- )));
+ plugin.getServer().getScheduler().runTask(plugin, () ->
+ plugin.getServer().getPluginManager().callEvent(
+ new ConfigReloadedEvent(
+ oldConfig,
+ newConfig,
+ oldChecksum,
+ newChecksum,
+ timeMs
+ )
+ )
+ );
}
- plugin.getLogger().info("[ConfigWatcher] Reloaded: " + file.getName());
-
} catch (Exception e) {
- plugin.getLogger().warning(
- "[ConfigWatcher] Failed to load: " + file.getName()
+ // Log full stack trace for debugging
+ plugin.getLogger().log(
+ Level.WARNING,
+ "[ConfigWatcher] Failed to reload configuration: " + file.getName(),
+ e
);
}
}
/**
- * Returns the current configuration snapshot for the specified path.
+ * Main watcher loop responsible for processing file system events.
+ *
+ * This method continuously listens for file system events and dispatches
+ * them to {@link #handleFileEvent(Path, WatchEvent.Kind)}.
+ *
+ * It applies debounce logic to prevent excessive reloads and ensures that
+ * unexpected errors do not terminate the watcher thread.
+ *
+ * @since 1.1.0
+ */
+ private void run() {
+ while (running.get()) {
+ try {
+ // Wait for next file system event
+ WatchKey key = watchService.take();
+ Path dir = (Path) key.watchable();
+
+ for (WatchEvent> event : key.pollEvents()) {
+
+ if (event.kind() == OVERFLOW) {
+ continue;
+ }
+
+ // Resolve full path of affected file
+ Path path = normalize(dir.resolve((Path) event.context()));
+
+ long now = System.currentTimeMillis();
+ long last = lastReload.getOrDefault(path, 0L);
+
+ // Apply debounce protection
+ if (now - last < DEBOUNCE_MS) {
+ continue;
+ }
+
+ lastReload.put(path, now);
+
+ // Delegate to handler
+ handleFileEvent(path, event.kind());
+ }
+
+ key.reset();
+
+ } catch (Exception e) {
+ plugin.getLogger().log(Level.WARNING, "[ConfigWatcher] Error", e);
+ }
+ }
+ }
+
+ /**
+ * Returns the current configuration snapshot for the given path.
+ *
+ * The returned value represents the latest known valid configuration
+ * state. If the configuration is not tracked, {@code null} is returned.
*
* @param path configuration path, must not be {@code null}
- * @return configuration snapshot, or {@code null} if not tracked
+ * @return configuration snapshot or {@code null} if not tracked
+ *
+ * @since 1.1.0
*/
public @Nullable YamlConfig get(@NotNull Path path) {
return configs.get(normalize(path));
@@ -295,17 +390,27 @@ private void handleFileEvent(@NotNull Path path, WatchEvent.@NotNull Kind> kin
/**
* Returns all currently tracked configuration snapshots.
*
- * @return collection of configurations, never {@code null}
+ * The returned collection is backed by the internal data structure and
+ * reflects the current state at the time of invocation.
+ *
+ * @return collection of configuration snapshots, never {@code null}
+ *
+ * @since 1.1.0
*/
public @NotNull Collection getAll() {
return configs.values();
}
/**
- * Normalizes a path to ensure consistent identity.
+ * Normalizes a file path to ensure consistent identity.
+ *
+ * This method converts the path to an absolute path and removes any
+ * redundant elements such as {@code "."} or {@code ".."}.
*
* @param path raw path, must not be {@code null}
* @return normalized absolute path
+ *
+ * @since 1.1.0
*/
private static @NotNull Path normalize(@NotNull Path path) {
return path.toAbsolutePath().normalize();
diff --git a/src/main/resources/paper-plugin.yml b/src/main/resources/paper-plugin.yml
index 2d92960..cbdb3e0 100644
--- a/src/main/resources/paper-plugin.yml
+++ b/src/main/resources/paper-plugin.yml
@@ -1,6 +1,6 @@
name: ConfigurationAPI
description: $description
-version: '1.1.0'
+version: '1.2.0'
main: dev.spexx.configurationAPI.ConfigurationAPI
api-version: '1.21.11'