// 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;
}
}