Instances represent a point-in-time view of a configuration and are intended - * to be treated as read-only. New instances are created during reload operations - * and atomically swapped by the managing component.
+ *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.
+ * + *This class is immutable. All fields are {@code final} and no mutator + * methods are provided. This ensures thread-safe access without requiring + * synchronization.
+ * + *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.
* * @apiNote - * Consumers should avoid holding long-lived references if they require access to - * the most up-to-date configuration. Instead, they should retrieve the current - * instance from the managing component when needed. + * To obtain the latest configuration, retrieve a new instance from the managing + * component instead of reusing previously obtained references. * * @implSpec - * This class is immutable. All fields are {@code final}, and no mutator methods - * are provided. Thread-safety is achieved through immutability and atomic - * replacement at a higher level. + * Thread-safety is achieved through immutability and atomic replacement of + * instances at a higher level (for example, by the configuration manager). * * @implNote - * Atomic replacement of instances ensures that readers never observe partially - * updated configuration state and do not require synchronization. + * 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 */ @@ -40,72 +46,53 @@ public final class YamlConfig { /** * The underlying configuration file on disk. - * - * @since 1.0.0 - */ - private final File file; - - /** - * Metadata describing the underlying file. - * - * @since 1.0.0 */ - private final FileProperties properties; + private final @NotNull File file; /** * Parsed configuration snapshot. - * - * @since 1.0.0 */ - private final FileConfiguration config; + private final @NotNull FileConfiguration config; /** - * Constructs a new immutable {@code YamlConfig} instance. + * Constructs a new {@code YamlConfig} instance. * - * @param file the configuration file, must not be {@code null} - * @param config the parsed configuration snapshot, must not be {@code null} + *The provided {@link FileConfiguration} is assumed to represent a fully + * parsed and valid snapshot of the given file.
* - * @since 1.0.0 + * @param file the configuration file, must not be {@code null} + * @param config the parsed configuration snapshot, must not be {@code null} */ public YamlConfig( @NotNull File file, - @NotNull FileConfiguration config) { + @NotNull FileConfiguration config + ) { this.file = file; - this.properties = new FileProperties(file); this.config = config; } /** * Returns the underlying configuration file. * - * @return the configuration file, never {@code null} + *This refers to the physical file on disk from which this snapshot + * was created.
* - * @since 1.0.0 + * @return the configuration file, never {@code null} */ public @NotNull File file() { return file; } - /** - * Returns metadata associated with this configuration file. - * - * @return file properties, never {@code null} - * - * @since 1.0.0 - */ - public @NotNull FileProperties properties() { - return properties; - } - /** * Returns the parsed configuration snapshot. * - *The returned instance represents a stable view of the configuration - * at the time this {@code YamlConfig} was created.
+ *The returned {@link FileConfiguration} represents a stable, immutable + * view of the configuration at the time this {@code YamlConfig} instance + * was created.
* - * @return the configuration snapshot, never {@code null} + *This object should be treated as read-only.
* - * @since 1.0.0 + * @return the configuration snapshot, never {@code null} */ public @NotNull FileConfiguration config() { return config; diff --git a/src/main/java/dev/spexx/configurationAPI/difference/ConfigChangeSummary.java b/src/main/java/dev/spexx/configurationAPI/difference/ConfigChangeSummary.java deleted file mode 100644 index b1e0e00..0000000 --- a/src/main/java/dev/spexx/configurationAPI/difference/ConfigChangeSummary.java +++ /dev/null @@ -1,102 +0,0 @@ -package dev.spexx.configurationAPI.difference; - -import org.jetbrains.annotations.NotNull; - -/** - * Represents a summary of changes detected between two versions of a configuration file. - * - *This class aggregates high-level change metrics, including the number of - * modified, added, and removed lines.
- * - * @param changedLines the number of lines that were modified - * @param addedLines the number of lines that were added - * @param removedLines the number of lines that were removed - * - * @apiNote - * Instances are immutable and safe for concurrent use. - * - * @implSpec - * All values are computed during diff analysis and remain constant for the lifetime - * of the instance. - * - * @implNote - * This class serves as a higher-level abstraction over raw diff data and is intended - * to simplify consumer logic and improve API clarity. - * - * @since 1.0.2 - */ -public record ConfigChangeSummary(int changedLines, int addedLines, int removedLines) { - - /** - * Returns the number of modified lines. - * - * @return number of changed lines - * - * @since 1.0.2 - */ - public int changedLines() { - return changedLines; - } - - /** - * Returns the number of added lines. - * - * @return number of added lines - * - * @since 1.0.2 - */ - public int addedLines() { - return addedLines; - } - - /** - * Returns the number of removed lines. - * - * @return number of removed lines - * - * @since 1.0.2 - */ - public int removedLines() { - return removedLines; - } - - /** - * Returns the total number of affected lines. - * - *This value is the sum of changed, added, and removed lines.
- * - * @return total number of changes - * - * @since 1.0.2 - */ - public int getTotalChanges() { - return changedLines + addedLines + removedLines; - } - - /** - * Indicates whether any changes were detected. - * - * @return {@code true} if changes exist, {@code false} otherwise - * - * @since 1.0.2 - */ - public boolean hasChanges() { - return getTotalChanges() > 0; - } - - /** - * Returns a string representation of this summary. - * - * @return string representation of change summary - * - * @since 1.0.2 - */ - @Override - public @NotNull String toString() { - return "ConfigChangeSummary{" + - "changed=" + changedLines + - ", added=" + addedLines + - ", removed=" + removedLines + - '}'; - } -} \ No newline at end of file diff --git a/src/main/java/dev/spexx/configurationAPI/difference/ConfigLineDifference.java b/src/main/java/dev/spexx/configurationAPI/difference/ConfigLineDifference.java deleted file mode 100644 index 1a9fa80..0000000 --- a/src/main/java/dev/spexx/configurationAPI/difference/ConfigLineDifference.java +++ /dev/null @@ -1,177 +0,0 @@ -package dev.spexx.configurationAPI.difference; - -import org.jetbrains.annotations.NotNull; - -/** - * Represents a single line-level difference between two versions of a file. - * - *This class encapsulates the following components:
- *Instances of this class are immutable and represent a snapshot of a - * detected difference at a specific point in time.
- * - * @apiNote - * Instances are safe for concurrent use and may be freely shared across threads. - * This class is intended for diagnostic, logging, and event propagation purposes. - * - * @implSpec - * The {@code charDelta} value is calculated as: - * {@code newLine.length() - oldLine.length()}. - * A positive value indicates an increase in length, while a negative value - * indicates a reduction. - * - * @implNote - * This class performs no deep or semantic diffing. It provides a lightweight, - * line-based comparison model suitable for high-frequency change detection - * scenarios without incurring significant computational overhead. - * - * @since 1.0.0 - */ -public final class ConfigLineDifference { - - /** - * The 1-based line number where the change occurred. - * - * @since 1.0.0 - */ - private final int lineNumber; - - /** - * The original content of the line prior to modification. - * - * @since 1.0.0 - */ - private final String oldLine; - - /** - * The updated content of the line after modification. - * - * @since 1.0.0 - */ - private final String newLine; - - /** - * The difference in character length between {@code newLine} and {@code oldLine}. - * - * @since 1.0.0 - */ - private final int charDelta; - - /** - * Constructs a new {@code ConfigLineDifference} instance. - * - * @param lineNumber the 1-based line number where the change occurred - * @param oldLine the original line content, must not be {@code null} - * @param newLine the updated line content, must not be {@code null} - * - * @since 1.0.0 - */ - public ConfigLineDifference(int lineNumber, - @NotNull String oldLine, - @NotNull String newLine) { - this.lineNumber = lineNumber; - this.oldLine = oldLine; - this.newLine = newLine; - this.charDelta = newLine.length() - oldLine.length(); - } - - /** - * Determines whether the difference between the old and new line - * consists solely of whitespace changes. - * - *This method ignores all whitespace characters (spaces, tabs, - * and other Unicode whitespace) and compares the normalized content - * of both lines.
- * - *Examples:
- *A positive value indicates the new line is longer, while a negative - * value indicates it is shorter.
- * - * @return the character length difference - * - * @since 1.0.0 - */ - public int getCharDelta() { - return charDelta; - } -} \ 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 d13fb7d..cd5b84e 100644 --- a/src/main/java/dev/spexx/configurationAPI/events/ConfigReloadedEvent.java +++ b/src/main/java/dev/spexx/configurationAPI/events/ConfigReloadedEvent.java @@ -1,181 +1,164 @@ package dev.spexx.configurationAPI.events; import dev.spexx.configurationAPI.config.YamlConfig; -import dev.spexx.configurationAPI.difference.ConfigChangeSummary; -import dev.spexx.configurationAPI.difference.ConfigLineDifference; import org.bukkit.event.Event; import org.bukkit.event.HandlerList; import org.jetbrains.annotations.NotNull; -import java.util.List; - /** * Event fired when a configuration file has been reloaded. * - *This event is dispatched after a successful atomic replacement of the - * underlying {@link YamlConfig} instance.
+ *This event is dispatched after a successful configuration reload + * triggered by a file system change detected by the configuration watcher.
* - *Provides both a high-level summary of changes via - * {@link ConfigChangeSummary} and detailed per-line differences via - * {@link ConfigLineDifference}.
+ *The event provides both the previous and updated {@link YamlConfig} + * instances, along with checksum information and the total time required + * to perform the reload operation.
* - * @apiNote - * This event is always fired synchronously on the main server thread. + *This event is always fired synchronously on the main server thread. + * Consumers may safely interact with the Bukkit API within event handlers.
* - * @implSpec - * Instances are immutable. All state is captured at creation time and remains - * constant for the lifetime of the event. + *All fields are immutable and represent a complete snapshot of the reload + * operation at the time the event was created.
* - * @implNote - * Diff and summary data are precomputed before event dispatch and are safe for - * concurrent read access without additional synchronization. + * @apiNote + * Consumers should treat {@link YamlConfig} instances as read-only snapshots. + * To obtain the most recent configuration state, query the managing component + * rather than storing references long-term. * - * @since 1.0.0 + * @since 1.0.5 */ public final class ConfigReloadedEvent extends Event { + /** + * Static handler list required by the Bukkit event system. + */ private static final HandlerList HANDLERS = new HandlerList(); /** - * The updated configuration snapshot. - * - * @since 1.0.0 + * Previous configuration snapshot before reload. */ - private final YamlConfig config; + private final @NotNull YamlConfig oldConfig; /** - * SHA-256 checksum of the previous file state. - * - * @since 1.0.0 + * Updated configuration snapshot after reload. */ - private final String oldChecksum; + private final @NotNull YamlConfig newConfig; /** - * SHA-256 checksum of the new file state. - * - * @since 1.0.0 + * SHA-256 checksum of the configuration file before reload. */ - private final String newChecksum; + private final @NotNull String oldChecksum; /** - * Aggregated summary of changes between file versions. - * - * @since 1.0.2 + * SHA-256 checksum of the configuration file after reload. */ - private final ConfigChangeSummary summary; + private final @NotNull String newChecksum; /** - * Immutable list of line-level differences. - * - * @since 1.0.1 + * Time required to reload the configuration, measured in milliseconds. */ - private final ListThis constructor is typically invoked internally by the configuration + * watcher after detecting and applying a file change.
* - * @since 1.0.0 + * @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 */ public ConfigReloadedEvent( - @NotNull YamlConfig config, + @NotNull YamlConfig oldConfig, + @NotNull YamlConfig newConfig, @NotNull String oldChecksum, @NotNull String newChecksum, - @NotNull ConfigChangeSummary summary, - @NotNull ListThis represents the last known valid state before the file change + * was applied.
* - * @since 1.0.0 + * @return previous configuration snapshot, never {@code null} */ - public @NotNull YamlConfig getConfig() { - return config; + public @NotNull YamlConfig getOldConfig() { + return oldConfig; } /** - * Returns the checksum of the previous file state. + * Returns the configuration snapshot after reload. + * + *This represents the newly loaded state reflecting the current + * contents of the configuration file.
* - * @return previous checksum, never {@code null} + * @return updated configuration snapshot, never {@code null} + */ + public @NotNull YamlConfig getNewConfig() { + return newConfig; + } + + /** + * Returns the checksum of the configuration file before reload. * - * @since 1.0.0 + * @return previous SHA-256 checksum, never {@code null} */ public @NotNull String getOldChecksum() { return oldChecksum; } /** - * Returns the checksum of the new file state. + * Returns the checksum of the configuration file after reload. * - * @return new checksum, never {@code null} - * - * @since 1.0.0 + * @return new SHA-256 checksum, never {@code null} */ public @NotNull String getNewChecksum() { return newChecksum; } /** - * Returns the aggregated summary of detected changes. - * - *This provides a high-level overview of modifications, additions, - * and removals.
+ * Returns the time required to reload the configuration. * - * @return change summary, never {@code null} + *The value is measured in milliseconds and represents the duration + * between the start of the reload process and completion of the new + * configuration snapshot.
* - * @since 1.0.2 + * @return reload duration in milliseconds */ - public @NotNull ConfigChangeSummary getSummary() { - return summary; + public int getReloadTimeMs() { + return reloadTimeMs; } /** - * Returns the list of detected line-level differences. - * - *The returned list is immutable and reflects the exact state at the - * time the event was created.
+ * Returns the handler list for this event instance. * - * @return immutable list of diffs, never {@code null} - * - * @apiNote - * The returned list is safe for concurrent iteration without additional synchronization. - * - * @implSpec - * Backed by {@link List#copyOf(java.util.Collection)} to guarantee immutability. - * - * @since 1.0.1 + * @return handler list, never {@code null} */ - public @NotNull ListThis class is responsible for loading, caching, reloading, and unloading - * configuration files. Configurations are indexed by their normalized - * {@link Path}, ensuring consistent identity across different environments.
+ *This class is responsible for: + *
The manager provides thread-safe access to configuration snapshots and - * coordinates integration with {@link YamlConfigWatcher} for automatic reloads.
+ *The {@link GlobalConfigWatcher} is the single source of truth for all + * configuration state. This class does not maintain its own cache.
* - * @apiNote - * This class is designed for concurrent access. Consumers may safely retrieve - * configuration instances from multiple threads without additional synchronization. - * Returned {@link YamlConfig} instances are immutable snapshots and may become - * outdated after reload operations. - * - * @implSpec - * Configuration instances are replaced atomically using - * {@link ConcurrentHashMap#computeIfPresent(Object, java.util.function.BiFunction)}. - * This guarantees that readers either observe the old or the new configuration, - * but never a partially updated state. + *All calls to {@link #get(File)} delegate directly to the watcher, + * ensuring that consumers always receive the most up-to-date configuration.
* - *Each loaded configuration is associated with a {@link YamlConfigWatcher} - * that monitors file system changes and triggers reload operations via a callback.
+ *This class is thread-safe. The underlying watcher uses concurrent + * data structures and atomic replacement of {@link YamlConfig} instances.
* - * @implNote - * Watchers are created per configuration file and managed alongside the cached - * configuration instances. When a configuration is unloaded, its associated - * watcher is stopped and removed to prevent resource leaks. + * @apiNote + * {@link YamlConfig} instances are immutable snapshots. Consumers should + * retrieve them on demand rather than caching references long-term. * - * @since 1.0.0 + * @since 1.0.5 */ public final class ConfigManager { - /** - * Owning plugin instance used for scheduling and logging. - * - * @since 1.0.0 - */ private final @NotNull JavaPlugin plugin; + private final @NotNull GlobalConfigWatcher watcher; /** - * Cache of loaded configurations indexed by normalized path. - * - * @since 1.0.0 - */ - private final ConcurrentHashMapThis initializes and starts the {@link GlobalConfigWatcher}.
* - * @param plugin the owning plugin instance, must not be {@code null} + * @param plugin owning plugin instance, must not be {@code null} * - * @since 1.0.0 + * @throws RuntimeException if watcher initialization fails */ public ConfigManager(@NotNull JavaPlugin plugin) { - this.plugin = 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); + } } /** - * Returns a loaded configuration for the given file. + * Returns the latest configuration snapshot for the given file. * - * @param file the configuration file, must not be {@code null} - * @return the corresponding configuration snapshot, never {@code null} - * @throws IllegalStateException if the configuration is not loaded + *The configuration must have been previously loaded using + * {@link #getOrLoad(File)} or {@link #getOrLoadResource(String)}.
+ * + * @param file configuration file, must not be {@code null} + * @return latest {@link YamlConfig} snapshot, never {@code null} * - * @since 1.0.0 + * @throws IllegalStateException if the configuration is not loaded */ public @NotNull YamlConfig get(@NotNull File file) { Path path = normalize(file.toPath()); - YamlConfig config = configs.get(path); + YamlConfig config = watcher.get(path); if (config == null) { throw new IllegalStateException("Config not loaded: " + path); } @@ -98,47 +88,36 @@ public ConfigManager(@NotNull JavaPlugin plugin) { } /** - * Returns an existing configuration or loads it if not already present. + * Returns an existing configuration or loads it if not already tracked. * - * @param file the configuration file, must not be {@code null} - * @return the existing or newly loaded configuration snapshot, never {@code null} + *If the configuration is not already registered, it is: + *
The resource is resolved from the plugin JAR and written to the - * data folder using {@link JavaPlugin#saveResource(String, boolean)}. - * If the file already exists, it is not overwritten.
- * - *After ensuring the file exists on disk, it is loaded and managed - * as a regular configuration via {@link #getOrLoad(File)}.
+ * Loads a configuration file from plugin resources if necessary. * - * @param name the resource name (for example, {@code "config.yml"}), must not be {@code null} - * @return the loaded configuration snapshot, never {@code null} + *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.
* - * @apiNote - * This method is intended for default configuration files packaged with - * the plugin. The resource must exist within the plugin JAR under the - * same path. - * - * @implSpec - * The resource is copied only if the target file does not already exist. - * Existing files are preserved to avoid overwriting user modifications. - * - * @implNote - * This method bridges bundled resources and the configuration management - * system by ensuring that all managed configurations operate on real - * files within the plugin data folder. - * - * @since 1.0.0 + * @param name resource name (for example {@code "config.yml"}), must not be {@code null} + * @return latest {@link YamlConfig} snapshot */ public @NotNull YamlConfig getOrLoadResource(@NotNull String name) { plugin.saveResource(name, false); @@ -146,117 +125,82 @@ public ConfigManager(@NotNull JavaPlugin plugin) { } /** - * Reloads the specified configuration file. - * - *A new {@link YamlConfig} instance is created and atomically replaces - * the previous instance in the cache.
- * - * @param file the configuration file, must not be {@code null} - * - * @since 1.0.0 - */ - public void reload(@NotNull File file) { - Path path = normalize(file.toPath()); - - configs.computeIfPresent(path, (p, oldConfig) -> { - - YamlConfig newConfig = new YamlConfig( - file, - YamlConfiguration.loadConfiguration(file) - ); - - YamlConfigWatcher watcher = watchers.get(path); - - if (watcher != null) { - watcher.updateConfig(newConfig); - watcher.updateChecksum(); - } - - return newConfig; - }); - } - - /** - * Unloads the specified configuration and stops its associated watcher. + * Stops tracking the specified configuration file. * - * @param file the configuration file, must not be {@code null} + *This removes the configuration from the watcher. If the file still + * exists, it may be reloaded again if explicitly registered.
* - * @since 1.0.0 + * @param file configuration file, must not be {@code null} */ public void unload(@NotNull File file) { Path path = normalize(file.toPath()); - - configs.remove(path); - - YamlConfigWatcher watcher = watchers.remove(path); - if (watcher != null) { - watcher.stop(); - } + watcher.unregister(path); } /** - * Returns all currently loaded configuration snapshots. + * Returns all currently tracked configuration snapshots. * * @return collection of configurations, never {@code null} - * - * @since 1.0.0 */ public @NotNull CollectionIf the file does not exist, parent directories are created as needed.
+ * @param file configuration file, must not be {@code null} + * @return loaded {@link YamlConfig} snapshot * - * @param file the configuration file, must not be {@code null} - * @return the loaded configuration snapshot, never {@code null} - * - * @since 1.0.0 + * @throws RuntimeException if watcher registration fails */ private @NotNull YamlConfig load(@NotNull File file) { - Path path = normalize(file.toPath()); + ensureFileExists(file); - if (!file.exists()) { - File parent = file.getParentFile(); + YamlConfig config = new YamlConfig( + file, + YamlConfiguration.loadConfiguration(file) + ); - if (parent != null && !parent.exists()) { - if (!parent.mkdirs() && !parent.exists()) { - throw new IllegalStateException("Failed to create directories: " + parent); - } - } + try { + watcher.register(config); + } catch (IOException e) { + throw new RuntimeException("Failed to register watcher for " + file, e); } - YamlConfig config = new YamlConfig(file, YamlConfiguration.loadConfiguration(file)); + return config; + } - YamlConfigWatcher watcher = new YamlConfigWatcher( - plugin, - config, - () -> reload(file) - ); + /** + * Ensures that the parent directories of the file exist. + * + * @param file file to verify, must not be {@code null} + * + * @throws IllegalStateException if directory creation fails + */ + private void ensureFileExists(@NotNull File file) { - try { - watcher.start(); - } catch (Exception e) { - throw new RuntimeException("Failed to start watcher", e); + if (file.exists()) { + return; } - watchers.put(path, watcher); + File parent = file.getParentFile(); - return config; + if (parent != null && !parent.exists()) { + if (!parent.mkdirs() && !parent.exists()) { + throw new IllegalStateException("Failed to create directories: " + parent); + } + } } /** * Normalizes a path to ensure consistent identity. * - * @param path the raw path, must not be {@code null} - * @return normalized absolute path, never {@code null} - * - * @since 1.0.0 + * @param path raw path, must not be {@code null} + * @return normalized absolute path */ - private @NotNull Path normalize(@NotNull Path path) { + private static @NotNull Path normalize(@NotNull Path path) { return path.toAbsolutePath().normalize(); } } \ No newline at end of file diff --git a/src/main/java/dev/spexx/configurationAPI/properties/FileProperties.java b/src/main/java/dev/spexx/configurationAPI/properties/FileProperties.java deleted file mode 100644 index ed0eb7c..0000000 --- a/src/main/java/dev/spexx/configurationAPI/properties/FileProperties.java +++ /dev/null @@ -1,281 +0,0 @@ -package dev.spexx.configurationAPI.properties; - -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.io.File; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.attribute.BasicFileAttributes; - -/** - * Represents metadata and derived properties of a file. - * - *This class provides a read-only abstraction over {@link File} and exposes - * commonly used attributes such as name, size, timestamps, permissions, and - * normalized path information.
- * - *Instances are lightweight and may lazily resolve additional file system - * attributes when required.
- * - * @apiNote - * This class does not actively monitor file system changes. Returned values - * reflect the state of the file at the time of method invocation and may become - * stale if the file is modified externally. - * - * @implSpec - * The underlying {@link Path} is normalized using - * {@link Path#toAbsolutePath()} and {@link Path#normalize()} to ensure consistent - * identity across different operating systems and path representations. - * - * @implNote - * File attribute access via {@link BasicFileAttributes} is performed lazily and - * cached after the first successful read. If attribute retrieval fails, methods - * gracefully fall back to alternative mechanisms or return sentinel values. - * - * @since 1.0.0 - */ -public final class FileProperties { - - /** - * The underlying file. - * - * @since 1.0.0 - */ - private final @NotNull File file; - - /** - * Normalized absolute path of the file. - * - * @since 1.0.0 - */ - private final @NotNull Path path; - - /** - * Lazily initialized file attributes. - * - * @since 1.0.0 - */ - private BasicFileAttributes attributes; - - /** - * Constructs a new {@code FileProperties} instance for the given file. - * - * @param file the file to inspect, must not be {@code null} - * - * @since 1.0.0 - */ - public FileProperties(@NotNull File file) { - this.file = file; - this.path = file.toPath().toAbsolutePath().normalize(); - } - - /** - * Returns the file name including its extension. - * - * @return file name with extension, never {@code null} - * - * @since 1.0.0 - */ - public @NotNull String getName() { - return file.getName(); - } - - /** - * Returns the file name without its extension. - * - * @return base file name, never {@code null} - * - * @since 1.0.0 - */ - public @NotNull String getBaseName() { - String name = file.getName(); - int dot = name.lastIndexOf('.'); - return (dot == -1) ? name : name.substring(0, dot); - } - - /** - * Returns the file extension without the leading dot. - * - * @return file extension, or an empty string if none exists - * - * @since 1.0.0 - */ - public @NotNull String getExtension() { - String name = file.getName(); - int dot = name.lastIndexOf('.'); - return (dot == -1) ? "" : name.substring(dot + 1); - } - - /** - * Returns the absolute file path as a string. - * - * @return absolute path, never {@code null} - * - * @since 1.0.0 - */ - public @NotNull String getAbsolutePath() { - return file.getAbsolutePath(); - } - - /** - * Returns the parent directory of the file. - * - * @return parent directory, never {@code null} - * @throws IllegalStateException if the file has no parent directory - * - * @since 1.0.0 - */ - public @NotNull File getParent() { - File parent = file.getParentFile(); - if (parent == null) { - throw new IllegalStateException("File has no parent: " + file); - } - return parent; - } - - /** - * Returns the file size in bytes. - * - * @return file size in bytes - * - * @since 1.0.0 - */ - public long getFileSize() { - try { - return Files.size(path); - } catch (Exception e) { - return file.length(); - } - } - - /** - * Returns whether the file is empty. - * - * @return {@code true} if file size is zero, {@code false} otherwise - * - * @since 1.0.0 - */ - public boolean isEmpty() { - return getFileSize() == 0; - } - - /** - * Returns the last modified time of the file. - * - * @return last modified timestamp in milliseconds since epoch - * - * @since 1.0.0 - */ - public long getLastModified() { - return file.lastModified(); - } - - /** - * Returns whether the file exists. - * - * @return {@code true} if the file exists, {@code false} otherwise - * - * @since 1.0.0 - */ - public boolean exists() { - return file.exists(); - } - - /** - * Returns whether the file is hidden. - * - * @return {@code true} if the file is hidden, {@code false} otherwise - * - * @since 1.0.0 - */ - public boolean isHidden() { - return file.isHidden(); - } - - /** - * Returns whether the file is readable. - * - * @return {@code true} if readable, {@code false} otherwise - * - * @since 1.0.0 - */ - public boolean canRead() { - return file.canRead(); - } - - /** - * Returns whether the file is writable. - * - * @return {@code true} if writable, {@code false} otherwise - * - * @since 1.0.0 - */ - public boolean canWrite() { - return file.canWrite(); - } - - /** - * Returns whether the file is executable. - * - * @return {@code true} if executable, {@code false} otherwise - * - * @since 1.0.0 - */ - public boolean canExecute() { - return file.canExecute(); - } - - /** - * Returns the file creation time. - * - * @return creation time in milliseconds since epoch, or {@code -1} if unavailable - * - * @since 1.0.0 - */ - public long getCreationTime() { - BasicFileAttributes attrs = attributes(); - return (attrs != null) ? attrs.creationTime().toMillis() : -1; - } - - /** - * Returns the last access time of the file. - * - * @return last access time in milliseconds since epoch, or {@code -1} if unavailable - * - * @since 1.0.0 - */ - public long getLastAccessTime() { - BasicFileAttributes attrs = attributes(); - return (attrs != null) ? attrs.lastAccessTime().toMillis() : -1; - } - - /** - * Returns the normalized {@link Path} representation of the file. - * - * @return normalized path, never {@code null} - * - * @since 1.0.0 - */ - public @NotNull Path toPath() { - return path; - } - - /** - * Returns cached file attributes, loading them if necessary. - * - * @return file attributes, or {@code null} if unavailable - * - * @since 1.0.0 - */ - private @Nullable BasicFileAttributes attributes() { - if (attributes == null) { - try { - attributes = Files.readAttributes(path, BasicFileAttributes.class); - } catch (Exception ignored) { - return null; - } - } - return attributes; - } -} \ No newline at end of file diff --git a/src/main/java/dev/spexx/configurationAPI/util/FileChecksum.java b/src/main/java/dev/spexx/configurationAPI/util/FileChecksum.java index f90fc40..5d47d42 100644 --- a/src/main/java/dev/spexx/configurationAPI/util/FileChecksum.java +++ b/src/main/java/dev/spexx/configurationAPI/util/FileChecksum.java @@ -9,23 +9,27 @@ /** * Utility class for computing cryptographic checksums of files. * - *This class provides methods for generating hash values used to detect - * changes in file contents.
+ *This class provides methods for generating hash values that can be used + * to detect changes in file contents. The primary use case is configuration + * change detection where file content equality must be verified reliably.
+ * + *Checksum computation requires reading the entire file. This operation + * may be expensive for large files and should not be performed excessively + * in performance-critical code paths without appropriate caching or throttling.
* * @apiNote - * Checksum computation performs a full file read operation. It should not be - * used excessively on large files or in performance-critical paths without - * appropriate caching or throttling. + * This class is stateless and thread-safe. All methods are static and do not + * maintain internal state. * * @implSpec - * The {@link #sha256(File)} method computes a SHA-256 hash of the file contents - * using a buffered stream. The returned value is encoded as a lowercase - * hexadecimal string. + * The {@link #sha256(File)} method computes a SHA-256 hash using a buffered + * stream and returns the result as a lowercase hexadecimal string. * * @implNote - * A fixed buffer size of 8192 bytes is used for streaming file data. The method - * relies on {@link MessageDigest} and standard Java I/O without additional - * optimizations such as memory-mapped files. + * 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. * * @since 1.0.0 */ @@ -33,24 +37,25 @@ public final class FileChecksum { /** * Private constructor to prevent instantiation. - * - * @since 1.0.0 */ - private FileChecksum() {} + private FileChecksum() { + } /** - * Computes the SHA-256 checksum of the given file. + * Computes the SHA-256 checksum of the specified file. + * + *The file is read in full using a buffered input stream and processed + * through a {@link MessageDigest} instance configured for SHA-256.
* - *The entire file is read and processed through a - * {@link MessageDigest} instance.
+ *The resulting hash is returned as a lowercase hexadecimal string.
* * @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 RuntimeException if an error occurs while reading the file or computing the hash * - * @since 1.0.0 + * @throws RuntimeException if an error occurs while reading the file or + * computing the checksum */ - public static @NotNull String sha256(File file) { + public static @NotNull String sha256(@NotNull File file) { try (FileInputStream fis = new FileInputStream(file)) { MessageDigest digest = MessageDigest.getInstance("SHA-256"); @@ -64,7 +69,7 @@ private FileChecksum() {} byte[] hash = digest.digest(); - StringBuilder hex = new StringBuilder(); + StringBuilder hex = new StringBuilder(hash.length * 2); for (byte b : hash) { hex.append(String.format("%02x", b)); } diff --git a/src/main/java/dev/spexx/configurationAPI/watcher/GlobalConfigWatcher.java b/src/main/java/dev/spexx/configurationAPI/watcher/GlobalConfigWatcher.java new file mode 100644 index 0000000..00b1e59 --- /dev/null +++ b/src/main/java/dev/spexx/configurationAPI/watcher/GlobalConfigWatcher.java @@ -0,0 +1,313 @@ +package dev.spexx.configurationAPI.watcher; + +import dev.spexx.configurationAPI.config.YamlConfig; +import dev.spexx.configurationAPI.events.ConfigReloadedEvent; +import dev.spexx.configurationAPI.util.FileChecksum; +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; +import java.nio.file.*; +import java.util.Collection; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; + +import static java.nio.file.StandardWatchEventKinds.*; + +/** + * Global watcher responsible for monitoring multiple configuration files + * using a single {@link WatchService} instance. + * + *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.
+ * + *File system events are processed on a dedicated daemon thread. + * Bukkit events are dispatched synchronously on the main server thread.
+ * + *Configuration instances are replaced atomically. Consumers will always + * observe either the previous or the updated snapshot, never a partially + * updated state.
+ * + *Changes are detected using SHA-256 checksums. Identical file contents + * will not trigger reloads even if file system events occur.
+ * + * @since 1.0.5 + */ +public final class GlobalConfigWatcher { + + /** + * Minimum time between reload attempts for the same file, in milliseconds. + */ + private static final long DEBOUNCE_MS = 300; + + private final @NotNull JavaPlugin plugin; + private final @NotNull WatchService watchService; + + /** + * Current configuration snapshots indexed by normalized path. + */ + private final MapThe parent directory of the file is registered with the + * {@link WatchService} if not already tracked.
+ * + * @param config configuration snapshot to register, must not be {@code null} + * @throws IOException if directory registration fails + */ + public void register(@NotNull YamlConfig config) throws IOException { + + Path path = normalize(config.file().toPath()); + Path dir = path.getParent(); + + configs.put(path, config); + checksums.put(path, FileChecksum.sha256(config.file())); + + if (watchedDirs.add(dir)) { + dir.register(watchService, ENTRY_CREATE, ENTRY_MODIFY, ENTRY_DELETE); + } + } + + /** + * Unregisters a configuration file from monitoring. + * + *This removes all associated state for the specified path.
+ * + * @param path configuration path, must not be {@code null} + */ + public void unregister(@NotNull Path path) { + Path normalized = normalize(path); + + configs.remove(normalized); + checksums.remove(normalized); + lastReload.remove(normalized); + } + + /** + * Starts the watcher thread. + * + *This method is idempotent. Calling it multiple times has no effect.
+ */ + public void start() { + if (running.get()) { + return; + } + + running.set(true); + + thread = new Thread(this::run, "GlobalConfigWatcher"); + thread.setDaemon(true); + thread.start(); + } + + /** + * Stops the watcher and releases system resources. + * + *The watcher thread is interrupted and the underlying + * {@link WatchService} is closed.
+ */ + public void stop() { + running.set(false); + + try { + watchService.close(); + } catch (IOException ignored) { + } + + if (thread != null) { + thread.interrupt(); + } + } + + /** + * 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. + * + * @param path affected file path, must not be {@code null} + * @param kind event type, must not be {@code null} + */ + private void handleFileEvent(@NotNull Path path, WatchEvent.@NotNull Kind> kind) { + + File file = path.toFile(); + + if (!configs.containsKey(path)) { + return; + } + + // Handle deletion + if (kind == ENTRY_DELETE) { + if (configs.remove(path) != null) { + checksums.remove(path); + lastReload.remove(path); + plugin.getLogger().info("[ConfigWatcher] Removed: " + path.getFileName()); + } + return; + } + + // Ignore if file does not exist + if (!file.exists()) { + return; + } + + try { + String newChecksum = FileChecksum.sha256(file); + @Nullable String oldChecksum = checksums.get(path); + + if (oldChecksum != null && oldChecksum.equals(newChecksum)) { + return; + } + + long start = System.nanoTime(); + + @Nullable YamlConfig oldConfig = configs.get(path); + + YamlConfig newConfig = new YamlConfig( + file, + YamlConfiguration.loadConfiguration(file) + ); + + configs.put(path, newConfig); + checksums.put(path, newChecksum); + + int timeMs = (int) ((System.nanoTime() - start) / 1_000_000); + + 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.getLogger().info("[ConfigWatcher] Reloaded: " + file.getName()); + + } catch (Exception e) { + plugin.getLogger().warning( + "[ConfigWatcher] Failed to load: " + file.getName() + ); + } + } + + /** + * Returns the current configuration snapshot for the specified path. + * + * @param path configuration path, must not be {@code null} + * @return configuration snapshot, or {@code null} if not tracked + */ + public @Nullable YamlConfig get(@NotNull Path path) { + return configs.get(normalize(path)); + } + + /** + * Returns all currently tracked configuration snapshots. + * + * @return collection of configurations, never {@code null} + */ + public @NotNull CollectionThis watcher monitors the parent directory of a target configuration file - * using {@link WatchService} and filters events specific to the file name.
- * - *It performs checksum validation and line-level diff analysis to detect changes. - * Reload operations are delegated to an external callback and dispatched on the - * main server thread.
- * - *