diff --git a/pom.xml b/pom.xml index d48aa7f..f79ef65 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ dev.spexx ConfigurationAPI - 1.1.0 + 1.2.0 jar ConfigurationAPI diff --git a/src/main/java/dev/spexx/configurationAPI/ConfigurationAPI.java b/src/main/java/dev/spexx/configurationAPI/ConfigurationAPI.java index 3a18123..9ce5c31 100644 --- a/src/main/java/dev/spexx/configurationAPI/ConfigurationAPI.java +++ b/src/main/java/dev/spexx/configurationAPI/ConfigurationAPI.java @@ -1,24 +1,78 @@ package dev.spexx.configurationAPI; +import dev.spexx.configurationAPI.manager.ConfigManager; +import org.bukkit.plugin.java.JavaPlugin; + /** * Entry point for the ConfigurationAPI plugin. * - *

This class serves as a minimal bootstrap for the API when used as a plugin. - * In most cases, consumers will interact with the API components directly - * (e.g., {@link dev.spexx.configurationAPI.manager.ConfigManager}) - * rather than relying on plugin lifecycle behavior.

+ *

This class acts as a minimal bootstrap when the API is deployed as a Bukkit plugin.

+ * + *

In most cases, consumers should interact directly with API components such as + * {@link ConfigManager} rather than relying on plugin lifecycle behavior.

+ * + *

Lifecycle

+ * + * + *

This implementation does not perform any automatic configuration setup. + * It exists primarily to allow the API to function as a standalone plugin if required.

* * @since 1.0.0 */ -public final class ConfigurationAPI extends org.bukkit.plugin.java.JavaPlugin { +public final class ConfigurationAPI extends JavaPlugin { /** - * Default constructor. + * Constructs the ConfigurationAPI plugin instance. * - *

Constructs the ConfigurationAPI plugin instance.

+ *

No initialization logic is performed in the constructor.

* * @since 1.0.4 */ public ConfigurationAPI() { } + + /** + * Called when the plugin is loaded. + * + *

This method is invoked before {@link #onEnable()} and can be used for + * early initialization logic if required.

+ * + * @since 1.1.0 + */ + @Override + public void onLoad() { + // No-op + } + + /** + * Called when the plugin is enabled. + * + *

This implementation does not automatically initialize any components. + * Consumers are expected to create and manage {@link ConfigManager} instances + * within their own plugins.

+ * + * @since 1.1.0 + */ + @Override + public void onEnable() { + // No-op + } + + /** + * Called when the plugin is disabled. + * + *

No shutdown logic is required at this level. Individual components + * such as {@link ConfigManager} should be properly shut down by their + * owning plugins.

+ * + * @since 1.1.0 + */ + @Override + public void onDisable() { + // No-op + } } \ No newline at end of file diff --git a/src/main/java/dev/spexx/configurationAPI/config/ConfigLoadResult.java b/src/main/java/dev/spexx/configurationAPI/config/ConfigLoadResult.java index a70711e..9dac51b 100644 --- a/src/main/java/dev/spexx/configurationAPI/config/ConfigLoadResult.java +++ b/src/main/java/dev/spexx/configurationAPI/config/ConfigLoadResult.java @@ -7,29 +7,70 @@ /** * Represents the result of a configuration load or creation operation. * - *

This class encapsulates:

+ *

This class encapsulates the outcome of a configuration-related operation, + * providing structured, non-throwing feedback to the caller.

+ * + *

It contains:

* * - *

This allows safe, non-throwing APIs for configuration handling.

+ *

Usage

+ *

This class is primarily used by safe APIs that avoid throwing exceptions, + * allowing callers to explicitly handle success and failure cases.

+ * + *

Consistency Guarantees

+ * * * @since 1.1.0 */ public final class ConfigLoadResult { + /** + * Status representing the outcome of the operation. + */ private final ConfigLoadStatus status; + + /** + * Loaded configuration snapshot, if available. + */ private final YamlConfig config; + + /** + * Exception describing a failure, if one occurred. + */ private final Exception error; /** - * Creates a new result instance. + * Creates a new {@code ConfigLoadResult}. + * + *

This constructor enforces internal consistency between the provided + * {@code status}, {@code config}, and {@code error} values.

+ * + *

The following invariants apply:

+ * * * @param status operation status, must not be {@code null} - * @param config resulting configuration, may be {@code null} - * @param error exception if an error occurred, may be {@code null} + * @param config resulting configuration, may be {@code null} depending on {@code status} + * @param error exception if an error occurred, may be {@code null} depending on {@code status} + * + * @throws NullPointerException if {@code status} is {@code null} + * @throws IllegalArgumentException if invariants between {@code status}, {@code config}, + * and {@code error} are violated + * + * @since 1.1.0 */ public ConfigLoadResult( @NotNull ConfigLoadStatus status, @@ -39,30 +80,55 @@ public ConfigLoadResult( this.status = status; this.config = config; this.error = error; + + // Enforce invariant: successful states must include a configuration + if ((status == ConfigLoadStatus.LOADED || status == ConfigLoadStatus.CREATED) && config == null) { + throw new IllegalArgumentException("Config must not be null for success status: " + status); + } + + // Enforce invariant: error state must include an exception + if (status == ConfigLoadStatus.IO_ERROR && error == null) { + throw new IllegalArgumentException("Error must not be null for IO_ERROR status"); + } } /** - * Returns the operation status. + * Returns the status of the configuration operation. * - * @return status value, never {@code null} + *

This value indicates whether the operation succeeded, created a new file, + * reused an existing configuration, or failed due to an I/O error.

+ * + * @return operation status, never {@code null} + * + * @since 1.1.0 */ public @NotNull ConfigLoadStatus status() { return status; } /** - * Returns the loaded configuration if present. + * Returns the loaded configuration snapshot, if available. + * + *

This value is present when the operation completed successfully + * (for example {@link ConfigLoadStatus#LOADED} or {@link ConfigLoadStatus#CREATED}).

* - * @return optional configuration + * @return optional configuration snapshot + * + * @since 1.1.0 */ public @NotNull Optional config() { return Optional.ofNullable(config); } /** - * Returns the error if one occurred. + * Returns the exception associated with a failed operation, if present. + * + *

This value is present when {@link #status()} is + * {@link ConfigLoadStatus#IO_ERROR}.

+ * + * @return optional exception describing the failure * - * @return optional exception + * @since 1.1.0 */ public @NotNull Optional error() { return Optional.ofNullable(error); diff --git a/src/main/java/dev/spexx/configurationAPI/config/ConfigLoadStatus.java b/src/main/java/dev/spexx/configurationAPI/config/ConfigLoadStatus.java index 5c18f65..057eefe 100644 --- a/src/main/java/dev/spexx/configurationAPI/config/ConfigLoadStatus.java +++ b/src/main/java/dev/spexx/configurationAPI/config/ConfigLoadStatus.java @@ -4,29 +4,78 @@ * Represents the result status of a configuration load or creation operation. * *

This enum is used in conjunction with {@link ConfigLoadResult} to provide - * structured feedback about configuration handling outcomes.

+ * structured, type-safe feedback about configuration handling outcomes.

+ * + *

Each constant describes a distinct state that may occur when interacting + * with configuration files, including successful operations and failure scenarios.

+ * + *

Usage

+ *

This enum is primarily used as part of {@link ConfigLoadResult} to allow + * non-throwing APIs to communicate results without relying on exceptions.

+ * + *

Typical usage pattern:

+ *
+ * ConfigLoadResult result = manager.tryLoad(file);
+ *
+ * switch (result.status()) {
+ *     case LOADED -> { ... }
+ *     case CREATED -> { ... }
+ *     case ALREADY_EXISTS -> { ... }
+ *     case IO_ERROR -> { ... }
+ * }
+ * 
* * @since 1.1.0 */ public enum ConfigLoadStatus { /** - * Configuration was successfully loaded from disk. + * Indicates that the configuration file was successfully loaded from disk. + * + *

This status is returned when an existing file is read and parsed + * without errors.

+ * + * @since 1.1.0 */ LOADED, /** - * Configuration file was newly created and loaded. + * Indicates that the configuration file was newly created and loaded. + * + *

This typically occurs when the file did not previously exist and was + * created (for example, via resource extraction or manual creation) + * before being loaded.

+ * + * @since 1.1.0 */ CREATED, /** - * Configuration already existed and was not created again. + * Indicates that the configuration file already existed and was not + * created again. + * + *

This status is commonly returned by creation methods when the target + * file is already present on disk.

+ * + * @since 1.1.0 */ ALREADY_EXISTS, /** - * An I/O error occurred during read/write operations. + * Indicates that an I/O error occurred during a configuration operation. + * + *

This may include failures such as:

+ * + * + *

When this status is returned, the associated exception can be obtained + * from {@link ConfigLoadResult#error()}.

+ * + * @since 1.1.0 */ IO_ERROR } \ No newline at end of file diff --git a/src/main/java/dev/spexx/configurationAPI/config/YamlConfig.java b/src/main/java/dev/spexx/configurationAPI/config/YamlConfig.java index 0142344..f41f376 100644 --- a/src/main/java/dev/spexx/configurationAPI/config/YamlConfig.java +++ b/src/main/java/dev/spexx/configurationAPI/config/YamlConfig.java @@ -1,329 +1,254 @@ -package dev.spexx.configurationAPI.manager; +package dev.spexx.configurationAPI.config; -import dev.spexx.configurationAPI.config.ConfigLoadResult; -import dev.spexx.configurationAPI.config.ConfigLoadStatus; -import dev.spexx.configurationAPI.config.YamlConfig; -import dev.spexx.configurationAPI.watcher.GlobalConfigWatcher; -import org.bukkit.configuration.file.YamlConfiguration; -import org.bukkit.plugin.java.JavaPlugin; +import org.bukkit.configuration.file.FileConfiguration; import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; import java.io.File; -import java.io.IOException; -import java.nio.file.Path; -import java.util.Collection; -import java.util.Objects; +import java.util.List; +import java.util.Optional; /** - * Central access point for configuration files managed by the system. + * Immutable snapshot of a YAML configuration file. * - *

This class is responsible for:

+ *

This record encapsulates the following components:

* * - *

Architecture

- *

The {@link GlobalConfigWatcher} serves as the single source of truth for all - * configuration state. This class delegates all state management to the watcher - * and does not maintain its own cache.

+ *

Each instance represents a point-in-time view of a configuration file. + * When the file is reloaded, a new {@code YamlConfig} instance is created + * and atomically replaces the previous instance.

* - *

Lifecycle

- *

This class provides multiple levels of control over configuration handling:

+ *

Immutability

+ *

This class is immutable. All fields are {@code final} and no mutator + * methods are provided. This ensures thread-safe access without requiring + * synchronization.

+ * + *

Usage

+ *

Instances should be treated as read-only snapshots. Consumers should not + * cache instances long-term if they require access to the most up-to-date + * configuration state.

+ * + *

Typed Access

+ *

This class provides two styles of accessors:

* * - *

Thread Safety

- *

This class is thread-safe. The underlying watcher uses concurrent data structures - * and atomic replacement of {@link YamlConfig} instances.

- * - * @apiNote {@link YamlConfig} instances are immutable snapshots. Consumers should - * retrieve them on demand instead of caching long-term references. + * @param file the backing file on disk, must not be {@code null} + * @param config parsed configuration snapshot, must not be {@code null} * * @since 1.1.0 */ -public final class ConfigManager { - - private final @NotNull JavaPlugin plugin; - private final @NotNull GlobalConfigWatcher watcher; +public record YamlConfig(@NotNull File file, @NotNull FileConfiguration config) { /** - * Creates a new {@code ConfigManager}. - * - *

This initializes and starts the {@link GlobalConfigWatcher}, which begins - * monitoring registered configuration files for changes.

+ * Returns the underlying configuration file. * - * @param plugin owning plugin instance, must not be {@code null} + * @return backing file, never {@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"); - - try { - this.watcher = new GlobalConfigWatcher(plugin); - this.watcher.start(); - } catch (IOException e) { - throw new RuntimeException("Failed to initialize GlobalConfigWatcher", e); - } + @Override + public @NotNull File file() { + return file; } /** - * Returns the latest configuration snapshot for the given file. + * Returns the parsed configuration snapshot. * - *

The configuration must have been previously loaded using one of the - * loading methods. This method does not attempt to load the file.

+ *

The returned {@link FileConfiguration} should be treated as read-only. + * Modifying it directly may lead to inconsistent behavior.

* - * @param file configuration file, must not be {@code null} - * @return latest {@link YamlConfig} snapshot, never {@code null} + * @return configuration 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); - if (config == null) { - throw new IllegalStateException("Config not loaded: " + path); - } - + @Override + public @NotNull FileConfiguration config() { return config; } /** - * Returns an existing configuration or loads it if not already tracked. + * Returns a string value at the given path. * - *

If the configuration is not registered, it will be loaded from disk - * and registered with the watcher.

+ * @param path configuration path, must not be {@code null} + * @return optional containing the value, or empty if not present * - * @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); - if (existing != null) { - return existing; - } - - return loadInternal(file); + public @NotNull Optional getString(@NotNull String path) { + return Optional.ofNullable(config.getString(path)); } /** - * 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.

+ * Returns an integer value at the given path. * - *

If the file already exists, it is simply loaded and returned.

+ *

This method avoids Bukkit's implicit default value ({@code 0}) by + * checking for path existence first.

* - *

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
  • - *
+ * @param path configuration path, must not be {@code null} + * @return optional containing the value, or empty if not present * - *

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.

+ * @since 1.1.0 + */ + public @NotNull Optional getInt(@NotNull String path) { + return config.contains(path) + ? Optional.of(config.getInt(path)) + : Optional.empty(); + } + + /** + * Returns a boolean value at the given path. * - *

Example usage:

- *
-     * YamlConfig config = manager.getInternal("config.yml");
-     * 
+ *

This method avoids Bukkit's implicit default value ({@code false}) + * by checking for path existence first.

* - * @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} + * @param path configuration path, must not be {@code null} + * @return optional containing the value, or empty if not present * - * @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 - if (plugin.getResource(resourceName) == null) { - throw new IllegalArgumentException( - "Resource not found in plugin JAR: " + resourceName - ); - } - - File file = new File(plugin.getDataFolder(), resourceName); - - // Copy if missing - if (!file.exists()) { - plugin.saveResource(resourceName, false); - } + public @NotNull Optional getBoolean(@NotNull String path) { + return config.contains(path) + ? Optional.of(config.getBoolean(path)) + : Optional.empty(); + } - return getOrLoad(file); + /** + * Returns a double value at the given path. + * + * @param path configuration path, must not be {@code null} + * @return optional containing the value, or empty if not present + * + * @since 1.1.0 + */ + public @NotNull Optional getDouble(@NotNull String path) { + return config.contains(path) + ? Optional.of(config.getDouble(path)) + : Optional.empty(); } /** - * Explicitly loads a configuration file and registers it with the watcher. + * Returns a float value at the given path. * - *

This method always attempts to load the file regardless of its current - * registration state.

+ *

This method internally reads a double value and casts it to float, + * as Bukkit does not provide a native float accessor.

* - * @param file configuration file, must not be {@code null} - * @return loaded {@link YamlConfig} snapshot, never {@code null} + * @param path configuration path, must not be {@code null} + * @return optional containing the value, or empty if not present * - * @throws NullPointerException if {@code file} is {@code null} - * @throws RuntimeException if loading or registration fails + * @since 1.1.0 */ - public @NotNull YamlConfig load(@NotNull File file) { - return loadInternal(file); + public @NotNull Optional getFloat(@NotNull String path) { + return config.contains(path) + ? Optional.of((float) config.getDouble(path)) + : Optional.empty(); } /** - * Ensures the configuration file exists, creating parent directories if needed, - * and then loads and registers it. + * Returns a list of strings at the given path. * - * @param file configuration file, must not be {@code null} - * @return loaded {@link YamlConfig} snapshot, never {@code null} + * @param path configuration path, must not be {@code null} + * @return optional containing the list, or empty if not present * - * @throws NullPointerException if {@code file} is {@code null} - * @throws RuntimeException if loading or registration fails + * @since 1.1.0 */ - public @NotNull YamlConfig createIfMissing(@NotNull File file) { - if (!file.exists()) { - ensureParentDirectories(file); - } - return loadInternal(file); + public @NotNull Optional> getStringList(@NotNull String path) { + return config.contains(path) + ? Optional.of(config.getStringList(path)) + : Optional.empty(); } /** - * Attempts to load a configuration file safely. + * Returns a raw object at the given path. * - *

This method does not throw exceptions. Instead, it returns a structured - * {@link ConfigLoadResult} describing the outcome.

+ * @param path configuration path, must not be {@code null} + * @return optional containing the value, or empty if not present * - * @param file configuration file, must not be {@code null} - * @return result object containing status, configuration, or error + * @since 1.1.0 */ - public @NotNull ConfigLoadResult tryLoad(@NotNull File file) { - try { - YamlConfig config = loadInternal(file); - return new ConfigLoadResult(ConfigLoadStatus.LOADED, config, null); - } catch (Exception e) { - return new ConfigLoadResult(ConfigLoadStatus.IO_ERROR, null, e); - } + public @NotNull Optional get(@NotNull String path) { + return Optional.ofNullable(config.get(path)); } /** - * Attempts to create and load a configuration file safely. + * Returns a string value or a default if missing. * - *

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'