From d583427f790c75aed265b5f9a569c6c6683f4bea Mon Sep 17 00:00:00 2001 From: Spexx Date: Sun, 5 Apr 2026 12:03:11 +0200 Subject: [PATCH 1/5] feat: add safe typed access methods to YamlConfig --- .../configurationAPI/config/YamlConfig.java | 155 ++++++++++++------ 1 file changed, 108 insertions(+), 47 deletions(-) diff --git a/src/main/java/dev/spexx/configurationAPI/config/YamlConfig.java b/src/main/java/dev/spexx/configurationAPI/config/YamlConfig.java index 50c72a0..1987621 100644 --- a/src/main/java/dev/spexx/configurationAPI/config/YamlConfig.java +++ b/src/main/java/dev/spexx/configurationAPI/config/YamlConfig.java @@ -4,6 +4,8 @@ import org.jetbrains.annotations.NotNull; import java.io.File; +import java.util.List; +import java.util.Optional; /** * Immutable snapshot of a YAML configuration file. @@ -28,73 +30,132 @@ * cache instances long-term if they require access to the most up-to-date * configuration state.

* - * @apiNote - * To obtain the latest configuration, retrieve a new instance from the managing - * component instead of reusing previously obtained references. - * - * @implSpec - * Thread-safety is achieved through immutability and atomic replacement of - * instances at a higher level (for example, by the configuration manager). - * - * @implNote - * The underlying {@link FileConfiguration} instance is assumed to be used in a - * read-only manner. Modifying it directly may lead to inconsistent behavior. - * - * @since 1.0.0 + * @since 1.1.0 */ -public final class YamlConfig { +public record YamlConfig(@NotNull File file, @NotNull FileConfiguration config) { /** - * The underlying configuration file on disk. + * Returns the underlying configuration file. */ - private final @NotNull File file; + @Override + public @NotNull File file() { + return file; + } /** - * Parsed configuration snapshot. + * Returns the parsed configuration snapshot. + * + *

This object should be treated as read-only.

*/ - private final @NotNull FileConfiguration config; + @Override + public @NotNull FileConfiguration config() { + return config; + } + + // ========================================================= + // Optional-based getters (safe access) + // ========================================================= /** - * Constructs a new {@code YamlConfig} instance. - * - *

The provided {@link FileConfiguration} is assumed to represent a fully - * parsed and valid snapshot of the given file.

+ * Returns a string value at the given path. * - * @param file the configuration file, must not be {@code null} - * @param config the parsed configuration snapshot, must not be {@code null} + * @param path config path + * @return Optional containing the value, or empty if not present */ - public YamlConfig( - @NotNull File file, - @NotNull FileConfiguration config - ) { - this.file = file; - this.config = config; + public @NotNull Optional getString(@NotNull String path) { + return Optional.ofNullable(config.getString(path)); } /** - * Returns the underlying configuration file. - * - *

This refers to the physical file on disk from which this snapshot - * was created.

+ * Returns an integer value at the given path. * - * @return the configuration file, never {@code null} + *

This method avoids Bukkit's default fallback (0) by checking presence.

*/ - public @NotNull File file() { - return file; + public @NotNull Optional getInt(@NotNull String path) { + return config.contains(path) + ? Optional.of(config.getInt(path)) + : Optional.empty(); } /** - * Returns the parsed configuration snapshot. - * - *

The returned {@link FileConfiguration} represents a stable, immutable - * view of the configuration at the time this {@code YamlConfig} instance - * was created.

- * - *

This object should be treated as read-only.

+ * Returns a boolean value at the given path. + */ + public @NotNull Optional getBoolean(@NotNull String path) { + return config.contains(path) + ? Optional.of(config.getBoolean(path)) + : Optional.empty(); + } + + /** + * Returns a double value at the given path. + */ + public @NotNull Optional getDouble(@NotNull String path) { + return config.contains(path) + ? Optional.of(config.getDouble(path)) + : Optional.empty(); + } + + /** + * Returns a float value at the given path. + */ + public @NotNull Optional getFloat(@NotNull String path) { + return config.contains(path) + ? Optional.of((float) config.getDouble(path)) + : Optional.empty(); + } + + /** + * Returns a string list at the given path. + */ + public @NotNull Optional> getStringList(@NotNull String path) { + return config.contains(path) + ? Optional.of(config.getStringList(path)) + : Optional.empty(); + } + + /** + * Returns a raw object at the given path. + */ + public @NotNull Optional get(@NotNull String path) { + return Optional.ofNullable(config.get(path)); + } + + // ========================================================= + // Default-based getters (most commonly used) + // ========================================================= + + public @NotNull String getStringOrDefault(@NotNull String path, @NotNull String def) { + String value = config.getString(path); + return value != null ? value : def; + } + + public int getIntOrDefault(@NotNull String path, int def) { + return config.getInt(path, def); + } + + public boolean getBooleanOrDefault(@NotNull String path, boolean def) { + return config.getBoolean(path, def); + } + + public double getDoubleOrDefault(@NotNull String path, double def) { + return config.getDouble(path, def); + } + + public float getFloatOrDefault(@NotNull String path, float def) { + return (float) config.getDouble(path, def); + } + + // ========================================================= + // Utility + // ========================================================= + + /** + * Checks whether a value exists at the given path. * - * @return the configuration snapshot, never {@code null} + * @param path config path + * @return true if present */ - public @NotNull FileConfiguration config() { - return config; + public boolean has(@NotNull String path) { + return config.contains(path); } } \ No newline at end of file From 90aeb916e22ebb7217627ca13651be3e76776954 Mon Sep 17 00:00:00 2001 From: Spexx Date: Sun, 5 Apr 2026 12:04:16 +0200 Subject: [PATCH 2/5] chore: bump version to 1.1 --- pom.xml | 2 +- src/main/resources/paper-plugin.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index dec1722..0b25067 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ dev.spexx ConfigurationAPI - 1.0.6 + 1.1 jar ConfigurationAPI diff --git a/src/main/resources/paper-plugin.yml b/src/main/resources/paper-plugin.yml index 9c44f3b..fbd112a 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.0.6' +version: '1.1' main: dev.spexx.configurationAPI.ConfigurationAPI api-version: '1.21.11' From d8c1076e6941642298c9f2053fe4070b6b5d5821 Mon Sep 17 00:00:00 2001 From: Spexx Date: Sun, 5 Apr 2026 13:32:13 +0200 Subject: [PATCH 3/5] fix(javadoc): resolve doclint errors and ensure successful javadoc build --- .../config/ConfigLoadResult.java | 70 +++++ .../config/ConfigLoadStatus.java | 32 +++ .../configurationAPI/config/YamlConfig.java | 96 +++++-- .../manager/ConfigManager.java | 240 +++++++++++------- 4 files changed, 332 insertions(+), 106 deletions(-) create mode 100644 src/main/java/dev/spexx/configurationAPI/config/ConfigLoadResult.java create mode 100644 src/main/java/dev/spexx/configurationAPI/config/ConfigLoadStatus.java diff --git a/src/main/java/dev/spexx/configurationAPI/config/ConfigLoadResult.java b/src/main/java/dev/spexx/configurationAPI/config/ConfigLoadResult.java new file mode 100644 index 0000000..a70711e --- /dev/null +++ b/src/main/java/dev/spexx/configurationAPI/config/ConfigLoadResult.java @@ -0,0 +1,70 @@ +package dev.spexx.configurationAPI.config; + +import org.jetbrains.annotations.NotNull; + +import java.util.Optional; + +/** + * Represents the result of a configuration load or creation operation. + * + *

This class encapsulates:

+ *
    + *
  • The {@link ConfigLoadStatus} indicating the outcome
  • + *
  • An optional {@link YamlConfig} if successful
  • + *
  • An optional {@link Exception} if an error occurred
  • + *
+ * + *

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

+ * + * @since 1.1.0 + */ +public final class ConfigLoadResult { + + private final ConfigLoadStatus status; + private final YamlConfig config; + private final Exception error; + + /** + * Creates a new result instance. + * + * @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} + */ + public ConfigLoadResult( + @NotNull ConfigLoadStatus status, + YamlConfig config, + Exception error + ) { + this.status = status; + this.config = config; + this.error = error; + } + + /** + * Returns the operation status. + * + * @return status value, never {@code null} + */ + public @NotNull ConfigLoadStatus status() { + return status; + } + + /** + * Returns the loaded configuration if present. + * + * @return optional configuration + */ + public @NotNull Optional config() { + return Optional.ofNullable(config); + } + + /** + * Returns the error if one occurred. + * + * @return optional exception + */ + public @NotNull Optional error() { + return Optional.ofNullable(error); + } +} \ No newline at end of file diff --git a/src/main/java/dev/spexx/configurationAPI/config/ConfigLoadStatus.java b/src/main/java/dev/spexx/configurationAPI/config/ConfigLoadStatus.java new file mode 100644 index 0000000..5c18f65 --- /dev/null +++ b/src/main/java/dev/spexx/configurationAPI/config/ConfigLoadStatus.java @@ -0,0 +1,32 @@ +package dev.spexx.configurationAPI.config; + +/** + * 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.

+ * + * @since 1.1.0 + */ +public enum ConfigLoadStatus { + + /** + * Configuration was successfully loaded from disk. + */ + LOADED, + + /** + * Configuration file was newly created and loaded. + */ + CREATED, + + /** + * Configuration already existed and was not created again. + */ + ALREADY_EXISTS, + + /** + * An I/O error occurred during read/write operations. + */ + 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 1987621..d5d582c 100644 --- a/src/main/java/dev/spexx/configurationAPI/config/YamlConfig.java +++ b/src/main/java/dev/spexx/configurationAPI/config/YamlConfig.java @@ -32,10 +32,29 @@ * * @since 1.1.0 */ +/** + * Immutable snapshot of a YAML configuration file. + * + *

This record encapsulates:

+ *
    + *
  • The underlying {@link File} on disk
  • + *
  • The parsed {@link FileConfiguration}
  • + *
+ * + *

Each instance represents a point-in-time view. When the file is reloaded, + * a new instance replaces the old one atomically.

+ * + * @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 record YamlConfig(@NotNull File file, @NotNull FileConfiguration config) { /** * Returns the underlying configuration file. + * + * @return backing file, never {@code null} */ @Override public @NotNull File file() { @@ -45,22 +64,18 @@ public record YamlConfig(@NotNull File file, @NotNull FileConfiguration config) /** * Returns the parsed configuration snapshot. * - *

This object should be treated as read-only.

+ * @return configuration snapshot, never {@code null} */ @Override public @NotNull FileConfiguration config() { return config; } - // ========================================================= - // Optional-based getters (safe access) - // ========================================================= - /** * Returns a string value at the given path. * - * @param path config path - * @return Optional containing the value, or empty if not present + * @param path configuration path, must not be {@code null} + * @return optional containing the value, or empty if not present */ public @NotNull Optional getString(@NotNull String path) { return Optional.ofNullable(config.getString(path)); @@ -69,7 +84,8 @@ public record YamlConfig(@NotNull File file, @NotNull FileConfiguration config) /** * Returns an integer value at the given path. * - *

This method avoids Bukkit's default fallback (0) by checking presence.

+ * @param path configuration path, must not be {@code null} + * @return optional containing the value, or empty if not present */ public @NotNull Optional getInt(@NotNull String path) { return config.contains(path) @@ -79,6 +95,9 @@ public record YamlConfig(@NotNull File file, @NotNull FileConfiguration config) /** * Returns a boolean value at the given path. + * + * @param path configuration path, must not be {@code null} + * @return optional containing the value, or empty if not present */ public @NotNull Optional getBoolean(@NotNull String path) { return config.contains(path) @@ -88,6 +107,9 @@ public record YamlConfig(@NotNull File file, @NotNull FileConfiguration config) /** * 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 */ public @NotNull Optional getDouble(@NotNull String path) { return config.contains(path) @@ -97,6 +119,9 @@ public record YamlConfig(@NotNull File file, @NotNull FileConfiguration config) /** * Returns a float value at the given path. + * + * @param path configuration path, must not be {@code null} + * @return optional containing the value, or empty if not present */ public @NotNull Optional getFloat(@NotNull String path) { return config.contains(path) @@ -105,7 +130,10 @@ public record YamlConfig(@NotNull File file, @NotNull FileConfiguration config) } /** - * Returns a string list at the given path. + * Returns a list of strings at the given path. + * + * @param path configuration path, must not be {@code null} + * @return optional containing the list, or empty if not present */ public @NotNull Optional> getStringList(@NotNull String path) { return config.contains(path) @@ -115,45 +143,75 @@ public record YamlConfig(@NotNull File file, @NotNull FileConfiguration config) /** * Returns a raw object at the given path. + * + * @param path configuration path, must not be {@code null} + * @return optional containing the value, or empty if not present */ public @NotNull Optional get(@NotNull String path) { return Optional.ofNullable(config.get(path)); } - // ========================================================= - // Default-based getters (most commonly used) - // ========================================================= - + /** + * Returns a string value or a default if missing. + * + * @param path configuration path + * @param def fallback value + * @return resolved value + */ public @NotNull String getStringOrDefault(@NotNull String path, @NotNull String def) { String value = config.getString(path); return value != null ? value : def; } + /** + * Returns an integer value or a default. + * + * @param path configuration path + * @param def fallback value + * @return resolved value + */ public int getIntOrDefault(@NotNull String path, int def) { return config.getInt(path, def); } + /** + * Returns a boolean value or a default. + * + * @param path configuration path + * @param def fallback value + * @return resolved value + */ public boolean getBooleanOrDefault(@NotNull String path, boolean def) { return config.getBoolean(path, def); } + /** + * Returns a double value or a default. + * + * @param path configuration path + * @param def fallback value + * @return resolved value + */ public double getDoubleOrDefault(@NotNull String path, double def) { return config.getDouble(path, def); } + /** + * Returns a float value or a default. + * + * @param path configuration path + * @param def fallback value + * @return resolved value + */ public float getFloatOrDefault(@NotNull String path, float def) { return (float) config.getDouble(path, def); } - // ========================================================= - // Utility - // ========================================================= - /** * Checks whether a value exists at the given path. * - * @param path config path - * @return true if present + * @param path configuration path + * @return {@code true} if present, otherwise {@code false} */ public boolean has(@NotNull String path) { return config.contains(path); diff --git a/src/main/java/dev/spexx/configurationAPI/manager/ConfigManager.java b/src/main/java/dev/spexx/configurationAPI/manager/ConfigManager.java index 5f1dc6e..0142344 100644 --- a/src/main/java/dev/spexx/configurationAPI/manager/ConfigManager.java +++ b/src/main/java/dev/spexx/configurationAPI/manager/ConfigManager.java @@ -1,5 +1,7 @@ package dev.spexx.configurationAPI.manager; +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; @@ -16,29 +18,37 @@ /** * Central access point for configuration files managed by the system. * - *

This class is responsible for: + *

This class is responsible for:

*
    - *
  • Initial loading of configuration files
  • + *
  • Loading configuration files from disk
  • *
  • Registering configurations with the global watcher
  • - *
  • Providing access to the latest configuration snapshots
  • + *
  • Providing access to the latest immutable configuration snapshots
  • *
* *

Architecture

- *

The {@link GlobalConfigWatcher} is the single source of truth for all - * configuration state. This class does not maintain its own cache.

+ *

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.

* - *

All calls to {@link #get(File)} delegate directly to the watcher, - * ensuring that consumers always receive the most up-to-date configuration.

+ *

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.

+ *

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 rather than caching references long-term. + * @apiNote {@link YamlConfig} instances are immutable snapshots. Consumers should + * retrieve them on demand instead of caching long-term references. * - * @since 1.0.5 + * @since 1.1.0 */ public final class ConfigManager { @@ -48,10 +58,12 @@ public final class ConfigManager { /** * Creates a new {@code ConfigManager}. * - *

This initializes and starts the {@link GlobalConfigWatcher}.

+ *

This 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 */ public ConfigManager(@NotNull JavaPlugin plugin) { @@ -68,12 +80,13 @@ public ConfigManager(@NotNull JavaPlugin plugin) { /** * Returns the latest configuration snapshot for the given file. * - *

The configuration must have been previously loaded using - * {@link #getOrLoad(File)} or {@link #getOrLoadResource(String)}.

+ *

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

* * @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 */ public @NotNull YamlConfig get(@NotNull File file) { @@ -90,14 +103,13 @@ public ConfigManager(@NotNull JavaPlugin plugin) { /** * Returns an existing configuration or loads it if not already tracked. * - *

If the configuration is not already registered, it is: - *

    - *
  1. Loaded from disk
  2. - *
  3. Registered with the watcher
  4. - *
+ *

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

* * @param file configuration file, must not be {@code null} - * @return latest {@link YamlConfig} snapshot + * @return latest {@link YamlConfig} snapshot, never {@code null} + * + * @throws NullPointerException if {@code file} is {@code null} */ public @NotNull YamlConfig getOrLoad(@NotNull File file) { Path path = normalize(file.toPath()); @@ -107,73 +119,151 @@ public ConfigManager(@NotNull JavaPlugin plugin) { return existing; } - return load(file); + return loadInternal(file); } /** - * Returns the latest configuration snapshot for a file located - * relative to the plugin's data folder. + * Loads a configuration file intended for internal plugin usage. * - *

This is a convenience method that resolves the provided path - * against {@link JavaPlugin#getDataFolder()}.

+ *

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.

+ * + *

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
  • + *
+ * + *

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.

* *

Example usage:

*
-     * YamlConfig config = manager.getByPath("configs/example.yml");
+     * YamlConfig config = manager.getInternal("config.yml");
      * 
* - * @param path relative file path using forward slashes, must not be {@code null} - * @return latest {@link YamlConfig} snapshot + * @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 IllegalStateException if the configuration is not loaded + * @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 */ - public @NotNull YamlConfig getByPath(@NotNull String path) { - File file = resolvePath(path); - return get(file); + 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); + } + + return getOrLoad(file); } /** - * Returns an existing configuration or loads it if not already tracked, - * using a path relative to the plugin's data folder. + * Explicitly loads a configuration file and registers it with the watcher. * - *

Example usage:

- *
-     * YamlConfig config = manager.getOrLoadByPath("configs/example.yml");
-     * 
+ *

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} * - * @param path relative file path using forward slashes, must not be {@code null} - * @return latest {@link YamlConfig} snapshot + * @throws NullPointerException if {@code file} is {@code null} + * @throws RuntimeException if loading or registration fails */ - public @NotNull YamlConfig getOrLoadByPath(@NotNull String path) { - File file = resolvePath(path); - return getOrLoad(file); + public @NotNull YamlConfig load(@NotNull File file) { + return loadInternal(file); } /** - * Loads a configuration file from plugin resources if necessary. + * Ensures the configuration file exists, creating parent directories if needed, + * and then loads and registers it. * - *

If the file does not exist in the plugin data folder, it is copied - * from the plugin JAR. The file is then loaded and registered.

+ * @param file configuration file, must not be {@code null} + * @return loaded {@link YamlConfig} snapshot, never {@code null} * - * @param name resource name (for example {@code "config.yml"}), must not be {@code null} - * @return latest {@link YamlConfig} snapshot + * @throws NullPointerException if {@code file} is {@code null} + * @throws RuntimeException if loading or registration fails */ - public @NotNull YamlConfig getOrLoadResource(@NotNull String name) { - plugin.saveResource(name, false); - return getOrLoad(new File(plugin.getDataFolder(), name)); + public @NotNull YamlConfig createIfMissing(@NotNull File file) { + if (!file.exists()) { + ensureParentDirectories(file); + } + return loadInternal(file); } /** - * Stops tracking the specified configuration file. + * Attempts to load a configuration file safely. * - *

This removes the configuration from the watcher. If the file still - * exists, it may be reloaded again if explicitly registered.

+ *

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

+ * + * @param file configuration file, must not be {@code null} + * @return result object containing status, configuration, or error + */ + 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); + } + } + + /** + * Attempts to create and load a configuration file safely. + * + *

If the file already exists, the existing configuration is returned.

+ * + * @param file configuration file, must not be {@code null} + * @return result object describing the outcome + */ + 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); + } + } + + /** + * Stops tracking the specified configuration file. * * @param file configuration file, must not be {@code null} */ public void unload(@NotNull File file) { - Path path = normalize(file.toPath()); - watcher.unregister(path); + watcher.unregister(normalize(file.toPath())); } /** @@ -186,16 +276,14 @@ public void unload(@NotNull File file) { } /** - * Loads a configuration file and registers it with the watcher. + * Internal method responsible for loading and registering configurations. * * @param file configuration file, must not be {@code null} * @return loaded {@link YamlConfig} snapshot - * - * @throws RuntimeException if watcher registration fails */ - private @NotNull YamlConfig load(@NotNull File file) { + private @NotNull YamlConfig loadInternal(@NotNull File file) { - ensureFileExists(file); + ensureParentDirectories(file); YamlConfig config = new YamlConfig( file, @@ -214,15 +302,11 @@ public void unload(@NotNull File file) { /** * Ensures that the parent directories of the file exist. * - * @param file file to verify, must not be {@code null} + * @param file file whose parent directories should be verified * * @throws IllegalStateException if directory creation fails */ - private void ensureFileExists(@NotNull File file) { - - if (file.exists()) { - return; - } + private void ensureParentDirectories(@NotNull File file) { File parent = file.getParentFile(); @@ -233,24 +317,6 @@ private void ensureFileExists(@NotNull File file) { } } - /** - * Resolves a relative path against the plugin's data folder. - * - *

Backslashes are normalized to forward slashes to ensure - * cross-platform compatibility.

- * - * @param path relative path, must not be {@code null} - * @return resolved file - */ - private @NotNull File resolvePath(@NotNull String path) { - Objects.requireNonNull(path, "path"); - - // Normalize separators (Windows → Unix style) - String normalized = path.replace("\\", "/"); - - return new File(plugin.getDataFolder(), normalized); - } - /** * Normalizes a path to ensure consistent identity. * From 03d79c613b0d622184d4cd28b9b63d09a6259610 Mon Sep 17 00:00:00 2001 From: Spexx Date: Sun, 5 Apr 2026 13:33:29 +0200 Subject: [PATCH 4/5] chore(version): bump version to 1.1.0 (semantic versioning) --- pom.xml | 2 +- src/main/resources/paper-plugin.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index 0b25067..d48aa7f 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ dev.spexx ConfigurationAPI - 1.1 + 1.1.0 jar ConfigurationAPI diff --git a/src/main/resources/paper-plugin.yml b/src/main/resources/paper-plugin.yml index fbd112a..2d92960 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' +version: '1.1.0' main: dev.spexx.configurationAPI.ConfigurationAPI api-version: '1.21.11' From 35c910028378d3b4ec6e8fc60a82f7821e89fc9f Mon Sep 17 00:00:00 2001 From: Spexx Date: Sun, 5 Apr 2026 14:33:23 +0200 Subject: [PATCH 5/5] refactor(docs): standardize Javadoc across API and improve internal documentation --- pom.xml | 2 +- .../configurationAPI/ConfigurationAPI.java | 68 +++- .../config/ConfigLoadResult.java | 94 ++++- .../config/ConfigLoadStatus.java | 59 ++- .../configurationAPI/config/YamlConfig.java | 75 ++-- .../events/ConfigReloadedEvent.java | 40 +++ .../manager/ConfigManager.java | 182 ++++------ .../configurationAPI/util/FileChecksum.java | 37 +- .../watcher/GlobalConfigWatcher.java | 335 ++++++++++++------ src/main/resources/paper-plugin.yml | 2 +- 10 files changed, 616 insertions(+), 278 deletions(-) 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

+ *
    + *
  • {@link #onLoad()} — called when the plugin is loaded
  • + *
  • {@link #onEnable()} — called when the plugin is enabled
  • + *
  • {@link #onDisable()} — called during shutdown
  • + *
+ * + *

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:

*
    - *
  • The {@link ConfigLoadStatus} indicating the outcome
  • - *
  • An optional {@link YamlConfig} if successful
  • - *
  • An optional {@link Exception} if an error occurred
  • + *
  • A {@link ConfigLoadStatus} indicating the result of the operation
  • + *
  • An optional {@link YamlConfig} when the operation succeeds
  • + *
  • An optional {@link Exception} when an error occurs
  • *
* - *

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

+ *
    + *
  • If {@link #status()} is {@link ConfigLoadStatus#LOADED} or {@link ConfigLoadStatus#CREATED}, + * {@link #config()} will be present
  • + *
  • If {@link #status()} is {@link ConfigLoadStatus#IO_ERROR}, + * {@link #error()} will be present
  • + *
* * @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:

+ *
    + *
  • If {@code status} is {@link ConfigLoadStatus#LOADED} or {@link ConfigLoadStatus#CREATED}, + * {@code config} must not be {@code null}
  • + *
  • If {@code status} is {@link ConfigLoadStatus#IO_ERROR}, + * {@code error} must not be {@code null}
  • + *
* * @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:

+ *
    + *
  • File read errors
  • + *
  • File write errors
  • + *
  • Permission issues
  • + *
  • Unexpected file system conditions
  • + *
+ * + *

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 d5d582c..f41f376 100644 --- a/src/main/java/dev/spexx/configurationAPI/config/YamlConfig.java +++ b/src/main/java/dev/spexx/configurationAPI/config/YamlConfig.java @@ -10,7 +10,7 @@ /** * Immutable snapshot of a YAML configuration file. * - *

This class encapsulates the following components:

+ *

This record encapsulates the following components:

*
    *
  • The underlying {@link File} on disk
  • *
  • The parsed {@link FileConfiguration} instance
  • @@ -30,20 +30,13 @@ * cache instances long-term if they require access to the most up-to-date * configuration state.

    * - * @since 1.1.0 - */ -/** - * Immutable snapshot of a YAML configuration file. - * - *

    This record encapsulates:

    + *

    Typed Access

    + *

    This class provides two styles of accessors:

    *
      - *
    • The underlying {@link File} on disk
    • - *
    • The parsed {@link FileConfiguration}
    • + *
    • Optional-based getters for safe, explicit handling of missing values
    • + *
    • Default-based getters for convenience when fallback values are acceptable
    • *
    * - *

    Each instance represents a point-in-time view. When the file is reloaded, - * a new instance replaces the old one atomically.

    - * * @param file the backing file on disk, must not be {@code null} * @param config parsed configuration snapshot, must not be {@code null} * @@ -55,6 +48,8 @@ public record YamlConfig(@NotNull File file, @NotNull FileConfiguration config) * Returns the underlying configuration file. * * @return backing file, never {@code null} + * + * @since 1.1.0 */ @Override public @NotNull File file() { @@ -64,7 +59,12 @@ public record YamlConfig(@NotNull File file, @NotNull FileConfiguration config) /** * Returns the parsed configuration snapshot. * + *

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

    + * * @return configuration snapshot, never {@code null} + * + * @since 1.1.0 */ @Override public @NotNull FileConfiguration config() { @@ -76,6 +76,8 @@ public record YamlConfig(@NotNull File file, @NotNull FileConfiguration config) * * @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 getString(@NotNull String path) { return Optional.ofNullable(config.getString(path)); @@ -84,8 +86,13 @@ public record YamlConfig(@NotNull File file, @NotNull FileConfiguration config) /** * Returns an integer value at the given path. * + *

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

    + * * @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 getInt(@NotNull String path) { return config.contains(path) @@ -96,8 +103,13 @@ public record YamlConfig(@NotNull File file, @NotNull FileConfiguration config) /** * Returns a boolean value at the given path. * + *

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

    + * * @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 getBoolean(@NotNull String path) { return config.contains(path) @@ -110,6 +122,8 @@ public record YamlConfig(@NotNull File file, @NotNull FileConfiguration config) * * @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) @@ -120,8 +134,13 @@ public record YamlConfig(@NotNull File file, @NotNull FileConfiguration config) /** * Returns a float value at the given path. * + *

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

    + * * @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 getFloat(@NotNull String path) { return config.contains(path) @@ -134,6 +153,8 @@ public record YamlConfig(@NotNull File file, @NotNull FileConfiguration config) * * @param path configuration path, must not be {@code null} * @return optional containing the list, or empty if not present + * + * @since 1.1.0 */ public @NotNull Optional> getStringList(@NotNull String path) { return config.contains(path) @@ -146,6 +167,8 @@ public record YamlConfig(@NotNull File file, @NotNull FileConfiguration config) * * @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 get(@NotNull String path) { return Optional.ofNullable(config.get(path)); @@ -154,9 +177,11 @@ public record YamlConfig(@NotNull File file, @NotNull FileConfiguration config) /** * Returns a string value or a default if missing. * - * @param path configuration path - * @param def fallback value - * @return resolved value + * @param path configuration path, must not be {@code null} + * @param def fallback value, must not be {@code null} + * @return resolved value, never {@code null} + * + * @since 1.1.0 */ public @NotNull String getStringOrDefault(@NotNull String path, @NotNull String def) { String value = config.getString(path); @@ -166,9 +191,11 @@ public record YamlConfig(@NotNull File file, @NotNull FileConfiguration config) /** * Returns an integer value or a default. * - * @param path configuration path + * @param path configuration path, must not be {@code null} * @param def fallback value * @return resolved value + * + * @since 1.1.0 */ public int getIntOrDefault(@NotNull String path, int def) { return config.getInt(path, def); @@ -177,9 +204,11 @@ public int getIntOrDefault(@NotNull String path, int def) { /** * Returns a boolean value or a default. * - * @param path configuration path + * @param path configuration path, must not be {@code null} * @param def fallback value * @return resolved value + * + * @since 1.1.0 */ public boolean getBooleanOrDefault(@NotNull String path, boolean def) { return config.getBoolean(path, def); @@ -188,9 +217,11 @@ public boolean getBooleanOrDefault(@NotNull String path, boolean def) { /** * Returns a double value or a default. * - * @param path configuration path + * @param path configuration path, must not be {@code null} * @param def fallback value * @return resolved value + * + * @since 1.1.0 */ public double getDoubleOrDefault(@NotNull String path, double def) { return config.getDouble(path, def); @@ -199,9 +230,11 @@ public double getDoubleOrDefault(@NotNull String path, double def) { /** * Returns a float value or a default. * - * @param path configuration path + * @param path configuration path, must not be {@code null} * @param def fallback value * @return resolved value + * + * @since 1.1.0 */ public float getFloatOrDefault(@NotNull String path, float def) { return (float) config.getDouble(path, def); @@ -210,8 +243,10 @@ public float getFloatOrDefault(@NotNull String path, float def) { /** * Checks whether a value exists at the given path. * - * @param path configuration path + * @param path configuration path, must not be {@code null} * @return {@code true} if present, otherwise {@code false} + * + * @since 1.1.0 */ public boolean has(@NotNull String path) { return config.contains(path); 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'