// Copyright (c) 2025-2026 GeWuYou // SPDX-License-Identifier: Apache-2.0 using System; using System.Collections.Generic; using System.IO; using System.Threading; using GFramework.Core.Abstractions.Logging; namespace GFramework.Godot.Logging; /// /// Owns discovery, loading, hot reload, and publication of the current Godot logger settings snapshot. /// /// /// Construction follows a fixed lifecycle: discover the configuration path, perform an initial strict load, then /// subscribe a when a concrete file exists. is /// published through so cached loggers can read a last-good immutable snapshot without /// locking. Hot reload keeps the previous settings when a transient parse or file-system error occurs. /// internal sealed class GodotLogConfigurationSource : IDisposable { private readonly Action? _configure; private readonly FileSystemWatcher? _watcher; private GodotLoggerSettings _currentSettings = GodotLoggerSettings.Default; /// /// Initializes the configuration source and starts watching the discovered file when one is available. /// /// Optional imperative option overrides applied after file settings are loaded. /// Thrown during initial loading when the configuration file cannot be read. /// Thrown during initial loading when the configuration file is locked. /// /// Initial loading uses retry/backoff and propagates the final error because startup configuration failures should /// be visible. Watcher callbacks use the hot-reload path and preserve the previous snapshot on failure. /// public GodotLogConfigurationSource(Action? configure) { _configure = configure; // Discovery is done before the first strict reload so startup reports invalid files immediately. ConfigurationPath = GodotLoggerSettingsLoader.DiscoverConfigurationPath(); Reload(throwOnError: true); _watcher = CreateWatcher(ConfigurationPath); } /// /// Gets the discovered configuration file path, or null when no supported location contains a file. /// public string? ConfigurationPath { get; } /// /// Gets the last successfully loaded settings snapshot. /// /// /// The snapshot is read through Volatile.Read so logger instances running on other /// threads observe settings published by reload callbacks without taking the configuration lock. /// public GodotLoggerSettings CurrentSettings => Volatile.Read(ref _currentSettings); /// /// Stops the file watcher before the source is abandoned. /// /// /// Disposal does not clear ; existing loggers can continue using the last published /// snapshot after watcher notifications have been stopped. /// public void Dispose() { _watcher?.Dispose(); } /// /// Creates the watcher that drives hot reload for the discovered configuration file. /// /// The configuration file to watch. /// A configured watcher, or null when no stable directory and file name can be resolved. private FileSystemWatcher? CreateWatcher(string? configurationPath) { if (string.IsNullOrWhiteSpace(configurationPath)) { return null; } var directory = Path.GetDirectoryName(configurationPath); var fileName = Path.GetFileName(configurationPath); if (string.IsNullOrWhiteSpace(directory) || string.IsNullOrWhiteSpace(fileName)) { return null; } // FileSystemWatcher raises callbacks on thread-pool threads; callbacks keep reload work short and non-blocking. var watcher = new FileSystemWatcher(directory, fileName) { EnableRaisingEvents = true, NotifyFilter = NotifyFilters.CreationTime | NotifyFilters.FileName | NotifyFilters.LastWrite | NotifyFilters.Size }; watcher.Changed += OnConfigurationChanged; watcher.Created += OnConfigurationChanged; watcher.Deleted += OnConfigurationChanged; watcher.Renamed += OnConfigurationRenamed; return watcher; } private void OnConfigurationChanged(object sender, FileSystemEventArgs e) { Reload(throwOnError: false); } private void OnConfigurationRenamed(object sender, RenamedEventArgs e) { Reload(throwOnError: false); } /// /// Reloads settings and publishes them when loading succeeds. /// /// Whether load errors should escape to the caller. private void Reload(bool throwOnError) { try { var settings = throwOnError ? LoadSettingsWithRetry() : LoadSettings(); // Volatile publication gives cached loggers a coherent replacement snapshot without per-log locks. Volatile.Write(ref _currentSettings, settings); } catch when (!throwOnError) { // Ignore transient parse or file-lock failures during hot reload and keep the last good snapshot. } } /// /// Loads settings with short retry/backoff for startup races with file writers or deployment tools. /// /// The loaded settings snapshot. /// Thrown when no retry produced a usable settings snapshot. private GodotLoggerSettings LoadSettingsWithRetry() { Exception? lastError = null; for (var attempt = 0; attempt < 3; attempt++) { try { return LoadSettings(); } catch (IOException ex) { lastError = ex; } catch (UnauthorizedAccessException ex) { lastError = ex; } if (attempt < 2) { // Startup can race with a writer finishing appsettings.json; keep the retry bounded and deterministic. Thread.Sleep(50); } } throw lastError ?? new InvalidOperationException("Failed to load Godot logging configuration."); } /// /// Loads settings from disk or defaults, then applies imperative overrides. /// /// The settings snapshot to publish. private GodotLoggerSettings LoadSettings() { var settings = string.IsNullOrWhiteSpace(ConfigurationPath) || !File.Exists(ConfigurationPath) ? GodotLoggerSettings.Default : GodotLoggerSettingsLoader.LoadFromJsonFile(ConfigurationPath); if (_configure == null) { return settings; } var configuredOptions = CloneOptions(settings.Options); _configure(configuredOptions); return new GodotLoggerSettings( configuredOptions.CreateNormalizedCopy(), settings.DefaultLogLevel, CopyLoggerLevels(settings)); } /// /// Creates a mutable options copy before user overrides are applied. /// /// The options from the file or default settings. /// A normalized mutable copy. private static GodotLoggerOptions CloneOptions(GodotLoggerOptions options) { return new GodotLoggerOptions { Mode = options.Mode, DebugMinLevel = options.DebugMinLevel, ReleaseMinLevel = options.ReleaseMinLevel, DebugOutputTemplate = options.DebugOutputTemplate, ReleaseOutputTemplate = options.ReleaseOutputTemplate, Colors = options.Colors is { } colors ? new Dictionary(colors) : [] }.CreateNormalizedCopy(); } /// /// Copies category log level overrides into an ordinal dictionary. /// /// The source settings snapshot. /// A copy that preserves exact and prefix matching semantics. private static IReadOnlyDictionary CopyLoggerLevels( GodotLoggerSettings settings) { var levels = new Dictionary(StringComparer.Ordinal); foreach (var pair in settings.LoggerLevels) { levels[pair.Key] = pair.Value; } return levels; } }